@ultimat3/auth 1.2.0 → 2.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 +231 -0
- package/README.md +416 -34
- package/package.json +5 -4
- package/src/adapter.ts +69 -4
- package/src/auth.ts +96 -14
- package/src/builtin-adapter.ts +83 -6
- package/src/directory.ts +77 -0
- package/src/email.ts +17 -0
- package/src/errors.ts +219 -14
- package/src/guards.ts +6 -26
- package/src/id-token.ts +48 -26
- package/src/index.ts +105 -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/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 +20 -8
- 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/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
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// Single responsibility: the two HTTP routes the OAuth library functions have always been
|
|
2
|
+
// missing — the redirect out and the callback back — mounted at one fixed pair of paths so the
|
|
3
|
+
// `fix:` lines that name them cannot go stale. Everything below composes `beginOAuth`,
|
|
4
|
+
// `handshakeCookie` and `completeOAuthLogin`; no new protocol lives here.
|
|
5
|
+
//
|
|
6
|
+
// A route DESCRIPTOR, never a mounted handler, for the reason `mcpHttpRoute()` is one:
|
|
7
|
+
// `@ultimat3/http` is tier 2 like this package, so auth may not import it — and `defineRoute`
|
|
8
|
+
// is tier 4 and describes a rendered page. A bare `Request` in, a `Response` out, drivable from
|
|
9
|
+
// a test and mountable by any router that can match a `:param`.
|
|
10
|
+
|
|
11
|
+
import { type Clock, isUltimateError, renderThrowable, type UltimateError } from '@ultimat3/core';
|
|
12
|
+
import type { Auth, LoginResult } from './auth';
|
|
13
|
+
import { oauthDenied, oauthExchangeFailed, oauthProviderUnknown } from './errors';
|
|
14
|
+
import { beginOAuth, type OAuthProviderId } from './oauth';
|
|
15
|
+
import { BUILTIN_OAUTH_PROVIDER_IDS } from './oauth-builtins';
|
|
16
|
+
import { clearHandshakeCookie, handshakeCookie, readHandshakeCookie } from './oauth-cookie';
|
|
17
|
+
import type { OAuthClientCredentials, OAuthFetch } from './oauth-exchange';
|
|
18
|
+
import { oauthCredentials } from './oauth-exchange';
|
|
19
|
+
import { completeOAuthLogin, type ResolveOAuthGrants } from './oauth-login';
|
|
20
|
+
import {
|
|
21
|
+
OAUTH_CALLBACK_ROUTE_PATH,
|
|
22
|
+
OAUTH_START_ROUTE_PATH,
|
|
23
|
+
oauthCallbackPath,
|
|
24
|
+
} from './oauth-paths';
|
|
25
|
+
import { hasOAuthProvider } from './oauth-registry';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What a router needs to mount one of these. Structural, like `RequestLike` in `session.ts`:
|
|
29
|
+
* `@ultimat3/http` binds to this shape, this package never binds to `@ultimat3/http`.
|
|
30
|
+
*/
|
|
31
|
+
export interface AuthRouteDescriptor {
|
|
32
|
+
readonly method: 'GET';
|
|
33
|
+
/** The mount pattern, `:provider` included. The handler re-reads it off the request itself. */
|
|
34
|
+
readonly path: string;
|
|
35
|
+
/** Stable id for a route table, a trace and the manifest. */
|
|
36
|
+
readonly name: string;
|
|
37
|
+
/** Both legs are public by definition — they are how an anonymous visitor stops being one. */
|
|
38
|
+
readonly auth: 'public';
|
|
39
|
+
handle(request: Request): Promise<Response>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface OAuthLoginRoutes {
|
|
43
|
+
readonly start: AuthRouteDescriptor;
|
|
44
|
+
readonly callback: AuthRouteDescriptor;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface OAuthLoginOptions {
|
|
48
|
+
/**
|
|
49
|
+
* The origin the provider redirects back to. Defaults to `APP_URL`, then to the request's own
|
|
50
|
+
* origin — which is the `Host` header, so it is preferred last: a forged one only ever produces
|
|
51
|
+
* a `redirect_uri` the provider refuses, but naming the canonical origin costs nothing.
|
|
52
|
+
*/
|
|
53
|
+
readonly baseUrl?: string | undefined;
|
|
54
|
+
/** Defaults to the two env vars in the provider table, read per request, never at import. */
|
|
55
|
+
readonly credentials?: OAuthClientCredentials | undefined;
|
|
56
|
+
/** Defaults to `SESSION_SECRET`. Seals the handshake across the two requests. */
|
|
57
|
+
readonly secret?: string | undefined;
|
|
58
|
+
/** Injected in tests; production uses the global. */
|
|
59
|
+
readonly fetch?: OAuthFetch | undefined;
|
|
60
|
+
readonly timeoutMs?: number | undefined;
|
|
61
|
+
/** Where a signed-in browser lands. A fixed path — never read from the request. See below. */
|
|
62
|
+
readonly successPath?: string | undefined;
|
|
63
|
+
/** Narrower scopes than the provider's defaults, when the app wants less. */
|
|
64
|
+
readonly scopes?: readonly string[] | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* The client address recorded on the session. Defaults to none: the only honest source is a
|
|
67
|
+
* trusted-proxy chain, and this package cannot see one. `@ultimat3/http` can, and passes it.
|
|
68
|
+
*/
|
|
69
|
+
readonly clientIp?: ((request: Request) => string | null | undefined) | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* What the IdP's answer entitles this identity to. **Omit it and a first-time SSO user is
|
|
72
|
+
* created with `roles: []` and `orgId: null`** — an actor every `can()` denies, and a
|
|
73
|
+
* tenant-scoped read that throws `X_TENANCY_ACTOR_ORG_REQUIRED` before the query is built. SSO
|
|
74
|
+
* "works" and the person can do nothing until somebody runs SQL.
|
|
75
|
+
*
|
|
76
|
+
* A seam and not a group-to-role table, because which IdP group means which role is business
|
|
77
|
+
* convention and business convention never ships (axiom 8). The framework's part is calling it
|
|
78
|
+
* on every login, so removing somebody from a group in the IdP takes effect at their next
|
|
79
|
+
* sign-in rather than never.
|
|
80
|
+
*/
|
|
81
|
+
readonly resolveGrants?: ResolveOAuthGrants | undefined;
|
|
82
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* HTTP status per code, for a descriptor driven OUTSIDE a pipeline — a bare `Request` in, a
|
|
87
|
+
* `Response` out, which is the whole point of a descriptor. `@ultimat3/http`'s `error-map.ts` OWNS
|
|
88
|
+
* these numbers and is where a new one is declared; this package is the same tier and can never
|
|
89
|
+
* import it, so the table is a copy the pin `scripts/oauth-route-status.test.ts` holds identical to
|
|
90
|
+
* `statusFor()`. Everything absent is the provider's fault until proven otherwise: 502.
|
|
91
|
+
*/
|
|
92
|
+
export const OAUTH_ROUTE_STATUS: Readonly<Record<string, number>> = {
|
|
93
|
+
X_OAUTH_PROVIDER_UNKNOWN: 404,
|
|
94
|
+
X_OAUTH_DENIED: 403,
|
|
95
|
+
X_OAUTH_STATE_INVALID: 400,
|
|
96
|
+
X_OAUTH_TOKEN_INVALID: 400,
|
|
97
|
+
X_UNAUTHENTICATED: 401,
|
|
98
|
+
X_MFA_REQUIRED: 401,
|
|
99
|
+
X_ACCOUNT_LOCKED: 429,
|
|
100
|
+
X_ENV_MISSING: 500,
|
|
101
|
+
X_CONFIG_INVALID: 500,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* What an anonymous caller is allowed to read. `UltimateError.toJSON()` carries `meta` and `stack`
|
|
106
|
+
* — a developer's fields — and BOTH legs of this flow are public by definition, so serialising it
|
|
107
|
+
* whole published a stack trace and whatever a factory put in `meta` to whoever typed the URL. Four
|
|
108
|
+
* fields, the same four on every code: no per-code judgement about which `meta` key is safe today.
|
|
109
|
+
*/
|
|
110
|
+
const publicBody = (coded: UltimateError): Record<string, string> => ({
|
|
111
|
+
code: coded.code,
|
|
112
|
+
title: coded.title,
|
|
113
|
+
cause: coded.cause,
|
|
114
|
+
fix: coded.fix,
|
|
115
|
+
docs: coded.docs,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Failure is JSON, never a redirect to a login page carrying `?error=`. A callback is the one
|
|
120
|
+
* request in the flow whose failure the developer has to read, and a redirect that drops the code
|
|
121
|
+
* and the fix line is exactly how three dead `fix:` strings survived a whole release. An app that
|
|
122
|
+
* wants a rendered page wraps these two descriptors; the framework ships the debuggable answer.
|
|
123
|
+
*/
|
|
124
|
+
function problem(error: unknown, extraCookies: readonly string[]): Response {
|
|
125
|
+
const coded = isUltimateError(error)
|
|
126
|
+
? error
|
|
127
|
+
: oauthExchangeFailed({
|
|
128
|
+
provider: 'oauth',
|
|
129
|
+
stage: 'token',
|
|
130
|
+
// `renderThrowable`, never `error.message`: the throw came from an adapter or a `fetch`
|
|
131
|
+
// this package does not own, and a getter on `message` — or a `Proxy` trapping
|
|
132
|
+
// `getPrototypeOf` — would make the callback's last answer throw instead of send.
|
|
133
|
+
detail: renderThrowable(error),
|
|
134
|
+
fix: 'throw an UltimateError from the AuthAdapter or OAuthFetch that failed — the factories are in packages/auth/src/errors.ts',
|
|
135
|
+
});
|
|
136
|
+
const headers = new Headers({ 'content-type': 'application/json; charset=utf-8' });
|
|
137
|
+
for (const cookie of extraCookies) headers.append('set-cookie', cookie);
|
|
138
|
+
return new Response(JSON.stringify(publicBody(coded)), {
|
|
139
|
+
// 502 is the default: an uncoded throw on this path came out of the provider conversation.
|
|
140
|
+
status: OAUTH_ROUTE_STATUS[coded.code] ?? 502,
|
|
141
|
+
headers,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `/auth/oauth/github` → `github`; `/auth/oauth/github/callback` → `github`. */
|
|
146
|
+
function providerSegment(request: Request, leg: 'start' | 'callback'): string {
|
|
147
|
+
const segments = new URL(request.url).pathname.split('/').filter((s) => s.length > 0);
|
|
148
|
+
const index = leg === 'start' ? segments.length - 1 : segments.length - 2;
|
|
149
|
+
return segments[index] ?? '';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Both halves of "is this a provider", in one refusal. An unknown segment and a known provider
|
|
154
|
+
* the app left out of `defineAuth({ providers })` are the same 404 on purpose — telling an
|
|
155
|
+
* anonymous caller which of the two it hit describes the app's configuration for free.
|
|
156
|
+
*
|
|
157
|
+
* The list in the refusal is the THREE BUILT-INS, never the live registry and never
|
|
158
|
+
* `defineAuth({ providers })`. Both of the latter are this deployment's own configuration, and
|
|
159
|
+
* this caller is an anonymous stranger who typed a URL: an app that registered an internal OP has
|
|
160
|
+
* put its own vocabulary into the registry, and echoing it back names a system the stranger had no
|
|
161
|
+
* way to know exists. The built-in list is a framework constant already in the public docs, so it
|
|
162
|
+
* discloses nothing while still making the fix executable — and `registerOAuthProvider` in the
|
|
163
|
+
* same sentence covers the other branch without enumerating anything.
|
|
164
|
+
*
|
|
165
|
+
* `providerFor()` keeps the full registered list for the same reason in reverse: its reader is a
|
|
166
|
+
* developer holding a stack trace, and there the list is exactly what makes the fix runnable.
|
|
167
|
+
*/
|
|
168
|
+
function assertEnabled(auth: Auth, segment: string): OAuthProviderId {
|
|
169
|
+
const supported = BUILTIN_OAUTH_PROVIDER_IDS;
|
|
170
|
+
if (!hasOAuthProvider(segment)) throw oauthProviderUnknown(segment, supported);
|
|
171
|
+
const provider: OAuthProviderId = segment;
|
|
172
|
+
if (!auth.providers.includes(provider)) throw oauthProviderUnknown(segment, supported);
|
|
173
|
+
return provider;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function originFor(request: Request, options: OAuthLoginOptions): string {
|
|
177
|
+
const env = options.env ?? Bun.env;
|
|
178
|
+
const declared = options.baseUrl ?? env['APP_URL']?.trim() ?? '';
|
|
179
|
+
return declared === '' ? new URL(request.url).origin : declared.replace(/\/+$/, '');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The handshake's own options, assembled once so both legs seal and open it identically. */
|
|
183
|
+
const sealOptions = (
|
|
184
|
+
clock: Clock,
|
|
185
|
+
options: OAuthLoginOptions,
|
|
186
|
+
): { clock: Clock; secret?: string | undefined } => ({
|
|
187
|
+
clock,
|
|
188
|
+
...(options.secret === undefined ? {} : { secret: options.secret }),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
async function startHandler(
|
|
192
|
+
auth: Auth,
|
|
193
|
+
options: OAuthLoginOptions,
|
|
194
|
+
request: Request,
|
|
195
|
+
): Promise<Response> {
|
|
196
|
+
try {
|
|
197
|
+
const provider = assertEnabled(auth, providerSegment(request, 'start'));
|
|
198
|
+
const credentials = options.credentials ?? oauthCredentials(provider, options.env ?? Bun.env);
|
|
199
|
+
const handshake = beginOAuth({
|
|
200
|
+
provider,
|
|
201
|
+
clientId: credentials.clientId,
|
|
202
|
+
redirectUri: `${originFor(request, options)}${oauthCallbackPath(provider)}`,
|
|
203
|
+
scopes: options.scopes,
|
|
204
|
+
});
|
|
205
|
+
return new Response(null, {
|
|
206
|
+
// 302, the status every OAuth client already expects on this hop.
|
|
207
|
+
status: 302,
|
|
208
|
+
headers: {
|
|
209
|
+
location: handshake.authorizeUrl,
|
|
210
|
+
'set-cookie': handshakeCookie(handshake, sealOptions(auth.clock, options)),
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
} catch (error) {
|
|
214
|
+
return problem(error, []);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The provider declining is `error=` on the redirect, and there is no code to exchange. */
|
|
219
|
+
function assertNoProviderError(url: URL, provider: string): void {
|
|
220
|
+
const declined = url.searchParams.get('error');
|
|
221
|
+
if (declined === null || declined === '') return;
|
|
222
|
+
throw oauthDenied(provider, declined, url.searchParams.get('error_description'));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function callbackHandler(
|
|
226
|
+
auth: Auth,
|
|
227
|
+
options: OAuthLoginOptions,
|
|
228
|
+
request: Request,
|
|
229
|
+
): Promise<Response> {
|
|
230
|
+
const segment = providerSegment(request, 'callback');
|
|
231
|
+
// Cleared on every outcome, success and failure alike: the code it authorised is spent either
|
|
232
|
+
// way, so a handshake that outlives its own callback is a replay window and nothing else.
|
|
233
|
+
const clear = hasOAuthProvider(segment) ? [clearHandshakeCookie(segment)] : [];
|
|
234
|
+
try {
|
|
235
|
+
const provider = assertEnabled(auth, segment);
|
|
236
|
+
const url = new URL(request.url);
|
|
237
|
+
assertNoProviderError(url, provider);
|
|
238
|
+
const result: LoginResult = await completeOAuthLogin(auth, {
|
|
239
|
+
handshake: readHandshakeCookie(request, provider, sealOptions(auth.clock, options)),
|
|
240
|
+
callback: {
|
|
241
|
+
state: url.searchParams.get('state') ?? '',
|
|
242
|
+
code: url.searchParams.get('code') ?? '',
|
|
243
|
+
},
|
|
244
|
+
...(options.credentials === undefined ? {} : { credentials: options.credentials }),
|
|
245
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
246
|
+
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
|
247
|
+
...(options.resolveGrants === undefined ? {} : { resolveGrants: options.resolveGrants }),
|
|
248
|
+
ip: options.clientIp?.(request) ?? null,
|
|
249
|
+
userAgent: request.headers.get('user-agent'),
|
|
250
|
+
});
|
|
251
|
+
const headers = new Headers({
|
|
252
|
+
// A fixed path, never `?next=`: an attacker-supplied return target on the one endpoint whose
|
|
253
|
+
// job is to hand out a session is the classic open redirect, and `@ultimat3/http`'s
|
|
254
|
+
// `nextAfterSignIn` is the one implementation of that check. Two copies is one that drifts.
|
|
255
|
+
location: options.successPath ?? '/',
|
|
256
|
+
});
|
|
257
|
+
// 303: the callback may arrive as a `form_post`, and the destination is a GET either way.
|
|
258
|
+
headers.append('set-cookie', result.cookie);
|
|
259
|
+
for (const cookie of clear) headers.append('set-cookie', cookie);
|
|
260
|
+
return new Response(null, { status: 303, headers });
|
|
261
|
+
} catch (error) {
|
|
262
|
+
return problem(error, clear);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The two routes. Mount them and "log in with GitHub" is a button pointing at
|
|
268
|
+
* `/auth/oauth/github` — no handshake store, no PKCE bookkeeping, no state check to forget.
|
|
269
|
+
*
|
|
270
|
+
* ```ts
|
|
271
|
+
* const { start, callback } = oauthLogin(auth);
|
|
272
|
+
* // start.path → '/auth/oauth/:provider'
|
|
273
|
+
* // callback.path → '/auth/oauth/:provider/callback'
|
|
274
|
+
* ```
|
|
275
|
+
*/
|
|
276
|
+
export function oauthLogin(auth: Auth, options: OAuthLoginOptions = {}): OAuthLoginRoutes {
|
|
277
|
+
return Object.freeze({
|
|
278
|
+
start: Object.freeze({
|
|
279
|
+
method: 'GET',
|
|
280
|
+
path: OAUTH_START_ROUTE_PATH,
|
|
281
|
+
name: 'auth.oauth.start',
|
|
282
|
+
auth: 'public',
|
|
283
|
+
handle: (request: Request) => startHandler(auth, options, request),
|
|
284
|
+
} as const),
|
|
285
|
+
callback: Object.freeze({
|
|
286
|
+
method: 'GET',
|
|
287
|
+
path: OAUTH_CALLBACK_ROUTE_PATH,
|
|
288
|
+
name: 'auth.oauth.callback',
|
|
289
|
+
auth: 'public',
|
|
290
|
+
handle: (request: Request) => callbackHandler(auth, options, request),
|
|
291
|
+
} as const),
|
|
292
|
+
});
|
|
293
|
+
}
|