+6
README.md
+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
+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
+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
+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
+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
+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
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
+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
+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
+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
+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
+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
+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
+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
+7
packages/oauth/browser-client/lib/types/dpop.ts
+7
packages/oauth/browser-client/lib/types/identity.ts
+7
packages/oauth/browser-client/lib/types/identity.ts
+4
packages/oauth/browser-client/lib/types/par.ts
+4
packages/oauth/browser-client/lib/types/par.ts
+67
packages/oauth/browser-client/lib/types/server.ts
+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
+6
packages/oauth/browser-client/lib/types/store.ts
+46
packages/oauth/browser-client/lib/types/token.ts
+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
+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
+3
packages/oauth/browser-client/lib/utils/response.ts
+55
packages/oauth/browser-client/lib/utils/runtime.ts
+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
+5
packages/oauth/browser-client/lib/utils/strings.ts
+29
packages/oauth/browser-client/package.json
+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
+4
packages/oauth/browser-client/tsconfig.build.json
+23
packages/oauth/browser-client/tsconfig.json
+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
+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