@ultimat3/auth 24.0.0 → 25.0.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.
@@ -3,7 +3,7 @@
3
3
  // matter which door the user came through. `completeOAuthLogin` is the blessed entry point;
4
4
  // the three steps under it are exported because a custom flow needs the seams, not a second path.
5
5
 
6
- import { ConfigInvalidError, logger, uuid } from '@ultimat3/core';
6
+ import { ConfigInvalidError, logger, uuidV7 } from '@ultimat3/core';
7
7
  import type { AuthAccount, AuthUser } from './adapter';
8
8
  import type { Auth, LoginResult } from './auth';
9
9
  import { normaliseEmail } from './email';
@@ -18,7 +18,8 @@ import {
18
18
  type OAuthTokens,
19
19
  oauthCredentials,
20
20
  } from './oauth-exchange';
21
- import { type OAuthProfile, oauthProfile } from './oauth-profile';
21
+ import { type OAuthGrantContext, oauthGrantContext } from './oauth-grant-context';
22
+ import { type OAuthProfile, readOAuthProfile } from './oauth-profile';
22
23
  import { resolveActor } from './policy-bridge';
23
24
  import { loginFailed } from './rate-limit';
24
25
  import { createSession, sessionCookie } from './session';
@@ -65,7 +66,7 @@ export const emailVerifiedNotStored = (provider: string, userId: string): AuthEr
65
66
  // implements `updateUser` and lost a write it accepted.
66
67
  code: 'X_AUTH_WRITE_FAILED',
67
68
  cause: `the adapter returned no row for new user ${userId}, so the ${provider}-verified address was never stamped verified`,
68
- fix: 'return the updated row from AuthAdapter.updateUser — MemoryAdapter.updateUser is the reference implementation',
69
+ fix: 'return the updated row from AuthAdapter.updateUser — MemoryAuthAdapter.updateUser is the reference implementation',
69
70
  meta: { provider, userId },
70
71
  });
71
72
 
@@ -89,8 +90,15 @@ export interface OAuthGrants {
89
90
  readonly externalId?: string | null | undefined;
90
91
  }
91
92
 
92
- /** Called once per callback, after the profile is proven and before the session is minted. */
93
- export type ResolveOAuthGrants = (profile: OAuthProfile) => Promise<OAuthGrants> | OAuthGrants;
93
+ /**
94
+ * Called once per callback, after the profile is proven and before the session is minted. The
95
+ * `context` is what the IdP said beyond the profile — the token set, every id-token claim, and a
96
+ * lazy `userinfo()` — for an IdP that keeps its grants off the id token (#670).
97
+ */
98
+ export type ResolveOAuthGrants = (
99
+ profile: OAuthProfile,
100
+ context: OAuthGrantContext,
101
+ ) => Promise<OAuthGrants> | OAuthGrants;
94
102
 
95
103
  export interface OAuthSignInInput {
96
104
  readonly profile: OAuthProfile;
@@ -149,7 +157,7 @@ async function applyGrants(auth: Auth, user: AuthUser, grants: OAuthGrants): Pro
149
157
  * display casing (`Ada@Example.com`) and change it between logins, and `x_users.email` is a plain
150
158
  * case-sensitive `unique` column — so unnormalised, the lookup below missed the account the user
151
159
  * registered and `createUserFor` minted a second one at the same address, which `login()` could
152
- * then never reach. `MemoryAdapter` used to fold case itself, which is why no test saw it.
160
+ * then never reach. `MemoryAuthAdapter` used to fold case itself, which is why no test saw it.
153
161
  */
154
162
  const profileEmail = (profile: OAuthProfile): string | null =>
155
163
  profile.email === null ? null : normaliseEmail(profile.email);
@@ -166,6 +174,12 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
166
174
  fix: `request the email scope for ${input.profile.provider} in beginOAuth(), then ${restartAt(input.profile.provider)}`,
167
175
  });
168
176
  }
177
+ // The provider did not vouch for the address, so this caller has proven nothing about owning
178
+ // it. Minting the account anyway is the pre-hijack: the real owner's `register()` then collides,
179
+ // their verified-provider login is refused as "never verified", and the attacker's identity stays
180
+ // linked to the row they later claim. Answered exactly as `resolveUser` answers the same
181
+ // unverified address when it IS taken, so the two branches are no oracle for "is it taken?".
182
+ if (!input.profile.emailVerified) throw loginFailed();
169
183
  const grants = input.grants ?? {};
170
184
  if ((grants.roles ?? []).length === 0 && (grants.orgId ?? null) === null) {
171
185
  // Not an error — an app may genuinely want a roleless account until somebody approves it —
@@ -177,7 +191,7 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
177
191
  });
178
192
  }
179
193
  const created = await auth.adapter.createUser({
180
- id: uuid(auth.clock),
194
+ id: uuidV7(auth.clock),
181
195
  email,
182
196
  // An OAuth-only account has no password to store, and must never be given a random one:
183
197
  // a hash nobody knows the input to is still a credential a reset flow could hand over.
@@ -188,7 +202,6 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
188
202
  externalId: grants.externalId ?? null,
189
203
  createdAt: auth.clock.now(),
190
204
  });
191
- if (!input.profile.emailVerified) return created;
192
205
  // `CreateUserInput` has no `emailVerifiedAt`, so the stamp is a required second write. Returning
193
206
  // `created` when it does not land would sign in a user whose row says unverified — and the next
194
207
  // login through this same provider would then refuse to link it at all.
@@ -199,7 +212,7 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
199
212
 
200
213
  /**
201
214
  * Resolve the identity to a user: an already-linked account first, then an existing account
202
- * with the same address, then a fresh user.
215
+ * with the same address, then a fresh user — and a fresh user only for a provider-verified address.
203
216
  *
204
217
  * An address alone is not proof of ownership on either side. Attaching a provider identity to a
205
218
  * local account that never verified its own email hands the login to whoever registered that
@@ -236,7 +249,7 @@ async function resolveUser(
236
249
 
237
250
  function accountFor(auth: Auth, user: AuthUser, input: OAuthSignInInput): AuthAccount {
238
251
  return {
239
- id: uuid(auth.clock),
252
+ id: uuidV7(auth.clock),
240
253
  userId: user.id,
241
254
  provider: input.profile.provider,
242
255
  providerAccountId: input.profile.providerAccountId,
@@ -327,11 +340,12 @@ export async function completeOAuthLogin(
327
340
  fetch: input.fetch,
328
341
  timeoutMs: input.timeoutMs,
329
342
  });
330
- const profile = await oauthProfile(provider, tokens, {
331
- fetch: input.fetch,
332
- timeoutMs: input.timeoutMs,
333
- });
334
- const grants = input.resolveGrants === undefined ? undefined : await input.resolveGrants(profile);
343
+ const options = { fetch: input.fetch, timeoutMs: input.timeoutMs };
344
+ const { profile, userinfo } = await readOAuthProfile(provider, tokens, options);
345
+ const grants =
346
+ input.resolveGrants === undefined
347
+ ? undefined
348
+ : await input.resolveGrants(profile, oauthGrantContext(profile, tokens, options, userinfo));
335
349
  return await signInWithOAuth(auth, {
336
350
  profile,
337
351
  tokens,
@@ -133,11 +133,16 @@ async function githubPrimaryEmail(
133
133
  return verified.find((entry) => entry.primary) ?? verified[0] ?? null;
134
134
  }
135
135
 
136
- async function fromUserInfo(
136
+ /**
137
+ * The provider's userinfo answer, whole: every claim, the ones `OAuthProfile` names and the ones it
138
+ * does not. Shared by the profile below and by `resolveGrants`' `userinfo()` (#670), so the two
139
+ * reads refuse the same three failures with the same codes — one endpoint, one reader.
140
+ */
141
+ export async function userinfoBody(
137
142
  provider: OAuthProviderId,
138
143
  tokens: OAuthTokens,
139
144
  options: OAuthProfileOptions,
140
- ): Promise<OAuthProfile> {
145
+ ): Promise<Record<string, unknown>> {
141
146
  const config = providerFor(provider);
142
147
  const url = config.userInfoUrl;
143
148
  if (url === null) {
@@ -167,12 +172,30 @@ async function fromUserInfo(
167
172
  fix: `confirm ${url} is the provider's real userinfo endpoint and not a proxy`,
168
173
  });
169
174
  }
175
+ return result.body;
176
+ }
170
177
 
171
- const body = result.body;
172
- // GitHub's id is a number; OIDC's `sub` is a string. Both are stable, so both are accepted.
178
+ /** GitHub's id is a number; OIDC's `sub` is a string. Both are stable, so both are accepted. */
179
+ export function userinfoAccountId(body: Readonly<Record<string, unknown>>): string {
173
180
  const rawId = body['sub'] ?? body['id'];
174
- const providerAccountId =
175
- typeof rawId === 'number' ? String(rawId) : typeof rawId === 'string' ? rawId : '';
181
+ return typeof rawId === 'number' ? String(rawId) : typeof rawId === 'string' ? rawId : '';
182
+ }
183
+
184
+ /** A profile, and the userinfo body it was read from when it was read from one. */
185
+ export interface OAuthProfileRead {
186
+ readonly profile: OAuthProfile;
187
+ /** `null` when the id token answered alone and no userinfo request was made. */
188
+ readonly userinfo: Record<string, unknown> | null;
189
+ }
190
+
191
+ async function fromUserInfo(
192
+ provider: OAuthProviderId,
193
+ tokens: OAuthTokens,
194
+ options: OAuthProfileOptions,
195
+ ): Promise<OAuthProfileRead> {
196
+ const config = providerFor(provider);
197
+ const body = await userinfoBody(provider, tokens, options);
198
+ const providerAccountId = userinfoAccountId(body);
176
199
  if (providerAccountId === '') {
177
200
  throw oauthExchangeFailed({
178
201
  provider,
@@ -193,13 +216,14 @@ async function fromUserInfo(
193
216
  }
194
217
  }
195
218
 
196
- return {
219
+ const profile: OAuthProfile = {
197
220
  provider,
198
221
  providerAccountId,
199
222
  email,
200
223
  emailVerified,
201
224
  name: stringOrNull(body['name']) ?? stringOrNull(body['login']),
202
225
  };
226
+ return { profile, userinfo: body };
203
227
  }
204
228
 
205
229
  /**
@@ -212,17 +236,29 @@ export async function oauthProfile(
212
236
  tokens: OAuthTokens,
213
237
  options: OAuthProfileOptions = {},
214
238
  ): Promise<OAuthProfile> {
239
+ return (await readOAuthProfile(provider, tokens, options)).profile;
240
+ }
241
+
242
+ /**
243
+ * `oauthProfile`, keeping the userinfo body it fetched — so `resolveGrants`' `userinfo()` hands
244
+ * that body back instead of asking the provider a second time in the same login (#670).
245
+ */
246
+ export async function readOAuthProfile(
247
+ provider: OAuthProviderId,
248
+ tokens: OAuthTokens,
249
+ options: OAuthProfileOptions = {},
250
+ ): Promise<OAuthProfileRead> {
215
251
  const claims = tokens.claims;
216
252
  if (claims === null) return await fromUserInfo(provider, tokens, options);
217
253
 
218
254
  const email = stringOrNull(claims.email);
219
255
  const userInfoUrl = providerFor(provider).userInfoUrl;
220
256
  if (email === null && userInfoUrl !== null) {
221
- const profile = await fromUserInfo(provider, tokens, options);
257
+ const read = await fromUserInfo(provider, tokens, options);
222
258
  // Two surfaces, one identity — or this is not that identity. Overwriting the subject and
223
259
  // keeping the address would link the account on an address belonging to whoever the second
224
260
  // call described, so a disagreement ends the handshake instead of being reconciled.
225
- if (profile.providerAccountId !== claims.sub) {
261
+ if (read.profile.providerAccountId !== claims.sub) {
226
262
  throw oauthExchangeFailed({
227
263
  provider,
228
264
  stage: 'userinfo',
@@ -230,14 +266,15 @@ export async function oauthProfile(
230
266
  fix: `confirm ${userInfoUrl} is ${provider}'s own userinfo endpoint and not a proxy that rewrites sub`,
231
267
  });
232
268
  }
233
- return profile;
269
+ return read;
234
270
  }
235
271
 
236
- return {
272
+ const profile: OAuthProfile = {
237
273
  provider,
238
274
  providerAccountId: claims.sub,
239
275
  email,
240
276
  emailVerified: email !== null && idTokenEmailVerified(claims),
241
277
  name: stringOrNull(claims.name),
242
278
  };
279
+ return { profile, userinfo: null };
243
280
  }
package/src/oauth.ts CHANGED
@@ -4,9 +4,10 @@
4
4
  // configs are pure data and live in `oauth-registry.ts`: importing this file performs no network
5
5
  // I/O and reads no env.
6
6
 
7
+ import { timingSafeEqual } from '@ultimat3/core';
7
8
  import { oauthStateInvalid } from './oauth-errors';
8
9
  import { providerFor } from './oauth-registry';
9
- import { base64Url, randomToken, sha256Bytes, timingSafeEqual } from './tokens';
10
+ import { base64Url, randomToken, sha256Bytes } from './tokens';
10
11
 
11
12
  export interface OAuthProvider {
12
13
  readonly id: string;
@@ -63,7 +64,7 @@ export function pkceChallenge(verifier: string): string {
63
64
  return base64Url(sha256Bytes(verifier));
64
65
  }
65
66
 
66
- export function createPkce(): PkcePair {
67
+ export function pkcePair(): PkcePair {
67
68
  // 32 random bytes -> 43 base64url chars, the RFC's minimum verifier length.
68
69
  const verifier = randomToken(32);
69
70
  return { verifier, challenge: pkceChallenge(verifier), method: 'S256' };
@@ -88,7 +89,7 @@ export interface BeginOAuthInput {
88
89
 
89
90
  export function beginOAuth(input: BeginOAuthInput): OAuthHandshake {
90
91
  const provider = providerFor(input.provider);
91
- const pkce = createPkce();
92
+ const pkce = pkcePair();
92
93
  const state = randomToken(16);
93
94
  const nonce = randomToken(16);
94
95
  const url = new URL(provider.authorizeUrl);
package/src/privileges.ts CHANGED
@@ -5,7 +5,12 @@
5
5
  import type { AuthUser, UserPatch } from './adapter';
6
6
  import type { Auth } from './auth';
7
7
  import { authWriteFailed } from './errors';
8
- import { type IssuedSession, rotateSession, sessionCookie } from './session';
8
+ import {
9
+ type IssuedSession,
10
+ remainingMaxAgeSeconds,
11
+ rotateSession,
12
+ sessionCookie,
13
+ } from './session';
9
14
 
10
15
  /**
11
16
  * The fields whose change invalidates whatever the current cookie was issued under. `roles`,
@@ -63,17 +68,25 @@ export async function updatePrivileges(
63
68
  if (user === null) throw authWriteFailed('updateUser', 'x_users');
64
69
  if (changed.length === 0) return { user, changed, sessionsRevoked: 0 };
65
70
 
66
- const issued =
67
- own !== undefined && auth.sessions.policy.rotateOnPrivilegeChange
68
- ? await rotateSession(auth.sessions, own)
69
- : undefined;
71
+ // Every OTHER session ends BEFORE the rotation, keyed on the caller's current id (which the
72
+ // rotation deletes anyway). Rotation refuses a session that is already gone, and a password
73
+ // change must not leave the lifted cookies it was made to kill alive because of that refusal.
70
74
  let sessionsRevoked = 0;
71
75
  if (credentialChanged) {
72
- const keep = issued?.session.id ?? own?.id;
73
76
  sessionsRevoked =
74
- keep === undefined
77
+ own === undefined
75
78
  ? await auth.adapter.deleteSessionsForUser(userId)
76
- : await auth.adapter.deleteOtherSessions(userId, keep);
79
+ : await auth.adapter.deleteOtherSessions(userId, own.id);
80
+ }
81
+ const issued =
82
+ own !== undefined && auth.sessions.policy.rotateOnPrivilegeChange
83
+ ? await rotateSession(auth.sessions, own)
84
+ : undefined;
85
+ // And once more AFTER it: a login that landed between the early sweep and the rotation was minted
86
+ // under the old password too. The early sweep stays — it is what still runs when rotation
87
+ // refuses a session that is already gone — and this one keeps only the replacement.
88
+ if (credentialChanged && own !== undefined) {
89
+ sessionsRevoked += await auth.adapter.deleteOtherSessions(userId, issued?.session.id ?? own.id);
77
90
  }
78
91
  if (issued === undefined) return { user, changed, sessionsRevoked };
79
92
  return {
@@ -81,6 +94,9 @@ export async function updatePrivileges(
81
94
  changed,
82
95
  sessionsRevoked,
83
96
  session: issued,
84
- cookie: sessionCookie(issued.token, auth.sessions.policy),
97
+ // The rotated session keeps the old ceiling, so the cookie's `Max-Age` counts down to it too.
98
+ cookie: sessionCookie(issued.token, auth.sessions.policy, {
99
+ maxAgeSeconds: remainingMaxAgeSeconds(issued.session, auth.sessions.clock.now()),
100
+ }),
85
101
  };
86
102
  }
package/src/rate-limit.ts CHANGED
@@ -109,14 +109,14 @@ export interface AuthLimiter {
109
109
  reset(): Promise<void>;
110
110
  /**
111
111
  * Drop every expired row this limiter is keeping, and answer how many went. Optional because a
112
- * limiter that bounds itself has nothing to sweep — `createAuthLimiter` evicts on write, so it
112
+ * limiter that bounds itself has nothing to sweep — `memoryAuthLimiter` evicts on write, so it
113
113
  * omits this and `purgeAuthLimits()` skips it. A limiter backed by a table declares it, and
114
114
  * that is what makes the framework's purge job able to reach one without knowing it is Postgres.
115
115
  */
116
116
  purgeExpired?(): Promise<number>;
117
117
  }
118
118
 
119
- /** What `createAuthLimiter` returns: the interface, plus the bound it keeps, observable. */
119
+ /** What `memoryAuthLimiter` returns: the interface, plus the bound it keeps, observable. */
120
120
  export interface MemoryAuthLimiter extends AuthLimiter {
121
121
  readonly size: number;
122
122
  }
@@ -174,7 +174,7 @@ const SWEEP_EVERY_MS = 60_000;
174
174
  * forgotten anyway go first: a locked account is the last key to go, so filling the table is not
175
175
  * a way to buy back attempts against one.
176
176
  */
177
- export function createAuthLimiter(
177
+ export function memoryAuthLimiter(
178
178
  clock: Clock,
179
179
  policy: AuthRateLimitPolicy = DEFAULT_AUTH_RATE_LIMIT,
180
180
  ): MemoryAuthLimiter {
package/src/session.ts CHANGED
@@ -5,11 +5,11 @@
5
5
  // to them without this package importing it (same-tier packages must not depend on each other).
6
6
 
7
7
  import type { Clock } from '@ultimat3/core';
8
- import { readCookie } from '@ultimat3/core';
8
+ import { readCookie, serializeSetCookie, timingSafeEqual } from '@ultimat3/core';
9
9
  import type { AuthSession, SessionStore } from './adapter';
10
- import { sessionExpired, sessionUnknown } from './errors';
10
+ import { AuthError, sessionExpired, sessionUnknown } from './errors';
11
11
  import { assertFiniteAuthCount } from './policy-numbers';
12
- import { randomToken, sha256Hex, timingSafeEqual } from './tokens';
12
+ import { randomToken, sha256Hex } from './tokens';
13
13
 
14
14
  export interface SessionPolicy {
15
15
  /** Hard ceiling from creation. Never extended. */
@@ -92,8 +92,38 @@ export interface CreateSessionInput {
92
92
  readonly ip?: string | null | undefined;
93
93
  readonly userAgent?: string | null | undefined;
94
94
  readonly mfaSatisfied?: boolean | undefined;
95
+ /**
96
+ * The lifetime a rotation inherits. Absent means a new login: born now, ceiling one
97
+ * `absoluteTtlMs` away. Either way the ceiling is clamped to `createdAt + absoluteTtlMs` and
98
+ * `createdAt` to now, so an inherited value can only ever shorten a session — never buy it time.
99
+ */
100
+ readonly createdAt?: Date | undefined;
101
+ readonly absoluteExpiresAt?: Date | undefined;
95
102
  }
96
103
 
104
+ /**
105
+ * An inherited instant that is not one. `X_CONFIG_INVALID`, core's code borrowed as
106
+ * `authPolicyNumberInvalid` borrows it: a caller handed this package a value it cannot honour. An
107
+ * Invalid Date ceiling is not a short session but one that never ends — `now >= NaN` is false.
108
+ */
109
+ const inheritedInstantInvalid = (field: 'createdAt' | 'absoluteExpiresAt'): AuthError =>
110
+ new AuthError({
111
+ code: 'X_CONFIG_INVALID',
112
+ cause: `createSession({ ${field} }) is an Invalid Date, and stored as a ceiling it would never expire, because every comparison against NaN is false`,
113
+ fix: `createSession(runtime, { userId, createdAt: session.createdAt, absoluteExpiresAt: session.absoluteExpiresAt }) with the Dates read off a live session, or omit both for a new login`,
114
+ meta: { field },
115
+ });
116
+
117
+ const inheritedMs = (
118
+ field: 'createdAt' | 'absoluteExpiresAt',
119
+ value: Date | undefined,
120
+ ): number | undefined => {
121
+ if (value === undefined) return undefined;
122
+ const ms = value.getTime();
123
+ if (!Number.isFinite(ms)) throw inheritedInstantInvalid(field);
124
+ return ms;
125
+ };
126
+
97
127
  /** `<id>.<secret>`: the id is the row key, the secret is the half that is hashed. */
98
128
  export function parseSessionToken(token: string): { id: string; secret: string } | null {
99
129
  const dot = token.indexOf('.');
@@ -106,14 +136,20 @@ export async function createSession(
106
136
  input: CreateSessionInput,
107
137
  ): Promise<IssuedSession> {
108
138
  const now = runtime.clock.now();
139
+ // Screened ABOVE the write: `Math.min(NaN, …)` is NaN, so an Invalid Date stored a ceiling
140
+ // nothing ever reaches.
141
+ const inheritedBorn = inheritedMs('createdAt', input.createdAt);
142
+ const inheritedCeiling = inheritedMs('absoluteExpiresAt', input.absoluteExpiresAt);
109
143
  const id = randomToken(12);
110
144
  const secret = randomToken(32);
145
+ const born = Math.min(inheritedBorn ?? now.getTime(), now.getTime());
146
+ const ceiling = born + runtime.policy.absoluteTtlMs;
111
147
  const session = await runtime.store.createSession({
112
148
  id,
113
149
  userId: input.userId,
114
150
  tokenHash: sha256Hex(secret),
115
- createdAt: now,
116
- absoluteExpiresAt: new Date(now.getTime() + runtime.policy.absoluteTtlMs),
151
+ createdAt: new Date(born),
152
+ absoluteExpiresAt: new Date(Math.min(inheritedCeiling ?? ceiling, ceiling)),
117
153
  lastSeenAt: now,
118
154
  ip: input.ip ?? null,
119
155
  userAgent: input.userAgent ?? null,
@@ -184,21 +220,39 @@ export async function verifySession(
184
220
  /**
185
221
  * Privilege change (role grant, MFA satisfied, password change) must not reuse the old id:
186
222
  * whoever already holds the old cookie would inherit the new privileges.
223
+ *
224
+ * A new id, never a new lifetime: `createdAt` and the absolute ceiling carry over. Minting them
225
+ * fresh let a session rotated often enough (a weekly role toggle) live forever, and hid it from
226
+ * `revokeSessionsCreatedBefore` — the sweep run after a leak, keyed on exactly that column.
187
227
  */
188
228
  export async function rotateSession(
189
229
  runtime: SessionRuntime,
190
230
  session: AuthSession,
191
231
  patch?: { readonly mfaSatisfied?: boolean | undefined },
192
232
  ): Promise<IssuedSession> {
193
- await runtime.store.deleteSession(session.id);
233
+ // The delete IS the claim, in one atomic step — the `consumeRecoveryCode` contract. Ignoring its
234
+ // answer resurrected a session revoked between verify and rotate, and let two concurrent
235
+ // rotations fork one session into two live ones. Only the caller whose delete landed mints S'.
236
+ if (!(await runtime.store.deleteSession(session.id))) throw sessionUnknown();
194
237
  return await createSession(runtime, {
195
238
  userId: session.userId,
196
239
  ip: session.ip,
197
240
  userAgent: session.userAgent,
198
241
  mfaSatisfied: patch?.mfaSatisfied ?? session.mfaSatisfied,
242
+ createdAt: session.createdAt,
243
+ absoluteExpiresAt: session.absoluteExpiresAt,
199
244
  });
200
245
  }
201
246
 
247
+ /**
248
+ * `Max-Age` for a cookie carrying `session`: the seconds left until its absolute ceiling, never
249
+ * below zero. A rotated session's cookie needs this rather than the policy's full TTL, or the
250
+ * client keeps the cookie for weeks after the server stopped honouring it.
251
+ */
252
+ export function remainingMaxAgeSeconds(session: AuthSession, now: Date): number {
253
+ return Math.max(0, Math.floor((session.absoluteExpiresAt.getTime() - now.getTime()) / 1000));
254
+ }
255
+
202
256
  export async function revokeSession(runtime: SessionRuntime, sessionId: string): Promise<boolean> {
203
257
  return await runtime.store.deleteSession(sessionId);
204
258
  }
@@ -270,12 +324,16 @@ export function sessionCookie(
270
324
  'the cookie carries an attribute that is not delta-seconds, so the browser drops it and keeps the session cookie for the whole browsing session',
271
325
  0,
272
326
  );
273
- return `${name}=${token}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
327
+ return serializeSetCookie(name, token, { maxAge });
274
328
  }
275
329
 
276
- /** Same attributes, empty value, `Max-Age=0` — a mismatched attribute set leaves a live twin. */
330
+ /**
331
+ * Same attributes, empty value, `Max-Age=0` — a mismatched attribute set leaves a live twin. Both
332
+ * go through core's `serializeSetCookie`, whose defaults ARE `Path=/; HttpOnly; Secure;
333
+ * SameSite=Lax`, so the two lines cannot drift apart by one hand-edited attribute.
334
+ */
277
335
  export function clearSessionCookie(policy: SessionPolicy, name?: string): string {
278
- return `${name ?? policy.cookieName}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
336
+ return serializeSetCookie(name ?? policy.cookieName, '', { maxAge: 0 });
279
337
  }
280
338
 
281
339
  /**
package/src/tables.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Single responsibility: the DDL `BuiltinAdapter` expects, as `AUTH_TABLES` — applied at every boot
1
+ // Single responsibility: the DDL `PostgresAuthAdapter` expects, as `AUTH_TABLES` — applied at every boot
2
2
  // by `@ultimat3/cli`'s `FRAMEWORK_SCHEMA`, upgrades of an older table included. Plain strings, so
3
3
  // what auth stores is verifiable by reading, never by trusting. There is nothing to paste into a
4
4
  // migration any more: the per-table exports and `X_USERS_MIGRATION_1_3` are gone (22.0.0).
package/src/tokens.ts CHANGED
@@ -1,14 +1,11 @@
1
1
  // Single responsibility: the secret primitives every other file in this package shares —
2
2
  // CSPRNG tokens and SHA-256 hashing. Centralised so no call site can quietly reach for `===` on
3
3
  // a secret, which leaks the shared prefix length one request at a time. The constant-time
4
- // comparison itself lives in `@ultimat3/core` (`timingSafeEqual`) — `@ultimat3/storage` needs the
5
- // exact same one, and re-exporting it here keeps every existing `from '@ultimat3/auth'` import
6
- // working.
4
+ // comparison itself is `@ultimat3/core`'s `timingSafeEqual` — `@ultimat3/storage` needs the exact
5
+ // same one — imported from core by every caller, never re-exported here (`X_HELPER_COPY`).
7
6
 
8
7
  import { finiteCount, timingSafeEqual } from '@ultimat3/core';
9
8
 
10
- export { timingSafeEqual };
11
-
12
9
  const BASE64URL_UNSAFE = /[+/=]/g;
13
10
  const BASE64URL_REPLACEMENTS: Readonly<Record<string, string>> = { '+': '-', '/': '_', '=': '' };
14
11
 
package/src/verify.ts CHANGED
@@ -4,11 +4,12 @@
4
4
  // tier 4 and auth is tier 2, so the app wires mail's `send` in and auth imports nothing upward.
5
5
 
6
6
  import type { Clock } from '@ultimat3/core';
7
+ import { timingSafeEqual } from '@ultimat3/core';
7
8
  import type { AuthVerification, VerificationStore } from './adapter';
8
9
  import { normaliseEmail } from './email';
9
10
  import { AuthError } from './errors';
10
11
  import { assertFiniteAuthCount } from './policy-numbers';
11
- import { randomToken, sha256Hex, timingSafeEqual } from './tokens';
12
+ import { randomToken, sha256Hex } from './tokens';
12
13
 
13
14
  /**
14
15
  * `locale` is required, not optional: an inferred locale is how a user gets a security email