@wtfalch/auth 0.24.0 → 0.25.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/dist/auth.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type JWTPayload } from 'jose';
1
+ import type { JWTPayload } from 'jose';
2
2
  import { type AuthRequest, type NewUser, type PeoplePage } from './broker.js';
3
3
  import { type AccessTokenResult, type AuthOptions } from './config.js';
4
4
  import { type SetCookie } from './cookies.js';
@@ -28,7 +28,7 @@ export interface User {
28
28
  claims: JWTPayload;
29
29
  }
30
30
  export interface ReadResult {
31
- /** A cookie exists for another binding/revision or cannot be opened. Do not auto-switch. */
31
+ /** @deprecated Never set. A cookie that cannot be opened (another binding's, an older revision's) reads as no session. */
32
32
  accountChanged?: true;
33
33
  /** Namespace mode: the binding is suspended or its status cannot be confirmed. Do not start a sign-in. */
34
34
  unavailable?: true;
@@ -287,6 +287,8 @@ export interface Auth {
287
287
  givenName: string;
288
288
  familyName: string;
289
289
  next?: string;
290
+ /** See `NewUser['personalEmail']`. */
291
+ personalEmail?: string;
290
292
  }): Promise<{
291
293
  userId: string;
292
294
  invited: boolean;
@@ -419,8 +421,8 @@ export interface Auth {
419
421
  /**
420
422
  * Starts registering a passkey on this app's own domain, for whoever is
421
423
  * signed in on `request` -- never for a stranger. Throws `AuthError` when
422
- * nobody is: `account_changed`/`unavailable` exactly as `read` reports
423
- * them, `unauthenticated` otherwise.
424
+ * nobody is: `unavailable` exactly as `read` reports it,
425
+ * `unauthenticated` otherwise.
424
426
  */
425
427
  startPasskeyRegistration(request: Request): Promise<PasskeyRegistrationStart>;
426
428
  /** The second half of `startPasskeyRegistration`. Same signed-in requirement. */
package/dist/auth.js CHANGED
@@ -1,12 +1,12 @@
1
- import { errors } from 'jose';
2
1
  import * as client from 'openid-client';
3
2
  import { Broker, BrokerError } from './broker.js';
4
3
  import { resolveOptions, } from './config.js';
5
4
  import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openRecord, openSession, openSilent, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealSilent, sealTransaction, sessionCookieName, silentCookieName, transactionCookieName, } from './cookies.js';
6
5
  import { namespaceSessions } from './namespace-session.js';
7
- import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, scopeFor } from './oidc.js';
6
+ import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, rejected, scopeFor, } from './oidc.js';
8
7
  import { pollDeviceToken, requestDeviceAuthorization } from './qr.js';
9
8
  import { safeLoginHint, safeNextPath } from './redirect.js';
9
+ import { expiryOf, issuedAtOf, timeoutSignal } from './util.js';
10
10
  export function createAuth(input) {
11
11
  // Resolved on first use so `next build`, which imports every route module, needs none of the values set.
12
12
  let resolved = null;
@@ -77,9 +77,19 @@ export function createAuth(input) {
77
77
  return brokerInstance;
78
78
  };
79
79
  const sessions = namespaceSessions({ options, oidc, broker });
80
- const sessionCookie = (tokens, requestHeaders) => options().namespace
81
- ? sessions.create(tokens, requestHeaders)
82
- : sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken });
80
+ // Every way in ends here, so this is the one place that reports a sign-in.
81
+ const sessionCookie = async (tokens, requestHeaders) => {
82
+ const cookie = options().namespace
83
+ ? await sessions.create(tokens, requestHeaders)
84
+ : await sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken });
85
+ const email = tokens.claims.email;
86
+ emit({
87
+ type: 'signIn',
88
+ email: typeof email === 'string' ? email : '',
89
+ userId: String(tokens.claims.sub),
90
+ });
91
+ return cookie;
92
+ };
83
93
  /**
84
94
  * Signing in again replaces this binding's previous session outright: in
85
95
  * namespace mode, ends whatever session record `cookieHeader` already
@@ -268,9 +278,7 @@ export function createAuth(input) {
268
278
  // `sessions.end` reaches the store, which can be briefly unreachable --
269
279
  // and a sign-out that 500s over that leaves the browser's own cookie
270
280
  // standing, which is worse than the record surviving a moment longer.
271
- const status = await sessions.end(value).catch(() => 'ended');
272
- if (status === 'changed')
273
- return new Response('Account changed', { status: 409 });
281
+ await sessions.end(value).catch(() => { });
274
282
  return endIssuerSession(idToken, [clearSession(options())]);
275
283
  }
276
284
  const session = await openSession(options(), value);
@@ -335,8 +343,6 @@ export function createAuth(input) {
335
343
  const result = await sessions.read(value);
336
344
  if (result.kind === 'user')
337
345
  return { user: boundUser(result.claims), cookies: [] };
338
- if (result.kind === 'changed')
339
- return { user: null, cookies: [], accountChanged: true };
340
346
  if (result.kind === 'unavailable')
341
347
  return { user: null, cookies: [], unavailable: true };
342
348
  return { user: null, cookies: [] };
@@ -389,11 +395,9 @@ export function createAuth(input) {
389
395
  const isPublic = url.pathname === options().basePath ||
390
396
  url.pathname.startsWith(`${options().basePath}/`) ||
391
397
  matches(rules.public, url.pathname);
392
- const { user, cookies, accountChanged, unavailable } = await read(request);
398
+ const { user, cookies, unavailable } = await read(request);
393
399
  if (user || isPublic)
394
400
  return { kind: 'next', user, cookies };
395
- if (accountChanged)
396
- return { kind: 'deny', cookies, reason: 'account_changed' };
397
401
  if (unavailable)
398
402
  return { kind: 'deny', cookies, reason: 'unavailable' };
399
403
  if (!isNavigation(request))
@@ -411,13 +415,11 @@ export function createAuth(input) {
411
415
  /**
412
416
  * The person signed in on `request`, for the passkey routes -- never a
413
417
  * stranger. Adapts `requireUser`'s reasons for a caller that throws rather
414
- * than redirects: `account_changed`/`unavailable` are read's own, and
418
+ * than redirects: `unavailable` is read's own, and
415
419
  * plain absence becomes `unauthenticated`, which read has no reason for.
416
420
  */
417
421
  const currentUser = async (request) => {
418
- const { user, accountChanged, unavailable } = await read(request, { refresh: false });
419
- if (accountChanged)
420
- throw new AuthError('account_changed', 'Choose an account to continue');
422
+ const { user, unavailable } = await read(request, { refresh: false });
421
423
  if (unavailable)
422
424
  throw new AuthError('unavailable', 'Sign-in is unavailable for this workspace');
423
425
  if (!user)
@@ -484,8 +486,13 @@ export function createAuth(input) {
484
486
  const nonce = client.randomNonce();
485
487
  const verifier = client.randomPKCECodeVerifier();
486
488
  const target = await oidc().authorizationUrl(state, nonce, verifier);
487
- const response = await options().fetch(target.href, { redirect: 'manual' });
489
+ const response = await options().fetch(target.href, {
490
+ redirect: 'manual',
491
+ signal: timeoutSignal(),
492
+ });
488
493
  const location = response.headers.get('location');
494
+ // Only the Location header is wanted; an unread body would hold the socket.
495
+ response.body?.cancel().catch(() => undefined);
489
496
  if (options().namespace &&
490
497
  location &&
491
498
  new URL(location, options().issuer).origin !== options().namespace?.loginOrigin)
@@ -542,7 +549,13 @@ export function createAuth(input) {
542
549
  const requestPasswordReset = (email, next) => quietly(() => broker().sendReset(email, safeNextPath(next, '')));
543
550
  const sendLink = (email, next) => quietly(() => broker().sendLink(email, safeNextPath(next, '')));
544
551
  const invite = async (input) => {
545
- const result = await broker().invite({ ...input, next: safeNextPath(input.next, '') });
552
+ const result = await broker().invite({
553
+ email: input.email,
554
+ givenName: input.givenName,
555
+ familyName: input.familyName,
556
+ next: safeNextPath(input.next, ''),
557
+ personalEmail: input.personalEmail,
558
+ });
546
559
  emit({ type: 'invite', email: input.email, userId: result.userId, invited: result.invited });
547
560
  return result;
548
561
  };
@@ -618,14 +631,11 @@ export function createAuth(input) {
618
631
  const signIn = async ({ authRequestId, email, password, }) => {
619
632
  try {
620
633
  const redirectTo = await broker().signIn({ authRequestId, email, password });
621
- emit({ type: 'signIn', email });
622
634
  return { ok: true, redirectTo };
623
635
  }
624
636
  catch (error) {
625
- if (needsHostedLogin(error)) {
626
- emit({ type: 'signIn', email });
637
+ if (needsHostedLogin(error))
627
638
  return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
628
- }
629
639
  const change = changeRequired(error);
630
640
  if (change)
631
641
  return change;
@@ -664,7 +674,6 @@ export function createAuth(input) {
664
674
  try {
665
675
  const flow = await serverFlow();
666
676
  const completed = await finishOrHandoff(() => broker().signIn({ authRequestId: flow.id, email, password }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
667
- emit({ type: 'signIn', email });
668
677
  return { ok: true, ...completed };
669
678
  }
670
679
  catch (error) {
@@ -690,26 +699,7 @@ export function createAuth(input) {
690
699
  return { ok: true, ...completed };
691
700
  }
692
701
  catch (error) {
693
- if (error instanceof BrokerError && error.error === 'email_taken') {
694
- return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
695
- }
696
- if (error instanceof BrokerError && error.error === 'registration_closed') {
697
- return { ok: false, error: 'registration_closed', message: error.detail ?? error.error };
698
- }
699
- // Both carry a sentence about the name, for the form to show as it is.
700
- if (error instanceof BrokerError && error.error === 'username_taken') {
701
- return { ok: false, error: 'username_taken', message: error.detail ?? error.error };
702
- }
703
- if (error instanceof BrokerError && error.error === 'username_invalid') {
704
- return { ok: false, error: 'username_invalid', message: error.detail ?? error.error };
705
- }
706
- if (error instanceof BrokerError && error.error === 'request') {
707
- return { ok: false, error: 'request', message: error.detail ?? error.error };
708
- }
709
- if (error instanceof BrokerError && error.status < 500) {
710
- return { ok: false, error: 'invalid', message: error.detail ?? error.error };
711
- }
712
- return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
702
+ return signUpFailure(error);
713
703
  }
714
704
  };
715
705
  const signUp = async ({ authRequestId, ...user }) => {
@@ -726,26 +716,7 @@ export function createAuth(input) {
726
716
  const change = changeRequired(error);
727
717
  if (change)
728
718
  return { ...change, message: 'choose a new password to continue' };
729
- if (error instanceof BrokerError && error.error === 'email_taken') {
730
- return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
731
- }
732
- if (error instanceof BrokerError && error.error === 'registration_closed') {
733
- return { ok: false, error: 'registration_closed', message: error.detail ?? error.error };
734
- }
735
- // Both carry a sentence about the name, for the form to show as it is.
736
- if (error instanceof BrokerError && error.error === 'username_taken') {
737
- return { ok: false, error: 'username_taken', message: error.detail ?? error.error };
738
- }
739
- if (error instanceof BrokerError && error.error === 'username_invalid') {
740
- return { ok: false, error: 'username_invalid', message: error.detail ?? error.error };
741
- }
742
- if (error instanceof BrokerError && error.error === 'request') {
743
- return { ok: false, error: 'request', message: error.detail ?? error.error };
744
- }
745
- if (error instanceof BrokerError && error.status < 500) {
746
- return { ok: false, error: 'invalid', message: error.detail ?? error.error };
747
- }
748
- return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
719
+ return signUpFailure(error);
749
720
  }
750
721
  };
751
722
  const startPasskeyRegistration = async (request) => {
@@ -806,7 +777,11 @@ export function createAuth(input) {
806
777
  const changePassword = async (request, input) => {
807
778
  const user = await currentFreshUser(request);
808
779
  try {
809
- await broker().changePassword({ userId: user.id, ...input });
780
+ await broker().changePassword({
781
+ userId: user.id,
782
+ currentPassword: input.currentPassword,
783
+ newPassword: input.newPassword,
784
+ });
810
785
  return { ok: true };
811
786
  }
812
787
  catch (error) {
@@ -862,7 +837,7 @@ export function createAuth(input) {
862
837
  if (options().namespace && !(await sessions.bindingActive()))
863
838
  throw new AuthError('unavailable', 'the namespace binding is not active');
864
839
  const device = await requestDeviceAuthorization(options(), scopeFor(options().organizationId, options().extraScopes));
865
- const cookie = await sealQrTransaction(options(), { dc: device.deviceCode, iv: device.interval }, device.expiresIn);
840
+ const cookie = await sealQrTransaction(options(), { dc: device.deviceCode }, device.expiresIn);
866
841
  return {
867
842
  url: qrVerificationUrl(options(), device.verificationUriComplete),
868
843
  userCode: device.userCode,
@@ -1032,28 +1007,6 @@ function matches(rule, pathname) {
1032
1007
  return pathname === entry;
1033
1008
  });
1034
1009
  }
1035
- function expiryOf(idToken) {
1036
- try {
1037
- const [, payload] = idToken.split('.');
1038
- const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
1039
- return typeof json.exp === 'number' ? json.exp : null;
1040
- }
1041
- catch {
1042
- return null;
1043
- }
1044
- }
1045
- // Age, not time left: a disabled or revoked account must not ride out a
1046
- // long-lived token just because it still has plenty of time on the clock.
1047
- function issuedAtOf(idToken) {
1048
- try {
1049
- const [, payload] = idToken.split('.');
1050
- const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
1051
- return typeof json.iat === 'number' ? json.iat : null;
1052
- }
1053
- catch {
1054
- return null;
1055
- }
1056
- }
1057
1010
  const PROMPTS = ['select_account', 'login'];
1058
1011
  function promptFrom(raw) {
1059
1012
  return PROMPTS.includes(raw ?? '') ? raw : undefined;
@@ -1117,15 +1070,6 @@ function userFrom(claims) {
1117
1070
  };
1118
1071
  }
1119
1072
  // The token itself is bad, as opposed to the keys being unreachable or rotating.
1120
- function rejected(error) {
1121
- return (error instanceof AuthError ||
1122
- error instanceof errors.JWTExpired ||
1123
- error instanceof errors.JWTClaimValidationFailed ||
1124
- error instanceof errors.JWSSignatureVerificationFailed ||
1125
- error instanceof errors.JWSInvalid ||
1126
- error instanceof errors.JWTInvalid ||
1127
- error instanceof errors.JOSEAlgNotAllowed);
1128
- }
1129
1073
  function isNavigation(request) {
1130
1074
  const mode = request.headers.get('sec-fetch-mode');
1131
1075
  if (mode && mode !== 'navigate')
@@ -1179,6 +1123,23 @@ function signInError(error) {
1179
1123
  return 'invalid_credentials';
1180
1124
  return 'unavailable';
1181
1125
  }
1126
+ /** The sign-up refusals the broker can name, each with its own sentence for the form to show as it is. */
1127
+ function signUpFailure(error) {
1128
+ if (error instanceof BrokerError) {
1129
+ const message = error.detail ?? error.error;
1130
+ switch (error.error) {
1131
+ case 'email_taken':
1132
+ case 'registration_closed':
1133
+ case 'username_taken':
1134
+ case 'username_invalid':
1135
+ case 'request':
1136
+ return { ok: false, error: error.error, message };
1137
+ }
1138
+ if (error.status < 500)
1139
+ return { ok: false, error: 'invalid', message };
1140
+ }
1141
+ return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
1142
+ }
1182
1143
  function reasonOf(error) {
1183
1144
  if (error instanceof AuthError)
1184
1145
  return error.reason;
package/dist/broker.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { timeoutSignal } from './util.js';
1
2
  export class BrokerError extends Error {
2
3
  status;
3
4
  error;
@@ -34,7 +35,7 @@ export class Broker {
34
35
  url.searchParams.set(k, String(v));
35
36
  const response = await this.options.fetch(url.href, {
36
37
  method,
37
- signal,
38
+ signal: timeoutSignal(signal),
38
39
  headers: {
39
40
  authorization: `Bearer ${this.options.appKey}`,
40
41
  'content-type': 'application/json',
@@ -84,8 +85,16 @@ export class Broker {
84
85
  return callbackUrl;
85
86
  }
86
87
  async signUp(input) {
88
+ // Named fields: `input` can come from a form a client controls.
87
89
  const { callbackUrl } = await this.call('POST', '/sign-up', {
88
- ...input,
90
+ authRequestId: input.authRequestId,
91
+ email: input.email,
92
+ password: input.password,
93
+ givenName: input.givenName,
94
+ familyName: input.familyName,
95
+ username: input.username,
96
+ changeRequired: input.changeRequired,
97
+ personalEmail: input.personalEmail,
89
98
  });
90
99
  return callbackUrl;
91
100
  }
@@ -113,7 +122,13 @@ export class Broker {
113
122
  return callbackUrl;
114
123
  }
115
124
  invite(input) {
116
- return this.call('POST', '/invite', input);
125
+ return this.call('POST', '/invite', {
126
+ email: input.email,
127
+ givenName: input.givenName,
128
+ familyName: input.familyName,
129
+ next: input.next,
130
+ personalEmail: input.personalEmail,
131
+ });
117
132
  }
118
133
  sendLink(email, next) {
119
134
  return this.call('POST', '/link/send', { email, next });
@@ -148,7 +163,11 @@ export class Broker {
148
163
  return this.call('POST', '/email/change/complete', { userId, code });
149
164
  }
150
165
  changePassword(input) {
151
- return this.call('POST', '/password/change', input);
166
+ return this.call('POST', '/password/change', {
167
+ userId: input.userId,
168
+ currentPassword: input.currentPassword,
169
+ newPassword: input.newPassword,
170
+ });
152
171
  }
153
172
  passkeys(userId) {
154
173
  return this.call('GET', '/passkeys', { userId });
@@ -158,14 +177,26 @@ export class Broker {
158
177
  }
159
178
  /** A public client revoking its own token: no key, no service. */
160
179
  async revoke(refreshToken) {
161
- await this.options.fetch(`${this.options.issuer}/oauth/v2/revoke`, {
162
- method: 'POST',
163
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
164
- body: new URLSearchParams({
165
- token: refreshToken,
166
- token_type_hint: 'refresh_token',
167
- client_id: this.options.clientId,
168
- }),
169
- });
180
+ let response;
181
+ try {
182
+ response = await this.options.fetch(`${this.options.issuer}/oauth/v2/revoke`, {
183
+ method: 'POST',
184
+ signal: timeoutSignal(),
185
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
186
+ body: new URLSearchParams({
187
+ token: refreshToken,
188
+ token_type_hint: 'refresh_token',
189
+ client_id: this.options.clientId,
190
+ }),
191
+ });
192
+ }
193
+ catch (error) {
194
+ console.error('@wtfalch/auth: the revoke request failed', error instanceof Error ? error.name : 'unknown');
195
+ throw error;
196
+ }
197
+ if (!response.ok) {
198
+ console.error('@wtfalch/auth: the issuer refused to revoke a refresh token', response.status);
199
+ throw new BrokerError(response.status, 'unavailable', 'the issuer did not revoke the token');
200
+ }
170
201
  }
171
202
  }
package/dist/browser.d.ts CHANGED
@@ -125,8 +125,9 @@ export type RefreshOutcome = {
125
125
  kind: 'no-refresh-token';
126
126
  }
127
127
  /**
128
- * The issuer refused rotation, or this operation was cancelled. An issuer
129
- * refusal drops the held session because the old refresh token was spent.
128
+ * The issuer refused rotation, or this operation was cancelled. A 4xx other
129
+ * than 429 drops the held session because the old refresh token was spent;
130
+ * a 429 or 5xx keeps it.
130
131
  * Cancellation discards the old response and preserves any newer session.
131
132
  */
132
133
  | {
package/dist/browser.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createNamespaceBrowserAuth } from './namespace-browser.js';
2
2
  import { safeLoginHint } from './redirect.js';
3
+ import { base64url, decodeJwtPayload, timeoutSignal } from './util.js';
3
4
  const TOKEN = 'wtfalch.auth.token';
4
5
  const VERIFIER = 'wtfalch.auth.verifier';
5
6
  /** CSRF protection on the callback: checked before the code exchange, never persisted past it. */
@@ -37,12 +38,6 @@ const defaultStore = {
37
38
  }
38
39
  },
39
40
  };
40
- function base64url(bytes) {
41
- return btoa(String.fromCharCode(...new Uint8Array(bytes)))
42
- .replace(/\+/g, '-')
43
- .replace(/\//g, '_')
44
- .replace(/=+$/, '');
45
- }
46
41
  async function challengeFor(verifier) {
47
42
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
48
43
  return base64url(digest);
@@ -53,22 +48,6 @@ function verifier() {
53
48
  // too -- a random, unguessable token is exactly what each of the three is.
54
49
  return base64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
55
50
  }
56
- /** The payload of a JWT, read without verifying its signature -- the ID token here comes straight
57
- * from the token endpoint over TLS, which OIDC Core 1.0 3.1.3.7 allows in place of a signature
58
- * check for the authorization code flow. Never used to trust a claim from anywhere else. */
59
- function decodeJwtPayload(jwt) {
60
- try {
61
- const part = jwt.split('.')[1];
62
- if (!part)
63
- return null;
64
- const base64 = part.replace(/-/g, '+').replace(/_/g, '/');
65
- const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
66
- return JSON.parse(atob(padded));
67
- }
68
- catch {
69
- return null;
70
- }
71
- }
72
51
  function publicSession(session) {
73
52
  const { refreshToken, ...rest } = session;
74
53
  return rest;
@@ -212,6 +191,7 @@ export function createBrowserAuth(options) {
212
191
  return { kind: 'no-refresh-token' };
213
192
  const response = await fetch(`${options.issuer}/oauth/v2/token`, {
214
193
  method: 'POST',
194
+ signal: timeoutSignal(),
215
195
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
216
196
  body: new URLSearchParams({
217
197
  grant_type: 'refresh_token',
@@ -223,10 +203,13 @@ export function createBrowserAuth(options) {
223
203
  return cancelledRefresh();
224
204
  if (!response.ok) {
225
205
  const body = await response.text();
206
+ // A 5xx or 429 says the issuer is struggling, not that the grant is gone: keep the session for the next try.
207
+ const definitive = response.status >= 400 && response.status < 500 && response.status !== 429;
226
208
  return mutate(async () => {
227
209
  if (!current(generation))
228
210
  return cancelledRefresh();
229
- await store.drop(tokenKey);
211
+ if (definitive)
212
+ await store.drop(tokenKey);
230
213
  if (!current(generation))
231
214
  return cancelledRefresh();
232
215
  return {
@@ -322,6 +305,7 @@ export function createBrowserAuth(options) {
322
305
  };
323
306
  const response = await fetch(`${options.issuer}/oauth/v2/token`, {
324
307
  method: 'POST',
308
+ signal: timeoutSignal(),
325
309
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
326
310
  body: new URLSearchParams({
327
311
  grant_type: 'authorization_code',
package/dist/config.d.ts CHANGED
@@ -8,9 +8,12 @@ import type { SessionStore } from './sessions.js';
8
8
  * to offer there; `invite` calls the broker's own endpoint, which returns
9
9
  * one.
10
10
  */
11
- export type AuthEvent = {
11
+ export type AuthEvent =
12
+ /** A session was established, by any method: password, passkey, link, reset, QR or the hosted login. `email` is empty when the id token carries none. */
13
+ {
12
14
  type: 'signIn';
13
15
  email: string;
16
+ userId: string;
14
17
  } | {
15
18
  type: 'signUp';
16
19
  email: string;
package/dist/cookies.d.ts CHANGED
@@ -32,10 +32,9 @@ export interface LinkPayload extends JWTPayload {
32
32
  stk: string;
33
33
  ar: string;
34
34
  }
35
- /** The QR device-grant transaction: the device_code, never handed to the caller, and the interval the issuer asked us to poll at. Its own `exp` -- set to the issuer's `expires_in` -- is what makes an unsealed-past-expiry cookie fail open with the others below. */
35
+ /** The QR device-grant transaction: the device_code, never handed to the caller, and nothing else: the issuer answers `slow_down` to a client that polls too fast, and `pollQrSignIn` passes that on. Its own `exp` -- set to the issuer's `expires_in` -- is what makes an unsealed-past-expiry cookie fail open with the others below. */
36
36
  export interface QrTransactionPayload extends JWTPayload {
37
37
  dc: string;
38
- iv: number;
39
38
  }
40
39
  export interface SetCookie {
41
40
  name: string;
package/dist/cookies.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { EncryptJWT, jwtDecrypt } from 'jose';
2
2
  import { deriveCookieKey } from './config.js';
3
3
  import { AuthError } from './oidc.js';
4
+ import { base64url } from './util.js';
4
5
  // `options.cookieKey` decodes once per `ResolvedOptions` (config.ts) and is
5
6
  // always the same `Uint8Array` reference after that, so keying the derived
6
7
  // key's own cache off it means every seal/open after the first reuses it
@@ -78,10 +79,7 @@ export async function contextDigest(options) {
78
79
  /** SHA-256, base64url. */
79
80
  export async function digest(value) {
80
81
  const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
81
- return btoa(String.fromCharCode(...new Uint8Array(bytes)))
82
- .replaceAll('+', '-')
83
- .replaceAll('/', '_')
84
- .replace(/=+$/, '');
82
+ return base64url(bytes);
85
83
  }
86
84
  export async function sealTransaction(options, payload) {
87
85
  const value = await seal(options, 'transaction', payload, TRANSACTION_TTL);
@@ -125,9 +123,7 @@ export async function sealQrTransaction(options, payload, ttl) {
125
123
  }
126
124
  export async function openQrTransaction(options, value) {
127
125
  const payload = await open(options, 'qr', value);
128
- return payload && typeof payload.dc === 'string' && typeof payload.iv === 'number'
129
- ? payload
130
- : null;
126
+ return payload && typeof payload.dc === 'string' ? payload : null;
131
127
  }
132
128
  export function clearQrTransaction(options) {
133
129
  return setCookie(qrTransactionCookieName(options), '', 0, options.secure);
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type MaxAge, type NewUser, type PasskeyRegistrationResult, type PasswordChangeRequired, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SetNewPasswordError, type SetNewPasswordResult, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, STEP_UP_MAX_AGE, createAuth, } from './auth.js';
1
+ export { type Auth, type AuthRequest, type ChangePasswordError, type ChangePasswordResult, type Completed, type DeletePasskeyResult, type EmailChangeError, type EmailChangeResult, type Gate, type GateRules, type Intent, type MaxAge, type NewUser, type Passkey, type PasskeyRegistrationResult, type PasswordChangeRequired, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SetNewPasswordError, type SetNewPasswordResult, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, STEP_UP_MAX_AGE, createAuth, } from './auth.js';
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
5
  export type { PersonTokenConfig, PersonTokenVerifier } from './person-token.js';
6
- export { BrokerError } from './broker.js';
6
+ export { BrokerError, type PeoplePage, type Person } from './broker.js';
7
7
  export { AuthError, type AuthErrorReason, ORG_CLAIM } from './oidc.js';
8
8
  export { safeLoginHint, safeNextPath } from './redirect.js';
@@ -1,15 +1,10 @@
1
1
  import { createRemoteJWKSet, jwtVerify } from 'jose';
2
2
  import { resolveNamespaceContext } from './namespaces.js';
3
3
  import { safeLoginHint, safeNextPath } from './redirect.js';
4
+ import { base64url } from './util.js';
4
5
  const OWNER = 'urn:zitadel:iam:user:resourceowner:id';
5
6
  const ttl = 10 * 60_000;
6
7
  const random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));
7
- function base64url(bytes) {
8
- return btoa(String.fromCharCode(...bytes))
9
- .replaceAll('+', '-')
10
- .replaceAll('/', '_')
11
- .replace(/=+$/, '');
12
- }
13
8
  function read(storage, key) {
14
9
  try {
15
10
  return JSON.parse(storage.getItem(key) ?? 'null');
@@ -11,10 +11,6 @@ export declare const STATUS_STALE_IF_ERROR_MS: number;
11
11
  export type SessionRead = {
12
12
  kind: 'none';
13
13
  }
14
- /** A cookie for another binding, revision or format. */
15
- | {
16
- kind: 'changed';
17
- }
18
14
  /** The binding is suspended, or its status cannot be confirmed. */
19
15
  | {
20
16
  kind: 'unavailable';
@@ -36,7 +32,7 @@ export declare function namespaceSessions(deps: {
36
32
  }): {
37
33
  bindingActive: () => Promise<boolean>;
38
34
  create: (tokens: Tokens, requestHeaders?: Headers) => Promise<SetCookie>;
39
- end: (value: string | undefined) => Promise<"ended" | "changed" | "none">;
35
+ end: (value: string | undefined) => Promise<"ended" | "none">;
40
36
  read: (value: string | undefined, force?: boolean) => Promise<SessionRead>;
41
37
  revokeSubject: (subject: string) => Promise<number>;
42
38
  listSessions: (subject: string) => Promise<SessionRecord[]>;
@@ -2,7 +2,8 @@ import { sleep } from '@wtfalch/utils/datetime';
2
2
  import * as client from 'openid-client';
3
3
  import { BrokerError } from './broker.js';
4
4
  import { contextDigest, digest, openHandle, openRecord, sealHandle, sealRecord, } from './cookies.js';
5
- import { AuthError, isOAuthRefusal } from './oidc.js';
5
+ import { AuthError, isOAuthRefusal, rejected } from './oidc.js';
6
+ import { expiryOf, issuedAtOf } from './util.js';
6
7
  /** How long a binding status answer is trusted. The propagation bound for a suspension. */
7
8
  export const STATUS_FRESH_MS = 60_000;
8
9
  /** How long the last good answer carries on while the service cannot be asked. */
@@ -158,7 +159,7 @@ export function namespaceSessions(deps) {
158
159
  return 'none';
159
160
  const key = await keyOf(value);
160
161
  if (!key)
161
- return 'changed';
162
+ return 'none';
162
163
  const loaded = await load(key);
163
164
  await store().revoke(key, now());
164
165
  revokeToken(loaded?.tokens.rt);
@@ -423,8 +424,9 @@ export function namespaceSessions(deps) {
423
424
  if (!value)
424
425
  return { kind: 'none' };
425
426
  const key = await keyOf(value);
427
+ // A handle that does not open (another binding's, an older revision's, or damaged) is nobody: sign in again replaces it.
426
428
  if (!key)
427
- return { kind: 'changed' };
429
+ return { kind: 'none' };
428
430
  if (!(await bindingActive()))
429
431
  return { kind: 'unavailable' };
430
432
  let loaded = await load(key);
@@ -443,8 +445,12 @@ export function namespaceSessions(deps) {
443
445
  try {
444
446
  return { kind: 'user', claims: await deps.oidc().verify(loaded.tokens.idt) };
445
447
  }
446
- catch {
447
- return { kind: 'none' };
448
+ catch (error) {
449
+ if (rejected(error))
450
+ return { kind: 'none' };
451
+ // The issuer's keys could not be fetched: not a verdict on this session.
452
+ console.error('@wtfalch/auth: could not verify the session id token', error instanceof Error ? error.name : 'unknown');
453
+ return { kind: 'unavailable' };
448
454
  }
449
455
  };
450
456
  return {
@@ -483,25 +489,3 @@ function ipFrom(headers) {
483
489
  .filter(Boolean);
484
490
  return hops.length > 0 ? hops[hops.length - 1] : null;
485
491
  }
486
- function expiryOf(idToken) {
487
- try {
488
- const [, payload] = idToken.split('.');
489
- const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
490
- return typeof json.exp === 'number' ? json.exp : null;
491
- }
492
- catch {
493
- return null;
494
- }
495
- }
496
- // Age, not time left: a disabled or revoked account must not ride out a
497
- // long-lived token just because it still has plenty of time on the clock.
498
- function issuedAtOf(idToken) {
499
- try {
500
- const [, payload] = idToken.split('.');
501
- const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
502
- return typeof json.iat === 'number' ? json.iat : null;
503
- }
504
- catch {
505
- return null;
506
- }
507
- }
package/dist/next.d.ts CHANGED
@@ -128,7 +128,7 @@ export interface NextAuth {
128
128
  /**
129
129
  * Registers a passkey on this app's own domain for the person signed in
130
130
  * here. Throws `AuthError('unauthenticated', ...)` for nobody, exactly as
131
- * `requireUser` throws `account_changed`/`unavailable` for those.
131
+ * `requireUser` throws `unavailable` for a suspended binding.
132
132
  */
133
133
  startPasskeyRegistration: () => Promise<PasskeyRegistrationStart>;
134
134
  /** The second half of `startPasskeyRegistration`. Same signed-in requirement. */
package/dist/next.js CHANGED
@@ -31,13 +31,9 @@ export function nextAuth(options) {
31
31
  response = NextResponse.redirect(gate.location, 303);
32
32
  }
33
33
  else if (gate.kind === 'deny') {
34
- response = new NextResponse(gate.reason === 'account_changed'
35
- ? 'Account changed; choose an account to continue'
36
- : gate.reason === 'unavailable'
37
- ? 'Sign-in is unavailable for this workspace'
38
- : 'Unauthorized', {
39
- status: gate.reason === 'account_changed' ? 409 : gate.reason === 'unavailable' ? 503 : 401,
40
- });
34
+ response = new NextResponse(gate.reason === 'unavailable'
35
+ ? 'Sign-in is unavailable for this workspace'
36
+ : 'Unauthorized', { status: gate.reason === 'unavailable' ? 503 : 401 });
41
37
  }
42
38
  else if (gate.cookies.length > 0) {
43
39
  const headers = new Headers(request.headers);
@@ -60,9 +56,9 @@ export function nextAuth(options) {
60
56
  };
61
57
  const requireUser = async (next) => {
62
58
  const store = await cookies();
63
- const { user, accountChanged, unavailable } = await auth.readCookie(store.get(auth.sessionCookieName)?.value, { refresh: false });
64
- if (accountChanged)
65
- throw new AuthError('account_changed', 'Choose an account to continue');
59
+ const { user, unavailable } = await auth.readCookie(store.get(auth.sessionCookieName)?.value, {
60
+ refresh: false,
61
+ });
66
62
  if (unavailable)
67
63
  throw new AuthError('unavailable', 'Sign-in is unavailable for this workspace');
68
64
  if (!user)
@@ -213,10 +209,16 @@ export function nextAuth(options) {
213
209
  };
214
210
  const setNewPassword = async (input) => {
215
211
  // Named fields: a Server Action's argument is whatever the client sent.
212
+ // A field that is not text is refused here and never reaches the broker.
213
+ if (typeof input?.authRequestId !== 'string' || typeof input.changeToken !== 'string') {
214
+ return { error: 'invalid_token' };
215
+ }
216
+ if (typeof input.newPassword !== 'string')
217
+ return { error: 'invalid_password' };
216
218
  const result = await auth.setNewPassword({
217
- authRequestId: String(input?.authRequestId),
218
- changeToken: String(input?.changeToken),
219
- newPassword: String(input?.newPassword),
219
+ authRequestId: input.authRequestId,
220
+ changeToken: input.changeToken,
221
+ newPassword: input.newPassword,
220
222
  });
221
223
  if (result.ok) {
222
224
  if (result.hosted)
@@ -226,7 +228,24 @@ export function nextAuth(options) {
226
228
  return { error: result.error };
227
229
  };
228
230
  const signUp = async (input) => {
229
- const result = await auth.signUp(input);
231
+ // Named fields: a Server Action's argument is whatever the client sent, so
232
+ // `changeRequired` and `personalEmail`, which only a server may set, are not read.
233
+ if (typeof input?.authRequestId !== 'string' ||
234
+ typeof input.email !== 'string' ||
235
+ typeof input.password !== 'string' ||
236
+ typeof input.givenName !== 'string' ||
237
+ typeof input.familyName !== 'string' ||
238
+ (input.username !== undefined && typeof input.username !== 'string')) {
239
+ return { error: 'invalid', message: 'Fill in every field.' };
240
+ }
241
+ const result = await auth.signUp({
242
+ authRequestId: input.authRequestId,
243
+ email: input.email,
244
+ password: input.password,
245
+ givenName: input.givenName,
246
+ familyName: input.familyName,
247
+ username: input.username,
248
+ });
230
249
  if (result.ok) {
231
250
  if (result.hosted)
232
251
  redirect(result.redirectTo);
package/dist/oidc.d.ts CHANGED
@@ -86,4 +86,6 @@ export declare class AuthError extends Error {
86
86
  * is never a statement that the grant is dead, and this issuer's own rate
87
87
  * limiting must not permanently revoke a session.
88
88
  */
89
+ /** An id token the issuer's keys or claims refuse, as opposed to keys that could not be fetched. */
90
+ export declare function rejected(error: unknown): boolean;
89
91
  export declare function isOAuthRefusal(error: unknown): boolean;
package/dist/oidc.js CHANGED
@@ -107,6 +107,10 @@ export class Oidc {
107
107
  return this.tokensFrom(response);
108
108
  }
109
109
  // Parallel requests holding the same refresh token share one grant; the issuer rotates it, so a second grant would fail.
110
+ // The rotated result is kept for ROTATION_CACHE_MS and keyed by the OLD refresh token, so a request that lost the race gets the
111
+ // winner's tokens. The cost: a copy of the old cookie (same refresh token) gets those tokens too in that window. Keying on
112
+ // the session does not help, since the copy is the same session; the only fix is a shorter window, which would break the race
113
+ // it exists for. The copy still needs the sealed cookie, so this exposes nothing a live cookie did not already hold.
110
114
  refresh(refreshToken) {
111
115
  const asked = Date.now();
112
116
  for (const [token, entry] of this.rotated)
@@ -221,6 +225,16 @@ export class AuthError extends Error {
221
225
  * is never a statement that the grant is dead, and this issuer's own rate
222
226
  * limiting must not permanently revoke a session.
223
227
  */
228
+ /** An id token the issuer's keys or claims refuse, as opposed to keys that could not be fetched. */
229
+ export function rejected(error) {
230
+ return (error instanceof AuthError ||
231
+ error instanceof jose.errors.JWTExpired ||
232
+ error instanceof jose.errors.JWTClaimValidationFailed ||
233
+ error instanceof jose.errors.JWSSignatureVerificationFailed ||
234
+ error instanceof jose.errors.JWSInvalid ||
235
+ error instanceof jose.errors.JWTInvalid ||
236
+ error instanceof jose.errors.JOSEAlgNotAllowed);
237
+ }
224
238
  export function isOAuthRefusal(error) {
225
239
  const status = statusOf(error);
226
240
  return status !== undefined && status >= 400 && status < 500 && status !== 429;
@@ -1,4 +1,5 @@
1
1
  import { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';
2
+ import { timeoutSignal } from './util.js';
2
3
  /** A JWT is three base64url segments; a ZITADEL opaque token is not shaped
3
4
  * like that at all, so this is enough to route between `jwtVerify` and
4
5
  * introspection without ever trying to parse either kind as the other. */
@@ -23,7 +24,7 @@ function sameOriginEndpoint(value, name, issuerOrigin) {
23
24
  async function discoverDocument(issuer, fetchImpl) {
24
25
  const discoveryUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
25
26
  const issuerOrigin = new URL(issuer).origin;
26
- const response = await fetchImpl(discoveryUrl);
27
+ const response = await fetchImpl(discoveryUrl, { signal: timeoutSignal() });
27
28
  if (!response.ok) {
28
29
  throw new Error(`person-token: discovery document request failed with ${response.status}`);
29
30
  }
@@ -108,6 +109,7 @@ export function createPersonTokenVerifier(config) {
108
109
  audience: config.audience,
109
110
  algorithms: ['RS256'],
110
111
  clockTolerance: 60,
112
+ requiredClaims: ['exp', 'sub'],
111
113
  });
112
114
  return payload;
113
115
  }
@@ -150,6 +152,7 @@ export function createPersonTokenVerifier(config) {
150
152
  authorization: `Basic ${credentials}`,
151
153
  },
152
154
  body: new URLSearchParams({ token: accessToken }).toString(),
155
+ signal: timeoutSignal(),
153
156
  });
154
157
  }
155
158
  catch {
package/dist/qr.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AuthError } from './oidc.js';
2
+ import { timeoutSignal } from './util.js';
2
3
  /**
3
4
  * The OAuth device authorization grant (RFC 8628), for signing in a new
4
5
  * device from a QR code a phone that is already signed in approves. Raw
@@ -11,6 +12,7 @@ const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
11
12
  export async function requestDeviceAuthorization(options, scope) {
12
13
  const response = await options.fetch(`${options.issuer}/oauth/v2/device_authorization`, {
13
14
  method: 'POST',
15
+ signal: timeoutSignal(),
14
16
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
15
17
  body: new URLSearchParams({ client_id: options.clientId, scope }),
16
18
  });
@@ -36,6 +38,7 @@ export async function requestDeviceAuthorization(options, scope) {
36
38
  export async function pollDeviceToken(options, oidc, deviceCode) {
37
39
  const response = await options.fetch(`${options.issuer}/oauth/v2/token`, {
38
40
  method: 'POST',
41
+ signal: timeoutSignal(),
39
42
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
40
43
  body: new URLSearchParams({
41
44
  grant_type: DEVICE_GRANT_TYPE,
package/dist/testing.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import type { Auth, User } from './auth.js';
2
+ import type { NextAuth } from './next.js';
2
3
  export declare function setSessionUser(user: User | null): void;
3
4
  /** A User shaped like the SDK's, for `setSessionUser`. */
4
5
  export declare function testUser(id: string, overrides?: Partial<User>): User;
5
6
  export declare function getUser(): Promise<User | null>;
6
7
  export declare function requireUser(_next?: string): Promise<User>;
8
+ export declare const requireFreshUser: NextAuth['requireFreshUser'];
7
9
  export declare const handlers: () => never;
8
10
  export declare const proxy: () => never;
9
11
  export declare const authRequest: () => never;
@@ -13,6 +15,24 @@ export declare const requestPasswordReset: () => never;
13
15
  export declare const resetPassword: () => never;
14
16
  export declare const sendLink: () => never;
15
17
  export declare const resendVerification: () => never;
18
+ export declare const setNewPassword: () => never;
19
+ export declare const invite: () => never;
20
+ export declare const people: () => never;
21
+ export declare const setPersonActive: () => never;
22
+ export declare const adminResetPassword: () => never;
23
+ export declare const sessions: () => never;
24
+ export declare const endSession: () => never;
25
+ export declare const startPasskeyRegistration: () => never;
26
+ export declare const finishPasskeyRegistration: () => never;
27
+ export declare const startEmailChange: () => never;
28
+ export declare const completeEmailChange: () => never;
29
+ export declare const changePassword: () => never;
30
+ export declare const passkeys: () => never;
31
+ export declare const deletePasskey: () => never;
32
+ export declare const startPasskeySignIn: () => never;
33
+ export declare const finishPasskeySignIn: () => never;
34
+ export declare const startQrSignIn: () => never;
35
+ export declare const pollQrSignIn: () => never;
16
36
  /**
17
37
  * The core SDK instance, as far as a device sign-in module reaches into it.
18
38
  *
@@ -29,3 +49,9 @@ export declare const sentLinks: {
29
49
  export declare function setFollowLinkResult(result: unknown): void;
30
50
  export declare function setCookieUser(user: User | null): void;
31
51
  export declare const auth: Auth;
52
+ /**
53
+ * Every member of `NextAuth`, so the compiler fails here when the real
54
+ * surface gains one this stub lacks. `export const { getUser } = nextAuthStub`
55
+ * is the same as importing the named exports above.
56
+ */
57
+ export declare const nextAuthStub: NextAuth;
package/dist/testing.js CHANGED
@@ -39,6 +39,7 @@ export async function requireUser(_next) {
39
39
  throw new Error('requireUser: no session set; call setSessionUser first');
40
40
  return current;
41
41
  }
42
+ export const requireFreshUser = async () => requireUser();
42
43
  function unavailable(name) {
43
44
  return () => {
44
45
  throw new Error(`${name} is not available in the integration tier`);
@@ -53,6 +54,24 @@ export const requestPasswordReset = unavailable('requestPasswordReset');
53
54
  export const resetPassword = unavailable('resetPassword');
54
55
  export const sendLink = unavailable('sendLink');
55
56
  export const resendVerification = unavailable('resendVerification');
57
+ export const setNewPassword = unavailable('setNewPassword');
58
+ export const invite = unavailable('invite');
59
+ export const people = unavailable('people');
60
+ export const setPersonActive = unavailable('setPersonActive');
61
+ export const adminResetPassword = unavailable('adminResetPassword');
62
+ export const sessions = unavailable('sessions');
63
+ export const endSession = unavailable('endSession');
64
+ export const startPasskeyRegistration = unavailable('startPasskeyRegistration');
65
+ export const finishPasskeyRegistration = unavailable('finishPasskeyRegistration');
66
+ export const startEmailChange = unavailable('startEmailChange');
67
+ export const completeEmailChange = unavailable('completeEmailChange');
68
+ export const changePassword = unavailable('changePassword');
69
+ export const passkeys = unavailable('passkeys');
70
+ export const deletePasskey = unavailable('deletePasskey');
71
+ export const startPasskeySignIn = unavailable('startPasskeySignIn');
72
+ export const finishPasskeySignIn = unavailable('finishPasskeySignIn');
73
+ export const startQrSignIn = unavailable('startQrSignIn');
74
+ export const pollQrSignIn = unavailable('pollQrSignIn');
56
75
  /**
57
76
  * The core SDK instance, as far as a device sign-in module reaches into it.
58
77
  *
@@ -79,3 +98,41 @@ export const auth = {
79
98
  followLink: async () => linkResult,
80
99
  readCookie: async () => ({ user: cookieUser, cookies: [] }),
81
100
  };
101
+ /**
102
+ * Every member of `NextAuth`, so the compiler fails here when the real
103
+ * surface gains one this stub lacks. `export const { getUser } = nextAuthStub`
104
+ * is the same as importing the named exports above.
105
+ */
106
+ export const nextAuthStub = {
107
+ auth,
108
+ handlers: handlers,
109
+ proxy,
110
+ getUser,
111
+ requireUser,
112
+ requireFreshUser,
113
+ authRequest,
114
+ signIn,
115
+ setNewPassword,
116
+ signUp,
117
+ resendVerification,
118
+ requestPasswordReset,
119
+ sendLink,
120
+ invite,
121
+ people,
122
+ setPersonActive,
123
+ adminResetPassword,
124
+ sessions,
125
+ endSession,
126
+ resetPassword,
127
+ startPasskeyRegistration,
128
+ finishPasskeyRegistration,
129
+ startEmailChange,
130
+ completeEmailChange,
131
+ changePassword,
132
+ passkeys,
133
+ deletePasskey,
134
+ startPasskeySignIn,
135
+ finishPasskeySignIn,
136
+ startQrSignIn,
137
+ pollQrSignIn,
138
+ };
package/dist/util.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /** Helpers shared by the server and browser entry points. No Node-only imports: the browser bundle uses this file too. */
2
+ /** How long an SDK request to the broker or issuer may take before it is abandoned. */
3
+ export declare const FETCH_TIMEOUT_MS = 15000;
4
+ /** `signal` if the caller brought one, otherwise a 15 second timeout. */
5
+ export declare function timeoutSignal(signal?: AbortSignal): AbortSignal;
6
+ /** Bytes as unpadded base64url. */
7
+ export declare function base64url(bytes: ArrayBuffer | Uint8Array): string;
8
+ /** The payload of a JWT, read without verifying it. Only for tokens that were verified, or came straight from the token endpoint. */
9
+ export declare function decodeJwtPayload(jwt: string): Record<string, unknown> | null;
10
+ /** The `exp` claim of an ID token, or null. */
11
+ export declare function expiryOf(idToken: string): number | null;
12
+ /** The `iat` claim of an ID token, or null. */
13
+ export declare function issuedAtOf(idToken: string): number | null;
package/dist/util.js ADDED
@@ -0,0 +1,44 @@
1
+ /** Helpers shared by the server and browser entry points. No Node-only imports: the browser bundle uses this file too. */
2
+ /** How long an SDK request to the broker or issuer may take before it is abandoned. */
3
+ export const FETCH_TIMEOUT_MS = 15_000;
4
+ /** `signal` if the caller brought one, otherwise a 15 second timeout. */
5
+ export function timeoutSignal(signal) {
6
+ return signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS);
7
+ }
8
+ /** Bytes as unpadded base64url. */
9
+ export function base64url(bytes) {
10
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
11
+ return btoa(String.fromCharCode(...view))
12
+ .replaceAll('+', '-')
13
+ .replaceAll('/', '_')
14
+ .replace(/=+$/, '');
15
+ }
16
+ /** The payload of a JWT, read without verifying it. Only for tokens that were verified, or came straight from the token endpoint. */
17
+ export function decodeJwtPayload(jwt) {
18
+ try {
19
+ const part = jwt.split('.')[1];
20
+ if (!part)
21
+ return null;
22
+ const base64 = part.replace(/-/g, '+').replace(/_/g, '/');
23
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
24
+ const json = JSON.parse(atob(padded));
25
+ return json && typeof json === 'object' && !Array.isArray(json)
26
+ ? json
27
+ : null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ /** The `exp` claim of an ID token, or null. */
34
+ export function expiryOf(idToken) {
35
+ const exp = decodeJwtPayload(idToken)?.exp;
36
+ return typeof exp === 'number' ? exp : null;
37
+ }
38
+ // Age, not time left: a disabled or revoked account must not ride out a
39
+ // long-lived token just because it still has plenty of time on the clock.
40
+ /** The `iat` claim of an ID token, or null. */
41
+ export function issuedAtOf(idToken) {
42
+ const iat = decodeJwtPayload(idToken)?.iat;
43
+ return typeof iat === 'number' ? iat : null;
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/auth",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Sign in against a self-hosted ZITADEL issuer: a server session for a Next.js app, and a browser client with silent single sign-on across subdomains.",
5
5
  "repository": {
6
6
  "type": "git",