Add a provider-neutral ExternalIdentity session boundary and configurable server-side OIDC adapter with Shoo defaults. Migrate existing identities into namespaces, move PKCE and callback verification behind server routes, and document OIDC replacement and custom provider implementation.
- commit
- eab3415cbd1b4f915fa0ce6ec7e438c9236c9839
- parent
- 904c0e40a59b8c199aaf5df5e10a4fbccdc2df0f
- author
- Dami <dami.etoile@gmail.com>
| M | Dockerfile | 2 | +- |
| M | README.md | 16 | +- |
| M | crates/ | 66 | +-- |
| M | crates/ | 159 | +-------- |
| M | crates/ | 150 | +++++++- |
| M | crates/ | 28 | +- |
| A | crates/ | 584 | ++++++++++++++++++++++++++++++ |
| M | deploy/ | 19 | +- |
| A | docs/ | 235 | ++++++++++++ |
| M | fly.toml | 7 | +- |
| M | web/ | 113 | +------ |
@@ -48,7 +48,7 @@ RUN --mount=type=cache,id=devsite-cargo-registry,target=/usr/local/cargo/registr
4848 4949 FROM debian:bookworm-slim5050 51-# TLS roots, for verifying Shoo's JWKS endpoint.51+# TLS roots, for OIDC code exchange and JWKS verification.5252 RUN apt-get update \5353 && apt-get install -y --no-install-recommends ca-certificates \5454 && rm -rf /var/lib/apt/lists/*@@ -190,6 +190,14 @@ export DEVSITE_PUBLIC_ORIGIN=http://127.0.0.1:4000
190190 cargo run -p devsite-server191191 ```192192 193+Shoo is the default OIDC login adapter. A fork can use any public OIDC client without194+changing application code by setting `DEVSITE_OIDC_ISSUER`,195+`DEVSITE_OIDC_AUTHORIZATION_ENDPOINT`, `DEVSITE_OIDC_TOKEN_ENDPOINT`,196+`DEVSITE_OIDC_JWKS_URI`, and `DEVSITE_OIDC_CLIENT_ID`. The server owns authorization-code197+exchange, PKCE, and token verification; the browser only enters through `/auth/start`.198+See [Authentication providers](docs/auth-providers.md) for OIDC replacement, identity199+continuity, and implementing a non-OIDC `ExternalIdentity` adapter.200+193201 For operator-only setup, mint a local browser session:194202 195203 ```bash@@ -216,9 +224,11 @@ The traffic path scales independently because it does not pass through Fly. Iroh
216224 end-to-end encrypted QUIC directly when possible and through a relay when necessary; the217225 control plane only signs authorization metadata.218226 219-The two durable identities are `DEVSITE_PUBLIC_ORIGIN`, which Shoo uses when deriving220-accounts, and `DEVSITE_SIGNING_KEY`, whose public half every daemon pins at login. Changing221-either is intentionally disruptive.227+The durable boundaries are the OIDC `(issuer, subject)` identity pairs and228+`DEVSITE_SIGNING_KEY`, whose public half every daemon pins at login. Shoo derives its229+default client id and pairwise subjects from `DEVSITE_PUBLIC_ORIGIN`; changing that origin230+is therefore still intentionally disruptive unless the replacement provider preserves231+the configured issuer, client, and subjects.222232 223233 ## Crates224234 @@ -15,7 +15,7 @@ use devsite_proto::capability::KeyBytes;
1515 use devsite_proto::{AccountId, ResourceId};1616 use serde::{Deserialize, Serialize};1717 18-use crate::auth::{self, ShooVerifier};18+use crate::auth::{self, ExternalIdentity};1919 use crate::db::{Db, MachineAuthentication, ResourceKind, ShareStatus};2020 use crate::issuer::Issuer;2121 use crate::policy::{can_view, Visibility};@@ -25,7 +25,7 @@ pub struct AppState {
2525 pub db: Mutex<Db>,2626 pub rate_limits: Mutex<RateLimits>,2727 pub issuer: Issuer,28- pub shoo: ShooVerifier,28+ pub identity_namespace: String,2929 pub public_origin: String,3030 }3131 @@ -219,17 +219,6 @@ fn cookie_token(headers: &HeaderMap) -> Option<String> {
219219 220220 // -- payloads ------------------------------------------------------------------221221 222-223-pub struct SignInRequest {224- pub id_token: String,225-}226-227-228-pub struct SignInResponse {229- pub account_id: String,230- pub handle: Option<String>,231-}232-233222 234223 pub struct ClaimHandleRequest {235224 pub handle: String,@@ -358,7 +347,7 @@ pub fn router(state: Shared) -> Router {
358347 Router::new()359348 .route("/api/config", get(config))360349 .route("/api/pubkey", get(pubkey))361- .route("/api/auth/session", post(sign_in).delete(sign_out))350+ .route("/api/auth/session", delete(sign_out))362351 .route("/api/me", get(me))363352 .route("/api/profile", post(claim_handle))364353 .route("/api/resources", post(create_resource).get(list_resources))@@ -401,7 +390,12 @@ pub fn router(state: Shared) -> Router {
401390 /// Public configuration the browser needs to start a sign-in.402391 async fn config(State(state): State<Shared>) -> impl IntoResponse {403392 Json(serde_json::json!({404- "issuer": auth::ISSUER,393+ // `issuer` stays as a compatibility alias for older clients that displayed it.394+ "issuer": state.identity_namespace,395+ "auth": {396+ "namespace": state.identity_namespace,397+ "start_url": "/auth/start",398+ },405399 "public_origin": state.public_origin,406400 "redirect_uri": format!("{}/auth/callback", state.public_origin),407401 "api_version": 3,@@ -416,25 +410,27 @@ async fn pubkey(State(state): State<Shared>) -> impl IntoResponse {
416410 Json(serde_json::json!({ "public_key": state.issuer.public_key_hex() }))417411 }418412 419-async fn sign_in(420- State(state): State<Shared>,421- Json(body): Json<SignInRequest>,422-) -> ApiResult<Response> {423- let claims = state.shoo.verify(&body.id_token).await.map_err(|err| {424- tracing::warn!("rejected an id_token: {err:#}");425- ApiError(StatusCode::UNAUTHORIZED, "invalid sign-in token".into())426- })?;413+pub(crate) struct EstablishedBrowserSession {414+ pub token: String,415+ pub handle: Option<String>,416+}427417 418+/// Application port shared by every login adapter: a verified external identity enters;419+/// an opaque, provider-independent browser session comes out.420+pub(crate) fn establish_browser_session(421+ state: &AppState,422+ identity: ExternalIdentity,423+) -> ApiResult<EstablishedBrowserSession> {428424 let now = now_secs();429425 let token = auth::generate_session_token();430426 let (account_id, handle) = {431427 let db = state.db.lock().unwrap();432428 let account = db433- .upsert_account(&claims.sub, now)429+ .upsert_account(&identity.namespace, &identity.subject, now)434430 .map_err(ApiError::internal)?;435431 (account.id, account.handle)436432 };437- check_rate(&state, account_id, RateClass::Session)?;433+ check_rate(state, account_id, RateClass::Session)?;438434 {439435 let db = state.db.lock().unwrap();440436 db.create_session(@@ -446,9 +442,13 @@ async fn sign_in(
446442 db.purge_expired_sessions(now).ok();447443 }448444 449- // HttpOnly so page scripts cannot read it; SameSite=Lax so it survives the OAuth450- // redirect back from Shoo without being sent on cross-site POSTs.451- let cookie = format!(445+ Ok(EstablishedBrowserSession { token, handle })446+}447+448+pub(crate) fn browser_session_cookie(state: &AppState, token: &str) -> String {449+ // HttpOnly so page scripts cannot read it; SameSite=Lax so it survives an identity450+ // provider redirect without being sent on cross-site POSTs.451+ format!(452452 "devsite_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}{}",453453 auth::SESSION_LIFETIME_SECS,454454 if state.public_origin.starts_with("https://") {@@ -456,17 +456,7 @@ async fn sign_in(
456456 } else {457457 ""458458 }459- );460-461- Ok((462- StatusCode::OK,463- [(header::SET_COOKIE, cookie)],464- Json(SignInResponse {465- account_id: account_id.to_string(),466- handle,467- }),468459 )469- .into_response())470460 }471461 472462 async fn sign_out(State(state): State<Shared>, headers: HeaderMap) -> ApiResult<Response> {@@ -1,147 +1,22 @@
1-//! Sign-in via Shoo, and the sessions that follow.1+//! Provider-neutral identities and the sessions that follow.22 //!3-//! Shoo is an OIDC broker for Google sign-in. The browser performs the authorize + PKCE4-//! token exchange itself and hands us the resulting `id_token`; we verify it independently5-//! against Shoo's JWKS and then mint our own session. Nothing downstream trusts the6-//! `id_token` again — only our session cookie.3+//! Login adapters end at `ExternalIdentity`. Nothing downstream knows which provider4+//! authenticated it; it only trusts the opaque dev.site session minted for that identity.75 8-use std::sync::RwLock;9-use std::time::{Duration, Instant};10-11-use anyhow::{bail, Context, Result};12-use jsonwebtoken::{Algorithm, DecodingKey, Validation};13-use serde::Deserialize;6+use anyhow::{bail, Result};147 use sha2::{Digest, Sha256};158 16-pub const ISSUER: &str = "https://shoo.dev";17-pub const JWKS_URL: &str = "https://shoo.dev/.well-known/jwks.json";18-199 /// How long a dev.site session lasts.2010 pub const SESSION_LIFETIME_SECS: u64 = 60 * 60 * 24 * 7;2111 22-/// Refetch JWKS at most this often, so key rotation is picked up without hammering Shoo.23-const JWKS_TTL: Duration = Duration::from_secs(10 * 60);24-25-/// The claims we care about. `sub` is Shoo's pairwise subject: stable for our origin,26-/// and uncorrelatable with the same person on any other site.12+/// The only value a login adapter is allowed to hand to the application.2713 ///28-/// `exp` must stay declared even though nothing reads it directly — `jsonwebtoken`29-/// enforces it during deserialization, and dropping the field would silently disable that.30-31-32-pub struct IdTokenClaims {33- pub sub: String,34- pub iss: String,35- pub aud: String,36- pub exp: u64,37- 38- pub email: Option<String>,39- 40- pub name: Option<String>,41-}42-43-44-struct Jwk {45- kid: String,46- x: String,47- y: String,48- 49- alg: Option<String>,50-}51-52-53-struct Jwks {54- keys: Vec<Jwk>,55-}56-57-pub struct ShooVerifier {58- /// `origin:<our origin>` — the audience Shoo stamps into tokens issued for us.59- ///60- /// Checking this exactly is what stops a token minted for some other site from being61- /// replayed at dev.site to impersonate its bearer.62- audience: String,63- http: reqwest::Client,64- cache: RwLock<Option<(Vec<Jwk>, Instant)>>,65-}66-67-impl ShooVerifier {68- /// `public_origin` is the exact origin the browser loads, e.g. `https://dev.site`.69- pub fn new(public_origin: &str) -> Self {70- Self {71- audience: format!("origin:{}", public_origin.trim_end_matches('/')),72- http: reqwest::Client::new(),73- cache: RwLock::new(None),74- }75- }76-77- pub fn audience(&self) -> &str {78- &self.audience79- }80-81- async fn keys(&self) -> Result<Vec<Jwk>> {82- if let Some((keys, fetched)) = self.cache.read().unwrap().as_ref() {83- if fetched.elapsed() < JWKS_TTL {84- return Ok(keys.clone());85- }86- }87-88- let jwks: Jwks = self89- .http90- .get(JWKS_URL)91- .send()92- .await93- .context("fetching Shoo JWKS")?94- .error_for_status()95- .context("Shoo JWKS returned an error status")?96- .json()97- .await98- .context("parsing Shoo JWKS")?;99-100- *self.cache.write().unwrap() = Some((jwks.keys.clone(), Instant::now()));101- Ok(jwks.keys)102- }103-104- /// Verify an `id_token` and return its claims.105- ///106- /// The algorithm is pinned to ES256 rather than read from the token header: accepting107- /// whatever a token asks for is how `alg: none` and HMAC-confusion attacks work.108- pub async fn verify(&self, id_token: &str) -> Result<IdTokenClaims> {109- let header = jsonwebtoken::decode_header(id_token).context("unreadable token header")?;110- if header.alg != Algorithm::ES256 {111- bail!("unexpected token algorithm {:?}", header.alg);112- }113- let kid = header.kid.context("token has no key id")?;114-115- let keys = self.keys().await?;116- let jwk = keys117- .iter()118- .find(|k| k.kid == kid)119- .with_context(|| format!("no Shoo key matches kid {kid}"))?;120- if jwk.alg.as_deref().is_some_and(|alg| alg != "ES256") {121- bail!("Shoo key {kid} is not an ES256 key");122- }123-124- let key = DecodingKey::from_ec_components(&jwk.x, &jwk.y)125- .context("Shoo key is not a usable EC public key")?;126-127- let mut validation = Validation::new(Algorithm::ES256);128- validation.set_issuer(&[ISSUER]);129- validation.set_audience(&[&self.audience]);130- validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]);131-132- let data = jsonwebtoken::decode::<IdTokenClaims>(id_token, &key, &validation)133- .context("id_token failed verification")?;134-135- // jsonwebtoken checks these, but they are the two that matter most here, and a136- // future change to `validation` should not be able to silently drop them.137- if data.claims.iss != ISSUER {138- bail!("unexpected issuer {}", data.claims.iss);139- }140- if data.claims.aud != self.audience {141- bail!("token was issued for a different site");142- }143- Ok(data.claims)144- }14+/// For OIDC, the namespace is the exact `iss` claim and the subject is `sub`; that pair,15+/// rather than either value alone, is the stable external identity.16+17+pub struct ExternalIdentity {18+ pub namespace: String,19+ pub subject: String,14520 }14621 14722 /// Mint an opaque session token. Only its hash is stored.@@ -214,18 +89,6 @@ pub fn validate_handle(handle: &str) -> Result<String> {
21489 mod tests {21590 use super::*;21691 217- 218- fn audience_is_the_prefixed_origin() {219- // Shoo derives client_id as "origin:" + origin, and stamps that into `aud`.220- let verifier = ShooVerifier::new("https://dev.site");221- assert_eq!(verifier.audience(), "origin:https://dev.site");222- // A trailing slash must not produce a different audience string.223- assert_eq!(224- ShooVerifier::new("https://dev.site/").audience(),225- "origin:https://dev.site"226- );227- }228-22992 23093 fn session_tokens_are_unpredictable_and_stored_hashed() {23194 let a = generate_session_token();@@ -8,6 +8,7 @@ use std::str::FromStr;
88 use anyhow::{Context, Result};99 use devsite_proto::{AccountId, ResourceId};1010 use rusqlite::{params, Connection, OptionalExtension};11+use sha2::{Digest, Sha256};1112 1213 use crate::policy::Visibility;1314 @@ -16,13 +17,21 @@ PRAGMA foreign_keys = ON;
1617 1718 CREATE TABLE IF NOT EXISTS accounts (1819 id TEXT PRIMARY KEY,19- -- Shoo's pairwise subject. Unique per (user, origin), so it is stable for us but20- -- cannot be correlated with the same user on another site.20+ -- Compatibility shadow for databases created before identities were namespaced.21+ -- New code resolves identities only through account_identities.2122 external_sub TEXT NOT NULL UNIQUE,2223 handle TEXT UNIQUE,2324 created_at INTEGER NOT NULL2425 );2526 27+CREATE TABLE IF NOT EXISTS account_identities (28+ namespace TEXT NOT NULL,29+ subject TEXT NOT NULL,30+ account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,31+ PRIMARY KEY (namespace, subject)32+);33+CREATE INDEX IF NOT EXISTS account_identities_account ON account_identities(account_id);34+2635 CREATE TABLE IF NOT EXISTS sessions (2736 -- Only a hash is stored: a leaked database does not yield usable session tokens.2837 token_hash TEXT PRIMARY KEY,@@ -138,6 +147,28 @@ const MIGRATIONS: &[&str] = &[
138147 ALTER TABLE machine_credentials ADD COLUMN enrolled_at INTEGER;139148 CREATE UNIQUE INDEX machine_credentials_endpoint140149 ON machine_credentials(endpoint_id) WHERE endpoint_id IS NOT NULL;",150+ // External subjects are meaningful only inside their provider namespace. Existing151+ // Shoo subjects retain their accounts; operator-created local identities lose the152+ // old `local:` storage prefix because the namespace now carries that distinction.153+ "CREATE TABLE IF NOT EXISTS account_identities (154+ namespace TEXT NOT NULL,155+ subject TEXT NOT NULL,156+ account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,157+ PRIMARY KEY (namespace, subject)158+ );159+ CREATE INDEX IF NOT EXISTS account_identities_account160+ ON account_identities(account_id);161+ INSERT OR IGNORE INTO account_identities (namespace, subject, account_id)162+ SELECT CASE163+ WHEN external_sub LIKE 'local:%' THEN 'local'164+ ELSE 'https://shoo.dev'165+ END,166+ CASE167+ WHEN external_sub LIKE 'local:%' THEN substr(external_sub, 7)168+ ELSE external_sub169+ END,170+ id171+ FROM accounts;",141172 ];142173 143174 fn migrate(conn: &Connection) -> Result<()> {@@ -301,24 +332,44 @@ impl Db {
301332 302333 // -- accounts ---------------------------------------------------------------303334 304- /// Find the account for an external subject, creating one on first sign-in.305- pub fn upsert_account(&self, external_sub: &str, now: u64) -> Result<Account> {306- if let Some(account) = self.account_by_external_sub(external_sub)? {335+ /// Find the account for a namespaced external identity, creating one on first sign-in.336+ pub fn upsert_account(&self, namespace: &str, subject: &str, now: u64) -> Result<Account> {337+ anyhow::ensure!(!namespace.is_empty(), "identity namespace cannot be empty");338+ anyhow::ensure!(!subject.is_empty(), "identity subject cannot be empty");339+ anyhow::ensure!(namespace.len() <= 2048, "identity namespace is too large");340+ anyhow::ensure!(subject.len() <= 2048, "identity subject is too large");341+ if let Some(account) = self.account_by_identity(namespace, subject)? {307342 return Ok(account);308343 }344+309345 let id = AccountId::generate();310- self.conn.execute(346+ // The old column remains so existing SQLite databases do not need a risky table347+ // rebuild. Preserve values an older Shoo-only binary understands; other provider348+ // identities get a collision-resistant compatibility shadow.349+ let shadow = legacy_identity_shadow(namespace, subject);350+ let transaction = self.conn.unchecked_transaction()?;351+ transaction.execute(311352 "INSERT INTO accounts (id, external_sub, handle, created_at) VALUES (?1, ?2, NULL, ?3)",312- params![id.to_string(), external_sub, now as i64],353+ params![id.to_string(), shadow, now as i64],354+ )?;355+ transaction.execute(356+ "INSERT INTO account_identities (namespace, subject, account_id)357+ VALUES (?1, ?2, ?3)",358+ params![namespace, subject, id.to_string()],313359 )?;360+ transaction.commit()?;314361 Ok(Account { id, handle: None })315362 }316363 317- pub fn account_by_external_sub(&self, external_sub: &str) -> Result<Option<Account>> {364+ pub fn account_by_identity(&self, namespace: &str, subject: &str) -> Result<Option<Account>> {318365 self.conn319366 .query_row(320- "SELECT id, handle FROM accounts WHERE external_sub = ?1",321- params![external_sub],367+ "SELECT accounts.id, accounts.handle368+ FROM account_identities369+ JOIN accounts ON accounts.id = account_identities.account_id370+ WHERE account_identities.namespace = ?1371+ AND account_identities.subject = ?2",372+ params![namespace, subject],322373 |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),323374 )324375 .optional()?@@ -1107,6 +1158,23 @@ impl Db {
11071158 }11081159 }11091160 1161+fn legacy_identity_shadow(namespace: &str, subject: &str) -> String {1162+ match namespace {1163+ "https://shoo.dev" => subject.to_string(),1164+ "local" => format!("local:{subject}"),1165+ _ => {1166+ let mut digest = Sha256::new();1167+ digest.update(namespace.as_bytes());1168+ digest.update([0]);1169+ digest.update(subject.as_bytes());1170+ format!(1171+ "identity:{}",1172+ data_encoding::HEXLOWER.encode(&digest.finalize())1173+ )1174+ }1175+ }1176+}1177+11101178 fn row_to_resource(row: &rusqlite::Row<'_>) -> rusqlite::Result<Result<Resource>> {11111179 let id: String = row.get(0)?;11121180 let owner: String = row.get(1)?;@@ -1135,8 +1203,8 @@ mod tests {
11351203 11361204 fn seeded() -> (Db, Account, Account) {11371205 let db = Db::open_in_memory().unwrap();1138- let alice = db.upsert_account("ps_alice", 0).unwrap();1139- let bob = db.upsert_account("ps_bob", 0).unwrap();1206+ let alice = db.upsert_account("test", "ps_alice", 0).unwrap();1207+ let bob = db.upsert_account("test", "ps_bob", 0).unwrap();11401208 db.set_handle(alice.id, "alice").unwrap();11411209 db.set_handle(bob.id, "bob").unwrap();11421210 (db, alice, bob)@@ -1186,21 +1254,73 @@ mod tests {
11861254 11871255 fn signing_in_twice_reuses_the_same_account() {11881256 let db = Db::open_in_memory().unwrap();1189- let first = db.upsert_account("ps_alice", 0).unwrap();1190- let second = db.upsert_account("ps_alice", 100).unwrap();1257+ let first = db.upsert_account("test", "ps_alice", 0).unwrap();1258+ let second = db.upsert_account("test", "ps_alice", 100).unwrap();11911259 assert_eq!(11921260 first.id, second.id,11931261 "a returning user must not get a new account"11941262 );11951263 }11961264 1265+ 1266+ fn the_same_subject_from_different_providers_is_not_the_same_account() {1267+ let db = Db::open_in_memory().unwrap();1268+ let first = db1269+ .upsert_account("https://one.example", "alice", 0)1270+ .unwrap();1271+ let second = db1272+ .upsert_account("https://two.example", "alice", 0)1273+ .unwrap();1274+ assert_ne!(first.id, second.id);1275+ }1276+1277+ 1278+ fn legacy_subjects_are_migrated_into_namespaces() {1279+ let connection = Connection::open_in_memory().unwrap();1280+ connection1281+ .execute_batch(1282+ "CREATE TABLE accounts (1283+ id TEXT PRIMARY KEY,1284+ external_sub TEXT NOT NULL UNIQUE,1285+ handle TEXT UNIQUE,1286+ created_at INTEGER NOT NULL1287+ );1288+ INSERT INTO accounts VALUES ('account-shoo', 'pairwise-alice', 'alice', 0);1289+ INSERT INTO accounts VALUES ('account-local', 'local:bob', 'bob', 0);",1290+ )1291+ .unwrap();1292+ connection1293+ .pragma_update(None, "user_version", (MIGRATIONS.len() - 1) as i64)1294+ .unwrap();1295+ migrate(&connection).unwrap();1296+1297+ let shoo: String = connection1298+ .query_row(1299+ "SELECT account_id FROM account_identities1300+ WHERE namespace = 'https://shoo.dev' AND subject = 'pairwise-alice'",1301+ [],1302+ |row| row.get(0),1303+ )1304+ .unwrap();1305+ let local: String = connection1306+ .query_row(1307+ "SELECT account_id FROM account_identities1308+ WHERE namespace = 'local' AND subject = 'bob'",1309+ [],1310+ |row| row.get(0),1311+ )1312+ .unwrap();1313+ assert_eq!(shoo, "account-shoo");1314+ assert_eq!(local, "account-local");1315+ }1316+11971317 11981318 fn re_sharing_replaces_the_list_rather_than_adding_to_it() {11991319 // The bug this exists to prevent: `service host --share @carol` reading as12001320 // "and Carol too". A share that can be granted and never revoked is not12011321 // a share, it is a one-way door.12021322 let (mut db, alice, bob) = seeded();1203- let carol = db.upsert_account("ps_carol", 0).unwrap();1323+ let carol = db.upsert_account("test", "ps_carol", 0).unwrap();12041324 db.set_handle(carol.id, "carol").unwrap();12051325 12061326 let agent = db@@ -7,6 +7,7 @@ mod api;
77 mod auth;88 mod db;99 mod issuer;10+mod oidc;1011 mod policy;1112 mod theme;1213 @@ -23,14 +24,14 @@ use tower_http::set_header::SetResponseHeaderLayer;
2324 use tower_http::trace::TraceLayer;2425 2526 use crate::api::{AppState, RateLimits};26-use crate::auth::ShooVerifier;2727 use crate::db::Db;2828 use crate::issuer::Issuer;29+use crate::oidc::OidcConfig;2930 3031 struct Config {3132 bind: String,32- /// The exact origin browsers load, e.g. `https://dev.site`. Shoo derives its client id33- /// and pairwise subjects from this, so changing it changes every account identity.33+ /// The exact origin browsers load, e.g. `https://dev.site`. It forms OAuth callback34+ /// URLs and the capability issuer name, so it must match the externally visible site.3435 public_origin: String,3536 database: String,3637 state_dir: PathBuf,@@ -102,11 +103,12 @@ async fn main() -> Result<()> {
102103 103104 let db = Db::open(&config.database)?;104105 let issuer = config.issuer()?;105- let shoo = ShooVerifier::new(&config.public_origin);106+ let oidc = OidcConfig::from_env(&config.public_origin)?;106107 107108 tracing::info!(108109 origin = %config.public_origin,109- audience = %shoo.audience(),110+ identity_namespace = %oidc.issuer(),111+ oidc_client_id = %oidc.client_id(),110112 "capability signing key {}",111113 issuer.public_key_hex()112114 );@@ -115,22 +117,21 @@ async fn main() -> Result<()> {
115117 db: Mutex::new(db),116118 rate_limits: Mutex::new(RateLimits::default()),117119 issuer,118- shoo,120+ identity_namespace: oidc.issuer().to_string(),119121 public_origin: config.public_origin.clone(),120122 });121123 122124 let index = config.web_root.join("index.html");123125 let app = api::router(Arc::clone(&state))126+ .merge(oidc::router(Arc::clone(&state), oidc)?)124127 // Nothing from the API is ever cacheable: profiles depend on who is asking, and a125128 // cached capability would be a reusable grant sitting in a shared cache.126129 .layer(SetResponseHeaderLayer::overriding(127130 header::CACHE_CONTROL,128131 HeaderValue::from_static("no-store, private"),129132 ))130- // `/@handle` and `/auth/callback` are client-side routes; serve the shell and let131- // the page work out what to render.133+ // `/@handle` is a client-side route; serve the shell and let the page render it.132134 .route("/@{handle}", get(serve_index(index.clone())))133- .route("/auth/callback", get(serve_index(index.clone())))134135 .fallback_service(static_assets(&config.web_root, &index))135136 // Fly also compresses at its edge today, but the origin should have the same136137 // contract when it is run directly or moved behind a different proxy.@@ -189,9 +190,8 @@ fn serve_index(path: PathBuf) -> impl Fn() -> std::future::Ready<Response> + Clo
189190 190191 /// Mint a session for `handle`, creating the account if it does not exist.191192 ///192-/// The account is keyed on a `local:` subject so it can never collide with a Shoo pairwise193-/// subject — signing in through Shoo will always produce a separate account rather than194-/// silently adopting one made here.193+/// New operator-created accounts use the `local` identity namespace, so they cannot collide194+/// with an external provider that happens to issue the same subject.195195 fn issue_session(config: &Config, handle: &str) -> Result<()> {196196 let db = Db::open(&config.database)?;197197 let handle = crate::auth::validate_handle(handle)?;@@ -199,11 +199,11 @@ fn issue_session(config: &Config, handle: &str) -> Result<()> {
199199 200200 // Attach to the existing account when the handle is taken. Creating a second one would201201 // collide on the unique handle, and the useful operator action here is "give me a202- // session for @alice" — including when @alice signed in through Shoo.202+ // session for @alice" — including when @alice signed in through an external provider.203203 let account = match db.account_by_handle(&handle)? {204204 Some(existing) => existing,205205 None => {206- let created = db.upsert_account(&format!("local:{handle}"), now)?;206+ let created = db.upsert_account("local", &handle, now)?;207207 db.set_handle(created.id, &handle)?;208208 created209209 }@@ -0,0 +1,584 @@
1+//! OIDC authorization-code + PKCE login adapter.2+//!3+//! Shoo is the zero-configuration default, not an application dependency. Another public4+//! OIDC client can be selected entirely with `DEVSITE_OIDC_*` settings. This module ends5+//! at `ExternalIdentity`; account and session policy remain in the application port.6+7+use std::collections::HashMap;8+use std::str::FromStr;9+use std::sync::{Arc, Mutex, RwLock};10+use std::time::{Duration, Instant};11+12+use anyhow::{bail, Context, Result};13+use axum::extract::{Query, State};14+use axum::http::{header, HeaderMap, HeaderValue, StatusCode};15+use axum::response::{Html, IntoResponse, Redirect, Response};16+use axum::routing::get;17+use axum::Router;18+use jsonwebtoken::jwk::{AlgorithmParameters, Jwk, JwkSet, KeyOperations, PublicKeyUse};19+use jsonwebtoken::{Algorithm, DecodingKey, Validation};20+use serde::Deserialize;21+use sha2::{Digest, Sha256};22+use url::Url;23+24+use crate::api::{self, Shared};25+use crate::auth::{self, ExternalIdentity};26+27+const SHOO_ISSUER: &str = "https://shoo.dev";28+const LOGIN_ATTEMPT_TTL_SECS: u64 = 10 * 60;29+const MAX_PENDING_LOGINS: usize = 4096;30+const JWKS_TTL: Duration = Duration::from_secs(10 * 60);31+32+pub struct OidcConfig {33+ issuer: String,34+ authorization_endpoint: Url,35+ token_endpoint: Url,36+ jwks_uri: Url,37+ client_id: String,38+ scopes: String,39+ algorithms: Vec<Algorithm>,40+ callback_uri: String,41+}42+43+impl OidcConfig {44+ pub fn from_env(public_origin: &str) -> Result<Self> {45+ let issuer = env_or("DEVSITE_OIDC_ISSUER", SHOO_ISSUER);46+ let authorization_endpoint = endpoint_from_env(47+ "DEVSITE_OIDC_AUTHORIZATION_ENDPOINT",48+ &format!("{}/authorize", issuer.trim_end_matches('/')),49+ )?;50+ let token_endpoint = endpoint_from_env(51+ "DEVSITE_OIDC_TOKEN_ENDPOINT",52+ &format!("{}/token", issuer.trim_end_matches('/')),53+ )?;54+ let jwks_uri = endpoint_from_env(55+ "DEVSITE_OIDC_JWKS_URI",56+ &format!("{}/.well-known/jwks.json", issuer.trim_end_matches('/')),57+ )?;58+ let client_id = std::env::var("DEVSITE_OIDC_CLIENT_ID")59+ .ok()60+ .filter(|value| !value.trim().is_empty())61+ .unwrap_or_else(|| format!("origin:{public_origin}"));62+ let scopes = env_or("DEVSITE_OIDC_SCOPES", "openid");63+ if !scopes.split_whitespace().any(|scope| scope == "openid") {64+ bail!("DEVSITE_OIDC_SCOPES must include openid");65+ }66+ // Keep Shoo's existing ES256 pin as the safe default. A different provider must67+ // explicitly name its asymmetric signing algorithm.68+ let algorithms = parse_algorithms(&env_or("DEVSITE_OIDC_ALGORITHMS", "ES256"))?;69+70+ let issuer_url = Url::parse(&issuer).context("DEVSITE_OIDC_ISSUER is not a URL")?;71+ validate_provider_url("DEVSITE_OIDC_ISSUER", &issuer_url)?;72+73+ Ok(Self {74+ issuer,75+ authorization_endpoint,76+ token_endpoint,77+ jwks_uri,78+ client_id,79+ scopes,80+ algorithms,81+ callback_uri: format!("{}/auth/callback", public_origin.trim_end_matches('/')),82+ })83+ }84+85+ pub fn issuer(&self) -> &str {86+ &self.issuer87+ }88+89+ pub fn client_id(&self) -> &str {90+ &self.client_id91+ }92+}93+94+fn env_or(name: &str, default: &str) -> String {95+ std::env::var(name)96+ .ok()97+ .filter(|value| !value.trim().is_empty())98+ .unwrap_or_else(|| default.to_string())99+}100+101+fn endpoint_from_env(name: &str, default: &str) -> Result<Url> {102+ let value = env_or(name, default);103+ let url = Url::parse(&value).with_context(|| format!("{name} is not a URL"))?;104+ validate_provider_url(name, &url)?;105+ Ok(url)106+}107+108+fn validate_provider_url(name: &str, url: &Url) -> Result<()> {109+ let loopback = url.host_str().is_some_and(|host| {110+ host == "localhost"111+ || host112+ .parse::<std::net::IpAddr>()113+ .is_ok_and(|ip| ip.is_loopback())114+ });115+ if url.scheme() != "https" && !(url.scheme() == "http" && loopback) {116+ bail!("{name} must use HTTPS (HTTP is allowed only for loopback development)");117+ }118+ Ok(())119+}120+121+fn parse_algorithms(value: &str) -> Result<Vec<Algorithm>> {122+ let mut algorithms = Vec::new();123+ for name in value124+ .split(',')125+ .map(str::trim)126+ .filter(|name| !name.is_empty())127+ {128+ let algorithm = Algorithm::from_str(name)129+ .with_context(|| format!("unsupported OIDC signing algorithm {name}"))?;130+ if matches!(131+ algorithm,132+ Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512133+ ) {134+ bail!("symmetric OIDC signing algorithm {name} is not accepted");135+ }136+ if !algorithms.contains(&algorithm) {137+ algorithms.push(algorithm);138+ }139+ }140+ if algorithms.is_empty() {141+ bail!("DEVSITE_OIDC_ALGORITHMS must name at least one asymmetric algorithm");142+ }143+ Ok(algorithms)144+}145+146+struct PendingLogin {147+ verifier: String,148+ return_to: String,149+ expires_at: u64,150+}151+152+struct OidcAdapter {153+ config: OidcConfig,154+ http: reqwest::Client,155+ keys: RwLock<Option<(JwkSet, Instant)>>,156+}157+158+impl OidcAdapter {159+ fn new(config: OidcConfig) -> Result<Self> {160+ Ok(Self {161+ config,162+ http: reqwest::Client::builder()163+ .connect_timeout(Duration::from_secs(5))164+ .timeout(Duration::from_secs(15))165+ .build()166+ .context("building OIDC HTTP client")?,167+ keys: RwLock::new(None),168+ })169+ }170+171+ fn authorization_url(&self, state: &str, challenge: &str) -> Url {172+ let mut url = self.config.authorization_endpoint.clone();173+ url.query_pairs_mut()174+ .append_pair("response_type", "code")175+ .append_pair("client_id", &self.config.client_id)176+ .append_pair("redirect_uri", &self.config.callback_uri)177+ .append_pair("scope", &self.config.scopes)178+ .append_pair("state", state)179+ .append_pair("code_challenge", challenge)180+ .append_pair("code_challenge_method", "S256");181+ url182+ }183+184+ async fn authenticate(&self, code: &str, verifier: &str) -> Result<ExternalIdentity> {185+ let form = {186+ let mut serializer = url::form_urlencoded::Serializer::new(String::new());187+ serializer188+ .append_pair("grant_type", "authorization_code")189+ .append_pair("code", code)190+ .append_pair("redirect_uri", &self.config.callback_uri)191+ .append_pair("client_id", &self.config.client_id)192+ .append_pair("code_verifier", verifier);193+ serializer.finish()194+ };195+196+ let response = self197+ .http198+ .post(self.config.token_endpoint.clone())199+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")200+ .body(form)201+ .send()202+ .await203+ .context("exchanging the OIDC authorization code")?204+ .error_for_status()205+ .context("the OIDC token endpoint rejected the authorization code")?206+ .json::<TokenResponse>()207+ .await208+ .context("parsing the OIDC token response")?;209+210+ self.verify(&response.id_token).await211+ }212+213+ async fn keys(&self) -> Result<JwkSet> {214+ if let Some((keys, fetched)) = self.keys.read().unwrap().as_ref() {215+ if fetched.elapsed() < JWKS_TTL {216+ return Ok(keys.clone());217+ }218+ }219+220+ let keys = self221+ .http222+ .get(self.config.jwks_uri.clone())223+ .send()224+ .await225+ .context("fetching OIDC JWKS")?226+ .error_for_status()227+ .context("OIDC JWKS endpoint returned an error status")?228+ .json::<JwkSet>()229+ .await230+ .context("parsing OIDC JWKS")?;231+ *self.keys.write().unwrap() = Some((keys.clone(), Instant::now()));232+ Ok(keys)233+ }234+235+ async fn verify(&self, id_token: &str) -> Result<ExternalIdentity> {236+ let token_header =237+ jsonwebtoken::decode_header(id_token).context("unreadable token header")?;238+ if !self.config.algorithms.contains(&token_header.alg) {239+ bail!("unexpected token algorithm {:?}", token_header.alg);240+ }241+ let kid = token_header.kid.context("token has no key id")?;242+ let keys = self.keys().await?;243+ let jwk = keys244+ .find(&kid)245+ .with_context(|| format!("no OIDC key matches kid {kid}"))?;246+ validate_jwk(jwk, token_header.alg)?;247+ let key = DecodingKey::from_jwk(jwk).context("OIDC key is not usable")?;248+249+ let mut validation = Validation::new(token_header.alg);250+ validation.set_issuer(&[&self.config.issuer]);251+ validation.set_audience(&[&self.config.client_id]);252+ validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]);253+ let data = jsonwebtoken::decode::<IdTokenClaims>(id_token, &key, &validation)254+ .context("id_token failed verification")?;255+256+ // Keep the two identity-defining checks explicit so a future Validation change257+ // cannot silently widen which identities this adapter accepts.258+ if data.claims.iss != self.config.issuer {259+ bail!("unexpected token issuer {}", data.claims.iss);260+ }261+ if !data.claims.aud.contains(&self.config.client_id) {262+ bail!("token was issued for a different client");263+ }264+ if data.claims.sub.is_empty() {265+ bail!("token subject is empty");266+ }267+ if data.claims.sub.len() > 2048 {268+ bail!("token subject is unreasonably large");269+ }270+271+ Ok(ExternalIdentity {272+ namespace: data.claims.iss,273+ subject: data.claims.sub,274+ })275+ }276+}277+278+fn validate_jwk(jwk: &Jwk, algorithm: Algorithm) -> Result<()> {279+ if jwk280+ .common281+ .public_key_use282+ .as_ref()283+ .is_some_and(|key_use| key_use != &PublicKeyUse::Signature)284+ {285+ bail!("OIDC key is not marked for signatures");286+ }287+ if jwk288+ .common289+ .key_operations290+ .as_ref()291+ .is_some_and(|operations| !operations.contains(&KeyOperations::Verify))292+ {293+ bail!("OIDC key is not permitted to verify signatures");294+ }295+ if matches!(jwk.algorithm, AlgorithmParameters::OctetKey(_)) {296+ bail!("symmetric OIDC keys are not accepted");297+ }298+ if jwk299+ .common300+ .key_algorithm301+ .is_some_and(|declared| declared.to_string() != format!("{algorithm:?}"))302+ {303+ bail!("OIDC key algorithm does not match the token header");304+ }305+ Ok(())306+}307+308+309+struct TokenResponse {310+ id_token: String,311+}312+313+314+struct IdTokenClaims {315+ sub: String,316+ iss: String,317+ aud: Audience,318+ // Its presence keeps expiry validation required even though the application does not319+ // otherwise use it directly.320+ 321+ exp: u64,322+}323+324+325+326+enum Audience {327+ One(String),328+ Many(Vec<String>),329+}330+331+impl Audience {332+ fn contains(&self, expected: &str) -> bool {333+ match self {334+ Self::One(value) => value == expected,335+ Self::Many(values) => values.iter().any(|value| value == expected),336+ }337+ }338+}339+340+struct OidcState {341+ app: Shared,342+ adapter: OidcAdapter,343+ pending: Mutex<HashMap<String, PendingLogin>>,344+}345+346+pub fn router(app: Shared, config: OidcConfig) -> Result<Router> {347+ let state = Arc::new(OidcState {348+ app,349+ adapter: OidcAdapter::new(config)?,350+ pending: Mutex::new(HashMap::new()),351+ });352+ Ok(Router::new()353+ .route("/auth/start", get(start))354+ .route("/auth/callback", get(callback))355+ .with_state(state))356+}357+358+359+struct StartQuery {360+ return_to: Option<String>,361+}362+363+async fn start(State(state): State<Arc<OidcState>>, Query(query): Query<StartQuery>) -> Response {364+ let now = api::now_secs();365+ let verifier = auth::generate_session_token();366+ let challenge = data_encoding::BASE64URL_NOPAD.encode(&Sha256::digest(verifier.as_bytes()));367+ let login_state = auth::generate_session_token();368+ let return_to = safe_return_to(query.return_to.as_deref());369+370+ {371+ let mut pending = state.pending.lock().unwrap();372+ pending.retain(|_, attempt| attempt.expires_at > now);373+ if pending.len() >= MAX_PENDING_LOGINS {374+ if let Some(oldest) = pending375+ .iter()376+ .min_by_key(|(_, attempt)| attempt.expires_at)377+ .map(|(key, _)| key.clone())378+ {379+ pending.remove(&oldest);380+ }381+ }382+ pending.insert(383+ login_state.clone(),384+ PendingLogin {385+ verifier,386+ return_to,387+ expires_at: now + LOGIN_ATTEMPT_TTL_SECS,388+ },389+ );390+ }391+392+ let mut response = Redirect::temporary(393+ state394+ .adapter395+ .authorization_url(&login_state, &challenge)396+ .as_str(),397+ )398+ .into_response();399+ match HeaderValue::from_str(&login_state_cookie(&state.app, &login_state, false)) {400+ Ok(cookie) => {401+ response.headers_mut().append(header::SET_COOKIE, cookie);402+ response403+ }404+ Err(err) => {405+ tracing::error!("could not create OIDC state cookie: {err}");406+ login_failed(StatusCode::INTERNAL_SERVER_ERROR)407+ }408+ }409+}410+411+412+struct CallbackQuery {413+ code: Option<String>,414+ state: Option<String>,415+ error: Option<String>,416+}417+418+async fn callback(419+ State(state): State<Arc<OidcState>>,420+ headers: HeaderMap,421+ Query(query): Query<CallbackQuery>,422+) -> Response {423+ let mut response = complete_callback(&state, &headers, query).await;424+ match HeaderValue::from_str(&login_state_cookie(&state.app, "", true)) {425+ Ok(cookie) => {426+ response.headers_mut().append(header::SET_COOKIE, cookie);427+ response428+ }429+ Err(err) => {430+ tracing::error!("could not clear OIDC state cookie: {err}");431+ login_failed(StatusCode::INTERNAL_SERVER_ERROR)432+ }433+ }434+}435+436+async fn complete_callback(437+ state: &OidcState,438+ headers: &HeaderMap,439+ query: CallbackQuery,440+) -> Response {441+ let Some(login_state) = query.state.as_deref() else {442+ return login_failed(StatusCode::BAD_REQUEST);443+ };444+ // State in the callback URL is visible to the browser that began the login. Requiring445+ // the same value in an HttpOnly cookie prevents an attacker from forwarding their own446+ // completed callback URL and logging another browser into the attacker's account.447+ if login_state_cookie_value(headers) != Some(login_state) {448+ return login_failed(StatusCode::BAD_REQUEST);449+ }450+ let attempt = {451+ let mut pending = state.pending.lock().unwrap();452+ pending.remove(login_state)453+ };454+ let Some(attempt) = attempt else {455+ return login_failed(StatusCode::BAD_REQUEST);456+ };457+ if attempt.expires_at <= api::now_secs() || query.error.is_some() {458+ return login_failed(StatusCode::UNAUTHORIZED);459+ }460+ let Some(code) = query.code.as_deref() else {461+ return login_failed(StatusCode::BAD_REQUEST);462+ };463+464+ let identity = match state.adapter.authenticate(code, &attempt.verifier).await {465+ Ok(identity) => identity,466+ Err(err) => {467+ tracing::warn!("OIDC sign-in failed: {err:#}");468+ return login_failed(StatusCode::UNAUTHORIZED);469+ }470+ };471+ let session = match api::establish_browser_session(&state.app, identity) {472+ Ok(session) => session,473+ Err(_) => return login_failed(StatusCode::INTERNAL_SERVER_ERROR),474+ };475+476+ let target = if session.handle.is_some() {477+ attempt.return_to478+ } else {479+ "/".to_string()480+ };481+ let mut response = Redirect::to(&target).into_response();482+ match HeaderValue::from_str(&api::browser_session_cookie(&state.app, &session.token)) {483+ Ok(cookie) => {484+ response.headers_mut().append(header::SET_COOKIE, cookie);485+ response486+ }487+ Err(err) => {488+ tracing::error!("could not create browser session cookie: {err}");489+ login_failed(StatusCode::INTERNAL_SERVER_ERROR)490+ }491+ }492+}493+494+fn login_state_cookie(app: &api::AppState, value: &str, clear: bool) -> String {495+ format!(496+ "devsite_login_state={value}; Path=/auth/callback; HttpOnly; SameSite=Lax; Max-Age={}{}",497+ if clear { 0 } else { LOGIN_ATTEMPT_TTL_SECS },498+ if app.public_origin.starts_with("https://") {499+ "; Secure"500+ } else {501+ ""502+ }503+ )504+}505+506+fn login_state_cookie_value(headers: &HeaderMap) -> Option<&str> {507+ headers508+ .get(header::COOKIE)?509+ .to_str()510+ .ok()?511+ .split(';')512+ .filter_map(|part| part.trim().split_once('='))513+ .find(|(name, _)| *name == "devsite_login_state")514+ .map(|(_, value)| value)515+}516+517+fn safe_return_to(value: Option<&str>) -> String {518+ value519+ .filter(|value| {520+ value.starts_with('/')521+ && !value.starts_with("//")522+ && value.bytes().all(|byte| byte.is_ascii_graphic())523+ && !value.contains('\\')524+ && !value.starts_with("/auth/")525+ })526+ .unwrap_or("/")527+ .to_string()528+}529+530+fn login_failed(status: StatusCode) -> Response {531+ (532+ status,533+ Html(534+ "<!doctype html><meta charset=utf-8><title>Sign-in failed</title>\535+ <main><h1>Sign-in failed.</h1><p>The login could not be verified.</p>\536+ <p><a href=/auth/start>Try again</a> · <a href=/>Return home</a></p></main>",537+ ),538+ )539+ .into_response()540+}541+542+543+mod tests {544+ use super::*;545+546+ 547+ fn return_paths_cannot_escape_the_site_or_reenter_the_callback() {548+ assert_eq!(safe_return_to(Some("/@alice")), "/@alice");549+ for unsafe_path in [550+ "https://evil.example",551+ "//evil.example",552+ "/auth/callback",553+ "/x\r\n",554+ "/x\0",555+ "/snowman-☃",556+ ] {557+ assert_eq!(safe_return_to(Some(unsafe_path)), "/");558+ }559+ }560+561+ 562+ fn audiences_support_both_oidc_encodings() {563+ assert!(Audience::One("client".into()).contains("client"));564+ assert!(Audience::Many(vec!["other".into(), "client".into()]).contains("client"));565+ assert!(!Audience::Many(vec!["other".into()]).contains("client"));566+ }567+568+ 569+ fn symmetric_provider_algorithms_are_rejected() {570+ assert_eq!(parse_algorithms("RS256, ES256").unwrap().len(), 2);571+ assert!(parse_algorithms("HS256").is_err());572+ assert!(parse_algorithms("").is_err());573+ }574+575+ 576+ fn callback_state_is_read_only_from_its_bound_cookie() {577+ let mut headers = HeaderMap::new();578+ headers.insert(579+ header::COOKIE,580+ HeaderValue::from_static("other=x; devsite_login_state=state-123"),581+ );582+ assert_eq!(login_state_cookie_value(&headers), Some("state-123"));583+ }584+}@@ -8,11 +8,13 @@ and nothing here wants a second writer. Since the heartbeat went away the databa
88 read-mostly — a few hundred bytes per account, and a handful of writes per user per99 lifetime — so this shape has a lot of room in it.1010 11-## The two things that cannot be undone11+## The durable identity boundaries1212 13-**`DEVSITE_PUBLIC_ORIGIN`.** Shoo derives `client_id` and every user's pairwise subject14-from it. Changing it after anyone has signed in does not migrate accounts, it orphans them.15-It lives in `fly.toml` and must match the hostname browsers actually load.13+**OIDC issuer and subjects.** Accounts are keyed by the verified `(issuer, subject)` pair.14+Shoo is the default adapter and derives its default `client_id` and pairwise subjects from15+`DEVSITE_PUBLIC_ORIGIN`, so changing that origin after anyone has signed in still orphans16+those identities. Another public OIDC client can be selected with the `DEVSITE_OIDC_*`17+settings below without changing the account or session code.1618 1719 **The capability signing key.** Every daemon pinned its public half at `devsite login` and1820 refuses capabilities signed by anything else. Lose it and every daemon in the world stops@@ -96,10 +98,19 @@ is the part that cannot be regenerated.
9698 ## Configuration9799 98100 Set in `fly.toml`, except the secret.101+See [Authentication providers](../docs/auth-providers.md) for replacing Shoo or implementing a102+non-OIDC adapter.99103 100104 | Variable | Where | Notes |101105 | --- | --- | --- |102106 | `DEVSITE_PUBLIC_ORIGIN` | `fly.toml` | `https://dev.site`. See above. |107+| `DEVSITE_OIDC_ISSUER` | optional | Token issuer; defaults to `https://shoo.dev`. |108+| `DEVSITE_OIDC_AUTHORIZATION_ENDPOINT` | optional | Defaults to `<issuer>/authorize`. |109+| `DEVSITE_OIDC_TOKEN_ENDPOINT` | optional | Defaults to `<issuer>/token`. |110+| `DEVSITE_OIDC_JWKS_URI` | optional | Defaults to `<issuer>/.well-known/jwks.json`. |111+| `DEVSITE_OIDC_CLIENT_ID` | optional | Public client id; defaults to `origin:<public origin>`. |112+| `DEVSITE_OIDC_SCOPES` | optional | Space-separated; defaults to `openid` and must include it. |113+| `DEVSITE_OIDC_ALGORITHMS` | optional | Allowed asymmetric JWT algorithms; defaults to Shoo's `ES256`. |103114 | `DEVSITE_SIGNING_KEY` | `fly secrets` | Hex. Never in the repository. |104115 | `DEVSITE_BIND` | `Dockerfile` | `0.0.0.0:8080`, matching `internal_port`. |105116 | `DEVSITE_DB` | `Dockerfile` | `/data/devsite.db`, on the volume. |@@ -0,0 +1,235 @@
1+# Authentication providers2+3+dev.site has one authentication boundary: a provider proves an external identity, then the4+application turns that identity into its own account and browser session. Shoo is the default5+OIDC configuration, not part of the account, sharing, CLI, or capability model.6+7+```text8+provider-specific login9+ │ verify credentials, callback, issuer and subject10+ ▼11+ExternalIdentity { namespace, subject }12+ │ establish_browser_session13+ ▼14+dev.site AccountId + opaque HttpOnly session15+```16+17+The stable types and functions are:18+19+- `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.22+23+The application port deliberately is not an OAuth-shaped trait. An OIDC callback, a passkey,24+a signed proxy header, and a local enterprise SSO flow have different HTTP exchanges. Each25+inbound adapter owns that exchange and ends by producing the same small `ExternalIdentity`.26+27+## Use another OIDC provider28+29+The 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 the32+application port. No provider token reaches browser JavaScript.33+34+The replacement provider must support:35+36+- 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.40+41+Client-secret authentication is intentionally not implemented. Configure a public client, or42+add secret handling to the adapter without exposing the secret through `/api/config` or the web43+application.44+45+Register this exact callback at the provider:46+47+```text48+<DEVSITE_PUBLIC_ORIGIN>/auth/callback49+```50+51+Then configure the adapter. Discovery is not automatic; endpoints are explicit so startup does52+not silently trust metadata from somewhere other than the configured issuer.53+54+| 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` |63+64+For example:65+66+```bash67+export DEVSITE_PUBLIC_ORIGIN=https://devsite.example.com68+export DEVSITE_OIDC_ISSUER=https://identity.example.com/realms/developers69+export DEVSITE_OIDC_AUTHORIZATION_ENDPOINT=https://identity.example.com/realms/developers/protocol/openid-connect/auth70+export DEVSITE_OIDC_TOKEN_ENDPOINT=https://identity.example.com/realms/developers/protocol/openid-connect/token71+export DEVSITE_OIDC_JWKS_URI=https://identity.example.com/realms/developers/protocol/openid-connect/certs72+export DEVSITE_OIDC_CLIENT_ID=devsite73+export DEVSITE_OIDC_SCOPES='openid profile email'74+export DEVSITE_OIDC_ALGORITHMS=RS25675+76+cargo run -p devsite-server77+```78+79+Provider URLs must use HTTPS. HTTP is accepted only for a loopback provider during local80+development. `openid` must remain in the scope list, symmetric `HS*` algorithms are rejected,81+and every accepted token must match the configured issuer and client ID exactly.82+83+To smoke-test initiation without completing a login:84+85+```bash86+curl -i 'http://127.0.0.1:4000/auth/start?return_to=%2F'87+```88+89+Expect a `307` response whose `Location` points at the configured authorization endpoint and a90+callback-scoped `devsite_login_state` cookie. Complete a browser login before considering the91+provider verified; that exercises code exchange, JWKS retrieval, claims validation, account92+lookup, and session creation together.93+94+### Identity continuity95+96+An account is keyed by the exact `(namespace, subject)` pair. For OIDC these values are the97+verified `iss` and `sub` claims. Changing the issuer string, client configuration in a provider98+that issues pairwise subjects, or the subject itself creates a different dev.site account.99+100+Never substitute email, handle, display name, or another mutable claim for `sub`. Never label a101+new provider with the old provider's namespace merely to preserve accounts: that would let the102+new provider impersonate every old identity. Continuity across unrelated providers requires an103+explicit, authenticated account-linking or operator migration design.104+105+Existing databases are migrated as follows:106+107+- old Shoo subjects become `("https://shoo.dev", <old subject>)`;108+- operator-issued identities become `("local", <handle>)`.109+110+## Implement a non-OIDC provider111+112+Use `crates/devsite-server/src/oidc.rs` as the reference inbound adapter, but keep only the113+security mechanisms appropriate to the new protocol. The implementation path is:114+115+1. Add a module under `crates/devsite-server/src/`, for example `passkey.rs`.116+2. Give it the provider-owned start, challenge, and callback routes.117+3. Verify the provider proof completely inside that module.118+4. Construct `ExternalIdentity` only after verification succeeds.119+5. Call `api::establish_browser_session` with that identity.120+6. Put the returned token into `api::browser_session_cookie` and redirect to a local path.121+7. Wire the adapter router in `main.rs`, replacing `oidc::router(...)`.122+8. Keep `/auth/start` as the browser entrypoint so `web/app.js` remains provider-neutral.123+124+A callback ends approximately like this:125+126+```rust,ignore127+use axum::http::{header, HeaderValue, StatusCode};128+use axum::response::{IntoResponse, Redirect, Response};129+130+use crate::api::{self, Shared};131+use crate::auth::ExternalIdentity;132+133+fn 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+ };143+144+ let session = match api::establish_browser_session(app, identity) {145+ Ok(session) => session,146+ Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),147+ };148+149+ 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+```161+162+The namespace must be hard-coded or trusted configuration, globally unambiguous, and stable for163+the lifetime of the installation. The subject must be a stable, provider-assigned identifier and164+is limited to 2048 bytes. The database automatically creates or reuses the corresponding account;165+a new provider namespace does not require another schema migration.166+167+### Wiring168+169+The current OIDC wiring in `main.rs` does three things that a replacement must preserve:170+171+1. constructs and validates adapter configuration before listening;172+2. exposes the active identity namespace in `AppState.identity_namespace` and `/api/config`;173+3. merges the adapter routes into the Axum application.174+175+The corresponding `main.rs` shape is:176+177+```rust,ignore178+mod passkey;179+180+let login = passkey::Config::from_env(&config.public_origin)?;181+let identity_namespace = login.namespace().to_string();182+183+let state = Arc::new(AppState {184+ // Existing database, rate limiter, capability issuer, and public origin fields...185+ identity_namespace,186+});187+188+let app = api::router(Arc::clone(&state))189+ .merge(passkey::router(Arc::clone(&state), login)?);190+```191+192+For one replacement provider, change the module and router construction in one place. The current193+UI advertises one `auth.start_url`, so supporting several providers simultaneously additionally194+requires a provider list in `/api/config` and a provider picker in `web/app.js`. The account table195+already supports multiple namespaces.196+197+### Security contract for a new adapter198+199+Before handing an identity to the application port, an adapter must:200+201+- 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.211+212+Provider logout is separate from dev.site logout. `DELETE /api/auth/session` always revokes the213+local session. An adapter may add upstream logout, but local revocation must succeed even when the214+provider is unavailable.215+216+### Tests to add217+218+At minimum, cover:219+220+- 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.227+228+Run the repository checks before deploying:229+230+```bash231+cargo test --workspace232+cargo clippy --workspace --all-targets -- -D warnings233+cargo build --release --locked -p devsite-server234+node --check web/app.js235+```@@ -16,10 +16,9 @@ primary_region = "sjc"
1616 dockerfile = "Dockerfile"1717 1818 [env]19- # The one irreversible setting. Shoo derives client_id and every user's20- # pairwise subject from this origin, so changing it after anyone has signed in21- # orphans their account rather than moving it. It must match the hostname22- # browsers actually load, certificate and all.19+ # This forms OAuth callbacks and the capability issuer. Shoo also derives its20+ # default client id and pairwise subjects from it, so changing it after anyone21+ # has signed in is disruptive. It must match the hostname browsers load.2322 DEVSITE_PUBLIC_ORIGIN = "https://dev.site"2423 RUST_LOG = "devsite_server=info,tower_http=warn,warn"2524 @@ -9,7 +9,6 @@
99 // against the elements below. Keep it semantic, keep it documented, and change1010 // it in step with docs/profile-template.md.1111 12-const SHOO = 'https://shoo.dev';1312 const $ = (id) => document.getElementById(id);1413 const main = $('main');1514 const pageFooter = $('page-footer');@@ -39,81 +38,11 @@ async function api(path, options = {}) {
3938 return response.status === 204 ? null : response.json();4039 }4140 42-// -- Shoo sign-in (authorization code + PKCE, run entirely in the browser) ------41+// -- provider-neutral sign-in --------------------------------------------------4342 44-const b64url = (bytes) =>45- btoa(String.fromCharCode(...new Uint8Array(bytes)))46- .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');47-48-function randomVerifier() {49- const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';50- const bytes = crypto.getRandomValues(new Uint8Array(64));51- return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join('');52-}53-54-async function startSignIn() {55- const verifier = randomVerifier();56- const state = b64url(crypto.getRandomValues(new Uint8Array(16)));57- const challenge = b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)));58-59- // The verifier never leaves the browser; only its hash goes to Shoo.60- sessionStorage.setItem('pkce_verifier', verifier);61- sessionStorage.setItem('pkce_state', state);62- sessionStorage.setItem('return_to', location.pathname);63-64- const redirectUri = `${location.origin}/auth/callback`;65- const url = new URL('/authorize', SHOO);66- url.searchParams.set('response_type', 'code');67- // Shoo derives the client id from the redirect origin; sending it explicitly keeps the68- // value we verify `aud` against and the value Shoo registers identical.69- url.searchParams.set('client_id', `origin:${location.origin}`);70- url.searchParams.set('redirect_uri', redirectUri);71- url.searchParams.set('scope', 'openid');72- url.searchParams.set('state', state);73- url.searchParams.set('code_challenge', challenge);74- url.searchParams.set('code_challenge_method', 'S256');75- location.assign(url.toString());76-}77-78-async function completeSignIn() {79- const params = new URLSearchParams(location.search);80- const code = params.get('code');81- const state = params.get('state');82- const verifier = sessionStorage.getItem('pkce_verifier');83- const expectedState = sessionStorage.getItem('pkce_state');84- const returnTo = sessionStorage.getItem('return_to') || '/';85-86- sessionStorage.removeItem('pkce_verifier');87- sessionStorage.removeItem('pkce_state');88-89- if (params.get('error')) throw new Error(params.get('error_description') || params.get('error'));90- if (!code || !verifier) throw new Error('sign-in did not complete');91- // Guards against an attacker pasting their own authorization code into your session.92- if (!state || state !== expectedState) throw new Error('sign-in state did not match');93-94- const body = new URLSearchParams({95- grant_type: 'authorization_code',96- code,97- redirect_uri: `${location.origin}/auth/callback`,98- client_id: `origin:${location.origin}`,99- code_verifier: verifier,100- });101- const tokenResponse = await fetch(`${SHOO}/token`, {102- method: 'POST',103- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },104- body,105- });106- if (!tokenResponse.ok) throw new Error(`token exchange failed (${tokenResponse.status})`);107- const { id_token } = await tokenResponse.json();108- if (!id_token) throw new Error('no id_token was returned');109-110- // The control plane verifies this independently against Shoo's JWKS and mints its own111- // session. Nothing after this point trusts the id_token again.112- const session = await api('/api/auth/session', {113- method: 'POST',114- body: JSON.stringify({ id_token }),115- });116- return { session, returnTo };43+function startSignIn() {44+ const returnTo = `${location.pathname}${location.search}`;45+ location.assign(`/auth/start?return_to=${encodeURIComponent(returnTo)}`);11746 }11847 11948 // -- themes --------------------------------------------------------------------@@ -798,16 +727,6 @@ async function signOut(button) {
798727 return;799728 }800729 801- try {802- if (window.Shoo?.clearIdentity) await window.Shoo.clearIdentity();803- } catch (err) {804- // The dev.site session is already gone; a Shoo cleanup failure must not805- // leave the dashboard looking authenticated.806- console.warn('Shoo identity cleanup failed', err);807- }808- sessionStorage.removeItem('pkce_verifier');809- sessionStorage.removeItem('pkce_state');810- sessionStorage.removeItem('return_to');811730 me = null;812731 history.replaceState({}, '', '/');813732 clearTheme();@@ -968,30 +887,6 @@ async function route() {
968887 const path = location.pathname;969888 setPageTitle();970889 971- if (path === '/auth/callback') {972- main.innerHTML = '<article aria-busy="true">Signing you in…</article>';973- try {974- const { session, returnTo } = await completeSignIn();975- me = { account_id: session.account_id, handle: session.handle };976- history.replaceState({}, '', '/');977- renderSession();978- if (!session.handle) {979- renderClaimHandle();980- } else {981- await showDashboard();982- }983- void returnTo;984- } catch (err) {985- main.innerHTML = `986- <article>987- <hgroup><h1>Sign-in failed.</h1><p class="error">${esc(err.message)}</p></hgroup>988- <button id="retry">Try again</button>989- </article>`;990- $('retry').addEventListener('click', startSignIn);991- }992- return;993- }994-995890 try {996891 me = await api('/api/me');997892 } catch {