personal web client for Bluesky
typescript solidjs bluesky atcute

feat: confidential client

mary.my.id 35f88243 891c1d82

verified
+1
.gitignore
··· 11 11 yarn-*.log 12 12 13 13 *.local 14 + *.local.json 14 15 15 16 tsconfig.tsbuildinfo
+5 -2
package.json
··· 20 20 "@atcute/identity": "^1.1.1", 21 21 "@atcute/identity-resolver": "^1.1.4", 22 22 "@atcute/lexicons": "^1.2.2", 23 - "@atcute/oauth-browser-client": "2.0.0", 23 + "@atcute/multibase": "^1.1.6", 24 + "@atcute/oauth-browser-client": "2.0.1", 24 25 "@atcute/tid": "^1.0.3", 26 + "@atcute/uint8array": "^1.0.5", 25 27 "@atcute/xrpc-server": "^0.1.3", 26 28 "@atlaskit/pragmatic-drag-and-drop": "1.6.0", 27 29 "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", ··· 45 47 "webm-muxer": "^5.1.4" 46 48 }, 47 49 "devDependencies": { 50 + "@badrap/valita": "^0.4.6", 48 51 "@cloudflare/vite-plugin": "^1.13.15", 49 52 "@trivago/prettier-plugin-sort-imports": "^5.2.2", 50 53 "@types/dom-close-watcher": "^1.0.0", ··· 58 61 "terser": "^5.44.0", 59 62 "typescript": "~5.9.3", 60 63 "vite": "^7.1.12", 61 - "vite-plugin-pwa": "0.21.0", 64 + "vite-plugin-pwa": "1.1.0", 62 65 "vite-plugin-solid": "^2.11.10", 63 66 "wrangler": "^4.45.0" 64 67 },
+21 -12
pnpm-lock.yaml
··· 66 66 '@atcute/lexicons': 67 67 specifier: ^1.2.2 68 68 version: 1.2.2 69 + '@atcute/multibase': 70 + specifier: ^1.1.6 71 + version: 1.1.6 69 72 '@atcute/oauth-browser-client': 70 - specifier: 2.0.0 71 - version: 2.0.0 73 + specifier: 2.0.1 74 + version: 2.0.1 72 75 '@atcute/tid': 73 76 specifier: ^1.0.3 74 77 version: 1.0.3 78 + '@atcute/uint8array': 79 + specifier: ^1.0.5 80 + version: 1.0.5 75 81 '@atcute/xrpc-server': 76 82 specifier: ^0.1.3 77 83 version: 0.1.3 ··· 136 142 specifier: ^5.1.4 137 143 version: 5.1.4 138 144 devDependencies: 145 + '@badrap/valita': 146 + specifier: ^0.4.6 147 + version: 0.4.6 139 148 '@cloudflare/vite-plugin': 140 149 specifier: ^1.13.15 141 150 version: 1.13.15(vite@7.1.12(@types/node@24.9.1)(jiti@1.21.7)(terser@5.44.0))(workerd@1.20251011.0)(wrangler@4.45.0) ··· 176 185 specifier: ^7.1.12 177 186 version: 7.1.12(@types/node@24.9.1)(jiti@1.21.7)(terser@5.44.0) 178 187 vite-plugin-pwa: 179 - specifier: 0.21.0 180 - version: 0.21.0(patch_hash=003379ded749ad87080f87b428b17f04c4c88b6b64544df4d928aab76fbf6325)(@types/babel__core@7.20.5)(vite@7.1.12(@types/node@24.9.1)(jiti@1.21.7)(terser@5.44.0)) 188 + specifier: 1.1.0 189 + version: 1.1.0(patch_hash=003379ded749ad87080f87b428b17f04c4c88b6b64544df4d928aab76fbf6325)(@types/babel__core@7.20.5)(vite@7.1.12(@types/node@24.9.1)(jiti@1.21.7)(terser@5.44.0)) 181 190 vite-plugin-solid: 182 191 specifier: ^2.11.10 183 192 version: 2.11.10(solid-js@1.9.9(patch_hash=9cf3f9930aa2f8d4e60502a75153adf9468eb53b42f69e86cac05dfaea3f82e7))(vite@7.1.12(@types/node@24.9.1)(jiti@1.21.7)(terser@5.44.0)) ··· 241 250 '@atcute/multibase@1.1.6': 242 251 resolution: {integrity: sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==} 243 252 244 - '@atcute/oauth-browser-client@2.0.0': 245 - resolution: {integrity: sha512-zK4wcQ79g9EqU7NJk/Wxx9ImOH6UwhpEcazDA8bimUJhl9pwXxMC0u0x/tgzFjJZYgwnOHoBMwLmhl0900hcCQ==} 253 + '@atcute/oauth-browser-client@2.0.1': 254 + resolution: {integrity: sha512-lG021GkeORG06zfFf4bH85egObjBEKHNgAWHvbtY/E2dX4wxo88hf370pJDx8acdnuUJLJ2VKPikJtZwo4Heeg==} 246 255 247 256 '@atcute/tid@1.0.3': 248 257 resolution: {integrity: sha512-wfMJx1IMdnu0CZgWl0uR4JO2s6PGT1YPhpytD4ZHzEYKKQVuqV6Eb/7vieaVo1eYNMp2FrY67FZObeR7utRl2w==} ··· 2604 2613 validate-html-nesting@1.2.3: 2605 2614 resolution: {integrity: sha512-kdkWdCl6eCeLlRShJKbjVOU2kFKxMF8Ghu50n+crEoyx+VKm3FxAxF9z4DCy6+bbTOqNW0+jcIYRnjoIRzigRw==} 2606 2615 2607 - vite-plugin-pwa@0.21.0: 2608 - resolution: {integrity: sha512-gnDE5sN2hdxA4vTl0pe6PCTPXqChk175jH8dZVVTBjFhWarZZoXaAdoTIKCIa8Zbx94sC0CnCOyERBWpxvry+g==} 2616 + vite-plugin-pwa@1.1.0: 2617 + resolution: {integrity: sha512-VsSpdubPzXhHWVINcSx6uHRMpOHVHQcHsef1QgkOlEoaIDAlssFEW88LBq1a59BuokAhsh2kUDJbaX1bZv4Bjw==} 2609 2618 engines: {node: '>=16.0.0'} 2610 2619 peerDependencies: 2611 - '@vite-pwa/assets-generator': ^0.2.6 2612 - vite: ^3.1.0 || ^4.0.0 || ^5.0.0 2620 + '@vite-pwa/assets-generator': ^1.0.0 2621 + vite: ^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 2613 2622 peerDependenciesMeta: 2614 2623 '@vite-pwa/assets-generator': 2615 2624 optional: true ··· 2864 2873 dependencies: 2865 2874 '@atcute/uint8array': 1.0.5 2866 2875 2867 - '@atcute/oauth-browser-client@2.0.0': 2876 + '@atcute/oauth-browser-client@2.0.1': 2868 2877 dependencies: 2869 2878 '@atcute/client': 4.0.5 2870 2879 '@atcute/identity': 1.1.1 ··· 5109 5118 5110 5119 validate-html-nesting@1.2.3: {} 5111 5120 5112 - vite-plugin-pwa@0.21.0(patch_hash=003379ded749ad87080f87b428b17f04c4c88b6b64544df4d928aab76fbf6325)(@types/babel__core@7.20.5)(vite@7.1.12(@types/node@24.9.1)(jiti@1.21.7)(terser@5.44.0)): 5121 + vite-plugin-pwa@1.1.0(patch_hash=003379ded749ad87080f87b428b17f04c4c88b6b64544df4d928aab76fbf6325)(@types/babel__core@7.20.5)(vite@7.1.12(@types/node@24.9.1)(jiti@1.21.7)(terser@5.44.0)): 5113 5122 dependencies: 5114 5123 debug: 4.4.3 5115 5124 pretty-bytes: 6.1.1
-12
public/oauth-client-metadata.json
··· 1 - { 2 - "client_id": "https://aglais.kelinci.net/oauth-client-metadata.json", 3 - "client_uri": "https://aglais.kelinci.net", 4 - "client_name": "Aglais", 5 - "application_type": "web", 6 - "scope": "atproto transition:generic transition:chat.bsky", 7 - "grant_types": ["authorization_code", "refresh_token"], 8 - "redirect_uris": ["https://aglais.kelinci.net/oauth/callback"], 9 - "response_types": ["code"], 10 - "token_endpoint_auth_method": "none", 11 - "dpop_bound_access_tokens": true 12 - }
+67
scripts/generate-oauth-keys.js
··· 1 + import * as fs from 'node:fs/promises'; 2 + 3 + import * as v from '@badrap/valita'; 4 + 5 + import * as TID from '@atcute/tid'; 6 + 7 + const jwksSchema = v.object({ 8 + keys: v.array( 9 + v.object({ 10 + privateKey: v.unknown(), 11 + publicKey: v.unknown(), 12 + }), 13 + ), 14 + }); 15 + 16 + /** @type {v.Infer<typeof jwksSchema> | undefined} */ 17 + let jwks; 18 + try { 19 + const raw = await fs.readFile('./oauth-credentials.local.json', 'utf-8'); 20 + const json = JSON.parse(raw); 21 + 22 + jwks = jwksSchema.parse(json, { mode: 'passthrough' }); 23 + } catch (err) { 24 + if (err.code !== 'ENOENT') { 25 + throw err; 26 + } 27 + 28 + jwks = { 29 + keys: [], 30 + }; 31 + } 32 + 33 + const { publicKey, privateKey } = await crypto.subtle.generateKey( 34 + { 35 + name: 'ECDSA', 36 + namedCurve: 'P-256', 37 + }, 38 + true, 39 + ['sign', 'verify'], 40 + ); 41 + 42 + const kid = `aglais-${TID.now()}`; 43 + const privateJWK = await crypto.subtle.exportKey('jwk', privateKey); 44 + const publicJWK = await crypto.subtle.exportKey('jwk', publicKey); 45 + 46 + jwks = { 47 + keys: [ 48 + { 49 + privateKey: { 50 + ...privateJWK, 51 + kid: kid, 52 + }, 53 + publicKey: { 54 + kty: publicJWK.kty, 55 + crv: publicJWK.crv, 56 + x: publicJWK.x, 57 + y: publicJWK.y, 58 + use: 'sig', 59 + alg: 'ES256', 60 + kid: kid, 61 + }, 62 + }, 63 + ...jwks.keys, 64 + ], 65 + }; 66 + 67 + await fs.writeFile('./oauth-credentials.local.json', JSON.stringify(jwks, null, '\t') + '\n');
+128 -30
server/index.ts
··· 1 - import { ComAtprotoIdentityResolveDid, ComAtprotoIdentityResolveHandle } from '@atcute/atproto'; 1 + import { type DidDocument, getAtprotoHandle, getPdsEndpoint } from '@atcute/identity'; 2 2 import { 3 3 AmbiguousHandleError, 4 4 CompositeDidDocumentResolver, ··· 13 13 WebDidDocumentResolver, 14 14 WellKnownHandleResolver, 15 15 } from '@atcute/identity-resolver'; 16 - import { InvalidRequestError, XRPCRouter, json } from '@atcute/xrpc-server'; 16 + import { type Did, type Handle, type ResourceUri, isDid } from '@atcute/lexicons/syntax'; 17 + import { AuthRequiredError, InvalidRequestError, XRPCRouter, json } from '@atcute/xrpc-server'; 18 + 19 + import * as jwks from '../oauth-credentials.local.json' with { type: 'json' }; 20 + 21 + import { InvalidDPoPError, createClientAssertion, verifyDPoP } from './jwt'; 22 + import { requestAssertionSchema, resolveIdentitySchema } from './lexicons'; 23 + 24 + const privateKeyId = jwks.keys[0].privateKey.kid; 25 + const privateKey = await crypto.subtle.importKey( 26 + 'jwk', 27 + jwks.keys[0].privateKey, 28 + { name: 'ECDSA', namedCurve: 'P-256' }, 29 + false, 30 + ['sign'], 31 + ); 17 32 18 33 const handleResolver = new CompositeHandleResolver({ 19 34 methods: { ··· 22 37 }, 23 38 }); 24 39 25 - const didDocResolver = new CompositeDidDocumentResolver<string>({ 40 + const didDocumentResolver = new CompositeDidDocumentResolver<string>({ 26 41 methods: { 27 42 plc: new PlcDidDocumentResolver(), 28 43 web: new WebDidDocumentResolver(), ··· 35 50 const router = new XRPCRouter({ 36 51 middlewares: [ 37 52 async (request, next) => { 53 + if (request.method !== 'GET') { 54 + return await next(request); 55 + } 56 + 38 57 let response = await cache.match(request); 39 58 if (response === undefined) { 40 59 response = await next(request); ··· 54 73 ], 55 74 }); 56 75 57 - router.add(ComAtprotoIdentityResolveHandle.mainSchema, { 58 - async handler({ params: { handle } }) { 59 - try { 60 - const did = await handleResolver.resolve(handle); 76 + router.addProcedure(requestAssertionSchema, { 77 + async handler({ input: { jkt, aud }, request }) { 78 + const url = new URL(request.url); 61 79 62 - return json({ did }, { headers: { 'cache-control': 'public, max-age=600' } }); 80 + const origin = request.headers.get('origin'); 81 + if (origin !== url.origin) { 82 + throw new AuthRequiredError({ description: 'invalid origin' }); 83 + } 84 + 85 + const dpop = request.headers.get('dpop'); 86 + try { 87 + await verifyDPoP(dpop, jkt); 63 88 } catch (err) { 64 - console.error(`resolveHandleToDid`, handle, err); 65 - 66 - if (err instanceof DidNotFoundError) { 67 - throw new InvalidRequestError({ description: `no did found under that handle` }); 89 + if (err instanceof InvalidDPoPError) { 90 + throw new AuthRequiredError({ description: err.message }); 68 91 } 69 92 70 - if (err instanceof InvalidResolvedHandleError) { 71 - throw new InvalidRequestError({ description: `did found but is invalid atproto did` }); 72 - } 93 + throw err; 94 + } 73 95 74 - if (err instanceof AmbiguousHandleError) { 75 - throw new InvalidRequestError({ description: `multiple did found under that handle` }); 76 - } 96 + const assertion = await createClientAssertion({ 97 + privateKey: privateKey, 98 + 99 + client_id: `https://${url.host}/oauth-client-metadata.json`, 100 + kid: privateKeyId, 101 + aud: aud, 102 + }); 77 103 78 - throw err; 79 - } 104 + return json({ 105 + assertion: assertion, 106 + }); 80 107 }, 81 108 }); 82 109 83 - router.add(ComAtprotoIdentityResolveDid.mainSchema, { 84 - async handler({ params: { did } }) { 85 - try { 86 - const doc = await didDocResolver.resolve(did); 110 + router.addQuery(resolveIdentitySchema, { 111 + async handler({ params: { identifier } }) { 112 + const identifierIsDid = isDid(identifier); 87 113 88 - return json( 89 - { didDoc: doc as unknown as Record<string, unknown> }, 90 - { headers: { 'cache-control': 'public, max-age=3600' } }, 91 - ); 92 - } catch (err) { 93 - console.error(`resolveDidToDoc`, did, err); 114 + let did: Did; 115 + if (identifierIsDid) { 116 + did = identifier; 117 + } else { 118 + try { 119 + did = await handleResolver.resolve(identifier); 120 + } catch (err) { 121 + if (err instanceof DidNotFoundError) { 122 + throw new InvalidRequestError({ description: `no did found under that handle` }); 123 + } 94 124 125 + if (err instanceof InvalidResolvedHandleError) { 126 + throw new InvalidRequestError({ description: `did found but is invalid atproto did` }); 127 + } 128 + 129 + if (err instanceof AmbiguousHandleError) { 130 + throw new InvalidRequestError({ description: `multiple did found under that handle` }); 131 + } 132 + 133 + throw err; 134 + } 135 + } 136 + 137 + let doc: DidDocument; 138 + try { 139 + doc = await didDocumentResolver.resolve(did); 140 + } catch (err) { 95 141 if (err instanceof DocumentNotFoundError) { 96 142 throw new InvalidRequestError({ description: `no document found under that did` }); 97 143 } ··· 106 152 107 153 throw err; 108 154 } 155 + 156 + const pds = getPdsEndpoint(doc); 157 + if (!pds) { 158 + throw new InvalidRequestError({ description: `missing pds endpoint` }); 159 + } 160 + 161 + let handle: Handle = 'handle.invalid'; 162 + if (identifierIsDid) { 163 + const writtenHandle = getAtprotoHandle(doc); 164 + if (writtenHandle) { 165 + try { 166 + const resolved = await handleResolver.resolve(writtenHandle); 167 + 168 + if (resolved === did) { 169 + handle = writtenHandle; 170 + } 171 + } catch {} 172 + } 173 + } else if (getAtprotoHandle(doc) === identifier) { 174 + handle = identifier; 175 + } 176 + 177 + return json({ 178 + did: did, 179 + handle: handle, 180 + pds: new URL(pds).href as ResourceUri, 181 + }); 109 182 }, 110 183 }); 111 184 112 185 export default { 113 186 fetch(request, _env, ctx) { 187 + const url = new URL(request.url); 188 + 189 + if (url.pathname === '/oauth-client-metadata.json') { 190 + return Response.json({ 191 + client_id: `https://${url.host}/oauth-client-metadata.json`, 192 + client_uri: `https://${url.host}`, 193 + client_name: import.meta.env.VITE_APP_NAME, 194 + application_type: 'web', 195 + scope: 'atproto transition:generic transition:chat.bsky', 196 + grant_types: ['authorization_code', 'refresh_token'], 197 + redirect_uris: [`https://${url.host}/oauth/callback`], 198 + response_types: ['code'], 199 + token_endpoint_auth_method: 'private_key_jwt', 200 + token_endpoint_auth_signing_alg: 'ES256', 201 + jwks_uri: `https://${url.host}/oauth-jwks.json`, 202 + dpop_bound_access_tokens: true, 203 + }); 204 + } 205 + 206 + if (url.pathname === '/oauth-jwks.json') { 207 + return Response.json({ 208 + keys: jwks.keys.map((key) => key.publicKey), 209 + }); 210 + } 211 + 114 212 contexts.set(request, ctx); 115 213 return router.fetch(request); 116 214 },
+206
server/jwt.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + import { fromBase64Url, toBase64Url } from '@atcute/multibase'; 4 + import { decodeUtf8From, encodeUtf8 } from '@atcute/uint8array'; 5 + 6 + export class MalformedJwtError extends Error { 7 + name = 'MalformedJwtError'; 8 + 9 + constructor(options?: ErrorOptions) { 10 + super(`malformed JWT`, options); 11 + } 12 + } 13 + 14 + export interface DecodedJwt<THeader, TPayload> { 15 + header: THeader; 16 + payload: TPayload; 17 + message: Uint8Array<ArrayBuffer>; 18 + signature: Uint8Array<ArrayBuffer>; 19 + } 20 + 21 + const decodeJwt = <THeader, TPayload>( 22 + input: string, 23 + schemas: { header: v.Type<THeader>; payload: v.Type<TPayload> }, 24 + ): DecodedJwt<THeader, TPayload> => { 25 + const parts = input.split('.'); 26 + if (parts.length !== 3) { 27 + throw new MalformedJwtError(); 28 + } 29 + 30 + const [headerString, payloadString, signatureString] = parts; 31 + 32 + const header = decodeJwtPortion(schemas.header, headerString); 33 + const payload = decodeJwtPortion(schemas.payload, payloadString); 34 + const signature = decodeJwtSignature(signatureString); 35 + 36 + return { 37 + header: header, 38 + payload: payload, 39 + message: encodeUtf8(`${headerString}.${payloadString}`), 40 + signature: signature, 41 + }; 42 + }; 43 + 44 + const decodeJwtPortion = <T>(schema: v.Type<T>, input: string): T => { 45 + try { 46 + const raw = decodeUtf8From(fromBase64Url(input)); 47 + const json = JSON.parse(raw); 48 + 49 + return schema.parse(json, { mode: 'passthrough' }); 50 + } catch (err) { 51 + throw new MalformedJwtError({ cause: err }); 52 + } 53 + }; 54 + 55 + const decodeJwtSignature = (input: string): Uint8Array<ArrayBuffer> => { 56 + try { 57 + return fromBase64Url(input); 58 + } catch (err) { 59 + throw new MalformedJwtError({ cause: err }); 60 + } 61 + }; 62 + 63 + const encodeJwtPortion = (data: unknown): string => { 64 + return toBase64Url(encodeUtf8(JSON.stringify(data))); 65 + }; 66 + 67 + const encodeJwtSignature = (data: Uint8Array): string => { 68 + return toBase64Url(data); 69 + }; 70 + 71 + // #region DPoP 72 + export class InvalidDPoPError extends Error { 73 + name = 'InvalidDPoPError'; 74 + } 75 + 76 + const dpopHeaderSchema = v.object({ 77 + typ: v.literal('dpop+jwt'), 78 + alg: v.literal('ES256'), 79 + jwk: v.object({ 80 + kty: v.literal('EC'), 81 + crv: v.literal('P-256'), 82 + x: v.string(), 83 + y: v.string(), 84 + }), 85 + }); 86 + 87 + const dpopPayloadSchema = v.object({ 88 + htm: v.string(), 89 + htu: v.string(), 90 + iat: v.number(), 91 + jti: v.string(), 92 + }); 93 + 94 + const calculateJwkThumbprint = async (jwk: JsonWebKey): Promise<string> => { 95 + // For EC keys, thumbprint is SHA-256 of canonical JSON 96 + // Members must be in lexicographic order 97 + const canonical = JSON.stringify({ 98 + crv: jwk.crv, 99 + kty: jwk.kty, 100 + x: jwk.x, 101 + y: jwk.y, 102 + }); 103 + 104 + const hash = await crypto.subtle.digest('SHA-256', encodeUtf8(canonical)); 105 + return toBase64Url(new Uint8Array(hash)); 106 + }; 107 + 108 + export const verifyDPoP = async (dpop: string | null, jkt: string): Promise<void> => { 109 + if (!dpop) { 110 + throw new InvalidDPoPError(`missing DPoP header`); 111 + } 112 + 113 + // Decode the DPoP JWT 114 + let decoded; 115 + try { 116 + decoded = decodeJwt(dpop, { 117 + header: dpopHeaderSchema, 118 + payload: dpopPayloadSchema, 119 + }); 120 + } catch (err) { 121 + throw new InvalidDPoPError(`malformed JWT`, { cause: err }); 122 + } 123 + 124 + const { header, message, signature } = decoded; 125 + 126 + // Verify JWK thumbprint matches jkt 127 + const thumbprint = await calculateJwkThumbprint(header.jwk); 128 + if (thumbprint !== jkt) { 129 + throw new InvalidDPoPError(`JWK thumbprint mismatch`); 130 + } 131 + 132 + // Import the public key for signature verification 133 + let publicKey: CryptoKey; 134 + try { 135 + publicKey = await crypto.subtle.importKey( 136 + 'jwk', 137 + header.jwk, 138 + { name: 'ECDSA', namedCurve: 'P-256' }, 139 + false, 140 + ['verify'], 141 + ); 142 + } catch (err) { 143 + throw new InvalidDPoPError(`failed to import JWK`, { cause: err }); 144 + } 145 + 146 + // Verify the signature 147 + const isValid = await crypto.subtle.verify( 148 + { name: 'ECDSA', hash: 'SHA-256' }, 149 + publicKey, 150 + signature, 151 + message, 152 + ); 153 + 154 + if (!isValid) { 155 + throw new InvalidDPoPError(`invalid DPoP signature`); 156 + } 157 + }; 158 + 159 + // #endregion 160 + 161 + // #region Client assertions 162 + 163 + export const createClientAssertion = async (options: { 164 + kid: string; 165 + client_id: string; 166 + aud: string; 167 + privateKey: CryptoKey; 168 + }): Promise<string> => { 169 + const { kid, client_id, aud, privateKey } = options; 170 + 171 + const now = Math.floor(Date.now() / 1000); 172 + 173 + const header = { 174 + alg: 'ES256', 175 + typ: 'JWT', 176 + kid: kid, 177 + }; 178 + 179 + const payload = { 180 + iss: client_id, 181 + sub: client_id, 182 + aud: aud, 183 + jti: crypto.randomUUID(), 184 + iat: now, 185 + exp: now + 60, 186 + }; 187 + 188 + const message = `${encodeJwtPortion(header)}.${encodeJwtPortion(payload)}`; 189 + 190 + const signature = encodeJwtSignature( 191 + new Uint8Array( 192 + await crypto.subtle.sign( 193 + { 194 + name: 'ECDSA', 195 + hash: 'SHA-256', 196 + }, 197 + privateKey, 198 + encodeUtf8(message), 199 + ), 200 + ), 201 + ); 202 + 203 + return `${message}.${signature}`; 204 + }; 205 + 206 + // #endregion
+43
server/lexicons.ts
··· 1 + import type {} from '@atcute/lexicons/ambient'; 2 + import * as v from '@atcute/lexicons/validations'; 3 + 4 + export const requestAssertionSchema = v.procedure('x.aglais.requestAssertion', { 5 + params: null, 6 + input: { 7 + type: 'lex', 8 + schema: v.object({ 9 + jkt: v.string(), 10 + aud: v.string(), 11 + }), 12 + }, 13 + output: { 14 + type: 'lex', 15 + schema: v.object({ 16 + assertion: v.string(), 17 + }), 18 + }, 19 + }); 20 + 21 + export const resolveIdentitySchema = v.query('x.aglais.resolveIdentity', { 22 + params: v.object({ 23 + identifier: v.actorIdentifierString(), 24 + }), 25 + output: { 26 + type: 'lex', 27 + schema: v.object({ 28 + did: v.didString(), 29 + handle: v.handleString(), 30 + pds: v.genericUriString(), 31 + }), 32 + }, 33 + }); 34 + 35 + declare module '@atcute/lexicons/ambient' { 36 + interface XRPCProcedures { 37 + 'x.aglais.requestAssertion': typeof requestAssertionSchema; 38 + } 39 + 40 + interface XRPCQueries { 41 + 'x.aglais.resolveIdentity': typeof resolveIdentitySchema; 42 + } 43 + }
+9
server/vite-env.d.ts
··· 1 + /// <reference types="vite/client" /> 2 + 3 + interface ImportMetaEnv { 4 + readonly VITE_APP_NAME: string; 5 + } 6 + 7 + interface ImportMeta { 8 + readonly env: ImportMetaEnv; 9 + }
+1 -1
src/components/main/sign-in-dialog.tsx
··· 53 53 54 54 const authUrl = await createAuthorizationUrl({ 55 55 target: target, 56 - scope: import.meta.env.VITE_OAUTH_SCOPE, 56 + scope: 'atproto transition:generic transition:chat.bsky', 57 57 }); 58 58 59 59 setPending(`Redirecting to authorization page`);
+37 -21
src/main.tsx
··· 2 2 import { type JSX, createSignal, onMount } from 'solid-js'; 3 3 import { render } from 'solid-js/web'; 4 4 5 - import type { Did, Handle } from '@atcute/lexicons'; 5 + import { Client, ok, simpleFetchHandler } from '@atcute/client'; 6 + import type { Did } from '@atcute/lexicons'; 6 7 import { configureOAuth } from '@atcute/oauth-browser-client'; 7 8 8 9 import * as navigation from '~/globals/navigation'; ··· 18 19 import CircularProgress from '~/components/circular-progress'; 19 20 import ModalRenderer from '~/components/main/modal-renderer'; 20 21 22 + import type {} from '../server/lexicons'; 23 + 21 24 import routes from './routes'; 22 25 import './service-worker'; 23 26 import Shell from './shell'; ··· 33 36 34 37 // Configure OAuth 35 38 { 39 + const host = new Client({ 40 + handler: simpleFetchHandler({ service: location.origin }), 41 + }); 42 + 36 43 configureOAuth({ 37 44 metadata: { 38 - client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 39 - redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 45 + client_id: `${location.origin}/oauth-client-metadata.json`, 46 + redirect_uri: `${location.origin}/oauth/callback`, 40 47 }, 41 48 42 49 identityResolver: { 43 50 async resolve(actor) { 44 - const url = new URL('https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc'); 45 - url.searchParams.set('identifier', actor); 51 + const data = await ok( 52 + host.get('x.aglais.resolveIdentity', { 53 + params: { 54 + identifier: actor, 55 + }, 56 + }), 57 + ); 46 58 47 - const response = await fetch(url); 48 - if (!response.ok) { 49 - throw new Error(`resolver responded with status ${response.status}`); 50 - } 59 + return data; 60 + }, 61 + }, 62 + async fetchClientAssertion({ aud, jkt, createDpopProof }) { 63 + const dpop = await createDpopProof(`${location.origin}/xrpc/x.aglais.requestAssertion`); 51 64 52 - const json = (await response.json()) as { 53 - did: Did; 54 - handle: Handle; 55 - pds: string; 56 - signing_key: string; 57 - }; 65 + const data = await ok( 66 + host.post('x.aglais.requestAssertion', { 67 + input: { 68 + aud: aud, 69 + jkt: jkt, 70 + }, 71 + headers: { 72 + dpop: dpop, 73 + }, 74 + }), 75 + ); 58 76 59 - return { 60 - did: json.did, 61 - handle: json.handle, 62 - pds: json.pds, 63 - }; 64 - }, 77 + return { 78 + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 79 + client_assertion: data.assertion, 80 + }; 65 81 }, 66 82 }); 67 83 }
-6
src/vite-env.d.ts
··· 6 6 7 7 interface ImportMetaEnv { 8 8 readonly VITE_APP_NAME: string; 9 - 10 - readonly VITE_DEV_SERVER_PORT?: string; 11 - readonly VITE_CLIENT_URI: string; 12 - readonly VITE_OAUTH_CLIENT_ID: string; 13 - readonly VITE_OAUTH_REDIRECT_URL: string; 14 - readonly VITE_OAUTH_SCOPE: string; 15 9 } 16 10 17 11 interface ImportMeta {
+2 -36
vite.config.ts
··· 5 5 import { VitePWA } from 'vite-plugin-pwa'; 6 6 import solid from 'vite-plugin-solid'; 7 7 8 - import metadata from './public/oauth-client-metadata.json'; 9 - 10 - const SERVER_HOST = '127.0.0.1'; 11 - const SERVER_PORT = 52222; 12 - 13 8 export default defineConfig({ 14 9 build: { 15 10 target: 'esnext', ··· 59 54 }, 60 55 }, 61 56 server: { 62 - host: SERVER_HOST, 63 - port: SERVER_PORT, 57 + allowedHosts: ['.trycloudflare.com'], 64 58 }, 65 59 optimizeDeps: { 66 60 esbuildOptions: { ··· 74 68 }, 75 69 }), 76 70 77 - process.env.NODE_ENV === 'development' && cloudflare(), 71 + cloudflare(), 78 72 79 73 VitePWA({ 80 74 registerType: 'prompt', ··· 116 110 ); 117 111 118 112 return { code: transformed, map: null }; 119 - }, 120 - }, 121 - 122 - // Injects OAuth-related variables 123 - { 124 - name: 'aglais-oauth-inject', 125 - config(_conf, { command }) { 126 - if (command === 'build') { 127 - process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id; 128 - process.env.VITE_OAUTH_REDIRECT_URL = metadata.redirect_uris[0]; 129 - } else { 130 - const redirectUri = (() => { 131 - const url = new URL(metadata.redirect_uris[0]); 132 - return `http://${SERVER_HOST}:${SERVER_PORT}${url.pathname}`; 133 - })(); 134 - 135 - const clientId = 136 - `http://localhost` + 137 - `?redirect_uri=${encodeURIComponent(redirectUri)}` + 138 - `&scope=${encodeURIComponent(metadata.scope)}`; 139 - 140 - process.env.VITE_DEV_SERVER_PORT = '' + SERVER_PORT; 141 - process.env.VITE_OAUTH_CLIENT_ID = clientId; 142 - process.env.VITE_OAUTH_REDIRECT_URL = redirectUri; 143 - } 144 - 145 - process.env.VITE_CLIENT_URI = metadata.client_uri; 146 - process.env.VITE_OAUTH_SCOPE = metadata.scope; 147 113 }, 148 114 }, 149 115 ],
+1 -2
wrangler.jsonc
··· 4 4 "compatibility_date": "2025-08-16", 5 5 "main": "server/index.ts", 6 6 "assets": { 7 - "directory": "dist", 8 7 "not_found_handling": "single-page-application", 9 - "run_worker_first": ["/xrpc/*"], 8 + "run_worker_first": ["/xrpc/*", "/oauth-client-metadata.json", "/oauth-jwks.json"], 10 9 }, 11 10 }