@ultimat3/auth 1.2.0 → 3.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.
- package/CLAUDE.md +275 -0
- package/README.md +438 -35
- package/package.json +5 -4
- package/src/adapter.ts +69 -4
- package/src/auth.ts +122 -18
- package/src/builtin-adapter.ts +83 -6
- package/src/directory.ts +77 -0
- package/src/email.ts +17 -0
- package/src/errors.ts +239 -14
- package/src/guards.ts +6 -26
- package/src/id-token.ts +48 -26
- package/src/index.ts +109 -13
- package/src/json.ts +33 -0
- package/src/jwks.ts +246 -0
- package/src/kdf-gate.ts +86 -0
- package/src/memory-adapter.ts +66 -4
- package/src/mfa.ts +104 -9
- package/src/oauth-builtins.ts +77 -0
- package/src/oauth-cookie.ts +4 -3
- package/src/oauth-discovery.ts +132 -0
- package/src/oauth-exchange.ts +40 -18
- package/src/oauth-login-fixture.ts +53 -0
- package/src/oauth-login.ts +111 -14
- package/src/oauth-paths.ts +20 -0
- package/src/oauth-profile.ts +9 -10
- package/src/oauth-registry.ts +65 -0
- package/src/oauth-route.ts +293 -0
- package/src/oauth.ts +31 -58
- package/src/password.ts +65 -13
- package/src/policy-bridge.ts +11 -5
- package/src/privileges.ts +74 -0
- package/src/rate-limit.ts +178 -15
- package/src/revocation.ts +100 -0
- package/src/session.ts +33 -4
- package/src/tables.ts +18 -2
- package/src/tokens.ts +26 -17
- package/src/verify.ts +12 -5
- package/src/workload.ts +131 -0
package/src/memory-adapter.ts
CHANGED
|
@@ -12,7 +12,9 @@ import type {
|
|
|
12
12
|
CreateUserInput,
|
|
13
13
|
SessionPatch,
|
|
14
14
|
UserPatch,
|
|
15
|
+
UserQuery,
|
|
15
16
|
} from './adapter';
|
|
17
|
+
import { timingSafeEqual } from './tokens';
|
|
16
18
|
|
|
17
19
|
const verificationKey = (purpose: string, identifier: string): string => `${purpose}:${identifier}`;
|
|
18
20
|
|
|
@@ -24,10 +26,15 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
24
26
|
readonly #verifications = new Map<string, AuthVerification>();
|
|
25
27
|
readonly #apiKeys = new Map<string, AuthApiKeyRecord>();
|
|
26
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Exact match, because `BuiltinAdapter` issues `where email = $1` against a plain `text ...
|
|
31
|
+
* unique` column and nothing folds case there. Normalising here instead made this the ONE
|
|
32
|
+
* adapter that found an account Postgres would not, which is a linked account under `x dev` and
|
|
33
|
+
* a duplicate one in production. `normaliseEmail` is the caller's, above the seam.
|
|
34
|
+
*/
|
|
27
35
|
async findUserByEmail(email: string): Promise<AuthUser | null> {
|
|
28
|
-
const wanted = email.trim().toLowerCase();
|
|
29
36
|
for (const user of this.#users.values()) {
|
|
30
|
-
if (user.email ===
|
|
37
|
+
if (user.email === email) return user;
|
|
31
38
|
}
|
|
32
39
|
return null;
|
|
33
40
|
}
|
|
@@ -39,14 +46,17 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
39
46
|
async createUser(input: CreateUserInput): Promise<AuthUser> {
|
|
40
47
|
const user: AuthUser = {
|
|
41
48
|
id: input.id,
|
|
42
|
-
|
|
49
|
+
// Stored as handed over, exactly as the `insert into x_users` binds it.
|
|
50
|
+
email: input.email,
|
|
43
51
|
emailVerifiedAt: null,
|
|
44
52
|
passwordHash: input.passwordHash,
|
|
45
53
|
orgId: input.orgId,
|
|
46
54
|
roles: [...input.roles],
|
|
47
55
|
permissions: [],
|
|
56
|
+
scopes: [...(input.scopes ?? [])],
|
|
48
57
|
mfaSecret: null,
|
|
49
58
|
recoveryCodeHashes: [],
|
|
59
|
+
externalId: input.externalId ?? null,
|
|
50
60
|
disabledAt: null,
|
|
51
61
|
createdAt: input.createdAt,
|
|
52
62
|
};
|
|
@@ -66,11 +76,30 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
66
76
|
recoveryCodeHashes: patch.recoveryCodeHashes ?? user.recoveryCodeHashes,
|
|
67
77
|
disabledAt: patch.disabledAt === undefined ? user.disabledAt : patch.disabledAt,
|
|
68
78
|
roles: patch.roles ?? user.roles,
|
|
79
|
+
permissions: patch.permissions ?? user.permissions,
|
|
80
|
+
scopes: patch.scopes ?? user.scopes,
|
|
81
|
+
orgId: patch.orgId === undefined ? user.orgId : patch.orgId,
|
|
82
|
+
externalId: patch.externalId === undefined ? user.externalId : patch.externalId,
|
|
69
83
|
};
|
|
70
84
|
this.#users.set(id, next);
|
|
71
85
|
return next;
|
|
72
86
|
}
|
|
73
87
|
|
|
88
|
+
async findUserByExternalId(externalId: string): Promise<AuthUser | null> {
|
|
89
|
+
for (const user of this.#users.values()) {
|
|
90
|
+
if (user.externalId === externalId) return user;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async listUsersByOrg(orgId: string, query?: UserQuery): Promise<readonly AuthUser[]> {
|
|
96
|
+
return [...this.#users.values()]
|
|
97
|
+
.filter((user) => user.orgId === orgId)
|
|
98
|
+
.filter((user) => query?.includeDisabled === true || user.disabledAt === null)
|
|
99
|
+
.filter((user) => query?.role === undefined || user.roles.includes(query.role))
|
|
100
|
+
.sort((a, b) => a.email.localeCompare(b.email));
|
|
101
|
+
}
|
|
102
|
+
|
|
74
103
|
async getSession(id: string): Promise<AuthSession | null> {
|
|
75
104
|
return this.#sessions.get(id) ?? null;
|
|
76
105
|
}
|
|
@@ -108,6 +137,32 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
108
137
|
return killed;
|
|
109
138
|
}
|
|
110
139
|
|
|
140
|
+
async deleteSessionsForUser(userId: string): Promise<number> {
|
|
141
|
+
return this.#deleteSessionsWhere((session) => session.userId === userId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Joins through the user map, which is what the Postgres adapter's subselect does. */
|
|
145
|
+
async deleteSessionsForOrg(orgId: string): Promise<number> {
|
|
146
|
+
const members = new Set(
|
|
147
|
+
[...this.#users.values()].filter((user) => user.orgId === orgId).map((user) => user.id),
|
|
148
|
+
);
|
|
149
|
+
return this.#deleteSessionsWhere((session) => members.has(session.userId));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async deleteSessionsCreatedBefore(before: Date): Promise<number> {
|
|
153
|
+
return this.#deleteSessionsWhere((session) => session.createdAt.getTime() < before.getTime());
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#deleteSessionsWhere(matches: (session: AuthSession) => boolean): number {
|
|
157
|
+
let killed = 0;
|
|
158
|
+
for (const [id, session] of this.#sessions) {
|
|
159
|
+
if (!matches(session)) continue;
|
|
160
|
+
this.#sessions.delete(id);
|
|
161
|
+
killed += 1;
|
|
162
|
+
}
|
|
163
|
+
return killed;
|
|
164
|
+
}
|
|
165
|
+
|
|
111
166
|
async listSessions(userId: string): Promise<readonly AuthSession[]> {
|
|
112
167
|
return [...this.#sessions.values()]
|
|
113
168
|
.filter((session) => session.userId === userId)
|
|
@@ -131,10 +186,17 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
131
186
|
this.#verifications.set(verificationKey(record.purpose, record.identifier), record);
|
|
132
187
|
}
|
|
133
188
|
|
|
134
|
-
async takeVerification(
|
|
189
|
+
async takeVerification(
|
|
190
|
+
purpose: string,
|
|
191
|
+
identifier: string,
|
|
192
|
+
tokenHash: string,
|
|
193
|
+
): Promise<AuthVerification | null> {
|
|
135
194
|
const key = verificationKey(purpose, identifier);
|
|
136
195
|
const record = this.#verifications.get(key);
|
|
137
196
|
if (record === undefined || record.consumedAt !== null) return null;
|
|
197
|
+
// Before the write, never after: a wrong guess that consumed the row would be an
|
|
198
|
+
// unauthenticated way to kill the victim's live link, which is the Postgres adapter's rule too.
|
|
199
|
+
if (!timingSafeEqual(tokenHash, record.tokenHash)) return null;
|
|
138
200
|
const consumed: AuthVerification = { ...record, consumedAt: new Date(record.createdAt) };
|
|
139
201
|
this.#verifications.set(key, consumed);
|
|
140
202
|
return consumed;
|
package/src/mfa.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// phishing page stays valid for the rest of its 30 seconds. Recovery codes are hashed at rest
|
|
4
4
|
// and single-use, so a database dump is not a permanent MFA bypass.
|
|
5
5
|
|
|
6
|
+
import type { Auth } from './auth';
|
|
6
7
|
import { randomBytes, sha256Hex, timingSafeEqual } from './tokens';
|
|
7
8
|
|
|
8
9
|
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
@@ -63,17 +64,28 @@ export interface TotpEnrolment {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
export interface EnrolTotpInput {
|
|
66
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Omitted, the issuer is `auth.mfa.issuer` — one declaration, at `defineAuth`, so the product
|
|
69
|
+
* name an authenticator app shows is not restated at every enrolment. Named here only when one
|
|
70
|
+
* call needs a different one (a separate admin console entry, say).
|
|
71
|
+
*/
|
|
72
|
+
readonly issuer?: string | undefined;
|
|
67
73
|
readonly account: string;
|
|
68
74
|
readonly secret?: string | undefined;
|
|
69
75
|
}
|
|
70
76
|
|
|
71
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Takes the `Auth` every other entry point in this package takes, and for the same reason: the
|
|
79
|
+
* issuer is configuration, and a pure function that could not read the configuration is what made
|
|
80
|
+
* `defineAuth({ mfa: { issuer } })` a string the framework wrote down and never read.
|
|
81
|
+
*/
|
|
82
|
+
export function enrolTotp(auth: Auth, input: EnrolTotpInput): TotpEnrolment {
|
|
72
83
|
const secret = input.secret ?? generateTotpSecret();
|
|
73
|
-
const
|
|
84
|
+
const issuer = input.issuer ?? auth.mfa.issuer;
|
|
85
|
+
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(input.account)}`;
|
|
74
86
|
const query = new URLSearchParams({
|
|
75
87
|
secret,
|
|
76
|
-
issuer
|
|
88
|
+
issuer,
|
|
77
89
|
algorithm: 'SHA1',
|
|
78
90
|
digits: String(TOTP_DIGITS),
|
|
79
91
|
period: String(TOTP_STEP_SECONDS),
|
|
@@ -148,23 +160,106 @@ export interface TotpReplayGuard {
|
|
|
148
160
|
remember(subject: string, step: number, at: Date): void;
|
|
149
161
|
}
|
|
150
162
|
|
|
163
|
+
/** What `createTotpReplayGuard` returns: the interface, plus the bound it keeps, observable. */
|
|
164
|
+
export interface MemoryTotpReplayGuard extends TotpReplayGuard {
|
|
165
|
+
readonly size: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Hard bound on tracked subjects. One entry is one user who has completed a TOTP check inside the
|
|
170
|
+
* last drift window, so the natural cardinality is far below this — the cap is the backstop, the
|
|
171
|
+
* same one `DEFAULT_MAX_AUTH_LIMIT_KEYS` is for the limiter's table.
|
|
172
|
+
*/
|
|
173
|
+
export const DEFAULT_MAX_TOTP_SUBJECTS = 10_000;
|
|
174
|
+
|
|
175
|
+
/** An idle guard still sweeps this often, so one burst's subjects do not sit until the next. */
|
|
176
|
+
const SWEEP_EVERY_STEPS = 2;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The cap arithmetic ran on the caller's number unchecked, and the two values a misread config
|
|
180
|
+
* hands you each defeated the bound in their own way: `Infinity` makes `used.size > cap` never
|
|
181
|
+
* true, so the table is exactly as unbounded as before it was capped; `NaN` makes EVERY comparison
|
|
182
|
+
* false, so `used.size <= evictTo` never stops the eviction loop and one sweep empties the table —
|
|
183
|
+
* including the subject who just authenticated, whose step is then replayable. Anything that is
|
|
184
|
+
* not a positive finite integer is a config the caller did not mean, so it takes the default
|
|
185
|
+
* rather than a bound derived from it. A fraction still floors: `2.5` is a caller who meant 2.
|
|
186
|
+
*/
|
|
187
|
+
function boundedSubjects(maxSubjects: number): number {
|
|
188
|
+
if (!Number.isFinite(maxSubjects) || maxSubjects < 1) return DEFAULT_MAX_TOTP_SUBJECTS;
|
|
189
|
+
return Math.floor(maxSubjects);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The last step this subject has spent — how close their entry still is to the live window. */
|
|
193
|
+
const newestStep = (steps: ReadonlySet<number>): number => {
|
|
194
|
+
let newest = Number.NEGATIVE_INFINITY;
|
|
195
|
+
for (const step of steps) newest = Math.max(newest, step);
|
|
196
|
+
return newest;
|
|
197
|
+
};
|
|
198
|
+
|
|
151
199
|
/**
|
|
152
200
|
* In-memory by default because a single web process is the common case; a multi-process
|
|
153
201
|
* deployment passes a Redis-backed guard with the same two methods. Steps older than the
|
|
154
202
|
* drift window are dropped — nothing outside it can be replayed anyway.
|
|
203
|
+
*
|
|
204
|
+
* Bounded, because the subject map only ever grew: pruning happened inside one subject's `Set`
|
|
205
|
+
* and never revisited a subject who stopped signing in, so the table carried one permanent entry
|
|
206
|
+
* per user for the life of the process. Two rules keep it flat, and the ORDER is the guarantee —
|
|
207
|
+
* evicting a subject makes a step they have already spent replayable again, so it may never be
|
|
208
|
+
* the subject who just authenticated. A subject whose every step has fallen below the drift floor
|
|
209
|
+
* is *forgotten*, not evicted: `verifyTotp` only ever offers a step within ±drift of now, so that
|
|
210
|
+
* entry answers exactly as a missing one and dropping it changes no decision. Only if forgetting
|
|
211
|
+
* is not enough does the cap evict live state, furthest from the live window first — the shape
|
|
212
|
+
* `createAuthLimiter` evicts by, where a live lockout is the last bucket to go.
|
|
155
213
|
*/
|
|
156
|
-
export function createTotpReplayGuard(
|
|
214
|
+
export function createTotpReplayGuard(
|
|
215
|
+
drift: number = TOTP_DRIFT_STEPS,
|
|
216
|
+
maxSubjects: number = DEFAULT_MAX_TOTP_SUBJECTS,
|
|
217
|
+
): MemoryTotpReplayGuard {
|
|
157
218
|
const used = new Map<string, Set<number>>();
|
|
219
|
+
const cap = boundedSubjects(maxSubjects);
|
|
220
|
+
// Batched down to 90% of the cap so the sort below is paid once per 10% of it, not per check.
|
|
221
|
+
const evictTo = Math.max(1, Math.floor(cap * 0.9));
|
|
222
|
+
let lastSweepStep = Number.NEGATIVE_INFINITY;
|
|
223
|
+
|
|
224
|
+
const prune = (steps: Set<number>, floor: number): void => {
|
|
225
|
+
for (const known of steps) {
|
|
226
|
+
if (known < floor) steps.delete(known);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const sweep = (now: number, floor: number): void => {
|
|
231
|
+
lastSweepStep = now;
|
|
232
|
+
for (const [subject, steps] of used) {
|
|
233
|
+
prune(steps, floor);
|
|
234
|
+
if (steps.size === 0) used.delete(subject);
|
|
235
|
+
}
|
|
236
|
+
if (used.size <= cap) return;
|
|
237
|
+
// Map iteration is insertion order and `remember` re-files the subject it touches, so this
|
|
238
|
+
// sort — stable by specification — breaks a tie on the newest step by least recently seen.
|
|
239
|
+
// Both keys point the same way: the subject who just proved a code is the last one out.
|
|
240
|
+
const furthest = [...used.entries()].sort((a, b) => newestStep(a[1]) - newestStep(b[1]));
|
|
241
|
+
for (const [subject] of furthest) {
|
|
242
|
+
if (used.size <= evictTo) break;
|
|
243
|
+
used.delete(subject);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
158
247
|
return {
|
|
248
|
+
get size() {
|
|
249
|
+
return used.size;
|
|
250
|
+
},
|
|
159
251
|
isUsed: (subject, step) => used.get(subject)?.has(step) === true,
|
|
160
252
|
remember: (subject, step, at) => {
|
|
253
|
+
const now = totpStep(at);
|
|
254
|
+
const floor = now - drift;
|
|
161
255
|
const steps = used.get(subject) ?? new Set<number>();
|
|
162
|
-
|
|
163
|
-
for (const known of steps) {
|
|
164
|
-
if (known < floor) steps.delete(known);
|
|
165
|
-
}
|
|
256
|
+
prune(steps, floor);
|
|
166
257
|
steps.add(step);
|
|
258
|
+
// Deleted before it is set, so this subject moves to the back of the iteration order and
|
|
259
|
+
// that order is least-recently-remembered first. Nothing else observes it.
|
|
260
|
+
used.delete(subject);
|
|
167
261
|
used.set(subject, steps);
|
|
262
|
+
if (used.size > cap || now - lastSweepStep >= SWEEP_EVERY_STEPS) sweep(now, floor);
|
|
168
263
|
},
|
|
169
264
|
};
|
|
170
265
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Single responsibility: the three consumer IdPs Ultimate ships with, as plain data. They live
|
|
2
|
+
// apart from `oauth-registry.ts` so the registry can be seeded from them while `oauth.ts` reads
|
|
3
|
+
// the registry — the only edge back to `oauth.ts` is the type, and a type is erased, so there is
|
|
4
|
+
// no runtime cycle between the three files.
|
|
5
|
+
|
|
6
|
+
import type { OAuthProvider } from './oauth';
|
|
7
|
+
|
|
8
|
+
export const GITHUB_PROVIDER: OAuthProvider = {
|
|
9
|
+
id: 'github',
|
|
10
|
+
authorizeUrl: 'https://github.com/login/oauth/authorize',
|
|
11
|
+
tokenUrl: 'https://github.com/login/oauth/access_token',
|
|
12
|
+
userInfoUrl: 'https://api.github.com/user',
|
|
13
|
+
// GitHub omits a private address from the profile; the identity is still incomplete
|
|
14
|
+
// without it, so the flow asks for the verified list rather than guessing.
|
|
15
|
+
userEmailsUrl: 'https://api.github.com/user/emails',
|
|
16
|
+
issuers: [],
|
|
17
|
+
// GitHub is OAuth2 and not OIDC: no id token, therefore no key set to verify one against.
|
|
18
|
+
jwksUri: null,
|
|
19
|
+
scopes: ['read:user', 'user:email'],
|
|
20
|
+
usesPkce: true,
|
|
21
|
+
usesNonce: false,
|
|
22
|
+
clientIdEnv: 'GITHUB_CLIENT_ID',
|
|
23
|
+
clientSecretEnv: 'GITHUB_CLIENT_SECRET',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const GOOGLE_PROVIDER: OAuthProvider = {
|
|
27
|
+
id: 'google',
|
|
28
|
+
authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
29
|
+
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
30
|
+
// Reached only when a narrowed `scopes` leaves the id token without an email claim.
|
|
31
|
+
userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
|
|
32
|
+
userEmailsUrl: null,
|
|
33
|
+
issuers: ['https://accounts.google.com', 'accounts.google.com'],
|
|
34
|
+
jwksUri: 'https://www.googleapis.com/oauth2/v3/certs',
|
|
35
|
+
scopes: ['openid', 'email', 'profile'],
|
|
36
|
+
usesPkce: true,
|
|
37
|
+
usesNonce: true,
|
|
38
|
+
clientIdEnv: 'GOOGLE_CLIENT_ID',
|
|
39
|
+
clientSecretEnv: 'GOOGLE_CLIENT_SECRET',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const APPLE_PROVIDER: OAuthProvider = {
|
|
43
|
+
id: 'apple',
|
|
44
|
+
authorizeUrl: 'https://appleid.apple.com/auth/authorize',
|
|
45
|
+
tokenUrl: 'https://appleid.apple.com/auth/token',
|
|
46
|
+
// Apple returns claims in the id token only; there is no userinfo endpoint to call.
|
|
47
|
+
userInfoUrl: null,
|
|
48
|
+
userEmailsUrl: null,
|
|
49
|
+
issuers: ['https://appleid.apple.com'],
|
|
50
|
+
jwksUri: 'https://appleid.apple.com/auth/keys',
|
|
51
|
+
scopes: ['name', 'email'],
|
|
52
|
+
usesPkce: true,
|
|
53
|
+
usesNonce: true,
|
|
54
|
+
clientIdEnv: 'APPLE_CLIENT_ID',
|
|
55
|
+
// Apple alone does not accept a static secret: `APPLE_CLIENT_SECRET` must hold the ES256
|
|
56
|
+
// client-secret JWT signed with the .p8 key, which Apple expires every six months.
|
|
57
|
+
clientSecretEnv: 'APPLE_CLIENT_SECRET',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Seeded into the registry at import, through the same call an app registers its own IdP with. */
|
|
61
|
+
export const BUILTIN_OAUTH_PROVIDERS: readonly OAuthProvider[] = Object.freeze([
|
|
62
|
+
GITHUB_PROVIDER,
|
|
63
|
+
GOOGLE_PROVIDER,
|
|
64
|
+
APPLE_PROVIDER,
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The three ids, and the only list an ANONYMOUS caller is ever shown. It is a framework constant
|
|
69
|
+
* already in the docs, so naming it discloses nothing; the live registry is not, because an app
|
|
70
|
+
* that registered `acme-internal-sso` has put its own vocabulary in there. `oauth-route.ts` is the
|
|
71
|
+
* one reader in this package — every other refusal reaches a developer and gets
|
|
72
|
+
* `oauthProviderIds()` instead. Off `index.ts` too, because an app writing its own login route
|
|
73
|
+
* owes an anonymous caller the same narrow list and must not have to restate the three ids.
|
|
74
|
+
*/
|
|
75
|
+
export const BUILTIN_OAUTH_PROVIDER_IDS: readonly string[] = Object.freeze(
|
|
76
|
+
BUILTIN_OAUTH_PROVIDERS.map((provider) => provider.id),
|
|
77
|
+
);
|
package/src/oauth-cookie.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
import type { Clock } from '@ultimat3/core';
|
|
9
9
|
import { EnvMissingError, systemClock } from '@ultimat3/core';
|
|
10
10
|
import { oauthStateInvalid } from './errors';
|
|
11
|
-
import {
|
|
11
|
+
import type { OAuthHandshake, OAuthProviderId } from './oauth';
|
|
12
|
+
import { hasOAuthProvider } from './oauth-registry';
|
|
12
13
|
import { type RequestLike, readCookie } from './session';
|
|
13
14
|
import { base64Url, timingSafeEqual } from './tokens';
|
|
14
15
|
|
|
@@ -138,7 +139,7 @@ export function openHandshake(
|
|
|
138
139
|
) {
|
|
139
140
|
throw oauthStateInvalid(provider, 'the stored handshake is not a handshake');
|
|
140
141
|
}
|
|
141
|
-
if (!
|
|
142
|
+
if (!hasOAuthProvider(sealedProvider) || sealedProvider !== provider) {
|
|
142
143
|
throw oauthStateInvalid(provider, 'the stored handshake belongs to a different provider');
|
|
143
144
|
}
|
|
144
145
|
|
|
@@ -157,7 +158,7 @@ export function openHandshake(
|
|
|
157
158
|
* session cookie's: the callback is a top-level cross-site GET from the provider, which `Lax`
|
|
158
159
|
* still attaches the cookie to and `Strict` would strip — leaving every login to fail its state
|
|
159
160
|
* check. A provider answering with `response_mode=form_post` POSTs instead, and this cookie
|
|
160
|
-
* would not reach it;
|
|
161
|
+
* would not reach it; none of the three built-in providers is configured that way.
|
|
161
162
|
*/
|
|
162
163
|
export function handshakeCookie(
|
|
163
164
|
handshake: OAuthHandshake,
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Single responsibility: turning an OIDC issuer URL into an `OAuthProvider`, by reading the
|
|
2
|
+
// discovery document every conforming OP publishes (OIDC Discovery 1.0, §4). One `fetch`, at
|
|
3
|
+
// boot, no dependency — an enterprise IdP is then three lines instead of a hand-copied table of
|
|
4
|
+
// four endpoints that nobody re-checks when the vendor moves one.
|
|
5
|
+
|
|
6
|
+
import { oauthExchangeFailed } from './errors';
|
|
7
|
+
import { isRecord } from './json';
|
|
8
|
+
import type { OAuthProvider } from './oauth';
|
|
9
|
+
import type { OAuthFetch } from './oauth-exchange';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
12
|
+
|
|
13
|
+
/** The document lives at a fixed suffix off the issuer; the issuer keeps its own path. */
|
|
14
|
+
export const discoveryUrl = (issuer: string): string =>
|
|
15
|
+
`${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`;
|
|
16
|
+
|
|
17
|
+
export interface DiscoverOAuthProviderInput {
|
|
18
|
+
/** The id the URL segment carries: `/auth/oauth/<id>`. Also names the two env vars. */
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly issuer: string;
|
|
21
|
+
/** Defaults to the OIDC minimum this package needs to identify somebody. */
|
|
22
|
+
readonly scopes?: readonly string[] | undefined;
|
|
23
|
+
readonly clientIdEnv?: string | undefined;
|
|
24
|
+
readonly clientSecretEnv?: string | undefined;
|
|
25
|
+
/** Injected in tests; production uses the global. */
|
|
26
|
+
readonly fetch?: OAuthFetch | undefined;
|
|
27
|
+
readonly timeoutMs?: number | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const stringOrNull = (body: Record<string, unknown>, key: string): string | null => {
|
|
31
|
+
const value = body[key];
|
|
32
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** `bigco-sso` → `BIGCO_SSO_CLIENT_ID`, the same shape the three built-ins use. */
|
|
36
|
+
const envPrefix = (id: string): string => id.toUpperCase().replace(/[^A-Z0-9]+/g, '_');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reads the issuer's discovery document and returns the provider. It does **not** register:
|
|
40
|
+
* `registerOAuthProvider(await discoverOAuthProvider({ id, issuer }))` keeps one install point,
|
|
41
|
+
* and an app that wants to override one endpoint spreads the result before registering it.
|
|
42
|
+
*
|
|
43
|
+
* `issuers` is pinned to the `issuer` the document itself declares, not the URL that was asked
|
|
44
|
+
* for — a document that names a different issuer is answering for somebody else, and pinning the
|
|
45
|
+
* requested URL would let it.
|
|
46
|
+
*/
|
|
47
|
+
export async function discoverOAuthProvider(
|
|
48
|
+
input: DiscoverOAuthProviderInput,
|
|
49
|
+
): Promise<OAuthProvider> {
|
|
50
|
+
const url = discoveryUrl(input.issuer);
|
|
51
|
+
const doFetch: OAuthFetch = input.fetch ?? ((target, init) => globalThis.fetch(target, init));
|
|
52
|
+
|
|
53
|
+
let response: Response;
|
|
54
|
+
try {
|
|
55
|
+
response = await doFetch(url, {
|
|
56
|
+
method: 'GET',
|
|
57
|
+
headers: { Accept: 'application/json' },
|
|
58
|
+
signal: AbortSignal.timeout(input.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
59
|
+
});
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw oauthExchangeFailed({
|
|
62
|
+
provider: input.id,
|
|
63
|
+
stage: 'discovery',
|
|
64
|
+
detail:
|
|
65
|
+
error instanceof Error ? error.message : 'the request failed before a response arrived',
|
|
66
|
+
fix: `curl -sS -m 5 ${url}`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
throw oauthExchangeFailed({
|
|
72
|
+
provider: input.id,
|
|
73
|
+
stage: 'discovery',
|
|
74
|
+
detail: 'the issuer published no discovery document at the well-known path',
|
|
75
|
+
status: response.status,
|
|
76
|
+
fix: `curl -sS -m 5 ${url} # then pass the real issuer URL to discoverOAuthProvider({ issuer })`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const body: unknown = await response.json().catch(() => undefined);
|
|
81
|
+
if (!isRecord(body)) {
|
|
82
|
+
throw oauthExchangeFailed({
|
|
83
|
+
provider: input.id,
|
|
84
|
+
stage: 'discovery',
|
|
85
|
+
detail: 'the discovery document is not a JSON object',
|
|
86
|
+
fix: `confirm ${url} is the OP's own discovery document and not a login page`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const issuer = stringOrNull(body, 'issuer');
|
|
91
|
+
const authorizeUrl = stringOrNull(body, 'authorization_endpoint');
|
|
92
|
+
const tokenUrl = stringOrNull(body, 'token_endpoint');
|
|
93
|
+
if (issuer === null || authorizeUrl === null || tokenUrl === null) {
|
|
94
|
+
throw oauthExchangeFailed({
|
|
95
|
+
provider: input.id,
|
|
96
|
+
stage: 'discovery',
|
|
97
|
+
detail:
|
|
98
|
+
'the discovery document is missing issuer, authorization_endpoint or token_endpoint, ' +
|
|
99
|
+
'so no handshake can be built from it',
|
|
100
|
+
fix: `curl -sS -m 5 ${url} | jq '{issuer, authorization_endpoint, token_endpoint}'`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const jwksUri = stringOrNull(body, 'jwks_uri');
|
|
105
|
+
if (jwksUri === null) {
|
|
106
|
+
// Without a key set there is nothing to verify an id token against, and the only remaining
|
|
107
|
+
// trust is the TLS channel to the token endpoint — which is the exemption, not a default.
|
|
108
|
+
throw oauthExchangeFailed({
|
|
109
|
+
provider: input.id,
|
|
110
|
+
stage: 'discovery',
|
|
111
|
+
detail:
|
|
112
|
+
'the discovery document publishes no jwks_uri, so no id token from it can be verified',
|
|
113
|
+
fix: `register this provider by hand with an explicit jwksUri: registerOAuthProvider({ id: '${input.id}', ... })`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const prefix = envPrefix(input.id);
|
|
118
|
+
return {
|
|
119
|
+
id: input.id,
|
|
120
|
+
authorizeUrl,
|
|
121
|
+
tokenUrl,
|
|
122
|
+
userInfoUrl: stringOrNull(body, 'userinfo_endpoint'),
|
|
123
|
+
userEmailsUrl: null,
|
|
124
|
+
issuers: [issuer],
|
|
125
|
+
jwksUri,
|
|
126
|
+
scopes: input.scopes ?? ['openid', 'email', 'profile'],
|
|
127
|
+
usesPkce: true,
|
|
128
|
+
usesNonce: true,
|
|
129
|
+
clientIdEnv: input.clientIdEnv ?? `${prefix}_CLIENT_ID`,
|
|
130
|
+
clientSecretEnv: input.clientSecretEnv ?? `${prefix}_CLIENT_SECRET`,
|
|
131
|
+
};
|
|
132
|
+
}
|
package/src/oauth-exchange.ts
CHANGED
|
@@ -4,16 +4,18 @@
|
|
|
4
4
|
// token is verified here, so no caller downstream can forget to.
|
|
5
5
|
|
|
6
6
|
import type { Clock } from '@ultimat3/core';
|
|
7
|
-
import { EnvMissingError, systemClock } from '@ultimat3/core';
|
|
8
|
-
import { oauthExchangeFailed } from './errors';
|
|
7
|
+
import { EnvMissingError, renderCauseValue, systemClock } from '@ultimat3/core';
|
|
8
|
+
import { oauthExchangeFailed, restartAt } from './errors';
|
|
9
9
|
import { type IdTokenClaims, verifyIdToken } from './id-token';
|
|
10
|
+
import { isRecord } from './json';
|
|
11
|
+
import type { IdTokenKeys } from './jwks';
|
|
10
12
|
import {
|
|
11
13
|
assertOAuthCallback,
|
|
12
|
-
OAUTH_PROVIDERS,
|
|
13
14
|
type OAuthCallback,
|
|
14
15
|
type OAuthHandshake,
|
|
15
16
|
type OAuthProviderId,
|
|
16
17
|
} from './oauth';
|
|
18
|
+
import { providerFor } from './oauth-registry';
|
|
17
19
|
|
|
18
20
|
/** Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to. */
|
|
19
21
|
export type OAuthFetch = (input: string, init: RequestInit) => Promise<Response>;
|
|
@@ -45,6 +47,13 @@ export interface OAuthExchangeOptions {
|
|
|
45
47
|
/** Injected in tests; production uses the global. */
|
|
46
48
|
readonly fetch?: OAuthFetch | undefined;
|
|
47
49
|
readonly timeoutMs?: number | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Defaults to `'token-endpoint-tls'`, and this is the one call site where that default is
|
|
52
|
+
* correct: the id token below was read off a TLS response from the provider's own token
|
|
53
|
+
* endpoint, which is the channel OIDC Core 3.1.3.7 exempts. An app that pins the key set anyway
|
|
54
|
+
* — a corporate egress proxy, a compliance requirement — passes `providerJwks(providerFor(id))`.
|
|
55
|
+
*/
|
|
56
|
+
readonly keys?: IdTokenKeys | undefined;
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
/**
|
|
@@ -55,7 +64,7 @@ export function oauthCredentials(
|
|
|
55
64
|
provider: OAuthProviderId,
|
|
56
65
|
env: Readonly<Record<string, string | undefined>> = Bun.env,
|
|
57
66
|
): OAuthClientCredentials {
|
|
58
|
-
const { clientIdEnv, clientSecretEnv } =
|
|
67
|
+
const { clientIdEnv, clientSecretEnv } = providerFor(provider);
|
|
59
68
|
const clientId = env[clientIdEnv]?.trim() ?? '';
|
|
60
69
|
const clientSecret = env[clientSecretEnv]?.trim() ?? '';
|
|
61
70
|
const missing = [
|
|
@@ -72,10 +81,16 @@ export function oauthCredentials(
|
|
|
72
81
|
return { clientId, clientSecret };
|
|
73
82
|
}
|
|
74
83
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Prefers the provider's own words; falls back to raw text, capped so a login page can't flood logs.
|
|
86
|
+
*
|
|
87
|
+
* Every branch but the empty-body sentence is a REMOTE server's bytes, and this string is spliced
|
|
88
|
+
* into an `X_OAUTH_EXCHANGE_FAILED` cause — so a token endpoint that is compromised, impersonated
|
|
89
|
+
* or merely behind a hostile proxy could otherwise write a second log line an operator reads as
|
|
90
|
+
* genuine. Rendered here rather than at the factory because `OAuthExchangeFailure.detail` is
|
|
91
|
+
* mostly prose this package authored, and quoting that would make every readable message unread-
|
|
92
|
+
* able. The rule is "remote text is rendered", and this function is where remote text is made.
|
|
93
|
+
*/
|
|
79
94
|
export async function providerDetail(response: Response): Promise<string> {
|
|
80
95
|
const text = await response.text().catch(() => '');
|
|
81
96
|
if (text === '') return 'the response body was empty';
|
|
@@ -83,21 +98,25 @@ export async function providerDetail(response: Response): Promise<string> {
|
|
|
83
98
|
const parsed: unknown = JSON.parse(text);
|
|
84
99
|
if (isRecord(parsed)) {
|
|
85
100
|
const description = parsed['error_description'] ?? parsed['error'] ?? parsed['message'];
|
|
86
|
-
if (typeof description === 'string' && description !== '')
|
|
101
|
+
if (typeof description === 'string' && description !== '') {
|
|
102
|
+
return renderCauseValue(description);
|
|
103
|
+
}
|
|
87
104
|
}
|
|
88
105
|
} catch {
|
|
89
106
|
// Not JSON — fall through to the truncated raw text below.
|
|
90
107
|
}
|
|
91
|
-
return
|
|
108
|
+
return renderCauseValue(
|
|
109
|
+
text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH)}…` : text,
|
|
110
|
+
);
|
|
92
111
|
}
|
|
93
112
|
|
|
94
113
|
function fixForStatus(provider: OAuthProviderId, status: number): string {
|
|
95
|
-
const { clientIdEnv, clientSecretEnv } =
|
|
114
|
+
const { clientIdEnv, clientSecretEnv } = providerFor(provider);
|
|
96
115
|
if (status === 401 || status === 403) {
|
|
97
116
|
return `set ${clientIdEnv} and ${clientSecretEnv} to the current values in the ${provider} app settings`;
|
|
98
117
|
}
|
|
99
118
|
if (status === 400) {
|
|
100
|
-
return `register this exact redirect_uri in the ${provider} app settings, then
|
|
119
|
+
return `register this exact redirect_uri in the ${provider} app settings, then ${restartAt(provider)}`;
|
|
101
120
|
}
|
|
102
121
|
return `retry; if it persists, check the ${provider} status page before changing anything`;
|
|
103
122
|
}
|
|
@@ -108,7 +127,7 @@ async function postForm(
|
|
|
108
127
|
options: OAuthExchangeOptions,
|
|
109
128
|
): Promise<Record<string, unknown>> {
|
|
110
129
|
const doFetch: OAuthFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
111
|
-
const url =
|
|
130
|
+
const url = providerFor(provider).tokenUrl;
|
|
112
131
|
|
|
113
132
|
let response: Response;
|
|
114
133
|
try {
|
|
@@ -166,7 +185,7 @@ async function postForm(
|
|
|
166
185
|
detail: typeof description === 'string' && description !== '' ? description : error,
|
|
167
186
|
fix:
|
|
168
187
|
error === 'bad_verification_code' || error === 'invalid_grant'
|
|
169
|
-
?
|
|
188
|
+
? `${restartAt(provider)} — an authorization code is single-use and short-lived`
|
|
170
189
|
: fixForStatus(provider, 400),
|
|
171
190
|
});
|
|
172
191
|
}
|
|
@@ -183,7 +202,7 @@ export async function exchangeOAuthCode(
|
|
|
183
202
|
options: OAuthExchangeOptions,
|
|
184
203
|
): Promise<OAuthTokens> {
|
|
185
204
|
assertOAuthCallback(handshake, callback);
|
|
186
|
-
const provider =
|
|
205
|
+
const provider = providerFor(handshake.provider);
|
|
187
206
|
const clock = options.clock ?? systemClock;
|
|
188
207
|
|
|
189
208
|
const body = new URLSearchParams({
|
|
@@ -193,7 +212,9 @@ export async function exchangeOAuthCode(
|
|
|
193
212
|
client_id: options.credentials.clientId,
|
|
194
213
|
client_secret: options.credentials.clientSecret,
|
|
195
214
|
});
|
|
196
|
-
|
|
215
|
+
// Unconditional: PKCE is not provider-dependent, and `OAuthProvider.usesPkce` is the type-level
|
|
216
|
+
// statement of that — there is no configuration in which this line is skipped.
|
|
217
|
+
body.set('code_verifier', handshake.verifier);
|
|
197
218
|
|
|
198
219
|
const payload = await postForm(handshake.provider, body, options);
|
|
199
220
|
const accessToken = payload['access_token'];
|
|
@@ -202,7 +223,7 @@ export async function exchangeOAuthCode(
|
|
|
202
223
|
provider: provider.id,
|
|
203
224
|
stage: 'token',
|
|
204
225
|
detail: 'the response carried no access_token',
|
|
205
|
-
fix: `confirm the ${provider.id} app grants the scopes in
|
|
226
|
+
fix: `confirm the ${provider.id} app grants the scopes in providerFor('${provider.id}').scopes`,
|
|
206
227
|
});
|
|
207
228
|
}
|
|
208
229
|
|
|
@@ -233,12 +254,13 @@ export async function exchangeOAuthCode(
|
|
|
233
254
|
claims:
|
|
234
255
|
idToken === null
|
|
235
256
|
? null
|
|
236
|
-
: verifyIdToken({
|
|
257
|
+
: await verifyIdToken({
|
|
237
258
|
provider: handshake.provider,
|
|
238
259
|
idToken,
|
|
239
260
|
clientId: options.credentials.clientId,
|
|
240
261
|
nonce: handshake.nonce,
|
|
241
262
|
clock,
|
|
263
|
+
keys: options.keys ?? 'token-endpoint-tls',
|
|
242
264
|
}),
|
|
243
265
|
};
|
|
244
266
|
}
|