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)!: align service auth with atproto proposal 0014

prepare for the audience/kid/lxm changes described in
https://github.com/bluesky-social/proposals/tree/main/0014-service-auth-revised

- replace `ServiceJwtVerifier.serviceDid` with `acceptAudiences` so a service
can accept both bare-DID and DID-with-service-fragment audiences during the
ecosystem transition
- widen jwt `aud` to accept `AtprotoAudience` in addition to a bare DID
- make the `kid` jwt header drive verification-method lookup in the issuer's
DID document (defaulting to `#atproto` when absent); still restricted to
`#atproto` for now, future kid support is non-breaking
- require `lxm` on both signing and verification paths, matching the spec's
"required for XRPC endpoint calls" direction

Mary bd82378b 6f8f7577

+288 -64
+19
.changeset/fluffy-eagles-hum.md
··· 1 + --- 2 + '@atcute/xrpc-server': major 3 + --- 4 + 5 + service auth: align with atproto proposal 0014 6 + 7 + - `ServiceJwtVerifier` replaces `serviceDid: Did | null` with 8 + `acceptAudiences: (Did | AtprotoAudience)[] | null`, letting a service accept multiple audience 9 + values at once. this allows operators to accept both a bare DID and a DID-with-service-fragment 10 + during the ecosystem transition. `null` still means "accept any audience"; an empty array rejects 11 + every audience. 12 + - jwt `aud` now accepts a DID with service fragment (e.g. `did:web:x.example#svc`), not just a bare 13 + DID. 14 + - the `kid` jwt header now drives verification-method lookup in the issuer's DID document 15 + (defaulting to `#atproto` when absent). only `#atproto` is accepted for now, but the fragment is 16 + used to look up the key in the DID document rather than being hardcoded, so future support for 17 + additional verification methods is a non-breaking change. 18 + - `lxm` is now required both when signing (`createServiceJwt`) and when verifying (`verify`'s 19 + `options.lxm` is no longer nullable, and tokens without an `lxm` claim are rejected as malformed).
+4 -1
packages/servers/xrpc-server/README.md
··· 227 227 } from '@atcute/identity-resolver'; 228 228 229 229 const jwtVerifier = new ServiceJwtVerifier({ 230 - serviceDid: 'did:web:my-service.example.com', 230 + // list of audience values this service accepts. during the transition to proposal 0014 231 + // (atproto service auth audience), configure both the bare DID and the DID-with-service-ref 232 + // form so that tokens from older issuers keep working alongside new ones. 233 + acceptAudiences: ['did:web:my-service.example.com', 'did:web:my-service.example.com#atproto_pds'], 231 234 resolver: new CompositeDidDocumentResolver({ 232 235 methods: { 233 236 plc: new PlcDidDocumentResolver(),
+2 -2
packages/servers/xrpc-server/lib/auth/jwt-creator.test.ts
··· 21 21 keypair: keypair, 22 22 issuer: issuerDid, 23 23 audience: audienceDid, 24 - lxm: null, 24 + lxm: lxm, 25 25 issuedAt: now, 26 26 expiresIn: 60, 27 27 }); ··· 40 40 iat: now, 41 41 iss: issuerDid, 42 42 jti: expect.stringMatching(/^[A-Za-z0-9_-]+$/), 43 - lxm: undefined, 43 + lxm: lxm, 44 44 }); 45 45 46 46 const signature = fromBase64Url(signatureB64);
+5 -3
packages/servers/xrpc-server/lib/auth/jwt-creator.ts
··· 1 1 import type { PrivateKey } from '@atcute/crypto'; 2 2 import type { Did, Nsid } from '@atcute/lexicons'; 3 + import type { AtprotoAudience } from '@atcute/lexicons/syntax'; 3 4 import { toBase64Url } from '@atcute/multibase'; 4 5 import { encodeUtf8 } from '@atcute/uint8array'; 5 6 ··· 10 11 export interface CreateServiceJwtOptions { 11 12 keypair: PrivateKey; 12 13 issuer: Did; 13 - audience: Did; 14 - lxm: Nsid | null; 14 + /** audience is either a bare DID or a DID with service fragment (e.g. `did:web:x.example#svc`) */ 15 + audience: Did | AtprotoAudience; 16 + lxm: Nsid; 15 17 issuedAt?: number; 16 18 expiresIn?: number; 17 19 } ··· 33 35 iat: issuedAt, 34 36 iss: options.issuer, 35 37 jti: nanoid(24), 36 - lxm: options.lxm ?? undefined, 38 + lxm: options.lxm, 37 39 }; 38 40 39 41 const headerB64 = encodeJwtPortion(header);
+194 -31
packages/servers/xrpc-server/lib/auth/jwt-verifier.test.ts
··· 1 - import { Secp256k1PrivateKeyExportable } from '@atcute/crypto'; 1 + import { Secp256k1PrivateKeyExportable, type PrivateKeyExportable } from '@atcute/crypto'; 2 2 import type { Did, Nsid } from '@atcute/lexicons'; 3 + import type { AtprotoAudience } from '@atcute/lexicons/syntax'; 4 + import { fromBase64Url, toBase64Url } from '@atcute/multibase'; 5 + import { decodeUtf8From, encodeUtf8 } from '@atcute/uint8array'; 3 6 4 - import { describe, expect, it } from 'vitest'; 7 + import { beforeAll, describe, expect, it } from 'vitest'; 5 8 6 9 import { createServiceJwt } from './jwt-creator.ts'; 7 10 import { ServiceJwtVerifier } from './jwt-verifier.ts'; 8 11 12 + // re-sign a header/payload pair with the given keypair, producing a valid-signature JWT. used 13 + // to construct tokens that exercise code paths `createServiceJwt` wouldn't (e.g. custom `kid`, 14 + // missing `lxm`). 15 + const signRaw = async (keypair: PrivateKeyExportable, header: unknown, payload: unknown): Promise<string> => { 16 + const encode = (data: unknown) => toBase64Url(encodeUtf8(JSON.stringify(data))); 17 + 18 + const headerB64 = encode(header); 19 + const payloadB64 = encode(payload); 20 + const signature = await keypair.sign(encodeUtf8(`${headerB64}.${payloadB64}`)); 21 + 22 + return `${headerB64}.${payloadB64}.${toBase64Url(signature)}`; 23 + }; 24 + 25 + const decodePortion = <T>(part: string): T => { 26 + return JSON.parse(decodeUtf8From(fromBase64Url(part))); 27 + }; 28 + 9 29 describe('ServiceJwtVerifier', () => { 10 - const issuerDid: Did = 'did:example:issuer123'; 11 - const audienceDid: Did = 'did:example:audience456'; 30 + const issuerDid: Did = 'did:web:issuer.example.com'; 31 + const audienceDid: Did = 'did:web:audience.example.com'; 32 + const audienceRef: AtprotoAudience = `${audienceDid}#svc`; 12 33 const lxm: Nsid = 'com.example.method'; 13 34 14 - it('should verify a valid JWT', async () => { 15 - const keypair = await Secp256k1PrivateKeyExportable.createKeypair(); 35 + let keypair: PrivateKeyExportable; 36 + 37 + beforeAll(async () => { 38 + keypair = await Secp256k1PrivateKeyExportable.createKeypair(); 39 + }); 16 40 41 + const makeResolver = (kp: PrivateKeyExportable) => ({ 42 + async resolve(did: Did) { 43 + return { 44 + '@context': [], 45 + id: did, 46 + verificationMethod: [ 47 + { 48 + id: `${did}#atproto`, 49 + type: 'Multikey', 50 + controller: did, 51 + publicKeyMultibase: await kp.exportPublicKey('multikey'), 52 + }, 53 + ], 54 + }; 55 + }, 56 + }); 57 + 58 + it('verifies a valid JWT with bare DID audience', async () => { 17 59 const verifier = new ServiceJwtVerifier({ 18 - serviceDid: audienceDid, 19 - resolver: { 20 - async resolve(did) { 21 - return { 22 - '@context': [], 23 - id: did, 24 - verificationMethod: [ 25 - { 26 - id: `${did}#atproto`, 27 - type: 'Multikey', 28 - controller: did, 29 - publicKeyMultibase: await keypair.exportPublicKey('multikey'), 30 - }, 31 - ], 32 - }; 33 - }, 34 - }, 60 + acceptAudiences: [audienceDid], 61 + resolver: makeResolver(keypair), 35 62 }); 36 63 37 64 const jwt = await createServiceJwt({ 38 - keypair: keypair, 65 + keypair, 39 66 issuer: issuerDid, 40 67 audience: audienceDid, 41 - lxm: lxm, 68 + lxm, 42 69 }); 43 70 44 - const result = await verifier.verify(jwt, { lxm: lxm }); 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 + } 77 + }); 45 78 79 + it('verifies a JWT with DID+fragment audience', async () => { 80 + const verifier = new ServiceJwtVerifier({ 81 + acceptAudiences: [audienceRef], 82 + resolver: makeResolver(keypair), 83 + }); 84 + 85 + const jwt = await createServiceJwt({ keypair, issuer: issuerDid, audience: audienceRef, lxm }); 86 + 87 + const result = await verifier.verify(jwt, { lxm }); 46 88 expect(result.ok).toBe(true); 47 89 48 90 if (result.ok) { 49 - expect(result.value).toEqual({ 50 - audience: audienceDid, 51 - issuer: issuerDid, 52 - lxm: lxm, 53 - }); 91 + expect(result.value.audience).toBe(audienceRef); 92 + } 93 + }); 94 + 95 + it('accepts either form when multiple audiences are configured', async () => { 96 + const verifier = new ServiceJwtVerifier({ 97 + acceptAudiences: [audienceRef, audienceDid], 98 + resolver: makeResolver(keypair), 99 + }); 100 + 101 + for (const aud of [audienceDid, audienceRef] as const) { 102 + 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); 106 + } 107 + }); 108 + 109 + it('rejects a JWT whose audience is not in the configured list', async () => { 110 + const verifier = new ServiceJwtVerifier({ 111 + acceptAudiences: [audienceRef], 112 + resolver: makeResolver(keypair), 113 + }); 114 + 115 + const jwt = await createServiceJwt({ keypair, issuer: issuerDid, audience: audienceDid, lxm }); 116 + 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 + } 123 + }); 124 + 125 + it('skips audience validation when acceptAudiences is null', async () => { 126 + const verifier = new ServiceJwtVerifier({ 127 + acceptAudiences: null, 128 + resolver: makeResolver(keypair), 129 + }); 130 + 131 + const jwt = await createServiceJwt({ 132 + keypair, 133 + issuer: issuerDid, 134 + audience: 'did:web:unrelated.example', 135 + lxm, 136 + }); 137 + 138 + const result = await verifier.verify(jwt, { lxm }); 139 + expect(result.ok).toBe(true); 140 + }); 141 + 142 + it('rejects every audience when acceptAudiences is an empty array', async () => { 143 + const verifier = new ServiceJwtVerifier({ 144 + acceptAudiences: [], 145 + resolver: makeResolver(keypair), 146 + }); 147 + 148 + const jwt = await createServiceJwt({ keypair, issuer: issuerDid, audience: audienceDid, lxm }); 149 + 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 + } 156 + }); 157 + 158 + it('rejects a JWT with an unsupported `kid` header', async () => { 159 + const verifier = new ServiceJwtVerifier({ 160 + acceptAudiences: [audienceDid], 161 + resolver: makeResolver(keypair), 162 + }); 163 + 164 + const [headerB64, payloadB64] = ( 165 + await createServiceJwt({ keypair, issuer: issuerDid, audience: audienceDid, lxm }) 166 + ).split('.'); 167 + 168 + const header = { ...decodePortion<Record<string, unknown>>(headerB64), kid: '#someOtherKey' }; 169 + const payload = decodePortion<Record<string, unknown>>(payloadB64); 170 + const tampered = await signRaw(keypair, header, payload); 171 + 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 + } 178 + }); 179 + 180 + it('accepts a JWT with `kid: "#atproto"`', async () => { 181 + const verifier = new ServiceJwtVerifier({ 182 + acceptAudiences: [audienceDid], 183 + resolver: makeResolver(keypair), 184 + }); 185 + 186 + const [headerB64, payloadB64] = ( 187 + await createServiceJwt({ keypair, issuer: issuerDid, audience: audienceDid, lxm }) 188 + ).split('.'); 189 + 190 + const header = { ...decodePortion<Record<string, unknown>>(headerB64), kid: '#atproto' }; 191 + const payload = decodePortion<Record<string, unknown>>(payloadB64); 192 + const signed = await signRaw(keypair, header, payload); 193 + 194 + const result = await verifier.verify(signed, { lxm }); 195 + expect(result.ok).toBe(true); 196 + }); 197 + 198 + it('rejects a JWT missing the `lxm` claim', async () => { 199 + const verifier = new ServiceJwtVerifier({ 200 + acceptAudiences: [audienceDid], 201 + resolver: makeResolver(keypair), 202 + }); 203 + 204 + const [headerB64, payloadB64] = ( 205 + await createServiceJwt({ keypair, issuer: issuerDid, audience: audienceDid, lxm }) 206 + ).split('.'); 207 + 208 + const header = decodePortion<Record<string, unknown>>(headerB64); 209 + const { lxm: _, ...payload } = decodePortion<Record<string, unknown>>(payloadB64); 210 + const signed = await signRaw(keypair, header, payload); 211 + 212 + const result = await verifier.verify(signed, { lxm }); 213 + 214 + expect(result.ok).toBe(false); 215 + if (!result.ok) { 216 + expect(result.error.error).toBe('MalformedJwt'); 54 217 } 55 218 }); 56 219 });
+50 -20
packages/servers/xrpc-server/lib/auth/jwt-verifier.ts
··· 1 1 import { getPublicKeyFromDidController, verifySig, type FoundPublicKey } from '@atcute/crypto'; 2 - import { getAtprotoVerificationMaterial, type DidDocument } from '@atcute/identity'; 2 + import { getVerificationMaterial, type DidDocument } from '@atcute/identity'; 3 3 import { type DidDocumentResolver } from '@atcute/identity-resolver'; 4 4 import type { Did, Nsid } from '@atcute/lexicons'; 5 + import type { AtprotoAudience } from '@atcute/lexicons/syntax'; 5 6 import * as uint8arrays from '@atcute/uint8array'; 6 7 7 8 import type { Result } from '../types/misc.ts'; ··· 9 10 import { parseJwt, type ParsedJwt } from './jwt.ts'; 10 11 import type { AuthError } from './types.ts'; 11 12 13 + /** only `#atproto` is accepted as a signing key identifier for now */ 14 + const DEFAULT_KID = '#atproto'; 15 + type SupportedKid = typeof DEFAULT_KID; 16 + 12 17 export interface ServiceJwtVerifierOptions { 13 - serviceDid: Did | null; 18 + /** 19 + * list of `aud` values accepted by this service; each entry is a bare DID or a DID with 20 + * service fragment (e.g. `did:web:x.example#svc`), and incoming tokens must exact-match any entry. 21 + * 22 + * pass `null` to skip audience validation (accept any audience). an empty array rejects every 23 + * audience, which is useful when a service wants to fail closed until configured. 24 + */ 25 + acceptAudiences: (Did | AtprotoAudience)[] | null; 14 26 resolver: DidDocumentResolver; 15 27 } 16 28 17 29 export interface VerifyJwtOptions { 18 - lxm: Nsid | Nsid[] | null; 30 + lxm: Nsid | Nsid[]; 19 31 } 20 32 21 33 export interface VerifiedJwt { 22 34 issuer: Did; 23 - audience: Did; 24 - lxm: string | undefined; 35 + audience: Did | AtprotoAudience; 36 + lxm: Nsid; 25 37 } 26 38 27 39 export class ServiceJwtVerifier { 28 40 didDocResolver: DidDocumentResolver; 29 - serviceDid: Did | null; 41 + acceptAudiences: (Did | AtprotoAudience)[] | null; 30 42 31 43 constructor(options: ServiceJwtVerifierOptions) { 32 44 this.didDocResolver = options.resolver; 33 - this.serviceDid = options.serviceDid; 45 + this.acceptAudiences = options.acceptAudiences; 34 46 } 35 47 36 - async #getSigningKey(issuer: Did, noCache: boolean): Promise<Result<FoundPublicKey, AuthError>> { 48 + async #getSigningKey( 49 + issuer: Did, 50 + kid: SupportedKid, 51 + noCache: boolean, 52 + ): Promise<Result<FoundPublicKey, AuthError>> { 37 53 let didDocument: DidDocument; 38 54 let key: FoundPublicKey; 39 55 ··· 49 65 }; 50 66 } 51 67 52 - const controller = getAtprotoVerificationMaterial(didDocument); 68 + const controller = getVerificationMaterial(didDocument, kid); 53 69 if (!controller) { 54 70 return { 55 71 ok: false, 56 72 error: { 57 73 error: 'BadJwtIssuer', 58 - description: `${issuer} does not have an atproto verification material`, 74 + description: `${issuer} does not have a ${kid} verification material`, 59 75 }, 60 76 }; 61 77 } ··· 67 83 ok: false, 68 84 error: { 69 85 error: 'BadJwtIssuer', 70 - description: `${issuer} has invalid atproto verification material`, 86 + description: `${issuer} has invalid ${kid} verification material`, 71 87 }, 72 88 }; 73 89 } ··· 92 108 } 93 109 } 94 110 95 - async verify(jwtString: string, options?: VerifyJwtOptions): Promise<Result<VerifiedJwt, AuthError>> { 111 + async verify(jwtString: string, options: VerifyJwtOptions): Promise<Result<VerifiedJwt, AuthError>> { 96 112 const parsed = parseJwt(jwtString); 97 113 if (!parsed.ok) { 98 114 return parsed; ··· 114 130 } 115 131 } 116 132 133 + // resolve the `kid` header (defaulting to `#atproto`) and restrict to the set of 134 + // identifiers this verifier knows how to look up in the issuer's DID document. 135 + // matches proposal 0014's "safe default" for SDKs. 136 + const kid: string = header.kid ?? DEFAULT_KID; 137 + if (kid !== DEFAULT_KID) { 138 + return { 139 + ok: false, 140 + error: { 141 + error: 'BadJwtIssuer', 142 + description: `unsupported signing key identifier (${kid})`, 143 + }, 144 + }; 145 + } 146 + 117 147 if (Date.now() / 1_000 > payload.exp) { 118 148 return { 119 149 ok: false, ··· 124 154 }; 125 155 } 126 156 127 - if (this.serviceDid !== null && this.serviceDid !== payload.aud) { 157 + if (this.acceptAudiences !== null && !this.acceptAudiences.includes(payload.aud)) { 128 158 return { 129 159 ok: false, 130 160 error: { 131 161 error: 'BadJwtAudience', 132 - description: `jwt audience does not match (expected ${this.serviceDid})`, 162 + description: 163 + this.acceptAudiences.length === 0 164 + ? `jwt audience does not match (no audiences accepted)` 165 + : `jwt audience does not match (expected one of: ${this.acceptAudiences.join(', ')})`, 133 166 }, 134 167 }; 135 168 } 136 169 137 - if ( 138 - options?.lxm != null && 139 - (typeof options.lxm === 'string' ? options.lxm !== payload.lxm : !options.lxm.includes(payload.lxm!)) 140 - ) { 170 + if (typeof options.lxm === 'string' ? options.lxm !== payload.lxm : !options.lxm.includes(payload.lxm)) { 141 171 return { 142 172 ok: false, 143 173 error: { ··· 147 177 }; 148 178 } 149 179 150 - const key = await this.#getSigningKey(payload.iss, false); 180 + const key = await this.#getSigningKey(payload.iss, kid, false); 151 181 if (!key.ok) { 152 182 return key; 153 183 } ··· 165 195 166 196 if (!isValid) { 167 197 // try again, uncached 168 - const freshKey = await this.#getSigningKey(payload.iss, true); 198 + const freshKey = await this.#getSigningKey(payload.iss, kid, true); 169 199 if (!freshKey.ok) { 170 200 return freshKey; 171 201 }
+14 -7
packages/servers/xrpc-server/lib/auth/jwt.ts
··· 1 + import { isAtprotoAudience } from '@atcute/identity'; 1 2 import type { Did, Nsid } from '@atcute/lexicons'; 2 - import { isDid, isNsid } from '@atcute/lexicons/syntax'; 3 + import { isDid, isNsid, type AtprotoAudience } from '@atcute/lexicons/syntax'; 3 4 import { fromBase64Url } from '@atcute/multibase'; 4 5 import { decodeUtf8From, encodeUtf8 } from '@atcute/uint8array'; 5 6 ··· 10 11 import type { AuthError } from './types.ts'; 11 12 12 13 const didString = v.string().assert(isDid, `must be a did`); 14 + const audienceString = v 15 + .string() 16 + .assert((input) => isAtprotoAudience(input) || isDid(input), `must be a did or atproto audience`); 13 17 const nsidString = v.string().assert(isNsid, `must be an nsid`); 14 18 15 19 const integer = v.number().assert((input) => input >= 0 && Number.isSafeInteger(input), `must be an integer`); ··· 17 21 export interface JwtHeader { 18 22 typ?: string; 19 23 alg: string; 24 + /** signing key identifier; a DID fragment, defaults to `#atproto` when absent */ 25 + kid?: string; 20 26 } 21 27 22 28 const jwtHeader: v.Type<JwtHeader> = v.object({ 23 29 typ: v.string().optional(), 24 30 alg: v.string(), 31 + kid: v.string().optional(), 25 32 }); 26 33 27 34 export interface JwtPayload { 28 35 iss: Did; 29 - aud: Did; 36 + aud: Did | AtprotoAudience; 30 37 exp: number; 31 38 iat?: number; 32 - lxm?: Nsid; 39 + lxm: Nsid; 33 40 jti?: string; 34 41 } 35 42 ··· 37 44 .object({ 38 45 /** issuer */ 39 46 iss: didString, 40 - /** target audience */ 41 - aud: didString, 47 + /** target audience; a bare DID or a DID with service fragment (e.g. `did:web:x.example#svc`) */ 48 + aud: audienceString, 42 49 /** expiration time */ 43 50 exp: integer, 44 51 /** creation time */ 45 52 iat: integer.optional(), 46 - /** xrpc operation being invoked */ 47 - lxm: nsidString.optional(), 53 + /** xrpc operation being invoked; required per atproto service auth spec */ 54 + lxm: nsidString, 48 55 /** unique identifier */ 49 56 jti: v.string().optional(), 50 57 })