Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ time = "0.3.55"
url = "2.5.8"
bytes = "1.12.1"
sha2 = "0.11.1"
getrandom = "0.4.3"
# aws-lc-rs: the rustls provider reqwest already uses.
sqlx = { version = "0.9.0", default-features = false, features = [
"macros",
Expand Down
3 changes: 3 additions & 0 deletions apps/auth-api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ PLATFORM_NETWORK=platform

# For Hosted Postgres: append `?sslmode=verify-full`.
EDB_DATABASE_URL=postgres://aapi:<EDB_AAPI_PASSWORD>@127.0.0.1:15432/examiner

COOKIE_KEY=
WEB_APP_ORIGINS=http://127.0.0.1:13003,http://localhost:13003
13 changes: 12 additions & 1 deletion apps/auth-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,21 @@ publish.workspace = true

[dependencies]
axum.workspace = true
axum-extra.workspace = true
edb.workspace = true
getrandom.workspace = true
http.workspace = true
runtime.workspace = true
serde.workspace = true
sqlx.workspace = true
thiserror.workspace = true
time.workspace = true
tokio.workspace = true
tower-http = { workspace = true, features = ["cors"] }
tracing.workspace = true
runtime.workspace = true

[dev-dependencies]
tower = { version = "0.5.3", features = ["util"] }

[lints]
workspace = true
6 changes: 4 additions & 2 deletions apps/auth-api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ COPY libs libs
COPY databases/curriculum databases/curriculum
COPY databases/examiner databases/examiner
ENV SQLX_OFFLINE=true
ARG CARGO_PROFILE=release
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target \
cargo build --locked --release -p auth-api -p edb \
&& cp target/release/auth-api target/release/edb-migrate /usr/local/bin/
cargo build --locked --profile "$CARGO_PROFILE" -p auth-api -p edb \
&& out="target/$([ "$CARGO_PROFILE" = dev ] && echo debug || echo "$CARGO_PROFILE")" \
&& cp "$out/auth-api" "$out/edb-migrate" /usr/local/bin/

FROM debian:trixie-slim
RUN apt-get update \
Expand Down
2 changes: 2 additions & 0 deletions apps/auth-api/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ services:
SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-0.2}
RUST_LOG: ${RUST_LOG:-info}
EDB_DATABASE_URL: ${EDB_DATABASE_URL:?set EDB_DATABASE_URL}
COOKIE_KEY: ${COOKIE_KEY:?set COOKIE_KEY}
WEB_APP_ORIGINS: ${WEB_APP_ORIGINS:?set WEB_APP_ORIGINS}
networks:
platform:
aliases: [auth-api]
Expand Down
112 changes: 110 additions & 2 deletions apps/auth-api/src/app.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,125 @@
use axum::{Router, routing::get};
use axum_extra::extract::cookie::Key;
use edb::Database;
use http::{Method, header::CONTENT_TYPE};
use tower_http::cors::CorsLayer;

use crate::config::AppConfig;
use crate::config::{AppConfig, ServerState};
use crate::routes;

pub async fn app(config: &AppConfig) -> Router {
let database = Database::connect(&config.edb_database_url)
.await
.expect("failed to connect to eDb");

let api = Router::new().with_state(database);
router(ServerState {
config: config.clone(),
database,
cookie_key: Key::from(config.cookie_key.as_bytes()),
})
}

pub fn router(server_state: ServerState) -> Router {
let cors = CorsLayer::new()
.allow_origin(server_state.config.web_app_origins.clone())
.allow_methods([Method::GET, Method::POST, Method::DELETE])
.allow_headers([CONTENT_TYPE])
.allow_credentials(true);

let api = Router::new()
.nest("/auth", routes::auth::router(server_state.config.mock_auth))
.layer(cors)
.with_state(server_state);

runtime::instrument(api)
// After instrumentation: probes stay out of request traces and Sentry.
.route("/healthz", get(routes::get_healthz))
}

#[cfg(test)]
mod tests {
use axum::body::Body;
use http::{
HeaderValue, Request, StatusCode,
header::{
ACCESS_CONTROL_ALLOW_CREDENTIALS, ACCESS_CONTROL_ALLOW_ORIGIN,
ACCESS_CONTROL_REQUEST_METHOD, ORIGIN,
},
};
use tower::ServiceExt;

use super::*;
use crate::testing::{WEB_APP_ORIGINS, server_state};

fn preflight(origin: &str) -> Request<Body> {
Request::options("/auth/session")
.header(ORIGIN, origin)
.header(ACCESS_CONTROL_REQUEST_METHOD, "GET")
.body(Body::empty())
.expect("valid request")
}

fn dev_login(email: &str) -> Request<Body> {
Request::post("/auth/dev/login")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(format!(
r#"{{"name":"Camper","email":"{email}"}}"#
)))
.expect("valid request")
}

#[tokio::test]
async fn allows_credentialed_requests_from_every_web_app_origin() {
for origin in WEB_APP_ORIGINS {
let response = router(server_state(false))
.oneshot(preflight(origin))
.await
.expect("infallible");

let headers = response.headers();
assert_eq!(
headers.get(ACCESS_CONTROL_ALLOW_ORIGIN),
Some(&HeaderValue::from_static(origin))
);
assert_eq!(
headers.get(ACCESS_CONTROL_ALLOW_CREDENTIALS),
Some(&HeaderValue::from_static("true"))
);
}
}

#[tokio::test]
async fn rejects_other_origins() {
let response = router(server_state(false))
.oneshot(preflight("https://evil.example"))
.await
.expect("infallible");

assert!(
response
.headers()
.get(ACCESS_CONTROL_ALLOW_ORIGIN)
.is_none()
);
}

#[tokio::test]
async fn dev_login_needs_mock_auth() {
let response = router(server_state(false))
.oneshot(dev_login("camper@example.com"))
.await
.expect("infallible");

assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn dev_login_rejects_invalid_email() {
let response = router(server_state(true))
.oneshot(dev_login("camper"))
.await
.expect("infallible");

assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
}
126 changes: 119 additions & 7 deletions apps/auth-api/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,133 @@
use std::env::var;

use axum::extract::FromRef;
use axum_extra::extract::cookie::Key;
use edb::Database;
use http::HeaderValue;

#[derive(Clone)]
pub struct ServerState {
pub config: AppConfig,
pub database: Database,
pub cookie_key: Key,
}

impl FromRef<ServerState> for Key {
fn from_ref(state: &ServerState) -> Self {
state.cookie_key.clone()
}
}

#[derive(Debug, Clone)]
pub struct AppConfig {
/// eDb connection string. For hosted Postgres ensure `?sslmode=verify-full`.
pub cookie_key: String,
pub edb_database_url: String,
pub mock_auth: bool,
pub web_app_origins: Vec<HeaderValue>,
}

fn parse_origins(value: &str) -> Result<Vec<HeaderValue>, String> {
let origins = value
.split(',')
.map(str::trim)
.filter(|origin| !origin.is_empty())
.map(|origin| {
if origin.ends_with('/') {
return Err(format!("{origin} must not end with /"));
}
HeaderValue::from_str(origin).map_err(|e| format!("{origin}: {e}"))
})
.collect::<Result<Vec<_>, _>>()?;

if origins.is_empty() {
return Err("no origins".to_string());
}
Ok(origins)
}

fn required_env(name: &str) -> String {
runtime::var(name).unwrap_or_else(|| {
tracing::error!("{name} not set");
panic!("{name} required");
})
}

fn env_or_mock_default(name: &str, default: &str, mock_auth: bool) -> String {
match runtime::var(name) {
Some(v) => v,
None if mock_auth => default.to_string(),
None => required_env(name),
}
}

impl AppConfig {
pub fn new() -> Self {
let edb_database_url = match var("EDB_DATABASE_URL") {
Ok(v) if !v.is_empty() => v,
_ => {
tracing::error!("EDB_DATABASE_URL not set");
panic!("EDB_DATABASE_URL required");
let mock_auth = match var("MOCK_AUTH") {
Ok(v) => {
let mock_auth = v.parse().unwrap_or(false);
if cfg!(not(debug_assertions)) && mock_auth {
panic!("MOCK_AUTH is not allowed to be set to 'true' outside of a debug build");
}

tracing::warn!("MOCK_AUTH explicitly set to {mock_auth}");
mock_auth
}
Err(_e) => false,
};

Self { edb_database_url }
let cookie_key = env_or_mock_default("COOKIE_KEY", &"test-dev-string".repeat(5), mock_auth);

assert!(
cookie_key.len() >= 64,
"COOKIE_KEY must be at least 64 bytes"
);

let edb_database_url = required_env("EDB_DATABASE_URL");

let web_app_origins = env_or_mock_default(
"WEB_APP_ORIGINS",
"http://127.0.0.1:13003,http://localhost:13003",
mock_auth,
);
let web_app_origins = parse_origins(&web_app_origins).unwrap_or_else(|e| {
tracing::error!("WEB_APP_ORIGINS invalid: {e}");
panic!("WEB_APP_ORIGINS invalid: {e}");
});

Self {
cookie_key,
edb_database_url,
mock_auth,
web_app_origins,
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parses_comma_separated_origins() {
let origins =
parse_origins("http://127.0.0.1:13003, http://localhost:13003,").expect("valid");

assert_eq!(
origins,
[
HeaderValue::from_static("http://127.0.0.1:13003"),
HeaderValue::from_static("http://localhost:13003"),
]
);
}

#[test]
fn rejects_empty_origins() {
assert!(parse_origins(" , ").is_err());
}

#[test]
fn rejects_origins_with_a_path() {
assert!(parse_origins("https://web-app.freecodecamp.dev/").is_err());
}
}
40 changes: 40 additions & 0 deletions apps/auth-api/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
use axum::response::{IntoResponse, Response};
use http::StatusCode;

#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("{1}")]
Server(StatusCode, String),
#[error("{0}")]
Sqlx(#[from] sqlx::Error),
#[error("{0}")]
TimeConversion(#[from] time::error::ConversionRange),
}

impl Error {
pub fn bad_request(message: impl Into<String>) -> Self {
Self::Server(StatusCode::BAD_REQUEST, message.into())
}

pub fn unauthorized(message: impl Into<String>) -> Self {
Self::Server(StatusCode::UNAUTHORIZED, message.into())
}
}

impl IntoResponse for Error {
fn into_response(self) -> Response {
let msg = self.to_string();
let status: StatusCode = self.into();

(status, msg).into_response()
}
}

impl From<Error> for StatusCode {
fn from(error: Error) -> Self {
match error {
Error::Server(c, _) => c,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
Loading