@wtfalch/auth 0.9.1 → 0.11.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/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
  };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, createAuth, } from './auth.js';
1
+ export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type MaxAge, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, createAuth, } from './auth.js';
2
2
  export { type AuthOptions, ISSUER } from './config.js';
3
3
  export type { SetCookie } from './cookies.js';
4
4
  export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.js';
@@ -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 MaxAge, type NewUser, type Passkey, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, 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;
@@ -21,6 +22,25 @@ export interface NextAuth {
21
22
  getUser: () => Promise<User | null>;
22
23
  /** The user, or a redirect to sign in with `next` to come back to. */
23
24
  requireUser: (next?: string) => Promise<User>;
25
+ /**
26
+ * The user, but only if they authenticated within `maxAge` seconds;
27
+ * otherwise a redirect that makes the issuer ask them again. Put it in front
28
+ * of an action somebody holding a borrowed laptop must not be able to take:
29
+ * changing the password, changing the email, removing a passkey.
30
+ *
31
+ * Somebody whose id token carries no `auth_time` is sent back too. The
32
+ * issuer only has to send that claim when the request asked for it, so its
33
+ * absence proves nothing, and the first round trip fixes it for good.
34
+ *
35
+ * **Pass the page's `searchParams`.** They carry the mark that says this
36
+ * person has already been sent once; without them a person the issuer will
37
+ * not re-authenticate is sent round the same loop for ever. See the
38
+ * implementation for why that is not hypothetical here.
39
+ */
40
+ requireFreshUser: (maxAge: MaxAge, opts?: {
41
+ next?: string;
42
+ searchParams?: SearchParams | Promise<SearchParams>;
43
+ }) => Promise<User>;
24
44
  /**
25
45
  * For the app's login and register pages: the auth request the issuer sent
26
46
  * the browser with, or a redirect that starts one. Pass the page's searchParams.
@@ -28,6 +48,7 @@ export interface NextAuth {
28
48
  authRequest: (searchParams: SearchParams | Promise<SearchParams>, opts?: {
29
49
  next?: string;
30
50
  intent?: Intent;
51
+ prompt?: Prompt;
31
52
  }) => Promise<AuthRequest>;
32
53
  /** For a server action: redirects into the app on success, returns the error otherwise. */
33
54
  signIn: (input: {
@@ -63,6 +84,13 @@ export interface NextAuth {
63
84
  people: Auth['people'];
64
85
  /** Deactivate or reactivate somebody in this app's organisation. */
65
86
  setPersonActive: Auth['setPersonActive'];
87
+ /** The devices page: the signed-in person's own live sessions, newest first. Namespace mode only. */
88
+ sessions: () => Promise<SessionRecord[]>;
89
+ /**
90
+ * Ends one of the signed-in person's own sessions. Throws `AuthError('request', ...)`
91
+ * for a key that is somebody else's, another binding's, or does not exist.
92
+ */
93
+ endSession: (key: string) => Promise<void>;
66
94
  /** Sets the password from a reset link, signs the person in, and redirects. */
67
95
  resetPassword: (input: {
68
96
  userId: string;
@@ -85,6 +113,19 @@ export interface NextAuth {
85
113
  credential: unknown;
86
114
  name: string;
87
115
  }) => Promise<PasskeyRegistrationResult>;
116
+ /** See `Auth['startEmailChange']`: mails a code to the new address and leaves the old one working until `completeEmailChange` checks it. */
117
+ startEmailChange: (email: string) => Promise<void>;
118
+ /** The other half of `startEmailChange`. */
119
+ completeEmailChange: (code: string) => Promise<EmailChangeResult>;
120
+ /** Changes the signed-in person's password. See `Auth['changePassword']` for how a wrong current password is told apart from a rejected new one. */
121
+ changePassword: (input: {
122
+ currentPassword: string;
123
+ newPassword: string;
124
+ }) => Promise<ChangePasswordResult>;
125
+ /** See `Auth['passkeys']`: enough to warn when this is the only one left. */
126
+ passkeys: () => Promise<Passkey[]>;
127
+ /** Removes one of this person's own passkeys. */
128
+ deletePasskey: (id: string) => Promise<DeletePasskeyResult>;
88
129
  /** The passkey half of `signIn`: a challenge to answer with `@wtfalch/auth/passkey`'s `getPasskey`. */
89
130
  startPasskeySignIn: (input: {
90
131
  authRequestId: string;
@@ -116,4 +157,4 @@ export interface NextAuth {
116
157
  }>;
117
158
  }
118
159
  export declare function nextAuth(options: AuthOptions): NextAuth;
119
- export type { Auth, AuthOptions, AuthRequest, GateRules, Intent, NewUser, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, QrPollStatus, User, };
160
+ export type { Auth, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, MaxAge, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, Prompt, QrPollStatus, SessionRecord, User, };
package/dist/next.js CHANGED
@@ -1,10 +1,16 @@
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';
5
5
  import { BrokerError } from './broker.js';
6
6
  import { withCookies } from './cookies.js';
7
7
  import { AuthError } from './oidc.js';
8
+ /** On the page a step-up returns to: this person has been sent once already. */
9
+ const STEP_UP_PARAM = 'auth_stepup';
10
+ function marked(next) {
11
+ const path = next ?? '/';
12
+ return `${path}${path.includes('?') ? '&' : '?'}${STEP_UP_PARAM}=1`;
13
+ }
8
14
  /**
9
15
  * Which of the two failures it was, in the vocabulary `auth_error` already
10
16
  * uses. A refusal under 500 is about this request — the broker says unknown,
@@ -63,6 +69,30 @@ export function nextAuth(options) {
63
69
  redirect(auth.startUrl(next));
64
70
  return user;
65
71
  };
72
+ /**
73
+ * One redirect, never two. The second one is the loop.
74
+ *
75
+ * The gate has exactly the shape `authRequest` warns about below, and the
76
+ * same price: send somebody to the issuer because they are stale, and if
77
+ * they come back no fresher, the next render sends them again. Nothing
78
+ * errors and nothing is logged. An issuer that declines to honour `max_age`,
79
+ * or one that omits `auth_time` from the token it mints afterwards, turns
80
+ * the gate into that loop for every person who reaches it.
81
+ *
82
+ * So the redirect marks the return address, and a person who arrives already
83
+ * marked gets an `AuthError` instead of another redirect. The app shows them
84
+ * something and they click. A human click cannot loop.
85
+ */
86
+ const requireFreshUser = async (maxAge, opts = {}) => {
87
+ const user = await requireUser(opts.next);
88
+ if (auth.fresh(user, maxAge))
89
+ return user;
90
+ const params = opts.searchParams ? await opts.searchParams : undefined;
91
+ if (params?.[STEP_UP_PARAM] !== undefined) {
92
+ throw new AuthError('stale', 'Could not confirm a recent sign-in');
93
+ }
94
+ redirect(auth.startUrl(marked(opts.next), 'login', undefined, maxAge));
95
+ };
66
96
  /**
67
97
  * The auth request the issuer sent the browser here with.
68
98
  *
@@ -93,7 +123,7 @@ export function nextAuth(options) {
93
123
  // Nobody has started a flow yet. This cannot loop: the issuer always
94
124
  // sends the browser back carrying an id.
95
125
  if (!id)
96
- redirect(auth.startUrl(opts.next, opts.intent));
126
+ redirect(auth.startUrl(opts.next, opts.intent, opts.prompt));
97
127
  try {
98
128
  return await auth.authRequest(id);
99
129
  }
@@ -105,11 +135,8 @@ export function nextAuth(options) {
105
135
  // 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
136
  const finish = async (callbackUrl) => {
107
137
  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);
138
+ const request = await requestFromCookies();
139
+ const { location, cookies: set } = await auth.complete(callbackUrl, request.headers.get('cookie'), request.headers);
113
140
  for (const c of set) {
114
141
  if (c.value)
115
142
  store.set(c.name, c.value, c.attributes);
@@ -119,20 +146,37 @@ export function nextAuth(options) {
119
146
  const url = new URL(location);
120
147
  redirect(`${url.pathname}${url.search}`);
121
148
  };
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.
149
+ // The passkey and session routes take a `Request`; a server action has
150
+ // none of its own, only what `cookies()`/`headers()` already expose for
151
+ // this invocation. `cookies()` (not the incoming `cookie` header) is the
152
+ // source of truth for the jar, since it reflects whatever this action has
153
+ // already set or cleared; the device headers `namespaceSessions` reads on
154
+ // sign-in come along too, so a session created from a server action still
155
+ // gets a real user agent and address.
126
156
  const requestFromCookies = async () => {
127
157
  const store = await cookies();
128
- const header = store
158
+ const cookieHeader = store
129
159
  .getAll()
130
160
  .map((c) => `${c.name}=${c.value}`)
131
161
  .join('; ');
132
- return new Request('http://localhost/', header ? { headers: { cookie: header } } : undefined);
162
+ const requestHeaders = new Headers();
163
+ if (cookieHeader)
164
+ requestHeaders.set('cookie', cookieHeader);
165
+ const incoming = await headers();
166
+ for (const name of ['user-agent', 'x-forwarded-for', 'x-real-ip']) {
167
+ const value = incoming.get(name);
168
+ if (value)
169
+ requestHeaders.set(name, value);
170
+ }
171
+ return new Request('http://localhost/', { headers: requestHeaders });
133
172
  };
134
173
  const startPasskeyRegistration = async () => auth.startPasskeyRegistration(await requestFromCookies());
135
174
  const finishPasskeyRegistration = async (input) => auth.finishPasskeyRegistration(await requestFromCookies(), input);
175
+ const startEmailChange = async (email) => auth.startEmailChange(await requestFromCookies(), email);
176
+ const completeEmailChange = async (code) => auth.completeEmailChange(await requestFromCookies(), code);
177
+ const changePassword = async (input) => auth.changePassword(await requestFromCookies(), input);
178
+ const passkeys = async () => auth.passkeys(await requestFromCookies());
179
+ const deletePasskey = async (id) => auth.deletePasskey(await requestFromCookies(), id);
136
180
  const finishPasskeySignIn = async (input) => {
137
181
  const result = await auth.finishPasskeySignIn(input);
138
182
  if (result.ok) {
@@ -143,7 +187,7 @@ export function nextAuth(options) {
143
187
  return { error: result.error };
144
188
  };
145
189
  const resetPassword = async (input) => {
146
- const result = await auth.resetPassword(input);
190
+ const result = await auth.resetPassword(input, (await requestFromCookies()).headers);
147
191
  if (!result.ok)
148
192
  return { error: result.error, message: result.message };
149
193
  const store = await cookies();
@@ -198,12 +242,15 @@ export function nextAuth(options) {
198
242
  }
199
243
  return reason ? { status, reason } : { status };
200
244
  };
245
+ const sessionsList = async () => auth.sessions(await requestFromCookies());
246
+ const endSession = async (key) => auth.endSession(await requestFromCookies(), key);
201
247
  return {
202
248
  auth,
203
249
  handlers: { GET: auth.handle, POST: auth.handle },
204
250
  proxy,
205
251
  getUser,
206
252
  requireUser,
253
+ requireFreshUser,
207
254
  authRequest,
208
255
  signIn,
209
256
  signUp,
@@ -213,9 +260,16 @@ export function nextAuth(options) {
213
260
  invite: auth.invite,
214
261
  people: auth.people,
215
262
  setPersonActive: auth.setPersonActive,
263
+ sessions: sessionsList,
264
+ endSession,
216
265
  resetPassword,
217
266
  startPasskeyRegistration,
218
267
  finishPasskeyRegistration,
268
+ startEmailChange,
269
+ completeEmailChange,
270
+ changePassword,
271
+ passkeys,
272
+ deletePasskey,
219
273
  startPasskeySignIn: auth.startPasskeySignIn,
220
274
  finishPasskeySignIn,
221
275
  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,15 @@ 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, maxAge, }?: {
21
30
  create?: boolean;
31
+ prompt?: 'select_account' | 'login';
32
+ maxAge?: number;
22
33
  }): Promise<URL>;
23
34
  exchange(callbackParams: URLSearchParams, transaction: {
24
35
  state: string;
@@ -26,14 +37,35 @@ export declare class Oidc {
26
37
  codeVerifier: string;
27
38
  }): Promise<Tokens>;
28
39
  refresh(refreshToken: string): Promise<Tokens>;
40
+ /**
41
+ * The issuer's RP-Initiated Logout URL, or null when discovery publishes no
42
+ * `end_session_endpoint` -- an issuer is free not to support it, and a
43
+ * sign-out has to succeed either way.
44
+ */
45
+ endSessionUrl(idToken: string, postLogoutRedirectUri: string): Promise<URL | null>;
29
46
  private tokensFrom;
30
47
  verify(idToken: string): Promise<jose.JWTPayload>;
31
48
  private assertOrganization;
32
49
  }
33
50
  export type AuthErrorReason = 'expired' | 'state' | 'denied' | 'exchange' | 'organization' | 'cookie' | 'request' | 'account_changed' | 'unavailable'
34
51
  /** No session at all -- distinct from `account_changed`/`unavailable`, which `requireUser` already covers by redirecting instead. */
35
- | 'unauthenticated';
52
+ | 'unauthenticated'
53
+ /** Came back from a step-up no fresher than it went. Never redirect on this: that is the loop. */
54
+ | 'stale';
36
55
  export declare class AuthError extends Error {
37
56
  readonly reason: AuthErrorReason;
38
57
  constructor(reason: AuthErrorReason, message: string);
39
58
  }
59
+ /**
60
+ * A refusal from the token endpoint itself -- the grant is gone, the account
61
+ * is disabled -- as opposed to the issuer being unreachable, rate-limiting,
62
+ * or having a bad day. Keyed on the HTTP status alone, never the body: a
63
+ * WAF or CDN in front of the issuer can answer a genuine refusal as HTML,
64
+ * which oauth4webapi cannot parse as an OAuth error, but it still hands back
65
+ * the response that carried the status. Trusting only a parsed `error` field
66
+ * would treat that case as an outage and let the old bug back in through a
67
+ * path nobody would look at. 429 is excluded on purpose: "come back later"
68
+ * is never a statement that the grant is dead, and this issuer's own rate
69
+ * limiting must not permanently revoke a session.
70
+ */
71
+ 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,10 @@ 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, maxAge, } = {}) {
57
66
  return client.buildAuthorizationUrl(await this.config(), {
58
- ...(create ? { prompt: 'create' } : {}),
67
+ ...(create ? { prompt: 'create' } : prompt ? { prompt } : {}),
68
+ ...(maxAge === undefined ? {} : { max_age: String(maxAge) }),
59
69
  redirect_uri: this.redirectUri.href,
60
70
  scope: scopeFor(this.options.organizationId),
61
71
  state,
@@ -78,16 +88,41 @@ export class Oidc {
78
88
  }
79
89
  // Parallel requests holding the same refresh token share one grant; the issuer rotates it, so a second grant would fail.
80
90
  refresh(refreshToken) {
91
+ const asked = Date.now();
92
+ for (const [token, entry] of this.rotated)
93
+ if (entry.until <= asked)
94
+ this.rotated.delete(token);
95
+ const cached = this.rotated.get(refreshToken);
96
+ if (cached)
97
+ return Promise.resolve(cached.tokens);
81
98
  let pending = this.refreshing.get(refreshToken);
82
99
  if (!pending) {
83
100
  pending = this.config()
84
101
  .then((config) => client.refreshTokenGrant(config, refreshToken))
85
102
  .then((response) => this.tokensFrom(response, refreshToken))
103
+ .then((tokens) => {
104
+ this.rotated.set(refreshToken, { tokens, until: Date.now() + ROTATION_CACHE_MS });
105
+ return tokens;
106
+ })
86
107
  .finally(() => this.refreshing.delete(refreshToken));
87
108
  this.refreshing.set(refreshToken, pending);
88
109
  }
89
110
  return pending;
90
111
  }
112
+ /**
113
+ * The issuer's RP-Initiated Logout URL, or null when discovery publishes no
114
+ * `end_session_endpoint` -- an issuer is free not to support it, and a
115
+ * sign-out has to succeed either way.
116
+ */
117
+ async endSessionUrl(idToken, postLogoutRedirectUri) {
118
+ const config = await this.config();
119
+ if (!config.serverMetadata().end_session_endpoint)
120
+ return null;
121
+ return client.buildEndSessionUrl(config, {
122
+ id_token_hint: idToken,
123
+ post_logout_redirect_uri: postLogoutRedirectUri,
124
+ });
125
+ }
91
126
  tokensFrom(response, previousRefreshToken) {
92
127
  const claims = response.claims();
93
128
  if (!response.id_token || !claims) {
@@ -130,3 +165,25 @@ export class AuthError extends Error {
130
165
  this.name = 'AuthError';
131
166
  }
132
167
  }
168
+ /**
169
+ * A refusal from the token endpoint itself -- the grant is gone, the account
170
+ * is disabled -- as opposed to the issuer being unreachable, rate-limiting,
171
+ * or having a bad day. Keyed on the HTTP status alone, never the body: a
172
+ * WAF or CDN in front of the issuer can answer a genuine refusal as HTML,
173
+ * which oauth4webapi cannot parse as an OAuth error, but it still hands back
174
+ * the response that carried the status. Trusting only a parsed `error` field
175
+ * would treat that case as an outage and let the old bug back in through a
176
+ * path nobody would look at. 429 is excluded on purpose: "come back later"
177
+ * is never a statement that the grant is dead, and this issuer's own rate
178
+ * limiting must not permanently revoke a session.
179
+ */
180
+ export function isOAuthRefusal(error) {
181
+ const status = statusOf(error);
182
+ return status !== undefined && status >= 400 && status < 500 && status !== 429;
183
+ }
184
+ function statusOf(error) {
185
+ if (error instanceof client.ResponseBodyError)
186
+ return error.status;
187
+ const cause = error instanceof Error ? error.cause : undefined;
188
+ return cause instanceof Response ? cause.status : undefined;
189
+ }
@@ -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