@hatk/hatk 0.0.1-alpha.72 → 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).
@@ -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":"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"}
@@ -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 },
@@ -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 },
@@ -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":"AA6DA,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,CAs6B5F;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, 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': '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)
@@ -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 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));
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,84 @@ 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
- return new Response(null, {
935
- status: 200,
936
- headers: { 'Set-Cookie': clearSessionCookieHeader() },
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 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;
938
1015
  }
939
1016
  // OAuth Token
940
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.72",
3
+ "version": "0.0.1-alpha.73",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "hatk": "dist/cli.js"