@wtfalch/auth 0.24.0 → 0.26.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.
@@ -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,3 +1,4 @@
1
+ import { type JWTPayload } from 'jose';
1
2
  /**
2
3
  * Verifies a signed-in person's own `@wtfalch/auth`/ZITADEL access token,
3
4
  * server-side, for a service that wants to accept one directly rather than
@@ -58,4 +59,12 @@ export interface PersonTokenConfig {
58
59
  * real cooldown. */
59
60
  jwksCooldownMs?: number;
60
61
  }
62
+ /** @internal Shared by `createPersonTokenVerifier` and `./mcp`: one discovery
63
+ * document and one JWKS cache per core. Not part of the public contract. */
64
+ export interface PersonTokenCore extends PersonTokenVerifier {
65
+ /** JWT path only, no introspection: the verified claims, or null. */
66
+ verifyJwtClaims(accessToken: string): Promise<JWTPayload | null>;
67
+ }
68
+ /** @internal */
69
+ export declare function createPersonTokenCore(config: PersonTokenConfig): PersonTokenCore;
61
70
  export declare function createPersonTokenVerifier(config: PersonTokenConfig): PersonTokenVerifier;
@@ -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
  }
@@ -76,7 +77,8 @@ class PositiveIntrospectionCache {
76
77
  this.entries.set(token, { payload, expiresAt });
77
78
  }
78
79
  }
79
- export function createPersonTokenVerifier(config) {
80
+ /** @internal */
81
+ export function createPersonTokenCore(config) {
80
82
  const fetchImpl = config.fetch ?? fetch;
81
83
  let discovery = null;
82
84
  let jwks = null;
@@ -108,6 +110,7 @@ export function createPersonTokenVerifier(config) {
108
110
  audience: config.audience,
109
111
  algorithms: ['RS256'],
110
112
  clockTolerance: 60,
113
+ requiredClaims: ['exp', 'sub'],
111
114
  });
112
115
  return payload;
113
116
  }
@@ -150,6 +153,7 @@ export function createPersonTokenVerifier(config) {
150
153
  authorization: `Basic ${credentials}`,
151
154
  },
152
155
  body: new URLSearchParams({ token: accessToken }).toString(),
156
+ signal: timeoutSignal(),
153
157
  });
154
158
  }
155
159
  catch {
@@ -179,6 +183,9 @@ export function createPersonTokenVerifier(config) {
179
183
  return payload;
180
184
  }
181
185
  return {
186
+ async verifyJwtClaims(accessToken) {
187
+ return JWT_SHAPE.test(accessToken) ? verifyJwt(accessToken) : null;
188
+ },
182
189
  async verify(accessToken) {
183
190
  const payload = JWT_SHAPE.test(accessToken)
184
191
  ? await verifyJwt(accessToken)
@@ -189,3 +196,7 @@ export function createPersonTokenVerifier(config) {
189
196
  },
190
197
  };
191
198
  }
199
+ export function createPersonTokenVerifier(config) {
200
+ const { verify } = createPersonTokenCore(config);
201
+ return { verify };
202
+ }
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
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The same OIDC sign-in the Next apps use, for a Cloudflare Worker: Request in,
3
+ * Response out, no `next`, no `node:`. It is `createAuth` with the app's origin
4
+ * taken from each request, so the session cookie (id token + refresh token,
5
+ * never an access token), the PKCE and state transaction, the organisation
6
+ * check and the refresh rules are the Next path's own code, not a copy.
7
+ */
8
+ export interface WorkerSessionOptions {
9
+ /** The org's login host, e.g. `https://login.candorfx.com`. */
10
+ issuer: string;
11
+ /** The entry's client id. */
12
+ clientId: string;
13
+ /** The org's ZITADEL organisation id. A person from any other organisation is refused. */
14
+ orgId: string;
15
+ /** Where the issuer sends the browser back, e.g. `/auth/callback`. Must end in `/callback` and have a prefix; the app routes it to `callback`. */
16
+ redirectPath: string;
17
+ /** 32 bytes, base64 or hex. Never logged. */
18
+ cookieSecret: string;
19
+ /** Test seam. */
20
+ fetch?: typeof fetch;
21
+ }
22
+ export interface WorkerSignedIn {
23
+ userId: string;
24
+ orgId: string;
25
+ /**
26
+ * `Set-Cookie` header values to add to the app's response. Empty unless the
27
+ * session was refreshed on this request. The refresh token is single use:
28
+ * an app that drops these signs the person out at the next refresh.
29
+ */
30
+ setCookies: string[];
31
+ }
32
+ export interface WorkerSession {
33
+ /** Redirects to the issuer. `register` opens registration, `returnTo` is a path on this app. */
34
+ signIn(req: Request, o?: {
35
+ register?: boolean;
36
+ returnTo?: string;
37
+ }): Promise<Response>;
38
+ /** Route `redirectPath` here. Always a redirect: to `returnTo` on success, to `/?auth_error=<reason>` otherwise. */
39
+ callback(req: Request): Promise<Response>;
40
+ currentUser(req: Request): Promise<WorkerSignedIn | null>;
41
+ /** POST from the app's own origin only. */
42
+ signOut(req: Request): Promise<Response>;
43
+ }
44
+ export declare function createWorkerSession(opts: WorkerSessionOptions): WorkerSession;
@@ -0,0 +1,106 @@
1
+ import { createAuth } from './auth.js';
2
+ import { resolveOptions } from './config.js';
3
+ import { FETCH_TIMEOUT_MS } from './util.js';
4
+ const CALLBACK = /^(\/[A-Za-z0-9._~-]+)+\/callback$/;
5
+ /** The fetch the SDK is given: every outside call (discovery, token, JWKS, revoke) is abandoned after FETCH_TIMEOUT_MS unless the caller set its own signal. */
6
+ function withTimeout(fetchImpl) {
7
+ return (input, init) => fetchImpl(input, { ...init, signal: init?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS) });
8
+ }
9
+ export function createWorkerSession(opts) {
10
+ for (const name of ['issuer', 'clientId', 'orgId', 'redirectPath', 'cookieSecret']) {
11
+ if (typeof opts[name] !== 'string' || !opts[name]) {
12
+ throw new Error(`@wtfalch/auth: ${name} is required`);
13
+ }
14
+ }
15
+ if (!CALLBACK.test(opts.redirectPath)) {
16
+ throw new Error('@wtfalch/auth: redirectPath must be a path ending in /callback, like /auth/callback');
17
+ }
18
+ let issuerUrl;
19
+ try {
20
+ issuerUrl = new URL(opts.issuer);
21
+ }
22
+ catch {
23
+ throw new Error('@wtfalch/auth: issuer must be an absolute URL');
24
+ }
25
+ if (issuerUrl.protocol !== 'https:' && issuerUrl.hostname !== 'localhost') {
26
+ throw new Error('@wtfalch/auth: issuer must be https');
27
+ }
28
+ const basePath = opts.redirectPath.slice(0, -'/callback'.length);
29
+ const fetchImpl = withTimeout(opts.fetch ?? globalThis.fetch);
30
+ const config = (appUrl) => ({
31
+ appUrl,
32
+ clientId: opts.clientId,
33
+ organizationId: opts.orgId,
34
+ issuer: opts.issuer,
35
+ cookieSecret: opts.cookieSecret,
36
+ basePath,
37
+ fetch: fetchImpl,
38
+ });
39
+ // A bad secret or client is a startup error, not the first visitor's.
40
+ resolveOptions(config('https://placeholder.invalid'));
41
+ // One Auth per origin, so the discovery document, JWKS and refresh dedupe are shared across requests in an isolate.
42
+ const byOrigin = new Map();
43
+ const authFor = (req) => {
44
+ const origin = new URL(req.url).origin;
45
+ let auth = byOrigin.get(origin);
46
+ if (!auth) {
47
+ auth = createAuth(config(origin));
48
+ byOrigin.set(origin, auth);
49
+ }
50
+ return auth;
51
+ };
52
+ // An outside failure is logged by its class only: messages can carry URLs and token fragments.
53
+ const unavailable = (what, error) => {
54
+ console.error(`@wtfalch/auth: worker session ${what} failed`, error instanceof Error ? error.name : 'unknown');
55
+ return new Response('Sign-in is unavailable', { status: 503 });
56
+ };
57
+ return {
58
+ async signIn(req, o = {}) {
59
+ const url = new URL(`${basePath}/start`, req.url);
60
+ if (o.register)
61
+ url.searchParams.set('intent', 'register');
62
+ if (o.returnTo !== undefined)
63
+ url.searchParams.set('next', o.returnTo);
64
+ try {
65
+ return await authFor(req).start(new Request(url, { headers: req.headers }));
66
+ }
67
+ catch (error) {
68
+ return unavailable('start', error);
69
+ }
70
+ },
71
+ async callback(req) {
72
+ try {
73
+ return await authFor(req).callback(req);
74
+ }
75
+ catch (error) {
76
+ return unavailable('callback', error);
77
+ }
78
+ },
79
+ async currentUser(req) {
80
+ try {
81
+ const { user, cookies } = await authFor(req).read(req);
82
+ if (!user)
83
+ return null;
84
+ return {
85
+ userId: user.id,
86
+ orgId: user.organizationId,
87
+ setCookies: cookies.map((c) => c.header),
88
+ };
89
+ }
90
+ catch (error) {
91
+ unavailable('session read', error);
92
+ return null;
93
+ }
94
+ },
95
+ async signOut(req) {
96
+ if (req.method !== 'POST')
97
+ return new Response('Method not allowed', { status: 405 });
98
+ try {
99
+ return await authFor(req).logout(req);
100
+ }
101
+ catch (error) {
102
+ return unavailable('sign-out', error);
103
+ }
104
+ },
105
+ };
106
+ }