@atcute/oauth-browser-client 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,142 @@
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
+ import { locks } from '../utils/runtime.js';
7
+
8
+ import { OAuthServerAgent } from './server-agent.js';
9
+
10
+ export interface SessionGetOptions {
11
+ signal?: AbortSignal;
12
+ noCache?: boolean;
13
+ allowStale?: boolean;
14
+ }
15
+
16
+ type PendingItem<V> = Promise<{ value: V; isFresh: boolean }>;
17
+ const pending = new Map<At.DID, PendingItem<Session>>();
18
+
19
+ export const getSession = async (sub: At.DID, options?: SessionGetOptions): Promise<Session> => {
20
+ options?.signal?.throwIfAborted();
21
+
22
+ let allowStored = isTokenUsable;
23
+ if (options?.noCache) {
24
+ allowStored = returnFalse;
25
+ } else if (options?.allowStale) {
26
+ allowStored = returnTrue;
27
+ }
28
+
29
+ // As long as concurrent requests are made for the same key, only one
30
+ // request will be made to the cache & getter function at a time. This works
31
+ // because there is no async operation between the while() loop and the
32
+ // pending.set() call. Because of the "single threaded" nature of
33
+ // JavaScript, the pending item will be set before the next iteration of the
34
+ // while loop.
35
+ let previousExecutionFlow: PendingItem<Session> | undefined;
36
+ while ((previousExecutionFlow = pending.get(sub))) {
37
+ try {
38
+ const { isFresh, value } = await previousExecutionFlow;
39
+
40
+ if (isFresh || allowStored(value)) {
41
+ return value;
42
+ }
43
+ } catch {
44
+ // Ignore errors from previous execution flows (they will have been
45
+ // propagated by that flow).
46
+ }
47
+
48
+ options?.signal?.throwIfAborted();
49
+ }
50
+
51
+ const run = async (): PendingItem<Session> => {
52
+ const storedSession = database.sessions.get(sub);
53
+
54
+ if (storedSession && allowStored(storedSession)) {
55
+ // Use the stored value as return value for the current execution
56
+ // flow. Notify other concurrent execution flows (that should be
57
+ // "stuck" in the loop before until this promise resolves) that we got
58
+ // a value, but that it came from the store (isFresh = false).
59
+ return { isFresh: false, value: storedSession };
60
+ }
61
+
62
+ const newSession = await refreshToken(sub, storedSession);
63
+
64
+ await storeSession(sub, newSession);
65
+ return { isFresh: true, value: newSession };
66
+ };
67
+
68
+ let promise: PendingItem<Session>;
69
+
70
+ if (locks) {
71
+ promise = locks.request(`atcute-oauth:${sub}`, run);
72
+ } else {
73
+ promise = run();
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
+ };
@@ -0,0 +1,99 @@
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 sub(): 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
+ };
@@ -0,0 +1 @@
1
+ export const DEFAULT_APPVIEW_URL = 'https://public.api.bsky.app';
package/lib/dpop.ts ADDED
@@ -0,0 +1,154 @@
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
+ };
@@ -0,0 +1,27 @@
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
+ };
package/lib/errors.ts ADDED
@@ -0,0 +1,76 @@
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
+ };
package/lib/index.ts ADDED
@@ -0,0 +1,17 @@
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';