@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.
@@ -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,UAcvB,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"}
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
@@ -69,6 +69,8 @@ export const HATK_ROUTES = [
69
69
  '/_health',
70
70
  '/robots.txt',
71
71
  '/auth/logout',
72
+ '/auth/switch',
73
+ '/auth/accounts',
72
74
  '/__dev/',
73
75
  ];
74
76
  export function isHatkRoute(pathname) {
@@ -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
- token_endpoint_auth_method: string;
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":"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"}
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"}
@@ -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
- token_endpoint_auth_method: 'none',
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 },
@@ -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":"AASA,MAAM,MAAM,WAAW,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAA;AAEzD,wBAAgB,oBAAoB,IAAI,MAAM,CAE7C;AAED,wBAAgB,WAAW,CAAC,UAAU,EAAE,UAAU,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAG7E;AAcD,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAM5E;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,CAI1E;AAED,wBAAgB,wBAAwB,IAAI,MAAM,CAEjD;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAuBtF"}
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"}
@@ -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
- export async function createSessionCookie(data) {
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 ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(payload));
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
- export function sessionCookieHeader(value, secure) {
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
- const data = JSON.parse(new TextDecoder().decode(plaintext));
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
+ }
@@ -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;AAsJD,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"}
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;
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AA4DA,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,CAy5B5F;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"}
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': 'application/json',
843
- 'Set-Cookie': sessionCookieHeader(cookieValue, secure),
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 cookie = await createSessionCookie({ did: result.did, handle });
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
- return new Response(null, {
927
- status: 200,
928
- headers: { 'Set-Cookie': clearSessionCookieHeader() },
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hatk/hatk",
3
- "version": "0.0.1-alpha.71",
3
+ "version": "0.0.1-alpha.73",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "hatk": "dist/cli.js"