@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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The fixtures the three `oauth-login` suites share: the frozen instant, a fresh
|
|
2
|
+
// `MemoryAdapter`-backed `Auth`, the two request bodies, the code of a rejection and a JSON
|
|
3
|
+
// `Response`. Shared rather than copied — three suites building their own `Auth` would be three
|
|
4
|
+
// flows that agree only by construction, the same reason `backfill-pass-fixture.ts` exists.
|
|
5
|
+
|
|
6
|
+
import { frozenClock, isUltimateError } from '@ultimat3/core';
|
|
7
|
+
import { type Auth, defineAuth } from './auth';
|
|
8
|
+
import { MemoryAdapter } from './memory-adapter';
|
|
9
|
+
import type { OAuthTokens } from './oauth-exchange';
|
|
10
|
+
import type { OAuthProfile } from './oauth-profile';
|
|
11
|
+
|
|
12
|
+
export const NOW = new Date('2026-08-09T12:00:00.000Z');
|
|
13
|
+
|
|
14
|
+
export const credentials = { clientId: 'client-id', clientSecret: 'client-secret' };
|
|
15
|
+
|
|
16
|
+
/** One adapter and the `Auth` over it, minted per `beforeEach` — never shared between tests. */
|
|
17
|
+
export const freshAuth = (): { adapter: MemoryAdapter; auth: Auth } => {
|
|
18
|
+
const adapter = new MemoryAdapter();
|
|
19
|
+
return {
|
|
20
|
+
adapter,
|
|
21
|
+
auth: defineAuth({ adapter, clock: frozenClock(NOW), providers: ['github', 'google'] }),
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const profile = (overrides: Partial<OAuthProfile> = {}): OAuthProfile => ({
|
|
26
|
+
provider: 'github',
|
|
27
|
+
providerAccountId: '583231',
|
|
28
|
+
email: 'ada@example.com',
|
|
29
|
+
emailVerified: true,
|
|
30
|
+
name: 'Ada Lovelace',
|
|
31
|
+
...overrides,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const tokens = (overrides: Partial<OAuthTokens> = {}): OAuthTokens => ({
|
|
35
|
+
accessToken: 'gho_first',
|
|
36
|
+
refreshToken: null,
|
|
37
|
+
expiresAt: null,
|
|
38
|
+
idToken: null,
|
|
39
|
+
claims: null,
|
|
40
|
+
...overrides,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const codeOf = async (call: Promise<unknown>): Promise<string> => {
|
|
44
|
+
try {
|
|
45
|
+
await call;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return isUltimateError(error) ? error.code : `not-an-UltimateError: ${String(error)}`;
|
|
48
|
+
}
|
|
49
|
+
return 'did-not-throw';
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const json = (body: unknown): Response =>
|
|
53
|
+
new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } });
|
package/src/oauth-login.ts
CHANGED
|
@@ -3,14 +3,18 @@
|
|
|
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, uuid } from '@ultimat3/core';
|
|
6
|
+
import { ConfigInvalidError, logger, uuid } from '@ultimat3/core';
|
|
7
7
|
import type { AuthAccount, AuthUser } from './adapter';
|
|
8
8
|
import type { Auth, LoginResult } from './auth';
|
|
9
|
+
import { normaliseEmail } from './email';
|
|
9
10
|
import {
|
|
11
|
+
authWriteFailed,
|
|
10
12
|
emailVerifiedNotStored,
|
|
11
13
|
mfaRequired,
|
|
12
14
|
oauthAccountNotLinked,
|
|
13
15
|
oauthExchangeFailed,
|
|
16
|
+
oauthLinkingDisabled,
|
|
17
|
+
restartAt,
|
|
14
18
|
} from './errors';
|
|
15
19
|
import type { OAuthCallback, OAuthHandshake } from './oauth';
|
|
16
20
|
import {
|
|
@@ -25,14 +29,43 @@ import { resolveActor } from './policy-bridge';
|
|
|
25
29
|
import { loginFailed } from './rate-limit';
|
|
26
30
|
import { createSession, sessionCookie } from './session';
|
|
27
31
|
|
|
32
|
+
/**
|
|
33
|
+
* What the IdP's answer entitles this identity to, in the app's own vocabulary. Which group maps
|
|
34
|
+
* to which role is business convention and never ships (axiom 8) — this is the seam it arrives
|
|
35
|
+
* through, and every field is independently optional so a seam that only knows the org says only
|
|
36
|
+
* that.
|
|
37
|
+
*/
|
|
38
|
+
export interface OAuthGrants {
|
|
39
|
+
readonly orgId?: string | null | undefined;
|
|
40
|
+
readonly roles?: readonly string[] | undefined;
|
|
41
|
+
readonly scopes?: readonly string[] | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* The IdP's own stable id for this person, if the app has one. Deliberately NOT derived from
|
|
44
|
+
* `profile.providerAccountId`: `x_users.external_id` is unique across the table, and two
|
|
45
|
+
* different OPs are free to issue the same `sub`, so auto-filling it would make one tenant's
|
|
46
|
+
* google login collide with another's okta login. The app knows which id its provisioning
|
|
47
|
+
* system uses; the framework does not.
|
|
48
|
+
*/
|
|
49
|
+
readonly externalId?: string | null | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Called once per callback, after the profile is proven and before the session is minted. */
|
|
53
|
+
export type ResolveOAuthGrants = (profile: OAuthProfile) => Promise<OAuthGrants> | OAuthGrants;
|
|
54
|
+
|
|
28
55
|
export interface OAuthSignInInput {
|
|
29
56
|
readonly profile: OAuthProfile;
|
|
30
57
|
readonly tokens: OAuthTokens;
|
|
31
58
|
readonly ip?: string | null | undefined;
|
|
32
59
|
readonly userAgent?: string | null | undefined;
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Authoritative on EVERY login, not only the first. The IdP is the source of truth for who is
|
|
62
|
+
* in which group, so a member removed from a group there has to lose the role here on their
|
|
63
|
+
* next sign-in — an apply-once-at-creation rule made "revoke in the IdP" a no-op, and left the
|
|
64
|
+
* first-time user with `roles: []`, `orgId: null` and an actor every `can()` denies.
|
|
65
|
+
*
|
|
66
|
+
* Absent means "the app has no opinion": the stored row is left exactly as it is.
|
|
67
|
+
*/
|
|
68
|
+
readonly grants?: OAuthGrants | undefined;
|
|
36
69
|
}
|
|
37
70
|
|
|
38
71
|
async function userForAccount(auth: Auth, account: AuthAccount): Promise<AuthUser> {
|
|
@@ -41,8 +74,48 @@ async function userForAccount(auth: Auth, account: AuthAccount): Promise<AuthUse
|
|
|
41
74
|
return user;
|
|
42
75
|
}
|
|
43
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The grant seam applied to a row that already exists. Only the fields the seam actually returned
|
|
79
|
+
* are written, and only when they differ — an unconditional `updateUser` would make every SSO
|
|
80
|
+
* login a write, which is the same mistake `verifySession` used to make on every request.
|
|
81
|
+
*/
|
|
82
|
+
async function applyGrants(auth: Auth, user: AuthUser, grants: OAuthGrants): Promise<AuthUser> {
|
|
83
|
+
const same = (a: readonly string[] | undefined, b: readonly string[]): boolean =>
|
|
84
|
+
a === undefined || (a.length === b.length && a.every((one, index) => one === b[index]));
|
|
85
|
+
const unchanged = (declared: string | null | undefined, stored: string | null): boolean =>
|
|
86
|
+
declared === undefined || (declared ?? null) === stored;
|
|
87
|
+
if (
|
|
88
|
+
unchanged(grants.orgId, user.orgId) &&
|
|
89
|
+
unchanged(grants.externalId, user.externalId) &&
|
|
90
|
+
same(grants.roles, user.roles) &&
|
|
91
|
+
same(grants.scopes, user.scopes)
|
|
92
|
+
) {
|
|
93
|
+
return user;
|
|
94
|
+
}
|
|
95
|
+
const patched = await auth.adapter.updateUser(user.id, {
|
|
96
|
+
...(grants.orgId === undefined ? {} : { orgId: grants.orgId ?? null }),
|
|
97
|
+
...(grants.roles === undefined ? {} : { roles: grants.roles }),
|
|
98
|
+
...(grants.scopes === undefined ? {} : { scopes: grants.scopes }),
|
|
99
|
+
...(grants.externalId === undefined ? {} : { externalId: grants.externalId ?? null }),
|
|
100
|
+
});
|
|
101
|
+
// Failing closed rather than signing in with the stale row: the whole point of the seam is that
|
|
102
|
+
// the session about to be minted carries what the IdP says today.
|
|
103
|
+
if (patched === null) throw authWriteFailed('updateUser', 'x_users');
|
|
104
|
+
return patched;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The provider's address, put through the one normalisation every other door uses. Providers send
|
|
109
|
+
* display casing (`Ada@Example.com`) and change it between logins, and `x_users.email` is a plain
|
|
110
|
+
* case-sensitive `unique` column — so unnormalised, the lookup below missed the account the user
|
|
111
|
+
* registered and `createUserFor` minted a second one at the same address, which `login()` could
|
|
112
|
+
* then never reach. `MemoryAdapter` used to fold case itself, which is why no test saw it.
|
|
113
|
+
*/
|
|
114
|
+
const profileEmail = (profile: OAuthProfile): string | null =>
|
|
115
|
+
profile.email === null ? null : normaliseEmail(profile.email);
|
|
116
|
+
|
|
44
117
|
async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthUser> {
|
|
45
|
-
const email = input.profile
|
|
118
|
+
const email = profileEmail(input.profile);
|
|
46
119
|
// The provider authenticated somebody and told us no address. There is nothing to create an
|
|
47
120
|
// account from, and saying "wrong password" here would send the developer hunting the wrong bug.
|
|
48
121
|
if (email === null) {
|
|
@@ -50,7 +123,17 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
|
|
|
50
123
|
provider: input.profile.provider,
|
|
51
124
|
stage: 'userinfo',
|
|
52
125
|
detail: 'the provider returned an identity with no email address',
|
|
53
|
-
fix: `request the email scope for ${input.profile.provider} in beginOAuth(), then
|
|
126
|
+
fix: `request the email scope for ${input.profile.provider} in beginOAuth(), then ${restartAt(input.profile.provider)}`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const grants = input.grants ?? {};
|
|
130
|
+
if ((grants.roles ?? []).length === 0 && (grants.orgId ?? null) === null) {
|
|
131
|
+
// Not an error — an app may genuinely want a roleless account until somebody approves it —
|
|
132
|
+
// but silent is how "SSO works and the user can do nothing" survives to production. Every
|
|
133
|
+
// `can()` denies a roleless actor, and a tenant-scoped read throws before the query is built.
|
|
134
|
+
logger.warn('auth.oauth.user_created_without_grants', {
|
|
135
|
+
provider: input.profile.provider,
|
|
136
|
+
seam: 'oauthLogin(auth, { resolveGrants })',
|
|
54
137
|
});
|
|
55
138
|
}
|
|
56
139
|
const created = await auth.adapter.createUser({
|
|
@@ -59,8 +142,10 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
|
|
|
59
142
|
// An OAuth-only account has no password to store, and must never be given a random one:
|
|
60
143
|
// a hash nobody knows the input to is still a credential a reset flow could hand over.
|
|
61
144
|
passwordHash: null,
|
|
62
|
-
orgId:
|
|
63
|
-
roles:
|
|
145
|
+
orgId: grants.orgId ?? null,
|
|
146
|
+
roles: grants.roles ?? [],
|
|
147
|
+
scopes: grants.scopes ?? [],
|
|
148
|
+
externalId: grants.externalId ?? null,
|
|
64
149
|
createdAt: auth.clock.now(),
|
|
65
150
|
});
|
|
66
151
|
if (!input.profile.emailVerified) return created;
|
|
@@ -79,13 +164,17 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
|
|
|
79
164
|
* An address alone is not proof of ownership on either side. Attaching a provider identity to a
|
|
80
165
|
* local account that never verified its own email hands the login to whoever registered that
|
|
81
166
|
* address first, so both halves must have proven it before they are treated as one person.
|
|
167
|
+
*
|
|
168
|
+
* `auth.link` is the app's one say in that. `'never'` skips the address step entirely and refuses
|
|
169
|
+
* a collision; `'verified-email'` — the default — is the both-halves-proven rule below.
|
|
82
170
|
*/
|
|
83
171
|
async function resolveUser(
|
|
84
172
|
auth: Auth,
|
|
85
173
|
input: OAuthSignInInput,
|
|
86
174
|
linked: AuthAccount | null,
|
|
87
175
|
): Promise<AuthUser> {
|
|
88
|
-
const { provider,
|
|
176
|
+
const { provider, emailVerified } = input.profile;
|
|
177
|
+
const email = profileEmail(input.profile);
|
|
89
178
|
if (linked !== null) return await userForAccount(auth, linked);
|
|
90
179
|
|
|
91
180
|
if (email === null) return await createUserFor(auth, input);
|
|
@@ -94,6 +183,12 @@ async function resolveUser(
|
|
|
94
183
|
if (existing.disabledAt !== null) throw loginFailed();
|
|
95
184
|
// The provider did not vouch for the address, so nothing here proves the two are one person.
|
|
96
185
|
if (!emailVerified) throw loginFailed();
|
|
186
|
+
// Only past that line is the caller known to own the address, which is what makes naming the
|
|
187
|
+
// collision safe. Refusing `'never'` any earlier answered an UNVERIFIED provider address with a
|
|
188
|
+
// distinct code and a `meta.email` where `'verified-email'` answers `loginFailed()` — so the
|
|
189
|
+
// strict policy confirmed an account exists at an address its caller never proved. `disabledAt`
|
|
190
|
+
// above is generic and reveals nothing, which is why it can be asked first.
|
|
191
|
+
if (auth.link === 'never') throw oauthLinkingDisabled(provider, email);
|
|
97
192
|
// It did vouch, and the local account never did: say so, because this caller owns the address.
|
|
98
193
|
if (existing.emailVerifiedAt === null) throw oauthAccountNotLinked(provider, email);
|
|
99
194
|
return existing;
|
|
@@ -127,7 +222,9 @@ export async function signInWithOAuth(auth: Auth, input: OAuthSignInInput): Prom
|
|
|
127
222
|
}
|
|
128
223
|
|
|
129
224
|
const linked = await auth.adapter.findAccount(provider, input.profile.providerAccountId);
|
|
130
|
-
const
|
|
225
|
+
const resolved = await resolveUser(auth, input, linked);
|
|
226
|
+
const user =
|
|
227
|
+
input.grants === undefined ? resolved : await applyGrants(auth, resolved, input.grants);
|
|
131
228
|
// Linked before the MFA gate on purpose: the second factor is finished on another request,
|
|
132
229
|
// and that request must find the identity already attached. A re-link refreshes the tokens
|
|
133
230
|
// and keeps the row's own identity — the provider account never changes hands.
|
|
@@ -162,8 +259,8 @@ export interface CompleteOAuthLoginInput {
|
|
|
162
259
|
readonly timeoutMs?: number | undefined;
|
|
163
260
|
readonly ip?: string | null | undefined;
|
|
164
261
|
readonly userAgent?: string | null | undefined;
|
|
165
|
-
|
|
166
|
-
readonly
|
|
262
|
+
/** The app's grant seam. Called once, after the profile is proven. */
|
|
263
|
+
readonly resolveGrants?: ResolveOAuthGrants | undefined;
|
|
167
264
|
}
|
|
168
265
|
|
|
169
266
|
/** Callback → session, in one call: exchange, identify, sign in. */
|
|
@@ -182,12 +279,12 @@ export async function completeOAuthLogin(
|
|
|
182
279
|
fetch: input.fetch,
|
|
183
280
|
timeoutMs: input.timeoutMs,
|
|
184
281
|
});
|
|
282
|
+
const grants = input.resolveGrants === undefined ? undefined : await input.resolveGrants(profile);
|
|
185
283
|
return await signInWithOAuth(auth, {
|
|
186
284
|
profile,
|
|
187
285
|
tokens,
|
|
188
286
|
ip: input.ip,
|
|
189
287
|
userAgent: input.userAgent,
|
|
190
|
-
|
|
191
|
-
orgId: input.orgId,
|
|
288
|
+
...(grants === undefined ? {} : { grants }),
|
|
192
289
|
});
|
|
193
290
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Single responsibility: the one declaration of where the OAuth login routes live. It imports
|
|
2
|
+
// nothing so that `errors.ts` and `oauth-route.ts` can both read it without a cycle — which is
|
|
3
|
+
// the point: three shipped `fix:` lines told the caller to restart at `GET /auth/oauth/<provider>`
|
|
4
|
+
// while no route was mounted anywhere. Deriving both the mount and the fix from here makes a fix
|
|
5
|
+
// line that names a route nothing serves impossible rather than merely discouraged.
|
|
6
|
+
|
|
7
|
+
/** Not configurable, on purpose: a movable base path is a `fix:` line that can go stale again. */
|
|
8
|
+
export const OAUTH_BASE_PATH = '/auth/oauth';
|
|
9
|
+
|
|
10
|
+
/** The pattern a router mounts. `:provider` is the segment `oauthStartPath` fills in. */
|
|
11
|
+
export const OAUTH_START_ROUTE_PATH = `${OAUTH_BASE_PATH}/:provider` as const;
|
|
12
|
+
|
|
13
|
+
export const OAUTH_CALLBACK_ROUTE_PATH = `${OAUTH_BASE_PATH}/:provider/callback` as const;
|
|
14
|
+
|
|
15
|
+
/** Where a browser starts a login. This exact string is what the `fix:` lines quote. */
|
|
16
|
+
export const oauthStartPath = (provider: string): string => `${OAUTH_BASE_PATH}/${provider}`;
|
|
17
|
+
|
|
18
|
+
/** Where the provider sends it back. Registered with the provider as the `redirect_uri`. */
|
|
19
|
+
export const oauthCallbackPath = (provider: string): string =>
|
|
20
|
+
`${OAUTH_BASE_PATH}/${provider}/callback`;
|
package/src/oauth-profile.ts
CHANGED
|
@@ -4,15 +4,17 @@
|
|
|
4
4
|
// decides whether this login may attach itself to an existing account by address.
|
|
5
5
|
|
|
6
6
|
import { logger } from '@ultimat3/core';
|
|
7
|
-
import { oauthExchangeFailed } from './errors';
|
|
7
|
+
import { oauthExchangeFailed, restartAt } from './errors';
|
|
8
8
|
import { idTokenEmailVerified, isVerifiedFlag } from './id-token';
|
|
9
|
-
import {
|
|
9
|
+
import { isRecord } from './json';
|
|
10
|
+
import type { OAuthProviderId } from './oauth';
|
|
10
11
|
import {
|
|
11
12
|
OAUTH_USER_AGENT,
|
|
12
13
|
type OAuthFetch,
|
|
13
14
|
type OAuthTokens,
|
|
14
15
|
providerDetail,
|
|
15
16
|
} from './oauth-exchange';
|
|
17
|
+
import { providerFor } from './oauth-registry';
|
|
16
18
|
|
|
17
19
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
18
20
|
|
|
@@ -37,9 +39,6 @@ interface GithubEmail {
|
|
|
37
39
|
readonly verified: boolean;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
41
|
-
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
42
|
-
|
|
43
42
|
const stringOrNull = (value: unknown): string | null =>
|
|
44
43
|
typeof value === 'string' && value !== '' ? value : null;
|
|
45
44
|
|
|
@@ -108,14 +107,14 @@ async function fromUserInfo(
|
|
|
108
107
|
tokens: OAuthTokens,
|
|
109
108
|
options: OAuthProfileOptions,
|
|
110
109
|
): Promise<OAuthProfile> {
|
|
111
|
-
const config =
|
|
110
|
+
const config = providerFor(provider);
|
|
112
111
|
const url = config.userInfoUrl;
|
|
113
112
|
if (url === null) {
|
|
114
113
|
throw oauthExchangeFailed({
|
|
115
114
|
provider,
|
|
116
115
|
stage: 'userinfo',
|
|
117
116
|
detail: `${provider} publishes no userinfo endpoint and its id token carried no identity`,
|
|
118
|
-
fix: `request the "email" scope for ${provider} in beginOAuth(), then
|
|
117
|
+
fix: `request the "email" scope for ${provider} in beginOAuth(), then ${restartAt(provider)}`,
|
|
119
118
|
});
|
|
120
119
|
}
|
|
121
120
|
|
|
@@ -126,7 +125,7 @@ async function fromUserInfo(
|
|
|
126
125
|
stage: 'userinfo',
|
|
127
126
|
detail: result.detail,
|
|
128
127
|
status: result.status,
|
|
129
|
-
fix: `confirm the ${provider} app still grants ${config.scopes.join(' ')}, then
|
|
128
|
+
fix: `confirm the ${provider} app still grants ${config.scopes.join(' ')}, then ${restartAt(provider)}`,
|
|
130
129
|
});
|
|
131
130
|
}
|
|
132
131
|
if (!isRecord(result.body)) {
|
|
@@ -148,7 +147,7 @@ async function fromUserInfo(
|
|
|
148
147
|
provider,
|
|
149
148
|
stage: 'userinfo',
|
|
150
149
|
detail: 'the profile carried no stable account id',
|
|
151
|
-
fix: `confirm the ${provider} app requests ${config.scopes.join(' ')}
|
|
150
|
+
fix: `confirm the ${provider} app requests ${config.scopes.join(' ')}, then ${restartAt(provider)}`,
|
|
152
151
|
});
|
|
153
152
|
}
|
|
154
153
|
|
|
@@ -186,7 +185,7 @@ export async function oauthProfile(
|
|
|
186
185
|
if (claims === null) return await fromUserInfo(provider, tokens, options);
|
|
187
186
|
|
|
188
187
|
const email = stringOrNull(claims.email);
|
|
189
|
-
const userInfoUrl =
|
|
188
|
+
const userInfoUrl = providerFor(provider).userInfoUrl;
|
|
190
189
|
if (email === null && userInfoUrl !== null) {
|
|
191
190
|
const profile = await fromUserInfo(provider, tokens, options);
|
|
192
191
|
// Two surfaces, one identity — or this is not that identity. Overwriting the subject and
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Single responsibility: which OAuth providers this process knows about. A closed union of three
|
|
2
|
+
// consumer IdPs made an enterprise OP — Okta, Entra, Ping, an in-house OP — *unrepresentable*: the
|
|
3
|
+
// constraint was a type, so there was no runtime escape and the only ways out were forking the
|
|
4
|
+
// package or bypassing the whole subsystem, losing PKCE, the sealed handshake, issuer pinning and
|
|
5
|
+
// account linking with it. The three built-ins register through the same call an app uses, so the
|
|
6
|
+
// opening does not create a second path.
|
|
7
|
+
|
|
8
|
+
import { oauthProviderDuplicate, oauthProviderUnknown } from './errors';
|
|
9
|
+
import type { OAuthProvider } from './oauth';
|
|
10
|
+
import { BUILTIN_OAUTH_PROVIDERS } from './oauth-builtins';
|
|
11
|
+
|
|
12
|
+
const registry = new Map<string, OAuthProvider>();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Frozen on the way in, arrays included: a registered provider is read on every login of its
|
|
16
|
+
* kind, and a caller that kept a reference to the object it passed could otherwise repoint
|
|
17
|
+
* `tokenUrl` or widen `issuers` after boot.
|
|
18
|
+
*/
|
|
19
|
+
const seal = (provider: OAuthProvider): OAuthProvider =>
|
|
20
|
+
Object.freeze({
|
|
21
|
+
...provider,
|
|
22
|
+
issuers: Object.freeze([...provider.issuers]),
|
|
23
|
+
scopes: Object.freeze([...provider.scopes]),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Add an IdP. The one extension point, and the same one `oauth-builtins.ts` goes through.
|
|
28
|
+
*
|
|
29
|
+
* `usesPkce` stays the literal `true` on `OAuthProvider`, so this opening cannot be used to
|
|
30
|
+
* register a PKCE-less provider: the mechanism the package exists to own survives the widening.
|
|
31
|
+
*
|
|
32
|
+
* A duplicate id is `X_OAUTH_PROVIDER_DUPLICATE` rather than a silent replacement — two modules
|
|
33
|
+
* each registering `'okta'` is one of them quietly deciding where every okta login goes.
|
|
34
|
+
*/
|
|
35
|
+
export function registerOAuthProvider(provider: OAuthProvider): OAuthProvider {
|
|
36
|
+
if (registry.has(provider.id)) throw oauthProviderDuplicate(provider.id);
|
|
37
|
+
const sealed = seal(provider);
|
|
38
|
+
registry.set(sealed.id, sealed);
|
|
39
|
+
return sealed;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const provider of BUILTIN_OAUTH_PROVIDERS) registerOAuthProvider(provider);
|
|
43
|
+
|
|
44
|
+
export function hasOAuthProvider(id: string): boolean {
|
|
45
|
+
return registry.has(id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The one lookup. Throws rather than answering `undefined`, because every caller in this package
|
|
50
|
+
* would otherwise have to decide what an unknown provider means — and one of them would decide
|
|
51
|
+
* wrong. `X_OAUTH_PROVIDER_UNKNOWN` already existed for exactly this.
|
|
52
|
+
*/
|
|
53
|
+
export function providerFor(id: string): OAuthProvider {
|
|
54
|
+
const provider = registry.get(id);
|
|
55
|
+
if (provider === undefined) throw oauthProviderUnknown(id, oauthProviderIds());
|
|
56
|
+
return provider;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Live, never a snapshot taken at import: `defineAuth({ providers })` defaults to this, and a
|
|
61
|
+
* frozen copy would leave an IdP registered after this module loaded enabled nowhere.
|
|
62
|
+
*/
|
|
63
|
+
export function oauthProviderIds(): readonly string[] {
|
|
64
|
+
return [...registry.keys()];
|
|
65
|
+
}
|