@hatk/hatk 0.0.1-alpha.71 → 0.0.1-alpha.73
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/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +2 -0
- package/dist/oauth/server.d.ts +54 -1
- package/dist/oauth/server.d.ts.map +1 -1
- package/dist/oauth/server.js +123 -7
- package/dist/oauth/session.d.ts +21 -0
- package/dist/oauth/session.d.ts.map +1 -1
- package/dist/oauth/session.js +95 -29
- package/dist/push.d.ts.map +1 -1
- package/dist/push.js +4 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +98 -13
- package/package.json +1 -1
package/dist/adapter.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,cAAc,EAAgB,MAAM,WAAW,CAAA;AAEnF;;GAEG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,eAAe,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CA0BrE;AAED;;GAEG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,cAAc,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAuBzF;AAED,wFAAwF;AACxF,eAAO,MAAM,WAAW,
|
|
1
|
+
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,cAAc,EAAgB,MAAM,WAAW,CAAA;AAEnF;;GAEG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,eAAe,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CA0BrE;AAED;;GAEG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,cAAc,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAuBzF;AAED,wFAAwF;AACxF,eAAO,MAAM,WAAW,UAgBvB,CAAA;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAErD;AAED;;;;GAIG;AACH,wBAAgB,KAAK,CACnB,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,EAChD,IAAI,EAAE,MAAM,EACZ,IAAI,CAAC,EAAE,MAAM,EACb,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,IAAI,KAAK,IAAI,6EA4BjF"}
|
package/dist/adapter.js
CHANGED
package/dist/oauth/server.d.ts
CHANGED
|
@@ -63,15 +63,59 @@ export declare function getJwks(): {
|
|
|
63
63
|
y?: string;
|
|
64
64
|
}[];
|
|
65
65
|
};
|
|
66
|
+
/**
|
|
67
|
+
* Whether we authenticate to PDSes as a confidential client.
|
|
68
|
+
*
|
|
69
|
+
* A confidential client gets a 2-year session and 3-month refresh window
|
|
70
|
+
* instead of 2 weeks, and is exempt from the consent screen atproto forces on
|
|
71
|
+
* every authorization request from a public client. It requires a fetchable
|
|
72
|
+
* `jwks_uri`, so loopback deployments (local dev) stay public.
|
|
73
|
+
*/
|
|
74
|
+
export declare function isConfidentialClient(issuer: string): boolean;
|
|
75
|
+
/** Public half of the client-authentication key, for the PDS to verify assertions. */
|
|
76
|
+
export declare function getClientJwks(): {
|
|
77
|
+
keys: {
|
|
78
|
+
kid: string;
|
|
79
|
+
use: string;
|
|
80
|
+
alg: string;
|
|
81
|
+
crv?: string;
|
|
82
|
+
d?: string;
|
|
83
|
+
dp?: string;
|
|
84
|
+
dq?: string;
|
|
85
|
+
e?: string;
|
|
86
|
+
ext?: boolean;
|
|
87
|
+
k?: string;
|
|
88
|
+
key_ops?: string[];
|
|
89
|
+
kty?: string;
|
|
90
|
+
n?: string;
|
|
91
|
+
oth?: RsaOtherPrimesInfo[];
|
|
92
|
+
p?: string;
|
|
93
|
+
q?: string;
|
|
94
|
+
qi?: string;
|
|
95
|
+
x?: string;
|
|
96
|
+
y?: string;
|
|
97
|
+
}[];
|
|
98
|
+
};
|
|
66
99
|
export declare function getClientMetadata(issuer: string, config: OAuthConfig): {
|
|
100
|
+
dpop_bound_access_tokens: boolean;
|
|
101
|
+
scope: string;
|
|
102
|
+
token_endpoint_auth_method: string;
|
|
103
|
+
token_endpoint_auth_signing_alg: string;
|
|
104
|
+
jwks_uri: string;
|
|
67
105
|
client_id: string;
|
|
68
106
|
client_name: string;
|
|
69
107
|
redirect_uris: string[];
|
|
70
108
|
grant_types: string[];
|
|
71
109
|
response_types: string[];
|
|
72
|
-
|
|
110
|
+
} | {
|
|
73
111
|
dpop_bound_access_tokens: boolean;
|
|
74
112
|
scope: string;
|
|
113
|
+
token_endpoint_auth_method: string;
|
|
114
|
+
client_id: string;
|
|
115
|
+
client_name: string;
|
|
116
|
+
redirect_uris: string[];
|
|
117
|
+
grant_types: string[];
|
|
118
|
+
response_types: string[];
|
|
75
119
|
};
|
|
76
120
|
/**
|
|
77
121
|
* Handle a Pushed Authorization Request (PAR).
|
|
@@ -106,6 +150,15 @@ export declare function handleCallback(config: OAuthConfig, code: string, state:
|
|
|
106
150
|
clientState: string | null;
|
|
107
151
|
did: string;
|
|
108
152
|
}>;
|
|
153
|
+
/**
|
|
154
|
+
* Handle an error redirect from the PDS (user denied, request expired, etc).
|
|
155
|
+
*
|
|
156
|
+
* The PDS reports failures by redirecting to our callback with `error` and no
|
|
157
|
+
* `code`. Discard the pending request and return a URL that hands the error
|
|
158
|
+
* back to the app, so the browser lands somewhere renderable instead of on a
|
|
159
|
+
* raw error response.
|
|
160
|
+
*/
|
|
161
|
+
export declare function handleCallbackError(config: OAuthConfig, error: string, errorDescription: string | null, state: string | null): Promise<string>;
|
|
109
162
|
export declare function handleToken(config: OAuthConfig, body: Record<string, string>, dpopHeader: string, requestUrl: string): Promise<any>;
|
|
110
163
|
export declare function refreshPdsSession(config: OAuthConfig, session: {
|
|
111
164
|
did: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/oauth/server.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/oauth/server.ts"],"names":[],"mappings":"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"}
|
package/dist/oauth/server.js
CHANGED
|
@@ -9,6 +9,8 @@ import { emit } from "../logger.js";
|
|
|
9
9
|
import { querySQL } from "../database/db.js";
|
|
10
10
|
import { fireOnLoginHook } from "../hooks.js";
|
|
11
11
|
const SERVER_KEY_KID = 'appview-oauth-key';
|
|
12
|
+
const CLIENT_KEY_KID = 'pds-client-auth-key';
|
|
13
|
+
const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
|
|
12
14
|
/**
|
|
13
15
|
* RFC 6749 §5.2 OAuth token-endpoint error.
|
|
14
16
|
*
|
|
@@ -59,6 +61,8 @@ let serverPrivateJwk;
|
|
|
59
61
|
let serverPublicJwk;
|
|
60
62
|
let serverPrivateKey;
|
|
61
63
|
let serverJkt;
|
|
64
|
+
let clientPublicJwk;
|
|
65
|
+
let clientPrivateKey;
|
|
62
66
|
let _plcUrl;
|
|
63
67
|
let _relayUrl;
|
|
64
68
|
export async function initOAuth(_config, plcUrl, relayUrl) {
|
|
@@ -78,6 +82,24 @@ export async function initOAuth(_config, plcUrl, relayUrl) {
|
|
|
78
82
|
}
|
|
79
83
|
serverPrivateKey = await importPrivateKey(serverPrivateJwk);
|
|
80
84
|
serverJkt = await computeJwkThumbprint(serverPublicJwk);
|
|
85
|
+
// Client-authentication key, kept separate from the access-token signing key
|
|
86
|
+
// above. A PDS binds each session to the kid that authenticated it and
|
|
87
|
+
// refuses a later refresh signed by a different one, so this key can't be
|
|
88
|
+
// rotated without signing every user out — keeping it distinct means the
|
|
89
|
+
// access-token key stays rotatable.
|
|
90
|
+
const existingClientKey = await getServerKey(CLIENT_KEY_KID);
|
|
91
|
+
let clientPrivateJwk;
|
|
92
|
+
if (existingClientKey) {
|
|
93
|
+
clientPrivateJwk = JSON.parse(existingClientKey.privateKey);
|
|
94
|
+
clientPublicJwk = JSON.parse(existingClientKey.publicKey);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const kp = await generateKeyPair();
|
|
98
|
+
clientPrivateJwk = kp.privateJwk;
|
|
99
|
+
clientPublicJwk = kp.publicJwk;
|
|
100
|
+
await storeServerKey(CLIENT_KEY_KID, JSON.stringify(clientPrivateJwk), JSON.stringify(clientPublicJwk));
|
|
101
|
+
}
|
|
102
|
+
clientPrivateKey = await importPrivateKey(clientPrivateJwk);
|
|
81
103
|
// Initialize SSR session cookie signing
|
|
82
104
|
initSession(serverPrivateJwk, _config.cookieName);
|
|
83
105
|
// Periodic cleanup of expired OAuth data
|
|
@@ -126,17 +148,80 @@ export function getJwks() {
|
|
|
126
148
|
],
|
|
127
149
|
};
|
|
128
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Whether we authenticate to PDSes as a confidential client.
|
|
153
|
+
*
|
|
154
|
+
* A confidential client gets a 2-year session and 3-month refresh window
|
|
155
|
+
* instead of 2 weeks, and is exempt from the consent screen atproto forces on
|
|
156
|
+
* every authorization request from a public client. It requires a fetchable
|
|
157
|
+
* `jwks_uri`, so loopback deployments (local dev) stay public.
|
|
158
|
+
*/
|
|
159
|
+
export function isConfidentialClient(issuer) {
|
|
160
|
+
return !isLoopbackClient(issuer);
|
|
161
|
+
}
|
|
162
|
+
/** Public half of the client-authentication key, for the PDS to verify assertions. */
|
|
163
|
+
export function getClientJwks() {
|
|
164
|
+
return {
|
|
165
|
+
keys: [
|
|
166
|
+
{
|
|
167
|
+
...clientPublicJwk,
|
|
168
|
+
kid: CLIENT_KEY_KID,
|
|
169
|
+
use: 'sig',
|
|
170
|
+
alg: 'ES256',
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* A JWT proving we are this client, per RFC 7523. The PDS checks `sub` against
|
|
177
|
+
* the client_id it resolved and `aud` against its own issuer, then verifies the
|
|
178
|
+
* signature against the key it fetched from `jwks_uri`.
|
|
179
|
+
*/
|
|
180
|
+
async function createClientAssertion(issuer, config, audience) {
|
|
181
|
+
const clientId = pdsClientId(issuer, config);
|
|
182
|
+
const now = Math.floor(Date.now() / 1000);
|
|
183
|
+
return signJwt({ typ: 'JWT', alg: 'ES256', kid: CLIENT_KEY_KID }, {
|
|
184
|
+
iss: clientId,
|
|
185
|
+
sub: clientId,
|
|
186
|
+
aud: audience,
|
|
187
|
+
jti: randomToken(),
|
|
188
|
+
iat: now,
|
|
189
|
+
exp: now + 60,
|
|
190
|
+
}, clientPrivateKey);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Add client authentication to a PAR or token request body. No-op for loopback
|
|
194
|
+
* deployments, which stay public clients.
|
|
195
|
+
*/
|
|
196
|
+
async function withClientAuth(params, issuer, config, audience) {
|
|
197
|
+
if (!isConfidentialClient(issuer))
|
|
198
|
+
return params;
|
|
199
|
+
return {
|
|
200
|
+
...params,
|
|
201
|
+
client_assertion_type: CLIENT_ASSERTION_TYPE,
|
|
202
|
+
client_assertion: await createClientAssertion(issuer, config, audience),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
129
205
|
export function getClientMetadata(issuer, config) {
|
|
130
206
|
// Find the metadata client entry to get its scope
|
|
131
207
|
const metadataClientId = `${issuer}/oauth-client-metadata.json`;
|
|
132
208
|
const clientConfig = config.clients.find((c) => c.client_id === metadataClientId);
|
|
209
|
+
const confidential = isConfidentialClient(issuer);
|
|
133
210
|
return {
|
|
134
211
|
client_id: metadataClientId,
|
|
135
212
|
client_name: clientConfig?.client_name || 'hatk',
|
|
136
213
|
redirect_uris: [`${issuer}/oauth/callback`],
|
|
137
214
|
grant_types: ['authorization_code', 'refresh_token'],
|
|
138
215
|
response_types: ['code'],
|
|
139
|
-
|
|
216
|
+
// atproto requires the signing alg alongside private_key_jwt, and requires
|
|
217
|
+
// its absence for a public client.
|
|
218
|
+
...(confidential
|
|
219
|
+
? {
|
|
220
|
+
token_endpoint_auth_method: 'private_key_jwt',
|
|
221
|
+
token_endpoint_auth_signing_alg: 'ES256',
|
|
222
|
+
jwks_uri: `${issuer}/oauth/client-jwks.json`,
|
|
223
|
+
}
|
|
224
|
+
: { token_endpoint_auth_method: 'none' }),
|
|
140
225
|
dpop_bound_access_tokens: true,
|
|
141
226
|
scope: clientConfig?.scope || 'atproto transition:generic',
|
|
142
227
|
};
|
|
@@ -242,7 +327,7 @@ export async function handlePar(config, body, dpopHeader, requestUrl) {
|
|
|
242
327
|
if (did) {
|
|
243
328
|
pdsParParams.login_hint = body.login_hint || did;
|
|
244
329
|
}
|
|
245
|
-
const pdsParBody = new URLSearchParams(pdsParParams);
|
|
330
|
+
const pdsParBody = new URLSearchParams(await withClientAuth(pdsParParams, config.issuer, config, authServerMetadata.issuer || pdsAuthServer));
|
|
246
331
|
const pdsParRes = await fetch(parEndpoint, {
|
|
247
332
|
method: 'POST',
|
|
248
333
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
|
|
@@ -383,7 +468,7 @@ export async function serverLogin(config, handle, options) {
|
|
|
383
468
|
if (did) {
|
|
384
469
|
pdsParParams.login_hint = handle;
|
|
385
470
|
}
|
|
386
|
-
const pdsParBody = new URLSearchParams(pdsParParams);
|
|
471
|
+
const pdsParBody = new URLSearchParams(await withClientAuth(pdsParParams, config.issuer, config, authServerMetadata.issuer || pdsAuthServer));
|
|
387
472
|
let pdsRequestUri;
|
|
388
473
|
const pdsParRes = await fetch(parEndpoint, {
|
|
389
474
|
method: 'POST',
|
|
@@ -466,13 +551,13 @@ export async function handleCallback(config, code, state, iss) {
|
|
|
466
551
|
// Exchange code at PDS token endpoint
|
|
467
552
|
const tokenEndpoint = `${request.pds_auth_server}/oauth/token`;
|
|
468
553
|
const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
|
|
469
|
-
const tokenBody = new URLSearchParams({
|
|
554
|
+
const tokenBody = new URLSearchParams(await withClientAuth({
|
|
470
555
|
grant_type: 'authorization_code',
|
|
471
556
|
code,
|
|
472
557
|
redirect_uri: pdsRedirectUri(config.issuer),
|
|
473
558
|
client_id: pdsClientId(config.issuer, config),
|
|
474
559
|
code_verifier: request.pds_code_verifier,
|
|
475
|
-
});
|
|
560
|
+
}, config.issuer, config, request.pds_auth_server));
|
|
476
561
|
let tokenRes = await fetch(tokenEndpoint, {
|
|
477
562
|
method: 'POST',
|
|
478
563
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
|
|
@@ -546,6 +631,37 @@ export async function handleCallback(config, code, state, iss) {
|
|
|
546
631
|
const clientRedirectUri = `${request.redirect_uri}?${params}`;
|
|
547
632
|
return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state, did };
|
|
548
633
|
}
|
|
634
|
+
/**
|
|
635
|
+
* Handle an error redirect from the PDS (user denied, request expired, etc).
|
|
636
|
+
*
|
|
637
|
+
* The PDS reports failures by redirecting to our callback with `error` and no
|
|
638
|
+
* `code`. Discard the pending request and return a URL that hands the error
|
|
639
|
+
* back to the app, so the browser lands somewhere renderable instead of on a
|
|
640
|
+
* raw error response.
|
|
641
|
+
*/
|
|
642
|
+
export async function handleCallbackError(config, error, errorDescription, state) {
|
|
643
|
+
let request = null;
|
|
644
|
+
if (state) {
|
|
645
|
+
const rows = await querySQL(`SELECT * FROM _oauth_requests WHERE pds_state = $1`, [state]);
|
|
646
|
+
request = rows.length > 0 ? rows[0] : null;
|
|
647
|
+
}
|
|
648
|
+
if (request)
|
|
649
|
+
await deleteOAuthRequest(request.request_uri);
|
|
650
|
+
emit('oauth', 'pds_callback_error', { error, error_description: errorDescription, matched: !!request });
|
|
651
|
+
const params = new URLSearchParams({ error });
|
|
652
|
+
if (errorDescription)
|
|
653
|
+
params.set('error_description', errorDescription);
|
|
654
|
+
// Server-initiated logins store redirect_uri as '/' — send the browser home
|
|
655
|
+
// with the error attached. Same for an unmatched state (expired or replayed).
|
|
656
|
+
if (!request || request.redirect_uri === '/')
|
|
657
|
+
return `/?${params}`;
|
|
658
|
+
// Client flow: echo the client's state and our issuer so the SPA's callback
|
|
659
|
+
// handler recognises the redirect as its own rather than re-entering here.
|
|
660
|
+
if (request.state)
|
|
661
|
+
params.set('state', request.state);
|
|
662
|
+
params.set('iss', config.issuer);
|
|
663
|
+
return `${request.redirect_uri}?${params}`;
|
|
664
|
+
}
|
|
549
665
|
// --- Token Endpoint ---
|
|
550
666
|
export async function handleToken(config, body, dpopHeader, requestUrl) {
|
|
551
667
|
const grantType = body.grant_type;
|
|
@@ -690,11 +806,11 @@ export async function refreshPdsSession(config, session) {
|
|
|
690
806
|
const tokenEndpoint = `${session.pds_auth_server || session.pds_endpoint}/oauth/token`;
|
|
691
807
|
const clientId = pdsClientId(config.issuer, config);
|
|
692
808
|
const dpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
|
|
693
|
-
const body = new URLSearchParams({
|
|
809
|
+
const body = new URLSearchParams(await withClientAuth({
|
|
694
810
|
grant_type: 'refresh_token',
|
|
695
811
|
refresh_token: session.refresh_token,
|
|
696
812
|
client_id: clientId,
|
|
697
|
-
});
|
|
813
|
+
}, config.issuer, config, session.pds_auth_server || session.pds_endpoint));
|
|
698
814
|
let tokenRes = await fetch(tokenEndpoint, {
|
|
699
815
|
method: 'POST',
|
|
700
816
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: dpopProof },
|
package/dist/oauth/session.d.ts
CHANGED
|
@@ -3,9 +3,30 @@ export type SessionData = {
|
|
|
3
3
|
handle: string;
|
|
4
4
|
};
|
|
5
5
|
export declare function getSessionCookieName(): string;
|
|
6
|
+
/**
|
|
7
|
+
* Companion cookie listing every account this browser has signed in as.
|
|
8
|
+
*
|
|
9
|
+
* The session cookie names the *active* account; this one is the set the
|
|
10
|
+
* browser is entitled to switch between without going back to the PDS. It's
|
|
11
|
+
* encrypted with the same key, so a client can't add a DID to it and assume
|
|
12
|
+
* that identity — which is the whole security gate on `/auth/switch`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function getAccountsCookieName(): string;
|
|
6
15
|
export declare function initSession(privateJwk: JsonWebKey, cookieName?: string): void;
|
|
7
16
|
export declare function createSessionCookie(data: SessionData): Promise<string>;
|
|
8
17
|
export declare function sessionCookieHeader(value: string, secure: boolean): string;
|
|
9
18
|
export declare function clearSessionCookieHeader(): string;
|
|
10
19
|
export declare function parseSessionCookie(request: Request): Promise<SessionData | null>;
|
|
20
|
+
export declare function createAccountsCookie(accounts: SessionData[]): Promise<string>;
|
|
21
|
+
export declare function accountsCookieHeader(value: string, secure: boolean): string;
|
|
22
|
+
export declare function clearAccountsCookieHeader(): string;
|
|
23
|
+
/** Every account this browser has signed in as, oldest first. */
|
|
24
|
+
export declare function parseAccountsCookie(request: Request): Promise<SessionData[]>;
|
|
25
|
+
/**
|
|
26
|
+
* Add (or refresh) an account in the browser's list. Existing entries keep
|
|
27
|
+
* their position so the switcher doesn't reshuffle; a re-login just updates
|
|
28
|
+
* the handle.
|
|
29
|
+
*/
|
|
30
|
+
export declare function withAccount(accounts: SessionData[], account: SessionData): SessionData[];
|
|
31
|
+
export declare function withoutAccount(accounts: SessionData[], did: string): SessionData[];
|
|
11
32
|
//# sourceMappingURL=session.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/oauth/session.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/oauth/session.ts"],"names":[],"mappings":"AAYA,MAAM,MAAM,WAAW,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAA;AAEzD,wBAAgB,oBAAoB,IAAI,MAAM,CAE7C;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,IAAI,MAAM,CAE9C;AAED,wBAAgB,WAAW,CAAC,UAAU,EAAE,UAAU,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAG7E;AAoDD,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAE5E;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,CAE1E;AAED,wBAAgB,wBAAwB,IAAI,MAAM,CAEjD;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAOtF;AAID,wBAAsB,oBAAoB,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAEnF;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,CAE3E;AAED,wBAAgB,yBAAyB,IAAI,MAAM,CAElD;AAED,iEAAiE;AACjE,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAUlF;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,WAAW,EAAE,EAAE,OAAO,EAAE,WAAW,GAAG,WAAW,EAAE,CAMxF;AAED,wBAAgB,cAAc,CAAC,QAAQ,EAAE,WAAW,EAAE,EAAE,GAAG,EAAE,MAAM,GAAG,WAAW,EAAE,CAElF"}
|
package/dist/oauth/session.js
CHANGED
|
@@ -4,9 +4,22 @@ import { base64UrlEncode, base64UrlDecode } from "./crypto.js";
|
|
|
4
4
|
let _privateJwk;
|
|
5
5
|
let _cookieName = '__hatk_session';
|
|
6
6
|
const MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
|
|
7
|
+
/** Most accounts one browser can keep signed in at once. */
|
|
8
|
+
const MAX_ACCOUNTS = 10;
|
|
7
9
|
export function getSessionCookieName() {
|
|
8
10
|
return _cookieName;
|
|
9
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Companion cookie listing every account this browser has signed in as.
|
|
14
|
+
*
|
|
15
|
+
* The session cookie names the *active* account; this one is the set the
|
|
16
|
+
* browser is entitled to switch between without going back to the PDS. It's
|
|
17
|
+
* encrypted with the same key, so a client can't add a DID to it and assume
|
|
18
|
+
* that identity — which is the whole security gate on `/auth/switch`.
|
|
19
|
+
*/
|
|
20
|
+
export function getAccountsCookieName() {
|
|
21
|
+
return `${_cookieName}_accounts`;
|
|
22
|
+
}
|
|
10
23
|
export function initSession(privateJwk, cookieName) {
|
|
11
24
|
_privateJwk = privateJwk;
|
|
12
25
|
if (cookieName)
|
|
@@ -17,33 +30,14 @@ async function aesKey() {
|
|
|
17
30
|
const keyMaterial = await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']);
|
|
18
31
|
return crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode('hatk-session-cookie') }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
19
32
|
}
|
|
20
|
-
|
|
21
|
-
const payload = JSON.stringify({ ...data, ts: Math.floor(Date.now() / 1000) });
|
|
33
|
+
async function encrypt(payload) {
|
|
22
34
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
23
35
|
const key = await aesKey();
|
|
24
|
-
const
|
|
36
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
37
|
+
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext);
|
|
25
38
|
return `${base64UrlEncode(iv)}.${base64UrlEncode(new Uint8Array(ciphertext))}`;
|
|
26
39
|
}
|
|
27
|
-
|
|
28
|
-
const parts = [`${_cookieName}=${value}`, 'HttpOnly', 'SameSite=Lax', 'Path=/', `Max-Age=${MAX_AGE}`];
|
|
29
|
-
if (secure)
|
|
30
|
-
parts.push('Secure');
|
|
31
|
-
return parts.join('; ');
|
|
32
|
-
}
|
|
33
|
-
export function clearSessionCookieHeader() {
|
|
34
|
-
return `${_cookieName}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`;
|
|
35
|
-
}
|
|
36
|
-
export async function parseSessionCookie(request) {
|
|
37
|
-
const cookieHeader = request.headers.get('cookie');
|
|
38
|
-
if (!cookieHeader)
|
|
39
|
-
return null;
|
|
40
|
-
const match = cookieHeader
|
|
41
|
-
.split(';')
|
|
42
|
-
.map((c) => c.trim())
|
|
43
|
-
.find((c) => c.startsWith(`${_cookieName}=`));
|
|
44
|
-
if (!match)
|
|
45
|
-
return null;
|
|
46
|
-
const value = match.slice(_cookieName.length + 1);
|
|
40
|
+
async function decrypt(value) {
|
|
47
41
|
const parts = value.split('.');
|
|
48
42
|
if (parts.length !== 2)
|
|
49
43
|
return null;
|
|
@@ -52,14 +46,86 @@ export async function parseSessionCookie(request) {
|
|
|
52
46
|
const ciphertext = base64UrlDecode(parts[1]);
|
|
53
47
|
const key = await aesKey();
|
|
54
48
|
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
|
|
55
|
-
|
|
56
|
-
if (!data.did || !data.handle || !data.ts)
|
|
57
|
-
return null;
|
|
58
|
-
if (Date.now() / 1000 - data.ts > MAX_AGE)
|
|
59
|
-
return null;
|
|
60
|
-
return { did: data.did, handle: data.handle };
|
|
49
|
+
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
61
50
|
}
|
|
62
51
|
catch {
|
|
63
52
|
return null;
|
|
64
53
|
}
|
|
65
54
|
}
|
|
55
|
+
function readCookie(request, name) {
|
|
56
|
+
const cookieHeader = request.headers.get('cookie');
|
|
57
|
+
if (!cookieHeader)
|
|
58
|
+
return null;
|
|
59
|
+
const match = cookieHeader
|
|
60
|
+
.split(';')
|
|
61
|
+
.map((c) => c.trim())
|
|
62
|
+
.find((c) => c.startsWith(`${name}=`));
|
|
63
|
+
return match ? match.slice(name.length + 1) : null;
|
|
64
|
+
}
|
|
65
|
+
function cookieHeader(name, value, secure) {
|
|
66
|
+
const parts = [`${name}=${value}`, 'HttpOnly', 'SameSite=Lax', 'Path=/', `Max-Age=${MAX_AGE}`];
|
|
67
|
+
if (secure)
|
|
68
|
+
parts.push('Secure');
|
|
69
|
+
return parts.join('; ');
|
|
70
|
+
}
|
|
71
|
+
export async function createSessionCookie(data) {
|
|
72
|
+
return encrypt({ ...data, ts: Math.floor(Date.now() / 1000) });
|
|
73
|
+
}
|
|
74
|
+
export function sessionCookieHeader(value, secure) {
|
|
75
|
+
return cookieHeader(_cookieName, value, secure);
|
|
76
|
+
}
|
|
77
|
+
export function clearSessionCookieHeader() {
|
|
78
|
+
return `${_cookieName}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`;
|
|
79
|
+
}
|
|
80
|
+
export async function parseSessionCookie(request) {
|
|
81
|
+
const value = readCookie(request, _cookieName);
|
|
82
|
+
if (!value)
|
|
83
|
+
return null;
|
|
84
|
+
const data = await decrypt(value);
|
|
85
|
+
if (!data?.did || !data.handle || !data.ts)
|
|
86
|
+
return null;
|
|
87
|
+
if (Date.now() / 1000 - data.ts > MAX_AGE)
|
|
88
|
+
return null;
|
|
89
|
+
return { did: data.did, handle: data.handle };
|
|
90
|
+
}
|
|
91
|
+
// --- Accounts cookie ---
|
|
92
|
+
export async function createAccountsCookie(accounts) {
|
|
93
|
+
return encrypt({ accounts: accounts.slice(-MAX_ACCOUNTS), ts: Math.floor(Date.now() / 1000) });
|
|
94
|
+
}
|
|
95
|
+
export function accountsCookieHeader(value, secure) {
|
|
96
|
+
return cookieHeader(getAccountsCookieName(), value, secure);
|
|
97
|
+
}
|
|
98
|
+
export function clearAccountsCookieHeader() {
|
|
99
|
+
return `${getAccountsCookieName()}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`;
|
|
100
|
+
}
|
|
101
|
+
/** Every account this browser has signed in as, oldest first. */
|
|
102
|
+
export async function parseAccountsCookie(request) {
|
|
103
|
+
const value = readCookie(request, getAccountsCookieName());
|
|
104
|
+
if (!value)
|
|
105
|
+
return [];
|
|
106
|
+
const data = await decrypt(value);
|
|
107
|
+
if (!data?.ts || !Array.isArray(data.accounts))
|
|
108
|
+
return [];
|
|
109
|
+
if (Date.now() / 1000 - data.ts > MAX_AGE)
|
|
110
|
+
return [];
|
|
111
|
+
return data.accounts.filter((a) => {
|
|
112
|
+
const account = a;
|
|
113
|
+
return !!account && typeof account.did === 'string' && typeof account.handle === 'string';
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Add (or refresh) an account in the browser's list. Existing entries keep
|
|
118
|
+
* their position so the switcher doesn't reshuffle; a re-login just updates
|
|
119
|
+
* the handle.
|
|
120
|
+
*/
|
|
121
|
+
export function withAccount(accounts, account) {
|
|
122
|
+
const index = accounts.findIndex((a) => a.did === account.did);
|
|
123
|
+
if (index === -1)
|
|
124
|
+
return [...accounts, account];
|
|
125
|
+
const next = [...accounts];
|
|
126
|
+
next[index] = account;
|
|
127
|
+
return next;
|
|
128
|
+
}
|
|
129
|
+
export function withoutAccount(accounts, did) {
|
|
130
|
+
return accounts.filter((a) => a.did !== did);
|
|
131
|
+
}
|
package/dist/push.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"push.d.ts","sourceRoot":"","sources":["../src/push.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,UAAU,CAAA;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAC9C;AAOD,iEAAiE;AACjE,wBAAgB,QAAQ,CAAC,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CASpE;AAED,iDAAiD;AACjD,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAED,4DAA4D;AAC5D,wBAAgB,kBAAkB,IAAI,aAAa,CAElD;
|
|
1
|
+
{"version":3,"file":"push.d.ts","sourceRoot":"","sources":["../src/push.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,UAAU,CAAA;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAC9C;AAOD,iEAAiE;AACjE,wBAAgB,QAAQ,CAAC,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CASpE;AAED,iDAAiD;AACjD,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAED,4DAA4D;AAC5D,wBAAgB,kBAAkB,IAAI,aAAa,CAElD;AA0JD,4DAA4D;AAC5D,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAO/F;AAED,2BAA2B;AAC3B,wBAAsB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE9D;AAED,6CAA6C;AAC7C,wBAAsB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/E"}
|
package/dist/push.js
CHANGED
|
@@ -91,6 +91,10 @@ async function send(payload) {
|
|
|
91
91
|
const aps = {
|
|
92
92
|
alert: { title: payload.title, body: payload.body },
|
|
93
93
|
sound: 'default',
|
|
94
|
+
// Run the client's notification service extension before the alert is
|
|
95
|
+
// shown, so it can enrich the payload (Grain attaches the actor's avatar
|
|
96
|
+
// from `data.avatar`). Ignored by clients that don't ship one.
|
|
97
|
+
'mutable-content': 1,
|
|
94
98
|
};
|
|
95
99
|
if (payload.badge !== undefined) {
|
|
96
100
|
aps.badge = payload.badge;
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAqEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AA4B9C;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,WAAW,GAAG,IAAI,GAAG,IAAI,CAqM3F;AAgDD,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,KAAK,EAAE,WAAW,GAAG,IAAI,CAAA;IACzB,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACxF,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IAC5D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,aAAa,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CA6/B5F;AAGD,wBAAgB,WAAW,CACzB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EAAE,EACrB,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,KAAK,EAAE,WAAW,GAAG,IAAI,EACzB,MAAM,GAAE,MAAM,EAAO,EACrB,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,EAC5D,QAAQ,CAAC,EAAE,MAAM,IAAI,GACpB,OAAO,WAAW,EAAE,MAAM,CAG5B"}
|
package/dist/server.js
CHANGED
|
@@ -11,9 +11,9 @@ import { handleOpengraphRequest, buildOgMeta } from "./opengraph.js";
|
|
|
11
11
|
import { getLabelDefinitions, rescanLabels } from "./labels.js";
|
|
12
12
|
import { triggerAutoBackfill } from "./indexer.js";
|
|
13
13
|
import { emit, timer } from "./logger.js";
|
|
14
|
-
import { getAuthServerMetadata, getProtectedResourceMetadata, getJwks, getClientMetadata, handlePar, buildAuthorizeRedirect, handleCallback, serverLogin, handleToken, authenticate, OAuthError, } from "./oauth/server.js";
|
|
15
|
-
import { createSessionCookie, sessionCookieHeader, clearSessionCookieHeader, parseSessionCookie, } from "./oauth/session.js";
|
|
16
|
-
import { getOAuthRequest } from "./oauth/db.js";
|
|
14
|
+
import { getAuthServerMetadata, getProtectedResourceMetadata, getJwks, getClientMetadata, getClientJwks, handlePar, buildAuthorizeRedirect, handleCallback, handleCallbackError, serverLogin, handleToken, authenticate, OAuthError, } from "./oauth/server.js";
|
|
15
|
+
import { createSessionCookie, sessionCookieHeader, clearSessionCookieHeader, parseSessionCookie, createAccountsCookie, accountsCookieHeader, clearAccountsCookieHeader, parseAccountsCookie, withAccount, withoutAccount, } from "./oauth/session.js";
|
|
16
|
+
import { getOAuthRequest, getSession } from "./oauth/db.js";
|
|
17
17
|
import { pdsCreateRecord, pdsDeleteRecord, pdsPutRecord, pdsApplyWrites, pdsUploadBlob, ProxyError, ScopeMissingProxyError, } from "./pds-proxy.js";
|
|
18
18
|
import { json, jsonError, cors, withCors, file, notFound } from "./response.js";
|
|
19
19
|
import { collectionFromUri, isPrivateCollection } from "./private-collections.js";
|
|
@@ -828,6 +828,10 @@ export function createHandler(config) {
|
|
|
828
828
|
if ((url.pathname === '/oauth/client-metadata.json' || url.pathname === '/oauth-client-metadata.json') && oauth) {
|
|
829
829
|
return withCors(json(getClientMetadata(oauth.issuer, oauth), 200, acceptEncoding));
|
|
830
830
|
}
|
|
831
|
+
// Public key a PDS fetches to verify our client assertions.
|
|
832
|
+
if (url.pathname === '/oauth/client-jwks.json' && oauth) {
|
|
833
|
+
return withCors(json(getClientJwks(), 200, acceptEncoding));
|
|
834
|
+
}
|
|
831
835
|
// Dev-only: create a session cookie for any DID (for testing)
|
|
832
836
|
if (url.pathname === '/__dev/login' && devMode && oauth) {
|
|
833
837
|
const did = url.searchParams.get('did');
|
|
@@ -836,12 +840,14 @@ export function createHandler(config) {
|
|
|
836
840
|
const handle = (await getRepoHandle(did)) ?? did;
|
|
837
841
|
const cookieValue = await createSessionCookie({ did, handle });
|
|
838
842
|
const secure = url.protocol === 'https:';
|
|
843
|
+
const accounts = await createAccountsCookie(withAccount(await parseAccountsCookie(request), { did, handle }));
|
|
839
844
|
return new Response(JSON.stringify({ ok: true }), {
|
|
840
845
|
status: 200,
|
|
841
|
-
headers:
|
|
842
|
-
'Content-Type'
|
|
843
|
-
'Set-Cookie'
|
|
844
|
-
|
|
846
|
+
headers: [
|
|
847
|
+
['Content-Type', 'application/json'],
|
|
848
|
+
['Set-Cookie', sessionCookieHeader(cookieValue, secure)],
|
|
849
|
+
['Set-Cookie', accountsCookieHeader(accounts, secure)],
|
|
850
|
+
],
|
|
845
851
|
});
|
|
846
852
|
}
|
|
847
853
|
// OAuth Login (server-initiated, no DPoP required)
|
|
@@ -903,12 +909,24 @@ export function createHandler(config) {
|
|
|
903
909
|
if (iss !== oauth.issuer) {
|
|
904
910
|
const code = url.searchParams.get('code');
|
|
905
911
|
const state = url.searchParams.get('state');
|
|
912
|
+
const oauthError = url.searchParams.get('error');
|
|
913
|
+
if (oauthError) {
|
|
914
|
+
const errorRedirect = await handleCallbackError(oauth, oauthError, url.searchParams.get('error_description'), state);
|
|
915
|
+
return new Response(null, {
|
|
916
|
+
status: 302,
|
|
917
|
+
headers: { Location: errorRedirect, 'Set-Cookie': clearSessionCookieHeader() },
|
|
918
|
+
});
|
|
919
|
+
}
|
|
906
920
|
if (!code)
|
|
907
921
|
return withCors(jsonError(400, 'Missing code', acceptEncoding));
|
|
908
922
|
const result = await handleCallback(oauth, code, state, iss);
|
|
909
923
|
const isSecure = requestOrigin.startsWith('https');
|
|
910
924
|
const handle = (await getRepoHandle(result.did)) ?? result.did;
|
|
911
|
-
const
|
|
925
|
+
const session = { did: result.did, handle };
|
|
926
|
+
const cookie = await createSessionCookie(session);
|
|
927
|
+
// Remember the account alongside the active session so the browser
|
|
928
|
+
// can switch back to it later without another trip to the PDS.
|
|
929
|
+
const accounts = await createAccountsCookie(withAccount(await parseAccountsCookie(request), session));
|
|
912
930
|
// Server-initiated login stores redirectUri as '/' — redirect cleanly without code/iss params
|
|
913
931
|
const redirectTo = result.clientRedirectUri.startsWith('/?code=') ? '/' : result.clientRedirectUri;
|
|
914
932
|
return new Response(null, {
|
|
@@ -916,17 +934,84 @@ export function createHandler(config) {
|
|
|
916
934
|
headers: [
|
|
917
935
|
['Location', redirectTo],
|
|
918
936
|
['Set-Cookie', sessionCookieHeader(cookie, isSecure)],
|
|
937
|
+
['Set-Cookie', accountsCookieHeader(accounts, isSecure)],
|
|
919
938
|
],
|
|
920
939
|
});
|
|
921
940
|
}
|
|
922
941
|
// Client-side callback — fall through to SPA
|
|
923
942
|
}
|
|
924
|
-
// Session cookie logout
|
|
943
|
+
// Session cookie logout. Also drops the account from the switch list —
|
|
944
|
+
// leaving it there would keep a working path back into an account the
|
|
945
|
+
// user believes they signed out of, which matters on a shared machine.
|
|
925
946
|
if (url.pathname === '/auth/logout' && request.method === 'POST') {
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
947
|
+
const current = await parseSessionCookie(request);
|
|
948
|
+
const isSecure = requestOrigin.startsWith('https');
|
|
949
|
+
const headers = [['Set-Cookie', clearSessionCookieHeader()]];
|
|
950
|
+
if (current) {
|
|
951
|
+
const remaining = withoutAccount(await parseAccountsCookie(request), current.did);
|
|
952
|
+
headers.push([
|
|
953
|
+
'Set-Cookie',
|
|
954
|
+
remaining.length
|
|
955
|
+
? accountsCookieHeader(await createAccountsCookie(remaining), isSecure)
|
|
956
|
+
: clearAccountsCookieHeader(),
|
|
957
|
+
]);
|
|
958
|
+
}
|
|
959
|
+
return new Response(null, { status: 200, headers });
|
|
960
|
+
}
|
|
961
|
+
// Accounts this browser may switch between. The cookie is HttpOnly, so
|
|
962
|
+
// this is the only way for a client to see what's in it.
|
|
963
|
+
if (url.pathname === '/auth/accounts' && request.method === 'GET') {
|
|
964
|
+
const accounts = await parseAccountsCookie(request);
|
|
965
|
+
const active = await parseSessionCookie(request);
|
|
966
|
+
// An account whose server-side OAuth session is gone can't be switched
|
|
967
|
+
// to — say so rather than letting the client offer a dead option.
|
|
968
|
+
// `__dev/login` fabricates a session with no OAuth grant behind it, so
|
|
969
|
+
// in dev every remembered account counts as switchable.
|
|
970
|
+
const resolved = await Promise.all(accounts.map(async (a) => ({ ...a, available: devMode || !!(await getSession(a.did)) })));
|
|
971
|
+
return withCors(json({ accounts: resolved, active: active?.did ?? null }, 200, acceptEncoding));
|
|
972
|
+
}
|
|
973
|
+
// Switch the active account without re-authorizing at the PDS.
|
|
974
|
+
//
|
|
975
|
+
// The OAuth grant belongs to this server acting for the user, not to a
|
|
976
|
+
// browser session — so once a browser has proven it owns an account, we
|
|
977
|
+
// can re-issue its session cookie from the stored session. The accounts
|
|
978
|
+
// cookie is the proof: it's encrypted with the server key, so a client
|
|
979
|
+
// can't name a DID it never signed in as.
|
|
980
|
+
if (url.pathname === '/auth/switch' && request.method === 'POST') {
|
|
981
|
+
let did;
|
|
982
|
+
try {
|
|
983
|
+
const body = await request.json();
|
|
984
|
+
did = typeof body?.did === 'string' ? body.did : undefined;
|
|
985
|
+
}
|
|
986
|
+
catch {
|
|
987
|
+
return withCors(jsonError(400, 'Invalid JSON body', acceptEncoding));
|
|
988
|
+
}
|
|
989
|
+
if (!did)
|
|
990
|
+
return withCors(jsonError(400, 'did required', acceptEncoding));
|
|
991
|
+
const accounts = await parseAccountsCookie(request);
|
|
992
|
+
const account = accounts.find((a) => a.did === did);
|
|
993
|
+
if (!account)
|
|
994
|
+
return withCors(jsonError(403, 'Not signed in as that account', acceptEncoding));
|
|
995
|
+
if (!devMode && !(await getSession(account.did))) {
|
|
996
|
+
// The stored grant is gone (revoked, or cleaned up). Drop it from the
|
|
997
|
+
// list so the client stops offering it and can send the user through
|
|
998
|
+
// a fresh sign-in instead.
|
|
999
|
+
const isSecure = requestOrigin.startsWith('https');
|
|
1000
|
+
const remaining = withoutAccount(accounts, account.did);
|
|
1001
|
+
const res = withCors(jsonError(409, 'Session expired for that account', acceptEncoding));
|
|
1002
|
+
res.headers.append('Set-Cookie', remaining.length
|
|
1003
|
+
? accountsCookieHeader(await createAccountsCookie(remaining), isSecure)
|
|
1004
|
+
: clearAccountsCookieHeader());
|
|
1005
|
+
return res;
|
|
1006
|
+
}
|
|
1007
|
+
// Refresh the handle in passing — it may have changed while dormant.
|
|
1008
|
+
const handle = (await getRepoHandle(account.did)) ?? account.handle;
|
|
1009
|
+
const session = { did: account.did, handle };
|
|
1010
|
+
const isSecure = requestOrigin.startsWith('https');
|
|
1011
|
+
const res = withCors(json({ did: session.did, handle: session.handle }, 200, acceptEncoding));
|
|
1012
|
+
res.headers.append('Set-Cookie', sessionCookieHeader(await createSessionCookie(session), isSecure));
|
|
1013
|
+
res.headers.append('Set-Cookie', accountsCookieHeader(await createAccountsCookie(withAccount(accounts, session)), isSecure));
|
|
1014
|
+
return res;
|
|
930
1015
|
}
|
|
931
1016
|
// OAuth Token
|
|
932
1017
|
if (url.pathname === '/oauth/token' && request.method === 'POST' && oauth) {
|