@ultimat3/auth 24.0.0 → 25.1.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/CLAUDE.md +25 -14
- package/README.md +96 -39
- package/package.json +5 -5
- package/src/adapter.ts +6 -2
- package/src/api-key-resolver.ts +2 -2
- package/src/api-keys.ts +2 -2
- package/src/auth.ts +11 -11
- package/src/builtin-adapter-violation.ts +1 -1
- package/src/builtin-adapter.ts +17 -4
- package/src/email.ts +1 -1
- package/src/errors.ts +3 -3
- package/src/guards.ts +2 -2
- package/src/id-token.ts +2 -3
- package/src/index.ts +11 -10
- package/src/jwks.ts +7 -7
- package/src/kdf-gate.ts +6 -6
- package/src/limiter-install.ts +1 -1
- package/src/memory-adapter.ts +32 -7
- package/src/mfa.ts +5 -5
- package/src/oauth-cookie.ts +10 -4
- package/src/oauth-grant-context.ts +106 -0
- package/src/oauth-login.ts +29 -15
- package/src/oauth-profile.ts +48 -11
- package/src/oauth.ts +4 -3
- package/src/policy-bridge.ts +9 -11
- package/src/privileges.ts +25 -9
- package/src/rate-limit.ts +3 -3
- package/src/session.ts +67 -9
- package/src/tables.ts +1 -1
- package/src/tokens.ts +2 -5
- package/src/verify.ts +2 -1
package/src/oauth-login.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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 —
|
|
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
|
-
/**
|
|
93
|
-
|
|
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. `
|
|
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:
|
|
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:
|
|
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
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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,
|
package/src/oauth-profile.ts
CHANGED
|
@@ -133,11 +133,16 @@ async function githubPrimaryEmail(
|
|
|
133
133
|
return verified.find((entry) => entry.primary) ?? verified[0] ?? null;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
|
|
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<
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
269
|
+
return read;
|
|
234
270
|
}
|
|
235
271
|
|
|
236
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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/policy-bridge.ts
CHANGED
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
// import a same-tier package; policy binds to it by structure, in whichever order they land.
|
|
6
6
|
|
|
7
7
|
import type { Actor } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
agentActor,
|
|
10
|
+
anonymousActor,
|
|
11
|
+
assertNever,
|
|
12
|
+
grantCovers,
|
|
13
|
+
serviceActor,
|
|
14
|
+
userActor,
|
|
15
|
+
} from '@ultimat3/core';
|
|
9
16
|
import type { AuthApiKeyRecord, AuthSession, AuthUser } from './adapter';
|
|
10
17
|
|
|
11
18
|
/** Structural mirror of `@ultimat3/policy`'s `PolicyActorFields`. Kept in sync by hand. */
|
|
@@ -72,15 +79,6 @@ export function actorFromUser(user: AuthUser, session: AuthSession): PolicyActor
|
|
|
72
79
|
/** `*` and `<resource>:*` — the two spellings a role uses to be granted everything. */
|
|
73
80
|
export const isWildcardScope = (scope: string): boolean => scope === '*' || scope.endsWith(':*');
|
|
74
81
|
|
|
75
|
-
// `@ultimat3/policy`'s own reading of a grant's resource, mirrored: same tier, so not imported.
|
|
76
|
-
const resourceOf = (grant: string): string => grant.split(':')[0] ?? grant;
|
|
77
|
-
|
|
78
|
-
/** Whether one of the owner's grants reaches a named permission. A grant MAY be a wildcard. */
|
|
79
|
-
const covers = (grant: string, wanted: string): boolean =>
|
|
80
|
-
grant === '*' ||
|
|
81
|
-
grant === wanted ||
|
|
82
|
-
(grant.endsWith(':*') && resourceOf(grant) === resourceOf(wanted));
|
|
83
|
-
|
|
84
82
|
/**
|
|
85
83
|
* What a key may carry of what it names. Two cuts, and both only ever remove:
|
|
86
84
|
*
|
|
@@ -99,7 +97,7 @@ export function apiKeyScopes(
|
|
|
99
97
|
return scopes.filter(
|
|
100
98
|
(scope) =>
|
|
101
99
|
!isWildcardScope(scope) &&
|
|
102
|
-
(ownerGrants === null || ownerGrants.some((grant) =>
|
|
100
|
+
(ownerGrants === null || ownerGrants.some((grant) => grantCovers(grant, scope))),
|
|
103
101
|
);
|
|
104
102
|
}
|
|
105
103
|
|
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 {
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
77
|
+
own === undefined
|
|
75
78
|
? await auth.adapter.deleteSessionsForUser(userId)
|
|
76
|
-
: await auth.adapter.deleteOtherSessions(userId,
|
|
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
|
-
|
|
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 — `
|
|
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 `
|
|
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
|
|
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
|
|
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:
|
|
116
|
-
absoluteExpiresAt: new Date(
|
|
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
|
-
|
|
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
|
|
327
|
+
return serializeSetCookie(name, token, { maxAge });
|
|
274
328
|
}
|
|
275
329
|
|
|
276
|
-
/**
|
|
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
|
|
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 `
|
|
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
|
|
5
|
-
//
|
|
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
|
|
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
|