@atcute/oauth-browser-client 1.0.4 → 1.0.6
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/README.md +17 -4
- package/dist/resolvers.js +4 -1
- package/dist/resolvers.js.map +1 -1
- package/dist/utils/strings.d.ts +1 -0
- package/dist/utils/strings.js +17 -0
- package/dist/utils/strings.js.map +1 -1
- 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 +225 -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 +24 -0
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -7,9 +7,9 @@ minimal OAuth browser client implementation for AT Protocol.
|
|
|
7
7
|
- **does not use IndexedDB**: makes the library work under Safari's lockdown mode, and has less
|
|
8
8
|
maintenance headache overall, but it also means this is "less secure" (it won't be able to use
|
|
9
9
|
non-exportable keys as recommended by [DPoP specification][idb-dpop-spec].)
|
|
10
|
-
- **not well-tested**: it has been used in personal projects for quite some time, but
|
|
11
|
-
any use outside of that. using the [reference implementation][oauth-atproto-lib] is
|
|
12
|
-
you are unsure about the implications presented here.
|
|
10
|
+
- **not well-tested**: it has been used in personal projects and by friends for quite some time, but
|
|
11
|
+
hasn't seen any use outside of that. using the [reference implementation][oauth-atproto-lib] is
|
|
12
|
+
recommended if you are unsure about the implications presented here.
|
|
13
13
|
|
|
14
14
|
[idb-dpop-spec]: https://datatracker.ietf.org/doc/html/rfc9449#section-2-4
|
|
15
15
|
[oauth-atproto-lib]: https://npm.im/@atproto/oauth-client-browser
|
|
@@ -118,7 +118,20 @@ const session = await finalizeAuthorization(params);
|
|
|
118
118
|
const agent = new OAuthUserAgent(session);
|
|
119
119
|
|
|
120
120
|
// pass it onto the XRPC so you can make RPC calls with the PDS.
|
|
121
|
-
|
|
121
|
+
{
|
|
122
|
+
const rpc = new XRPC({ handler: agent });
|
|
123
|
+
|
|
124
|
+
const { data } = await rpc.get('com.atproto.identity.resolveHandle', {
|
|
125
|
+
params: {
|
|
126
|
+
handle: 'mary.my.id',
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// or, use it directly!
|
|
132
|
+
{
|
|
133
|
+
const response = await agent.handle('/xrpc/com.atproto.identity.resolveHandle?handle=mary.my.id');
|
|
134
|
+
}
|
|
122
135
|
```
|
|
123
136
|
|
|
124
137
|
the `session` object returned by `finalizeAuthorization` should not be stored anywhere else, as it
|
package/dist/resolvers.js
CHANGED
|
@@ -2,7 +2,7 @@ import { getPdsEndpoint } from '@atcute/client/utils/did';
|
|
|
2
2
|
import { DEFAULT_APPVIEW_URL } from './constants.js';
|
|
3
3
|
import { ResolverError } from './errors.js';
|
|
4
4
|
import { extractContentType } from './utils/response.js';
|
|
5
|
-
import { isDid } from './utils/strings.js';
|
|
5
|
+
import { isDid, isValidUrl } from './utils/strings.js';
|
|
6
6
|
const DID_WEB_RE = /^([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))$/;
|
|
7
7
|
/**
|
|
8
8
|
* Resolves domain handles into DID identifiers, by requesting Bluesky's AppView
|
|
@@ -102,6 +102,9 @@ export const getAuthorizationServerMetadata = async (host) => {
|
|
|
102
102
|
if (metadata.issuer !== url.origin) {
|
|
103
103
|
throw new ResolverError(`unexpected issuer`);
|
|
104
104
|
}
|
|
105
|
+
if (!isValidUrl(metadata.authorization_endpoint)) {
|
|
106
|
+
throw new ResolverError(`authorization server provided incorrect authorization endpoint`);
|
|
107
|
+
}
|
|
105
108
|
if (!metadata.client_id_metadata_document_supported) {
|
|
106
109
|
throw new ResolverError(`authorization server does not support 'client_id_metadata_document'`);
|
|
107
110
|
}
|
package/dist/resolvers.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolvers.js","sourceRoot":"","sources":["../lib/resolvers.ts"],"names":[],"mappings":"AACA,OAAO,EAAoB,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAE5E,OAAO,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAG5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"resolvers.js","sourceRoot":"","sources":["../lib/resolvers.ts"],"names":[],"mappings":"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"}
|
package/dist/utils/strings.d.ts
CHANGED
package/dist/utils/strings.js
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
|
+
const isUrlParseSupported = 'parse' in URL;
|
|
1
2
|
export const isDid = (value) => {
|
|
2
3
|
return value.startsWith('did:');
|
|
3
4
|
};
|
|
5
|
+
export const isValidUrl = (urlString) => {
|
|
6
|
+
let url = null;
|
|
7
|
+
if (isUrlParseSupported) {
|
|
8
|
+
url = URL.parse(urlString);
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
try {
|
|
12
|
+
url = new URL(urlString);
|
|
13
|
+
}
|
|
14
|
+
catch { }
|
|
15
|
+
}
|
|
16
|
+
if (url !== null) {
|
|
17
|
+
return url.protocol === 'https:' || url.protocol === 'http:';
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
};
|
|
4
21
|
//# sourceMappingURL=strings.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"strings.js","sourceRoot":"","sources":["../../lib/utils/strings.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,KAAK,GAAG,CAAC,KAAa,EAAmB,EAAE;IACvD,OAAO,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;AACjC,CAAC,CAAC"}
|
|
1
|
+
{"version":3,"file":"strings.js","sourceRoot":"","sources":["../../lib/utils/strings.ts"],"names":[],"mappings":"AAEA,MAAM,mBAAmB,GAAG,OAAO,IAAI,GAAG,CAAC;AAE3C,MAAM,CAAC,MAAM,KAAK,GAAG,CAAC,KAAa,EAAmB,EAAE;IACvD,OAAO,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;AACjC,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,SAAiB,EAAW,EAAE;IACxD,IAAI,GAAG,GAAe,IAAI,CAAC;IAC3B,IAAI,mBAAmB,EAAE,CAAC;QACzB,GAAG,GAAG,GAAG,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;IAC5B,CAAC;SAAM,CAAC;QACP,IAAI,CAAC;YACJ,GAAG,GAAG,IAAI,GAAG,CAAC,SAAS,CAAC,CAAC;QAC1B,CAAC;QAAC,MAAM,CAAC,CAAA,CAAC;IACX,CAAC;IAED,IAAI,GAAG,KAAK,IAAI,EAAE,CAAC;QAClB,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO,CAAC;IAC9D,CAAC;IAED,OAAO,KAAK,CAAC;AACd,CAAC,CAAC"}
|
|
@@ -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';
|