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
6 changes: 6 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
4 changes: 3 additions & 1 deletion docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ' ' -)"
Expand Down Expand Up @@ -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 <<EOF
Expand Down
16 changes: 11 additions & 5 deletions src/api/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ use crate::utils::net::error::HttpError;
use crate::utils::security::crypto::{base64url_encode, random_token};
use crate::utils::storage;

const OAUTH_CLIENT_ID: &str = "codmin";
fn oauth_client_id() -> 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";
Expand Down Expand Up @@ -237,6 +239,7 @@ async fn compute_code_challenge(verifier: &str) -> Result<String, HttpError> {
}

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();
Expand Down Expand Up @@ -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\
Expand All @@ -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)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -636,6 +641,7 @@ fn classify_cookie_logout(response: Result<TextResponse, HttpError>) -> 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.
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/pages/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub fn LoginPage(logout_warning: Option<String>) -> 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
Expand Down
40 changes: 39 additions & 1 deletion src/utils/net/config.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -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::<RuntimeConfig>(serde_json::json!({
Expand All @@ -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 => {
Expand Down Expand Up @@ -80,5 +106,17 @@ pub async fn load_runtime_config() -> Result<RuntimeConfig, ConfigLoadError> {
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))
}
7 changes: 7 additions & 0 deletions src/utils/net/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Loading