diff --git a/src/oidc.ts b/src/oidc.ts index 78e5006..6d8dde5 100644 --- a/src/oidc.ts +++ b/src/oidc.ts @@ -28,7 +28,7 @@ async function generateAndStoreKey(): Promise { const privateKeyPem = await exportKeyToPem(privateKey); const publicKeyPem = await exportKeyToPem(publicKey); - const kid = `indiko-oidc-key-${Date.now()}`; + const kid = `indiko-oidc-key-${crypto.randomUUID()}`; db.query( "INSERT INTO oidc_keys (kid, private_key, public_key, is_active) VALUES (?, ?, ?, 1)", @@ -111,15 +111,28 @@ interface IDTokenClaims { aud: string; nonce?: string; auth_time?: number; + at_hash?: string; name?: string; email?: string; picture?: string; website?: string; } +// OIDC Core §3.1.3.6: at_hash is the base64url of the left half of the +// SHA-256 hash of the access token. +async function computeAtHash(accessToken: string): Promise { + const hash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(accessToken), + ); + const half = new Uint8Array(hash).slice(0, 16); + return Buffer.from(half).toString("base64url"); +} + export async function signIDToken( issuer: string, claims: IDTokenClaims, + accessToken?: string, ): Promise { const key = await getActiveKey(); const privateKey = await importPKCS8(key.private_key, "RS256"); @@ -127,8 +140,13 @@ export async function signIDToken( const now = Math.floor(Date.now() / 1000); const expiresIn = 3600; // 1 hour + const allClaims = { ...claims }; + if (accessToken) { + allClaims.at_hash = await computeAtHash(accessToken); + } + const builder = new SignJWT({ - ...claims, + ...allClaims, iss: issuer, iat: now, exp: now + expiresIn, diff --git a/src/routes/oauth/token.ts b/src/routes/oauth/token.ts index 96570f9..640f6e1 100644 --- a/src/routes/oauth/token.ts +++ b/src/routes/oauth/token.ts @@ -121,13 +121,33 @@ async function handleRefreshTokenGrant( } // Rotate: issue a new row in the same family, mark this one rotated. + // The UPDATE must be atomic (WHERE rotated = 0) — otherwise two concurrent + // refreshes both win and reuse detection is defeated. const newAccessToken = generateToken(); const expiresAt = now + ACCESS_TOKEN_TTL; const newRefreshToken = generateToken(); const refreshExpiresAt = now + REFRESH_TOKEN_TTL; const family = tokenData.family ?? crypto.randomUUID(); - db.query("UPDATE tokens SET rotated = 1 WHERE id = ?").run(tokenData.id); + const rotateResult = db + .query("UPDATE tokens SET rotated = 1 WHERE id = ? AND rotated = 0") + .run(tokenData.id); + + if (rotateResult.changes === 0) { + // Someone else rotated first — this is a replay of a stale token. + // Revoke the whole family per RFC 9700 §4.14.2. + if (tokenData.family) { + db.query("UPDATE tokens SET revoked = 1 WHERE family = ?").run( + tokenData.family, + ); + } else { + db.query("UPDATE tokens SET revoked = 1 WHERE id = ?").run(tokenData.id); + } + console.warn( + `[token] refresh token race lost — family ${tokenData.family ?? tokenData.id} revoked`, + ); + return oauthError(400, "invalid_grant", "Refresh token was already used"); + } db.query( "INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at, family) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", @@ -183,9 +203,19 @@ async function handleDeviceCodeGrant( ); } - let clientId: string | undefined; + // RFC 8628 §3.4: client_id is required for public clients polling the + // token endpoint. Without it the device_code isn't bound to its client. + if (!rawClientId) { + return oauthError( + 400, + "invalid_request", + "client_id parameter is required", + ); + } + + let clientId: string; try { - clientId = rawClientId ? canonicalizeURL(rawClientId) : undefined; + clientId = canonicalizeURL(rawClientId); } catch { return oauthError(400, "invalid_request", "Invalid client_id URL format"); } @@ -217,10 +247,17 @@ async function handleDeviceCodeGrant( return oauthError(400, "expired_token", "The device_code has expired"); } - if (clientId && deviceCode.client_id !== clientId) { + if (deviceCode.client_id !== clientId) { return oauthError(400, "invalid_grant", "client_id mismatch"); } + // Pre-registered (confidential) clients must authenticate with their + // client_secret, same as the authorization_code grant. + const credentialError = verifyClientCredentials(clientId, body.client_secret); + if (credentialError) { + return credentialError; + } + // Rate limiting: enforce minimum poll interval (RFC 8628 §3.5) if (deviceCode.last_polled_at) { const elapsed = now - deviceCode.last_polled_at; @@ -568,18 +605,17 @@ async function handleAuthorizationCodeGrant( response.refresh_token = refreshToken; } - if (refreshToken) { - response.refresh_token = refreshToken; - } - if (permission?.role) { response.role = permission.role; } // Generate OIDC id_token if openid scope is requested if (scopes.includes("openid")) { + // sub must be stable and unique per OIDC Core §8 — use the canonical + // profile URL, not the user-mutable website URL. + const stableSub = `${origin}/u/${user.username}`; const idTokenClaims: Record = { - sub: meValue, + sub: stableSub, aud: client_id, }; @@ -613,6 +649,7 @@ async function handleAuthorizationCodeGrant( picture?: string; website?: string; }, + accessToken, ); }