@atcute/oauth-browser-client 1.0.4 → 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.
- package/lib/agents/exchange.ts +115 -0
- package/lib/agents/server-agent.ts +149 -0
- package/lib/agents/sessions.ts +142 -0
- package/lib/agents/user-agent.ts +99 -0
- package/lib/constants.ts +1 -0
- package/lib/dpop.ts +154 -0
- package/lib/environment.ts +27 -0
- package/lib/errors.ts +76 -0
- package/lib/index.ts +17 -0
- package/lib/resolvers.ts +222 -0
- package/lib/store/db.ts +184 -0
- package/lib/types/client.ts +82 -0
- package/lib/types/dpop.ts +7 -0
- package/lib/types/identity.ts +7 -0
- package/lib/types/par.ts +4 -0
- package/lib/types/server.ts +67 -0
- package/lib/types/store.ts +6 -0
- package/lib/types/token.ts +46 -0
- package/lib/utils/misc.ts +14 -0
- package/lib/utils/response.ts +3 -0
- package/lib/utils/runtime.ts +55 -0
- package/lib/utils/strings.ts +5 -0
- package/package.json +8 -4
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
if (!res.sub) {
|
|
102
|
+
throw new TypeError(`missing sub field in token response`);
|
|
103
|
+
}
|
|
104
|
+
if (!res.scope) {
|
|
105
|
+
throw new TypeError(`missing scope field in token response`);
|
|
106
|
+
}
|
|
107
|
+
if (res.token_type !== 'DPoP') {
|
|
108
|
+
throw new TypeError(`token response returned a non-dpop token`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
scope: res.scope,
|
|
113
|
+
refresh: res.refresh_token,
|
|
114
|
+
access: res.access_token,
|
|
115
|
+
type: res.token_type,
|
|
116
|
+
expires_at: typeof res.expires_in === 'number' ? Date.now() + res.expires_in * 1_000 : undefined,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async #processExchangeResponse(res: OAuthTokenResponse): Promise<{ info: ExchangeInfo; token: TokenInfo }> {
|
|
121
|
+
const sub = res.sub;
|
|
122
|
+
if (!sub) {
|
|
123
|
+
throw new TypeError(`missing sub field in token response`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const token = this.#processTokenResponse(res);
|
|
127
|
+
const resolved = await resolveFromIdentity(sub);
|
|
128
|
+
|
|
129
|
+
if (resolved.metadata.issuer !== this.#metadata.issuer) {
|
|
130
|
+
throw new TypeError(`issuer mismatch; got ${resolved.metadata.issuer}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
token: token,
|
|
135
|
+
info: {
|
|
136
|
+
sub: sub as At.DID,
|
|
137
|
+
aud: resolved.identity.pds.href,
|
|
138
|
+
server: pick(resolved.metadata, [
|
|
139
|
+
'issuer',
|
|
140
|
+
'authorization_endpoint',
|
|
141
|
+
'introspection_endpoint',
|
|
142
|
+
'pushed_authorization_request_endpoint',
|
|
143
|
+
'revocation_endpoint',
|
|
144
|
+
'token_endpoint',
|
|
145
|
+
]),
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -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
|
+
};
|
package/lib/constants.ts
ADDED
|
@@ -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
|
+
};
|