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

Configure Feed

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

feat(xrpc-server): WWW-Authenticate helper and AuthRequiredError option

add `formatWWWAuthenticate()` for building RFC 7235 challenge headers from
`{ scheme, params?, token68? }` values. `AuthRequiredError` now accepts a
`wwwAuthenticate` option that auto-formats the header and appends
`access-control-expose-headers: www-authenticate` so browsers can read the
challenge from CORS responses.

Mary 94d5ce8d 282f14f1

+178 -2
+8
.changeset/xrpc-server-www-authenticate.md
··· 1 + --- 2 + '@atcute/xrpc-server': minor 3 + --- 4 + 5 + export `formatWWWAuthenticate(challenge | challenges)` for building RFC 7235 challenge headers from 6 + `{ scheme, params?, token68? }`. `AuthRequiredError` gains a `wwwAuthenticate` option that 7 + auto-formats the header onto the response and appends 8 + `access-control-expose-headers: www-authenticate` so browsers can read it from CORS responses.
+13
packages/servers/xrpc-server/README.md
··· 147 147 `ForbiddenError`, `RateLimitExceededError`, `InternalServerError`, `UpstreamFailureError`, 148 148 `NotEnoughResourcesError`, `UpstreamTimeoutError`. 149 149 150 + `AuthRequiredError` accepts a `wwwAuthenticate` option that auto-formats an RFC 7235 151 + `WWW-Authenticate` header on the response (and appends `access-control-expose-headers` so browsers 152 + can read it from CORS responses): 153 + 154 + ```ts 155 + import { AuthRequiredError } from '@atcute/xrpc-server'; 156 + 157 + throw new AuthRequiredError({ 158 + message: 'invalid token', 159 + wwwAuthenticate: { scheme: 'Bearer', params: { error: 'BadJwtSignature' } }, 160 + }); 161 + ``` 162 + 150 163 ### observing errors 151 164 152 165 for logs or metrics, use the `onError` / `onSocketError` router options. these are fire-and-forget
+72
packages/servers/xrpc-server/lib/main/xrpc-error.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { AuthRequiredError, formatWWWAuthenticate } from './xrpc-error.ts'; 4 + 5 + describe('formatWWWAuthenticate', () => { 6 + it('formats a bare scheme', () => { 7 + expect(formatWWWAuthenticate({ scheme: 'Bearer' })).toBe('Bearer'); 8 + }); 9 + 10 + it('formats a scheme with params', () => { 11 + expect( 12 + formatWWWAuthenticate({ 13 + scheme: 'Bearer', 14 + params: { realm: 'api.example.com', error: 'BadJwtSignature' }, 15 + }), 16 + ).toBe('Bearer realm="api.example.com", error="BadJwtSignature"'); 17 + }); 18 + 19 + it('skips params whose value is undefined', () => { 20 + expect( 21 + formatWWWAuthenticate({ 22 + scheme: 'Bearer', 23 + params: { realm: 'api', error: undefined }, 24 + }), 25 + ).toBe('Bearer realm="api"'); 26 + }); 27 + 28 + it('emits token68 instead of params when provided', () => { 29 + expect(formatWWWAuthenticate({ scheme: 'Basic', token68: 'abc==' })).toBe('Basic abc=='); 30 + }); 31 + 32 + it('joins multiple challenges with commas', () => { 33 + expect( 34 + formatWWWAuthenticate([ 35 + { scheme: 'Bearer', params: { error: 'BadJwt' } }, 36 + { scheme: 'DPoP', params: { error: 'use_dpop_nonce' } }, 37 + ]), 38 + ).toBe('Bearer error="BadJwt", DPoP error="use_dpop_nonce"'); 39 + }); 40 + }); 41 + 42 + describe('AuthRequiredError', () => { 43 + it('sets WWW-Authenticate header from wwwAuthenticate option', () => { 44 + const err = new AuthRequiredError({ 45 + message: 'invalid token', 46 + wwwAuthenticate: { scheme: 'Bearer', params: { error: 'BadJwtSignature' } }, 47 + }); 48 + 49 + const response = err.toResponse(); 50 + expect(response.headers.get('www-authenticate')).toBe('Bearer error="BadJwtSignature"'); 51 + expect(response.headers.get('access-control-expose-headers')).toBe('www-authenticate'); 52 + }); 53 + 54 + it('merges with caller-provided headers', () => { 55 + const err = new AuthRequiredError({ 56 + message: 'invalid token', 57 + headers: { 'x-custom': 'value' }, 58 + wwwAuthenticate: { scheme: 'Bearer' }, 59 + }); 60 + 61 + const response = err.toResponse(); 62 + expect(response.headers.get('x-custom')).toBe('value'); 63 + expect(response.headers.get('www-authenticate')).toBe('Bearer'); 64 + }); 65 + 66 + it('does nothing special when wwwAuthenticate is absent', () => { 67 + const err = new AuthRequiredError({ message: 'unauthorized' }); 68 + 69 + const response = err.toResponse(); 70 + expect(response.headers.has('www-authenticate')).toBe(false); 71 + }); 72 + });
+85 -2
packages/servers/xrpc-server/lib/main/xrpc-error.ts
··· 1 + /** 2 + * a single WWW-Authenticate challenge. exactly one of `params` or `token68` may 3 + * be provided. a bare scheme (no params, no token) is valid and renders as just 4 + * the scheme name. 5 + * 6 + * @see {@link https://datatracker.ietf.org/doc/html/rfc7235#section-4.1 | RFC 7235 §4.1} 7 + */ 8 + export interface WWWAuthenticateChallenge { 9 + /** authentication scheme, e.g. `Bearer`, `DPoP`, `Basic`. */ 10 + scheme: string; 11 + /** auth-param pairs. entries whose value is `undefined` are omitted. */ 12 + params?: Record<string, string | undefined>; 13 + /** 14 + * token68 value for schemes that carry one instead of auth-params (e.g. 15 + * `Basic`). mutually exclusive with `params`. 16 + */ 17 + token68?: string; 18 + } 19 + 20 + /** 21 + * formats one or more WWW-Authenticate challenges into a single header value. 22 + * 23 + * each challenge is emitted as `<scheme>` followed by its params or token68. 24 + * multiple challenges are joined with `, `. auth-param values are quoted using 25 + * `JSON.stringify` (RFC 7230 quoted-string semantics for ASCII content). 26 + * 27 + * @param challenges one challenge, or an ordered array of challenges 28 + * @returns the formatted header value 29 + * 30 + * @example 31 + * ```ts 32 + * formatWWWAuthenticate({ scheme: 'Bearer', params: { error: 'BadJwtSignature' } }) 33 + * // => `Bearer error="BadJwtSignature"` 34 + * ``` 35 + */ 36 + export const formatWWWAuthenticate = ( 37 + challenges: WWWAuthenticateChallenge | WWWAuthenticateChallenge[], 38 + ): string => { 39 + const list = Array.isArray(challenges) ? challenges : [challenges]; 40 + return list.map(formatChallenge).join(', '); 41 + }; 42 + 43 + const formatChallenge = (challenge: WWWAuthenticateChallenge): string => { 44 + if (challenge.token68 !== undefined) { 45 + return `${challenge.scheme} ${challenge.token68}`; 46 + } 47 + 48 + if (challenge.params !== undefined) { 49 + const parts: string[] = []; 50 + for (const name in challenge.params) { 51 + const value = challenge.params[name]; 52 + if (value !== undefined) { 53 + parts.push(`${name}=${JSON.stringify(value)}`); 54 + } 55 + } 56 + 57 + if (parts.length > 0) { 58 + return `${challenge.scheme} ${parts.join(', ')}`; 59 + } 60 + } 61 + 62 + return challenge.scheme; 63 + }; 64 + 1 65 export interface XRPCErrorOptions { 2 66 status: number; 3 67 error: string; ··· 37 101 } 38 102 } 39 103 104 + export interface AuthRequiredErrorOptions extends Partial<XRPCErrorOptions> { 105 + /** 106 + * WWW-Authenticate challenge(s) to attach to the response. the formatted 107 + * header is set on `headers` automatically, and `access-control-expose-headers` 108 + * is appended so browsers can read it from CORS responses. 109 + */ 110 + wwwAuthenticate?: WWWAuthenticateChallenge | WWWAuthenticateChallenge[]; 111 + } 112 + 40 113 export class AuthRequiredError extends XRPCError { 41 114 constructor({ 42 115 status = 401, 43 116 error = 'AuthenticationRequired', 44 117 message, 45 118 headers, 46 - }: Partial<XRPCErrorOptions> = {}) { 47 - super({ status, error, message, headers }); 119 + wwwAuthenticate, 120 + }: AuthRequiredErrorOptions = {}) { 121 + let mergedHeaders = headers; 122 + 123 + if (wwwAuthenticate !== undefined) { 124 + const target = new Headers(headers); 125 + target.set('www-authenticate', formatWWWAuthenticate(wwwAuthenticate)); 126 + target.append('access-control-expose-headers', 'www-authenticate'); 127 + mergedHeaders = target; 128 + } 129 + 130 + super({ status, error, message, headers: mergedHeaders }); 48 131 } 49 132 } 50 133