1# Authentication providers23dev.site has one authentication boundary: a provider proves an external identity, then the4application turns that identity into its own account and browser session. Shoo is the default5OIDC configuration, not part of the account, sharing, CLI, or capability model.67```text8provider-specific login9 │ verify credentials, callback, issuer and subject10 ▼11ExternalIdentity { namespace, subject }12 │ establish_browser_session13 ▼14dev.site AccountId + opaque HttpOnly session15```1617The stable types and functions are:1819- `auth::ExternalIdentity` in `crates/devsite-server/src/auth.rs`.20- `api::establish_browser_session` in `crates/devsite-server/src/api.rs`.21- `api::browser_session_cookie`, which applies the dev.site cookie policy.2223The application port deliberately is not an OAuth-shaped trait. An OIDC callback, a passkey,24a signed proxy header, and a local enterprise SSO flow have different HTTP exchanges. Each25inbound adapter owns that exchange and ends by producing the same small `ExternalIdentity`.2627## Use another OIDC provider2829The built-in adapter implements authorization code flow with PKCE S256. The browser enters at30`GET /auth/start`; the server stores a short-lived verifier, redirects to the provider, handles31`GET /auth/callback`, exchanges the code, verifies the ID token against JWKS, and then calls the32application port. No provider token reaches browser JavaScript.3334The replacement provider must support:3536- a public client using authorization code flow and PKCE S256;37- a token endpoint that accepts `token_endpoint_auth_method=none`;38- an ID token containing `iss`, `sub`, `aud`, and `exp`;39- a JWKS endpoint and an asymmetric JWT signing algorithm.4041Client-secret authentication is intentionally not implemented. Configure a public client, or42add secret handling to the adapter without exposing the secret through `/api/config` or the web43application.4445Register this exact callback at the provider:4647```text48<DEVSITE_PUBLIC_ORIGIN>/auth/callback49```5051Then configure the adapter. Discovery is not automatic; endpoints are explicit so startup does52not silently trust metadata from somewhere other than the configured issuer.5354| Variable | Default |55| --- | --- |56| `DEVSITE_OIDC_ISSUER` | `https://shoo.dev` |57| `DEVSITE_OIDC_AUTHORIZATION_ENDPOINT` | `<issuer>/authorize` |58| `DEVSITE_OIDC_TOKEN_ENDPOINT` | `<issuer>/token` |59| `DEVSITE_OIDC_JWKS_URI` | `<issuer>/.well-known/jwks.json` |60| `DEVSITE_OIDC_CLIENT_ID` | `origin:<DEVSITE_PUBLIC_ORIGIN>` |61| `DEVSITE_OIDC_SCOPES` | `openid` |62| `DEVSITE_OIDC_ALGORITHMS` | `ES256` |6364For example:6566```bash67export DEVSITE_PUBLIC_ORIGIN=https://devsite.example.com68export DEVSITE_OIDC_ISSUER=https://identity.example.com/realms/developers69export DEVSITE_OIDC_AUTHORIZATION_ENDPOINT=https://identity.example.com/realms/developers/protocol/openid-connect/auth70export DEVSITE_OIDC_TOKEN_ENDPOINT=https://identity.example.com/realms/developers/protocol/openid-connect/token71export DEVSITE_OIDC_JWKS_URI=https://identity.example.com/realms/developers/protocol/openid-connect/certs72export DEVSITE_OIDC_CLIENT_ID=devsite73export DEVSITE_OIDC_SCOPES='openid profile email'74export DEVSITE_OIDC_ALGORITHMS=RS2567576cargo run -p devsite-server77```7879Provider URLs must use HTTPS. HTTP is accepted only for a loopback provider during local80development. `openid` must remain in the scope list, symmetric `HS*` algorithms are rejected,81and every accepted token must match the configured issuer and client ID exactly.8283To smoke-test initiation without completing a login:8485```bash86curl -i 'http://127.0.0.1:4000/auth/start?return_to=%2F'87```8889Expect a `307` response whose `Location` points at the configured authorization endpoint and a90callback-scoped `devsite_login_state` cookie. Complete a browser login before considering the91provider verified; that exercises code exchange, JWKS retrieval, claims validation, account92lookup, and session creation together.9394### Identity continuity9596An account is keyed by the exact `(namespace, subject)` pair. For OIDC these values are the97verified `iss` and `sub` claims. Changing the issuer string, client configuration in a provider98that issues pairwise subjects, or the subject itself creates a different dev.site account.99100Never substitute email, handle, display name, or another mutable claim for `sub`. Never label a101new provider with the old provider's namespace merely to preserve accounts: that would let the102new provider impersonate every old identity. Continuity across unrelated providers requires an103explicit, authenticated account-linking or operator migration design.104105Existing databases are migrated as follows:106107- old Shoo subjects become `("https://shoo.dev", <old subject>)`;108- operator-issued identities become `("local", <handle>)`.109110## Implement a non-OIDC provider111112Use `crates/devsite-server/src/oidc.rs` as the reference inbound adapter, but keep only the113security mechanisms appropriate to the new protocol. The implementation path is:1141151. Add a module under `crates/devsite-server/src/`, for example `passkey.rs`.1162. Give it the provider-owned start, challenge, and callback routes.1173. Verify the provider proof completely inside that module.1184. Construct `ExternalIdentity` only after verification succeeds.1195. Call `api::establish_browser_session` with that identity.1206. Put the returned token into `api::browser_session_cookie` and redirect to a local path.1217. Wire the adapter router in `main.rs`, replacing `oidc::router(...)`.1228. Keep `/auth/start` as the browser entrypoint so `web/app.ts` remains provider-neutral.123124A callback ends approximately like this:125126```rust,ignore127use axum::http::{header, HeaderValue, StatusCode};128use axum::response::{IntoResponse, Redirect, Response};129130use crate::api::{self, Shared};131use crate::auth::ExternalIdentity;132133fn finish_login(134 app: &Shared,135 verified_provider_user_id: String,136 return_to: &str,137) -> Response {138 // This namespace is a permanent identity boundary. Do not accept it from the request.139 let identity = ExternalIdentity {140 namespace: "passkey:https://devsite.example.com".to_string(),141 subject: verified_provider_user_id,142 };143144 let session = match api::establish_browser_session(app, identity) {145 Ok(session) => session,146 Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),147 };148149 let target = if session.handle.is_some() { return_to } else { "/" };150 let mut response = Redirect::to(target).into_response();151 let cookie = api::browser_session_cookie(app, &session.token);152 match HeaderValue::from_str(&cookie) {153 Ok(cookie) => {154 response.headers_mut().append(header::SET_COOKIE, cookie);155 response156 }157 Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),158 }159}160```161162The namespace must be hard-coded or trusted configuration, globally unambiguous, and stable for163the lifetime of the installation. The subject must be a stable, provider-assigned identifier and164is limited to 2048 bytes. The database automatically creates or reuses the corresponding account;165a new provider namespace does not require another schema migration.166167### Wiring168169The current OIDC wiring in `main.rs` does three things that a replacement must preserve:1701711. constructs and validates adapter configuration before listening;1722. exposes the active identity namespace in `AppState.identity_namespace` and `/api/config`;1733. merges the adapter routes into the Axum application.174175The corresponding `main.rs` shape is:176177```rust,ignore178mod passkey;179180let login = passkey::Config::from_env(&config.public_origin)?;181let identity_namespace = login.namespace().to_string();182183let state = Arc::new(AppState {184 // Existing database, rate limiter, capability issuer, and public origin fields...185 identity_namespace,186});187188let app = api::router(Arc::clone(&state))189 .merge(passkey::router(Arc::clone(&state), login)?);190```191192For one replacement provider, change the module and router construction in one place. The current193UI advertises one `auth.start_url`, so supporting several providers simultaneously additionally194requires a provider list in `/api/config` and a provider picker in `web/app.ts`. The account table195already supports multiple namespaces.196197### Security contract for a new adapter198199Before handing an identity to the application port, an adapter must:200201- authenticate the proof cryptographically or through a trusted back channel;202- bind callbacks to the browser that started them and consume challenges once;203- reject expired, replayed, wrong-audience, and wrong-origin proofs as applicable;204- derive `namespace` from trusted configuration, never request data;205- use a stable opaque subject, never email or a display name;206- validate local return paths before placing them in a `Location` header;207- keep credentials, codes, assertions, and provider tokens out of logs and browser storage;208- return indistinguishable public errors for invalid identities while logging useful server-side209 context without secrets;210- bound temporary state, credential sizes, and outbound request timeouts.211212Provider logout is separate from dev.site logout. `DELETE /api/auth/session` always revokes the213local session. An adapter may add upstream logout, but local revocation must succeed even when the214provider is unavailable.215216### Tests to add217218At minimum, cover:219220- a valid proof produces the expected namespace and subject;221- invalid signatures or credentials never reach `establish_browser_session`;222- challenge/state mismatch, expiry, and replay are rejected;223- the same `(namespace, subject)` reuses an account;224- the same subject in two namespaces creates two accounts;225- unsafe return paths cannot redirect off-site;226- provider outages time out and do not create sessions.227228Run the repository checks before deploying:229230```bash231cargo test --workspace232cargo clippy --workspace --all-targets -- -D warnings233cargo build --release --locked -p devsite-server234npm run check:web235```