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

feat(oauth-browser-client): initial commit

mary.my.id 36bd8bd3 da069b1b

verified
+6
README.md
··· 36 36 <td><code>whitewind</code>: adds <code>com.whtwnd.*</code> definitions</td> 37 37 </tr> 38 38 <tr> 39 + <th colspan="2" align="left">OAuth packages</th> 40 + </tr> 41 + <tr> 42 + <td><code>oauth-browser-client</code>: minimal OAuth browser client implementation</td> 43 + </tr> 44 + <tr> 39 45 <th colspan="2" align="left">Utility packages</th> 40 46 </tr> 41 47 <tr>
+159
packages/oauth/browser-client/README.md
··· 1 + # @atcute/oauth-browser-client 2 + 3 + minimal OAuth browser client implementation for AT Protocol. 4 + 5 + - **only the bare minimum**: enough code to get authentication reasonably working, with only one 6 + happy path is supported (only ES256 keys for DPoP. PKCE and DPoP-bound PAR is required.) 7 + - **does not use IndexedDB**: makes the library work under Safari's lockdown mode, and has less 8 + [maintenance headache][indexeddb-woes] overall, but it also means this is "less secure" (it won't 9 + be able to use non-exportable keys as recommended by [DPoP specification][idb-dpop-spec].) 10 + - **no independent DNS/HTTP handle checks**: the default handle resolver makes use of Bluesky's 11 + AppView to retrieve the correct DID identifier. you should be able to write your own resolver 12 + function that'll resolve via DNS-over-HTTPS or via other PDSes. 13 + - **not well-tested**: it has been used in personal projects for quite some time, but hasn't seen 14 + any use outside of that. using the [reference implementation][oauth-atproto-lib] is recommended if 15 + you are unsure about the implications presented here. 16 + 17 + [indexeddb-woes]: https://gist.github.com/pesterhazy/4de96193af89a6dd5ce682ce2adff49a 18 + [idb-dpop-spec]: https://datatracker.ietf.org/doc/html/rfc9449#section-2-4 19 + [oauth-atproto-lib]: https://npm.im/@atproto/oauth-client-browser 20 + 21 + ## usage 22 + 23 + ### setup 24 + 25 + initialize the client by importing and calling `configureOAuth` with the client ID and redirect URL. 26 + this call should be placed before any other calls you make with this library. 27 + 28 + ```ts 29 + import { configureOAuth } from '@atcute/oauth-browser-client'; 30 + 31 + configureOAuth({ 32 + metadata: { 33 + client_id: 'https://example.com/oauth/client-metadata.json', 34 + redirect_uri: 'https://example.com/oauth/callback', 35 + }, 36 + }); 37 + ``` 38 + 39 + ### starting an authorization flow 40 + 41 + if your application involves asking for the user's handle or DID, you can use `resolveFromIdentity` 42 + which resolves the user's identity to get its PDS, and the metadata of its authorization server. 43 + 44 + ```ts 45 + import { resolveFromIdentity } from '@atcute/oauth-browser-client'; 46 + 47 + const { identity, metadata } = await resolveFromIdentity('mary.my.id'); 48 + ``` 49 + 50 + alternatively, if it involves asking for the user's PDS, then you can use `resolveFromService` which 51 + just grabs the authorization server metadata. 52 + 53 + ```ts 54 + import { resolveFromService } from '@atcute/oauth-browser-client'; 55 + 56 + const { metadata } = await resolveFromService('bsky.social'); 57 + ``` 58 + 59 + we can then proceed with authorization by calling `createAuthorizationUrl` with the resolved 60 + `metadata` (and `identity`, if using `resolveFromIdentity`) along with the scope of the 61 + authorization, which should either match the one in your client metadata, or a reduced set of it. 62 + 63 + ```ts 64 + import { createAuthorizationUrl } from '@atcute/oauth-browser-client'; 65 + 66 + // passing `identity` is optional, 67 + // it allows for the login form to be autofilled with the user's handle or DID 68 + const authUrl = await createAuthorizationUrl({ 69 + metadata: metadata, 70 + identity: identity, 71 + scope: 'atproto transition:generic transition:chat.bsky', 72 + }); 73 + 74 + // recommended to wait for the browser to persist local storage before proceeding 75 + await sleep(200); 76 + 77 + // redirect the user to sign in and authorize the app 78 + window.location.assign(authUrl); 79 + 80 + // if this is on an async function, ideally the function should never ever resolve. 81 + // the only way it should resolve at this point is if the user aborted the authorization 82 + // by returning back to this page (thanks to back-forward page caching) 83 + await new Promise((_resolve, reject) => { 84 + const listener = () => { 85 + reject(new Error(`user aborted the login request`)); 86 + }; 87 + 88 + window.addEventListener('pageshow', listener, { once: true }); 89 + }); 90 + ``` 91 + 92 + ### finalizing authorization 93 + 94 + once the user has been redirected to your redirect URL, we can call `finalizeAuthorization` with the 95 + parameters that have been provided. 96 + 97 + ```ts 98 + import { XRPC } from '@atcute/client'; 99 + import { OAuthUserAgent, finalizeAuthorization } from '@atcute/oauth-browser-client'; 100 + 101 + // `createAuthorizationUrl` asks for the server to redirect here with the 102 + // parameters assigned in the hash, not the search string. 103 + const params = new URLSearchParams(location.hash.slice(1)); 104 + 105 + // this is optional, but after retrieving the parameters, we should ideally 106 + // scrub it from history to prevent this authorization state to be replayed, 107 + // just for good measure. 108 + history.replaceState(null, '', location.pathname + location.search); 109 + 110 + // you'd be given a session object that you can then pass to OAuthUserAgent! 111 + const session = await finalizeAuthorization(params); 112 + 113 + // now you can start making requests! 114 + const agent = new OAuthUserAgent(session); 115 + 116 + // pass it onto the XRPC so you can make RPC calls with the PDS. 117 + const rpc = new XRPC({ handler: agent }); 118 + ``` 119 + 120 + the `session` object returned by `finalizeAuthorization` should not be stored anywhere else, as it 121 + is already persisted in the internal database. you are expected to keep track of who's signed in and 122 + who was last signed in for your own UI, as the sessions stored by the database is not guaranteed to 123 + be permanent (mostly if they don't come with a refresh token.) 124 + 125 + ### resuming existing sessions 126 + 127 + you can resume existing sessions by calling `getSession` with the DID identifier you intend to 128 + resume. 129 + 130 + ```ts 131 + import { XRPC } from '@atcute/client'; 132 + import { OAuthUserAgent, getSession } from '@atcute/oauth-browser-client'; 133 + 134 + const session = await getSession('did:plc:ia76kvnndjutgedggx2ibrem', { allowStale: true }); 135 + 136 + const agent = new OAuthUserAgent(session); 137 + const rpc = new XRPC({ handler: agent }); 138 + ``` 139 + 140 + ### removing sessions 141 + 142 + you can manually remove sessions via `deleteStoredSession`, but ideally, you should revoke the token 143 + first before doing so. 144 + 145 + ```ts 146 + import { OAuthUserAgent, deleteStoredSession, getSession } from '@atcute/oauth-browser-client'; 147 + 148 + const did = 'did:plc:ia76kvnndjutgedggx2ibrem'; 149 + 150 + try { 151 + const session = await getSession(did, { allowStale: true }); 152 + const agent = new OAuthUserAgent(session); 153 + 154 + await agent.signOut(); 155 + } catch (err) { 156 + // `signOut` also deletes the session, we only serve as fallback if it fails. 157 + deleteStoredSession(did); 158 + } 159 + ```
+115
packages/oauth/browser-client/lib/agents/exchange.ts
··· 1 + import { createES256Key } from '../dpop.js'; 2 + import { CLIENT_ID, database, REDIRECT_URI } from '../environment.js'; 3 + import { AuthorizationError, LoginError } from '../errors.js'; 4 + import type { IdentityMetadata } from '../types/identity.js'; 5 + import type { AuthorizationServerMetadata } from '../types/server.js'; 6 + import type { Session } from '../types/token.js'; 7 + import { generatePKCE, generateState } from '../utils/runtime.js'; 8 + 9 + import { OAuthServerAgent } from './server-agent.js'; 10 + import { storeSession } from './sessions.js'; 11 + 12 + export interface AuthorizeOptions { 13 + metadata: AuthorizationServerMetadata; 14 + identity?: IdentityMetadata; 15 + scope: string; 16 + } 17 + 18 + /** 19 + * Create authentication URL for authorization 20 + * @param options 21 + * @returns URL to redirect the user for authorization 22 + */ 23 + export const createAuthorizationUrl = async ({ 24 + metadata, 25 + identity, 26 + scope, 27 + }: AuthorizeOptions): Promise<URL> => { 28 + const state = generateState(); 29 + 30 + const pkce = await generatePKCE(); 31 + const dpopKey = await createES256Key(); 32 + 33 + const params = { 34 + redirect_uri: REDIRECT_URI, 35 + code_challenge: pkce.challenge, 36 + code_challenge_method: pkce.method, 37 + state: state, 38 + login_hint: identity?.raw, 39 + response_mode: 'fragment', 40 + response_type: 'code', 41 + display: 'page', 42 + // id_token_hint: undefined, 43 + // max_age: undefined, 44 + // prompt: undefined, 45 + scope: scope, 46 + // ui_locales: undefined, 47 + } satisfies Record<string, string | undefined>; 48 + 49 + database.states.set(state, { 50 + dpopKey: dpopKey, 51 + metadata: metadata, 52 + verifier: pkce.verifier, 53 + }); 54 + 55 + const server = new OAuthServerAgent(metadata, dpopKey); 56 + const response = await server.request('pushed_authorization_request', params); 57 + 58 + const authUrl = new URL(metadata.authorization_endpoint); 59 + authUrl.searchParams.set('client_id', CLIENT_ID); 60 + authUrl.searchParams.set('request_uri', response.request_uri); 61 + 62 + return authUrl; 63 + }; 64 + 65 + /** 66 + * Finalize authorization 67 + * @param params Search params 68 + * @returns Session object, which you can use to instantiate user agents 69 + */ 70 + export const finalizeAuthorization = async (params: URLSearchParams) => { 71 + const issuer = params.get('iss'); 72 + const state = params.get('state'); 73 + const code = params.get('code'); 74 + const error = params.get('error'); 75 + 76 + if (!state || !(code || error)) { 77 + throw new LoginError(`missing parameters`); 78 + } 79 + 80 + const stored = database.states.get(state); 81 + if (stored) { 82 + // Delete now that we've caught it 83 + database.states.delete(state); 84 + } else { 85 + throw new LoginError(`unknown state provided`); 86 + } 87 + 88 + const dpopKey = stored.dpopKey; 89 + const metadata = stored.metadata; 90 + 91 + if (error) { 92 + throw new AuthorizationError(params.get('error_description') || error); 93 + } 94 + if (!code) { 95 + throw new LoginError(`missing code parameter`); 96 + } 97 + 98 + if (issuer === null) { 99 + throw new LoginError(`missing issuer parameter`); 100 + } else if (issuer !== metadata.issuer) { 101 + throw new LoginError(`issuer mismatch`); 102 + } 103 + 104 + // Retrieve authentication tokens 105 + const server = new OAuthServerAgent(metadata, dpopKey); 106 + const { info, token } = await server.exchangeCode(code, stored.verifier); 107 + 108 + // We're finished! 109 + const sub = info.sub; 110 + const session: Session = { dpopKey, info, token }; 111 + 112 + await storeSession(sub, session); 113 + 114 + return session; 115 + };
+148
packages/oauth/browser-client/lib/agents/server-agent.ts
··· 1 + import type { At } from '@atcute/client/lexicons'; 2 + 3 + import { createDPoPFetch } from '../dpop.js'; 4 + import { CLIENT_ID, REDIRECT_URI } from '../environment.js'; 5 + import { FetchResponseError, OAuthResponseError, TokenRefreshError } from '../errors.js'; 6 + import { resolveFromIdentity } from '../resolvers.js'; 7 + import type { DPoPKey } from '../types/dpop.js'; 8 + import type { OAuthParResponse } from '../types/par.js'; 9 + import type { PersistedAuthorizationServerMetadata } from '../types/server.js'; 10 + import type { ExchangeInfo, OAuthTokenResponse, TokenInfo } from '../types/token.js'; 11 + import { pick } from '../utils/misc.js'; 12 + import { extractContentType } from '../utils/response.js'; 13 + 14 + export class OAuthServerAgent { 15 + #fetch: typeof fetch; 16 + #metadata: PersistedAuthorizationServerMetadata; 17 + 18 + constructor(metadata: PersistedAuthorizationServerMetadata, dpopKey: DPoPKey) { 19 + this.#metadata = metadata; 20 + this.#fetch = createDPoPFetch(CLIENT_ID, dpopKey, true); 21 + } 22 + 23 + async request( 24 + endpoint: 'pushed_authorization_request', 25 + payload: Record<string, unknown>, 26 + ): Promise<OAuthParResponse>; 27 + async request(endpoint: 'token', payload: Record<string, unknown>): Promise<OAuthTokenResponse>; 28 + async request(endpoint: 'revocation', payload: Record<string, unknown>): Promise<any>; 29 + async request(endpoint: 'introspection', payload: Record<string, unknown>): Promise<any>; 30 + async request(endpoint: string, payload: Record<string, unknown>): Promise<any> { 31 + const url: string | undefined = (this.#metadata as any)[`${endpoint}_endpoint`]; 32 + if (!url) { 33 + throw new Error(`no endpoint for ${endpoint}`); 34 + } 35 + 36 + const response = await this.#fetch(url, { 37 + method: 'post', 38 + headers: { 'content-type': 'application/json' }, 39 + body: JSON.stringify({ ...payload, client_id: CLIENT_ID }), 40 + }); 41 + 42 + if (extractContentType(response.headers) !== 'application/json') { 43 + throw new FetchResponseError(response, 2, `unexpected content-type`); 44 + } 45 + 46 + const json = await response.json(); 47 + 48 + if (response.ok) { 49 + return json; 50 + } else { 51 + throw new OAuthResponseError(response, json); 52 + } 53 + } 54 + 55 + async revoke(token: string): Promise<void> { 56 + try { 57 + await this.request('revocation', { token: token }); 58 + } catch {} 59 + } 60 + 61 + async exchangeCode(code: string, verifier?: string): Promise<{ info: ExchangeInfo; token: TokenInfo }> { 62 + const response = await this.request('token', { 63 + grant_type: 'authorization_code', 64 + redirect_uri: REDIRECT_URI, 65 + code: code, 66 + code_verifier: verifier, 67 + }); 68 + 69 + try { 70 + return await this.#processExchangeResponse(response); 71 + } catch (err) { 72 + await this.revoke(response.access_token); 73 + throw err; 74 + } 75 + } 76 + 77 + async refresh({ sub, token }: { sub: At.DID; token: TokenInfo }): Promise<TokenInfo> { 78 + if (!token.refresh) { 79 + throw new TokenRefreshError(sub, 'no refresh token available'); 80 + } 81 + 82 + const response = await this.request('token', { 83 + grant_type: 'refresh_token', 84 + refresh_token: token.refresh, 85 + }); 86 + 87 + try { 88 + if (sub !== response.sub) { 89 + throw new TokenRefreshError(sub, `sub mismatch in token response; got ${response.sub}`); 90 + } 91 + 92 + return this.#processTokenResponse(response); 93 + } catch (err) { 94 + await this.revoke(response.access_token); 95 + 96 + throw err; 97 + } 98 + } 99 + 100 + #processTokenResponse(res: OAuthTokenResponse): TokenInfo { 101 + const sub = res.sub; 102 + const scope = res.scope; 103 + if (!sub) { 104 + throw new TypeError(`missing sub field in token response`); 105 + } 106 + if (!scope) { 107 + throw new TypeError(`missing scope field in token response`); 108 + } 109 + 110 + return { 111 + scope: scope, 112 + refresh: res.refresh_token, 113 + access: res.access_token, 114 + type: res.token_type ?? 'Bearer', 115 + expires_at: typeof res.expires_in === 'number' ? Date.now() + res.expires_in * 1_000 : undefined, 116 + }; 117 + } 118 + 119 + async #processExchangeResponse(res: OAuthTokenResponse): Promise<{ info: ExchangeInfo; token: TokenInfo }> { 120 + const sub = res.sub; 121 + if (!sub) { 122 + throw new TypeError(`missing sub field in token response`); 123 + } 124 + 125 + const token = this.#processTokenResponse(res); 126 + const resolved = await resolveFromIdentity(sub); 127 + 128 + if (resolved.metadata.issuer !== this.#metadata.issuer) { 129 + throw new TypeError(`issuer mismatch; got ${resolved.metadata.issuer}`); 130 + } 131 + 132 + return { 133 + token: token, 134 + info: { 135 + sub: sub as At.DID, 136 + aud: resolved.identity.pds.href, 137 + server: pick(resolved.metadata, [ 138 + 'issuer', 139 + 'authorization_endpoint', 140 + 'introspection_endpoint', 141 + 'pushed_authorization_request_endpoint', 142 + 'revocation_endpoint', 143 + 'token_endpoint', 144 + ]), 145 + }, 146 + }; 147 + } 148 + }
+142
packages/oauth/browser-client/lib/agents/sessions.ts
··· 1 + import type { At } from '@atcute/client/lexicons'; 2 + 3 + import { database } from '../environment.js'; 4 + import { OAuthResponseError, TokenRefreshError } from '../errors.js'; 5 + import type { Session } from '../types/token.js'; 6 + 7 + import { OAuthServerAgent } from './server-agent.js'; 8 + 9 + export interface SessionGetOptions { 10 + signal?: AbortSignal; 11 + noCache?: boolean; 12 + allowStale?: boolean; 13 + } 14 + 15 + type PendingItem<V> = Promise<{ value: V; isFresh: boolean }>; 16 + const pending = new Map<At.DID, PendingItem<Session>>(); 17 + 18 + export const getSession = async (sub: At.DID, options?: SessionGetOptions): Promise<Session> => { 19 + options?.signal?.throwIfAborted(); 20 + 21 + let allowStored = isTokenUsable; 22 + if (options?.noCache) { 23 + allowStored = returnFalse; 24 + } else if (options?.allowStale) { 25 + allowStored = returnTrue; 26 + } 27 + 28 + // As long as concurrent requests are made for the same key, only one 29 + // request will be made to the cache & getter function at a time. This works 30 + // because there is no async operation between the while() loop and the 31 + // pending.set() call. Because of the "single threaded" nature of 32 + // JavaScript, the pending item will be set before the next iteration of the 33 + // while loop. 34 + let previousExecutionFlow: PendingItem<Session> | undefined; 35 + while ((previousExecutionFlow = pending.get(sub))) { 36 + try { 37 + const { isFresh, value } = await previousExecutionFlow; 38 + 39 + if (isFresh || allowStored(value)) { 40 + return value; 41 + } 42 + } catch { 43 + // Ignore errors from previous execution flows (they will have been 44 + // propagated by that flow). 45 + } 46 + 47 + options?.signal?.throwIfAborted(); 48 + } 49 + 50 + const lockKey = `atcute-oauth:${sub}`; 51 + 52 + let promise: PendingItem<Session>; 53 + 54 + promise = navigator.locks.request(lockKey, async (): PendingItem<Session> => { 55 + const storedSession = database.sessions.get(sub); 56 + 57 + console.log(storedSession, allowStored); 58 + 59 + if (storedSession && allowStored(storedSession)) { 60 + console.log('true'); 61 + // Use the stored value as return value for the current execution 62 + // flow. Notify other concurrent execution flows (that should be 63 + // "stuck" in the loop before until this promise resolves) that we got 64 + // a value, but that it came from the store (isFresh = false). 65 + return { isFresh: false, value: storedSession }; 66 + } 67 + 68 + console.log('false'); 69 + 70 + const newSession = await refreshToken(sub, storedSession); 71 + 72 + await storeSession(sub, newSession); 73 + return { isFresh: true, value: newSession }; 74 + }); 75 + 76 + promise = promise.finally(() => pending.delete(sub)); 77 + 78 + if (pending.has(sub)) { 79 + // This should never happen. Indeed, there must not be any 'await' 80 + // statement between this and the loop iteration check meaning that 81 + // this.pending.get returned undefined. It is there to catch bugs that 82 + // would occur in future changes to the code. 83 + throw new Error('concurrent request for the same key'); 84 + } 85 + 86 + pending.set(sub, promise); 87 + 88 + const { value } = await promise; 89 + return value; 90 + }; 91 + 92 + export const storeSession = async (sub: At.DID, newSession: Session): Promise<void> => { 93 + try { 94 + database.sessions.set(sub, newSession); 95 + } catch (err) { 96 + await onRefreshError(newSession); 97 + throw err; 98 + } 99 + }; 100 + 101 + export const deleteStoredSession = (sub: At.DID): void => { 102 + database.sessions.delete(sub); 103 + }; 104 + 105 + export const listStoredSessions = (): At.DID[] => { 106 + return database.sessions.keys(); 107 + }; 108 + 109 + const returnTrue = () => true; 110 + const returnFalse = () => false; 111 + 112 + const refreshToken = async (sub: At.DID, storedSession: Session | undefined): Promise<Session> => { 113 + if (storedSession === undefined) { 114 + throw new TokenRefreshError(sub, `session deleted by another tab`); 115 + } 116 + 117 + const { dpopKey, info, token } = storedSession; 118 + const server = new OAuthServerAgent(info.server, dpopKey); 119 + 120 + try { 121 + const newToken = await server.refresh({ sub: info.sub, token }); 122 + 123 + return { dpopKey, info, token: newToken }; 124 + } catch (cause) { 125 + if (cause instanceof OAuthResponseError && cause.status === 400 && cause.error === 'invalid_grant') { 126 + throw new TokenRefreshError(sub, `session was revoked`, { cause }); 127 + } 128 + 129 + throw cause; 130 + } 131 + }; 132 + 133 + const onRefreshError = async ({ dpopKey, info, token }: Session) => { 134 + // If the token data cannot be stored, let's revoke it 135 + const server = new OAuthServerAgent(info.server, dpopKey); 136 + await server.revoke(token.refresh ?? token.access); 137 + }; 138 + 139 + const isTokenUsable = ({ token }: Session): boolean => { 140 + const expires = token.expires_at; 141 + return expires == null || Date.now() + 60_000 <= expires; 142 + };
+99
packages/oauth/browser-client/lib/agents/user-agent.ts
··· 1 + import type { FetchHandlerObject } from '@atcute/client'; 2 + import type { At } from '@atcute/client/lexicons'; 3 + 4 + import { createDPoPFetch } from '../dpop.js'; 5 + import { CLIENT_ID } from '../environment.js'; 6 + import type { Session } from '../types/token.js'; 7 + 8 + import { OAuthServerAgent } from './server-agent.js'; 9 + import { type SessionGetOptions, deleteStoredSession, getSession } from './sessions.js'; 10 + 11 + export class OAuthUserAgent implements FetchHandlerObject { 12 + #fetch: typeof fetch; 13 + #getSessionPromise: Promise<Session> | undefined; 14 + 15 + constructor(public session: Session) { 16 + this.#fetch = createDPoPFetch(CLIENT_ID, session.dpopKey, false); 17 + } 18 + 19 + get did(): At.DID { 20 + return this.session.info.sub; 21 + } 22 + 23 + getSession(options?: SessionGetOptions): Promise<Session> { 24 + const promise = getSession(this.session.info.sub, options); 25 + 26 + promise 27 + .then((session) => { 28 + this.session = session; 29 + }) 30 + .finally(() => { 31 + this.#getSessionPromise = undefined; 32 + }); 33 + 34 + return (this.#getSessionPromise = promise); 35 + } 36 + 37 + async signOut(): Promise<void> { 38 + const sub = this.session.info.sub; 39 + 40 + try { 41 + const { dpopKey, info, token } = await getSession(sub, { allowStale: true }); 42 + const server = new OAuthServerAgent(info.server, dpopKey); 43 + 44 + await server.revoke(token.refresh ?? token.access); 45 + } finally { 46 + deleteStoredSession(sub); 47 + } 48 + } 49 + 50 + async handle(pathname: string, init?: RequestInit): Promise<Response> { 51 + await this.#getSessionPromise; 52 + 53 + const headers = new Headers(init?.headers); 54 + 55 + let session = this.session; 56 + let url = new URL(pathname, session.info.aud); 57 + 58 + headers.set('authorization', `${session.token.type} ${session.token.access}`); 59 + 60 + let response = await this.#fetch(url, { ...init, headers }); 61 + if (!isInvalidTokenResponse(response)) { 62 + return response; 63 + } 64 + 65 + try { 66 + if (this.#getSessionPromise) { 67 + session = await this.#getSessionPromise; 68 + } else { 69 + session = await this.getSession(); 70 + } 71 + } catch { 72 + return response; 73 + } 74 + 75 + // Stream already consumed, can't retry. 76 + if (init?.body instanceof ReadableStream) { 77 + return response; 78 + } 79 + 80 + url = new URL(pathname, session.info.aud); 81 + headers.set('authorization', `${session.token.type} ${session.token.access}`); 82 + 83 + return await this.#fetch(url, { ...init, headers }); 84 + } 85 + } 86 + 87 + const isInvalidTokenResponse = (response: Response) => { 88 + if (response.status !== 401) { 89 + return false; 90 + } 91 + 92 + const auth = response.headers.get('www-authenticate'); 93 + 94 + return ( 95 + auth != null && 96 + (auth.startsWith('Bearer ') || auth.startsWith('DPoP ')) && 97 + auth.includes('error="invalid_token"') 98 + ); 99 + };
+1
packages/oauth/browser-client/lib/constants.ts
··· 1 + export const DEFAULT_APPVIEW_URL = 'https://public.api.bsky.app';
+154
packages/oauth/browser-client/lib/dpop.ts
··· 1 + import { nanoid } from 'nanoid/non-secure'; 2 + 3 + import { database } from './environment.js'; 4 + import type { DPoPKey } from './types/dpop.js'; 5 + import { extractContentType } from './utils/response.js'; 6 + import { encoder, fromBase64Url, toBase64Url, toSha256 } from './utils/runtime.js'; 7 + 8 + const ES256_ALG = { name: 'ECDSA', namedCurve: 'P-256' } as const; 9 + 10 + export const createES256Key = async (): Promise<DPoPKey> => { 11 + const pair = await crypto.subtle.generateKey(ES256_ALG, true, ['sign', 'verify']); 12 + 13 + const key = await crypto.subtle.exportKey('pkcs8', pair.privateKey); 14 + const { ext: _ext, key_ops: _key_opts, ...jwk } = await crypto.subtle.exportKey('jwk', pair.publicKey); 15 + 16 + return { 17 + typ: 'ES256', 18 + key: toBase64Url(new Uint8Array(key)), 19 + jwt: toBase64Url(encoder.encode(JSON.stringify({ typ: 'dpop+jwt', alg: 'ES256', jwk: jwk }))), 20 + }; 21 + }; 22 + 23 + export const createDPoPSignage = (issuer: string, dpopKey: DPoPKey) => { 24 + const headerString = dpopKey.jwt; 25 + const keyPromise = crypto.subtle.importKey('pkcs8', fromBase64Url(dpopKey.key), ES256_ALG, true, ['sign']); 26 + 27 + const constructPayload = ( 28 + method: string, 29 + url: string, 30 + nonce: string | undefined, 31 + ath: string | undefined, 32 + ) => { 33 + const now = (Date.now() / 1_000) | 0; 34 + 35 + const payload = { 36 + iss: issuer, 37 + iat: now, 38 + // This seems fine, we can remake the request if it fails. 39 + jti: nanoid(12), 40 + htm: method, 41 + htu: url, 42 + nonce: nonce, 43 + ath: ath, 44 + }; 45 + 46 + return toBase64Url(encoder.encode(JSON.stringify(payload))); 47 + }; 48 + 49 + return async (method: string, url: string, nonce: string | undefined, ath: string | undefined) => { 50 + const payloadString = constructPayload(method, url, nonce, ath); 51 + 52 + const signed = await crypto.subtle.sign( 53 + { name: 'ECDSA', hash: { name: 'SHA-256' } }, 54 + await keyPromise, 55 + encoder.encode(headerString + '.' + payloadString), 56 + ); 57 + 58 + const signatureString = toBase64Url(new Uint8Array(signed)); 59 + 60 + return headerString + '.' + payloadString + '.' + signatureString; 61 + }; 62 + }; 63 + 64 + export const createDPoPFetch = (issuer: string, dpopKey: DPoPKey, isAuthServer?: boolean): typeof fetch => { 65 + const nonces = database.dpopNonces; 66 + const sign = createDPoPSignage(issuer, dpopKey); 67 + 68 + return async (input, init) => { 69 + const request: Request = init == null && input instanceof Request ? input : new Request(input, init); 70 + 71 + const authorizationHeader = request.headers.get('authorization'); 72 + const ath = authorizationHeader?.startsWith('DPoP ') 73 + ? await toSha256(authorizationHeader.slice(5)) 74 + : undefined; 75 + 76 + const { method, url } = request; 77 + const { origin } = new URL(url); 78 + 79 + let initNonce: string | undefined; 80 + try { 81 + initNonce = nonces.get(origin); 82 + } catch { 83 + // Ignore get errors, we will just not send a nonce 84 + } 85 + 86 + const initProof = await sign(method, url, initNonce, ath); 87 + request.headers.set('dpop', initProof); 88 + 89 + const initResponse = await fetch(request); 90 + 91 + const nextNonce = initResponse.headers.get('dpop-nonce'); 92 + if (!nextNonce || nextNonce === initNonce) { 93 + // No nonce was returned or it is the same as the one we sent. No need to 94 + // update the nonce store, or retry the request. 95 + return initResponse; 96 + } 97 + 98 + // Store the fresh nonce for future requests 99 + try { 100 + nonces.set(origin, nextNonce); 101 + } catch { 102 + // Ignore set errors 103 + } 104 + 105 + const shouldRetry = await isUseDpopNonceError(initResponse, isAuthServer); 106 + if (!shouldRetry) { 107 + // Not a "use_dpop_nonce" error, so there is no need to retry 108 + return initResponse; 109 + } 110 + 111 + // If the input stream was already consumed, we cannot retry the request. A 112 + // solution would be to clone() the request but that would bufferize the 113 + // entire stream in memory which can lead to memory starvation. Instead, we 114 + // will return the original response and let the calling code handle retries. 115 + 116 + if (input === request || init?.body instanceof ReadableStream) { 117 + return initResponse; 118 + } 119 + 120 + const nextProof = await sign(method, url, nextNonce, ath); 121 + const nextRequest = new Request(input, init); 122 + nextRequest.headers.set('dpop', nextProof); 123 + 124 + return await fetch(nextRequest); 125 + }; 126 + }; 127 + 128 + const isUseDpopNonceError = async (response: Response, isAuthServer?: boolean): Promise<boolean> => { 129 + // https://datatracker.ietf.org/doc/html/rfc6750#section-3 130 + // https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no 131 + if (isAuthServer === undefined || isAuthServer === false) { 132 + if (response.status === 401) { 133 + const wwwAuth = response.headers.get('www-authenticate'); 134 + if (wwwAuth?.startsWith('DPoP')) { 135 + return wwwAuth.includes('error="use_dpop_nonce"'); 136 + } 137 + } 138 + } 139 + 140 + // https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid 141 + if (isAuthServer === undefined || isAuthServer === true) { 142 + if (response.status === 400 && extractContentType(response.headers) === 'application/json') { 143 + try { 144 + const json = await response.clone().json(); 145 + return typeof json === 'object' && json?.['error'] === 'use_dpop_nonce'; 146 + } catch { 147 + // Response too big (to be "use_dpop_nonce" error) or invalid JSON 148 + return false; 149 + } 150 + } 151 + } 152 + 153 + return false; 154 + };
+27
packages/oauth/browser-client/lib/environment.ts
··· 1 + import { createOAuthDatabase, type OAuthDatabase } from './store/db.js'; 2 + 3 + export let CLIENT_ID: string; 4 + export let REDIRECT_URI: string; 5 + 6 + export let database: OAuthDatabase; 7 + 8 + export interface ConfigureOAuthOptions { 9 + /** 10 + * Client metadata, necessary to drive the whole request 11 + */ 12 + metadata: { 13 + client_id: string; 14 + redirect_uri: string; 15 + }; 16 + 17 + /** 18 + * Name that will be used as prefix for storage keys needed to persist authentication. 19 + * @default "atcute-oauth" 20 + */ 21 + storageName?: string; 22 + } 23 + 24 + export const configureOAuth = (options: ConfigureOAuthOptions) => { 25 + ({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI } = options.metadata); 26 + database = createOAuthDatabase({ name: options.storageName ?? 'atcute-oauth' }); 27 + };
+76
packages/oauth/browser-client/lib/errors.ts
··· 1 + import type { At } from '@atcute/client/lexicons'; 2 + 3 + export class LoginError extends Error { 4 + override name = 'LoginError'; 5 + } 6 + 7 + export class AuthorizationError extends Error { 8 + override name = 'AuthorizationError'; 9 + } 10 + 11 + export class ResolverError extends Error { 12 + override name = 'ResolverError'; 13 + } 14 + 15 + export class TokenRefreshError extends Error { 16 + override name = 'TokenRefreshError'; 17 + 18 + constructor( 19 + public readonly sub: At.DID, 20 + message: string, 21 + options?: ErrorOptions, 22 + ) { 23 + super(message, options); 24 + } 25 + } 26 + 27 + export class OAuthResponseError extends Error { 28 + override name = 'OAuthResponseError'; 29 + 30 + readonly error: string | undefined; 31 + readonly description: string | undefined; 32 + 33 + constructor( 34 + public readonly response: Response, 35 + public readonly data: any, 36 + ) { 37 + const error = ifString(ifObject(data)?.['error']); 38 + const errorDescription = ifString(ifObject(data)?.['error_description']); 39 + 40 + const messageError = error ? `"${error}"` : 'unknown'; 41 + const messageDesc = errorDescription ? `: ${errorDescription}` : ''; 42 + const message = `OAuth ${messageError} error${messageDesc}`; 43 + 44 + super(message); 45 + 46 + this.error = error; 47 + this.description = errorDescription; 48 + } 49 + 50 + get status() { 51 + return this.response.status; 52 + } 53 + 54 + get headers() { 55 + return this.response.headers; 56 + } 57 + } 58 + 59 + export class FetchResponseError extends Error { 60 + override name = 'FetchResponseError'; 61 + 62 + constructor( 63 + public readonly response: Response, 64 + public status: number, 65 + message: string, 66 + ) { 67 + super(message); 68 + } 69 + } 70 + 71 + const ifString = (v: unknown): string | undefined => { 72 + return typeof v === 'string' ? v : undefined; 73 + }; 74 + const ifObject = (v: unknown): Record<string, unknown> | undefined => { 75 + return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as any) : undefined; 76 + };
+17
packages/oauth/browser-client/lib/index.ts
··· 1 + export { configureOAuth, type ConfigureOAuthOptions } from './environment.js'; 2 + 3 + export * from './errors.js'; 4 + export * from './resolvers.js'; 5 + 6 + export * from './agents/exchange.js'; 7 + export * from './agents/server-agent.js'; 8 + export * from './agents/sessions.js'; 9 + export * from './agents/user-agent.js'; 10 + 11 + export * from './types/client.js'; 12 + export * from './types/dpop.js'; 13 + export * from './types/identity.js'; 14 + export * from './types/par.js'; 15 + export * from './types/server.js'; 16 + export * from './types/store.js'; 17 + export * from './types/token.js';
+222
packages/oauth/browser-client/lib/resolvers.ts
··· 1 + import type { At, ComAtprotoIdentityResolveHandle } from '@atcute/client/lexicons'; 2 + import { type DidDocument, getPdsEndpoint } from '@atcute/client/utils/did'; 3 + 4 + import { DEFAULT_APPVIEW_URL } from './constants.js'; 5 + import { ResolverError } from './errors.js'; 6 + import type { IdentityMetadata } from './types/identity.js'; 7 + import type { AuthorizationServerMetadata, ProtectedResourceMetadata } from './types/server.js'; 8 + import { extractContentType } from './utils/response.js'; 9 + import { isDid } from './utils/strings.js'; 10 + 11 + const DID_WEB_RE = /^([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))$/; 12 + 13 + /** 14 + * Resolves domain handles into DID identifiers, by requesting Bluesky's AppView 15 + * for identity resolution. 16 + * @param handle Domain handle to resolve 17 + * @returns DID identifier resolved from the domain handle 18 + */ 19 + export const resolveHandle = async (handle: string): Promise<At.DID> => { 20 + const url = DEFAULT_APPVIEW_URL + `/xrpc/com.atproto.identity.resolveHandle` + `?handle=${handle}`; 21 + 22 + const response = await fetch(url); 23 + if (response.status === 400) { 24 + throw new ResolverError(`domain handle not found`); 25 + } else if (!response.ok) { 26 + throw new ResolverError(`directory is unreachable`); 27 + } 28 + 29 + const json = (await response.json()) as ComAtprotoIdentityResolveHandle.Output; 30 + return json.did; 31 + }; 32 + 33 + /** 34 + * Get DID documents of did:plc (via plc.directory) and did:web identifiers 35 + * @param did DID identifier we're seeking DID doc from 36 + * @returns Retrieved DID document 37 + */ 38 + export const getDidDocument = async (did: At.DID): Promise<DidDocument> => { 39 + const colon_index = did.indexOf(':', 4); 40 + 41 + const type = did.slice(4, colon_index); 42 + const ident = did.slice(colon_index + 1); 43 + 44 + // 2. retrieve their DID documents 45 + let doc: DidDocument; 46 + 47 + if (type === 'plc') { 48 + const response = await fetch(`https://plc.directory/${did}`); 49 + 50 + if (response.status === 404) { 51 + throw new ResolverError(`did not found in directory`); 52 + } else if (!response.ok) { 53 + throw new ResolverError(`directory is unreachable`); 54 + } 55 + 56 + const json = await response.json(); 57 + 58 + doc = json as DidDocument; 59 + } else if (type === 'web') { 60 + if (!DID_WEB_RE.test(ident)) { 61 + throw new ResolverError(`invalid identifier`); 62 + } 63 + 64 + const response = await fetch(`https://${ident}/.well-known/did.json`); 65 + 66 + if (!response.ok) { 67 + throw new ResolverError(`did document is unreachable`); 68 + } 69 + 70 + const json = await response.json(); 71 + 72 + doc = json as DidDocument; 73 + } else { 74 + throw new ResolverError(`unsupported did method`); 75 + } 76 + 77 + return doc; 78 + }; 79 + 80 + /** 81 + * Get OAuth protected resource metadata from a host 82 + * @param host URL of the host 83 + * @returns Retrieved protected resource metadata 84 + */ 85 + export const getProtectedResourceMetadata = async (host: string): Promise<ProtectedResourceMetadata> => { 86 + const url = new URL(`/.well-known/oauth-protected-resource`, host); 87 + const response = await fetch(url, { 88 + redirect: 'manual', 89 + headers: { 90 + accept: 'application/json', 91 + }, 92 + }); 93 + 94 + if (response.status !== 200 || extractContentType(response.headers) !== 'application/json') { 95 + throw new ResolverError(`unexpected response`); 96 + } 97 + 98 + const metadata = (await response.json()) as ProtectedResourceMetadata; 99 + if (metadata.resource !== url.origin) { 100 + throw new ResolverError(`unexpected issuer`); 101 + } 102 + 103 + return metadata; 104 + }; 105 + 106 + /** 107 + * Get OAuth authorization server metadata from a host 108 + * @param host URL of the host 109 + * @returns Retrieved authorization server metadata 110 + */ 111 + export const getAuthorizationServerMetadata = async (host: string): Promise<AuthorizationServerMetadata> => { 112 + const url = new URL(`/.well-known/oauth-authorization-server`, host); 113 + const response = await fetch(url, { 114 + redirect: 'manual', 115 + headers: { 116 + accept: 'application/json', 117 + }, 118 + }); 119 + 120 + if (response.status !== 200 || extractContentType(response.headers) !== 'application/json') { 121 + throw new ResolverError(`unexpected response`); 122 + } 123 + 124 + const metadata = (await response.json()) as AuthorizationServerMetadata; 125 + if (metadata.issuer !== url.origin) { 126 + throw new ResolverError(`unexpected issuer`); 127 + } 128 + if (!metadata.client_id_metadata_document_supported) { 129 + throw new ResolverError(`authorization server does not support 'client_id_metadata_document'`); 130 + } 131 + if (!metadata.pushed_authorization_request_endpoint) { 132 + throw new ResolverError(`authorization server does not support 'pushed_authorization request'`); 133 + } 134 + if (metadata.response_types_supported) { 135 + if (!metadata.response_types_supported.includes('code')) { 136 + throw new ResolverError(`authorization server does not support 'code' response type`); 137 + } 138 + } 139 + 140 + return metadata; 141 + }; 142 + 143 + /** 144 + * Resolve handle domains or DID identifiers to get their PDS and its authorization server metadata 145 + * @param ident Handle domain or DID identifier to resolve 146 + * @returns Resolved PDS and authorization server metadata 147 + */ 148 + export const resolveFromIdentity = async ( 149 + ident: string, 150 + ): Promise<{ identity: IdentityMetadata; metadata: AuthorizationServerMetadata }> => { 151 + let did: At.DID; 152 + if (isDid(ident)) { 153 + did = ident; 154 + } else { 155 + const resolved = await resolveHandle(ident); 156 + did = resolved; 157 + } 158 + 159 + const doc = await getDidDocument(did); 160 + const pds = getPdsEndpoint(doc); 161 + 162 + if (!pds) { 163 + throw new ResolverError(`missing pds endpoint`); 164 + } 165 + 166 + return { 167 + identity: { 168 + id: did, 169 + raw: ident, 170 + pds: new URL(pds), 171 + }, 172 + metadata: await getMetadataFromResourceServer(pds), 173 + }; 174 + }; 175 + 176 + /** 177 + * Request authorization server metadata from a PDS 178 + * @param host URL of the host 179 + * @returns Resolved authorization server metadata 180 + */ 181 + export const resolveFromService = async ( 182 + host: string, 183 + ): Promise<{ metadata: AuthorizationServerMetadata }> => { 184 + try { 185 + const metadata = await getMetadataFromResourceServer(host); 186 + return { metadata }; 187 + } catch (err) { 188 + if (err instanceof ResolverError) { 189 + try { 190 + const metadata = await getAuthorizationServerMetadata(host); 191 + return { metadata }; 192 + } catch {} 193 + } 194 + 195 + throw err; 196 + } 197 + }; 198 + 199 + /** 200 + * Request authorization server metadata from its protected resource metadata 201 + * @param input URL of the host whose authorization server is delegated 202 + * @returns Resolved authorization server metadata 203 + */ 204 + export const getMetadataFromResourceServer = async (input: string) => { 205 + const rs_metadata = await getProtectedResourceMetadata(input); 206 + 207 + if (rs_metadata.authorization_servers?.length !== 1) { 208 + throw new ResolverError(`expected exactly one authorization server in the listing`); 209 + } 210 + 211 + const issuer = rs_metadata.authorization_servers[0]; 212 + 213 + const as_metadata = await getAuthorizationServerMetadata(issuer); 214 + 215 + if (as_metadata.protected_resources) { 216 + if (!as_metadata.protected_resources.includes(rs_metadata.resource)) { 217 + throw new ResolverError(`server is not in authorization server's jurisdiction`); 218 + } 219 + } 220 + 221 + return as_metadata; 222 + };
+176
packages/oauth/browser-client/lib/store/db.ts
··· 1 + import type { At } from '@atcute/client/lexicons'; 2 + 3 + import type { DPoPKey } from '../types/dpop.js'; 4 + import type { AuthorizationServerMetadata } from '../types/server.js'; 5 + import type { SimpleStore } from '../types/store.js'; 6 + import type { Session } from '../types/token.js'; 7 + import { locks } from '../utils/runtime.js'; 8 + 9 + export interface OAuthDatabaseOptions { 10 + name: string; 11 + } 12 + 13 + interface SchemaItem<T> { 14 + value: T; 15 + expiresAt: number | null; 16 + } 17 + 18 + interface Schema { 19 + sessions: { 20 + key: At.DID; 21 + value: Session; 22 + indexes: { 23 + expiresAt: number; 24 + }; 25 + }; 26 + states: { 27 + key: string; 28 + value: { 29 + dpopKey: DPoPKey; 30 + metadata: AuthorizationServerMetadata; 31 + verifier?: string; 32 + }; 33 + }; 34 + 35 + dpopNonces: { 36 + key: string; 37 + value: string; 38 + }; 39 + } 40 + 41 + const parse = (raw: string | null) => { 42 + if (raw != null) { 43 + const parsed = JSON.parse(raw); 44 + if (parsed != null) { 45 + return parsed; 46 + } 47 + } 48 + 49 + return {}; 50 + }; 51 + 52 + export type OAuthDatabase = ReturnType<typeof createOAuthDatabase>; 53 + 54 + export const createOAuthDatabase = ({ name }: OAuthDatabaseOptions) => { 55 + const controller = new AbortController(); 56 + const signal = controller.signal; 57 + 58 + const createStore = <N extends keyof Schema>( 59 + subname: N, 60 + expiresAt: (item: Schema[N]['value']) => null | number, 61 + ): SimpleStore<Schema[N]['key'], Schema[N]['value']> => { 62 + let store: any; 63 + 64 + const storageKey = `${name}:${subname}`; 65 + 66 + const persist = () => store && localStorage.setItem(storageKey, JSON.stringify(store)); 67 + const read = () => { 68 + if (signal.aborted) { 69 + throw new Error(`store closed`); 70 + } 71 + 72 + return (store ??= parse(localStorage.getItem(storageKey))); 73 + }; 74 + 75 + { 76 + const listener = (ev: StorageEvent) => { 77 + if (ev.key === storageKey) { 78 + store = undefined; 79 + } 80 + }; 81 + 82 + window.addEventListener('storage', listener, { signal }); 83 + } 84 + 85 + locks.request(`${storageKey}:cleanup`, { ifAvailable: true }, async (lock) => { 86 + if (!lock || signal.aborted) { 87 + return; 88 + } 89 + 90 + await new Promise((resolve) => setTimeout(resolve, 10_000)); 91 + if (signal.aborted) { 92 + return; 93 + } 94 + 95 + let now = Date.now(); 96 + let changed = false; 97 + 98 + read(); 99 + 100 + for (const key in store) { 101 + const item = store[key]; 102 + const expiresAt = item.expiresAt; 103 + 104 + if (expiresAt !== null && now > expiresAt) { 105 + changed = true; 106 + delete store[key]; 107 + } 108 + } 109 + 110 + if (changed) { 111 + persist(); 112 + } 113 + }); 114 + 115 + return { 116 + get(key) { 117 + read(); 118 + 119 + const item: SchemaItem<Schema[N]['value']> = store[key]; 120 + if (!item) { 121 + return; 122 + } 123 + 124 + const expiresAt = item.expiresAt; 125 + if (expiresAt !== null && Date.now() > expiresAt) { 126 + delete store[key]; 127 + persist(); 128 + 129 + return; 130 + } 131 + 132 + return item.value; 133 + }, 134 + set(key, value) { 135 + read(); 136 + 137 + const item: SchemaItem<Schema[N]['value']> = { 138 + expiresAt: expiresAt(value), 139 + value: value, 140 + }; 141 + 142 + store[key] = item; 143 + persist(); 144 + }, 145 + delete(key) { 146 + read(); 147 + 148 + if (store[key] !== undefined) { 149 + delete store[key]; 150 + persist(); 151 + } 152 + }, 153 + keys() { 154 + read(); 155 + 156 + return Object.keys(store); 157 + }, 158 + }; 159 + }; 160 + 161 + return { 162 + dispose: () => { 163 + controller.abort(); 164 + }, 165 + 166 + sessions: createStore('sessions', ({ token }) => { 167 + if (token.refresh) { 168 + return null; 169 + } 170 + 171 + return token.expires_at ?? null; 172 + }), 173 + states: createStore('states', (_item) => Date.now() + 10 * 60 * 1_000), 174 + dpopNonces: createStore('dpopNonces', (_item) => Date.now() + 10 * 60 * 1_000), 175 + }; 176 + };
+82
packages/oauth/browser-client/lib/types/client.ts
··· 1 + export interface ClientMetadata { 2 + redirect_uris: string[]; 3 + response_types: ( 4 + | 'code' 5 + | 'token' 6 + | 'none' 7 + | 'code id_token token' 8 + | 'code id_token' 9 + | 'code token' 10 + | 'id_token token' 11 + | 'id_token' 12 + )[]; 13 + grant_types: ( 14 + | 'authorization_code' 15 + | 'implicit' 16 + | 'refresh_token' 17 + | 'password' 18 + | 'client_credentials' 19 + | 'urn:ietf:params:oauth:grant-type:jwt-bearer' 20 + | 'urn:ietf:params:oauth:grant-type:saml2-bearer' 21 + )[]; 22 + scope?: string; 23 + token_endpoint_auth_method?: 24 + | 'none' 25 + | 'client_secret_basic' 26 + | 'client_secret_jwt' 27 + | 'client_secret_post' 28 + | 'private_key_jwt' 29 + | 'self_signed_tls_client_auth' 30 + | 'tls_client_auth'; 31 + token_endpoint_auth_signing_alg?: string; 32 + introspection_endpoint_auth_method?: 33 + | 'none' 34 + | 'client_secret_basic' 35 + | 'client_secret_jwt' 36 + | 'client_secret_post' 37 + | 'private_key_jwt' 38 + | 'self_signed_tls_client_auth' 39 + | 'tls_client_auth'; 40 + introspection_endpoint_auth_signing_alg?: string; 41 + revocation_endpoint_auth_method?: 42 + | 'none' 43 + | 'client_secret_basic' 44 + | 'client_secret_jwt' 45 + | 'client_secret_post' 46 + | 'private_key_jwt' 47 + | 'self_signed_tls_client_auth' 48 + | 'tls_client_auth'; 49 + revocation_endpoint_auth_signing_alg?: string; 50 + pushed_authorization_request_endpoint_auth_method?: 51 + | 'none' 52 + | 'client_secret_basic' 53 + | 'client_secret_jwt' 54 + | 'client_secret_post' 55 + | 'private_key_jwt' 56 + | 'self_signed_tls_client_auth' 57 + | 'tls_client_auth'; 58 + pushed_authorization_request_endpoint_auth_signing_alg?: string; 59 + userinfo_signed_response_alg?: string; 60 + userinfo_encrypted_response_alg?: string; 61 + jwks_uri?: string; 62 + jwks?: unknown; 63 + application_type?: 'web' | 'native'; 64 + subject_type?: 'public' | 'pairwise'; 65 + request_object_signing_alg?: string; 66 + id_token_signed_response_alg?: string; 67 + authorization_signed_response_alg?: string; 68 + authorization_encrypted_response_enc?: 'A128CBC-HS256'; 69 + authorization_encrypted_response_alg?: string; 70 + client_id?: string; 71 + client_name?: string; 72 + client_uri?: string; 73 + policy_uri?: string; 74 + tos_uri?: string; 75 + logo_uri?: string; 76 + default_max_age?: number; 77 + require_auth_time?: boolean; 78 + contacts?: string[]; 79 + tls_client_certificate_bound_access_tokens?: boolean; 80 + dpop_bound_access_tokens?: boolean; 81 + authorization_details_types?: string[]; 82 + }
+7
packages/oauth/browser-client/lib/types/dpop.ts
··· 1 + export interface DPoPKey { 2 + typ: 'ES256'; 3 + /** private key in base64url-encoded pkcs #8 */ 4 + key: string; 5 + /** base64url-encoded jwt token */ 6 + jwt: string; 7 + }
+7
packages/oauth/browser-client/lib/types/identity.ts
··· 1 + import type { At } from '@atcute/client/lexicons'; 2 + 3 + export interface IdentityMetadata { 4 + id: At.DID; 5 + raw: string; 6 + pds: URL; 7 + }
+4
packages/oauth/browser-client/lib/types/par.ts
··· 1 + export interface OAuthParResponse { 2 + request_uri: string; 3 + expires_in: number; 4 + }
+67
packages/oauth/browser-client/lib/types/server.ts
··· 1 + export interface ProtectedResourceMetadata { 2 + resource: string; 3 + jwks_uri?: string; 4 + authorization_servers?: string[]; 5 + scopes_supported?: string[]; 6 + bearer_methods_supported?: ('header' | 'body' | 'query')[]; 7 + resource_signing_alg_values_supported?: string[]; 8 + resource_documentation?: string; 9 + resource_policy_uri?: string; 10 + resource_tos_uri?: string; 11 + } 12 + 13 + export interface AuthorizationServerMetadata { 14 + issuer: string; 15 + authorization_endpoint: string; 16 + token_endpoint: string; 17 + jwks_uri?: string; 18 + scopes_supported?: string[]; 19 + claims_supported?: string[]; 20 + claims_locales_supported?: string[]; 21 + claims_parameter_supported?: boolean; 22 + request_parameter_supported?: boolean; 23 + request_uri_parameter_supported?: boolean; 24 + require_request_uri_registration?: boolean; 25 + subject_types_supported?: string[]; 26 + response_types_supported?: string[]; 27 + response_modes_supported?: string[]; 28 + grant_types_supported?: string[]; 29 + code_challenge_methods_supported?: string[]; 30 + ui_locales_supported?: string[]; 31 + id_token_signing_alg_values_supported?: string[]; 32 + display_values_supported?: string[]; 33 + request_object_signing_alg_values_supported?: string[]; 34 + authorization_response_iss_parameter_supported?: boolean; 35 + authorization_details_types_supported?: string[]; 36 + request_object_encryption_alg_values_supported?: string[]; 37 + request_object_encryption_enc_values_supported?: string[]; 38 + token_endpoint_auth_methods_supported?: string[]; 39 + token_endpoint_auth_signing_alg_values_supported?: string[]; 40 + revocation_endpoint?: string; 41 + revocation_endpoint_auth_methods_supported?: string[]; 42 + revocation_endpoint_auth_signing_alg_values_supported?: string[]; 43 + introspection_endpoint?: string; 44 + introspection_endpoint_auth_methods_supported?: string[]; 45 + introspection_endpoint_auth_signing_alg_values_supported?: string[]; 46 + pushed_authorization_request_endpoint?: string; 47 + pushed_authorization_request_endpoint_auth_methods_supported?: string[]; 48 + pushed_authorization_request_endpoint_auth_signing_alg_values_supported?: string[]; 49 + require_pushed_authorization_requests?: boolean; 50 + userinfo_endpoint?: string; 51 + end_session_endpoint?: string; 52 + registration_endpoint?: string; 53 + dpop_signing_alg_values_supported?: string[]; 54 + protected_resources?: string[]; 55 + client_id_metadata_document_supported?: boolean; 56 + } 57 + 58 + export interface PersistedAuthorizationServerMetadata 59 + extends Pick< 60 + AuthorizationServerMetadata, 61 + | 'issuer' 62 + | 'authorization_endpoint' 63 + | 'introspection_endpoint' 64 + | 'pushed_authorization_request_endpoint' 65 + | 'revocation_endpoint' 66 + | 'token_endpoint' 67 + > {}
+6
packages/oauth/browser-client/lib/types/store.ts
··· 1 + export interface SimpleStore<K extends string | number, V extends {} | null> { 2 + get: (key: K) => undefined | V; 3 + set: (key: K, value: V) => void; 4 + delete: (key: K) => void; 5 + keys: () => K[]; 6 + }
+46
packages/oauth/browser-client/lib/types/token.ts
··· 1 + import type { At } from '@atcute/client/lexicons'; 2 + 3 + import type { DPoPKey } from './dpop.js'; 4 + import type { PersistedAuthorizationServerMetadata } from './server.js'; 5 + 6 + export interface OAuthTokenResponse { 7 + access_token: string; 8 + // Can be DPoP or Bearer, normalize casing. 9 + token_type: string; 10 + issuer?: string; 11 + sub?: string; 12 + scope?: string; 13 + id_token?: `${string}.${string}.${string}`; 14 + refresh_token?: string; 15 + expires_in?: number; 16 + authorization_details?: 17 + | { 18 + type: string; 19 + locations?: string[]; 20 + actions?: string[]; 21 + datatypes?: string[]; 22 + identifier?: string; 23 + privileges?: string[]; 24 + }[] 25 + | undefined; 26 + } 27 + 28 + export interface TokenInfo { 29 + scope: string; 30 + type: string; 31 + expires_at?: number; 32 + refresh?: string; 33 + access: string; 34 + } 35 + 36 + export interface ExchangeInfo { 37 + sub: At.DID; 38 + aud: string; 39 + server: PersistedAuthorizationServerMetadata; 40 + } 41 + 42 + export interface Session { 43 + dpopKey: DPoPKey; 44 + info: ExchangeInfo; 45 + token: TokenInfo; 46 + }
+14
packages/oauth/browser-client/lib/utils/misc.ts
··· 1 + type UnwrapArray<T> = T extends (infer V)[] ? V : never; 2 + 3 + export const pick = <T, K extends (keyof T)[]>(obj: T, keys: K): Pick<T, UnwrapArray<K>> => { 4 + const cloned = {}; 5 + 6 + for (let idx = 0, len = keys.length; idx < len; idx++) { 7 + const key = keys[idx]; 8 + 9 + // @ts-expect-error 10 + cloned[key] = obj[key]; 11 + } 12 + 13 + return cloned as Pick<T, UnwrapArray<K>>; 14 + };
+3
packages/oauth/browser-client/lib/utils/response.ts
··· 1 + export const extractContentType = (headers: Headers): string | undefined => { 2 + return headers.get('content-type')?.split(';')[0]; 3 + };
+55
packages/oauth/browser-client/lib/utils/runtime.ts
··· 1 + export const encoder = new TextEncoder(); 2 + 3 + export const locks = navigator.locks; 4 + 5 + export const toBase64Url = (input: Uint8Array): string => { 6 + const CHUNK_SIZE = 0x8000; 7 + const arr = []; 8 + 9 + for (let i = 0; i < input.byteLength; i += CHUNK_SIZE) { 10 + // @ts-expect-error 11 + arr.push(String.fromCharCode.apply(null, input.subarray(i, i + CHUNK_SIZE))); 12 + } 13 + 14 + return btoa(arr.join('')).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); 15 + }; 16 + 17 + export const fromBase64Url = (input: string): Uint8Array => { 18 + try { 19 + const binary = atob(input.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, '')); 20 + const bytes = new Uint8Array(binary.length); 21 + 22 + for (let i = 0; i < binary.length; i++) { 23 + bytes[i] = binary.charCodeAt(i); 24 + } 25 + 26 + return bytes; 27 + } catch (err) { 28 + throw new TypeError(`invalid base64url`, { cause: err }); 29 + } 30 + }; 31 + 32 + export const toSha256 = async (input: string): Promise<string> => { 33 + const bytes = encoder.encode(input); 34 + const digest = await crypto.subtle.digest('SHA-256', bytes); 35 + 36 + return toBase64Url(new Uint8Array(digest)); 37 + }; 38 + 39 + export const randomBytes = (length: number): string => { 40 + return toBase64Url(crypto.getRandomValues(new Uint8Array(length))); 41 + }; 42 + 43 + export const generateState = (): string => { 44 + return randomBytes(16); 45 + }; 46 + 47 + export const generatePKCE = async (): Promise<{ verifier: string; challenge: string; method: string }> => { 48 + const verifier = randomBytes(32); 49 + 50 + return { 51 + verifier: verifier, 52 + challenge: await toSha256(verifier), 53 + method: 'S256', 54 + }; 55 + };
+5
packages/oauth/browser-client/lib/utils/strings.ts
··· 1 + import type { At } from '@atcute/client/lexicons'; 2 + 3 + export const isDid = (value: string): value is At.DID => { 4 + return value.startsWith('did:'); 5 + };
+29
packages/oauth/browser-client/package.json
··· 1 + { 2 + "type": "module", 3 + "name": "@atcute/oauth-browser-client", 4 + "version": "1.0.0", 5 + "description": "minimal OAuth browser client implementation for AT Protocol", 6 + "license": "MIT", 7 + "repository": { 8 + "url": "https://codeberg.org/mary-ext/atcute" 9 + }, 10 + "files": [ 11 + "dist/" 12 + ], 13 + "exports": { 14 + ".": "./dist/index.js" 15 + }, 16 + "sideEffects": false, 17 + "scripts": { 18 + "build": "tsc --project tsconfig.build.json", 19 + "test": "bun test --coverage", 20 + "prepublish": "rm -rf dist; pnpm run build" 21 + }, 22 + "dependencies": { 23 + "@atcute/client": "workspace:^", 24 + "nanoid": "^5.0.7" 25 + }, 26 + "devDependencies": { 27 + "@types/bun": "^1.1.10" 28 + } 29 + }
+4
packages/oauth/browser-client/tsconfig.build.json
··· 1 + { 2 + "extends": "./tsconfig.json", 3 + "exclude": ["**/*.test.ts"] 4 + }
+23
packages/oauth/browser-client/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "types": ["bun"], 4 + "outDir": "dist/", 5 + "esModuleInterop": true, 6 + "skipLibCheck": true, 7 + "target": "ESNext", 8 + "allowJs": true, 9 + "resolveJsonModule": true, 10 + "moduleDetection": "force", 11 + "isolatedModules": true, 12 + "verbatimModuleSyntax": true, 13 + "strict": true, 14 + "noImplicitOverride": true, 15 + "noUnusedLocals": true, 16 + "noUnusedParameters": true, 17 + "noFallthroughCasesInSwitch": true, 18 + "module": "NodeNext", 19 + "sourceMap": true, 20 + "declaration": true, 21 + }, 22 + "include": ["lib"], 23 + }
+20
pnpm-lock.yaml
··· 155 155 specifier: ^5.1.0 156 156 version: 5.1.0 157 157 158 + packages/oauth/browser-client: 159 + dependencies: 160 + '@atcute/client': 161 + specifier: workspace:^ 162 + version: link:../../core/client 163 + nanoid: 164 + specifier: ^5.0.7 165 + version: 5.0.7 166 + devDependencies: 167 + '@types/bun': 168 + specifier: ^1.1.10 169 + version: 1.1.10 170 + 158 171 packages/utilities/base32: 159 172 devDependencies: 160 173 '@types/bun': ··· 1783 1796 nanoid@3.3.7: 1784 1797 resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} 1785 1798 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 1799 + hasBin: true 1800 + 1801 + nanoid@5.0.7: 1802 + resolution: {integrity: sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==} 1803 + engines: {node: ^18 || >=20} 1786 1804 hasBin: true 1787 1805 1788 1806 napi-build-utils@1.0.2: ··· 4665 4683 multiformats@9.9.0: {} 4666 4684 4667 4685 nanoid@3.3.7: {} 4686 + 4687 + nanoid@5.0.7: {} 4668 4688 4669 4689 napi-build-utils@1.0.2: {} 4670 4690
+1
pnpm-workspace.yaml
··· 1 1 packages: 2 2 - packages/core/* 3 3 - packages/definitions/* 4 + - packages/oauth/* 4 5 - packages/utilities/* 5 6 - packages/bluesky/* 6 7 - packages/internal/*