From 7403e14880063a2072e46375c0500767167620ea Mon Sep 17 00:00:00 2001 From: Chris Young Date: Thu, 22 Oct 2026 23:11:47 +0800 Subject: [PATCH] Configure the Coauth OAuth client ID at runtime --- DEPLOYMENT.md | 6 ++++++ Dockerfile | 1 + docker-entrypoint.sh | 4 +++- src/api/auth.rs | 16 +++++++++++----- src/pages/login.rs | 1 + src/utils/net/config.rs | 40 +++++++++++++++++++++++++++++++++++++++- src/utils/net/session.rs | 7 +++++++ 7 files changed, 68 insertions(+), 7 deletions(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 799fdee..bf3ba7f 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -15,10 +15,16 @@ the Gitea workflow. | `COAUTH_URL` | recommended | Internal URL for coauth admin/auth endpoints. Enables `/auth/`, `/_arkret/gate/`, the coauth `/_coauth/admin/*` resource roots, `/authorize`, `/oauth/`, and `/.well-known/` proxy locations. | | `COAUTH_PUBLIC_URL` | recommended | Browser-facing coauth origin written to `/config.json` for OAuth2 PKCE redirects. | | `CODMIN_PORT` | no | nginx listen port. Defaults to `80`. | +| `CODMIN_OAUTH_CLIENT_ID` | no | Coauth's registered public client ID (ULID). Written as `oauth_client_id` in `/config.json`. Defaults to `01GFWR28C4KNE04WG3HKXB7C9R`. Register that same ID with the exact Codmin origin's `/oauth/callback` redirect URI. | | `CODMIN_TELEMETRY_ENDPOINT` | no | Opt-in browser-error telemetry sink. When set, `/config.json` exposes the URL and `utils::telemetry` POSTs structured (no-PII) error events. Operator must also flip `localStorage.codmin_telemetry_opt_in=1`. | ## Port / Path Routing Table +Custom static hosts must serve the matching `oauth_client_id` in `/config.json`. +Coauth uses ULID client IDs; the former hard-coded client name `codmin` is not a +valid client ID. Authorization, code exchange, refresh and revoke all use the +configured value. The client ID is public configuration, never a client secret. + The codmin container is a single nginx instance serving a Dioxus/WASM SPA plus reverse-proxy locations for upstream services. Operators fronting additional services should update both `connect-src` in the diff --git a/Dockerfile b/Dockerfile index 6559deb..3db0f4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -91,6 +91,7 @@ ENV COLAND_URL="" ENV COAUTH_URL="" ENV COAUTH_PUBLIC_URL="" ENV CODMIN_PORT="80" +ENV CODMIN_OAUTH_CLIENT_ID="01GFWR28C4KNE04WG3HKXB7C9R" EXPOSE 80 HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 2518bca..e1f8057 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -6,6 +6,7 @@ COAUTH_URL="${COAUTH_URL:-}" COAUTH_PUBLIC_URL="${COAUTH_PUBLIC_URL:-}" CODMIN_TELEMETRY_ENDPOINT="${CODMIN_TELEMETRY_ENDPOINT:-}" CODMIN_PORT="${CODMIN_PORT:-80}" +CODMIN_OAUTH_CLIENT_ID="${CODMIN_OAUTH_CLIENT_ID:-01GFWR28C4KNE04WG3HKXB7C9R}" CODMIN_UPSTREAM_CA_FILE="${CODMIN_UPSTREAM_CA_FILE:-/etc/ssl/certs/ca-certificates.crt}" RESOLVERS="$(awk '/^nameserver / { print $2 }' /etc/resolv.conf | paste -sd ' ' -)" @@ -96,9 +97,10 @@ json_escape() { # Escape backslash first, then double-quote. printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' } -printf '{"coauth_public_url":"%s","telemetry_endpoint":"%s"}' \ +printf '{"coauth_public_url":"%s","telemetry_endpoint":"%s","oauth_client_id":"%s"}' \ "$(json_escape "$COAUTH_PUBLIC_URL")" \ "$(json_escape "$CODMIN_TELEMETRY_ENDPOINT")" \ + "$(json_escape "$CODMIN_OAUTH_CLIENT_ID")" \ > /runtime/config.json cat > /etc/nginx/conf.d/default.conf < String { + urlencoding::encode(&crate::utils::net::session::oauth_client_id()).into_owned() +} const COAUTH_ADMIN_SCOPE: &str = "urn:coauth:admin"; const ARKRET_ADMIN_SCOPE: &str = "urn:arkret:admin:*"; const PKCE_VERIFIER_KEY: &str = "pkce_code_verifier"; @@ -237,6 +239,7 @@ async fn compute_code_challenge(verifier: &str) -> Result { } pub async fn start_oauth_login() -> Result<(), HttpError> { + let oauth_client_id = oauth_client_id(); let verifier = generate_code_verifier(); let challenge = compute_code_challenge(&verifier).await?; let state = generate_code_verifier(); @@ -267,7 +270,7 @@ pub async fn start_oauth_login() -> Result<(), HttpError> { let auth_url = format!( "{coauth_base}/authorize?response_type=code\ - &client_id={OAUTH_CLIENT_ID}\ + &client_id={oauth_client_id}\ &redirect_uri={}\ &code_challenge={challenge}\ &code_challenge_method=S256\ @@ -289,6 +292,7 @@ pub async fn start_oauth_login() -> Result<(), HttpError> { } pub async fn handle_oauth_callback(code: &str, state: Option<&str>) -> Result<(), HttpError> { + let oauth_client_id = oauth_client_id(); let session = session_storage()?; let verifier = session .get_item(PKCE_VERIFIER_KEY) @@ -321,7 +325,7 @@ pub async fn handle_oauth_callback(code: &str, state: Option<&str>) -> Result<() "grant_type=authorization_code\ &code={}\ &redirect_uri={}\ - &client_id={OAUTH_CLIENT_ID}\ + &client_id={oauth_client_id}\ &code_verifier={}", urlencoding::encode(code), urlencoding::encode(&redirect_uri), @@ -475,11 +479,12 @@ async fn refresh_oauth_token_singleflight() -> bool { } pub async fn refresh_oauth_token() -> bool { + let oauth_client_id = oauth_client_id(); // S5: the refresh token is in the httpOnly cookie. Just hit // /oauth/token (refresh) with credentials: "include" — coauth pulls the // refresh token from the cookie, mints a new pair, and ships back // a new cookie. The JSON body carries no secrets. - let form_body = format!("grant_type=refresh_token&client_id={OAUTH_CLIENT_ID}"); + let form_body = format!("grant_type=refresh_token&client_id={oauth_client_id}"); let response = match send_oauth_form_request("/oauth/token", &form_body).await { Ok(r) => r, @@ -636,6 +641,7 @@ fn classify_cookie_logout(response: Result) -> LogoutSt } pub async fn logout() -> LogoutReport { + let oauth_client_id = oauth_client_id(); // The protocol hard-logout is authoritative for the account grant and // Principal-side device session. It runs before the standalone OAuth // revoke so token invalidation cannot prevent Principal-side cleanup. @@ -645,7 +651,7 @@ pub async fn logout() -> LogoutReport { // OAuth revocation remains a separate result. RFC 7009 success is conveyed // by a 2xx response, including the already-revoked/unknown-token case. - let body = format!("client_id={OAUTH_CLIENT_ID}"); + let body = format!("client_id={oauth_client_id}"); let oauth_revoke = classify_oauth_revoke(send_oauth_form_request("/oauth/revoke", &body).await); // coauth owns the HttpOnly browser-session cookie. Always attempt this diff --git a/src/pages/login.rs b/src/pages/login.rs index f30ffc1..178e099 100644 --- a/src/pages/login.rs +++ b/src/pages/login.rs @@ -16,6 +16,7 @@ pub fn LoginPage(logout_warning: Option) -> Element { spawn(async move { match crate::utils::net::config::load_runtime_config().await { Ok(cfg) => { + crate::utils::storage::set_item("oauth_client_id", &cfg.oauth_client_id); crate::utils::storage::set_item("coauth_public_url", &cfg.coauth_public_url); // Propagate the optional telemetry endpoint // into localStorage so `utils::net::telemetry` can pick diff --git a/src/utils/net/config.rs b/src/utils/net/config.rs index 15ed828..cf83ca7 100644 --- a/src/utils/net/config.rs +++ b/src/utils/net/config.rs @@ -1,4 +1,10 @@ -/// Shape of `/config.json` served by the deployment. Only the coauth +pub const DEFAULT_OAUTH_CLIENT_ID: &str = "01GFWR28C4KNE04WG3HKXB7C9R"; + +fn default_oauth_client_id() -> String { + DEFAULT_OAUTH_CLIENT_ID.into() +} + +/// Shape of `/config.json` served by the deployment. The coauth /// public URL is currently consumed; `pages::login` writes it into /// storage on app boot so `utils::net::session::coauth_public_url` can read /// it back from any layer. @@ -7,6 +13,9 @@ pub struct RuntimeConfig { #[serde(default)] pub coauth_public_url: String, + /// Public OAuth client registered at Coauth (a ULID, never a client name). + #[serde(default = "default_oauth_client_id")] + pub oauth_client_id: String, /// Optional opt-in browser-error telemetry endpoint. When set /// AND the operator has flipped `codmin_telemetry_opt_in` in /// localStorage, `utils::net::telemetry::report_http_error` posts @@ -19,6 +28,19 @@ pub struct RuntimeConfig { mod tests { use super::*; + #[test] + fn oauth_client_id_defaults_and_overrides_match_coauth_ulids() { + let cfg: RuntimeConfig = serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(cfg.oauth_client_id, DEFAULT_OAUTH_CLIENT_ID); + assert!(valid_oauth_client_id(&cfg.oauth_client_id)); + assert!(!valid_oauth_client_id("codmin")); + assert!(!valid_oauth_client_id("ZZZZZZZZZZZZZZZZZZZZZZZZZZ")); + let configured = "01KMQE9VZQ33VQT9GP1WA4265X"; + let cfg: RuntimeConfig = + serde_json::from_value(serde_json::json!({"oauth_client_id": configured})).unwrap(); + assert_eq!(cfg.oauth_client_id, configured); + } + #[test] fn runtime_config_rejects_misspelled_security_relevant_keys() { let error = serde_json::from_value::(serde_json::json!({ @@ -42,11 +64,15 @@ pub enum ConfigLoadError { Parse(String), /// The required `coauth_public_url` field was absent or empty. MissingCoauthUrl, + InvalidOAuthClientId, } impl std::fmt::Display for ConfigLoadError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + Self::InvalidOAuthClientId => { + f.write_str("/config.json oauth_client_id must be a ULID") + } Self::Fetch(msg) => write!(f, "Failed to fetch /config.json: {msg}"), Self::Parse(msg) => write!(f, "Failed to parse /config.json: {msg}"), Self::MissingCoauthUrl => { @@ -80,5 +106,17 @@ pub async fn load_runtime_config() -> Result { return Err(ConfigLoadError::MissingCoauthUrl); } + if !valid_oauth_client_id(&cfg.oauth_client_id) { + return Err(ConfigLoadError::InvalidOAuthClientId); + } + Ok(cfg) } + +fn valid_oauth_client_id(value: &str) -> bool { + value.len() == 26 + && value.as_bytes()[0] <= b'7' + && value + .bytes() + .all(|b| b"0123456789ABCDEFGHJKMNPQRSTVWXYZ".contains(&b)) +} diff --git a/src/utils/net/session.rs b/src/utils/net/session.rs index b1e7ac0..ae472d9 100644 --- a/src/utils/net/session.rs +++ b/src/utils/net/session.rs @@ -65,3 +65,10 @@ pub fn has_coauth() -> bool { // gating here. The sidebar derives its visibility from the deployment // facts above (`has_coauth()`); server-side RBAC is the only // authorization authority (DEPLOYMENT.md). + +/// OAuth client ID is public deployment configuration, not a credential. +pub fn oauth_client_id() -> String { + storage::get_item("oauth_client_id") + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| super::config::DEFAULT_OAUTH_CLIENT_ID.into()) +}