clone

one developer profile for links and private tcp services, reached peer to peer over iroh with no open ports

refactor: decouple authentication providers

Dami committed eab3415
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>
11 files changed, +1043 -336
MDockerfile2+-
MREADME.md16+-
Mcrates/devsite-server/src/api.rs66+--
Mcrates/devsite-server/src/auth.rs159+--------
Mcrates/devsite-server/src/db.rs150+++++++-
Mcrates/devsite-server/src/main.rs28+-
Acrates/devsite-server/src/oidc.rs584++++++++++++++++++++++++++++++
Mdeploy/README.md19+-
Adocs/auth-providers.md235++++++++++++
Mfly.toml7+-
Mweb/app.js113+------
M Dockerfile +1 -1
M README.md +13 -3
@@ -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
M crates/devsite-server/src/api.rs +28 -38
@@ -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-#[derive(Deserialize)]223-pub struct SignInRequest {224- pub id_token: String,225-}226-227-#[derive(Serialize)]228-pub struct SignInResponse {229- pub account_id: String,230- pub handle: Option<String>,231-}232-233222 #[derive(Deserialize)]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> {
M crates/devsite-server/src/auth.rs +11 -148
@@ -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-#[derive(Debug, Deserialize)]31-#[allow(dead_code)]32-pub struct IdTokenClaims {33- pub sub: String,34- pub iss: String,35- pub aud: String,36- pub exp: u64,37- #[serde(default)]38- pub email: Option<String>,39- #[serde(default)]40- pub name: Option<String>,41-}42-43-#[derive(Debug, Deserialize, Clone)]44-struct Jwk {45- kid: String,46- x: String,47- y: String,48- #[serde(default)]49- alg: Option<String>,50-}51-52-#[derive(Debug, Deserialize)]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+#[derive(Debug, Clone, PartialEq, Eq)]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- #[test]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 #[test]23093 fn session_tokens_are_unpredictable_and_stored_hashed() {23194 let a = generate_session_token();
M crates/devsite-server/src/db.rs +135 -15
@@ -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 #[test]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+ #[test]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+ #[test]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 #[test]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
M crates/devsite-server/src/main.rs +14 -14
@@ -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 }
A crates/devsite-server/src/oidc.rs +584 -0
@@ -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+#[derive(Deserialize)]309+struct TokenResponse {310+ id_token: String,311+}312+313+#[derive(Debug, Deserialize)]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+ #[allow(dead_code)]321+ exp: u64,322+}323+324+#[derive(Debug, Deserialize)]325+#[serde(untagged)]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+#[derive(Deserialize)]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+#[derive(Deserialize)]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+#[cfg(test)]543+mod tests {544+ use super::*;545+546+ #[test]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+ #[test]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+ #[test]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+ #[test]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+}
M deploy/README.md +15 -4
@@ -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. |
A docs/auth-providers.md +235 -0
@@ -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+```
M fly.toml +3 -4
M web/app.js +4 -109
@@ -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 {