@hatk/hatk 0.0.1-alpha.72 → 0.0.1-alpha.74
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 +45 -1
- package/dist/oauth/server.d.ts.map +1 -1
- package/dist/oauth/server.js +92 -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 +102 -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).
|
|
@@ -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":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AA8C/C;;;;;;;GAOG;AACH,qBAAa,UAAW,SAAQ,KAAK;aAEjB,IAAI,EAChB,iBAAiB,GACjB,gBAAgB,GAChB,eAAe,GACf,qBAAqB,GACrB,wBAAwB,GACxB,eAAe;aACH,WAAW,EAAE,MAAM;aACnB,MAAM,EAAE,MAAM;gBARd,IAAI,EAChB,iBAAiB,GACjB,gBAAgB,GAChB,eAAe,GACf,qBAAqB,GACrB,wBAAwB,GACxB,eAAe,EACH,WAAW,EAAE,MAAM,EACnB,MAAM,GAAE,MAAY;CAKvC;AAyCD,wBAAsB,SAAS,CAAC,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAwCrG;AAID,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW;;;;;;;;;;;;;;;;;;;EAqBxE;AAED,wBAAgB,4BAA4B,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW;;;;;EAO/E;AAED,wBAAgB,OAAO;;;;;;;;;;;;;;;;;;;;;;EAWtB;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAE5D;AAED,sFAAsF;AACtF,wBAAgB,aAAa;;;;;;;;;;;;;;;;;;;;;;EAW5B;AA0CD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW;;;;;;;;;;;;;;;;;;;;EAuBpE;AAID;;;;;;;;;;GAUG;AACH,wBAAsB,SAAS,CAC7B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,CAyKtD;AAID,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,GAAG,GAAG,MAAM,CAShF;AAID;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,WAAW,EACnB,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAC1C,OAAO,CAAC,MAAM,CAAC,CA+HjB;AAID,wBAAsB,cAAc,CAClC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,GAAG,IAAI,EACpB,GAAG,EAAE,MAAM,GAAG,IAAI,GACjB,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAkIrG;AAED;;;;;;;GAOG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,WAAW,EACnB,KAAK,EAAE,MAAM,EACb,gBAAgB,EAAE,MAAM,GAAG,IAAI,EAC/B,KAAK,EAAE,MAAM,GAAG,IAAI,GACnB,OAAO,CAAC,MAAM,CAAC,CAsBjB;AAID,wBAAsB,WAAW,CAC/B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,GAAG,CAAC,CAUd;AA4JD,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAChH,OAAO,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CA6EpF;AAID,wBAAsB,YAAY,CAChC,UAAU,EAAE,MAAM,GAAG,IAAI,EACzB,UAAU,EAAE,MAAM,GAAG,IAAI,EACzB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,GACV,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CA0BjC"}
|
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 },
|
|
@@ -721,11 +806,11 @@ export async function refreshPdsSession(config, session) {
|
|
|
721
806
|
const tokenEndpoint = `${session.pds_auth_server || session.pds_endpoint}/oauth/token`;
|
|
722
807
|
const clientId = pdsClientId(config.issuer, config);
|
|
723
808
|
const dpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
|
|
724
|
-
const body = new URLSearchParams({
|
|
809
|
+
const body = new URLSearchParams(await withClientAuth({
|
|
725
810
|
grant_type: 'refresh_token',
|
|
726
811
|
refresh_token: session.refresh_token,
|
|
727
812
|
client_id: clientId,
|
|
728
|
-
});
|
|
813
|
+
}, config.issuer, config, session.pds_auth_server || session.pds_endpoint));
|
|
729
814
|
let tokenRes = await fetch(tokenEndpoint, {
|
|
730
815
|
method: 'POST',
|
|
731
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,CA6gC5F;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, handleCallbackError, 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)
|
|
@@ -916,7 +922,11 @@ export function createHandler(config) {
|
|
|
916
922
|
const result = await handleCallback(oauth, code, state, iss);
|
|
917
923
|
const isSecure = requestOrigin.startsWith('https');
|
|
918
924
|
const handle = (await getRepoHandle(result.did)) ?? result.did;
|
|
919
|
-
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));
|
|
920
930
|
// Server-initiated login stores redirectUri as '/' — redirect cleanly without code/iss params
|
|
921
931
|
const redirectTo = result.clientRedirectUri.startsWith('/?code=') ? '/' : result.clientRedirectUri;
|
|
922
932
|
return new Response(null, {
|
|
@@ -924,17 +934,96 @@ export function createHandler(config) {
|
|
|
924
934
|
headers: [
|
|
925
935
|
['Location', redirectTo],
|
|
926
936
|
['Set-Cookie', sessionCookieHeader(cookie, isSecure)],
|
|
937
|
+
['Set-Cookie', accountsCookieHeader(accounts, isSecure)],
|
|
927
938
|
],
|
|
928
939
|
});
|
|
929
940
|
}
|
|
930
941
|
// Client-side callback — fall through to SPA
|
|
931
942
|
}
|
|
932
|
-
// 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.
|
|
933
946
|
if (url.pathname === '/auth/logout' && request.method === 'POST') {
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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 stored = await parseAccountsCookie(request);
|
|
965
|
+
const active = await parseSessionCookie(request);
|
|
966
|
+
// Sessions created before this feature existed have a session cookie
|
|
967
|
+
// and no accounts cookie. Adopt the active account into the list rather
|
|
968
|
+
// than reporting it as unknown — otherwise the switcher looks empty and
|
|
969
|
+
// signing into a second account appears to drop the first.
|
|
970
|
+
const needsHealing = !!active && !stored.some((a) => a.did === active.did);
|
|
971
|
+
const accounts = needsHealing ? withAccount(stored, active) : stored;
|
|
972
|
+
// An account whose server-side OAuth session is gone can't be switched
|
|
973
|
+
// to — say so rather than letting the client offer a dead option.
|
|
974
|
+
// `__dev/login` fabricates a session with no OAuth grant behind it, so
|
|
975
|
+
// in dev every remembered account counts as switchable.
|
|
976
|
+
const resolved = await Promise.all(accounts.map(async (a) => ({ ...a, available: devMode || !!(await getSession(a.did)) })));
|
|
977
|
+
const res = withCors(json({ accounts: resolved, active: active?.did ?? null }, 200, acceptEncoding));
|
|
978
|
+
// Persist the adoption so the next switch has something to authorize
|
|
979
|
+
// against, instead of healing the list again on every load.
|
|
980
|
+
if (needsHealing) {
|
|
981
|
+
res.headers.append('Set-Cookie', accountsCookieHeader(await createAccountsCookie(accounts), requestOrigin.startsWith('https')));
|
|
982
|
+
}
|
|
983
|
+
return res;
|
|
984
|
+
}
|
|
985
|
+
// Switch the active account without re-authorizing at the PDS.
|
|
986
|
+
//
|
|
987
|
+
// The OAuth grant belongs to this server acting for the user, not to a
|
|
988
|
+
// browser session — so once a browser has proven it owns an account, we
|
|
989
|
+
// can re-issue its session cookie from the stored session. The accounts
|
|
990
|
+
// cookie is the proof: it's encrypted with the server key, so a client
|
|
991
|
+
// can't name a DID it never signed in as.
|
|
992
|
+
if (url.pathname === '/auth/switch' && request.method === 'POST') {
|
|
993
|
+
let did;
|
|
994
|
+
try {
|
|
995
|
+
const body = await request.json();
|
|
996
|
+
did = typeof body?.did === 'string' ? body.did : undefined;
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
return withCors(jsonError(400, 'Invalid JSON body', acceptEncoding));
|
|
1000
|
+
}
|
|
1001
|
+
if (!did)
|
|
1002
|
+
return withCors(jsonError(400, 'did required', acceptEncoding));
|
|
1003
|
+
const accounts = await parseAccountsCookie(request);
|
|
1004
|
+
const account = accounts.find((a) => a.did === did);
|
|
1005
|
+
if (!account)
|
|
1006
|
+
return withCors(jsonError(403, 'Not signed in as that account', acceptEncoding));
|
|
1007
|
+
if (!devMode && !(await getSession(account.did))) {
|
|
1008
|
+
// The stored grant is gone (revoked, or cleaned up). Drop it from the
|
|
1009
|
+
// list so the client stops offering it and can send the user through
|
|
1010
|
+
// a fresh sign-in instead.
|
|
1011
|
+
const isSecure = requestOrigin.startsWith('https');
|
|
1012
|
+
const remaining = withoutAccount(accounts, account.did);
|
|
1013
|
+
const res = withCors(jsonError(409, 'Session expired for that account', acceptEncoding));
|
|
1014
|
+
res.headers.append('Set-Cookie', remaining.length
|
|
1015
|
+
? accountsCookieHeader(await createAccountsCookie(remaining), isSecure)
|
|
1016
|
+
: clearAccountsCookieHeader());
|
|
1017
|
+
return res;
|
|
1018
|
+
}
|
|
1019
|
+
// Refresh the handle in passing — it may have changed while dormant.
|
|
1020
|
+
const handle = (await getRepoHandle(account.did)) ?? account.handle;
|
|
1021
|
+
const session = { did: account.did, handle };
|
|
1022
|
+
const isSecure = requestOrigin.startsWith('https');
|
|
1023
|
+
const res = withCors(json({ did: session.did, handle: session.handle }, 200, acceptEncoding));
|
|
1024
|
+
res.headers.append('Set-Cookie', sessionCookieHeader(await createSessionCookie(session), isSecure));
|
|
1025
|
+
res.headers.append('Set-Cookie', accountsCookieHeader(await createAccountsCookie(withAccount(accounts, session)), isSecure));
|
|
1026
|
+
return res;
|
|
938
1027
|
}
|
|
939
1028
|
// OAuth Token
|
|
940
1029
|
if (url.pathname === '/oauth/token' && request.method === 'POST' && oauth) {
|