a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm
101
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(xrpc-server)!: harden service JWT verification

- add `nbf` (not-before) validation with configurable `clockLeeway`
(default 5s) on `exp`/`nbf`.
- add `maxAge` option (default 300s) that bounds how far `exp` may be in
the future and `iat` may be in the past, preventing long-lived tokens.
- add optional `replayStore` for nonce/replay protection. when set, tokens
must carry a `jti` claim and the store is consulted with
`{ iss, jti }` per verification; duplicates are rejected as
`NonceNotUnique`.
- collapse the verifier surface onto a single throwing
`verifyRequest(request, options)` method that parses the `Authorization`
header, verifies the bearer token, forwards `request.signal` (or
`options.signal`) to DID resolution, and throws `AuthRequiredError`
with a populated `WWW-Authenticate: Bearer` challenge on failure.
- align `AuthError.error` vocabulary with the atproto reference SDK:
`BadJwt` (was `MalformedJwt`), `DidResolutionFailed` (was
`UnresolvedDidDocument`), `InvalidAudience` (was `BadJwtAudience`);
new codes `MissingBearer`, `JwtNotYetValid`, `JwtTooOld`,
`NonceNotUnique`.

Mary bdd2ed1e d9a05fea

+369 -74
+17
.changeset/xrpc-server-jwt-hardening.md
··· 1 + --- 2 + '@atcute/xrpc-server': major 3 + --- 4 + 5 + harden service JWT verification. 6 + 7 + - collapse `ServiceJwtVerifier.verify()` onto a single throwing method 8 + `verifyRequest(request, { lxm, signal? })`. it parses the `Authorization: Bearer` header, forwards 9 + the signal into DID resolution, and throws `AuthRequiredError` with a populated 10 + `WWW-Authenticate: Bearer` challenge on failure. 11 + - add `nbf` validation, `maxAge` bound (default 300s) on `exp`/`iat`, and `clockLeeway` (default 5s) 12 + for `exp`/`nbf` comparisons. 13 + - add optional `replayStore` for nonce/replay protection. when set, tokens must carry a `jti` and 14 + the store is consulted with `{ iss, jti }` per verification. 15 + - align `AuthError.error` vocabulary with the atproto reference SDK: `BadJwt` (was `MalformedJwt`), 16 + `DidResolutionFailed` (was `UnresolvedDidDocument`), `InvalidAudience` (was `BadJwtAudience`), 17 + plus new `MissingBearer`, `JwtNotYetValid`, `JwtTooOld`, `NonceNotUnique`.
+26 -17
packages/servers/xrpc-server/README.md
··· 267 267 verifying incoming JWTs: 268 268 269 269 ```ts 270 - import { AuthRequiredError } from '@atcute/xrpc-server'; 271 - import { ServiceJwtVerifier, type VerifiedJwt } from '@atcute/xrpc-server/auth'; 270 + import { ServiceJwtVerifier } from '@atcute/xrpc-server/auth'; 272 271 import { 273 272 CompositeDidDocumentResolver, 274 273 PlcDidDocumentResolver, ··· 288 287 }), 289 288 }); 290 289 291 - const verifyServiceAuth = async (request: Request, lxm: string): Promise<VerifiedJwt> => { 292 - const authHeader = request.headers.get('authorization'); 293 - if (!authHeader?.startsWith('Bearer ')) { 294 - throw new AuthRequiredError({ description: `missing or invalid authorization header` }); 295 - } 290 + router.addQuery(ComExampleProtectedEndpoint, { 291 + async handler({ request }) { 292 + const auth = await jwtVerifier.verifyRequest(request, { lxm: 'com.example.protectedEndpoint' }); 293 + return json({ caller: auth.issuer }); 294 + }, 295 + }); 296 + ``` 296 297 297 - const result = await jwtVerifier.verify(authHeader.slice(7), { lxm }); 298 - if (!result.ok) { 299 - throw new AuthRequiredError({ description: result.error.description }); 300 - } 298 + `verifyRequest` parses the `Authorization: Bearer` header, verifies the token, and throws an 299 + `AuthRequiredError` with a populated `WWW-Authenticate: Bearer error="…"` challenge on every failure 300 + path. it forwards `request.signal` into DID resolution so aborted requests don't keep network calls 301 + alive. 301 302 302 - return result.value; 303 - }; 303 + additional options tune verification: 304 304 305 - router.addQuery(ComExampleProtectedEndpoint, { 306 - async handler({ request }) { 307 - const auth = await verifyServiceAuth(request, 'com.example.protectedEndpoint'); 308 - return json({ caller: auth.issuer }); 305 + ```ts 306 + const jwtVerifier = new ServiceJwtVerifier({ 307 + acceptAudiences: [...], 308 + resolver: ..., 309 + maxAge: 300, // max token lifetime window in seconds (default 300) 310 + clockLeeway: 5, // leeway applied to nbf/exp comparisons (default 5) 311 + replayStore: { // optional replay protection; requires jti in tokens 312 + async check({ iss, jti }, ttlSeconds) { 313 + const key = `${iss}:${jti}`; 314 + const isNew = await redis.setnx(key, '1'); 315 + if (isNew === 1) await redis.expire(key, ttlSeconds); 316 + return isNew === 1; 317 + }, 309 318 }, 310 319 }); 311 320 ```
+156 -44
packages/servers/xrpc-server/lib/auth/jwt-verifier.test.ts
··· 6 6 7 7 import { beforeAll, describe, expect, it } from 'vitest'; 8 8 9 + import { AuthRequiredError } from '../main/xrpc-error.ts'; 10 + 9 11 import { createServiceJwt } from './jwt-creator.ts'; 10 - import { ServiceJwtVerifier } from './jwt-verifier.ts'; 12 + import { ServiceJwtVerifier, type ReplayStore } from './jwt-verifier.ts'; 11 13 12 14 // re-sign a header/payload pair with the given keypair, producing a valid-signature JWT. used 13 15 // to construct tokens that exercise code paths `createServiceJwt` wouldn't (e.g. custom `kid`, 14 - // missing `lxm`). 16 + // missing `lxm`, synthetic `nbf`, stale `iat`). 15 17 const signRaw = async (keypair: PrivateKeyExportable, header: unknown, payload: unknown): Promise<string> => { 16 18 const encode = (data: unknown) => toBase64Url(encodeUtf8(JSON.stringify(data))); 17 19 ··· 24 26 25 27 const decodePortion = <T>(part: string): T => { 26 28 return JSON.parse(decodeUtf8From(fromBase64Url(part))); 29 + }; 30 + 31 + const bearer = (jwt: string): Request => { 32 + return new Request('http://example.com/xrpc/com.example.method', { 33 + headers: { authorization: `Bearer ${jwt}` }, 34 + }); 35 + }; 36 + 37 + const expectAuthError = async (promise: Promise<unknown>, code: string): Promise<AuthRequiredError> => { 38 + await expect(promise).rejects.toBeInstanceOf(AuthRequiredError); 39 + const err = await promise.catch((e) => e as AuthRequiredError); 40 + expect(err.headers).toBeInstanceOf(Headers); 41 + const headerValue = (err.headers as Headers).get('www-authenticate'); 42 + expect(headerValue).toContain(`error="${code}"`); 43 + return err; 27 44 }; 28 45 29 46 describe('ServiceJwtVerifier', () => { ··· 68 85 lxm, 69 86 }); 70 87 71 - const result = await verifier.verify(jwt, { lxm }); 72 - expect(result.ok).toBe(true); 73 - 74 - if (result.ok) { 75 - expect(result.value).toEqual({ audience: audienceDid, issuer: issuerDid, lxm }); 76 - } 88 + const result = await verifier.verifyRequest(bearer(jwt), { lxm }); 89 + expect(result).toEqual({ audience: audienceDid, issuer: issuerDid, lxm }); 77 90 }); 78 91 79 92 it('verifies a JWT with DID+fragment audience', async () => { ··· 84 97 85 98 const jwt = await createServiceJwt({ keypair, issuer: issuerDid, audience: audienceRef, lxm }); 86 99 87 - const result = await verifier.verify(jwt, { lxm }); 88 - expect(result.ok).toBe(true); 89 - 90 - if (result.ok) { 91 - expect(result.value.audience).toBe(audienceRef); 92 - } 100 + const result = await verifier.verifyRequest(bearer(jwt), { lxm }); 101 + expect(result.audience).toBe(audienceRef); 93 102 }); 94 103 95 104 it('accepts either form when multiple audiences are configured', async () => { ··· 100 109 101 110 for (const aud of [audienceDid, audienceRef] as const) { 102 111 const jwt = await createServiceJwt({ keypair, issuer: issuerDid, audience: aud, lxm }); 103 - const result = await verifier.verify(jwt, { lxm }); 104 - 105 - expect(result.ok).toBe(true); 112 + await expect(verifier.verifyRequest(bearer(jwt), { lxm })).resolves.toBeTruthy(); 106 113 } 107 114 }); 108 115 ··· 114 121 115 122 const jwt = await createServiceJwt({ keypair, issuer: issuerDid, audience: audienceDid, lxm }); 116 123 117 - const result = await verifier.verify(jwt, { lxm }); 118 - 119 - expect(result.ok).toBe(false); 120 - if (!result.ok) { 121 - expect(result.error.error).toBe('BadJwtAudience'); 122 - } 124 + await expectAuthError(verifier.verifyRequest(bearer(jwt), { lxm }), 'InvalidAudience'); 123 125 }); 124 126 125 127 it('skips audience validation when acceptAudiences is null', async () => { ··· 135 137 lxm, 136 138 }); 137 139 138 - const result = await verifier.verify(jwt, { lxm }); 139 - expect(result.ok).toBe(true); 140 + await expect(verifier.verifyRequest(bearer(jwt), { lxm })).resolves.toBeTruthy(); 140 141 }); 141 142 142 143 it('rejects every audience when acceptAudiences is an empty array', async () => { ··· 147 148 148 149 const jwt = await createServiceJwt({ keypair, issuer: issuerDid, audience: audienceDid, lxm }); 149 150 150 - const result = await verifier.verify(jwt, { lxm }); 151 - 152 - expect(result.ok).toBe(false); 153 - if (!result.ok) { 154 - expect(result.error.error).toBe('BadJwtAudience'); 155 - } 151 + await expectAuthError(verifier.verifyRequest(bearer(jwt), { lxm }), 'InvalidAudience'); 156 152 }); 157 153 158 154 it('rejects a JWT with an unsupported `kid` header', async () => { ··· 169 165 const payload = decodePortion<Record<string, unknown>>(payloadB64); 170 166 const tampered = await signRaw(keypair, header, payload); 171 167 172 - const result = await verifier.verify(tampered, { lxm }); 173 - 174 - expect(result.ok).toBe(false); 175 - if (!result.ok) { 176 - expect(result.error.error).toBe('BadJwtIssuer'); 177 - } 168 + await expectAuthError(verifier.verifyRequest(bearer(tampered), { lxm }), 'BadJwtIssuer'); 178 169 }); 179 170 180 171 it('accepts a JWT with `kid: "#atproto"`', async () => { ··· 191 182 const payload = decodePortion<Record<string, unknown>>(payloadB64); 192 183 const signed = await signRaw(keypair, header, payload); 193 184 194 - const result = await verifier.verify(signed, { lxm }); 195 - expect(result.ok).toBe(true); 185 + await expect(verifier.verifyRequest(bearer(signed), { lxm })).resolves.toBeTruthy(); 196 186 }); 197 187 198 188 it('rejects a JWT missing the `lxm` claim', async () => { ··· 209 199 const { lxm: _, ...payload } = decodePortion<Record<string, unknown>>(payloadB64); 210 200 const signed = await signRaw(keypair, header, payload); 211 201 212 - const result = await verifier.verify(signed, { lxm }); 202 + await expectAuthError(verifier.verifyRequest(bearer(signed), { lxm }), 'BadJwt'); 203 + }); 213 204 214 - expect(result.ok).toBe(false); 215 - if (!result.ok) { 216 - expect(result.error.error).toBe('MalformedJwt'); 217 - } 205 + it('rejects when the Authorization header is missing', async () => { 206 + const verifier = new ServiceJwtVerifier({ 207 + acceptAudiences: [audienceDid], 208 + resolver: makeResolver(keypair), 209 + }); 210 + 211 + const request = new Request('http://example.com/xrpc/com.example.method'); 212 + await expect(verifier.verifyRequest(request, { lxm })).rejects.toBeInstanceOf(AuthRequiredError); 213 + }); 214 + 215 + it('rejects when the Authorization header does not use Bearer', async () => { 216 + const verifier = new ServiceJwtVerifier({ 217 + acceptAudiences: [audienceDid], 218 + resolver: makeResolver(keypair), 219 + }); 220 + 221 + const request = new Request('http://example.com/xrpc/com.example.method', { 222 + headers: { authorization: 'Basic abc' }, 223 + }); 224 + 225 + await expectAuthError(verifier.verifyRequest(request, { lxm }), 'MissingBearer'); 226 + }); 227 + 228 + it('rejects an expired JWT', async () => { 229 + const verifier = new ServiceJwtVerifier({ 230 + acceptAudiences: [audienceDid], 231 + resolver: makeResolver(keypair), 232 + }); 233 + 234 + const now = Math.floor(Date.now() / 1_000); 235 + const jwt = await createServiceJwt({ 236 + keypair, 237 + issuer: issuerDid, 238 + audience: audienceDid, 239 + lxm, 240 + issuedAt: now - 120, 241 + expiresIn: 60, 242 + }); 243 + 244 + await expectAuthError(verifier.verifyRequest(bearer(jwt), { lxm }), 'JwtExpired'); 245 + }); 246 + 247 + it('rejects a JWT whose `nbf` is in the future', async () => { 248 + const verifier = new ServiceJwtVerifier({ 249 + acceptAudiences: [audienceDid], 250 + resolver: makeResolver(keypair), 251 + }); 252 + 253 + const now = Math.floor(Date.now() / 1_000); 254 + const [headerB64, payloadB64] = ( 255 + await createServiceJwt({ keypair, issuer: issuerDid, audience: audienceDid, lxm }) 256 + ).split('.'); 257 + 258 + const header = decodePortion<Record<string, unknown>>(headerB64); 259 + const payload = { ...decodePortion<Record<string, unknown>>(payloadB64), nbf: now + 120 }; 260 + const signed = await signRaw(keypair, header, payload); 261 + 262 + await expectAuthError(verifier.verifyRequest(bearer(signed), { lxm }), 'JwtNotYetValid'); 263 + }); 264 + 265 + it('rejects a JWT exceeding the configured maxAge', async () => { 266 + const verifier = new ServiceJwtVerifier({ 267 + acceptAudiences: [audienceDid], 268 + resolver: makeResolver(keypair), 269 + maxAge: 60, 270 + }); 271 + 272 + const now = Math.floor(Date.now() / 1_000); 273 + const jwt = await createServiceJwt({ 274 + keypair, 275 + issuer: issuerDid, 276 + audience: audienceDid, 277 + lxm, 278 + issuedAt: now, 279 + expiresIn: 3600, 280 + }); 281 + 282 + await expectAuthError(verifier.verifyRequest(bearer(jwt), { lxm }), 'JwtTooOld'); 283 + }); 284 + 285 + it('consults the replay store and rejects duplicates', async () => { 286 + const seen = new Set<string>(); 287 + const replayStore: ReplayStore = { 288 + async check({ iss, jti }) { 289 + const key = `${iss}:${jti}`; 290 + if (seen.has(key)) return false; 291 + seen.add(key); 292 + return true; 293 + }, 294 + }; 295 + 296 + const verifier = new ServiceJwtVerifier({ 297 + acceptAudiences: [audienceDid], 298 + resolver: makeResolver(keypair), 299 + replayStore, 300 + }); 301 + 302 + const jwt = await createServiceJwt({ keypair, issuer: issuerDid, audience: audienceDid, lxm }); 303 + 304 + await expect(verifier.verifyRequest(bearer(jwt), { lxm })).resolves.toBeTruthy(); 305 + await expectAuthError(verifier.verifyRequest(bearer(jwt), { lxm }), 'NonceNotUnique'); 306 + }); 307 + 308 + it('rejects a JWT without jti when a replay store is configured', async () => { 309 + const replayStore: ReplayStore = { 310 + async check() { 311 + return true; 312 + }, 313 + }; 314 + 315 + const verifier = new ServiceJwtVerifier({ 316 + acceptAudiences: [audienceDid], 317 + resolver: makeResolver(keypair), 318 + replayStore, 319 + }); 320 + 321 + const [headerB64, payloadB64] = ( 322 + await createServiceJwt({ keypair, issuer: issuerDid, audience: audienceDid, lxm }) 323 + ).split('.'); 324 + 325 + const header = decodePortion<Record<string, unknown>>(headerB64); 326 + const { jti: _, ...payload } = decodePortion<Record<string, unknown>>(payloadB64); 327 + const signed = await signRaw(keypair, header, payload); 328 + 329 + await expectAuthError(verifier.verifyRequest(bearer(signed), { lxm }), 'BadJwt'); 218 330 }); 219 331 });
+163 -10
packages/servers/xrpc-server/lib/auth/jwt-verifier.ts
··· 5 5 import type { AtprotoAudience } from '@atcute/lexicons/syntax'; 6 6 import * as uint8arrays from '@atcute/uint8array'; 7 7 8 + import { AuthRequiredError } from '../main/xrpc-error.ts'; 8 9 import type { Result } from '../types/misc.ts'; 9 10 10 11 import { parseJwt, type ParsedJwt } from './jwt.ts'; 11 12 import type { AuthError } from './types.ts'; 12 13 14 + type SupportedKid = `#${string}`; 13 15 /** only `#atproto` is accepted as a signing key identifier for now */ 14 - const DEFAULT_KID = '#atproto'; 15 - type SupportedKid = typeof DEFAULT_KID; 16 + const DEFAULT_KID: SupportedKid = '#atproto'; 17 + 18 + /** 19 + * replay-protection store for service JWTs. when configured on a verifier, 20 + * tokens must carry a `jti` claim and the verifier consults this store to 21 + * reject duplicates. 22 + */ 23 + export interface ReplayStore { 24 + /** 25 + * record a `(iss, jti)` pair seen now. 26 + * 27 + * @param key issuer + token identifier; implementations decide how to 28 + * encode this into a storage key. 29 + * @param ttlSeconds how long the entry must be retained. implementations 30 + * are free to retain it for longer. 31 + * @returns `true` if the pair was previously unseen (token is unique), 32 + * `false` if the pair has been recorded before (replay). 33 + */ 34 + check(key: { iss: Did; jti: string }, ttlSeconds: number): Promise<boolean>; 35 + } 16 36 17 37 export interface ServiceJwtVerifierOptions { 18 38 /** ··· 24 44 */ 25 45 acceptAudiences: (Did | AtprotoAudience)[] | null; 26 46 resolver: DidDocumentResolver; 47 + /** 48 + * maximum token lifetime window in seconds. rejects tokens whose `exp` is 49 + * more than this far in the future or whose `iat` is more than this far in 50 + * the past. defaults to 300 (5 minutes), matching atproto convention. 51 + */ 52 + maxAge?: number; 53 + /** 54 + * clock-skew leeway in seconds applied to `exp` and `nbf` comparisons. 55 + * defaults to 5 seconds. 56 + */ 57 + clockLeeway?: number; 58 + /** 59 + * optional replay-protection store. when provided, tokens must carry a 60 + * `jti` claim and the verifier rejects any `(iss, jti)` the store reports 61 + * as previously seen. 62 + */ 63 + replayStore?: ReplayStore; 27 64 } 28 65 29 66 export interface VerifyJwtOptions { 30 67 lxm: Nsid | Nsid[]; 68 + /** abort signal forwarded to DID resolution; falls back to `request.signal` in `verifyRequest`. */ 69 + signal?: AbortSignal; 31 70 } 32 71 33 72 export interface VerifiedJwt { ··· 36 75 lxm: Nsid; 37 76 } 38 77 78 + const BEARER_PREFIX = 'Bearer '; 79 + 39 80 export class ServiceJwtVerifier { 40 81 didDocResolver: DidDocumentResolver; 41 82 acceptAudiences: (Did | AtprotoAudience)[] | null; 83 + maxAge: number; 84 + clockLeeway: number; 85 + replayStore?: ReplayStore; 42 86 43 87 constructor(options: ServiceJwtVerifierOptions) { 44 88 this.didDocResolver = options.resolver; 45 89 this.acceptAudiences = options.acceptAudiences; 90 + this.maxAge = options.maxAge ?? 5 * 60; 91 + this.clockLeeway = options.clockLeeway ?? 5; 92 + this.replayStore = options.replayStore; 93 + } 94 + 95 + /** 96 + * parse the Authorization header, verify the bearer token, and return the 97 + * validated claims. throws {@link AuthRequiredError} with a populated 98 + * `WWW-Authenticate: Bearer` challenge on every failure path. 99 + * 100 + * @param request incoming request; `request.signal` is forwarded to DID 101 + * resolution unless `options.signal` overrides it. 102 + * @param options verification options; `lxm` restricts which lexicon 103 + * methods the token is allowed to invoke. 104 + * @throws {AuthRequiredError} on missing header, malformed token, 105 + * signature mismatch, audience/lxm rejection, replay, or expiry. 106 + */ 107 + async verifyRequest(request: Request, options: VerifyJwtOptions): Promise<VerifiedJwt> { 108 + const authorization = request.headers.get('authorization'); 109 + if (authorization === null) { 110 + throw new AuthRequiredError({ 111 + message: 'authorization header required', 112 + wwwAuthenticate: { scheme: 'Bearer' }, 113 + }); 114 + } 115 + 116 + if (!authorization.startsWith(BEARER_PREFIX)) { 117 + throw authError({ error: 'MissingBearer', description: 'expected a bearer token' }); 118 + } 119 + 120 + const token = authorization.slice(BEARER_PREFIX.length).trim(); 121 + const signal = options.signal ?? request.signal; 122 + 123 + const result = await this.#verifyToken(token, { lxm: options.lxm, signal }); 124 + if (!result.ok) { 125 + throw authError(result.error); 126 + } 127 + 128 + return result.value; 46 129 } 47 130 48 131 async #getSigningKey( 49 132 issuer: Did, 50 133 kid: SupportedKid, 51 134 noCache: boolean, 135 + signal: AbortSignal, 52 136 ): Promise<Result<FoundPublicKey, AuthError>> { 53 137 let didDocument: DidDocument; 54 138 let key: FoundPublicKey; 55 139 56 140 try { 57 - didDocument = await this.didDocResolver.resolve(issuer, { noCache }); 141 + didDocument = await this.didDocResolver.resolve(issuer, { noCache, signal }); 58 142 } catch { 59 143 return { 60 144 ok: false, 61 145 error: { 62 - error: 'UnresolvedDidDocument', 146 + error: 'DidResolutionFailed', 63 147 description: `failed to retrieve did document for ${issuer}`, 64 148 }, 65 149 }; ··· 108 192 } 109 193 } 110 194 111 - async verify(jwtString: string, options: VerifyJwtOptions): Promise<Result<VerifiedJwt, AuthError>> { 112 - const parsed = parseJwt(jwtString); 195 + async #verifyToken( 196 + token: string, 197 + options: { lxm: Nsid | Nsid[]; signal: AbortSignal }, 198 + ): Promise<Result<VerifiedJwt, AuthError>> { 199 + const parsed = parseJwt(token); 113 200 if (!parsed.ok) { 114 201 return parsed; 115 202 } ··· 144 231 }; 145 232 } 146 233 147 - if (Date.now() / 1_000 > payload.exp) { 234 + const now = Math.floor(Date.now() / 1_000); 235 + 236 + if (payload.nbf !== undefined && now < payload.nbf - this.clockLeeway) { 237 + return { 238 + ok: false, 239 + error: { 240 + error: 'JwtNotYetValid', 241 + description: `jwt is not yet valid`, 242 + }, 243 + }; 244 + } 245 + 246 + if (now > payload.exp + this.clockLeeway) { 148 247 return { 149 248 ok: false, 150 249 error: { ··· 154 253 }; 155 254 } 156 255 256 + // prevent issuers from minting very long-lived tokens: the configured max-age 257 + // window bounds how far `exp` can be in the future and how far `iat` can be in 258 + // the past. 259 + if ( 260 + payload.exp - now > this.maxAge || 261 + (payload.iat !== undefined && now - payload.iat > this.maxAge) 262 + ) { 263 + return { 264 + ok: false, 265 + error: { 266 + error: 'JwtTooOld', 267 + description: `jwt exceeds maximum age (${this.maxAge}s)`, 268 + }, 269 + }; 270 + } 271 + 157 272 if (this.acceptAudiences !== null && !this.acceptAudiences.includes(payload.aud)) { 158 273 return { 159 274 ok: false, 160 275 error: { 161 - error: 'BadJwtAudience', 276 + error: 'InvalidAudience', 162 277 description: 163 278 this.acceptAudiences.length === 0 164 279 ? `jwt audience does not match (no audiences accepted)` ··· 177 292 }; 178 293 } 179 294 180 - const key = await this.#getSigningKey(payload.iss, kid, false); 295 + let jti: string | undefined; 296 + if (this.replayStore !== undefined) { 297 + if (payload.jti === undefined) { 298 + return { 299 + ok: false, 300 + error: { 301 + error: 'BadJwt', 302 + description: `jwt is missing the jti claim (required for replay protection)`, 303 + }, 304 + }; 305 + } 306 + 307 + jti = payload.jti; 308 + } 309 + 310 + const key = await this.#getSigningKey(payload.iss, kid, false, options.signal); 181 311 if (!key.ok) { 182 312 return key; 183 313 } ··· 195 325 196 326 if (!isValid) { 197 327 // try again, uncached 198 - const freshKey = await this.#getSigningKey(payload.iss, kid, true); 328 + const freshKey = await this.#getSigningKey(payload.iss, kid, true, options.signal); 199 329 if (!freshKey.ok) { 200 330 return freshKey; 201 331 } ··· 233 363 }; 234 364 } 235 365 366 + // replay-store check runs after signature verification so forged tokens 367 + // can't burn entries (memory dos) or evict legitimate `(iss, jti)` pairs 368 + // before the real request lands. 369 + if (this.replayStore !== undefined && jti !== undefined) { 370 + const unique = await this.replayStore.check({ iss: payload.iss, jti }, this.maxAge); 371 + if (!unique) { 372 + return { 373 + ok: false, 374 + error: { 375 + error: 'NonceNotUnique', 376 + description: `jwt has been used before`, 377 + }, 378 + }; 379 + } 380 + } 381 + 236 382 return { 237 383 ok: true, 238 384 value: { ··· 243 389 }; 244 390 } 245 391 } 392 + 393 + const authError = (err: AuthError): AuthRequiredError => { 394 + return new AuthRequiredError({ 395 + message: err.description, 396 + wwwAuthenticate: { scheme: 'Bearer', params: { error: err.error } }, 397 + }); 398 + };
+7 -3
packages/servers/xrpc-server/lib/auth/jwt.ts
··· 36 36 aud: Did | AtprotoAudience; 37 37 exp: number; 38 38 iat?: number; 39 + /** not-before time; token is invalid before this unix timestamp */ 40 + nbf?: number; 39 41 lxm: Nsid; 40 42 jti?: string; 41 43 } ··· 50 52 exp: integer, 51 53 /** creation time */ 52 54 iat: integer.optional(), 55 + /** not-before time */ 56 + nbf: integer.optional(), 53 57 /** xrpc operation being invoked; required per atproto service auth spec */ 54 58 lxm: nsidString, 55 59 /** unique identifier */ ··· 81 85 return { 82 86 ok: false, 83 87 error: { 84 - error: `MalformedJwt`, 88 + error: `BadJwt`, 85 89 description: `jwt is malformed`, 86 90 }, 87 91 }; ··· 95 99 return { 96 100 ok: false, 97 101 error: { 98 - error: `MalformedJwt`, 102 + error: `BadJwt`, 99 103 description: `jwt is malformed`, 100 104 }, 101 105 }; ··· 107 111 return { 108 112 ok: false, 109 113 error: { 110 - error: `MalformedJwt`, 114 + error: `BadJwt`, 111 115 description: `jwt is malformed`, 112 116 }, 113 117 };