@wtfalch/auth 0.19.0 → 0.22.0

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/README.md CHANGED
@@ -94,6 +94,15 @@ Auth-request lookup failures lead to the configured error page with
94
94
  `auth_error=request` or `auth_error=unavailable`. That page should show a
95
95
  retry link; it must not automatically restart authorization.
96
96
 
97
+ When a sign-in fails after it started (reasons such as `state`, `unavailable`,
98
+ `exchange`, `denied`, `organization`, `cookie`), the error URL is
99
+ `<onError>?auth_error=<reason>&next=<path>`. `next` is the page the person came
100
+ for, always a path starting with one `/`, URL-encoded as a single parameter, and
101
+ present only when the sealed value is a safe same-origin path. An `onError` that
102
+ already has a query string keeps it. `expired` and `request` carry no `next`:
103
+ there is no transaction to read it from. Use it for the retry link
104
+ (`/auth/start?next=<path>`); a page that ignores it behaves as before.
105
+
97
106
  ## In a browser app
98
107
 
99
108
  `@wtfalch/auth/browser` is the other half: a public PKCE client for an app that
@@ -248,7 +257,7 @@ refresh token to begin with.
248
257
  | `appUrl` | This app's origin. Every URL the SDK builds starts here, never from the request's `Host`. |
249
258
  | `clientId` | The OIDC client for this origin. Each URL an app runs at is its own client, because the issuer holds one login URL per application. |
250
259
  | `organizationId` | Scopes every sign-in, and is checked on every token. |
251
- | `cookieSecret` | 32 bytes, `openssl rand -base64 32`. One per app. |
260
+ | `cookieSecret` | 32 bytes, `openssl rand -base64 32`. **Each app needs its own** -- see below. |
252
261
  | `appKey` | This app's key for the sign-in service. Needed for anything that checks or creates a credential. |
253
262
 
254
263
  Values are read on first use rather than at import, so `next build` needs none
@@ -260,6 +269,13 @@ unset: the error waits until something actually reaches for the key. A secret
260
269
  that is *supplied and wrong* still throws at startup, because that is a typo
261
270
  rather than a deployment that does not need one.
262
271
 
272
+ **Each app's `cookieSecret` must be its own, not shared with another app.**
273
+ The AES key `cookies.ts` actually encrypts and decrypts with is HKDF-SHA256
274
+ derived from `cookieSecret` using a fixed info string common to every
275
+ consumer of this package -- two apps given the same `cookieSecret` would
276
+ derive the identical key and could open each other's session cookies.
277
+ Foundry already mints a distinct secret per app.
278
+
263
279
  ## What the app key can do
264
280
 
265
281
  It reaches the sign-in service, which holds ZITADEL's login client. Scoped to
package/dist/auth.d.ts CHANGED
@@ -251,6 +251,18 @@ export interface Auth {
251
251
  }, requestHeaders?: Headers): Promise<SignedIn<SignInError> | (PasswordChangeRequired & {
252
252
  message: string;
253
253
  })>;
254
+ /**
255
+ * `signUp` as data, run over the same server-started transaction as
256
+ * `signInWithPassword` -- for a caller with no browser to carry through
257
+ * the OIDC redirect. Unlike `signUp`, which needs an `authRequestId` a
258
+ * browser flow already started with `start()`, this starts and finishes
259
+ * its own transaction, exactly as `signInWithPassword` does. Same two
260
+ * checks apply: an `ok: true` with an empty `cookies` means hosted
261
+ * login, and the cookie name is fixed.
262
+ */
263
+ signUpWithPassword(input: {
264
+ next?: string;
265
+ } & NewUser, requestHeaders?: Headers): Promise<SignedIn<SignUpError>>;
254
266
  /** Emails a fresh verification link. Says nothing about whether the address exists. */
255
267
  resendVerification(email: string, next?: string): Promise<void>;
256
268
  /** Emails a password-reset link to {basePath}/reset?userId=&code=. Says nothing about whether the address exists. */
@@ -278,6 +290,21 @@ export interface Auth {
278
290
  }): Promise<{
279
291
  userId: string;
280
292
  invited: boolean;
293
+ personalEmailStored: boolean;
294
+ }>;
295
+ /**
296
+ * A company's own test user: a new person, marked in the sign-in service
297
+ * so nothing it does is read as a real customer. Refused for an address
298
+ * that already has a user, and for an entry that acts as another (stage,
299
+ * a laptop) -- a test user is made from production's entry.
300
+ */
301
+ createTestUser(input: {
302
+ email: string;
303
+ givenName: string;
304
+ familyName: string;
305
+ username?: string;
306
+ }): Promise<{
307
+ userId: string;
281
308
  }>;
282
309
  handle(request: Request): Promise<Response>;
283
310
  /**
@@ -331,6 +358,17 @@ export interface Auth {
331
358
  userId: string;
332
359
  state: string;
333
360
  }>;
361
+ /**
362
+ * An admin's reset for somebody in this app's own organisation whom
363
+ * `requestPasswordReset` cannot otherwise get back in -- returns a fresh
364
+ * one-time password the caller is trusted to deliver some other way,
365
+ * rather than mailing it. Refused for a user outside this app's
366
+ * organisation.
367
+ */
368
+ adminResetPassword(userId: string): Promise<{
369
+ userId: string;
370
+ password: string;
371
+ }>;
334
372
  /**
335
373
  * Namespace mode: end every session this person has in this service, on
336
374
  * every replica, at their next request. Deactivating someone does this too.
package/dist/auth.js CHANGED
@@ -159,9 +159,9 @@ export function createAuth(input) {
159
159
  return failure('expired', options().namespace ? [] : [cleared]);
160
160
  const params = new URL(callbackUrl).searchParams;
161
161
  if (params.get('state') !== transaction.st)
162
- return failure('state', options().namespace ? [] : [cleared]);
162
+ return failure('state', options().namespace ? [] : [cleared], transaction.nx);
163
163
  if (options().namespace && !(await sessions.bindingActive()))
164
- return failure('unavailable', [cleared]);
164
+ return failure('unavailable', [cleared], transaction.nx);
165
165
  // A silent pass the issuer refused (login_required, interaction_required,
166
166
  // consent_required, or any other error) is not a failure: sign in the normal
167
167
  // way, once. `done` is already set, so `/start` will not go silent again.
@@ -181,7 +181,7 @@ export function createAuth(input) {
181
181
  session = await sessionCookie(tokens, requestHeaders);
182
182
  }
183
183
  catch (error) {
184
- return failure(reasonOf(error), [cleared]);
184
+ return failure(reasonOf(error), [cleared], transaction.nx);
185
185
  }
186
186
  await replacePreviousSession(cookieHeader);
187
187
  return {
@@ -539,6 +539,25 @@ export function createAuth(input) {
539
539
  emit({ type: 'invite', email: input.email, userId: result.userId, invited: result.invited });
540
540
  return result;
541
541
  };
542
+ const createTestUser = async (input) => {
543
+ const result = await broker().createTestUser(input);
544
+ emit({ type: 'createTestUser', email: input.email, userId: result.userId });
545
+ return result;
546
+ };
547
+ /**
548
+ * An admin's reset for somebody in this app's own organisation whom
549
+ * `requestPasswordReset` cannot otherwise get back in -- nobody at the
550
+ * mailbox a personal address or the login address itself would reach.
551
+ * Refused for a user outside this app's organisation, exactly as
552
+ * `setPersonActive` is. The password is a one-time one: the person must
553
+ * choose their own at next sign-in, same as an invite's. Audited with the
554
+ * target's id and never the password itself.
555
+ */
556
+ const adminResetPassword = async (userId) => {
557
+ const result = await broker().adminResetPassword(userId);
558
+ emit({ type: 'adminResetPassword', userId: result.userId });
559
+ return result;
560
+ };
542
561
  const resetPassword = async ({ userId, code, password, next, }, requestHeaders) => {
543
562
  try {
544
563
  const flow = await serverFlow();
@@ -656,6 +675,36 @@ export function createAuth(input) {
656
675
  return { ok: false, error: code, message };
657
676
  }
658
677
  };
678
+ const signUpWithPassword = async ({ next, ...user }, requestHeaders) => {
679
+ try {
680
+ const flow = await serverFlow();
681
+ const completed = await finishOrHandoff(() => broker().signUp({ authRequestId: flow.id, ...user }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
682
+ emit({ type: 'signUp', email: user.email });
683
+ return { ok: true, ...completed };
684
+ }
685
+ catch (error) {
686
+ if (error instanceof BrokerError && error.error === 'email_taken') {
687
+ return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
688
+ }
689
+ if (error instanceof BrokerError && error.error === 'registration_closed') {
690
+ return { ok: false, error: 'registration_closed', message: error.detail ?? error.error };
691
+ }
692
+ // Both carry a sentence about the name, for the form to show as it is.
693
+ if (error instanceof BrokerError && error.error === 'username_taken') {
694
+ return { ok: false, error: 'username_taken', message: error.detail ?? error.error };
695
+ }
696
+ if (error instanceof BrokerError && error.error === 'username_invalid') {
697
+ return { ok: false, error: 'username_invalid', message: error.detail ?? error.error };
698
+ }
699
+ if (error instanceof BrokerError && error.error === 'request') {
700
+ return { ok: false, error: 'request', message: error.detail ?? error.error };
701
+ }
702
+ if (error instanceof BrokerError && error.status < 500) {
703
+ return { ok: false, error: 'invalid', message: error.detail ?? error.error };
704
+ }
705
+ return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
706
+ }
707
+ };
659
708
  const signUp = async ({ authRequestId, ...user }) => {
660
709
  try {
661
710
  const redirectTo = await broker().signUp({ authRequestId, ...user });
@@ -891,9 +940,13 @@ export function createAuth(input) {
891
940
  cookies,
892
941
  };
893
942
  };
894
- const failure = (reason, cookies) => {
943
+ /** `next`, when it is a safe same-origin path, rides on the error URL so the error page can offer a retry to it. */
944
+ const failure = (reason, cookies, next) => {
895
945
  const url = new URL(options().onError, options().appUrl);
896
946
  url.searchParams.set('auth_error', reason);
947
+ const retry = safeNextPath(next, '');
948
+ if (retry)
949
+ url.searchParams.set('next', retry);
897
950
  return { location: url.href, cookies };
898
951
  };
899
952
  /** `failure`'s location on its own, for a caller that redirects rather than answering. */
@@ -918,6 +971,7 @@ export function createAuth(input) {
918
971
  resetPassword,
919
972
  sendLink,
920
973
  invite,
974
+ createTestUser,
921
975
  handle,
922
976
  read,
923
977
  readCookie,
@@ -927,6 +981,7 @@ export function createAuth(input) {
927
981
  errorUrl,
928
982
  people,
929
983
  setPersonActive,
984
+ adminResetPassword,
930
985
  revokeSessions,
931
986
  sessions: listSessions,
932
987
  endSession,
@@ -935,6 +990,7 @@ export function createAuth(input) {
935
990
  setNewPassword,
936
991
  setNewPasswordHeadless,
937
992
  signInWithPassword,
993
+ signUpWithPassword,
938
994
  signUp,
939
995
  startPasskeyRegistration,
940
996
  finishPasskeyRegistration,
package/dist/broker.d.ts CHANGED
@@ -21,6 +21,12 @@ export interface NewUser {
21
21
  * `password_change_required`, then `setNewPassword`).
22
22
  */
23
23
  changeRequired?: boolean;
24
+ /**
25
+ * Where this person's reset links go instead of their own login address --
26
+ * for somebody who cannot yet open the mailbox that address names. See
27
+ * `Auth['adminResetPassword']` for the person this does not cover.
28
+ */
29
+ personalEmail?: string;
24
30
  }
25
31
  /** One of the organisation's people, as the sign-in service reports them. */
26
32
  export interface Person {
@@ -64,6 +70,11 @@ export declare class Broker {
64
70
  userId: string;
65
71
  state: string;
66
72
  }>;
73
+ /** An admin's reset for somebody requestPasswordReset cannot get back in: see `Auth['adminResetPassword']`. */
74
+ adminResetPassword(userId: string): Promise<{
75
+ userId: string;
76
+ password: string;
77
+ }>;
67
78
  authRequest(id: string): Promise<AuthRequest>;
68
79
  signIn(input: {
69
80
  authRequestId: string;
@@ -119,11 +130,22 @@ export declare class Broker {
119
130
  givenName: string;
120
131
  familyName: string;
121
132
  next: string;
133
+ personalEmail?: string;
122
134
  }): Promise<{
123
135
  userId: string;
124
136
  invited: boolean;
137
+ personalEmailStored: boolean;
125
138
  }>;
126
139
  sendLink(email: string, next: string): Promise<unknown>;
140
+ /** A company's own test user, marked in the sign-in service. See `Auth['createTestUser']`. */
141
+ createTestUser(input: {
142
+ email: string;
143
+ givenName: string;
144
+ familyName: string;
145
+ username?: string;
146
+ }): Promise<{
147
+ userId: string;
148
+ }>;
127
149
  sendReset(email: string, next: string): Promise<unknown>;
128
150
  sendVerification(email: string, next: string): Promise<unknown>;
129
151
  /**
package/dist/broker.js CHANGED
@@ -67,6 +67,10 @@ export class Broker {
67
67
  setPersonActive(userId, active) {
68
68
  return this.call('POST', active ? '/people/reactivate' : '/people/deactivate', { userId });
69
69
  }
70
+ /** An admin's reset for somebody requestPasswordReset cannot get back in: see `Auth['adminResetPassword']`. */
71
+ adminResetPassword(userId) {
72
+ return this.call('POST', '/people/reset-password', { userId });
73
+ }
70
74
  authRequest(id) {
71
75
  return this.call('GET', '/auth-request', { id });
72
76
  }
@@ -114,6 +118,10 @@ export class Broker {
114
118
  sendLink(email, next) {
115
119
  return this.call('POST', '/link/send', { email, next });
116
120
  }
121
+ /** A company's own test user, marked in the sign-in service. See `Auth['createTestUser']`. */
122
+ createTestUser(input) {
123
+ return this.call('POST', '/test-users', input);
124
+ }
117
125
  sendReset(email, next) {
118
126
  return this.call('POST', '/reset/send', { email, next });
119
127
  }
package/dist/browser.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { createNamespaceBrowserAuth } from './namespace-browser.js';
2
2
  const TOKEN = 'wtfalch.auth.token';
3
3
  const VERIFIER = 'wtfalch.auth.verifier';
4
+ /** CSRF protection on the callback: checked before the code exchange, never persisted past it. */
5
+ const STATE = 'wtfalch.auth.state';
6
+ /** Replay protection on the ID token: checked against its own claim after the exchange, never persisted past it. */
7
+ const NONCE = 'wtfalch.auth.nonce';
4
8
  const RETURN = 'wtfalch.auth.return';
5
9
  /** Set before a silent attempt leaves, so a refusal cannot become a loop. */
6
10
  const TRIED = 'wtfalch.auth.silent-tried';
@@ -44,9 +48,26 @@ async function challengeFor(verifier) {
44
48
  }
45
49
  function verifier() {
46
50
  // 32 bytes is RFC 7636's recommendation; base64url of it is 43 characters,
47
- // inside the 43..128 the spec allows.
51
+ // inside the 43..128 the spec allows. Reused below for `state` and `nonce`
52
+ // too -- a random, unguessable token is exactly what each of the three is.
48
53
  return base64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
49
54
  }
55
+ /** The payload of a JWT, read without verifying its signature -- the ID token here comes straight
56
+ * from the token endpoint over TLS, which OIDC Core 1.0 3.1.3.7 allows in place of a signature
57
+ * check for the authorization code flow. Never used to trust a claim from anywhere else. */
58
+ function decodeJwtPayload(jwt) {
59
+ try {
60
+ const part = jwt.split('.')[1];
61
+ if (!part)
62
+ return null;
63
+ const base64 = part.replace(/-/g, '+').replace(/_/g, '/');
64
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
65
+ return JSON.parse(atob(padded));
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
50
71
  function publicSession(session) {
51
72
  const { refreshToken, ...rest } = session;
52
73
  return rest;
@@ -86,6 +107,8 @@ export function createBrowserAuth(options) {
86
107
  const key = (name) => (context ? `wtfalch.auth.browser.v1:${context}:${name}` : name);
87
108
  const tokenKey = key(TOKEN);
88
109
  const verifierKey = key(VERIFIER);
110
+ const stateKey = key(STATE);
111
+ const nonceKey = key(NONCE);
89
112
  const returnKey = key(RETURN);
90
113
  const triedKey = key(TRIED);
91
114
  let contexts = coordinators.get(store);
@@ -117,6 +140,8 @@ export function createBrowserAuth(options) {
117
140
  const authorize = async (prompt, returnTo) => {
118
141
  const generation = ++coordinator.generation;
119
142
  const proof = verifier();
143
+ const state = verifier();
144
+ const nonce = verifier();
120
145
  await mutate(async () => {
121
146
  if (!current(generation))
122
147
  return;
@@ -127,6 +152,12 @@ export function createBrowserAuth(options) {
127
152
  if (!current(generation))
128
153
  return;
129
154
  await store.set(verifierKey, proof);
155
+ if (!current(generation))
156
+ return;
157
+ await store.set(stateKey, state);
158
+ if (!current(generation))
159
+ return;
160
+ await store.set(nonceKey, nonce);
130
161
  if (!current(generation))
131
162
  return;
132
163
  await store.set(returnKey, returnTo);
@@ -140,6 +171,8 @@ export function createBrowserAuth(options) {
140
171
  url.searchParams.set('scope', scope);
141
172
  url.searchParams.set('code_challenge', await challengeFor(proof));
142
173
  url.searchParams.set('code_challenge_method', 'S256');
174
+ url.searchParams.set('state', state);
175
+ url.searchParams.set('nonce', nonce);
143
176
  if (prompt)
144
177
  url.searchParams.set('prompt', prompt);
145
178
  if (!current(generation))
@@ -240,16 +273,27 @@ export function createBrowserAuth(options) {
240
273
  if (!current(generation))
241
274
  return null;
242
275
  const proof = store.get(verifierKey);
276
+ const expectedState = store.get(stateKey);
277
+ const expectedNonce = store.get(nonceKey);
243
278
  const returnTo = store.get(returnKey) ?? '/';
279
+ // Cleared together, unconditionally, before any check below runs --
280
+ // a mismatch is refused on its own terms, not merely by virtue of a
281
+ // transaction that cannot be replayed a second time either way.
244
282
  await store.drop(verifierKey);
283
+ if (!current(generation))
284
+ return null;
285
+ await store.drop(stateKey);
286
+ if (!current(generation))
287
+ return null;
288
+ await store.drop(nonceKey);
245
289
  if (!current(generation))
246
290
  return null;
247
291
  await store.drop(returnKey);
248
- return { proof, returnTo };
292
+ return { proof, expectedState, expectedNonce, returnTo };
249
293
  });
250
294
  if (!transaction || !current(generation))
251
295
  return cancelled();
252
- const { proof, returnTo } = transaction;
296
+ const { proof, expectedState, expectedNonce, returnTo } = transaction;
253
297
  const issuerError = params.get('error');
254
298
  if (issuerError) {
255
299
  if (['login_required', 'interaction_required', 'consent_required'].includes(issuerError)) {
@@ -257,6 +301,13 @@ export function createBrowserAuth(options) {
257
301
  }
258
302
  return { kind: 'error', error: params.get('error_description') ?? issuerError };
259
303
  }
304
+ // Checked before the code exchange: a callback whose `state` does not
305
+ // match this tab's own sign-in is refused before it ever reaches the
306
+ // issuer, exactly as a cross-site request forgery on this callback
307
+ // would need it not to be.
308
+ if (params.get('state') !== expectedState) {
309
+ return { kind: 'error', error: 'the callback state does not match this sign-in' };
310
+ }
260
311
  const code = params.get('code');
261
312
  if (!code)
262
313
  return { kind: 'error', error: 'the issuer sent no code' };
@@ -293,6 +344,14 @@ export function createBrowserAuth(options) {
293
344
  return cancelled();
294
345
  if (!token.access_token)
295
346
  return { kind: 'error', error: 'the issuer returned no access token' };
347
+ // Checked against the ID token's own claim, after the exchange: a
348
+ // code this flow never requested cannot be replayed into a session
349
+ // here even if it were otherwise accepted, because the token minted
350
+ // for it carries a different nonce.
351
+ const idTokenClaims = token.id_token ? decodeJwtPayload(token.id_token) : null;
352
+ if (idTokenClaims?.nonce !== expectedNonce) {
353
+ return { kind: 'error', error: 'the ID token nonce does not match this sign-in' };
354
+ }
296
355
  const session = {
297
356
  accessToken: token.access_token,
298
357
  expiresAt: Date.now() + (token.expires_in ?? 3600) * 1000,
@@ -326,6 +385,8 @@ export function createBrowserAuth(options) {
326
385
  await mutate(async () => {
327
386
  await store.drop(tokenKey);
328
387
  await store.drop(verifierKey);
388
+ await store.drop(stateKey);
389
+ await store.drop(nonceKey);
329
390
  await store.drop(returnKey);
330
391
  await store.set(triedKey, '1');
331
392
  });
package/dist/config.d.ts CHANGED
@@ -19,6 +19,15 @@ export type AuthEvent = {
19
19
  email: string;
20
20
  userId: string;
21
21
  invited: boolean;
22
+ }
23
+ /** Never the password: an audit record of who was reset, not a copy of the credential. */
24
+ | {
25
+ type: 'adminResetPassword';
26
+ userId: string;
27
+ } | {
28
+ type: 'createTestUser';
29
+ email: string;
30
+ userId: string;
22
31
  };
23
32
  /**
24
33
  * Why `accessToken` refused: no session (no cookie in cookie mode, no live
@@ -153,3 +162,14 @@ export interface ResolvedOptions {
153
162
  onAuthEvent: ((event: AuthEvent) => void) | null;
154
163
  }
155
164
  export declare function resolveOptions(options: AuthOptions): ResolvedOptions;
165
+ /**
166
+ * The actual AES-256 key `cookies.ts` encrypts and decrypts with -- HKDF-SHA256
167
+ * over the decoded `cookieSecret`, never the secret's bytes directly. A secret an
168
+ * operator can read (an env var, a value pasted into a terminal) is weaker key
169
+ * material than a key meant to be used as one; HKDF's job is exactly this gap.
170
+ * No fallback to the raw key: decided as a hard cut (auth#255), so the only
171
+ * way a cookie sealed before this shipped keeps validating is if it already
172
+ * carries no more than its own session's worth of trust -- which is what every
173
+ * cookie here already assumes of `sessionMaxAge`/`TRANSACTION_TTL`/etc.
174
+ */
175
+ export declare function deriveCookieKey(secret: Uint8Array): Promise<Uint8Array>;
package/dist/config.js CHANGED
@@ -105,3 +105,29 @@ function decodeKey(secret) {
105
105
  }
106
106
  return bytes;
107
107
  }
108
+ // Fixed, not random: every server sealing or opening a cookie must derive
109
+ // the same AES key from the same `cookieSecret`, so there is nowhere to
110
+ // store a per-process salt even if one were generated. `COOKIE_KEY_INFO`
111
+ // scopes the derivation to this one use of the secret, the same way a
112
+ // derived key's `info` is meant to -- a future second use of `cookieSecret`
113
+ // (there is none today) would derive under a different info string and
114
+ // never collide with this key.
115
+ const COOKIE_KEY_SALT = new TextEncoder().encode('@wtfalch/auth cookie salt v1');
116
+ const COOKIE_KEY_INFO = new TextEncoder().encode('@wtfalch/auth cookie v1');
117
+ /**
118
+ * The actual AES-256 key `cookies.ts` encrypts and decrypts with -- HKDF-SHA256
119
+ * over the decoded `cookieSecret`, never the secret's bytes directly. A secret an
120
+ * operator can read (an env var, a value pasted into a terminal) is weaker key
121
+ * material than a key meant to be used as one; HKDF's job is exactly this gap.
122
+ * No fallback to the raw key: decided as a hard cut (auth#255), so the only
123
+ * way a cookie sealed before this shipped keeps validating is if it already
124
+ * carries no more than its own session's worth of trust -- which is what every
125
+ * cookie here already assumes of `sessionMaxAge`/`TRANSACTION_TTL`/etc.
126
+ */
127
+ export async function deriveCookieKey(secret) {
128
+ const keyMaterial = await crypto.subtle.importKey('raw', secret, 'HKDF', false, [
129
+ 'deriveBits',
130
+ ]);
131
+ const bits = await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: COOKIE_KEY_SALT, info: COOKIE_KEY_INFO }, keyMaterial, 256);
132
+ return new Uint8Array(bits);
133
+ }
package/dist/cookies.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { type JWTPayload } from 'jose';
2
- import type { ResolvedOptions } from './config.js';
2
+ import { type ResolvedOptions } from './config.js';
3
+ /** Exported for `namespace-server.ts`, which decrypts a transaction cookie itself to read its `ns` claim before any binding is chosen. */
4
+ export declare function cookieKeyFor(options: ResolvedOptions): Promise<Uint8Array>;
3
5
  export interface SessionPayload extends JWTPayload {
4
6
  idt: string;
5
7
  rt?: string;
package/dist/cookies.js CHANGED
@@ -1,5 +1,21 @@
1
1
  import { EncryptJWT, jwtDecrypt } from 'jose';
2
+ import { deriveCookieKey } from './config.js';
2
3
  import { AuthError } from './oidc.js';
4
+ // `options.cookieKey` decodes once per `ResolvedOptions` (config.ts) and is
5
+ // always the same `Uint8Array` reference after that, so keying the derived
6
+ // key's own cache off it means every seal/open after the first reuses it
7
+ // rather than re-running HKDF per call.
8
+ const derivedKeyCache = new WeakMap();
9
+ /** Exported for `namespace-server.ts`, which decrypts a transaction cookie itself to read its `ns` claim before any binding is chosen. */
10
+ export function cookieKeyFor(options) {
11
+ const raw = options.cookieKey;
12
+ let derived = derivedKeyCache.get(raw);
13
+ if (!derived) {
14
+ derived = deriveCookieKey(raw);
15
+ derivedKeyCache.set(raw, derived);
16
+ }
17
+ return derived;
18
+ }
3
19
  const SESSION = 'wtfalch_auth';
4
20
  const TRANSACTION = 'wtfalch_auth_tx';
5
21
  const LINK = 'wtfalch_auth_link';
@@ -152,17 +168,21 @@ async function seal(options, kind, payload, ttl) {
152
168
  .setAudience(await audience(options, kind))
153
169
  .setIssuedAt()
154
170
  .setExpirationTime(Math.floor(Date.now() / 1000) + ttl)
155
- .encrypt(options.cookieKey);
171
+ .encrypt(await cookieKeyFor(options));
156
172
  }
157
173
  async function open(options, kind, value) {
158
174
  if (!value)
159
175
  return null;
160
176
  try {
161
- const { payload } = await jwtDecrypt(value, options.cookieKey, {
177
+ const { payload } = await jwtDecrypt(value, await cookieKeyFor(options), {
162
178
  issuer: options.appUrl.origin,
163
179
  audience: await audience(options, kind),
164
180
  contentEncryptionAlgorithms: ['A256GCM'],
165
181
  keyManagementAlgorithms: ['dir'],
182
+ // Every cookie `seal` makes already sets `exp` (the call's own `ttl`),
183
+ // so requiring it here signs nobody out -- it only refuses the one
184
+ // thing a cookie with none could otherwise be: unbounded.
185
+ requiredClaims: ['exp'],
166
186
  });
167
187
  return payload;
168
188
  }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, ty
2
2
  export type { AccessTokenReason, AccessTokenResult, AuthEvent, AuthOptions } from './config.js';
3
3
  export type { SetCookie } from './cookies.js';
4
4
  export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.js';
5
+ export type { PersonTokenConfig, PersonTokenVerifier } from './person-token.js';
5
6
  export { BrokerError } from './broker.js';
6
7
  export { AuthError, type AuthErrorReason, ORG_CLAIM } from './oidc.js';
7
8
  export { safeNextPath } from './redirect.js';
@@ -1,6 +1,6 @@
1
1
  import { jwtDecrypt } from 'jose';
2
2
  import { resolveOptions } from './config.js';
3
- import { cookieFrom, openTransaction, transactionCookieName } from './cookies.js';
3
+ import { cookieFrom, cookieKeyFor, openTransaction, transactionCookieName } from './cookies.js';
4
4
  import { isWebService, parseNamespaceRegistry, } from './namespaces.js';
5
5
  /** Resolve a shared callback from its authenticated transaction, never a URL
6
6
  * namespace hint. The second open checks the entire current registry binding. */
@@ -46,7 +46,7 @@ export async function resolveNamespaceCallback(input) {
46
46
  return null;
47
47
  // No identity is returned based on this hint alone. It only chooses which
48
48
  // current binding's audience to validate next, so decryption is bounded.
49
- const { payload } = await jwtDecrypt(value, first.cookieKey, {
49
+ const { payload } = await jwtDecrypt(value, await cookieKeyFor(first), {
50
50
  issuer: input.appOrigin,
51
51
  requiredClaims: ['exp', 'iat', 'aud'],
52
52
  contentEncryptionAlgorithms: ['A256GCM'],
@@ -1,3 +1,4 @@
1
+ import { sleep } from '@wtfalch/utils/datetime';
1
2
  import * as client from 'openid-client';
2
3
  import { BrokerError } from './broker.js';
3
4
  import { contextDigest, digest, openHandle, openRecord, sealHandle, sealRecord, } from './cookies.js';
@@ -15,7 +16,6 @@ const LEASE_SECONDS = 60;
15
16
  const WAIT_STEP_MS = 100;
16
17
  const WAIT_STEPS = 50;
17
18
  const now = () => Math.floor(Date.now() / 1000);
18
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
19
19
  /**
20
20
  * Server-side sessions for namespace mode. The cookie holds a random handle;
21
21
  * the record holds the tokens. Refreshes are serialised across replicas by a
package/dist/next.d.ts CHANGED
@@ -95,14 +95,18 @@ export interface NextAuth {
95
95
  givenName: string;
96
96
  familyName: string;
97
97
  next?: string;
98
+ personalEmail?: string;
98
99
  }) => Promise<{
99
100
  userId: string;
100
101
  invited: boolean;
102
+ personalEmailStored: boolean;
101
103
  }>;
102
104
  /** The organisation's people, for an operator surface. Scoped to this app's organisation by the service. */
103
105
  people: Auth['people'];
104
106
  /** Deactivate or reactivate somebody in this app's organisation. */
105
107
  setPersonActive: Auth['setPersonActive'];
108
+ /** An admin's reset for somebody in this app's organisation whom requestPasswordReset cannot otherwise get back in. */
109
+ adminResetPassword: Auth['adminResetPassword'];
106
110
  /** The devices page: the signed-in person's own live sessions, newest first. Namespace mode only. */
107
111
  sessions: () => Promise<SessionRecord[]>;
108
112
  /**
package/dist/next.js CHANGED
@@ -272,6 +272,7 @@ export function nextAuth(options) {
272
272
  invite: auth.invite,
273
273
  people: auth.people,
274
274
  setPersonActive: auth.setPersonActive,
275
+ adminResetPassword: auth.adminResetPassword,
275
276
  sessions: sessionsList,
276
277
  endSession,
277
278
  resetPassword,