@wtfalch/auth 0.9.0 → 0.10.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
@@ -2,6 +2,7 @@ import { type JWTPayload } from 'jose';
2
2
  import { type AuthRequest, type NewUser, type PeoplePage } from './broker.js';
3
3
  import { type AuthOptions } from './config.js';
4
4
  import { type SetCookie } from './cookies.js';
5
+ import type { SessionRecord } from './sessions.js';
5
6
  export interface User {
6
7
  /** Present only for validated namespace sessions. Membership is app-owned. */
7
8
  namespaceId?: string;
@@ -45,6 +46,8 @@ export type Gate = {
45
46
  reason?: 'account_changed' | 'unavailable';
46
47
  };
47
48
  export type Intent = 'login' | 'register';
49
+ /** Allowlisted so a caller cannot smuggle an arbitrary OIDC prompt value through. */
50
+ export type Prompt = 'select_account' | 'login';
48
51
  export type SignInError = 'invalid_credentials' | 'too_many' | 'request' | 'unavailable';
49
52
  export type ResetError = 'invalid_code' | 'invalid_password' | 'unavailable';
50
53
  export type SignUpError = 'registration_closed' | 'email_taken' | 'username_taken' | 'username_invalid' | 'invalid' | 'request' | 'unavailable';
@@ -65,6 +68,33 @@ export type SignUpResult = {
65
68
  error: SignUpError;
66
69
  message: string;
67
70
  };
71
+ export type EmailChangeError = 'invalid' | 'email_taken' | 'too_many' | 'unavailable';
72
+ export type EmailChangeResult = {
73
+ ok: true;
74
+ } | {
75
+ ok: false;
76
+ error: EmailChangeError;
77
+ };
78
+ export type ChangePasswordError = 'invalid_current_password' | 'invalid_password' | 'unavailable';
79
+ export type ChangePasswordResult = {
80
+ ok: true;
81
+ } | {
82
+ ok: false;
83
+ error: ChangePasswordError;
84
+ };
85
+ export type DeletePasskeyResult = {
86
+ ok: true;
87
+ } | {
88
+ ok: false;
89
+ error: 'invalid';
90
+ };
91
+ /** One passkey as the issuer reports it -- `ready` or still mid-registration. */
92
+ export interface Passkey {
93
+ readonly id: string;
94
+ readonly name: string;
95
+ /** `ready`, `not_ready` for a registration nobody finished, or `unknown` for a state a newer issuer added. Only `ready` signs anyone in. */
96
+ readonly state: string;
97
+ }
68
98
  export interface PasskeyRegistrationStart {
69
99
  passkeyId: string;
70
100
  /** ZITADEL's `publicKeyCredentialCreationOptions`, opaque here -- `@wtfalch/auth/passkey`'s `createPasskey` knows its shape. */
@@ -127,8 +157,13 @@ export interface Auth {
127
157
  start(request: Request): Promise<Response>;
128
158
  /** GET {basePath}/callback */
129
159
  callback(request: Request): Promise<Response>;
130
- /** The callback as data, for a server action that finishes the sign-in itself and sets the cookies. */
131
- complete(callbackUrl: string, cookieHeader: string | null): Promise<Completed>;
160
+ /**
161
+ * The callback as data, for a server action that finishes the sign-in
162
+ * itself and sets the cookies. `requestHeaders`, when given, is the
163
+ * signing-in request's own headers -- namespace mode keeps its user agent
164
+ * and address on the new session record.
165
+ */
166
+ complete(callbackUrl: string, cookieHeader: string | null, requestHeaders?: Headers): Promise<Completed>;
132
167
  /** POST {basePath}/logout */
133
168
  logout(request: Request): Promise<Response>;
134
169
  /** GET {basePath}/verify?userId=&code= */
@@ -154,7 +189,7 @@ export interface Auth {
154
189
  sessionId: string;
155
190
  code: string;
156
191
  next?: string;
157
- }): Promise<SignedIn>;
192
+ }, requestHeaders?: Headers): Promise<SignedIn>;
158
193
  /** Emails a fresh verification link. Says nothing about whether the address exists. */
159
194
  resendVerification(email: string, next?: string): Promise<void>;
160
195
  /** Emails a password-reset link to {basePath}/reset?userId=&code=. Says nothing about whether the address exists. */
@@ -165,7 +200,7 @@ export interface Auth {
165
200
  code: string;
166
201
  password: string;
167
202
  next?: string;
168
- }): Promise<SignedIn>;
203
+ }, requestHeaders?: Headers): Promise<SignedIn>;
169
204
  /** Emails a sign-in link. Needs a verified address. Says nothing about whether the address exists. */
170
205
  sendLink(email: string, next?: string): Promise<void>;
171
206
  /**
@@ -195,7 +230,8 @@ export interface Auth {
195
230
  refresh?: boolean;
196
231
  }): Promise<ReadResult>;
197
232
  gate(request: Request, rules?: GateRules): Promise<Gate>;
198
- startUrl(next?: string | null, intent?: Intent): string;
233
+ /** `prompt` is allowlisted server-side too -- an unrecognised value is dropped, not forwarded. */
234
+ startUrl(next?: string | null, intent?: Intent, prompt?: Prompt): string;
199
235
  /**
200
236
  * Where to send somebody when a sign-in step failed for a reason they
201
237
  * cannot fix by retrying it automatically: `onError` with `?auth_error=`,
@@ -230,6 +266,19 @@ export interface Auth {
230
266
  * straight back in.
231
267
  */
232
268
  revokeSessions(userId: string): Promise<number>;
269
+ /**
270
+ * The devices page: every live session belonging to whoever is signed in
271
+ * on `request`, newest first. Namespace mode only. Throws `AuthError`
272
+ * exactly as `startPasskeyRegistration` does for nobody signed in.
273
+ */
274
+ sessions(request: Request): Promise<SessionRecord[]>;
275
+ /**
276
+ * Ends one of the signed-in person's own sessions. A key for somebody
277
+ * else, another binding, or nothing at all is refused the same way --
278
+ * `AuthError('request', ...)` -- so a guess cannot tell an id that exists
279
+ * from one that does not.
280
+ */
281
+ endSession(request: Request, key: string): Promise<void>;
233
282
  /** The auth request the issuer sent the browser here with. Refuses one for another application. */
234
283
  authRequest(id: string): Promise<AuthRequest>;
235
284
  signIn(input: {
@@ -253,6 +302,39 @@ export interface Auth {
253
302
  credential: unknown;
254
303
  name: string;
255
304
  }): Promise<PasskeyRegistrationResult>;
305
+ /**
306
+ * Starts changing the address of whoever is signed in on `request` to
307
+ * `email`. A code is mailed to that NEW address, never to the old one --
308
+ * it is what proves they hold it -- and only `completeEmailChange` moves
309
+ * the live address once that code checks out. Until then the person's
310
+ * current address is untouched and keeps working, so a code that is lost
311
+ * or never used costs nothing. Same signed-in requirement as
312
+ * `startPasskeyRegistration`.
313
+ */
314
+ startEmailChange(request: Request, email: string): Promise<void>;
315
+ /** The other half of `startEmailChange`; moves the live address once `code` checks out. */
316
+ completeEmailChange(request: Request, code: string): Promise<EmailChangeResult>;
317
+ /**
318
+ * Changes the password of whoever is signed in on `request`. The issuer
319
+ * checks `currentPassword` itself, so this never opens a session just to
320
+ * find out -- a wrong one comes back as its own error, distinct from a
321
+ * `newPassword` the issuer's own policy rejects.
322
+ */
323
+ changePassword(request: Request, input: {
324
+ currentPassword: string;
325
+ newPassword: string;
326
+ }): Promise<ChangePasswordResult>;
327
+ /**
328
+ * Every passkey this person has, `ready` or not. A person with no
329
+ * password who deletes their only `ready` passkey can lock themselves
330
+ * out of their own account -- the broker does not guard this and neither
331
+ * does the issuer, deliberately: whether that is a problem for this app
332
+ * is its call to make, not the SDK's. This returns enough to make that
333
+ * call -- an app can warn when the list holds exactly one `ready` entry.
334
+ */
335
+ passkeys(request: Request): Promise<Passkey[]>;
336
+ /** Removes one of this person's own passkeys. See `passkeys` for the lockout this does not guard against. */
337
+ deletePasskey(request: Request, id: string): Promise<DeletePasskeyResult>;
256
338
  /** The passkey half of `signIn`: a challenge to answer with `@wtfalch/auth/passkey`'s `getPasskey`. */
257
339
  startPasskeySignIn(input: {
258
340
  authRequestId: string;
package/dist/auth.js CHANGED
@@ -2,9 +2,9 @@ import { errors } from 'jose';
2
2
  import * as client from 'openid-client';
3
3
  import { Broker, BrokerError } from './broker.js';
4
4
  import { resolveOptions } from './config.js';
5
- import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openSession, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
5
+ import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openRecord, openSession, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
6
6
  import { namespaceSessions } from './namespace-session.js';
7
- import { AuthError, ORG_CLAIM, Oidc, scopeFor } from './oidc.js';
7
+ import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, scopeFor } from './oidc.js';
8
8
  import { pollDeviceToken, requestDeviceAuthorization } from './qr.js';
9
9
  import { safeNextPath } from './redirect.js';
10
10
  export function createAuth(input) {
@@ -57,8 +57,8 @@ export function createAuth(input) {
57
57
  return brokerInstance;
58
58
  };
59
59
  const sessions = namespaceSessions({ options, oidc, broker });
60
- const sessionCookie = (tokens) => options().namespace
61
- ? sessions.create(tokens)
60
+ const sessionCookie = (tokens, requestHeaders) => options().namespace
61
+ ? sessions.create(tokens, requestHeaders)
62
62
  : sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken });
63
63
  /**
64
64
  * Signing in again replaces this binding's previous session outright: in
@@ -74,13 +74,15 @@ export function createAuth(input) {
74
74
  .catch((error) => console.error('@wtfalch/auth: could not end the replaced session', error));
75
75
  };
76
76
  const unavailable = () => new Response('Sign-in is unavailable for this workspace', { status: 503 });
77
- const startUrl = (next, intent = 'login') => {
77
+ const startUrl = (next, intent = 'login', prompt) => {
78
78
  const url = new URL(`${options().basePath}/start`, options().appUrl);
79
79
  const path = safeNextPath(next, '');
80
80
  if (path)
81
81
  url.searchParams.set('next', path);
82
82
  if (intent === 'register')
83
83
  url.searchParams.set('intent', 'register');
84
+ if (prompt)
85
+ url.searchParams.set('prompt', prompt);
84
86
  return url.href;
85
87
  };
86
88
  const start = async (request) => {
@@ -95,6 +97,7 @@ export function createAuth(input) {
95
97
  const codeVerifier = client.randomPKCECodeVerifier();
96
98
  const target = await oidc().authorizationUrl(state, nonce, codeVerifier, {
97
99
  create: params.get('intent') === 'register',
100
+ prompt: promptFrom(params.get('prompt')),
98
101
  });
99
102
  const transaction = await sealTransaction(options(), {
100
103
  st: state,
@@ -104,7 +107,7 @@ export function createAuth(input) {
104
107
  });
105
108
  return redirect(target.href, [transaction]);
106
109
  };
107
- const complete = async (callbackUrl, cookieHeader) => {
110
+ const complete = async (callbackUrl, cookieHeader, requestHeaders) => {
108
111
  if (!validCallback(callbackUrl))
109
112
  return failure('request', []);
110
113
  const cleared = clearTransaction(options());
@@ -123,7 +126,7 @@ export function createAuth(input) {
123
126
  nonce: transaction.nc,
124
127
  codeVerifier: transaction.cv,
125
128
  });
126
- session = await sessionCookie(tokens);
129
+ session = await sessionCookie(tokens, requestHeaders);
127
130
  }
128
131
  catch (error) {
129
132
  return failure(reasonOf(error), [cleared]);
@@ -135,26 +138,77 @@ export function createAuth(input) {
135
138
  };
136
139
  };
137
140
  const callback = async (request) => {
138
- const { location, cookies } = await complete(request.url, request.headers.get('cookie'));
141
+ // Behind a proxy the request can arrive as http:// or on an internal host,
142
+ // and `validCallback` would refuse it. The browser reached this app, so the
143
+ // path and query are what it asked for, on the app's own origin.
144
+ const { pathname, search } = new URL(request.url);
145
+ const callbackUrl = new URL(`${pathname}${search}`, options().appUrl).href;
146
+ const { location, cookies } = await complete(callbackUrl, request.headers.get('cookie'), request.headers);
139
147
  return redirect(location, cookies);
140
148
  };
149
+ /**
150
+ * The record's own id token, read before `sessions.end` revokes it. Namespace
151
+ * sessions never hand the raw token back to a caller -- `sessions.read`
152
+ * answers with claims, not the token itself -- so logout, the one place that
153
+ * needs it for `id_token_hint`, opens the record the same way `end` does.
154
+ */
155
+ const namespaceIdToken = async (value) => {
156
+ const store = options().sessionStore;
157
+ const key = store ? await sessions.keyOf(value) : null;
158
+ if (!store || !key)
159
+ return undefined;
160
+ const record = await store.get(key).catch(() => null);
161
+ const payload = record && (await openRecord(options(), key, record.payload).catch(() => null));
162
+ return payload?.idt;
163
+ };
164
+ /**
165
+ * Where a sign-out lands: the issuer's own logout, so its session ends and
166
+ * the next sign-in asks who again, rather than straight back to
167
+ * `afterLogout`. What this function can see fail -- no id token to hint
168
+ * with, no `end_session_endpoint` published, the issuer not answering --
169
+ * falls back to the plain redirect a sign-out has always ended with.
170
+ * Losing the issuer's co-operation is never a reason to fail somebody's
171
+ * sign-out.
172
+ *
173
+ * `afterLogout` not being a registered `post_logout_redirect_uri` is a
174
+ * failure this function cannot see: that request reaches the issuer, which
175
+ * is free to answer with its own error page instead of a redirect back.
176
+ * Registering it (docs/adopting.md) is what prevents that, not a check
177
+ * here -- cookie mode has no registry to check against at runtime, and
178
+ * namespace mode already refuses to start with a binding whose
179
+ * `afterLogout` is not registered.
180
+ */
181
+ const endIssuerSession = async (idToken, cookies) => {
182
+ const afterLogout = new URL(options().afterLogout, options().appUrl).href;
183
+ const target = idToken
184
+ ? await oidc()
185
+ .endSessionUrl(idToken, afterLogout)
186
+ .catch(() => null)
187
+ : null;
188
+ return redirect(target?.href ?? afterLogout, cookies, 303);
189
+ };
141
190
  const logout = async (request) => {
142
191
  if (!isSameOrigin(request, options().appUrl.origin)) {
143
192
  return new Response('Forbidden', { status: 403 });
144
193
  }
145
194
  const value = cookieFrom(request.headers.get('cookie'), sessionCookieName(options()));
146
195
  if (options().namespace) {
196
+ const idToken = await namespaceIdToken(value);
147
197
  // Revoked in the store first: that is what ends copies of the cookie too.
148
- if ((await sessions.end(value)) === 'changed')
198
+ // `sessions.end` reaches the store, which can be briefly unreachable --
199
+ // and a sign-out that 500s over that leaves the browser's own cookie
200
+ // standing, which is worse than the record surviving a moment longer.
201
+ const status = await sessions.end(value).catch(() => 'ended');
202
+ if (status === 'changed')
149
203
  return new Response('Account changed', { status: 409 });
150
- return redirect(new URL(options().afterLogout, options().appUrl).href, [clearSession(options())], 303);
204
+ return endIssuerSession(idToken, [clearSession(options())]);
151
205
  }
152
206
  const session = await openSession(options(), value);
153
207
  if (session?.rt)
154
208
  await broker()
155
209
  .revoke(session.rt)
156
210
  .catch(() => { });
157
- return redirect(new URL(options().afterLogout, options().appUrl).href, [clearSession(options())], 303);
211
+ return endIssuerSession(session?.idt, [clearSession(options())]);
158
212
  };
159
213
  const verify = async (request) => {
160
214
  const params = new URL(request.url).searchParams;
@@ -222,8 +276,10 @@ export function createAuth(input) {
222
276
  return { user: null, cookies: [] };
223
277
  const now = Math.floor(Date.now() / 1000);
224
278
  const exp = expiryOf(session.idt);
279
+ const iat = issuedAtOf(session.idt);
225
280
  const valid = exp !== null && exp > now - 60;
226
- const due = exp === null || exp - options().refreshWindow <= now;
281
+ const stale = iat !== null && now - iat >= options().freshness;
282
+ const due = exp === null || exp - options().refreshWindow <= now || stale;
227
283
  if (due && refresh && session.rt) {
228
284
  try {
229
285
  const tokens = await oidc().refresh(session.rt);
@@ -234,9 +290,17 @@ export function createAuth(input) {
234
290
  return { user: boundUser(tokens.claims), cookies: [cookie] };
235
291
  }
236
292
  catch (error) {
237
- // A failed refresh never clears the cookie: a valid token carries on, and a dead one is replaced by the next sign-in.
238
293
  if (error instanceof AuthError)
239
294
  return { user: null, cookies: [clearSession(options())] };
295
+ // Under the issuer's own rotation nobody else can have spent this
296
+ // token, so a refusal is final -- the account is disabled or the
297
+ // grant is gone, and it stays denied on every request from here.
298
+ // The cookie is left alone though: a losing request in a genuine
299
+ // race must not blank out whatever cookie the winner's response is
300
+ // setting for the browser to pick up next.
301
+ if (isOAuthRefusal(error))
302
+ return { user: null, cookies: [] };
303
+ // A network error or a 5xx: the current token carries on, never past its own expiry.
240
304
  if (!valid)
241
305
  return { user: null, cookies: [] };
242
306
  }
@@ -294,6 +358,19 @@ export function createAuth(input) {
294
358
  throw new Error('@wtfalch/auth: revokeSessions needs namespace sessions');
295
359
  return sessions.revokeSubject(userId);
296
360
  };
361
+ const listSessions = async (request) => {
362
+ if (!options().namespace)
363
+ throw new Error('@wtfalch/auth: sessions needs namespace sessions');
364
+ const user = await currentUser(request);
365
+ return sessions.listSessions(user.id);
366
+ };
367
+ const endSession = async (request, key) => {
368
+ if (!options().namespace)
369
+ throw new Error('@wtfalch/auth: endSession needs namespace sessions');
370
+ const user = await currentUser(request);
371
+ if (!(await sessions.endSession(key, user.id)))
372
+ throw new AuthError('request', 'session does not belong to the signed-in person');
373
+ };
297
374
  const authRequest = (id) => broker().authRequest(id);
298
375
  /**
299
376
  * An OIDC flow this app starts and finishes itself, for a person arriving
@@ -318,7 +395,7 @@ export function createAuth(input) {
318
395
  return { id, state, nonce, verifier };
319
396
  };
320
397
  /** Exchanges a callback the service issued, and seals the cookie. */
321
- const finish = async (callbackUrl, flow, next) => {
398
+ const finish = async (callbackUrl, flow, next, requestHeaders) => {
322
399
  if (!validCallback(callbackUrl))
323
400
  throw new AuthError('request', 'callback does not match namespace binding');
324
401
  const tokens = await oidc().exchange(new URL(callbackUrl).searchParams, {
@@ -326,7 +403,7 @@ export function createAuth(input) {
326
403
  nonce: flow.nonce,
327
404
  codeVerifier: flow.verifier,
328
405
  });
329
- const cookie = await sessionCookie(tokens);
406
+ const cookie = await sessionCookie(tokens, requestHeaders);
330
407
  return { location: new URL(next, options().appUrl).href, cookies: [cookie] };
331
408
  };
332
409
  const hostedUrl = (id) => {
@@ -337,9 +414,9 @@ export function createAuth(input) {
337
414
  const needsHostedLogin = (error) => error instanceof BrokerError && error.error === 'hosted_login_required';
338
415
  // Email/reset flows started on the server must transfer their PKCE transaction
339
416
  // to the browser before handing off to the issuer's full authentication UI.
340
- const finishOrHandoff = async (action, flow, next) => {
417
+ const finishOrHandoff = async (action, flow, next, requestHeaders) => {
341
418
  try {
342
- return await finish(await action(), flow, next);
419
+ return await finish(await action(), flow, next, requestHeaders);
343
420
  }
344
421
  catch (error) {
345
422
  if (!needsHostedLogin(error))
@@ -362,10 +439,10 @@ export function createAuth(input) {
362
439
  const requestPasswordReset = (email, next) => quietly(() => broker().sendReset(email, safeNextPath(next, '')));
363
440
  const sendLink = (email, next) => quietly(() => broker().sendLink(email, safeNextPath(next, '')));
364
441
  const invite = (input) => broker().invite({ ...input, next: safeNextPath(input.next, '') });
365
- const resetPassword = async ({ userId, code, password, next, }) => {
442
+ const resetPassword = async ({ userId, code, password, next, }, requestHeaders) => {
366
443
  try {
367
444
  const flow = await serverFlow();
368
- const completed = await finishOrHandoff(() => broker().completeReset({ authRequestId: flow.id, userId, code, password }), flow, safeNextPath(next, options().afterLogin));
445
+ const completed = await finishOrHandoff(() => broker().completeReset({ authRequestId: flow.id, userId, code, password }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
369
446
  return { ok: true, ...completed };
370
447
  }
371
448
  catch (error) {
@@ -378,10 +455,10 @@ export function createAuth(input) {
378
455
  return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
379
456
  }
380
457
  };
381
- const followLink = async ({ sessionId, code, next, }) => {
458
+ const followLink = async ({ sessionId, code, next }, requestHeaders) => {
382
459
  try {
383
460
  const flow = await serverFlow();
384
- const completed = await finishOrHandoff(() => broker().followLink({ authRequestId: flow.id, sessionId, code }), flow, safeNextPath(next, options().afterLogin));
461
+ const completed = await finishOrHandoff(() => broker().followLink({ authRequestId: flow.id, sessionId, code }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
385
462
  return { ok: true, ...completed };
386
463
  }
387
464
  catch (error) {
@@ -401,7 +478,7 @@ export function createAuth(input) {
401
478
  sessionId,
402
479
  code,
403
480
  next: params.get('next') ?? undefined,
404
- });
481
+ }, request.headers);
405
482
  return result.ok ? redirect(result.location, result.cookies) : failed('link', []);
406
483
  };
407
484
  const signIn = async ({ authRequestId, email, password, }) => {
@@ -475,6 +552,62 @@ export function createAuth(input) {
475
552
  throw error;
476
553
  }
477
554
  };
555
+ const startEmailChange = async (request, email) => {
556
+ const user = await currentUser(request);
557
+ await broker().startEmailChange(user.id, email);
558
+ };
559
+ const completeEmailChange = async (request, code) => {
560
+ const user = await currentUser(request);
561
+ try {
562
+ await broker().completeEmailChange(user.id, code);
563
+ return { ok: true };
564
+ }
565
+ catch (error) {
566
+ if (error instanceof BrokerError && error.error === 'email_taken') {
567
+ return { ok: false, error: 'email_taken' };
568
+ }
569
+ if (error instanceof BrokerError && error.error === 'too_many') {
570
+ return { ok: false, error: 'too_many' };
571
+ }
572
+ if (error instanceof BrokerError && error.status < 500) {
573
+ return { ok: false, error: 'invalid' };
574
+ }
575
+ return { ok: false, error: 'unavailable' };
576
+ }
577
+ };
578
+ const changePassword = async (request, input) => {
579
+ const user = await currentUser(request);
580
+ try {
581
+ await broker().changePassword({ userId: user.id, ...input });
582
+ return { ok: true };
583
+ }
584
+ catch (error) {
585
+ if (error instanceof BrokerError && error.error === 'invalid_current_password') {
586
+ return { ok: false, error: 'invalid_current_password' };
587
+ }
588
+ if (error instanceof BrokerError && error.status < 500) {
589
+ return { ok: false, error: 'invalid_password' };
590
+ }
591
+ return { ok: false, error: 'unavailable' };
592
+ }
593
+ };
594
+ const passkeys = async (request) => {
595
+ const user = await currentUser(request);
596
+ const result = await broker().passkeys(user.id);
597
+ return result.passkeys;
598
+ };
599
+ const deletePasskey = async (request, id) => {
600
+ const user = await currentUser(request);
601
+ try {
602
+ await broker().deletePasskey(user.id, id);
603
+ return { ok: true };
604
+ }
605
+ catch (error) {
606
+ if (error instanceof BrokerError && error.status < 500)
607
+ return { ok: false, error: 'invalid' };
608
+ throw error;
609
+ }
610
+ };
478
611
  const startPasskeySignIn = async ({ authRequestId, email, }) => {
479
612
  try {
480
613
  const { sessionId, options } = await broker().startPasskeySignIn({ authRequestId, email });
@@ -543,7 +676,7 @@ export function createAuth(input) {
543
676
  return { status: 'unavailable', cookies: [cleared] };
544
677
  let session;
545
678
  try {
546
- session = await sessionCookie(outcome.tokens);
679
+ session = await sessionCookie(outcome.tokens, request.headers);
547
680
  }
548
681
  catch (error) {
549
682
  return { status: 'failed', reason: reasonOf(error), cookies: [cleared] };
@@ -587,11 +720,18 @@ export function createAuth(input) {
587
720
  people,
588
721
  setPersonActive,
589
722
  revokeSessions,
723
+ sessions: listSessions,
724
+ endSession,
590
725
  authRequest,
591
726
  signIn,
592
727
  signUp,
593
728
  startPasskeyRegistration,
594
729
  finishPasskeyRegistration,
730
+ startEmailChange,
731
+ completeEmailChange,
732
+ changePassword,
733
+ passkeys,
734
+ deletePasskey,
595
735
  startPasskeySignIn,
596
736
  finishPasskeySignIn,
597
737
  startQrSignIn,
@@ -627,6 +767,22 @@ function expiryOf(idToken) {
627
767
  return null;
628
768
  }
629
769
  }
770
+ // Age, not time left: a disabled or revoked account must not ride out a
771
+ // long-lived token just because it still has plenty of time on the clock.
772
+ function issuedAtOf(idToken) {
773
+ try {
774
+ const [, payload] = idToken.split('.');
775
+ const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
776
+ return typeof json.iat === 'number' ? json.iat : null;
777
+ }
778
+ catch {
779
+ return null;
780
+ }
781
+ }
782
+ const PROMPTS = ['select_account', 'login'];
783
+ function promptFrom(raw) {
784
+ return PROMPTS.includes(raw ?? '') ? raw : undefined;
785
+ }
630
786
  function userFrom(claims) {
631
787
  const str = (key) => typeof claims[key] === 'string' ? claims[key] : null;
632
788
  return {
package/dist/broker.d.ts CHANGED
@@ -123,6 +123,22 @@ export declare class Broker {
123
123
  revoked?: string[];
124
124
  }>;
125
125
  verifyEmail(userId: string, code: string): Promise<unknown>;
126
+ /** Mails a code to `email`, never to the address it would replace -- see `Auth['startEmailChange']`. */
127
+ startEmailChange(userId: string, email: string): Promise<unknown>;
128
+ completeEmailChange(userId: string, code: string): Promise<unknown>;
129
+ changePassword(input: {
130
+ userId: string;
131
+ currentPassword: string;
132
+ newPassword: string;
133
+ }): Promise<unknown>;
134
+ passkeys(userId: string): Promise<{
135
+ passkeys: {
136
+ id: string;
137
+ name: string;
138
+ state: string;
139
+ }[];
140
+ }>;
141
+ deletePasskey(userId: string, passkeyId: string): Promise<unknown>;
126
142
  /** A public client revoking its own token: no key, no service. */
127
143
  revoke(refreshToken: string): Promise<void>;
128
144
  }
package/dist/broker.js CHANGED
@@ -123,6 +123,22 @@ export class Broker {
123
123
  verifyEmail(userId, code) {
124
124
  return this.call('POST', '/verify', { userId, code });
125
125
  }
126
+ /** Mails a code to `email`, never to the address it would replace -- see `Auth['startEmailChange']`. */
127
+ startEmailChange(userId, email) {
128
+ return this.call('POST', '/email/change/send', { userId, email });
129
+ }
130
+ completeEmailChange(userId, code) {
131
+ return this.call('POST', '/email/change/complete', { userId, code });
132
+ }
133
+ changePassword(input) {
134
+ return this.call('POST', '/password/change', input);
135
+ }
136
+ passkeys(userId) {
137
+ return this.call('GET', '/passkeys', { userId });
138
+ }
139
+ deletePasskey(userId, passkeyId) {
140
+ return this.call('POST', '/passkeys/delete', { userId, passkeyId });
141
+ }
126
142
  /** A public client revoking its own token: no key, no service. */
127
143
  async revoke(refreshToken) {
128
144
  await this.options.fetch(`${this.options.issuer}/oauth/v2/revoke`, {
package/dist/config.d.ts CHANGED
@@ -43,6 +43,12 @@ export interface AuthOptions {
43
43
  sessionMaxAge?: number;
44
44
  /** Refresh when the id token has fewer seconds left than this. Default 300. */
45
45
  refreshWindow?: number;
46
+ /**
47
+ * Refresh when the id token is older than this, however far it is from
48
+ * expiry -- the propagation bound for a disabled or revoked account.
49
+ * Default 300. Both modes.
50
+ */
51
+ freshness?: number;
46
52
  fetch?: typeof fetch;
47
53
  }
48
54
  export interface ResolvedOptions {
@@ -74,6 +80,7 @@ export interface ResolvedOptions {
74
80
  brokerUrl: string;
75
81
  sessionMaxAge: number;
76
82
  refreshWindow: number;
83
+ freshness: number;
77
84
  secure: boolean;
78
85
  fetch: typeof fetch;
79
86
  }
package/dist/config.js CHANGED
@@ -55,6 +55,7 @@ export function resolveOptions(options) {
55
55
  brokerUrl: (options.brokerUrl ?? `${(options.issuer ?? ISSUER).replace(/\/$/, '')}/api`).replace(/\/$/, ''),
56
56
  sessionMaxAge: seconds(options.sessionMaxAge, 'sessionMaxAge', 30 * 24 * 60 * 60),
57
57
  refreshWindow: seconds(options.refreshWindow, 'refreshWindow', 5 * 60),
58
+ freshness: seconds(options.freshness, 'freshness', 5 * 60),
58
59
  secure: appUrl.protocol === 'https:',
59
60
  fetch: options.fetch ?? globalThis.fetch,
60
61
  };
@@ -3,6 +3,7 @@ import { type Broker } from './broker.js';
3
3
  import type { ResolvedOptions } from './config.js';
4
4
  import { type SetCookie } from './cookies.js';
5
5
  import { type Oidc, type Tokens } from './oidc.js';
6
+ import type { SessionRecord } from './sessions.js';
6
7
  /** How long a binding status answer is trusted. The propagation bound for a suspension. */
7
8
  export declare const STATUS_FRESH_MS = 60000;
8
9
  /** How long the last good answer carries on while the service cannot be asked. */
@@ -34,9 +35,11 @@ export declare function namespaceSessions(deps: {
34
35
  broker: () => Broker;
35
36
  }): {
36
37
  bindingActive: () => Promise<boolean>;
37
- create: (tokens: Tokens) => Promise<SetCookie>;
38
+ create: (tokens: Tokens, requestHeaders?: Headers) => Promise<SetCookie>;
38
39
  end: (value: string | undefined) => Promise<"ended" | "changed" | "none">;
39
40
  read: (value: string | undefined, force?: boolean) => Promise<SessionRead>;
40
41
  revokeSubject: (subject: string) => Promise<number>;
42
+ listSessions: (subject: string) => Promise<SessionRecord[]>;
43
+ endSession: (key: string, subject: string) => Promise<boolean>;
41
44
  keyOf: (value: string | undefined) => Promise<string | null>;
42
45
  };
@@ -1,7 +1,7 @@
1
1
  import * as client from 'openid-client';
2
2
  import { BrokerError } from './broker.js';
3
3
  import { contextDigest, digest, openHandle, openRecord, sealHandle, sealRecord, } from './cookies.js';
4
- import { AuthError } from './oidc.js';
4
+ import { AuthError, isOAuthRefusal } from './oidc.js';
5
5
  /** How long a binding status answer is trusted. The propagation bound for a suspension. */
6
6
  export const STATUS_FRESH_MS = 60_000;
7
7
  /** How long the last good answer carries on while the service cannot be asked. */
@@ -127,10 +127,15 @@ export function namespaceSessions(deps) {
127
127
  .revoke(refreshToken)
128
128
  .catch(() => { });
129
129
  };
130
- /** A new record and the cookie that points at it. */
131
- const create = async (tokens) => {
130
+ /**
131
+ * A new record and the cookie that points at it. `requestHeaders` is
132
+ * whatever the request that signed the person in carried; omitted where
133
+ * none exists (there is no browser request behind a server-initiated flow).
134
+ */
135
+ const create = async (tokens, requestHeaders) => {
132
136
  const sid = client.randomState();
133
137
  const key = await digest(sid);
138
+ const at = now();
134
139
  await store().create({
135
140
  key,
136
141
  context: await contextDigest(options()),
@@ -138,8 +143,12 @@ export function namespaceSessions(deps) {
138
143
  generation: 0,
139
144
  leaseUntil: null,
140
145
  payload: await seal(key, tokens),
141
- expiresAt: now() + options().sessionMaxAge,
146
+ expiresAt: at + options().sessionMaxAge,
142
147
  revokedAt: null,
148
+ createdAt: at,
149
+ lastSeenAt: at,
150
+ userAgent: requestHeaders?.get('user-agent') ?? null,
151
+ ip: ipFrom(requestHeaders),
143
152
  });
144
153
  return sealHandle(options(), sid);
145
154
  };
@@ -156,9 +165,32 @@ export function namespaceSessions(deps) {
156
165
  return 'ended';
157
166
  };
158
167
  const revokeSubject = async (subject) => store().revokeSubject(await contextDigest(options()), subject, now());
168
+ /** A subject's live sessions under this binding, newest first: the devices page. */
169
+ const listSessions = async (subject) => store().listSubject(await contextDigest(options()), subject);
170
+ /**
171
+ * Ends one of a subject's sessions. Resolves whether it did: false for a
172
+ * key that is unknown, already over, somebody else's, or another binding's
173
+ * -- the same answer either way, so a guess at another person's key cannot
174
+ * be told apart from a stranger's.
175
+ */
176
+ const endSession = async (key, subject) => {
177
+ const record = await store().get(key);
178
+ if (!record ||
179
+ record.subject !== subject ||
180
+ record.context !== (await contextDigest(options())))
181
+ return false;
182
+ await store().revoke(key, now());
183
+ return true;
184
+ };
159
185
  // ---- refresh ------------------------------------------------------------
160
- const definitive = (error) => error instanceof AuthError ||
161
- (error instanceof client.ResponseBodyError && error.error === 'invalid_grant');
186
+ /**
187
+ * Under the lease nobody else can have spent the token, so any 4xx the
188
+ * issuer answers with is the grant itself gone -- the account disabled, the
189
+ * refresh token revoked -- never a rival request. This issuer does not
190
+ * reserve `invalid_grant` for that: a deactivated user and a dead refresh
191
+ * token both come back `invalid_request`, so only the status class counts.
192
+ */
193
+ const definitive = (error) => error instanceof AuthError || isOAuthRefusal(error);
162
194
  /**
163
195
  * Refresh under the lease. Resolves the freshest usable tokens: new ones,
164
196
  * another request's, or null when the session is over. While another
@@ -250,8 +282,10 @@ export function namespaceSessions(deps) {
250
282
  if (!loaded)
251
283
  return { kind: 'none' };
252
284
  const exp = expiryOf(loaded.tokens.idt);
285
+ const iat = issuedAtOf(loaded.tokens.idt);
253
286
  const valid = exp !== null && exp > now() - 60;
254
- const due = force || exp === null || exp - options().refreshWindow <= now();
287
+ const stale = iat !== null && now() - iat >= options().freshness;
288
+ const due = force || exp === null || exp - options().refreshWindow <= now() || stale;
255
289
  if (due && loaded.tokens.rt) {
256
290
  loaded = await refresh(loaded, valid);
257
291
  if (!loaded)
@@ -264,7 +298,31 @@ export function namespaceSessions(deps) {
264
298
  return { kind: 'none' };
265
299
  }
266
300
  };
267
- return { bindingActive, create, end, read, revokeSubject, keyOf };
301
+ return { bindingActive, create, end, read, revokeSubject, listSessions, endSession, keyOf };
302
+ }
303
+ /**
304
+ * These apps sit behind a proxy, so the client never reaches this process
305
+ * directly. `x-real-ip` first: a proxy that sets it overwrites rather than
306
+ * appends, so it cannot be a client-supplied value riding along underneath
307
+ * the real one. Otherwise the rightmost `x-forwarded-for` hop -- the one the
308
+ * nearest proxy itself observed and prepended, not the leftmost one, which
309
+ * is exactly the entry a client sends and controls, so trusting it would
310
+ * let anyone write whatever address they like into this record. There is no
311
+ * trusted-proxy config -- this value is only ever as trustworthy as
312
+ * whatever sits in front of the app.
313
+ */
314
+ function ipFrom(headers) {
315
+ const realIp = headers?.get('x-real-ip');
316
+ if (realIp)
317
+ return realIp;
318
+ const forwarded = headers?.get('x-forwarded-for');
319
+ if (!forwarded)
320
+ return null;
321
+ const hops = forwarded
322
+ .split(',')
323
+ .map((hop) => hop.trim())
324
+ .filter(Boolean);
325
+ return hops.length > 0 ? hops[hops.length - 1] : null;
268
326
  }
269
327
  function expiryOf(idToken) {
270
328
  try {
@@ -276,3 +334,15 @@ function expiryOf(idToken) {
276
334
  return null;
277
335
  }
278
336
  }
337
+ // Age, not time left: a disabled or revoked account must not ride out a
338
+ // long-lived token just because it still has plenty of time on the clock.
339
+ function issuedAtOf(idToken) {
340
+ try {
341
+ const [, payload] = idToken.split('.');
342
+ const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
343
+ return typeof json.iat === 'number' ? json.iat : null;
344
+ }
345
+ catch {
346
+ return null;
347
+ }
348
+ }
package/dist/next.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type NextRequest, NextResponse } from 'next/server';
2
- import { type Auth, type AuthRequest, type GateRules, type Intent, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type QrPollStatus, type ResetError, type SignInError, type SignUpError, type User } from './auth.js';
2
+ import { type Auth, type AuthRequest, type ChangePasswordResult, type DeletePasskeyResult, type EmailChangeResult, type GateRules, type Intent, type NewUser, type Passkey, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type QrPollStatus, type ResetError, type SignInError, type SignUpError, type User } from './auth.js';
3
3
  import type { AuthOptions } from './config.js';
4
+ import type { SessionRecord } from './sessions.js';
4
5
  type SearchParams = Record<string, string | string[] | undefined>;
5
6
  export interface QrSignIn {
6
7
  url: string;
@@ -63,6 +64,13 @@ export interface NextAuth {
63
64
  people: Auth['people'];
64
65
  /** Deactivate or reactivate somebody in this app's organisation. */
65
66
  setPersonActive: Auth['setPersonActive'];
67
+ /** The devices page: the signed-in person's own live sessions, newest first. Namespace mode only. */
68
+ sessions: () => Promise<SessionRecord[]>;
69
+ /**
70
+ * Ends one of the signed-in person's own sessions. Throws `AuthError('request', ...)`
71
+ * for a key that is somebody else's, another binding's, or does not exist.
72
+ */
73
+ endSession: (key: string) => Promise<void>;
66
74
  /** Sets the password from a reset link, signs the person in, and redirects. */
67
75
  resetPassword: (input: {
68
76
  userId: string;
@@ -85,6 +93,19 @@ export interface NextAuth {
85
93
  credential: unknown;
86
94
  name: string;
87
95
  }) => Promise<PasskeyRegistrationResult>;
96
+ /** See `Auth['startEmailChange']`: mails a code to the new address and leaves the old one working until `completeEmailChange` checks it. */
97
+ startEmailChange: (email: string) => Promise<void>;
98
+ /** The other half of `startEmailChange`. */
99
+ completeEmailChange: (code: string) => Promise<EmailChangeResult>;
100
+ /** Changes the signed-in person's password. See `Auth['changePassword']` for how a wrong current password is told apart from a rejected new one. */
101
+ changePassword: (input: {
102
+ currentPassword: string;
103
+ newPassword: string;
104
+ }) => Promise<ChangePasswordResult>;
105
+ /** See `Auth['passkeys']`: enough to warn when this is the only one left. */
106
+ passkeys: () => Promise<Passkey[]>;
107
+ /** Removes one of this person's own passkeys. */
108
+ deletePasskey: (id: string) => Promise<DeletePasskeyResult>;
88
109
  /** The passkey half of `signIn`: a challenge to answer with `@wtfalch/auth/passkey`'s `getPasskey`. */
89
110
  startPasskeySignIn: (input: {
90
111
  authRequestId: string;
@@ -116,4 +137,4 @@ export interface NextAuth {
116
137
  }>;
117
138
  }
118
139
  export declare function nextAuth(options: AuthOptions): NextAuth;
119
- export type { Auth, AuthOptions, AuthRequest, GateRules, Intent, NewUser, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, QrPollStatus, User, };
140
+ export type { Auth, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, QrPollStatus, SessionRecord, User, };
package/dist/next.js CHANGED
@@ -1,4 +1,4 @@
1
- import { cookies } from 'next/headers';
1
+ import { cookies, headers } from 'next/headers';
2
2
  import { redirect } from 'next/navigation';
3
3
  import { NextResponse } from 'next/server';
4
4
  import { createAuth, } from './auth.js';
@@ -105,11 +105,8 @@ export function nextAuth(options) {
105
105
  // A server action cannot redirect into a route handler, so the callback runs here and the cookies are set on the action's response.
106
106
  const finish = async (callbackUrl) => {
107
107
  const store = await cookies();
108
- const header = store
109
- .getAll()
110
- .map((c) => `${c.name}=${c.value}`)
111
- .join('; ');
112
- const { location, cookies: set } = await auth.complete(callbackUrl, header);
108
+ const request = await requestFromCookies();
109
+ const { location, cookies: set } = await auth.complete(callbackUrl, request.headers.get('cookie'), request.headers);
113
110
  for (const c of set) {
114
111
  if (c.value)
115
112
  store.set(c.name, c.value, c.attributes);
@@ -119,20 +116,37 @@ export function nextAuth(options) {
119
116
  const url = new URL(location);
120
117
  redirect(`${url.pathname}${url.search}`);
121
118
  };
122
- // The passkey routes take a `Request`; a server action has none of its
123
- // own, only what `cookies()` already exposes for this request. Only the
124
- // cookie header matters to `read`, so a synthetic request carrying just
125
- // that is exactly as good as the real one.
119
+ // The passkey and session routes take a `Request`; a server action has
120
+ // none of its own, only what `cookies()`/`headers()` already expose for
121
+ // this invocation. `cookies()` (not the incoming `cookie` header) is the
122
+ // source of truth for the jar, since it reflects whatever this action has
123
+ // already set or cleared; the device headers `namespaceSessions` reads on
124
+ // sign-in come along too, so a session created from a server action still
125
+ // gets a real user agent and address.
126
126
  const requestFromCookies = async () => {
127
127
  const store = await cookies();
128
- const header = store
128
+ const cookieHeader = store
129
129
  .getAll()
130
130
  .map((c) => `${c.name}=${c.value}`)
131
131
  .join('; ');
132
- return new Request('http://localhost/', header ? { headers: { cookie: header } } : undefined);
132
+ const requestHeaders = new Headers();
133
+ if (cookieHeader)
134
+ requestHeaders.set('cookie', cookieHeader);
135
+ const incoming = await headers();
136
+ for (const name of ['user-agent', 'x-forwarded-for', 'x-real-ip']) {
137
+ const value = incoming.get(name);
138
+ if (value)
139
+ requestHeaders.set(name, value);
140
+ }
141
+ return new Request('http://localhost/', { headers: requestHeaders });
133
142
  };
134
143
  const startPasskeyRegistration = async () => auth.startPasskeyRegistration(await requestFromCookies());
135
144
  const finishPasskeyRegistration = async (input) => auth.finishPasskeyRegistration(await requestFromCookies(), input);
145
+ const startEmailChange = async (email) => auth.startEmailChange(await requestFromCookies(), email);
146
+ const completeEmailChange = async (code) => auth.completeEmailChange(await requestFromCookies(), code);
147
+ const changePassword = async (input) => auth.changePassword(await requestFromCookies(), input);
148
+ const passkeys = async () => auth.passkeys(await requestFromCookies());
149
+ const deletePasskey = async (id) => auth.deletePasskey(await requestFromCookies(), id);
136
150
  const finishPasskeySignIn = async (input) => {
137
151
  const result = await auth.finishPasskeySignIn(input);
138
152
  if (result.ok) {
@@ -143,7 +157,7 @@ export function nextAuth(options) {
143
157
  return { error: result.error };
144
158
  };
145
159
  const resetPassword = async (input) => {
146
- const result = await auth.resetPassword(input);
160
+ const result = await auth.resetPassword(input, (await requestFromCookies()).headers);
147
161
  if (!result.ok)
148
162
  return { error: result.error, message: result.message };
149
163
  const store = await cookies();
@@ -198,6 +212,8 @@ export function nextAuth(options) {
198
212
  }
199
213
  return reason ? { status, reason } : { status };
200
214
  };
215
+ const sessionsList = async () => auth.sessions(await requestFromCookies());
216
+ const endSession = async (key) => auth.endSession(await requestFromCookies(), key);
201
217
  return {
202
218
  auth,
203
219
  handlers: { GET: auth.handle, POST: auth.handle },
@@ -213,9 +229,16 @@ export function nextAuth(options) {
213
229
  invite: auth.invite,
214
230
  people: auth.people,
215
231
  setPersonActive: auth.setPersonActive,
232
+ sessions: sessionsList,
233
+ endSession,
216
234
  resetPassword,
217
235
  startPasskeyRegistration,
218
236
  finishPasskeyRegistration,
237
+ startEmailChange,
238
+ completeEmailChange,
239
+ changePassword,
240
+ passkeys,
241
+ deletePasskey,
219
242
  startPasskeySignIn: auth.startPasskeySignIn,
220
243
  finishPasskeySignIn,
221
244
  startQrSignIn,
package/dist/oidc.d.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import * as jose from 'jose';
2
2
  import type { ResolvedOptions } from './config.js';
3
3
  export declare const ORG_CLAIM = "urn:zitadel:iam:user:resourceowner:id";
4
+ /**
5
+ * How long a rotated refresh token still answers with the tokens it rotated
6
+ * to. A request that started just before another one finished the same
7
+ * rotation would otherwise present the now-dead token and be refused as if
8
+ * the grant were revoked; this is the single-process fix. A second server,
9
+ * or a request more than a minute behind, still gets the real refusal.
10
+ */
11
+ export declare const ROTATION_CACHE_MS = 60000;
4
12
  /** The scopes every flow asks for: the code-exchange authorization request and the QR device grant alike. */
5
13
  export declare function scopeFor(organizationId: string): string;
6
14
  export interface Tokens {
@@ -13,12 +21,14 @@ export declare class Oidc {
13
21
  private configuration;
14
22
  private jwks;
15
23
  private refreshing;
24
+ private rotated;
16
25
  constructor(options: ResolvedOptions);
17
26
  private config;
18
27
  private keys;
19
28
  get redirectUri(): URL;
20
- authorizationUrl(state: string, nonce: string, codeVerifier: string, { create }?: {
29
+ authorizationUrl(state: string, nonce: string, codeVerifier: string, { create, prompt }?: {
21
30
  create?: boolean;
31
+ prompt?: 'select_account' | 'login';
22
32
  }): Promise<URL>;
23
33
  exchange(callbackParams: URLSearchParams, transaction: {
24
34
  state: string;
@@ -26,6 +36,12 @@ export declare class Oidc {
26
36
  codeVerifier: string;
27
37
  }): Promise<Tokens>;
28
38
  refresh(refreshToken: string): Promise<Tokens>;
39
+ /**
40
+ * The issuer's RP-Initiated Logout URL, or null when discovery publishes no
41
+ * `end_session_endpoint` -- an issuer is free not to support it, and a
42
+ * sign-out has to succeed either way.
43
+ */
44
+ endSessionUrl(idToken: string, postLogoutRedirectUri: string): Promise<URL | null>;
29
45
  private tokensFrom;
30
46
  verify(idToken: string): Promise<jose.JWTPayload>;
31
47
  private assertOrganization;
@@ -37,3 +53,16 @@ export declare class AuthError extends Error {
37
53
  readonly reason: AuthErrorReason;
38
54
  constructor(reason: AuthErrorReason, message: string);
39
55
  }
56
+ /**
57
+ * A refusal from the token endpoint itself -- the grant is gone, the account
58
+ * is disabled -- as opposed to the issuer being unreachable, rate-limiting,
59
+ * or having a bad day. Keyed on the HTTP status alone, never the body: a
60
+ * WAF or CDN in front of the issuer can answer a genuine refusal as HTML,
61
+ * which oauth4webapi cannot parse as an OAuth error, but it still hands back
62
+ * the response that carried the status. Trusting only a parsed `error` field
63
+ * would treat that case as an outage and let the old bug back in through a
64
+ * path nobody would look at. 429 is excluded on purpose: "come back later"
65
+ * is never a statement that the grant is dead, and this issuer's own rate
66
+ * limiting must not permanently revoke a session.
67
+ */
68
+ export declare function isOAuthRefusal(error: unknown): boolean;
package/dist/oidc.js CHANGED
@@ -1,6 +1,14 @@
1
1
  import * as jose from 'jose';
2
2
  import * as client from 'openid-client';
3
3
  export const ORG_CLAIM = 'urn:zitadel:iam:user:resourceowner:id';
4
+ /**
5
+ * How long a rotated refresh token still answers with the tokens it rotated
6
+ * to. A request that started just before another one finished the same
7
+ * rotation would otherwise present the now-dead token and be refused as if
8
+ * the grant were revoked; this is the single-process fix. A second server,
9
+ * or a request more than a minute behind, still gets the real refusal.
10
+ */
11
+ export const ROTATION_CACHE_MS = 60_000;
4
12
  /** The scopes every flow asks for: the code-exchange authorization request and the QR device grant alike. */
5
13
  export function scopeFor(organizationId) {
6
14
  return [
@@ -17,6 +25,7 @@ export class Oidc {
17
25
  configuration = null;
18
26
  jwks = null;
19
27
  refreshing = new Map();
28
+ rotated = new Map();
20
29
  constructor(options) {
21
30
  this.options = options;
22
31
  }
@@ -53,9 +62,9 @@ export class Oidc {
53
62
  get redirectUri() {
54
63
  return new URL(`${this.options.basePath}/callback`, this.options.appUrl);
55
64
  }
56
- async authorizationUrl(state, nonce, codeVerifier, { create = false } = {}) {
65
+ async authorizationUrl(state, nonce, codeVerifier, { create = false, prompt } = {}) {
57
66
  return client.buildAuthorizationUrl(await this.config(), {
58
- ...(create ? { prompt: 'create' } : {}),
67
+ ...(create ? { prompt: 'create' } : prompt ? { prompt } : {}),
59
68
  redirect_uri: this.redirectUri.href,
60
69
  scope: scopeFor(this.options.organizationId),
61
70
  state,
@@ -78,16 +87,41 @@ export class Oidc {
78
87
  }
79
88
  // Parallel requests holding the same refresh token share one grant; the issuer rotates it, so a second grant would fail.
80
89
  refresh(refreshToken) {
90
+ const asked = Date.now();
91
+ for (const [token, entry] of this.rotated)
92
+ if (entry.until <= asked)
93
+ this.rotated.delete(token);
94
+ const cached = this.rotated.get(refreshToken);
95
+ if (cached)
96
+ return Promise.resolve(cached.tokens);
81
97
  let pending = this.refreshing.get(refreshToken);
82
98
  if (!pending) {
83
99
  pending = this.config()
84
100
  .then((config) => client.refreshTokenGrant(config, refreshToken))
85
101
  .then((response) => this.tokensFrom(response, refreshToken))
102
+ .then((tokens) => {
103
+ this.rotated.set(refreshToken, { tokens, until: Date.now() + ROTATION_CACHE_MS });
104
+ return tokens;
105
+ })
86
106
  .finally(() => this.refreshing.delete(refreshToken));
87
107
  this.refreshing.set(refreshToken, pending);
88
108
  }
89
109
  return pending;
90
110
  }
111
+ /**
112
+ * The issuer's RP-Initiated Logout URL, or null when discovery publishes no
113
+ * `end_session_endpoint` -- an issuer is free not to support it, and a
114
+ * sign-out has to succeed either way.
115
+ */
116
+ async endSessionUrl(idToken, postLogoutRedirectUri) {
117
+ const config = await this.config();
118
+ if (!config.serverMetadata().end_session_endpoint)
119
+ return null;
120
+ return client.buildEndSessionUrl(config, {
121
+ id_token_hint: idToken,
122
+ post_logout_redirect_uri: postLogoutRedirectUri,
123
+ });
124
+ }
91
125
  tokensFrom(response, previousRefreshToken) {
92
126
  const claims = response.claims();
93
127
  if (!response.id_token || !claims) {
@@ -130,3 +164,25 @@ export class AuthError extends Error {
130
164
  this.name = 'AuthError';
131
165
  }
132
166
  }
167
+ /**
168
+ * A refusal from the token endpoint itself -- the grant is gone, the account
169
+ * is disabled -- as opposed to the issuer being unreachable, rate-limiting,
170
+ * or having a bad day. Keyed on the HTTP status alone, never the body: a
171
+ * WAF or CDN in front of the issuer can answer a genuine refusal as HTML,
172
+ * which oauth4webapi cannot parse as an OAuth error, but it still hands back
173
+ * the response that carried the status. Trusting only a parsed `error` field
174
+ * would treat that case as an outage and let the old bug back in through a
175
+ * path nobody would look at. 429 is excluded on purpose: "come back later"
176
+ * is never a statement that the grant is dead, and this issuer's own rate
177
+ * limiting must not permanently revoke a session.
178
+ */
179
+ export function isOAuthRefusal(error) {
180
+ const status = statusOf(error);
181
+ return status !== undefined && status >= 400 && status < 500 && status !== 429;
182
+ }
183
+ function statusOf(error) {
184
+ if (error instanceof client.ResponseBodyError)
185
+ return error.status;
186
+ const cause = error instanceof Error ? error.cause : undefined;
187
+ return cause instanceof Response ? cause.status : undefined;
188
+ }
@@ -32,19 +32,33 @@ export interface SessionRecord {
32
32
  expiresAt: number;
33
33
  /** Unix seconds, or null while the session is live. */
34
34
  revokedAt: number | null;
35
+ /**
36
+ * Unix seconds the record was made, or null for a row written before this
37
+ * column existed. Nullable so an old row keeps working unmigrated.
38
+ */
39
+ createdAt: number | null;
40
+ /** Unix seconds, bumped whenever `advance` succeeds. Null for the same reason as `createdAt`. */
41
+ lastSeenAt: number | null;
42
+ /** The signing-in request's `User-Agent`, or null if it sent none or the row predates this column. */
43
+ userAgent: string | null;
44
+ /** The signing-in request's address as the proxy in front reported it, or null. See `ipFrom` in namespace-session.ts for how much to trust it. */
45
+ ip: string | null;
35
46
  }
36
47
  export type SessionUpdate = Pick<SessionRecord, 'payload' | 'expiresAt' | 'leaseUntil'>;
37
48
  export interface SessionStore {
38
49
  create(record: SessionRecord): Promise<void>;
39
50
  get(key: string): Promise<SessionRecord | null>;
40
51
  /**
41
- * Replace the payload, expiry and lease, and bump the generation, only if the
42
- * record is still at `generation` and not revoked. Resolves whether it did.
52
+ * Replace the payload, expiry and lease, bump the generation and touch
53
+ * `lastSeenAt`, only if the record is still at `generation` and not
54
+ * revoked. Resolves whether it did.
43
55
  */
44
56
  advance(key: string, generation: number, next: SessionUpdate): Promise<boolean>;
45
57
  revoke(key: string, at: number): Promise<void>;
46
58
  /** Revoke every live record for this subject under this binding context. Resolves how many. */
47
59
  revokeSubject(context: string, subject: string, at: number): Promise<number>;
60
+ /** Live (not revoked, not expired) records for this subject under this binding context, newest first: the devices page. */
61
+ listSubject(context: string, subject: string): Promise<SessionRecord[]>;
48
62
  }
49
63
  /**
50
64
  * One process only. For tests and a single-instance development server: two
@@ -52,7 +66,13 @@ export interface SessionStore {
52
66
  */
53
67
  export declare function createMemorySessionStore(): SessionStore;
54
68
  /** The table `createPostgresSessionStore` expects. Run it in the app's own migrations. */
55
- export declare const POSTGRES_SESSION_TABLE = "create table if not exists wtfalch_auth_sessions (\n key text primary key,\n context text not null,\n subject text not null,\n generation integer not null,\n payload text not null,\n expires_at bigint not null,\n lease_until bigint,\n revoked_at bigint\n);\ncreate index if not exists wtfalch_auth_sessions_subject on wtfalch_auth_sessions (context, subject);\ncreate index if not exists wtfalch_auth_sessions_expiry on wtfalch_auth_sessions (expires_at);";
69
+ export declare const POSTGRES_SESSION_TABLE = "create table if not exists wtfalch_auth_sessions (\n key text primary key,\n context text not null,\n subject text not null,\n generation integer not null,\n payload text not null,\n expires_at bigint not null,\n lease_until bigint,\n revoked_at bigint,\n created_at bigint,\n last_seen_at bigint,\n user_agent text,\n ip text\n);\ncreate index if not exists wtfalch_auth_sessions_subject on wtfalch_auth_sessions (context, subject);\ncreate index if not exists wtfalch_auth_sessions_expiry on wtfalch_auth_sessions (expires_at);";
70
+ /**
71
+ * `POSTGRES_SESSION_TABLE` already has these columns for a fresh table. A
72
+ * consumer whose table predates them runs this once, in its own migrations,
73
+ * to catch up; `add column if not exists` makes it safe to run again.
74
+ */
75
+ export declare const POSTGRES_SESSION_MIGRATION_1 = "alter table wtfalch_auth_sessions add column if not exists created_at bigint;\nalter table wtfalch_auth_sessions add column if not exists last_seen_at bigint;\nalter table wtfalch_auth_sessions add column if not exists user_agent text;\nalter table wtfalch_auth_sessions add column if not exists ip text;";
56
76
  type Row = Record<string, unknown>;
57
77
  /**
58
78
  * Postgres through whatever driver the app already has. `query` runs one
package/dist/sessions.js CHANGED
@@ -24,7 +24,12 @@ export function createMemorySessionStore() {
24
24
  const record = records.get(key);
25
25
  if (!record || record.revokedAt !== null || record.generation !== generation)
26
26
  return false;
27
- records.set(key, { ...record, ...next, generation: generation + 1 });
27
+ records.set(key, {
28
+ ...record,
29
+ ...next,
30
+ generation: generation + 1,
31
+ lastSeenAt: Math.floor(Date.now() / 1000),
32
+ });
28
33
  return true;
29
34
  },
30
35
  async revoke(key, at) {
@@ -41,6 +46,16 @@ export function createMemorySessionStore() {
41
46
  }
42
47
  return count;
43
48
  },
49
+ async listSubject(context, subject) {
50
+ const nowSeconds = Math.floor(Date.now() / 1000);
51
+ return [...records.values()]
52
+ .filter((r) => r.context === context &&
53
+ r.subject === subject &&
54
+ r.revokedAt === null &&
55
+ r.expiresAt > nowSeconds)
56
+ .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
57
+ .map((r) => ({ ...r }));
58
+ },
44
59
  };
45
60
  }
46
61
  /** The table `createPostgresSessionStore` expects. Run it in the app's own migrations. */
@@ -52,11 +67,25 @@ export const POSTGRES_SESSION_TABLE = `create table if not exists wtfalch_auth_s
52
67
  payload text not null,
53
68
  expires_at bigint not null,
54
69
  lease_until bigint,
55
- revoked_at bigint
70
+ revoked_at bigint,
71
+ created_at bigint,
72
+ last_seen_at bigint,
73
+ user_agent text,
74
+ ip text
56
75
  );
57
76
  create index if not exists wtfalch_auth_sessions_subject on wtfalch_auth_sessions (context, subject);
58
77
  create index if not exists wtfalch_auth_sessions_expiry on wtfalch_auth_sessions (expires_at);`;
78
+ /**
79
+ * `POSTGRES_SESSION_TABLE` already has these columns for a fresh table. A
80
+ * consumer whose table predates them runs this once, in its own migrations,
81
+ * to catch up; `add column if not exists` makes it safe to run again.
82
+ */
83
+ export const POSTGRES_SESSION_MIGRATION_1 = `alter table wtfalch_auth_sessions add column if not exists created_at bigint;
84
+ alter table wtfalch_auth_sessions add column if not exists last_seen_at bigint;
85
+ alter table wtfalch_auth_sessions add column if not exists user_agent text;
86
+ alter table wtfalch_auth_sessions add column if not exists ip text;`;
59
87
  const nullable = (value) => value === null || value === undefined ? null : Number(value);
88
+ const nullableString = (value) => value === null || value === undefined ? null : String(value);
60
89
  /**
61
90
  * Postgres through whatever driver the app already has. `query` runs one
62
91
  * parameterised statement and resolves its rows:
@@ -77,14 +106,19 @@ export function createPostgresSessionStore(input) {
77
106
  expiresAt: Number(row.expires_at),
78
107
  leaseUntil: nullable(row.lease_until),
79
108
  revokedAt: nullable(row.revoked_at),
109
+ createdAt: nullable(row.created_at),
110
+ lastSeenAt: nullable(row.last_seen_at),
111
+ userAgent: nullableString(row.user_agent),
112
+ ip: nullableString(row.ip),
80
113
  });
81
114
  return {
82
115
  async create(r) {
83
116
  await query(`delete from wtfalch_auth_sessions where key in
84
117
  (select key from wtfalch_auth_sessions where expires_at <= $1 limit 100)`, [Math.floor(Date.now() / 1000)]);
85
118
  await query(`insert into wtfalch_auth_sessions
86
- (key, context, subject, generation, payload, expires_at, lease_until, revoked_at)
87
- values ($1, $2, $3, $4, $5, $6, $7, $8)`, [
119
+ (key, context, subject, generation, payload, expires_at, lease_until, revoked_at,
120
+ created_at, last_seen_at, user_agent, ip)
121
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, [
88
122
  r.key,
89
123
  r.context,
90
124
  r.subject,
@@ -93,6 +127,10 @@ export function createPostgresSessionStore(input) {
93
127
  r.expiresAt,
94
128
  r.leaseUntil,
95
129
  r.revokedAt,
130
+ r.createdAt,
131
+ r.lastSeenAt,
132
+ r.userAgent,
133
+ r.ip,
96
134
  ]);
97
135
  },
98
136
  async get(key) {
@@ -101,9 +139,17 @@ export function createPostgresSessionStore(input) {
101
139
  },
102
140
  async advance(key, generation, next) {
103
141
  const rows = await query(`update wtfalch_auth_sessions
104
- set payload = $3, expires_at = $4, lease_until = $5, generation = generation + 1
142
+ set payload = $3, expires_at = $4, lease_until = $5, generation = generation + 1,
143
+ last_seen_at = $6
105
144
  where key = $1 and generation = $2 and revoked_at is null
106
- returning key`, [key, generation, next.payload, next.expiresAt, next.leaseUntil]);
145
+ returning key`, [
146
+ key,
147
+ generation,
148
+ next.payload,
149
+ next.expiresAt,
150
+ next.leaseUntil,
151
+ Math.floor(Date.now() / 1000),
152
+ ]);
107
153
  return rows.length === 1;
108
154
  },
109
155
  async revoke(key, at) {
@@ -115,5 +161,11 @@ export function createPostgresSessionStore(input) {
115
161
  returning key`, [context, subject, at]);
116
162
  return rows.length;
117
163
  },
164
+ async listSubject(context, subject) {
165
+ const rows = await query(`select * from wtfalch_auth_sessions
166
+ where context = $1 and subject = $2 and revoked_at is null and expires_at > $3
167
+ order by created_at desc nulls last`, [context, subject, Math.floor(Date.now() / 1000)]);
168
+ return rows.map(record);
169
+ },
118
170
  };
119
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/auth",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Sign in against auth.wtfalch.dev: 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",