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.

refactor(oauth-cab)!: some changes to the server handler

Mary 104abad7 ad63f242

+94 -42
+75 -25
packages/oauth/cab/README.md
··· 17 17 > other websites from abusing it. serving the endpoint from the same origin as your web application 18 18 > is the simplest way to enforce this. 19 19 20 - #### standalone handler 20 + #### with XRPC router 21 21 22 22 ```ts 23 - import { createCabHandler, Keyset, generatePrivateKey } from '@atcute/oauth-cab/server'; 23 + import { 24 + buildClientMetadata, 25 + generatePrivateKey, 26 + Keyset, 27 + registerCab, 28 + } from '@atcute/oauth-cab/server'; 29 + import { XRPCRouter, cors } from '@atcute/xrpc-server'; 24 30 25 - // create keyset 26 31 const keyset = new Keyset([await generatePrivateKey('my-key')]); 27 32 28 - // create handler (returns undefined for non-matching paths) 29 - const handler = await createCabHandler({ 30 - clientId: 'https://example.com/client-metadata.json', 33 + const metadata = buildClientMetadata( 34 + { 35 + client_id: 'https://example.com/oauth-client-metadata.json', 36 + redirect_uris: ['https://example.com/oauth/callback'], 37 + scope: 'atproto transition:generic', 38 + client_name: 'my app', 39 + jwks_uri: 'https://example.com/jwks.json', 40 + }, 31 41 keyset, 32 - // dpopSecret: false, // disable DPoP nonce requirement 33 - // dpopSecret: 'hex-secret', // shared secret for multi-instance deployments 42 + ); 43 + 44 + const router = new XRPCRouter({ 45 + // if using CORS middleware, exclude CAB endpoint (it should be same-origin) 46 + middlewares: [cors({ exclude: ['dev.atcute.oauth.getClientAssertion'] })], 34 47 }); 35 48 36 - // use with Hono 37 - app.all('*', async (c, next) => { 38 - const res = await handler(c.req.raw); 39 - if (res === undefined) { 40 - return next(); 41 - } 42 - return res; 49 + await registerCab(router, { 50 + client_id: metadata.client_id, 51 + keyset, 52 + // dpopSecret: false, 53 + // dpopSecret: 'hex-secret' 43 54 }); 55 + 56 + export default { 57 + async fetch(request: Request): Promise<Response> { 58 + const url = new URL(request.url); 59 + 60 + if (url.pathname === '/oauth-client-metadata.json') { 61 + return Response.json(metadata); 62 + } 63 + if (url.pathname === '/jwks.json') { 64 + return Response.json(keyset.publicJwks); 65 + } 66 + 67 + return router.fetch(request); 68 + }, 69 + }; 44 70 ``` 45 71 46 - #### with XRPC router 72 + #### standalone handler 47 73 48 74 ```ts 49 - import { registerCab, Keyset, generatePrivateKey } from '@atcute/oauth-cab/server'; 50 - import { XRPCRouter, cors } from '@atcute/xrpc-server'; 75 + import { 76 + buildClientMetadata, 77 + createCabHandler, 78 + generatePrivateKey, 79 + Keyset, 80 + } from '@atcute/oauth-cab/server'; 51 81 82 + // create keyset 52 83 const keyset = new Keyset([await generatePrivateKey('my-key')]); 53 84 54 - const router = new XRPCRouter({ 55 - // if using CORS middleware, exclude CAB endpoint (it should be same-origin) 56 - middlewares: [cors({ exclude: ['dev.atcute.oauth.getClientAssertion'] })], 57 - }); 85 + // build client metadata 86 + const metadata = buildClientMetadata( 87 + { 88 + client_id: 'https://example.com/oauth-client-metadata.json', 89 + redirect_uris: ['https://example.com/oauth/callback'], 90 + scope: 'atproto transition:generic', 91 + client_name: 'my app', 92 + jwks_uri: 'https://example.com/jwks.json', 93 + }, 94 + keyset, 95 + ); 58 96 59 - await registerCab(router, { 60 - clientId: 'https://example.com/client-metadata.json', 97 + // create handler (returns undefined for non-matching paths) 98 + const handler = await createCabHandler({ 99 + client_id: metadata.client_id, 61 100 keyset, 101 + // dpopSecret: false, 102 + // dpopSecret: 'hex-secret', 62 103 }); 63 104 64 - export default router; 105 + // use with Hono 106 + app.get('/oauth-client-metadata.json', (c) => c.json(metadata)); 107 + app.get('/jwks.json', (c) => c.json(keyset.publicJwks)); 108 + app.all('*', async (c, next) => { 109 + const res = await handler(c.req.raw); 110 + if (res === undefined) { 111 + return next(); 112 + } 113 + return res; 114 + }); 65 115 ``` 66 116 67 117 ### client-side (browser)
+11 -9
packages/oauth/cab/lib/server/dpop-verifier.ts
··· 1 1 import * as v from '@badrap/valita'; 2 + import { importJWK, jwtVerify } from 'jose'; 3 + 2 4 import { fromBase64Url, toBase64Url } from '@atcute/multibase'; 3 5 import { decodeUtf8From, encodeUtf8, toSha256 } from '@atcute/uint8array'; 4 - import { importJWK, jwtVerify } from 'jose'; 5 6 6 7 import type { DpopNonce } from './dpop-nonce.js'; 7 8 ··· 135 136 options: DPoPVerifyOptions, 136 137 ): Promise<DPoPVerifyResult> => { 137 138 if (!dpopHeader) { 138 - throw new DPoPVerifyError('missing DPoP header', 'missing'); 139 + throw new DPoPVerifyError(`missing DPoP header`, 'missing'); 139 140 } 140 141 141 142 const { method, url, nonce: dpopNonce, maxClockSkew = 60 } = options; ··· 143 144 // parse the JWT 144 145 const parts = dpopHeader.split('.'); 145 146 if (parts.length !== 3) { 146 - throw new DPoPVerifyError('invalid DPoP proof format', 'invalid'); 147 + throw new DPoPVerifyError(`invalid DPoP proof format`, 'invalid'); 147 148 } 148 149 149 150 // parse and validate header ··· 152 153 const raw = decodeBase64UrlJson(parts[0]); 153 154 header = dpopHeaderSchema.parse(raw, { mode: 'passthrough' }); 154 155 } catch { 155 - throw new DPoPVerifyError('invalid DPoP header', 'invalid'); 156 + throw new DPoPVerifyError(`invalid DPoP header`, 'invalid'); 156 157 } 157 158 158 159 const { jwk, alg } = header; ··· 165 166 payload = dpopPayloadSchema.parse(result.payload, { mode: 'passthrough' }); 166 167 } catch (err) { 167 168 if (err instanceof v.ValitaError) { 168 - throw new DPoPVerifyError('invalid DPoP payload', 'invalid'); 169 + throw new DPoPVerifyError(`invalid DPoP payload`, 'invalid'); 169 170 } 170 - throw new DPoPVerifyError('DPoP signature verification failed', 'invalid'); 171 + 172 + throw new DPoPVerifyError(`DPoP signature verification failed`, 'invalid'); 171 173 } 172 174 173 175 const { htm, htu, iat, nonce: proofNonce } = payload; ··· 183 185 184 186 const now = Math.floor(Date.now() / 1000); 185 187 if (iat > now + maxClockSkew) { 186 - throw new DPoPVerifyError('DPoP proof issued in the future', 'invalid'); 188 + throw new DPoPVerifyError(`DPoP proof issued in the future`, 'invalid'); 187 189 } 188 190 if (iat < now - maxClockSkew) { 189 - throw new DPoPVerifyError('DPoP proof expired', 'expired'); 191 + throw new DPoPVerifyError(`DPoP proof expired`, 'expired'); 190 192 } 191 193 192 194 // validate nonce if configured 193 195 if (dpopNonce) { 194 196 if (!proofNonce || !(await dpopNonce.check(proofNonce))) { 195 - throw new DPoPVerifyError('invalid or missing DPoP nonce', 'nonce_required'); 197 + throw new DPoPVerifyError(`invalid or missing DPoP nonce`, 'nonce_required'); 196 198 } 197 199 } 198 200
+8 -8
packages/oauth/cab/lib/server/handler.ts
··· 13 13 import { DpopNonce, type DpopSecret } from './dpop-nonce.js'; 14 14 import { DPoPVerifyError, verifyDPoP } from './dpop-verifier.js'; 15 15 16 - const CAB_PATH = '/xrpc/dev.atcute.oauth.getClientAssertion'; 17 - 18 16 /** 19 17 * options for creating a CAB handler 20 18 */ 21 19 export interface CabOptions { 22 20 /** OAuth client ID */ 23 - clientId: string; 21 + client_id: string; 24 22 /** client's private keyset */ 25 23 keyset: Keyset; 26 24 /** ··· 37 35 const createCabProcedure = async ( 38 36 options: CabOptions, 39 37 ): Promise<ProcedureConfig<DevAtcuteOauthGetClientAssertion.mainSchema>> => { 40 - const { clientId, keyset, dpopSecret, serverAlgs } = options; 38 + const { client_id, keyset, dpopSecret, serverAlgs } = options; 41 39 42 40 const dpopNonce = dpopSecret === false ? undefined : await DpopNonce.create(dpopSecret); 43 41 ··· 48 46 49 47 // get fresh nonce for response headers 50 48 const nextNonce = dpopNonce ? await dpopNonce.next() : undefined; 51 - const headers: HeadersInit | undefined = nextNonce ? { 'DPoP-Nonce': nextNonce } : undefined; 49 + const headers: HeadersInit | undefined = nextNonce ? { 'dpop-nonce': nextNonce } : undefined; 52 50 53 51 // verify DPoP proof (includes nonce validation if configured) 54 52 let jkt: string; ··· 69 67 70 68 // create client assertion 71 69 const assertion = await createClientAssertion({ 72 - clientId, 70 + clientId: client_id, 73 71 audience: aud, 74 72 jkt, 75 73 keyset, ··· 91 89 const config = await createCabProcedure(options); 92 90 router.addProcedure(DevAtcuteOauthGetClientAssertion.mainSchema, config); 93 91 }; 92 + 93 + const CAB_PATH = `/xrpc/${DevAtcuteOauthGetClientAssertion.mainSchema.nsid}`; 94 94 95 95 /** 96 96 * creates a standalone CAB handler. ··· 103 103 export const createCabHandler = async ( 104 104 options: CabOptions, 105 105 ): Promise<(request: Request) => Promise<Response> | undefined> => { 106 - const config = await createCabProcedure(options); 107 106 const handler = createXrpcHandler({ 108 107 lxm: DevAtcuteOauthGetClientAssertion.mainSchema, 109 - ...config, 108 + ...(await createCabProcedure(options)), 110 109 }); 111 110 112 111 return (request: Request): Promise<Response> | undefined => { ··· 114 113 if (url.pathname !== CAB_PATH) { 115 114 return undefined; 116 115 } 116 + 117 117 return handler(request); 118 118 }; 119 119 };