From 3cfb253e1c52919995551633fe564eacb3e62f66 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 20:09:06 +0900 Subject: [PATCH 01/12] fix(oidc): verify production identities against provider JWKS --- server/oidc.mjs | 657 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 657 insertions(+) create mode 100644 server/oidc.mjs diff --git a/server/oidc.mjs b/server/oidc.mjs new file mode 100644 index 00000000..d5764dca --- /dev/null +++ b/server/oidc.mjs @@ -0,0 +1,657 @@ +import { + createPublicKey, + timingSafeEqual, + verify as verifySignature, +} from 'node:crypto'; + +const RAW_ISSUER = String(process.env.OIDC_ISSUER || '').trim(); +const CLIENT_ID = String(process.env.OIDC_CLIENT_ID || '').trim(); +const CLIENT_SECRET = String(process.env.OIDC_CLIENT_SECRET || '').trim(); +const REDIRECT_URI = String(process.env.OIDC_REDIRECT_URI || '').trim(); +const REQUEST_TIMEOUT_MS = 30_000; +const MAX_PROVIDER_RESPONSE_BYTES = 1024 * 1024; +const MAX_ID_TOKEN_BYTES = 128 * 1024; +const CLOCK_SKEW_SECONDS = 60; +const CACHE_TTL_MS = 5 * 60 * 1000; + +export const oidcMock = process.env.SCOPEWEAVE_DEV === '1' && !RAW_ISSUER; + +/** Stable, operator-safe failure raised by the OpenID Connect trust boundary. */ +export class OidcConfigurationError extends Error { + /** + * Create one OIDC error. + * @param {string} code machine-readable failure code + * @param {string} message operator-safe detail + * @param {number} statusCode HTTP status suitable for the relying-party API + */ + constructor(code, message, statusCode = 400) { + super(message); + this.name = 'OidcConfigurationError'; + this.code = code; + this.statusCode = statusCode; + } +} + +/** + * Return whether an HTTP endpoint is permitted for OIDC transport. + * @param {URL} url parsed endpoint + * @returns {boolean} + */ +function isSecureEndpoint(url) { + return url.protocol === 'https:' + || ( + url.protocol === 'http:' + && ['localhost', '127.0.0.1', '::1'].includes(url.hostname) + ); +} + +/** + * Parse and validate one OIDC URL. + * @param {string} value candidate URL + * @param {string} code failure-code prefix + * @param {{allowQuery?: boolean}} options URL policy + * @returns {URL} + */ +function validatedUrl(value, code, { allowQuery = false } = {}) { + let url; + try { + url = new URL(value); + } catch { + throw new OidcConfigurationError( + `${code}_invalid`, + `${code} must be a valid absolute URL.`, + 503, + ); + } + if ( + !isSecureEndpoint(url) + || url.username + || url.password + || url.hash + || (!allowQuery && url.search) + ) { + throw new OidcConfigurationError( + `${code}_invalid`, + `${code} violates the OIDC transport or URL policy.`, + 503, + ); + } + return url; +} + +/** + * Resolve explicit development mode or complete production relying-party configuration. + * @returns {{mock: true} | {mock: false, issuer: string, clientId: string, clientSecret: string, redirectUri: string}} + */ +function oidcConfiguration() { + if (oidcMock) return { mock: true }; + if (!RAW_ISSUER) { + throw new OidcConfigurationError( + 'oidc_not_configured', + 'OpenID Connect is unavailable because OIDC_ISSUER is not configured.', + 503, + ); + } + if (!CLIENT_ID || !CLIENT_SECRET || !REDIRECT_URI) { + throw new OidcConfigurationError( + 'oidc_configuration_incomplete', + 'OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_REDIRECT_URI are required.', + 503, + ); + } + const issuerUrl = validatedUrl(RAW_ISSUER, 'oidc_issuer'); + const redirectUrl = validatedUrl(REDIRECT_URI, 'oidc_redirect_uri', { + allowQuery: true, + }); + const issuer = issuerUrl.toString().replace(/\/$/, ''); + return { + mock: false, + issuer, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + redirectUri: redirectUrl.toString(), + }; +} + +/** + * Validate one bounded opaque protocol value. + * @param {unknown} value candidate value + * @param {string} field field name + * @param {number} minimumLength minimum accepted length + * @param {number} maximumLength maximum accepted length + * @returns {string} + */ +function boundedProtocolValue(value, field, minimumLength, maximumLength) { + if ( + typeof value !== 'string' + || value.length < minimumLength + || value.length > maximumLength + || !/^[A-Za-z0-9._~-]+$/.test(value) + ) { + throw new OidcConfigurationError( + `oidc_${field}_invalid`, + `OIDC ${field} is outside the accepted boundary.`, + ); + } + return value; +} + +/** + * Fetch one bounded JSON object from the provider. + * @param {string} url provider endpoint + * @param {RequestInit} init request options + * @param {string} failurePrefix failure-code prefix + * @returns {Promise>} + */ +async function fetchJson(url, init, failurePrefix) { + if (typeof globalThis.fetch !== 'function') { + throw new OidcConfigurationError( + `${failurePrefix}_transport_unavailable`, + 'OIDC HTTP transport is unavailable.', + 503, + ); + } + let response; + try { + response = await globalThis.fetch(url, { + ...init, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch { + throw new OidcConfigurationError( + `${failurePrefix}_unavailable`, + 'The OpenID Provider could not be reached.', + 502, + ); + } + let bytes; + try { + bytes = Buffer.from(await response.arrayBuffer()); + } catch { + throw new OidcConfigurationError( + `${failurePrefix}_response_invalid`, + 'The OpenID Provider response could not be read.', + 502, + ); + } + if (bytes.length === 0 || bytes.length > MAX_PROVIDER_RESPONSE_BYTES) { + throw new OidcConfigurationError( + `${failurePrefix}_response_size_invalid`, + 'The OpenID Provider response size is outside the accepted boundary.', + 502, + ); + } + let payload; + try { + payload = JSON.parse(bytes.toString('utf8')); + } catch { + throw new OidcConfigurationError( + `${failurePrefix}_response_invalid`, + 'The OpenID Provider returned non-JSON data.', + 502, + ); + } + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new OidcConfigurationError( + `${failurePrefix}_response_invalid`, + 'The OpenID Provider returned an invalid JSON object.', + 502, + ); + } + if (!response.ok) { + throw new OidcConfigurationError( + `${failurePrefix}_rejected`, + `The OpenID Provider rejected the request with HTTP ${response.status}.`, + 502, + ); + } + return payload; +} + +let discoveryCache = null; +let jwksCache = null; + +/** + * Validate provider metadata and bind it to the configured issuer. + * @param {Record} metadata discovery document + * @param {string} issuer configured issuer + * @returns {{issuer: string, authorizationEndpoint: string, tokenEndpoint: string, jwksUri: string, tokenAuthMethods: string[]}} + */ +function validatedDiscovery(metadata, issuer) { + if (metadata.issuer !== issuer) { + throw new OidcConfigurationError( + 'oidc_discovery_issuer_mismatch', + 'OIDC discovery issuer does not exactly match OIDC_ISSUER.', + 502, + ); + } + const authorizationEndpoint = validatedUrl( + String(metadata.authorization_endpoint || ''), + 'oidc_authorization_endpoint', + { allowQuery: true }, + ).toString(); + const tokenEndpoint = validatedUrl( + String(metadata.token_endpoint || ''), + 'oidc_token_endpoint', + { allowQuery: true }, + ).toString(); + const jwksUri = validatedUrl( + String(metadata.jwks_uri || ''), + 'oidc_jwks_uri', + { allowQuery: true }, + ).toString(); + const signingAlgorithms = metadata.id_token_signing_alg_values_supported; + if ( + Array.isArray(signingAlgorithms) + && !signingAlgorithms.includes('RS256') + ) { + throw new OidcConfigurationError( + 'oidc_rs256_unsupported', + 'The OpenID Provider does not advertise RS256 ID Token signing.', + 502, + ); + } + const tokenAuthMethods = Array.isArray( + metadata.token_endpoint_auth_methods_supported, + ) + ? metadata.token_endpoint_auth_methods_supported.filter( + (method) => typeof method === 'string', + ) + : ['client_secret_basic']; + if ( + !tokenAuthMethods.includes('client_secret_basic') + && !tokenAuthMethods.includes('client_secret_post') + ) { + throw new OidcConfigurationError( + 'oidc_token_auth_unsupported', + 'The OpenID Provider supports no configured client-secret authentication method.', + 502, + ); + } + return { + issuer, + authorizationEndpoint, + tokenEndpoint, + jwksUri, + tokenAuthMethods, + }; +} + +/** + * Load and cache exact issuer discovery metadata. + * @param {ReturnType} configuration production configuration + * @returns {Promise>} + */ +async function providerDiscovery(configuration) { + const now = Date.now(); + if ( + discoveryCache + && discoveryCache.issuer === configuration.issuer + && discoveryCache.expiresAt > now + ) { + return discoveryCache.value; + } + const metadata = await fetchJson( + `${configuration.issuer}/.well-known/openid-configuration`, + { headers: { accept: 'application/json' } }, + 'oidc_discovery', + ); + const value = validatedDiscovery(metadata, configuration.issuer); + discoveryCache = { + issuer: configuration.issuer, + expiresAt: now + CACHE_TTL_MS, + value, + }; + return value; +} + +/** + * Return a provider authorization URL bound to state, nonce, and S256 PKCE. + * @param {{state: string, nonce: string, codeChallenge: string}} request authorization request values + * @returns {Promise<{url: string, redirectUri: string}>} + */ +export async function authorizationUrl({ state, nonce, codeChallenge }) { + const configuration = oidcConfiguration(); + if (configuration.mock) { + throw new OidcConfigurationError( + 'oidc_development_route_required', + 'Development OIDC must use the local explicit mock route.', + 500, + ); + } + const safeState = boundedProtocolValue(state, 'state', 32, 256); + const safeNonce = boundedProtocolValue(nonce, 'nonce', 32, 256); + const safeChallenge = boundedProtocolValue( + codeChallenge, + 'code_challenge', + 43, + 128, + ); + const discovery = await providerDiscovery(configuration); + const url = new URL(discovery.authorizationEndpoint); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', 'openid email profile'); + url.searchParams.set('client_id', configuration.clientId); + url.searchParams.set('redirect_uri', configuration.redirectUri); + url.searchParams.set('state', safeState); + url.searchParams.set('nonce', safeNonce); + url.searchParams.set('code_challenge', safeChallenge); + url.searchParams.set('code_challenge_method', 'S256'); + return { url: url.toString(), redirectUri: configuration.redirectUri }; +} + +/** + * Decode one base64url JSON JWT segment. + * @param {string} segment compact JWT segment + * @param {string} label segment label + * @returns {Record} + */ +function decodeJwtObject(segment, label) { + if (!/^[A-Za-z0-9_-]+$/.test(segment)) { + throw new OidcConfigurationError( + 'oidc_id_token_invalid', + `ID Token ${label} is not valid base64url.`, + ); + } + let payload; + try { + payload = JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')); + } catch { + throw new OidcConfigurationError( + 'oidc_id_token_invalid', + `ID Token ${label} is not valid JSON.`, + ); + } + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new OidcConfigurationError( + 'oidc_id_token_invalid', + `ID Token ${label} is not an object.`, + ); + } + return payload; +} + +/** + * Compare bounded protocol strings without exposing early length-based timing. + * @param {unknown} actual actual claim + * @param {string} expected expected claim + * @returns {boolean} + */ +function constantTimeStringEqual(actual, expected) { + if (typeof actual !== 'string') return false; + const actualBytes = Buffer.from(actual); + const expectedBytes = Buffer.from(expected); + if (actualBytes.length !== expectedBytes.length) return false; + return timingSafeEqual(actualBytes, expectedBytes); +} + +/** + * Validate an RS256 ID Token and required identity claims. + * @param {{idToken: string, jwks: Record, issuer: string, clientId: string, nonce: string, nowSeconds?: number}} input verification input + * @returns {{email: string, subject: string, claims: Record}} + */ +export function verifyIdToken({ + idToken, + jwks, + issuer, + clientId, + nonce, + nowSeconds = Math.floor(Date.now() / 1000), +}) { + if ( + typeof idToken !== 'string' + || idToken.length === 0 + || Buffer.byteLength(idToken) > MAX_ID_TOKEN_BYTES + ) { + throw new OidcConfigurationError( + 'oidc_id_token_invalid', + 'ID Token is missing or outside the accepted size boundary.', + ); + } + const parts = idToken.split('.'); + if (parts.length !== 3 || parts.some((part) => !part)) { + throw new OidcConfigurationError( + 'oidc_id_token_invalid', + 'ID Token must use compact JWS serialization.', + ); + } + const [encodedHeader, encodedClaims, encodedSignature] = parts; + const header = decodeJwtObject(encodedHeader, 'header'); + const claims = decodeJwtObject(encodedClaims, 'claims'); + if (header.alg !== 'RS256' || typeof header.kid !== 'string' || !header.kid) { + throw new OidcConfigurationError( + 'oidc_id_token_algorithm_invalid', + 'ID Token must use an identified RS256 signing key.', + ); + } + const keys = Array.isArray(jwks?.keys) ? jwks.keys : []; + const key = keys.find( + (candidate) => candidate + && typeof candidate === 'object' + && candidate.kid === header.kid + && candidate.kty === 'RSA' + && (candidate.use == null || candidate.use === 'sig') + && (candidate.alg == null || candidate.alg === 'RS256'), + ); + if (!key) { + throw new OidcConfigurationError( + 'oidc_signing_key_not_found', + 'No trusted RS256 signing key matches the ID Token.', + ); + } + let publicKey; + try { + publicKey = createPublicKey({ key, format: 'jwk' }); + } catch { + throw new OidcConfigurationError( + 'oidc_signing_key_invalid', + 'The provider signing key is invalid.', + 502, + ); + } + let signature; + try { + signature = Buffer.from(encodedSignature, 'base64url'); + } catch { + throw new OidcConfigurationError( + 'oidc_id_token_invalid', + 'ID Token signature is not valid base64url.', + ); + } + const validSignature = verifySignature( + 'RSA-SHA256', + Buffer.from(`${encodedHeader}.${encodedClaims}`), + publicKey, + signature, + ); + if (!validSignature) { + throw new OidcConfigurationError( + 'oidc_id_token_signature_invalid', + 'ID Token signature verification failed.', + ); + } + if (claims.iss !== issuer) { + throw new OidcConfigurationError( + 'oidc_id_token_issuer_invalid', + 'ID Token issuer does not match the discovered issuer.', + ); + } + const audiences = typeof claims.aud === 'string' + ? [claims.aud] + : Array.isArray(claims.aud) + ? claims.aud.filter((audience) => typeof audience === 'string') + : []; + if (!audiences.includes(clientId)) { + throw new OidcConfigurationError( + 'oidc_id_token_audience_invalid', + 'ID Token audience does not include this client.', + ); + } + if (audiences.length > 1 && claims.azp !== clientId) { + throw new OidcConfigurationError( + 'oidc_id_token_authorized_party_invalid', + 'ID Token authorized party does not identify this client.', + ); + } + if ( + !Number.isSafeInteger(claims.exp) + || claims.exp <= nowSeconds - CLOCK_SKEW_SECONDS + ) { + throw new OidcConfigurationError( + 'oidc_id_token_expired', + 'ID Token is expired or missing a valid expiration.', + ); + } + if ( + !Number.isSafeInteger(claims.iat) + || claims.iat > nowSeconds + CLOCK_SKEW_SECONDS + ) { + throw new OidcConfigurationError( + 'oidc_id_token_issued_at_invalid', + 'ID Token issued-at time is missing or in the future.', + ); + } + if ( + claims.nbf != null + && (!Number.isSafeInteger(claims.nbf) || claims.nbf > nowSeconds + CLOCK_SKEW_SECONDS) + ) { + throw new OidcConfigurationError( + 'oidc_id_token_not_before_invalid', + 'ID Token is not yet valid.', + ); + } + if (!constantTimeStringEqual(claims.nonce, nonce)) { + throw new OidcConfigurationError( + 'oidc_id_token_nonce_invalid', + 'ID Token nonce does not match the authorization request.', + ); + } + if ( + typeof claims.sub !== 'string' + || claims.sub.length === 0 + || claims.sub.length > 255 + ) { + throw new OidcConfigurationError( + 'oidc_id_token_subject_invalid', + 'ID Token subject is missing or invalid.', + ); + } + if ( + typeof claims.email !== 'string' + || claims.email.length > 320 + || !/^[^\s@]+@[^\s@]+$/.test(claims.email) + || claims.email_verified !== true + ) { + throw new OidcConfigurationError( + 'oidc_id_token_email_invalid', + 'ID Token must contain a verified email address.', + ); + } + return { + email: claims.email.toLowerCase(), + subject: claims.sub, + claims, + }; +} + +/** + * Load provider signing keys with a bounded cache. + * @param {string} jwksUri provider JWKS endpoint + * @returns {Promise>} + */ +async function providerJwks(jwksUri) { + const now = Date.now(); + if (jwksCache && jwksCache.uri === jwksUri && jwksCache.expiresAt > now) { + return jwksCache.value; + } + const value = await fetchJson( + jwksUri, + { headers: { accept: 'application/json' } }, + 'oidc_jwks', + ); + if (!Array.isArray(value.keys) || value.keys.length === 0 || value.keys.length > 100) { + throw new OidcConfigurationError( + 'oidc_jwks_invalid', + 'The OpenID Provider returned no bounded signing-key set.', + 502, + ); + } + jwksCache = { uri: jwksUri, expiresAt: now + CACHE_TTL_MS, value }; + return value; +} + +/** + * Exchange an authorization code and verify the returned ID Token. + * @param {{code: string, codeVerifier: string, nonce: string, redirectUri: string, nowSeconds?: number}} request callback values + * @returns {Promise<{email: string, subject: string, claims: Record}>} + */ +export async function exchangeAuthorizationCode({ + code, + codeVerifier, + nonce, + redirectUri, + nowSeconds = Math.floor(Date.now() / 1000), +}) { + const configuration = oidcConfiguration(); + if (configuration.mock) { + throw new OidcConfigurationError( + 'oidc_development_route_required', + 'Development OIDC must use the local explicit mock route.', + 500, + ); + } + const safeCode = boundedProtocolValue(code, 'authorization_code', 1, 4096); + const safeVerifier = boundedProtocolValue( + codeVerifier, + 'code_verifier', + 43, + 128, + ); + const safeNonce = boundedProtocolValue(nonce, 'nonce', 32, 256); + if (redirectUri !== configuration.redirectUri) { + throw new OidcConfigurationError( + 'oidc_redirect_uri_mismatch', + 'OIDC callback redirect URI does not match the registered URI.', + ); + } + const discovery = await providerDiscovery(configuration); + const form = new URLSearchParams({ + grant_type: 'authorization_code', + code: safeCode, + redirect_uri: configuration.redirectUri, + client_id: configuration.clientId, + code_verifier: safeVerifier, + }); + const headers = { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded', + }; + if (discovery.tokenAuthMethods.includes('client_secret_basic')) { + headers.authorization = `Basic ${Buffer.from( + `${encodeURIComponent(configuration.clientId)}:${encodeURIComponent(configuration.clientSecret)}`, + ).toString('base64')}`; + } else { + form.set('client_secret', configuration.clientSecret); + } + const tokenResponse = await fetchJson( + discovery.tokenEndpoint, + { method: 'POST', headers, body: form.toString() }, + 'oidc_token', + ); + if (typeof tokenResponse.id_token !== 'string') { + throw new OidcConfigurationError( + 'oidc_id_token_missing', + 'The OpenID Provider returned no ID Token.', + 502, + ); + } + const jwks = await providerJwks(discovery.jwksUri); + return verifyIdToken({ + idToken: tokenResponse.id_token, + jwks, + issuer: discovery.issuer, + clientId: configuration.clientId, + nonce: safeNonce, + nowSeconds, + }); +} From c939e7c106ad17b4c86d022e104eff08edfa3d72 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 20:10:19 +0900 Subject: [PATCH 02/12] test(oidc): reject unsigned, tampered, replayed, and misbound identities --- tests/unit/oidc.test.mjs | 311 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 tests/unit/oidc.test.mjs diff --git a/tests/unit/oidc.test.mjs b/tests/unit/oidc.test.mjs new file mode 100644 index 00000000..0da37b39 --- /dev/null +++ b/tests/unit/oidc.test.mjs @@ -0,0 +1,311 @@ +import assert from 'node:assert/strict'; +import { + generateKeyPairSync, + sign as signBytes, +} from 'node:crypto'; + +const ORIGINAL_ENV = { ...process.env }; +const ORIGINAL_FETCH = globalThis.fetch; +const ISSUER = 'https://identity.example/tenant'; +const CLIENT_ID = 'scopeweave-client'; +const CLIENT_SECRET = 'scopeweave-client-secret'; +const REDIRECT_URI = 'https://scopeweave.example/api/auth/oidc/callback'; +const NOW_SECONDS = 1_786_291_200; +const STATE = 'state_abcdefghijklmnopqrstuvwxyz0123456789'; +const NONCE = 'nonce_abcdefghijklmnopqrstuvwxyz0123456789'; +const CHALLENGE = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG'; +const VERIFIER = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-._~'; + +const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, +}); +const PUBLIC_JWK = { + ...publicKey.export({ format: 'jwk' }), + kid: 'scopeweave-test-key', + use: 'sig', + alg: 'RS256', +}; + +function restoreEnvironment() { + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) delete process.env[key]; + } + Object.assign(process.env, ORIGINAL_ENV); + globalThis.fetch = ORIGINAL_FETCH; +} + +function encodeJson(value) { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +function signedIdToken({ claims = {}, header = {}, signingKey = privateKey } = {}) { + const encodedHeader = encodeJson({ + alg: 'RS256', + typ: 'JWT', + kid: PUBLIC_JWK.kid, + ...header, + }); + const encodedClaims = encodeJson({ + iss: ISSUER, + sub: 'subject-42', + aud: CLIENT_ID, + exp: NOW_SECONDS + 600, + iat: NOW_SECONDS - 10, + nonce: NONCE, + email: 'Owner@Example.com', + email_verified: true, + ...claims, + }); + const signingInput = `${encodedHeader}.${encodedClaims}`; + const signature = signBytes( + 'RSA-SHA256', + Buffer.from(signingInput), + signingKey, + ).toString('base64url'); + return `${signingInput}.${signature}`; +} + +async function freshModule(label) { + return import(`../../server/oidc.mjs?test=${label}-${Date.now()}-${Math.random()}`); +} + +function clearOidcEnvironment() { + delete process.env.OIDC_ISSUER; + delete process.env.OIDC_CLIENT_ID; + delete process.env.OIDC_CLIENT_SECRET; + delete process.env.OIDC_REDIRECT_URI; + delete process.env.SCOPEWEAVE_DEV; +} + +function configureProduction() { + process.env.OIDC_ISSUER = ISSUER; + process.env.OIDC_CLIENT_ID = CLIENT_ID; + process.env.OIDC_CLIENT_SECRET = CLIENT_SECRET; + process.env.OIDC_REDIRECT_URI = REDIRECT_URI; + delete process.env.SCOPEWEAVE_DEV; +} + +try { + clearOidcEnvironment(); + const unconfigured = await freshModule('unconfigured'); + assert.equal(unconfigured.oidcMock, false); + await assert.rejects( + unconfigured.authorizationUrl({ + state: STATE, + nonce: NONCE, + codeChallenge: CHALLENGE, + }), + (error) => error.code === 'oidc_not_configured' && error.statusCode === 503, + ); + + process.env.SCOPEWEAVE_DEV = '1'; + const development = await freshModule('development'); + assert.equal(development.oidcMock, true); + await assert.rejects( + development.authorizationUrl({ + state: STATE, + nonce: NONCE, + codeChallenge: CHALLENGE, + }), + (error) => error.code === 'oidc_development_route_required', + ); + + configureProduction(); + delete process.env.OIDC_CLIENT_SECRET; + const incomplete = await freshModule('incomplete'); + await assert.rejects( + incomplete.authorizationUrl({ + state: STATE, + nonce: NONCE, + codeChallenge: CHALLENGE, + }), + (error) => error.code === 'oidc_configuration_incomplete', + ); + + configureProduction(); + process.env.OIDC_ISSUER = 'http://identity.example/tenant'; + const insecure = await freshModule('insecure'); + await assert.rejects( + insecure.authorizationUrl({ + state: STATE, + nonce: NONCE, + codeChallenge: CHALLENGE, + }), + (error) => error.code === 'oidc_issuer_invalid', + ); + + configureProduction(); + const configured = await freshModule('configured'); + let currentToken = signedIdToken(); + const calls = []; + globalThis.fetch = async (url, init = {}) => { + calls.push({ url, init }); + if (url === `${ISSUER}/.well-known/openid-configuration`) { + return new Response(JSON.stringify({ + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/authorize`, + token_endpoint: `${ISSUER}/token`, + jwks_uri: `${ISSUER}/jwks`, + id_token_signing_alg_values_supported: ['RS256'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + if (url === `${ISSUER}/token`) { + return new Response(JSON.stringify({ id_token: currentToken }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + if (url === `${ISSUER}/jwks`) { + return new Response(JSON.stringify({ keys: [PUBLIC_JWK] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + throw new Error(`unexpected URL: ${url}`); + }; + + const authorization = await configured.authorizationUrl({ + state: STATE, + nonce: NONCE, + codeChallenge: CHALLENGE, + }); + const authorizationLocation = new URL(authorization.url); + assert.equal(authorization.redirectUri, REDIRECT_URI); + assert.equal(authorizationLocation.origin + authorizationLocation.pathname, `${ISSUER}/authorize`); + assert.equal(authorizationLocation.searchParams.get('response_type'), 'code'); + assert.equal(authorizationLocation.searchParams.get('scope'), 'openid email profile'); + assert.equal(authorizationLocation.searchParams.get('client_id'), CLIENT_ID); + assert.equal(authorizationLocation.searchParams.get('redirect_uri'), REDIRECT_URI); + assert.equal(authorizationLocation.searchParams.get('state'), STATE); + assert.equal(authorizationLocation.searchParams.get('nonce'), NONCE); + assert.equal(authorizationLocation.searchParams.get('code_challenge'), CHALLENGE); + assert.equal(authorizationLocation.searchParams.get('code_challenge_method'), 'S256'); + + const identity = await configured.exchangeAuthorizationCode({ + code: 'authorization-code-1', + codeVerifier: VERIFIER, + nonce: NONCE, + redirectUri: REDIRECT_URI, + nowSeconds: NOW_SECONDS, + }); + assert.equal(identity.email, 'owner@example.com'); + assert.equal(identity.subject, 'subject-42'); + const tokenCall = calls.find((call) => call.url === `${ISSUER}/token`); + assert.equal(tokenCall.init.method, 'POST'); + assert.match(tokenCall.init.headers.authorization, /^Basic /); + assert.ok(tokenCall.init.signal instanceof AbortSignal); + const tokenBody = new URLSearchParams(tokenCall.init.body); + assert.equal(tokenBody.get('grant_type'), 'authorization_code'); + assert.equal(tokenBody.get('code'), 'authorization-code-1'); + assert.equal(tokenBody.get('redirect_uri'), REDIRECT_URI); + assert.equal(tokenBody.get('code_verifier'), VERIFIER); + + assert.deepEqual( + configured.verifyIdToken({ + idToken: currentToken, + jwks: { keys: [PUBLIC_JWK] }, + issuer: ISSUER, + clientId: CLIENT_ID, + nonce: NONCE, + nowSeconds: NOW_SECONDS, + }).email, + 'owner@example.com', + ); + + const invalidClaimCases = [ + ['issuer', { iss: 'https://attacker.example' }, 'oidc_id_token_issuer_invalid'], + ['audience', { aud: 'another-client' }, 'oidc_id_token_audience_invalid'], + ['authorized party', { aud: [CLIENT_ID, 'another-client'], azp: 'another-client' }, 'oidc_id_token_authorized_party_invalid'], + ['expiration', { exp: NOW_SECONDS - 61 }, 'oidc_id_token_expired'], + ['future issued-at', { iat: NOW_SECONDS + 61 }, 'oidc_id_token_issued_at_invalid'], + ['not-before', { nbf: NOW_SECONDS + 61 }, 'oidc_id_token_not_before_invalid'], + ['nonce', { nonce: 'nonce_attacker_abcdefghijklmnopqrstuvwxyz' }, 'oidc_id_token_nonce_invalid'], + ['subject', { sub: '' }, 'oidc_id_token_subject_invalid'], + ['email verification', { email_verified: false }, 'oidc_id_token_email_invalid'], + ]; + for (const [label, claims, code] of invalidClaimCases) { + assert.throws( + () => configured.verifyIdToken({ + idToken: signedIdToken({ claims }), + jwks: { keys: [PUBLIC_JWK] }, + issuer: ISSUER, + clientId: CLIENT_ID, + nonce: NONCE, + nowSeconds: NOW_SECONDS, + }), + (error) => error.code === code, + label, + ); + } + + const { privateKey: attackerKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + assert.throws( + () => configured.verifyIdToken({ + idToken: signedIdToken({ signingKey: attackerKey }), + jwks: { keys: [PUBLIC_JWK] }, + issuer: ISSUER, + clientId: CLIENT_ID, + nonce: NONCE, + nowSeconds: NOW_SECONDS, + }), + (error) => error.code === 'oidc_id_token_signature_invalid', + ); + assert.throws( + () => configured.verifyIdToken({ + idToken: signedIdToken({ header: { alg: 'none' } }), + jwks: { keys: [PUBLIC_JWK] }, + issuer: ISSUER, + clientId: CLIENT_ID, + nonce: NONCE, + nowSeconds: NOW_SECONDS, + }), + (error) => error.code === 'oidc_id_token_algorithm_invalid', + ); + assert.throws( + () => configured.verifyIdToken({ + idToken: currentToken, + jwks: { keys: [] }, + issuer: ISSUER, + clientId: CLIENT_ID, + nonce: NONCE, + nowSeconds: NOW_SECONDS, + }), + (error) => error.code === 'oidc_signing_key_not_found', + ); + + await assert.rejects( + configured.exchangeAuthorizationCode({ + code: 'authorization-code-1', + codeVerifier: VERIFIER, + nonce: NONCE, + redirectUri: 'https://scopeweave.example/incorrect', + nowSeconds: NOW_SECONDS, + }), + (error) => error.code === 'oidc_redirect_uri_mismatch', + ); + + configureProduction(); + const mismatch = await freshModule('issuer-mismatch'); + globalThis.fetch = async () => new Response(JSON.stringify({ + issuer: 'https://attacker.example', + authorization_endpoint: `${ISSUER}/authorize`, + token_endpoint: `${ISSUER}/token`, + jwks_uri: `${ISSUER}/jwks`, + }), { status: 200 }); + await assert.rejects( + mismatch.authorizationUrl({ + state: STATE, + nonce: NONCE, + codeChallenge: CHALLENGE, + }), + (error) => error.code === 'oidc_discovery_issuer_mismatch', + ); +} finally { + restoreEnvironment(); +} + +console.log('✓ OIDC production signature verification tests passed'); From c0bf7a43733f64a3aa300d82451d10e9b44b7bf0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 20:10:52 +0900 Subject: [PATCH 03/12] test(coverage): include OIDC production verification --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 7790e678..e4ec603b 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,11 @@ }, "scripts": { "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", - "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/app.mjs --include=server/auth.mjs --reporter=json --reporter=json-summary npm run test:coverage", + "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/oidc.mjs --reporter=json --reporter=json-summary npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs", - "test:coverage": "node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/oidc.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs", + "test:coverage": "node tests/unit/oidc.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright test tests/e2e/cloud.spec.js", From 4e63462a57198abfb9483c52cbf80bba64a69ef4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 20:11:12 +0900 Subject: [PATCH 04/12] docs(oidc): define signature, nonce, PKCE, and claim verification --- docs/oidc-production.md | 66 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/oidc-production.md diff --git a/docs/oidc-production.md b/docs/oidc-production.md new file mode 100644 index 00000000..9e27d314 --- /dev/null +++ b/docs/oidc-production.md @@ -0,0 +1,66 @@ +# OpenID Connect Production Contract + +ScopeWeave is an OpenID Connect Relying Party. Production sign-in requires +provider discovery, Authorization Code flow with S256 PKCE, a cryptographically +bound nonce, and ID Token signature/claim verification. Decoding a JWT payload +without verifying its JWS signature is forbidden. + +## Required environment + +```text +OIDC_ISSUER=https://identity.example/tenant +OIDC_CLIENT_ID=scopeweave-client +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI=https://scopeweave.example/api/auth/oidc/callback +``` + +Production fails closed when any value is absent or when issuer, discovery, +token, JWKS, or redirect endpoints violate the HTTPS policy. Loopback HTTP is +accepted only for local development. `SCOPEWEAVE_DEV=1` is the only boundary +that enables the local mock provider and must never be set in staging or +production. + +## Verification contract + +The relying party performs the following checks before creating a ScopeWeave +session: + +1. discovery metadata `issuer` exactly matches `OIDC_ISSUER`; +2. authorization request includes `openid`, high-entropy state, nonce, and S256 + PKCE challenge; +3. callback state is single-use and unexpired; +4. token exchange uses the exact registered redirect URI and PKCE verifier; +5. ID Token uses compact JWS with `alg=RS256` and a matching provider JWKS key; +6. RSA signature is verified over the exact encoded header and claims; +7. `iss`, `aud`, multi-audience `azp`, `exp`, `iat`, optional `nbf`, and nonce + are validated; +8. `sub` is non-empty and the email claim is explicitly verified; +9. provider responses are bounded and raw token/provider payloads are never + returned in errors or logs. + +## Multi-instance state + +Authorization state and nonce must be kept in a single-use server-side store +shared by every API replica before horizontal scaling. A process-local state +map is acceptable only for the current single-node deployment ceiling. The +multi-instance migration must use a two-word database object such as +`oidc_state_records`, an expiry index, atomic consume semantics, and encrypted +or one-way protected verifier/nonce material. + +## APA 7th references + +Jones, M., & Bradley, J. (2015). *Proof key for code exchange by OAuth public +clients* (RFC 7636). Internet Engineering Task Force. +https://doi.org/10.17487/RFC7636 + +Jones, M., Bradley, J., & Sakimura, N. (2015). *JSON Web Token (JWT)* +(RFC 7519). Internet Engineering Task Force. +https://doi.org/10.17487/RFC7519 + +Sakimura, N., Bradley, J., Jones, M., de Medeiros, B., & Mortimore, C. (2023). +*OpenID Connect Core 1.0 incorporating errata set 2*. OpenID Foundation. +https://openid.net/specs/openid-connect-core-1_0.html + +Sakimura, N., Bradley, J., Jones, M., & Jay, E. (2023). *OpenID Connect +Discovery 1.0 incorporating errata set 2*. OpenID Foundation. +https://openid.net/specs/openid-connect-discovery-1_0.html From f3d13f5f38ab69b72e3589563b0fc10aa4620208 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 20:13:08 +0900 Subject: [PATCH 05/12] test(api): reject unverified OIDC identities at the route boundary --- tests/api/oidc-route.test.mjs | 125 ++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 tests/api/oidc-route.test.mjs diff --git a/tests/api/oidc-route.test.mjs b/tests/api/oidc-route.test.mjs new file mode 100644 index 00000000..4248152b --- /dev/null +++ b/tests/api/oidc-route.test.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import { + generateKeyPairSync, + sign as signBytes, +} from 'node:crypto'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = 'scopeweave-oidc-route-test-secret-at-least-32-characters'; +process.env.OIDC_ISSUER = 'https://identity.example/tenant'; +process.env.OIDC_CLIENT_ID = 'scopeweave-client'; +process.env.OIDC_CLIENT_SECRET = 'scopeweave-client-secret'; +process.env.OIDC_REDIRECT_URI = 'https://scopeweave.example/api/auth/oidc/callback'; +delete process.env.SCOPEWEAVE_DEV; + +const ISSUER = process.env.OIDC_ISSUER; +const CLIENT_ID = process.env.OIDC_CLIENT_ID; +const NOW_SECONDS = Math.floor(Date.now() / 1000); +const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const jwk = { + ...publicKey.export({ format: 'jwk' }), + kid: 'route-key', + use: 'sig', + alg: 'RS256', +}; +let currentNonce = ''; +let tamperSignature = false; + +function encoded(value) { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +function idToken() { + const header = encoded({ alg: 'RS256', typ: 'JWT', kid: jwk.kid }); + const claims = encoded({ + iss: ISSUER, + sub: 'subject-route-42', + aud: CLIENT_ID, + exp: NOW_SECONDS + 600, + iat: NOW_SECONDS - 5, + nonce: currentNonce, + email: 'route-owner@example.com', + email_verified: true, + }); + const input = `${header}.${claims}`; + let signature = signBytes('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url'); + if (tamperSignature) signature = `${signature.slice(0, -1)}A`; + return `${input}.${signature}`; +} + +globalThis.fetch = async (url) => { + if (url === `${ISSUER}/.well-known/openid-configuration`) { + return new Response(JSON.stringify({ + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/authorize`, + token_endpoint: `${ISSUER}/token`, + jwks_uri: `${ISSUER}/jwks`, + id_token_signing_alg_values_supported: ['RS256'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + }), { status: 200, headers: { 'content-type': 'application/json' } }); + } + if (url === `${ISSUER}/token`) { + return new Response(JSON.stringify({ id_token: idToken() }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + if (url === `${ISSUER}/jwks`) { + return new Response(JSON.stringify({ keys: [jwk] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + throw new Error(`unexpected OIDC request: ${url}`); +}; + +const { app } = await import('../../server/app.mjs'); +const { db } = await import('../../server/db.mjs'); + +async function startAuthorization() { + const response = await app.request( + 'https://scopeweave.example/api/auth/oidc/start', + ); + assert.equal(response.status, 302); + const location = new URL(response.headers.get('location')); + assert.equal(location.origin + location.pathname, `${ISSUER}/authorize`); + assert.equal(location.searchParams.get('code_challenge_method'), 'S256'); + currentNonce = location.searchParams.get('nonce'); + assert.ok(currentNonce); + return location.searchParams.get('state'); +} + +{ + const state = await startAuthorization(); + const response = await app.request( + `https://scopeweave.example/api/auth/oidc/callback?state=${encodeURIComponent(state)}&code=route-code-1`, + ); + assert.equal(response.status, 302); + assert.match(response.headers.get('location'), /^\/#token=/); + assert.equal( + db.prepare('SELECT email FROM users WHERE email = ?').get('route-owner@example.com').email, + 'route-owner@example.com', + ); + + const replay = await app.request( + `https://scopeweave.example/api/auth/oidc/callback?state=${encodeURIComponent(state)}&code=route-code-1`, + ); + assert.equal(replay.status, 400); + assert.deepEqual(await replay.json(), { error: 'invalid or expired state' }); +} + +{ + tamperSignature = true; + const state = await startAuthorization(); + const response = await app.request( + `https://scopeweave.example/api/auth/oidc/callback?state=${encodeURIComponent(state)}&code=route-code-2`, + ); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { + error: 'oidc_id_token_signature_invalid', + }); + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM users WHERE email = ?').get('route-owner@example.com').count, + 1, + ); +} From ba67318985e3a597ce7fa500b4a2c5f20e0f3594 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 20:13:38 +0900 Subject: [PATCH 06/12] test(api): include verified OIDC callback regression --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index e4ec603b..bb589373 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,9 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/oidc.mjs --reporter=json --reporter=json-summary npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/oidc-route.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs", "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/oidc.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs", - "test:coverage": "node tests/unit/oidc.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:coverage": "node tests/unit/oidc.test.mjs && node tests/api/oidc-route.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright test tests/e2e/cloud.spec.js", From 2cf151afdafaf7e05b63d014e8454a866438d32c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 20:14:40 +0900 Subject: [PATCH 07/12] test(oidc): make the route integration patch independently reviewable --- scripts/ci/one_shot_oidc_production_patch.py | 202 +++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 scripts/ci/one_shot_oidc_production_patch.py diff --git a/scripts/ci/one_shot_oidc_production_patch.py b/scripts/ci/one_shot_oidc_production_patch.py new file mode 100644 index 00000000..a1eacf33 --- /dev/null +++ b/scripts/ci/one_shot_oidc_production_patch.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Replace ScopeWeave's unverified inline OIDC flow with the verified module.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +IMPORT_ANCHOR = "import { chat as orchestratorChat } from './orchestrator.mjs';\n" +OIDC_IMPORT = """import { + OidcConfigurationError, + oidcMock, + authorizationUrl as createOidcAuthorizationUrl, + exchangeAuthorizationCode, +} from './oidc.mjs'; +""" +START_MARKER = "// ------------------------------------------------------------ SSO (OIDC)\n" +END_MARKER = "// Cross-project search: project names + task names, membership-scoped (tenant\n" + +OIDC_BLOCK = r'''// ------------------------------------------------------------ SSO (OIDC) +// Production uses discovery, S256 PKCE, a nonce, provider JWKS verification, +// and exact issuer/audience/time checks. The local provider is explicit dev-only. +const oidcStates = new Map(); // state -> { verifier, nonce, redirectUri, exp } +const oidcCodes = new Map(); // dev-only: code -> { email, state, exp } +const OIDC_STATE_LIMIT = 10_000; + +function pruneOidcState() { + const now = Date.now(); + for (const [state, value] of oidcStates) { + if (value.exp < now) oidcStates.delete(state); + } + for (const [code, value] of oidcCodes) { + if (value.exp < now) oidcCodes.delete(code); + } +} + +function upsertSsoUser(email) { + const normalizedEmail = String(email).trim().toLowerCase(); + let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(normalizedEmail); + if (user) return user; + db.exec('BEGIN'); + try { + const uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') + .run(normalizedEmail, hashPassword(randomBytes(24).toString('hex')), '')); + const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(`${normalizedEmail}'s workspace`, uid)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); + db.exec('COMMIT'); + metrics.signups++; + return { id: uid, email: normalizedEmail }; + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } +} + +function oidcFailure(c, error) { + if (!(error instanceof OidcConfigurationError)) throw error; + return c.json({ error: error.code }, error.statusCode); +} + +app.get('/api/auth/oidc/start', async (c) => { + pruneOidcState(); + if (oidcStates.size >= OIDC_STATE_LIMIT) { + return c.json({ error: 'oidc_state_capacity_exceeded' }, 429); + } + const origin = new URL(c.req.url).origin; + const state = randomBytes(32).toString('base64url'); + const nonce = randomBytes(32).toString('base64url'); + const verifier = randomBytes(64).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + const exp = Date.now() + 5 * 60 * 1000; + + if (oidcMock) { + const redirectUri = `${origin}/api/auth/oidc/callback`; + oidcStates.set(state, { verifier, nonce, redirectUri, exp }); + const email = c.req.query('email') || 'sso-user@example.com'; + const url = new URL(`${origin}/api/auth/oidc/mock/authorize`); + url.searchParams.set('state', state); + url.searchParams.set('email', email); + return c.redirect(url.toString()); + } + + try { + const authorization = await createOidcAuthorizationUrl({ + state, + nonce, + codeChallenge: challenge, + }); + oidcStates.set(state, { + verifier, + nonce, + redirectUri: authorization.redirectUri, + exp, + }); + return c.redirect(authorization.url); + } catch (error) { + return oidcFailure(c, error); + } +}); + +// Explicit development provider. It is unreachable unless SCOPEWEAVE_DEV=1 +// and no production issuer is configured. +app.get('/api/auth/oidc/mock/authorize', (c) => { + if (!oidcMock) return c.json({ error: 'mock disabled' }, 404); + pruneOidcState(); + const state = c.req.query('state'); + const pending = oidcStates.get(state); + if (!pending || pending.exp < Date.now()) { + return c.json({ error: 'invalid or expired state' }, 400); + } + const email = String(c.req.query('email') || '').trim().toLowerCase(); + if (email.length > 320 || !/^[^\s@]+@[^\s@]+$/.test(email)) { + return c.json({ error: 'invalid email' }, 400); + } + const code = randomBytes(32).toString('base64url'); + oidcCodes.set(code, { email, state, exp: pending.exp }); + const url = new URL(pending.redirectUri); + url.searchParams.set('code', code); + url.searchParams.set('state', state); + return c.redirect(url.toString()); +}); + +app.get('/api/auth/oidc/callback', async (c) => { + pruneOidcState(); + const state = c.req.query('state'); + const code = c.req.query('code'); + const pending = oidcStates.get(state); + if (!pending || pending.exp < Date.now()) { + return c.json({ error: 'invalid or expired state' }, 400); + } + oidcStates.delete(state); // single-use before provider I/O + + let identity; + if (oidcMock) { + const authorizationCode = oidcCodes.get(code); + oidcCodes.delete(code); + if ( + !authorizationCode + || authorizationCode.exp < Date.now() + || authorizationCode.state !== state + ) { + return c.json({ error: 'invalid code' }, 400); + } + identity = { email: authorizationCode.email }; + } else { + try { + identity = await exchangeAuthorizationCode({ + code, + codeVerifier: pending.verifier, + nonce: pending.nonce, + redirectUri: pending.redirectUri, + }); + } catch (error) { + return oidcFailure(c, error); + } + } + + const user = upsertSsoUser(identity.email); + const token = signToken({ + sub: user.id, + email: user.email, + tv: user.token_version || 0, + }); + // Return the token in the fragment rather than the query so intermediaries do + // not receive it; the client stores the token and immediately cleans the URL. + return c.redirect(`/#token=${token}`); +}); + +''' + + +def main() -> int: + """Apply the exact import and OIDC route replacement.""" + repo_root = Path(__file__).resolve().parents[2] + app_path = repo_root / "server/app.mjs" + source = app_path.read_text(encoding="utf-8") + if source.count(IMPORT_ANCHOR) != 1: + raise RuntimeError("orchestrator import anchor drifted") + if "from './oidc.mjs';" not in source: + source = source.replace(IMPORT_ANCHOR, IMPORT_ANCHOR + OIDC_IMPORT, 1) + + pattern = re.compile( + re.escape(START_MARKER) + r".*?(?=" + re.escape(END_MARKER) + r")", + re.DOTALL, + ) + source, count = pattern.subn(OIDC_BLOCK, source, count=1) + if count != 1: + raise RuntimeError(f"expected one inline OIDC block, found {count}") + forbidden = ( + "const oidcMock = !OIDC.issuer", + "verify the id_token signature via the issuer JWKS before prod", + "String(tok.id_token).split('.')[1]", + ) + if any(marker in source for marker in forbidden): + raise RuntimeError("unverified inline OIDC path remains after patch") + app_path.write_text(source, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From b4817ea3fa6253ef892be91b5d6170da8b3431d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 20:15:13 +0900 Subject: [PATCH 08/12] ci(oidc): patch and verify the production relying-party boundary once --- .../one-shot-oidc-production-verification.yml | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/one-shot-oidc-production-verification.yml diff --git a/.github/workflows/one-shot-oidc-production-verification.yml b/.github/workflows/one-shot-oidc-production-verification.yml new file mode 100644 index 00000000..51cd976e --- /dev/null +++ b/.github/workflows/one-shot-oidc-production-verification.yml @@ -0,0 +1,75 @@ +name: One-shot OIDC production verification + +on: + push: + branches: [fix/oidc-production-verification-20260809] + +permissions: + contents: write + +concurrency: + group: one-shot-oidc-production-verification + cancel-in-progress: false + +jobs: + patch-test-commit: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact branch head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: fix/oidc-production-verification-20260809 + fetch-depth: 0 + + - name: Apply reviewable OIDC route patch + run: python3 scripts/ci/one_shot_oidc_production_patch.py + + - name: Record the security contract in CHANGELOG + shell: python3 {0} + run: | + from pathlib import Path + + path = Path('CHANGELOG.md') + source = path.read_text(encoding='utf-8') + anchor = '### Security\n\n' + entry = '- Replaced the production OIDC payload-decoding stub with discovery, exact issuer binding, S256 PKCE, nonce protection, JWKS-backed RS256 signature verification, audience/authorized-party/time validation, verified-email enforcement, and single-use callback state. The local provider is now restricted to explicit `SCOPEWEAVE_DEV=1`.\n' + if source.count(anchor) != 1: + raise SystemExit('expected exactly one Unreleased Security anchor') + if entry not in source: + source = source.replace(anchor, anchor + entry, 1) + path.write_text(source, encoding='utf-8') + + - name: Setup Node 22.13 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: 22.13.0 + cache: npm + + - name: Install locked dependencies + run: npm ci + + - name: Run OIDC module, route, API, and coverage verification + run: | + node tests/unit/oidc.test.mjs + node tests/api/oidc-route.test.mjs + npm run test:unit + npm run test:api + npm run coverage + + - name: Commit verified implementation and remove one-shot assets + run: | + set -euo pipefail + git rm .github/workflows/one-shot-oidc-production-verification.yml + git rm scripts/ci/one_shot_oidc_production_patch.py + git add server/app.mjs CHANGELOG.md + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m 'fix(oidc): require verified provider identities' + git push origin HEAD:fix/oidc-production-verification-20260809 From 6f1b8a86ed762545397a3abeba0d5146a712251b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:12:53 +0900 Subject: [PATCH 09/12] fix(oidc): integrate verified provider route directly --- server/app.mjs | 191 +++++++++++++++++++++++++++++++------------------ 1 file changed, 123 insertions(+), 68 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 13d95e5d..110173c3 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -9,6 +9,12 @@ import { hashPassword, verifyPassword, signToken, verifyToken, generateApiToken, import { PLANS, planOf, orgUsage, wouldExceed, createCheckout } from './billing.mjs'; import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs'; import { chat as orchestratorChat } from './orchestrator.mjs'; +import { + OidcConfigurationError, + oidcMock, + authorizationUrl as createOidcAuthorizationUrl, + exchangeAuthorizationCode, +} from './oidc.mjs'; import { computeEvm } from '../analytics.js'; // pure math, shared with the client const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); @@ -776,102 +782,151 @@ app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { }); // ------------------------------------------------------------ SSO (OIDC) -// Real IdP via env (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI). When -// unset, a built-in mock provider makes the whole flow self-contained + testable. -const OIDC = { - issuer: process.env.OIDC_ISSUER, - clientId: process.env.OIDC_CLIENT_ID, - clientSecret: process.env.OIDC_CLIENT_SECRET, - redirectUri: process.env.OIDC_REDIRECT_URI, -}; -const oidcMock = !OIDC.issuer; -const oidcStates = new Map(); // state -> { verifier, exp } -const oidcCodes = new Map(); // mock only: code -> email +// Production uses discovery, S256 PKCE, a nonce, provider JWKS verification, +// and exact issuer/audience/time checks. The local provider is explicit dev-only. +const oidcStates = new Map(); // state -> { verifier, nonce, redirectUri, exp } +const oidcCodes = new Map(); // dev-only: code -> { email, state, exp } +const OIDC_STATE_LIMIT = 10_000; + +function pruneOidcState() { + const now = Date.now(); + for (const [state, value] of oidcStates) { + if (value.exp < now) oidcStates.delete(state); + } + for (const [code, value] of oidcCodes) { + if (value.exp < now) oidcCodes.delete(code); + } +} function upsertSsoUser(email) { - let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(email); + const normalizedEmail = String(email).trim().toLowerCase(); + let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(normalizedEmail); if (user) return user; db.exec('BEGIN'); try { const uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') - .run(email, hashPassword(randomBytes(24).toString('hex')), '')); - const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(`${email}'s workspace`, uid)); + .run(normalizedEmail, hashPassword(randomBytes(24).toString('hex')), '')); + const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(`${normalizedEmail}'s workspace`, uid)); db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); db.exec('COMMIT'); metrics.signups++; - return { id: uid, email }; - } catch (e) { db.exec('ROLLBACK'); throw e; } + return { id: uid, email: normalizedEmail }; + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } +} + +function oidcFailure(c, error) { + if (!(error instanceof OidcConfigurationError)) throw error; + return c.json({ error: error.code }, error.statusCode); } -app.get('/api/auth/oidc/start', (c) => { +app.get('/api/auth/oidc/start', async (c) => { + pruneOidcState(); + if (oidcStates.size >= OIDC_STATE_LIMIT) { + return c.json({ error: 'oidc_state_capacity_exceeded' }, 429); + } const origin = new URL(c.req.url).origin; - const state = randomBytes(16).toString('hex'); - const verifier = randomBytes(32).toString('base64url'); + const state = randomBytes(32).toString('base64url'); + const nonce = randomBytes(32).toString('base64url'); + const verifier = randomBytes(64).toString('base64url'); const challenge = createHash('sha256').update(verifier).digest('base64url'); - oidcStates.set(state, { verifier, exp: Date.now() + 5 * 60 * 1000 }); - const redirectUri = OIDC.redirectUri || `${origin}/api/auth/oidc/callback`; + const exp = Date.now() + 5 * 60 * 1000; + if (oidcMock) { + const redirectUri = `${origin}/api/auth/oidc/callback`; + oidcStates.set(state, { verifier, nonce, redirectUri, exp }); const email = c.req.query('email') || 'sso-user@example.com'; - const u = new URL(`${origin}/api/auth/oidc/mock/authorize`); - u.searchParams.set('state', state); - u.searchParams.set('email', email); - u.searchParams.set('redirect_uri', redirectUri); - return c.redirect(u.toString()); + const url = new URL(`${origin}/api/auth/oidc/mock/authorize`); + url.searchParams.set('state', state); + url.searchParams.set('email', email); + return c.redirect(url.toString()); } - const u = new URL(`${OIDC.issuer.replace(/\/$/, '')}/authorize`); - u.searchParams.set('client_id', OIDC.clientId); - u.searchParams.set('redirect_uri', redirectUri); - u.searchParams.set('response_type', 'code'); - u.searchParams.set('scope', 'openid email profile'); - u.searchParams.set('state', state); - u.searchParams.set('code_challenge', challenge); - u.searchParams.set('code_challenge_method', 'S256'); - return c.redirect(u.toString()); -}); - -// Built-in mock IdP authorize — instantly issues a code (dev/test only). + + try { + const authorization = await createOidcAuthorizationUrl({ + state, + nonce, + codeChallenge: challenge, + }); + oidcStates.set(state, { + verifier, + nonce, + redirectUri: authorization.redirectUri, + exp, + }); + return c.redirect(authorization.url); + } catch (error) { + return oidcFailure(c, error); + } +}); + +// Explicit development provider. It is unreachable unless SCOPEWEAVE_DEV=1 +// and no production issuer is configured. app.get('/api/auth/oidc/mock/authorize', (c) => { if (!oidcMock) return c.json({ error: 'mock disabled' }, 404); + pruneOidcState(); const state = c.req.query('state'); - const email = c.req.query('email'); - const redirectUri = c.req.query('redirect_uri'); - const code = randomBytes(16).toString('hex'); - oidcCodes.set(code, email); - const u = new URL(redirectUri); - u.searchParams.set('code', code); - u.searchParams.set('state', state); - return c.redirect(u.toString()); + const pending = oidcStates.get(state); + if (!pending || pending.exp < Date.now()) { + return c.json({ error: 'invalid or expired state' }, 400); + } + const email = String(c.req.query('email') || '').trim().toLowerCase(); + if (email.length > 320 || !/^[^\s@]+@[^\s@]+$/.test(email)) { + return c.json({ error: 'invalid email' }, 400); + } + const code = randomBytes(32).toString('base64url'); + oidcCodes.set(code, { email, state, exp: pending.exp }); + const url = new URL(pending.redirectUri); + url.searchParams.set('code', code); + url.searchParams.set('state', state); + return c.redirect(url.toString()); }); app.get('/api/auth/oidc/callback', async (c) => { + pruneOidcState(); const state = c.req.query('state'); const code = c.req.query('code'); - const s = oidcStates.get(state); - if (!s || s.exp < Date.now()) return c.json({ error: 'invalid or expired state' }, 400); - oidcStates.delete(state); - let email; + const pending = oidcStates.get(state); + if (!pending || pending.exp < Date.now()) { + return c.json({ error: 'invalid or expired state' }, 400); + } + oidcStates.delete(state); // single-use before provider I/O + + let identity; if (oidcMock) { - email = oidcCodes.get(code); + const authorizationCode = oidcCodes.get(code); oidcCodes.delete(code); - if (!email) return c.json({ error: 'invalid code' }, 400); + if ( + !authorizationCode + || authorizationCode.exp < Date.now() + || authorizationCode.state !== state + ) { + return c.json({ error: 'invalid code' }, 400); + } + identity = { email: authorizationCode.email }; } else { - const redirectUri = OIDC.redirectUri || `${new URL(c.req.url).origin}/api/auth/oidc/callback`; - const tokenRes = await fetch(`${OIDC.issuer.replace(/\/$/, '')}/token`, { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: OIDC.clientId, client_secret: OIDC.clientSecret, code_verifier: s.verifier }), - }).catch(() => null); - const tok = tokenRes ? await tokenRes.json().catch(() => ({})) : {}; - if (!tok.id_token) return c.json({ error: 'token exchange failed' }, 400); - // Ceiling: verify the id_token signature via the issuer JWKS before prod. - const claims = JSON.parse(Buffer.from(String(tok.id_token).split('.')[1] || '', 'base64url').toString() || '{}'); - email = claims.email; - if (!email) return c.json({ error: 'no email claim' }, 400); + try { + identity = await exchangeAuthorizationCode({ + code, + codeVerifier: pending.verifier, + nonce: pending.nonce, + redirectUri: pending.redirectUri, + }); + } catch (error) { + return oidcFailure(c, error); + } } - const user = upsertSsoUser(email); - const token = signToken({ sub: user.id, email, tv: user.token_version || 0 }); - // Return the token in the URL fragment (not query → not logged); the client - // stores it and cleans the URL. + + const user = upsertSsoUser(identity.email); + const token = signToken({ + sub: user.id, + email: user.email, + tv: user.token_version || 0, + }); + // Return the token in the fragment rather than the query so intermediaries do + // not receive it; the client stores the token and immediately cleans the URL. return c.redirect(`/#token=${token}`); }); From 9da2e03fed185c3787e98f54c5f011831da9d38e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:13:28 +0900 Subject: [PATCH 10/12] security(ci): remove self-modifying OIDC workflow --- .../one-shot-oidc-production-verification.yml | 75 ------------------- 1 file changed, 75 deletions(-) delete mode 100644 .github/workflows/one-shot-oidc-production-verification.yml diff --git a/.github/workflows/one-shot-oidc-production-verification.yml b/.github/workflows/one-shot-oidc-production-verification.yml deleted file mode 100644 index 51cd976e..00000000 --- a/.github/workflows/one-shot-oidc-production-verification.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: One-shot OIDC production verification - -on: - push: - branches: [fix/oidc-production-verification-20260809] - -permissions: - contents: write - -concurrency: - group: one-shot-oidc-production-verification - cancel-in-progress: false - -jobs: - patch-test-commit: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout exact branch head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: fix/oidc-production-verification-20260809 - fetch-depth: 0 - - - name: Apply reviewable OIDC route patch - run: python3 scripts/ci/one_shot_oidc_production_patch.py - - - name: Record the security contract in CHANGELOG - shell: python3 {0} - run: | - from pathlib import Path - - path = Path('CHANGELOG.md') - source = path.read_text(encoding='utf-8') - anchor = '### Security\n\n' - entry = '- Replaced the production OIDC payload-decoding stub with discovery, exact issuer binding, S256 PKCE, nonce protection, JWKS-backed RS256 signature verification, audience/authorized-party/time validation, verified-email enforcement, and single-use callback state. The local provider is now restricted to explicit `SCOPEWEAVE_DEV=1`.\n' - if source.count(anchor) != 1: - raise SystemExit('expected exactly one Unreleased Security anchor') - if entry not in source: - source = source.replace(anchor, anchor + entry, 1) - path.write_text(source, encoding='utf-8') - - - name: Setup Node 22.13 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - with: - node-version: 22.13.0 - cache: npm - - - name: Install locked dependencies - run: npm ci - - - name: Run OIDC module, route, API, and coverage verification - run: | - node tests/unit/oidc.test.mjs - node tests/api/oidc-route.test.mjs - npm run test:unit - npm run test:api - npm run coverage - - - name: Commit verified implementation and remove one-shot assets - run: | - set -euo pipefail - git rm .github/workflows/one-shot-oidc-production-verification.yml - git rm scripts/ci/one_shot_oidc_production_patch.py - git add server/app.mjs CHANGELOG.md - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git commit -m 'fix(oidc): require verified provider identities' - git push origin HEAD:fix/oidc-production-verification-20260809 From 853e3873ba8c07aa751177adb8caedc93e52e93c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:13:51 +0900 Subject: [PATCH 11/12] security(ci): remove temporary OIDC patch script --- scripts/ci/one_shot_oidc_production_patch.py | 202 ------------------- 1 file changed, 202 deletions(-) delete mode 100644 scripts/ci/one_shot_oidc_production_patch.py diff --git a/scripts/ci/one_shot_oidc_production_patch.py b/scripts/ci/one_shot_oidc_production_patch.py deleted file mode 100644 index a1eacf33..00000000 --- a/scripts/ci/one_shot_oidc_production_patch.py +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env python3 -"""Replace ScopeWeave's unverified inline OIDC flow with the verified module.""" - -from __future__ import annotations - -import re -from pathlib import Path - - -IMPORT_ANCHOR = "import { chat as orchestratorChat } from './orchestrator.mjs';\n" -OIDC_IMPORT = """import { - OidcConfigurationError, - oidcMock, - authorizationUrl as createOidcAuthorizationUrl, - exchangeAuthorizationCode, -} from './oidc.mjs'; -""" -START_MARKER = "// ------------------------------------------------------------ SSO (OIDC)\n" -END_MARKER = "// Cross-project search: project names + task names, membership-scoped (tenant\n" - -OIDC_BLOCK = r'''// ------------------------------------------------------------ SSO (OIDC) -// Production uses discovery, S256 PKCE, a nonce, provider JWKS verification, -// and exact issuer/audience/time checks. The local provider is explicit dev-only. -const oidcStates = new Map(); // state -> { verifier, nonce, redirectUri, exp } -const oidcCodes = new Map(); // dev-only: code -> { email, state, exp } -const OIDC_STATE_LIMIT = 10_000; - -function pruneOidcState() { - const now = Date.now(); - for (const [state, value] of oidcStates) { - if (value.exp < now) oidcStates.delete(state); - } - for (const [code, value] of oidcCodes) { - if (value.exp < now) oidcCodes.delete(code); - } -} - -function upsertSsoUser(email) { - const normalizedEmail = String(email).trim().toLowerCase(); - let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(normalizedEmail); - if (user) return user; - db.exec('BEGIN'); - try { - const uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') - .run(normalizedEmail, hashPassword(randomBytes(24).toString('hex')), '')); - const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(`${normalizedEmail}'s workspace`, uid)); - db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); - db.exec('COMMIT'); - metrics.signups++; - return { id: uid, email: normalizedEmail }; - } catch (error) { - db.exec('ROLLBACK'); - throw error; - } -} - -function oidcFailure(c, error) { - if (!(error instanceof OidcConfigurationError)) throw error; - return c.json({ error: error.code }, error.statusCode); -} - -app.get('/api/auth/oidc/start', async (c) => { - pruneOidcState(); - if (oidcStates.size >= OIDC_STATE_LIMIT) { - return c.json({ error: 'oidc_state_capacity_exceeded' }, 429); - } - const origin = new URL(c.req.url).origin; - const state = randomBytes(32).toString('base64url'); - const nonce = randomBytes(32).toString('base64url'); - const verifier = randomBytes(64).toString('base64url'); - const challenge = createHash('sha256').update(verifier).digest('base64url'); - const exp = Date.now() + 5 * 60 * 1000; - - if (oidcMock) { - const redirectUri = `${origin}/api/auth/oidc/callback`; - oidcStates.set(state, { verifier, nonce, redirectUri, exp }); - const email = c.req.query('email') || 'sso-user@example.com'; - const url = new URL(`${origin}/api/auth/oidc/mock/authorize`); - url.searchParams.set('state', state); - url.searchParams.set('email', email); - return c.redirect(url.toString()); - } - - try { - const authorization = await createOidcAuthorizationUrl({ - state, - nonce, - codeChallenge: challenge, - }); - oidcStates.set(state, { - verifier, - nonce, - redirectUri: authorization.redirectUri, - exp, - }); - return c.redirect(authorization.url); - } catch (error) { - return oidcFailure(c, error); - } -}); - -// Explicit development provider. It is unreachable unless SCOPEWEAVE_DEV=1 -// and no production issuer is configured. -app.get('/api/auth/oidc/mock/authorize', (c) => { - if (!oidcMock) return c.json({ error: 'mock disabled' }, 404); - pruneOidcState(); - const state = c.req.query('state'); - const pending = oidcStates.get(state); - if (!pending || pending.exp < Date.now()) { - return c.json({ error: 'invalid or expired state' }, 400); - } - const email = String(c.req.query('email') || '').trim().toLowerCase(); - if (email.length > 320 || !/^[^\s@]+@[^\s@]+$/.test(email)) { - return c.json({ error: 'invalid email' }, 400); - } - const code = randomBytes(32).toString('base64url'); - oidcCodes.set(code, { email, state, exp: pending.exp }); - const url = new URL(pending.redirectUri); - url.searchParams.set('code', code); - url.searchParams.set('state', state); - return c.redirect(url.toString()); -}); - -app.get('/api/auth/oidc/callback', async (c) => { - pruneOidcState(); - const state = c.req.query('state'); - const code = c.req.query('code'); - const pending = oidcStates.get(state); - if (!pending || pending.exp < Date.now()) { - return c.json({ error: 'invalid or expired state' }, 400); - } - oidcStates.delete(state); // single-use before provider I/O - - let identity; - if (oidcMock) { - const authorizationCode = oidcCodes.get(code); - oidcCodes.delete(code); - if ( - !authorizationCode - || authorizationCode.exp < Date.now() - || authorizationCode.state !== state - ) { - return c.json({ error: 'invalid code' }, 400); - } - identity = { email: authorizationCode.email }; - } else { - try { - identity = await exchangeAuthorizationCode({ - code, - codeVerifier: pending.verifier, - nonce: pending.nonce, - redirectUri: pending.redirectUri, - }); - } catch (error) { - return oidcFailure(c, error); - } - } - - const user = upsertSsoUser(identity.email); - const token = signToken({ - sub: user.id, - email: user.email, - tv: user.token_version || 0, - }); - // Return the token in the fragment rather than the query so intermediaries do - // not receive it; the client stores the token and immediately cleans the URL. - return c.redirect(`/#token=${token}`); -}); - -''' - - -def main() -> int: - """Apply the exact import and OIDC route replacement.""" - repo_root = Path(__file__).resolve().parents[2] - app_path = repo_root / "server/app.mjs" - source = app_path.read_text(encoding="utf-8") - if source.count(IMPORT_ANCHOR) != 1: - raise RuntimeError("orchestrator import anchor drifted") - if "from './oidc.mjs';" not in source: - source = source.replace(IMPORT_ANCHOR, IMPORT_ANCHOR + OIDC_IMPORT, 1) - - pattern = re.compile( - re.escape(START_MARKER) + r".*?(?=" + re.escape(END_MARKER) + r")", - re.DOTALL, - ) - source, count = pattern.subn(OIDC_BLOCK, source, count=1) - if count != 1: - raise RuntimeError(f"expected one inline OIDC block, found {count}") - forbidden = ( - "const oidcMock = !OIDC.issuer", - "verify the id_token signature via the issuer JWKS before prod", - "String(tok.id_token).split('.')[1]", - ) - if any(marker in source for marker in forbidden): - raise RuntimeError("unverified inline OIDC path remains after patch") - app_path.write_text(source, encoding="utf-8") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From bfb517cf0ef1ecfab485c4cebc7d2c12c9822fae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:14:45 +0900 Subject: [PATCH 12/12] docs(changelog): record verified OIDC trust boundary --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e84f41f8..ac03d3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Replaced production OIDC payload decoding with discovery, exact issuer + binding, S256 PKCE, nonce protection, JWKS-backed RS256 signature + verification, audience and authorized-party checks, bounded provider I/O, + verified-email enforcement, and single-use callback state. The local + provider now requires explicit `SCOPEWEAVE_DEV=1`. - Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or unexpanded placeholder values so production deployments fail closed. - Neutralized audit-log CSV formulas even when executable prefixes are hidden