import { createPrivateKey, createPublicKey } from 'node:crypto' import { readFileSync } from 'node:fs' const route = '/oauth-client-metadata.json' const scopeToken = /^[A-Za-z0-9:.*_/-]+$/ const maxPreviousKeys = 8 export function createOAuthClientMetadata(env = process.env) { const names = ['OAUTH_CLIENT_ID', 'OAUTH_REDIRECT_URI', 'OAUTH_SCOPE', 'OAUTH_SIGNING_KEY', 'OAUTH_KID'] const configured = names.map(name => Boolean(env[name])) if (configured.every(value => !value)) return null if (configured.some(value => !value)) throw new Error(`OAuth metadata requires all of: ${names.join(', ')}.`) const clientId = absoluteHttpsUrl(env.OAUTH_CLIENT_ID, 'OAUTH_CLIENT_ID') const expectedId = new URL(route, clientId).href if (clientId !== expectedId) throw new Error(`OAUTH_CLIENT_ID must be the absolute HTTPS URL ending in ${route}.`) const redirectUri = absoluteHttpsUrl(env.OAUTH_REDIRECT_URI, 'OAUTH_REDIRECT_URI') if (new URL(redirectUri).origin !== new URL(clientId).origin) { throw new Error('OAUTH_REDIRECT_URI must use the same HTTPS origin as OAUTH_CLIENT_ID.') } if (!/^[A-Za-z0-9._-]{1,128}$/.test(env.OAUTH_KID)) throw new Error('OAUTH_KID must be a non-empty key identifier.') const scopes = env.OAUTH_SCOPE.trim().split(/\s+/) if (!scopes.length || scopes.some(scope => !scopeToken.test(scope))) throw new Error('OAUTH_SCOPE must contain valid space-separated scope tokens.') const signingKeys = [{ kid: env.OAUTH_KID, keyPath: env.OAUTH_SIGNING_KEY }, ...parsePreviousKeys(env.OAUTH_PREVIOUS_KEYS, env.OAUTH_KID)] const keys = signingKeys.map(({ kid, keyPath }) => { const privateKey = readP256Key(keyPath, kid === env.OAUTH_KID ? 'OAUTH_SIGNING_KEY' : `previous key ${kid}`) const { x, y } = createPublicKey(privateKey).export({ format: 'jwk' }) return { kty: 'EC', crv: 'P-256', x, y, kid, alg: 'ES256', use: 'sig' } }) return Object.freeze({ route, clientId, metadata: Object.freeze({ client_id: clientId, client_name: 'Reams', client_uri: new URL('/', clientId).href, logo_uri: new URL('/icon-192.png', clientId).href, redirect_uris: [redirectUri], response_types: ['code'], grant_types: ['authorization_code', 'refresh_token'], scope: scopes.join(' '), token_endpoint_auth_method: 'private_key_jwt', token_endpoint_auth_signing_alg: 'ES256', jwks: { keys }, dpop_bound_access_tokens: true, }), }) } function parsePreviousKeys(raw, currentKid) { if (raw === undefined || raw === '') return [] let values try { values = JSON.parse(raw) } catch { throw new Error('OAUTH_PREVIOUS_KEYS must be a JSON array of {kid,keyPath} objects.') } if (!Array.isArray(values) || values.length > maxPreviousKeys) throw new Error(`OAUTH_PREVIOUS_KEYS must contain at most ${maxPreviousKeys} keys.`) const kids = new Set([currentKid]) return values.map(value => { if (!value || typeof value !== 'object' || Array.isArray(value) || typeof value.kid !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(value.kid) || typeof value.keyPath !== 'string' || !value.keyPath.trim()) throw new Error('Each OAUTH_PREVIOUS_KEYS entry must have a valid kid and keyPath.') if (kids.has(value.kid)) throw new Error(`OAuth key identifier ${value.kid} is configured more than once.`) kids.add(value.kid) return { kid: value.kid, keyPath: value.keyPath } }) } function readP256Key(path, name) { const privateKey = createPrivateKey(readFileSync(path)) if (privateKey.asymmetricKeyType !== 'ec' || privateKey.asymmetricKeyDetails?.namedCurve !== 'prime256v1') throw new Error(`${name} must be a PEM-encoded P-256 EC private key.`) return privateKey } function absoluteHttpsUrl(value, name) { let parsed try { parsed = new URL(value) } catch { throw new Error(`${name} must be an absolute HTTPS URL.`) } if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.hash) { throw new Error(`${name} must be an absolute HTTPS URL without credentials or a fragment.`) } return parsed.href }