// The OAuth authorization-server endpoints, driven through the full flow a // client walks: PAR, the consent page, approval, the code and refresh grants, // and revocation. A loopback client keeps client-metadata resolution local. import { afterEach, describe, expect, it, vi } from 'vitest'; import { base64UrlEncode, generateKeyPair, importPrivateKey, } from '../packages/core/src/crypto.js'; import { createOAuthHandlers } from '../packages/core/src/handlers/oauth.js'; import { mintDpopNonce } from '../packages/core/src/oauth.js'; import { createPasskeyCeremony } from '../packages/core/src/passkey.js'; const DID = 'did:plc:oauthendpoints234567abcd'; const SECRET = 'test-secret'; const PASSWORD = 'account-password'; const ORIGIN = 'https://pds.example.com'; const CLIENT_ID = 'http://localhost'; const REDIRECT_URI = 'http://localhost/callback'; /** * @param {{ * readOnly?: boolean, hasAccountApp?: boolean, passkeys?: boolean, * did?: string|null, signingKey?: boolean, spacesEnabled?: boolean, * lexiconResolver?: import('../packages/core/src/ports.js').LexiconResolverPort, * }} [options] */ function createServer({ readOnly = false, hasAccountApp = false, passkeys = false, did = DID, signingKey = true, spacesEnabled = false, lexiconResolver = undefined, } = {}) { /** @type {Map} */ const oauthRequests = new Map(); /** @type {Map} */ const oauthTokens = new Map(); /** @type {Set} */ const jtis = new Set(); /** @type {Object[]} */ const bootstraps = []; const sharedStorage = /** @type {any} */ ({ getOAuthRequest: async (/** @type {string} */ uri) => oauthRequests.get(uri) || null, putOAuthRequest: async ( /** @type {string} */ uri, /** @type {any} */ data, ) => void oauthRequests.set(uri, data), deleteOAuthRequest: async (/** @type {string} */ uri) => void oauthRequests.delete(uri), getOAuthToken: async (/** @type {string} */ id) => oauthTokens.get(id) || null, putOAuthToken: async (/** @type {string} */ id, /** @type {any} */ data) => void oauthTokens.set(id, data), deleteOAuthToken: async (/** @type {string} */ id) => void oauthTokens.delete(id), checkAndStoreDpopJti: async (/** @type {string} */ jti) => { if (jtis.has(jti)) return false; jtis.add(jti); return true; }, }); if (passkeys) sharedStorage.getPasskey = async () => null; /** @type {Promise|null} */ let keyPromise = null; const getSigningKey = () => { if (!signingKey) return Promise.resolve(null); keyPromise ??= generateKeyPair().then((kp) => importPrivateKey(kp.privateKey), ); return keyPromise; }; const handlers = createOAuthHandlers({ sharedStorage, passkeys: createPasskeyCeremony(sharedStorage, SECRET), jwtSecret: SECRET, verifyAccountPassword: async (candidate) => candidate === PASSWORD, passwordConfigured: async () => true, readOnly, spacesEnabled, hasAccountApp, lexiconResolver, getDid: async () => did, getSigningKey, getPublicKeyJwk: async () => signingKey ? { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' } : null, readJsonBounded: (request) => request.json(), accountAppResponse: (bootstrap) => { bootstraps.push(/** @type {Object} */ (bootstrap)); return Response.json({ mounted: true }); }, readAccountUiState: async () => ({ theme: 'dark' }), readOnlyError: () => Response.json( { error: 'AuthenticationRequired', message: 'This PDS is read-only' }, { status: 401 }, ), }); return { handlers, oauthRequests, oauthTokens, bootstraps }; } /** * A DPoP key pair whose proofs all present the same public key, so the jkt * PAR captures matches the one the token endpoint checks. */ async function dpopKeys() { const keys = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], ); const jwk = await crypto.subtle.exportKey('jwk', keys.publicKey); return { keys, jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y } }; } /** * @param {Awaited>} client * @param {string} htu */ async function dpopProof(client, htu) { const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: client.jwk }; const payload = { jti: crypto.randomUUID(), htm: 'POST', htu, iat: Math.floor(Date.now() / 1000), nonce: await mintDpopNonce(SECRET), }; const enc = (/** @type {object} */ o) => base64UrlEncode(new TextEncoder().encode(JSON.stringify(o))); const input = `${enc(header)}.${enc(payload)}`; const sig = new Uint8Array( await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, client.keys.privateKey, new TextEncoder().encode(input), ), ); return `${input}.${base64UrlEncode(sig)}`; } /** A verifier string and the S256 challenge PAR registers for it. */ async function pkcePair() { const verifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32))); const digest = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(verifier), ); return { verifier, challenge: base64UrlEncode(new Uint8Array(digest)) }; } /** * @param {ReturnType['handlers']} handlers * @param {string} path * @param {{method?: string, headers?: Record, body?: string, search?: Record}} [options] */ async function call(handlers, path, { method, headers, body, search } = {}) { const url = new URL(`${ORIGIN}${path}`); for (const [k, v] of Object.entries(search || {})) { url.searchParams.set(k, v); } const route = handlers.routes[path]; const handler = /** @type {(request: Request, url: URL) => Promise|Response} */ ( route.handler ); return handler( new Request(url, { method: method || route.method || 'GET', headers, body, }), url, ); } /** * Push an authorization request and return its request_uri. * @param {ReturnType['handlers']} handlers * @param {Awaited>} client * @param {string} challenge * @param {Record} [extra] */ async function par(handlers, client, challenge, extra = {}) { const response = await call(handlers, '/oauth/par', { headers: { 'content-type': 'application/x-www-form-urlencoded', DPoP: await dpopProof(client, `${ORIGIN}/oauth/par`), }, body: new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code', code_challenge: challenge, code_challenge_method: 'S256', scope: 'atproto', state: 'st4te', ...extra, }).toString(), }); expect(response.status).toBe(201); return (await response.json()).request_uri; } /** * Approve a pushed request with the account password; returns the code from * the redirect. * @param {ReturnType['handlers']} handlers * @param {string} requestUri */ async function approve(handlers, requestUri) { const response = await call(handlers, '/oauth/authorize', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ request_uri: requestUri, client_id: CLIENT_ID, password: PASSWORD, action: 'approve', }).toString(), }); expect(response.status).toBe(302); const redirect = new URL(response.headers.get('Location') || ''); expect(redirect.origin).toBe(new URL(REDIRECT_URI).origin); expect(redirect.searchParams.get('iss')).toBe(ORIGIN); expect(redirect.searchParams.get('state')).toBe('st4te'); return /** @type {string} */ (redirect.searchParams.get('code')); } /** * Redeem a code for tokens. * @param {ReturnType['handlers']} handlers * @param {Awaited>} client * @param {Record} params */ async function token(handlers, client, params) { return call(handlers, '/oauth/token', { headers: { 'content-type': 'application/x-www-form-urlencoded', DPoP: await dpopProof(client, `${ORIGIN}/oauth/token`), }, body: new URLSearchParams(params).toString(), }); } describe('server metadata', () => { it('names the issuer in the authorization-server document', async () => { const { handlers } = createServer(); const response = await call( handlers, '/.well-known/oauth-authorization-server', ); const body = await response.json(); expect(body.issuer).toBe(ORIGIN); expect(body.pushed_authorization_request_endpoint).toBe( `${ORIGIN}/oauth/par`, ); }); it('names the resource in the protected-resource document', async () => { const { handlers } = createServer(); const response = await call( handlers, '/.well-known/oauth-protected-resource', ); const body = await response.json(); expect(body.resource).toBe(ORIGIN); expect(body.authorization_servers).toEqual([ORIGIN]); }); it('serves the signing key as a JWKS', async () => { const { handlers } = createServer(); const { keys } = await (await call(handlers, '/oauth/jwks')).json(); expect(keys[0].kid).toBe('pds-oauth-key'); expect(keys[0].alg).toBe('ES256'); }); it('reports a server with no signing key', async () => { const { handlers } = createServer({ signingKey: false }); const response = await call(handlers, '/oauth/jwks'); expect(response.status).toBe(500); }); }); describe('the authorization flow', () => { it('walks PAR, consent, approval, and the code grant', async () => { const { handlers, oauthRequests, oauthTokens } = createServer(); const client = await dpopKeys(); const { verifier, challenge } = await pkcePair(); const requestUri = await par(handlers, client, challenge); expect(requestUri).toMatch(/^urn:ietf:params:oauth:request_uri:/); // The consent page names the client. const consent = await call(handlers, '/oauth/authorize', { search: { request_uri: requestUri, client_id: CLIENT_ID }, }); expect(consent.status).toBe(200); expect(await consent.text()).toContain('localhost'); const code = await approve(handlers, requestUri); expect(code).toBeTruthy(); const response = await token(handlers, client, { grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier, }); expect(response.status).toBe(200); const body = await response.json(); expect(body.token_type).toBe('DPoP'); expect(body.sub).toBe(DID); expect(body.access_token.split('.')).toHaveLength(3); expect(oauthTokens.get(body.refresh_token).did).toBe(DID); // The code is one-time use. expect(oauthRequests.has(`code:${code}`)).toBe(false); }); it('sends a denial back to the client as access_denied', async () => { const { handlers } = createServer(); const client = await dpopKeys(); const { challenge } = await pkcePair(); const requestUri = await par(handlers, client, challenge); const response = await call(handlers, '/oauth/authorize', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ request_uri: requestUri, client_id: CLIENT_ID, action: 'deny', }).toString(), }); expect(response.status).toBe(302); const redirect = new URL(response.headers.get('Location') || ''); expect(redirect.searchParams.get('error')).toBe('access_denied'); expect(redirect.searchParams.get('state')).toBe('st4te'); }); it('re-renders consent when the password is wrong', async () => { const { handlers } = createServer(); const client = await dpopKeys(); const { challenge } = await pkcePair(); const requestUri = await par(handlers, client, challenge); const response = await call(handlers, '/oauth/authorize', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ request_uri: requestUri, client_id: CLIENT_ID, password: 'wrong', action: 'approve', }).toString(), }); expect(response.status).toBe(200); expect(await response.text()).toContain('Invalid password'); }); it('answers an approval that asks for JSON with the redirect URL', async () => { const { handlers } = createServer(); const client = await dpopKeys(); const { challenge } = await pkcePair(); const requestUri = await par(handlers, client, challenge); const response = await call(handlers, '/oauth/authorize', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json', }, body: new URLSearchParams({ request_uri: requestUri, client_id: CLIENT_ID, password: PASSWORD, action: 'approve', }).toString(), }); expect(response.status).toBe(200); expect(response.headers.get('Cache-Control')).toBe('no-store'); const redirect = new URL((await response.json()).redirect); expect(redirect.origin).toBe(new URL(REDIRECT_URI).origin); expect(redirect.searchParams.get('code')).toBeTruthy(); expect(redirect.searchParams.get('state')).toBe('st4te'); }); it('answers a denial that asks for JSON with the redirect URL', async () => { const { handlers } = createServer(); const client = await dpopKeys(); const { challenge } = await pkcePair(); const requestUri = await par(handlers, client, challenge); const response = await call(handlers, '/oauth/authorize', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json', }, body: new URLSearchParams({ request_uri: requestUri, client_id: CLIENT_ID, action: 'deny', }).toString(), }); expect(response.status).toBe(200); const redirect = new URL((await response.json()).redirect); expect(redirect.searchParams.get('error')).toBe('access_denied'); }); it('answers a wrong password with JSON rather than the consent page', async () => { const { handlers } = createServer(); const client = await dpopKeys(); const { challenge } = await pkcePair(); const requestUri = await par(handlers, client, challenge); const response = await call(handlers, '/oauth/authorize', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json', }, body: new URLSearchParams({ request_uri: requestUri, client_id: CLIENT_ID, password: 'wrong', action: 'approve', }).toString(), }); expect(response.status).toBe(401); expect((await response.json()).error).toBe('Invalid password'); }); it('refuses an approval for a request it does not hold', async () => { const { handlers } = createServer(); const response = await call(handlers, '/oauth/authorize', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ request_uri: 'urn:ietf:params:oauth:request_uri:ghost', client_id: CLIENT_ID, password: PASSWORD, action: 'approve', }).toString(), }); expect(response.status).toBe(400); }); it('refuses a consent view whose client does not match the request', async () => { const { handlers } = createServer(); const client = await dpopKeys(); const { challenge } = await pkcePair(); const requestUri = await par(handlers, client, challenge); const response = await call(handlers, '/oauth/authorize', { search: { request_uri: requestUri, client_id: 'http://other.example' }, }); expect(response.status).toBe(400); expect(await response.text()).toBe('Client mismatch'); }); it('refuses to show a consent page for a spent request', async () => { const { handlers } = createServer(); const client = await dpopKeys(); const { verifier, challenge } = await pkcePair(); const requestUri = await par(handlers, client, challenge); const code = await approve(handlers, requestUri); // Redeeming deletes the request; re-approving must not find it. await token(handlers, client, { grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier, }); const response = await call(handlers, '/oauth/authorize', { search: { request_uri: requestUri, client_id: CLIENT_ID }, }); expect(response.status).toBe(400); }); it('starts a passkey ceremony with the consent page when storage holds passkeys', async () => { const { handlers } = createServer({ passkeys: true }); const client = await dpopKeys(); const { challenge } = await pkcePair(); const requestUri = await par(handlers, client, challenge); const response = await call(handlers, '/oauth/authorize', { search: { request_uri: requestUri, client_id: CLIENT_ID }, }); expect(response.headers.get('Set-Cookie')).toContain('pdsjs_webauthn='); expect(response.headers.get('Set-Cookie')).toContain('Path=/oauth'); }); it('renders consent through the mounted account app with parsed permissions', async () => { const { handlers, bootstraps } = createServer({ hasAccountApp: true }); const client = await dpopKeys(); const { challenge } = await pkcePair(); const requestUri = await par(handlers, client, challenge, { scope: 'atproto repo:app.bsky.feed.post?action=create', }); const response = await call(handlers, '/oauth/authorize', { search: { request_uri: requestUri, client_id: CLIENT_ID }, }); expect((await response.json()).mounted).toBe(true); const bootstrap = /** @type {any} */ (bootstraps[0]); expect(bootstrap.mode).toBe('consent'); expect(bootstrap.theme).toBe('dark'); expect(bootstrap.permissions.hasAtproto).toBe(true); expect(bootstrap.permissions.repo).toEqual([ { collection: 'app.bsky.feed.post', create: true, update: false, delete: false, }, ]); }); it('joins an include scope with its permission set in the consent bootstrap', async () => { const SET_NSID = 'social.grain.authFullApp'; const AUD = 'did:web:api.grain.social#grain'; const set = { lexicon: 1, id: SET_NSID, defs: { main: { type: 'permission-set', title: 'Full grain access', 'title:fr': 'Acces complet', detail: 'Manage galleries.', permissions: [ { type: 'permission', resource: 'repo', collection: ['social.grain.gallery'], }, ], }, }, }; const { handlers, bootstraps } = createServer({ hasAccountApp: true, lexiconResolver: { assertValid: async () => /** @type {'valid'} */ ('valid'), getLexicon: async (nsid) => (nsid === SET_NSID ? set : null), }, }); const client = await dpopKeys(); const { challenge } = await pkcePair(); const requestUri = await par(handlers, client, challenge, { scope: `atproto include:${SET_NSID}?aud=${AUD}`, }); await call(handlers, '/oauth/authorize', { search: { request_uri: requestUri, client_id: CLIENT_ID }, }); const include = /** @type {any} */ (bootstraps[0]).permissions.includes[0]; expect(include.nsid).toBe(SET_NSID); expect(include.title).toBe('Full grain access'); expect(include.titleLangs).toEqual({ fr: 'Acces complet' }); expect(include.repo).toEqual([ { collection: 'social.grain.gallery', create: true, update: true, delete: true, }, ]); }); }); describe('space scopes that name a type alone', () => { const FORUM = 'com.example.forum'; const forum = { lexicon: 1, id: FORUM, defs: { main: { type: 'space', collections: ['com.example.post', 'com.example.reply'], }, }, }; /** @type {import('../packages/core/src/ports.js').LexiconResolverPort} */ const lexiconResolver = { assertValid: async () => /** @type {'valid'} */ ('valid'), getLexicon: async (nsid) => (nsid === FORUM ? forum : null), }; it('grants the collections the type declares', async () => { const { handlers, oauthTokens } = createServer({ spacesEnabled: true, lexiconResolver, }); const client = await dpopKeys(); const { verifier, challenge } = await pkcePair(); const requestUri = await par(handlers, client, challenge, { scope: `atproto space:${FORUM}?authority=*&manage=create`, }); const code = await approve(handlers, requestUri); const response = await token(handlers, client, { grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier, }); expect(response.status).toBe(200); const body = await response.json(); const granted = `atproto space:${FORUM}?authority=*&manage=create&collection=com.example.post&collection=com.example.reply`; expect(body.scope).toBe(granted); expect(oauthTokens.get(body.refresh_token).scope).toBe(granted); }); it('grants a type that does not resolve to a space as asked', async () => { const { handlers, oauthRequests } = createServer({ spacesEnabled: true, lexiconResolver: { ...lexiconResolver, getLexicon: async (nsid) => { if (nsid === 'com.example.broken') throw new Error('unreachable'); return nsid === FORUM ? forum : null; }, }, }); const client = await dpopKeys(); const { challenge } = await pkcePair(); const scope = `atproto space:com.example.unknown space:com.example.broken space:${FORUM}`; const requestUri = await par(handlers, client, challenge, { scope }); await approve(handlers, requestUri); expect(oauthRequests.get(requestUri).scope).toBe( `atproto space:com.example.unknown space:com.example.broken space:${FORUM}?collection=com.example.post&collection=com.example.reply`, ); }); it('grants every type as asked with no resolver, on the direct path', async () => { const { handlers, oauthRequests } = createServer({ spacesEnabled: true }); const response = await call(handlers, '/oauth/authorize', { search: { client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code', code_challenge: 'c'.repeat(43), code_challenge_method: 'S256', scope: `atproto space:${FORUM}`, }, }); expect(response.status).toBe(200); const [stored] = [...oauthRequests.values()]; expect(stored.scope).toBe(`atproto space:${FORUM}`); expect(stored.spaceTypes).toBeUndefined(); }); it('grants the declared collections on the direct path', async () => { const { handlers, oauthRequests } = createServer({ spacesEnabled: true, lexiconResolver, }); await call(handlers, '/oauth/authorize', { search: { client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code', code_challenge: 'c'.repeat(43), code_challenge_method: 'S256', scope: `atproto space:${FORUM}`, }, }); const [stored] = [...oauthRequests.values()]; expect(stored.spaceTypes).toEqual({ [FORUM]: ['com.example.post', 'com.example.reply'], }); }); }); // RFC 9449 ยง10.1 lets a pushed request carry the key thumbprint in dpop_jkt // instead of a proof, and the reference authorization server accepts a request // that carries neither. Clients in the wild send both shapes. describe('PAR without a DPoP proof', () => { /** * @param {ReturnType['handlers']} handlers * @param {string} challenge * @param {Record} [extra] */ function parWithoutProof(handlers, challenge, extra = {}) { return call(handlers, '/oauth/par', { headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code', code_challenge: challenge, code_challenge_method: 'S256', scope: 'atproto', state: 'st4te', ...extra, }).toString(), }); } it('takes the key thumbprint from dpop_jkt', async () => { const { handlers, oauthRequests } = createServer(); const { challenge } = await pkcePair(); const response = await parWithoutProof(handlers, challenge, { dpop_jkt: 'client-key-thumbprint', }); expect(response.status).toBe(201); const { request_uri } = await response.json(); expect(oauthRequests.get(request_uri).dpopJkt).toBe( 'client-key-thumbprint', ); }); it('binds the key at the token endpoint when neither is sent', async () => { const { handlers, oauthRequests, oauthTokens } = createServer(); const client = await dpopKeys(); const { verifier, challenge } = await pkcePair(); const response = await parWithoutProof(handlers, challenge); expect(response.status).toBe(201); const { request_uri } = await response.json(); expect(oauthRequests.get(request_uri).dpopJkt).toBeNull(); // The client still needs a nonce for the token request. expect(response.headers.get('DPoP-Nonce')).toBeTruthy(); const code = await approve(handlers, request_uri); const tokens = await token(handlers, client, { grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier, }); expect(tokens.status).toBe(200); const { refresh_token } = await tokens.json(); expect(oauthTokens.get(refresh_token).dpopJkt).toBeTruthy(); }); }); describe('PAR refusals', () => { it('refuses PAR on a read-only server', async () => { const { handlers } = createServer({ readOnly: true }); const response = await call(handlers, '/oauth/par', { headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: '', }); expect(response.status).toBe(401); }); it('rejects a dpop_jkt that contradicts the proof', async () => { const { handlers } = createServer(); const client = await dpopKeys(); const { challenge } = await pkcePair(); const response = await call(handlers, '/oauth/par', { headers: { 'content-type': 'application/x-www-form-urlencoded', DPoP: await dpopProof(client, `${ORIGIN}/oauth/par`), }, body: new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code', code_challenge: challenge, code_challenge_method: 'S256', dpop_jkt: 'some-other-thumbprint', }).toString(), }); expect(response.status).toBe(400); expect((await response.json()).error).toBe('invalid_dpop_proof'); }); it('hands a nonce to a proof that lacks one', async () => { const { handlers } = createServer(); const client = await dpopKeys(); // A proof built without the server nonce. const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: client.jwk }; const payload = { jti: crypto.randomUUID(), htm: 'POST', htu: `${ORIGIN}/oauth/par`, iat: Math.floor(Date.now() / 1000), }; const enc = (/** @type {object} */ o) => base64UrlEncode(new TextEncoder().encode(JSON.stringify(o))); const input = `${enc(header)}.${enc(payload)}`; const sig = new Uint8Array( await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, client.keys.privateKey, new TextEncoder().encode(input), ), ); const response = await call(handlers, '/oauth/par', { headers: { 'content-type': 'application/x-www-form-urlencoded', DPoP: `${input}.${base64UrlEncode(sig)}`, }, body: new URLSearchParams({ client_id: CLIENT_ID }).toString(), }); expect(response.status).toBe(400); expect((await response.json()).error).toBe('use_dpop_nonce'); expect(response.headers.get('DPoP-Nonce')).toBeTruthy(); }); it('accepts a loopback redirect on any port', async () => { const { handlers } = createServer(); const { challenge } = await pkcePair(); const requestUri = await par(handlers, await dpopKeys(), challenge, { client_id: 'http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback', redirect_uri: 'http://127.0.0.1:53817/callback', }); expect(requestUri).toMatch(/^urn:ietf:params:oauth:request_uri:/); }); it('rejects a loopback redirect to another host', async () => { const { handlers } = createServer(); const client = await dpopKeys(); const { challenge } = await pkcePair(); const response = await call(handlers, '/oauth/par', { headers: { 'content-type': 'application/x-www-form-urlencoded', DPoP: await dpopProof(client, `${ORIGIN}/oauth/par`), }, body: new URLSearchParams({ client_id: 'http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback', redirect_uri: 'http://[::1]:53817/callback', response_type: 'code', code_challenge: challenge, code_challenge_method: 'S256', }).toString(), }); expect(response.status).toBe(400); expect((await response.json()).error_description).toBe( 'redirect_uri not registered for client', ); }); it('rejects PKCE methods other than S256', async () => { const { handlers } = createServer(); const client = await dpopKeys(); const response = await call(handlers, '/oauth/par', { headers: { 'content-type': 'application/x-www-form-urlencoded', DPoP: await dpopProof(client, `${ORIGIN}/oauth/par`), }, body: new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code', code_challenge: 'abc', code_challenge_method: 'plain', }).toString(), }); expect(response.status).toBe(400); expect((await response.json()).error_description).toBe( 'PKCE with S256 required', ); }); }); describe('the token endpoint', () => { it('refuses token requests on a read-only server', async () => { const { handlers } = createServer({ readOnly: true }); const response = await call(handlers, '/oauth/token', { body: '' }); expect(response.status).toBe(401); }); it('requires a DPoP proof', async () => { const { handlers } = createServer(); const response = await call(handlers, '/oauth/token', { headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: '', }); expect(response.status).toBe(400); expect((await response.json()).error).toBe('invalid_dpop_proof'); }); it('names a grant type it does not serve', async () => { const { handlers } = createServer(); const client = await dpopKeys(); const response = await token(handlers, client, { grant_type: 'client_credentials', }); expect(response.status).toBe(400); expect((await response.json()).error).toBe('unsupported_grant_type'); }); it('rejects a code it never issued', async () => { const { handlers } = createServer(); const client = await dpopKeys(); const response = await token(handlers, client, { grant_type: 'authorization_code', code: 'forged', redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: 'v'.repeat(43), }); expect(response.status).toBe(400); expect((await response.json()).error).toBe('invalid_grant'); }); it('rejects a wrong code_verifier', async () => { const { handlers } = createServer(); const client = await dpopKeys(); const { challenge } = await pkcePair(); const requestUri = await par(handlers, client, challenge); const code = await approve(handlers, requestUri); const response = await token(handlers, client, { grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: 'not-the-verifier-that-made-the-challenge-123', }); expect(response.status).toBe(400); expect((await response.json()).message).toContain('PKCE'); }); it('rejects a redeem from a key other than the one PAR bound', async () => { const { handlers } = createServer(); const parKeys = await dpopKeys(); const { verifier, challenge } = await pkcePair(); const requestUri = await par(handlers, parKeys, challenge); const code = await approve(handlers, requestUri); const response = await token(handlers, await dpopKeys(), { grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier, }); expect(response.status).toBe(400); expect((await response.json()).error_description).toBe('DPoP key mismatch'); }); it('rejects a redeem whose redirect_uri or client_id differ', async () => { const { handlers } = createServer(); const client = await dpopKeys(); const { verifier, challenge } = await pkcePair(); const requestUri = await par(handlers, client, challenge); const code = await approve(handlers, requestUri); const wrongRedirect = await token(handlers, client, { grant_type: 'authorization_code', code, redirect_uri: 'http://localhost/elsewhere2', client_id: CLIENT_ID, code_verifier: verifier, }); expect((await wrongRedirect.json()).error_description).toBe( 'Redirect URI mismatch', ); const wrongClient = await token(handlers, client, { grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: 'http://other.example', code_verifier: verifier, }); expect((await wrongClient.json()).error_description).toBe( 'Client ID mismatch', ); }); }); describe('the refresh grant', () => { /** Walk the code grant and return what the client holds afterwards. */ async function establishSession( /** @type {ReturnType} */ server, ) { const client = await dpopKeys(); const { verifier, challenge } = await pkcePair(); const requestUri = await par(server.handlers, client, challenge); const code = await approve(server.handlers, requestUri); const response = await token(server.handlers, client, { grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier, }); const body = await response.json(); return { client, refreshToken: body.refresh_token }; } it('rotates the refresh token and keeps createdAt', async () => { const server = createServer(); const { client, refreshToken } = await establishSession(server); const before = server.oauthTokens.get(refreshToken); const response = await token(server.handlers, client, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID, }); expect(response.status).toBe(200); const body = await response.json(); expect(body.refresh_token).not.toBe(refreshToken); expect(server.oauthTokens.get(body.refresh_token).createdAt).toBe( before.createdAt, ); // The one just used is kept a moment, spent, pointing at its replacement. expect(server.oauthTokens.get(refreshToken).rotatedTo).toBe( body.refresh_token, ); }); // A browser app is many pages of one session. A reload, or a link followed // while a refresh is in flight, tears down the page holding the new tokens // before it can store them; the next page comes back with the old one. it('answers a page that comes back with the token just used', async () => { const server = createServer(); const { client, refreshToken } = await establishSession(server); const first = await ( await token(server.handlers, client, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID, }) ).json(); const again = await token(server.handlers, client, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID, }); expect(again.status).toBe(200); const body = await again.json(); // Both pages end up holding the same session, not two. expect(body.refresh_token).toBe(first.refresh_token); expect(body.sub).toBe(DID); expect(body.access_token).toBeTruthy(); }); // A laptop that goes to sleep while a refresh is in flight never gets the // reply, and comes back with the old token hours later. it('answers the token just used however much later, until its replacement is used', async () => { const server = createServer(); const { client, refreshToken } = await establishSession(server); const first = await ( await token(server.handlers, client, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID, }) ).json(); vi.useFakeTimers({ now: Date.now() + 7 * 60 * 60 * 1000, toFake: ['Date'], }); try { const late = await token(server.handlers, client, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID, }); expect(late.status).toBe(200); expect((await late.json()).refresh_token).toBe(first.refresh_token); } finally { vi.useRealTimers(); } }); it('refuses the spent token once its replacement has been used', async () => { const server = createServer(); const { client, refreshToken } = await establishSession(server); const first = await ( await token(server.handlers, client, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID, }) ).json(); // Used, but the one it replaced still held, as a race between the two // could leave it. const spent = server.oauthTokens.get(refreshToken); await token(server.handlers, client, { grant_type: 'refresh_token', refresh_token: first.refresh_token, client_id: CLIENT_ID, }); server.oauthTokens.set(refreshToken, spent); const response = await token(server.handlers, client, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID, }); expect(response.status).toBe(400); expect((await response.json()).error_description).toBe( 'Invalid refresh token', ); // And it is not kept around after that. expect(server.oauthTokens.has(refreshToken)).toBe(false); }); it('answers nothing for a session revoked since', async () => { const server = createServer(); const { client, refreshToken } = await establishSession(server); const { refresh_token: current } = await ( await token(server.handlers, client, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID, }) ).json(); server.oauthTokens.delete(current); const response = await token(server.handlers, client, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID, }); expect(response.status).toBe(400); }); it('keeps one spent token at a time, however often it refreshes', async () => { const server = createServer(); const { client, refreshToken } = await establishSession(server); let held = refreshToken; const used = []; for (let i = 0; i < 3; i++) { used.push(held); const body = await ( await token(server.handlers, client, { grant_type: 'refresh_token', refresh_token: held, client_id: CLIENT_ID, }) ).json(); held = body.refresh_token; } const kept = [...used, held].filter((t) => server.oauthTokens.has(t)); expect(kept).toEqual([used[2], held]); }); it('rejects a refresh token it does not hold', async () => { const server = createServer(); const { client } = await establishSession(server); const response = await token(server.handlers, client, { grant_type: 'refresh_token', refresh_token: 'forged', client_id: CLIENT_ID, }); expect(response.status).toBe(400); expect((await response.json()).error_description).toBe( 'Invalid refresh token', ); }); it('rejects a refresh from another client or another key', async () => { const server = createServer(); const { client, refreshToken } = await establishSession(server); const wrongClient = await token(server.handlers, client, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: 'http://other.example', }); expect((await wrongClient.json()).error_description).toBe( 'Client mismatch', ); const wrongKey = await token(server.handlers, await dpopKeys(), { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID, }); expect((await wrongKey.json()).error_description).toBe('DPoP key mismatch'); }); it('retires a refresh token past its two-week lifetime', async () => { const server = createServer(); const { client, refreshToken } = await establishSession(server); const row = server.oauthTokens.get(refreshToken); row.createdAt = Date.now() - 15 * 24 * 60 * 60 * 1000; const response = await token(server.handlers, client, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID, }); expect(response.status).toBe(400); expect((await response.json()).error_description).toBe( 'Refresh token expired', ); expect(server.oauthTokens.has(refreshToken)).toBe(false); }); describe('of a confidential client', () => { const CONFIDENTIAL_ID = 'https://client.example/metadata.json'; const DAY = 24 * 60 * 60 * 1000; afterEach(() => vi.unstubAllGlobals()); async function refreshAged( /** @type {{createdDaysAgo: number, updatedDaysAgo: number}} */ age, ) { const server = createServer(); const { client, refreshToken } = await establishSession(server); const row = server.oauthTokens.get(refreshToken); row.clientId = CONFIDENTIAL_ID; row.createdAt = Date.now() - age.createdDaysAgo * DAY; row.updatedAt = Date.now() - age.updatedDaysAgo * DAY; const pair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], ); const jwk = await crypto.subtle.exportKey('jwk', pair.publicKey); const metadata = { client_id: CONFIDENTIAL_ID, token_endpoint_auth_method: 'private_key_jwt', jwks: { keys: [{ ...jwk, kid: 'k' }] }, }; vi.stubGlobal('fetch', async () => Response.json(metadata)); const encode = (/** @type {object} */ part) => base64UrlEncode(new TextEncoder().encode(JSON.stringify(part))); const seconds = Math.floor(Date.now() / 1000); const signed = `${encode({ alg: 'ES256', kid: 'k' })}.${encode({ iss: CONFIDENTIAL_ID, sub: CONFIDENTIAL_ID, aud: ORIGIN, jti: crypto.randomUUID(), iat: seconds, exp: seconds + 60, })}`; const signature = await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, pair.privateKey, new TextEncoder().encode(signed), ); return token(server.handlers, client, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CONFIDENTIAL_ID, client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', client_assertion: `${signed}.${base64UrlEncode(new Uint8Array(signature))}`, }); } it('refreshes past two weeks while it is used', async () => { const response = await refreshAged({ createdDaysAgo: 400, updatedDaysAgo: 89, }); expect(response.status).toBe(200); }); it('retires a session unused for three months', async () => { const response = await refreshAged({ createdDaysAgo: 100, updatedDaysAgo: 91, }); expect((await response.json()).error_description).toBe( 'Refresh token expired', ); }); it('retires a session past two years, however used', async () => { const response = await refreshAged({ createdDaysAgo: 731, updatedDaysAgo: 0, }); expect((await response.json()).error_description).toBe( 'Refresh token expired', ); }); }); }); describe('revocation', () => { it('refuses revocation on a read-only server', async () => { const { handlers } = createServer({ readOnly: true }); const response = await call(handlers, '/oauth/revoke', { body: '' }); expect(response.status).toBe(401); }); it('requires a token and client_id', async () => { const { handlers } = createServer(); const response = await call(handlers, '/oauth/revoke', { headers: { 'content-type': 'application/json' }, body: JSON.stringify({}), }); expect(response.status).toBe(400); }); it('deletes the named refresh token', async () => { const server = createServer(); server.oauthTokens.set('rt1', { clientId: CLIENT_ID, did: DID }); const response = await call(server.handlers, '/oauth/revoke', { headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: 'rt1', client_id: CLIENT_ID, }).toString(), }); expect(response.status).toBe(200); expect(server.oauthTokens.has('rt1')).toBe(false); }); it('answers 200 for a token held by another client, without deleting it', async () => { const server = createServer(); server.oauthTokens.set('rt1', { clientId: 'http://other.example', did: DID, }); const response = await call(server.handlers, '/oauth/revoke', { headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: 'rt1', client_id: CLIENT_ID, }).toString(), }); expect(response.status).toBe(200); expect(server.oauthTokens.has('rt1')).toBe(true); }); });