chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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<string, any>} */
const oauthRequests = new Map();
/** @type {Map<string, any>} */
const oauthTokens = new Map();
/** @type {Set<string>} */
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<import('../packages/core/src/crypto.js').SigningKey>|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<ReturnType<typeof dpopKeys>>} 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<typeof createServer>['handlers']} handlers
* @param {string} path
* @param {{method?: string, headers?: Record<string, string>, body?: string, search?: Record<string, string>}} [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>|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<typeof createServer>['handlers']} handlers
* @param {Awaited<ReturnType<typeof dpopKeys>>} client
* @param {string} challenge
* @param {Record<string, string>} [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<typeof createServer>['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<typeof createServer>['handlers']} handlers
* @param {Awaited<ReturnType<typeof dpopKeys>>} client
* @param {Record<string, string>} 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<typeof createServer>['handlers']} handlers
* @param {string} challenge
* @param {Record<string, string>} [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<typeof createServer>} */ 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);
});
});