import { base64UrlEncode } from '@pdsjs/core/crypto'; import { getLoopbackClientMetadata, isLoopbackClient, isValidDpopNonce, mintDpopNonce, validateClientMetadata, } from '@pdsjs/core/oauth'; import { PersonalDataServer } from '@pdsjs/core/pds'; import { describe, expect, test } from 'vitest'; describe('Client Metadata', () => { test('isLoopbackClient detects localhost', () => { expect(isLoopbackClient('http://localhost:8080')).toBe(true); expect(isLoopbackClient('http://127.0.0.1:3000')).toBe(true); expect(isLoopbackClient('https://example.com')).toBe(false); }); test('getLoopbackClientMetadata returns permissive defaults', () => { const metadata = getLoopbackClientMetadata('http://localhost:8080'); expect(metadata.client_id).toBe('http://localhost:8080'); expect(metadata.grant_types.includes('authorization_code')).toBe(true); expect(metadata.dpop_bound_access_tokens).toBe(true); }); test('validateClientMetadata rejects mismatched client_id', () => { const metadata = { client_id: 'https://other.com/metadata.json', redirect_uris: ['https://example.com/callback'], grant_types: ['authorization_code'], response_types: ['code'], }; expect(() => validateClientMetadata(metadata, 'https://example.com/metadata.json'), ).toThrow(/client_id mismatch/); }); }); describe('DPoP nonces', () => { const SECRET = 'test-secret'; const WINDOW_MS = 5 * 60 * 1000; test('nonce validates within its own window', async () => { const now = Date.now(); const nonce = await mintDpopNonce(SECRET, now); expect(typeof nonce).toBe('string'); expect(nonce.length).toBeGreaterThan(10); expect(await isValidDpopNonce(SECRET, nonce, now)).toBe(true); }); test('nonce from previous window still validates', async () => { const now = Date.now(); const nonce = await mintDpopNonce(SECRET, now - WINDOW_MS); expect(await isValidDpopNonce(SECRET, nonce, now)).toBe(true); }); test('nonce two windows old is rejected', async () => { const now = Date.now(); const nonce = await mintDpopNonce(SECRET, now - 2 * WINDOW_MS); expect(await isValidDpopNonce(SECRET, nonce, now)).toBe(false); }); test('garbage nonces are rejected', async () => { expect(await isValidDpopNonce(SECRET, 'not-a-nonce', Date.now())).toBe( false, ); expect(await isValidDpopNonce(SECRET, '', Date.now())).toBe(false); }); test('nonces are secret-bound', async () => { const now = Date.now(); const nonce = await mintDpopNonce('other-secret', now); expect(await isValidDpopNonce(SECRET, nonce, now)).toBe(false); }); }); // The resource-server side of OAuth: keeping a client's DPoP nonce fresh on // ordinary traffic, and telling it to refresh (not re-login) when its access // token expires. Mirrors the reference PDS resource verifier. const JWT_SECRET = 'test-secret'; const XRPC_URL = 'https://pds.example/xrpc/com.atproto.repo.getRecord'; /** The smallest server exposing the resource-auth helpers. */ function makeServer() { return new PersonalDataServer(/** @type {any} */ ({ jwtSecret: JWT_SECRET })); } /** * A fake DPoP access token whose payload decodes to `payload`. The header and * signature are placeholders: verifyDpopToken checks token expiry (and the * presence of a DPoP proof) before it ever verifies the signature, so these * tests never need the account signing key. * @param {Record} payload */ function fakeAccessToken(payload) { const b64 = (/** @type {unknown} */ obj) => base64UrlEncode(new TextEncoder().encode(JSON.stringify(obj))); return `${b64({ typ: 'at+jwt', alg: 'ES256' })}.${b64(payload)}.sig`; } describe('resource DPoP-Nonce emission', () => { test('adds a fresh, valid nonce to responses for DPoP requests', async () => { const pds = makeServer(); const request = new Request(XRPC_URL, { headers: { authorization: 'DPoP sometoken', DPoP: 'proof' }, }); const decorated = await pds.withDpopNonce( request, Response.json({ ok: true }), ); const nonce = decorated.headers.get('DPoP-Nonce'); expect(nonce).toBeTruthy(); // The nonce the client caches must validate on its next token refresh. expect(await isValidDpopNonce(JWT_SECRET, nonce || '')).toBe(true); expect(decorated.status).toBe(200); }); test('leaves non-DPoP (Bearer / anonymous) responses untouched', async () => { const pds = makeServer(); const request = new Request(XRPC_URL, { headers: { authorization: 'Bearer legacy' }, }); const decorated = await pds.withDpopNonce( request, Response.json({ ok: true }), ); expect(decorated.headers.get('DPoP-Nonce')).toBe(null); }); test('does not overwrite a nonce an OAuth endpoint already set', async () => { const pds = makeServer(); const request = new Request('https://pds.example/oauth/token', { headers: { authorization: 'DPoP sometoken' }, }); const decorated = await pds.withDpopNonce( request, Response.json({ ok: true }, { headers: { 'DPoP-Nonce': 'preset' } }), ); expect(decorated.headers.get('DPoP-Nonce')).toBe('preset'); }); }); describe('resource DPoP auth challenge', () => { test('expired access token yields an invalid_token challenge', async () => { const pds = makeServer(); const token = fakeAccessToken({ sub: 'did:plc:test', exp: Math.floor(Date.now() / 1000) - 100, }); const request = new Request(XRPC_URL, { headers: { authorization: `DPoP ${token}`, DPoP: 'proof' }, }); const { auth, challenge, tokenError } = await pds.authenticateResource(request); expect(auth).toBe(null); // invalid_token is the signal a spec-compliant client refreshes on, rather // than treating the 401 as a hard logout. expect(challenge).toContain('error="invalid_token"'); expect(challenge).toContain('Access token expired'); // The response body carries the same `invalid_token` code, so clients that // branch on the body's `error` rather than the WWW-Authenticate header // still know to refresh. Mirrors the reference PDS. expect(tokenError).toBe('invalid_token'); }); test('missing DPoP proof header yields a challenge, no auth', async () => { const pds = makeServer(); const token = fakeAccessToken({ sub: 'did:plc:test' }); const request = new Request(XRPC_URL, { headers: { authorization: `DPoP ${token}` }, }); const { auth, challenge, tokenError } = await pds.authenticateResource(request); expect(auth).toBe(null); expect(challenge).toContain('error="invalid_token"'); expect(tokenError).toBe('invalid_token'); }); test('no Authorization header yields no auth, no challenge, no tokenError', async () => { const pds = makeServer(); const { auth, challenge, tokenError } = await pds.authenticateResource( new Request(XRPC_URL), ); expect(auth).toBe(null); expect(challenge).toBe(null); expect(tokenError).toBe(undefined); }); });