@wtfalch/auth 0.20.0 → 0.23.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
@@ -257,7 +257,7 @@ refresh token to begin with.
257
257
  | `appUrl` | This app's origin. Every URL the SDK builds starts here, never from the request's `Host`. |
258
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. |
259
259
  | `organizationId` | Scopes every sign-in, and is checked on every token. |
260
- | `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. |
261
261
  | `appKey` | This app's key for the sign-in service. Needed for anything that checks or creates a credential. |
262
262
 
263
263
  Values are read on first use rather than at import, so `next build` needs none
@@ -269,6 +269,13 @@ unset: the error waits until something actually reaches for the key. A secret
269
269
  that is *supplied and wrong* still throws at startup, because that is a typo
270
270
  rather than a deployment that does not need one.
271
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
+
272
279
  ## What the app key can do
273
280
 
274
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. */
@@ -280,6 +292,20 @@ export interface Auth {
280
292
  invited: boolean;
281
293
  personalEmailStored: boolean;
282
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;
308
+ }>;
283
309
  handle(request: Request): Promise<Response>;
284
310
  /**
285
311
  * `refresh: false` for callers that cannot set cookies; they get a user only while the token is valid.
package/dist/auth.js CHANGED
@@ -539,6 +539,11 @@ 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
+ };
542
547
  /**
543
548
  * An admin's reset for somebody in this app's own organisation whom
544
549
  * `requestPasswordReset` cannot otherwise get back in -- nobody at the
@@ -670,6 +675,36 @@ export function createAuth(input) {
670
675
  return { ok: false, error: code, message };
671
676
  }
672
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
+ };
673
708
  const signUp = async ({ authRequestId, ...user }) => {
674
709
  try {
675
710
  const redirectTo = await broker().signUp({ authRequestId, ...user });
@@ -936,6 +971,7 @@ export function createAuth(input) {
936
971
  resetPassword,
937
972
  sendLink,
938
973
  invite,
974
+ createTestUser,
939
975
  handle,
940
976
  read,
941
977
  readCookie,
@@ -954,6 +990,7 @@ export function createAuth(input) {
954
990
  setNewPassword,
955
991
  setNewPasswordHeadless,
956
992
  signInWithPassword,
993
+ signUpWithPassword,
957
994
  signUp,
958
995
  startPasskeyRegistration,
959
996
  finishPasskeyRegistration,
package/dist/broker.d.ts CHANGED
@@ -137,6 +137,15 @@ export declare class Broker {
137
137
  personalEmailStored: boolean;
138
138
  }>;
139
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
+ }>;
140
149
  sendReset(email: string, next: string): Promise<unknown>;
141
150
  sendVerification(email: string, next: string): Promise<unknown>;
142
151
  /**
package/dist/broker.js CHANGED
@@ -118,6 +118,10 @@ export class Broker {
118
118
  sendLink(email, next) {
119
119
  return this.call('POST', '/link/send', { email, next });
120
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
+ }
121
125
  sendReset(email, next) {
122
126
  return this.call('POST', '/reset/send', { email, next });
123
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
@@ -24,6 +24,10 @@ export type AuthEvent = {
24
24
  | {
25
25
  type: 'adminResetPassword';
26
26
  userId: string;
27
+ } | {
28
+ type: 'createTestUser';
29
+ email: string;
30
+ userId: string;
27
31
  };
28
32
  /**
29
33
  * Why `accessToken` refused: no session (no cookie in cookie mode, no live
@@ -158,3 +162,14 @@ export interface ResolvedOptions {
158
162
  onAuthEvent: ((event: AuthEvent) => void) | null;
159
163
  }
160
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';
package/dist/names.d.ts CHANGED
@@ -38,6 +38,14 @@ export interface NameRules {
38
38
  pattern: RegExp;
39
39
  }
40
40
  export declare const DEFAULT_RULES: NameRules;
41
+ /**
42
+ * Lowercase letters with the digits read as letters and nothing else, runs
43
+ * collapsed. Every reading, since `1` may be an `i` or an `l`; the first is
44
+ * the one a list entry is stored under.
45
+ */
46
+ export declare function flattenings(name: string): string[];
47
+ /** Every entry checkName holds back: the system names, the people's and the reserved-usernames list, once each. */
48
+ export declare function reservedNames(): readonly string[];
41
49
  /** The refusal as a sentence the person can act on. */
42
50
  export declare function explain(reason: Refusal, rules?: NameRules): string;
43
51
  export declare function checkName(name: string, rules?: NameRules): Verdict;
package/dist/names.js CHANGED
@@ -35,7 +35,10 @@ strictWords.addPhrase((phrase) => {
35
35
  phrase.addWhitelistedTerm(term);
36
36
  return phrase;
37
37
  });
38
- const strict = new RegExpMatcher({ ...strictWords.build(), ...englishRecommendedTransformers });
38
+ const strict = new RegExpMatcher({
39
+ ...strictWords.build(),
40
+ ...englishRecommendedTransformers,
41
+ });
39
42
  /** Only the words that are slurs wherever they appear, read with everything between the letters skipped. */
40
43
  const looseWords = new DataSet();
41
44
  for (const entry of [
@@ -84,7 +87,7 @@ const LETTERS = {
84
87
  * collapsed. Every reading, since `1` may be an `i` or an `l`; the first is
85
88
  * the one a list entry is stored under.
86
89
  */
87
- function flattenings(name) {
90
+ export function flattenings(name) {
88
91
  let forms = [''];
89
92
  for (const char of name.toLowerCase()) {
90
93
  const readings = /[a-z]/.test(char) ? [char] : (LETTERS[char] ?? []);
@@ -96,7 +99,12 @@ function flattenings(name) {
96
99
  }
97
100
  return [...new Set(forms.map((form) => form.replace(/(.)\1+/g, '$1')))];
98
101
  }
99
- const reserved = new Set([...reservedUsernames, ...SYSTEM, ...PEOPLE].map((entry) => flattenings(entry)[0]));
102
+ const RESERVED = [...new Set([...reservedUsernames, ...SYSTEM, ...PEOPLE])];
103
+ const reserved = new Set(RESERVED.map((entry) => flattenings(entry)[0]));
104
+ /** Every entry checkName holds back: the system names, the people's and the reserved-usernames list, once each. */
105
+ export function reservedNames() {
106
+ return RESERVED;
107
+ }
100
108
  /** The name in the shapes a word might hide in. */
101
109
  function shapes(name) {
102
110
  const parts = name.split(/[^A-Za-z0-9æøåÆØÅ]+/).filter(Boolean);
@@ -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
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Verifies a signed-in person's own `@wtfalch/auth`/ZITADEL access token,
3
+ * server-side, for a service that wants to accept one directly rather than
4
+ * only a machine credential. Two token shapes, both checked:
5
+ *
6
+ * - A JWT (three base64url segments): `jwtVerify` against a remote JWKS
7
+ * built from the issuer's discovery document -- signature (RS256),
8
+ * issuer, audience, expiry. `jose`'s `createRemoteJWKSet` refetches on an
9
+ * unrecognised `kid` on its own, so key rotation needs nothing here
10
+ * beyond the cooldown below.
11
+ * - Anything else: RFC 7662 introspection against the issuer's
12
+ * `introspection_endpoint`, authenticating as this resource server with
13
+ * `client_secret_basic` (`introspectionClientId`/`Secret`), checking the
14
+ * response's `active`, `exp` and `aud`. Not attempted -- refused outright
15
+ * -- when either credential is unset, the same as an opaque token would
16
+ * have refused before introspection existed. This matters because some
17
+ * apps mint an *opaque* access token on purpose, and without this path no
18
+ * such token could ever reach a server that only checked JWTs.
19
+ *
20
+ * Neither path checks a fixed ZITADEL organisation: a caller can come from
21
+ * any organisation's app, so there is nothing fixed to compare against.
22
+ * This module only answers "is this a live token, and whose" -- membership
23
+ * and authorization are the caller's own concern, same as for a machine
24
+ * credential.
25
+ *
26
+ * A network failure or a non-2xx from either the discovery document or the
27
+ * introspection endpoint fails closed: `verify` returns `null`, exactly
28
+ * like a bad token, never treated as good. There is no second outcome for
29
+ * "could not check" versus "checked and refused" -- a caller has only one
30
+ * response to either, refuse.
31
+ */
32
+ export interface PersonTokenVerifier {
33
+ /** The token's subject (`sub`), or null for anything that does not stand
34
+ * up: bad signature, wrong issuer/audience, expired, inactive, or
35
+ * unreachable. Refusals never say which. */
36
+ verify(accessToken: string): Promise<{
37
+ sub: string;
38
+ } | null>;
39
+ }
40
+ export interface PersonTokenConfig {
41
+ /** The issuer every token must have been minted by, e.g.
42
+ * `https://auth.wtfalch.dev`. */
43
+ issuer: string;
44
+ /** The ZITADEL project id a person's token must carry in `aud` --
45
+ * requested by whatever app mints the token (an `extraScopes` entry of
46
+ * `urn:zitadel:iam:org:project:id:<audience>:aud`). */
47
+ audience: string;
48
+ /** This resource server's own credentials at the issuer, for RFC 7662
49
+ * introspection of an opaque (non-JWT) access token --
50
+ * `client_secret_basic`. Both required together; either absent disables
51
+ * introspection and an opaque bearer is refused outright. */
52
+ introspectionClientId?: string;
53
+ introspectionClientSecret?: string;
54
+ /** Test seam; a live deployment never sets this. */
55
+ fetch?: typeof fetch;
56
+ /** jose's default 30s would keep rejecting a token signed with a freshly
57
+ * rotated key for that long; a test passes 0 so it need not sleep past a
58
+ * real cooldown. */
59
+ jwksCooldownMs?: number;
60
+ }
61
+ export declare function createPersonTokenVerifier(config: PersonTokenConfig): PersonTokenVerifier;