@ultimat3/auth 23.0.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +95 -46
- package/README.md +245 -93
- package/package.json +5 -5
- package/src/adapter.ts +49 -18
- package/src/api-key-resolver.ts +14 -9
- package/src/api-keys.ts +61 -14
- package/src/auth.ts +56 -53
- package/src/builtin-adapter-violation.ts +43 -0
- package/src/builtin-adapter.ts +77 -9
- package/src/client-scope.ts +8 -2
- package/src/directory.ts +2 -13
- package/src/email.ts +1 -1
- package/src/errors.ts +80 -48
- package/src/guards.ts +2 -2
- package/src/id-token.ts +2 -3
- package/src/index.ts +57 -30
- package/src/jwks.ts +18 -7
- package/src/kdf-gate.ts +6 -6
- package/src/limiter-install.ts +1 -1
- package/src/login-attempt.ts +70 -0
- package/src/memory-adapter.ts +73 -7
- package/src/mfa-challenge.ts +151 -0
- package/src/mfa-secret.ts +91 -0
- package/src/mfa.ts +14 -28
- package/src/oauth-builtins.ts +6 -0
- package/src/oauth-cookie.ts +17 -7
- package/src/oauth-discovery.ts +24 -5
- package/src/oauth-errors.ts +4 -84
- package/src/oauth-exchange.ts +13 -7
- package/src/oauth-grant-context.ts +106 -0
- package/src/oauth-login.ts +78 -23
- package/src/oauth-profile.ts +48 -11
- package/src/oauth-registry.ts +16 -1
- package/src/oauth-route.ts +75 -22
- package/src/oauth.ts +4 -3
- package/src/policy-bridge.ts +51 -8
- package/src/privileges.ts +46 -18
- package/src/rate-limit-postgres.ts +113 -101
- package/src/rate-limit.ts +50 -16
- package/src/revocation.ts +25 -25
- package/src/session.ts +69 -37
- package/src/tables.ts +8 -10
- package/src/tokens.ts +2 -5
- package/src/verify.ts +4 -3
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Single responsibility: what `resolveGrants` may read besides the profile (#670). An IdP is free
|
|
2
|
+
// to put authorization facts — groups, entitlements, `allowed_oauth_apps` — on `/userinfo` only,
|
|
3
|
+
// or in an id-token claim `OAuthProfile` does not name; a seam that sees the profile alone refuses
|
|
4
|
+
// a correctly granted user. The token set, the whole verified payload, and a lazy userinfo read.
|
|
5
|
+
|
|
6
|
+
import { renderFixLiteral } from '@ultimat3/core';
|
|
7
|
+
import { decodeJwtSegment } from './json';
|
|
8
|
+
import type { OAuthProviderId } from './oauth';
|
|
9
|
+
import { oauthExchangeFailed } from './oauth-errors';
|
|
10
|
+
import type { OAuthTokens } from './oauth-exchange';
|
|
11
|
+
import { oauthStartPath } from './oauth-paths';
|
|
12
|
+
import {
|
|
13
|
+
type OAuthProfile,
|
|
14
|
+
type OAuthProfileOptions,
|
|
15
|
+
userinfoAccountId,
|
|
16
|
+
userinfoBody,
|
|
17
|
+
} from './oauth-profile';
|
|
18
|
+
import { providerFor } from './oauth-registry';
|
|
19
|
+
|
|
20
|
+
/** The second argument of `resolveGrants`. Read-only, and alive for one callback only. */
|
|
21
|
+
export interface OAuthGrantContext {
|
|
22
|
+
readonly provider: OAuthProviderId;
|
|
23
|
+
/**
|
|
24
|
+
* The exchange's token set, in flight. The framework persists none of it (`accountFor` writes
|
|
25
|
+
* `null`), so an app that keeps a token past this call has chosen to store a credential.
|
|
26
|
+
*/
|
|
27
|
+
readonly tokens: OAuthTokens;
|
|
28
|
+
/**
|
|
29
|
+
* Every claim of the id token the exchange VERIFIED — custom ones (`hd`, `groups`) included,
|
|
30
|
+
* which `tokens.claims` drops. `null` when the provider issued no id token.
|
|
31
|
+
*/
|
|
32
|
+
readonly idTokenClaims: Readonly<Record<string, unknown>> | null;
|
|
33
|
+
/**
|
|
34
|
+
* The provider's userinfo answer, every claim, read with the access token. At most one request
|
|
35
|
+
* per login, however often it is asked: none at all when the profile was already read off
|
|
36
|
+
* userinfo (GitHub), whose body is handed back; a failed read is not remembered.
|
|
37
|
+
* A body naming another subject is `X_OAUTH_EXCHANGE_FAILED` — the same rule `oauthProfile`
|
|
38
|
+
* applies, so a grant can never be read off somebody else's identity.
|
|
39
|
+
*/
|
|
40
|
+
userinfo(): Promise<Readonly<Record<string, unknown>>>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The verified token's payload, whole. Its signature, `iss`, `aud` and `exp` were checked already. */
|
|
44
|
+
function fullPayload(tokens: OAuthTokens): Readonly<Record<string, unknown>> | null {
|
|
45
|
+
if (tokens.claims === null || tokens.idToken === null) return null;
|
|
46
|
+
const payload = decodeJwtSegment(tokens.idToken.split('.')[1] ?? '');
|
|
47
|
+
return payload === null ? null : Object.freeze(payload);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readUserinfo(
|
|
51
|
+
profile: OAuthProfile,
|
|
52
|
+
tokens: OAuthTokens,
|
|
53
|
+
options: OAuthProfileOptions,
|
|
54
|
+
): Promise<Readonly<Record<string, unknown>>> {
|
|
55
|
+
const { provider } = profile;
|
|
56
|
+
// Asked before `userinfoBody`, whose own refusal for this case speaks of a missing identity:
|
|
57
|
+
// here the identity is proven, and what is missing is the endpoint the app's rule reads.
|
|
58
|
+
if (providerFor(provider).userInfoUrl === null) {
|
|
59
|
+
throw oauthExchangeFailed({
|
|
60
|
+
provider,
|
|
61
|
+
stage: 'userinfo',
|
|
62
|
+
detail: `${provider} publishes no userinfo endpoint, so resolveGrants cannot read one`,
|
|
63
|
+
fix: `resolveGrants: (profile, { idTokenClaims }) => grantsFrom(idTokenClaims) # ${provider} puts its claims in the id token, never behind context.userinfo()`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const body = await userinfoBody(provider, tokens, options);
|
|
67
|
+
if (userinfoAccountId(body) !== profile.providerAccountId) {
|
|
68
|
+
throw oauthExchangeFailed({
|
|
69
|
+
provider,
|
|
70
|
+
stage: 'userinfo',
|
|
71
|
+
detail: 'the userinfo subject is not the subject of the identity being signed in',
|
|
72
|
+
fix: `registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')}, userInfoUrl: '<the provider's own userinfo endpoint>', … }) # a proxy that rewrites sub is refused; then restart at ${oauthStartPath(provider)}`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return Object.freeze(body);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Built once per callback, after the profile is proven and before `resolveGrants` runs. `fetched`
|
|
80
|
+
* is the userinfo body the profile read already made, if it made one — its subject IS the profile's.
|
|
81
|
+
*/
|
|
82
|
+
export function oauthGrantContext(
|
|
83
|
+
profile: OAuthProfile,
|
|
84
|
+
tokens: OAuthTokens,
|
|
85
|
+
options: OAuthProfileOptions,
|
|
86
|
+
fetched: Record<string, unknown> | null = null,
|
|
87
|
+
): OAuthGrantContext {
|
|
88
|
+
let pending: Promise<Readonly<Record<string, unknown>>> | undefined =
|
|
89
|
+
fetched === null ? undefined : Promise.resolve(Object.freeze(fetched));
|
|
90
|
+
return Object.freeze({
|
|
91
|
+
provider: profile.provider,
|
|
92
|
+
tokens,
|
|
93
|
+
idTokenClaims: fullPayload(tokens),
|
|
94
|
+
userinfo() {
|
|
95
|
+
if (pending === undefined) {
|
|
96
|
+
const read = readUserinfo(profile, tokens, options);
|
|
97
|
+
pending = read;
|
|
98
|
+
// Forget a failure, so a seam that retries asks the provider again rather than the cache.
|
|
99
|
+
read.catch(() => {
|
|
100
|
+
if (pending === read) pending = undefined;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return pending;
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
package/src/oauth-login.ts
CHANGED
|
@@ -3,19 +3,14 @@
|
|
|
3
3
|
// matter which door the user came through. `completeOAuthLogin` is the blessed entry point;
|
|
4
4
|
// the three steps under it are exported because a custom flow needs the seams, not a second path.
|
|
5
5
|
|
|
6
|
-
import { ConfigInvalidError, logger,
|
|
6
|
+
import { ConfigInvalidError, logger, uuidV7 } from '@ultimat3/core';
|
|
7
7
|
import type { AuthAccount, AuthUser } from './adapter';
|
|
8
8
|
import type { Auth, LoginResult } from './auth';
|
|
9
9
|
import { normaliseEmail } from './email';
|
|
10
|
-
import {
|
|
10
|
+
import { AuthError, authWriteFailed } from './errors';
|
|
11
|
+
import { mfaChallengeRequired } from './mfa-challenge';
|
|
11
12
|
import type { OAuthCallback, OAuthHandshake } from './oauth';
|
|
12
|
-
import {
|
|
13
|
-
emailVerifiedNotStored,
|
|
14
|
-
oauthAccountNotLinked,
|
|
15
|
-
oauthExchangeFailed,
|
|
16
|
-
oauthLinkingDisabled,
|
|
17
|
-
restartAt,
|
|
18
|
-
} from './oauth-errors';
|
|
13
|
+
import { oauthExchangeFailed, restartAt } from './oauth-errors';
|
|
19
14
|
import {
|
|
20
15
|
exchangeOAuthCode,
|
|
21
16
|
type OAuthClientCredentials,
|
|
@@ -23,11 +18,58 @@ import {
|
|
|
23
18
|
type OAuthTokens,
|
|
24
19
|
oauthCredentials,
|
|
25
20
|
} from './oauth-exchange';
|
|
26
|
-
import { type
|
|
21
|
+
import { type OAuthGrantContext, oauthGrantContext } from './oauth-grant-context';
|
|
22
|
+
import { type OAuthProfile, readOAuthProfile } from './oauth-profile';
|
|
27
23
|
import { resolveActor } from './policy-bridge';
|
|
28
24
|
import { loginFailed } from './rate-limit';
|
|
29
25
|
import { createSession, sessionCookie } from './session';
|
|
30
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The address is proven to the provider, and an account that never proved it already holds it.
|
|
29
|
+
* Naming that is not account enumeration — this caller just demonstrated they own the address —
|
|
30
|
+
* and staying silent would leave them with a login that fails forever and no way out.
|
|
31
|
+
*
|
|
32
|
+
* The address itself rides in `meta`, never in `cause`: a log pipeline can redact a field by
|
|
33
|
+
* key, and cannot redact an address that was already interpolated into a sentence.
|
|
34
|
+
*/
|
|
35
|
+
export const oauthAccountNotLinked = (provider: string, email: string): AuthError =>
|
|
36
|
+
new AuthError({
|
|
37
|
+
code: 'X_UNAUTHENTICATED',
|
|
38
|
+
cause: `an account holds this ${provider} address but never verified it, so ${provider} may not claim it`,
|
|
39
|
+
fix: `sign in with that account's password and confirm the email-verify link, then retry ${provider}`,
|
|
40
|
+
meta: { provider, email },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `link: 'never'` and a local account already holds the address. Same code and same disclosure
|
|
45
|
+
* rule as `oauthAccountNotLinked` — the caller proved to the provider that the address is theirs,
|
|
46
|
+
* so naming the collision is not enumeration — and the address rides in `meta`, never in `cause`.
|
|
47
|
+
*/
|
|
48
|
+
export const oauthLinkingDisabled = (provider: string, email: string): AuthError =>
|
|
49
|
+
new AuthError({
|
|
50
|
+
code: 'X_UNAUTHENTICATED',
|
|
51
|
+
cause: `an account already holds this address and defineAuth({ link: 'never' }) forbids ${provider} from claiming it`,
|
|
52
|
+
fix: "sign in with that account's own credentials, or set link: 'verified-email' in defineAuth to let a provider-verified address claim a locally-verified account",
|
|
53
|
+
meta: { provider, email },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `CreateUserInput` carries no `emailVerifiedAt`, so a provider-verified address takes a second
|
|
58
|
+
* write. Falling back to the unstamped row would mint a session for a user every later login
|
|
59
|
+
* reads as unverified — the exact state `resolveUser` refuses to link a provider to — so the
|
|
60
|
+
* flow fails closed on an adapter that loses the stamp instead of half-succeeding.
|
|
61
|
+
*/
|
|
62
|
+
export const emailVerifiedNotStored = (provider: string, userId: string): AuthError =>
|
|
63
|
+
new AuthError({
|
|
64
|
+
// `X_AUTH_WRITE_FAILED`, the code for "an adapter write returned no row". It was
|
|
65
|
+
// `X_NOT_IMPLEMENTED` — a 501 telling an operator a feature is missing, about an adapter that
|
|
66
|
+
// implements `updateUser` and lost a write it accepted.
|
|
67
|
+
code: 'X_AUTH_WRITE_FAILED',
|
|
68
|
+
cause: `the adapter returned no row for new user ${userId}, so the ${provider}-verified address was never stamped verified`,
|
|
69
|
+
fix: 'return the updated row from AuthAdapter.updateUser — MemoryAuthAdapter.updateUser is the reference implementation',
|
|
70
|
+
meta: { provider, userId },
|
|
71
|
+
});
|
|
72
|
+
|
|
31
73
|
/**
|
|
32
74
|
* What the IdP's answer entitles this identity to, in the app's own vocabulary. Which group maps
|
|
33
75
|
* to which role is business convention and never ships (axiom 8) — this is the seam it arrives
|
|
@@ -48,8 +90,15 @@ export interface OAuthGrants {
|
|
|
48
90
|
readonly externalId?: string | null | undefined;
|
|
49
91
|
}
|
|
50
92
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Called once per callback, after the profile is proven and before the session is minted. The
|
|
95
|
+
* `context` is what the IdP said beyond the profile — the token set, every id-token claim, and a
|
|
96
|
+
* lazy `userinfo()` — for an IdP that keeps its grants off the id token (#670).
|
|
97
|
+
*/
|
|
98
|
+
export type ResolveOAuthGrants = (
|
|
99
|
+
profile: OAuthProfile,
|
|
100
|
+
context: OAuthGrantContext,
|
|
101
|
+
) => Promise<OAuthGrants> | OAuthGrants;
|
|
53
102
|
|
|
54
103
|
export interface OAuthSignInInput {
|
|
55
104
|
readonly profile: OAuthProfile;
|
|
@@ -108,7 +157,7 @@ async function applyGrants(auth: Auth, user: AuthUser, grants: OAuthGrants): Pro
|
|
|
108
157
|
* display casing (`Ada@Example.com`) and change it between logins, and `x_users.email` is a plain
|
|
109
158
|
* case-sensitive `unique` column — so unnormalised, the lookup below missed the account the user
|
|
110
159
|
* registered and `createUserFor` minted a second one at the same address, which `login()` could
|
|
111
|
-
* then never reach. `
|
|
160
|
+
* then never reach. `MemoryAuthAdapter` used to fold case itself, which is why no test saw it.
|
|
112
161
|
*/
|
|
113
162
|
const profileEmail = (profile: OAuthProfile): string | null =>
|
|
114
163
|
profile.email === null ? null : normaliseEmail(profile.email);
|
|
@@ -125,6 +174,12 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
|
|
|
125
174
|
fix: `request the email scope for ${input.profile.provider} in beginOAuth(), then ${restartAt(input.profile.provider)}`,
|
|
126
175
|
});
|
|
127
176
|
}
|
|
177
|
+
// The provider did not vouch for the address, so this caller has proven nothing about owning
|
|
178
|
+
// it. Minting the account anyway is the pre-hijack: the real owner's `register()` then collides,
|
|
179
|
+
// their verified-provider login is refused as "never verified", and the attacker's identity stays
|
|
180
|
+
// linked to the row they later claim. Answered exactly as `resolveUser` answers the same
|
|
181
|
+
// unverified address when it IS taken, so the two branches are no oracle for "is it taken?".
|
|
182
|
+
if (!input.profile.emailVerified) throw loginFailed();
|
|
128
183
|
const grants = input.grants ?? {};
|
|
129
184
|
if ((grants.roles ?? []).length === 0 && (grants.orgId ?? null) === null) {
|
|
130
185
|
// Not an error — an app may genuinely want a roleless account until somebody approves it —
|
|
@@ -136,7 +191,7 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
|
|
|
136
191
|
});
|
|
137
192
|
}
|
|
138
193
|
const created = await auth.adapter.createUser({
|
|
139
|
-
id:
|
|
194
|
+
id: uuidV7(auth.clock),
|
|
140
195
|
email,
|
|
141
196
|
// An OAuth-only account has no password to store, and must never be given a random one:
|
|
142
197
|
// a hash nobody knows the input to is still a credential a reset flow could hand over.
|
|
@@ -147,7 +202,6 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
|
|
|
147
202
|
externalId: grants.externalId ?? null,
|
|
148
203
|
createdAt: auth.clock.now(),
|
|
149
204
|
});
|
|
150
|
-
if (!input.profile.emailVerified) return created;
|
|
151
205
|
// `CreateUserInput` has no `emailVerifiedAt`, so the stamp is a required second write. Returning
|
|
152
206
|
// `created` when it does not land would sign in a user whose row says unverified — and the next
|
|
153
207
|
// login through this same provider would then refuse to link it at all.
|
|
@@ -158,7 +212,7 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
|
|
|
158
212
|
|
|
159
213
|
/**
|
|
160
214
|
* Resolve the identity to a user: an already-linked account first, then an existing account
|
|
161
|
-
* with the same address, then a fresh user.
|
|
215
|
+
* with the same address, then a fresh user — and a fresh user only for a provider-verified address.
|
|
162
216
|
*
|
|
163
217
|
* An address alone is not proof of ownership on either side. Attaching a provider identity to a
|
|
164
218
|
* local account that never verified its own email hands the login to whoever registered that
|
|
@@ -195,7 +249,7 @@ async function resolveUser(
|
|
|
195
249
|
|
|
196
250
|
function accountFor(auth: Auth, user: AuthUser, input: OAuthSignInInput): AuthAccount {
|
|
197
251
|
return {
|
|
198
|
-
id:
|
|
252
|
+
id: uuidV7(auth.clock),
|
|
199
253
|
userId: user.id,
|
|
200
254
|
provider: input.profile.provider,
|
|
201
255
|
providerAccountId: input.profile.providerAccountId,
|
|
@@ -244,7 +298,7 @@ export async function signInWithOAuth(auth: Auth, input: OAuthSignInInput): Prom
|
|
|
244
298
|
linked === null ? account : { ...account, id: linked.id, createdAt: linked.createdAt },
|
|
245
299
|
);
|
|
246
300
|
|
|
247
|
-
if (user.mfaSecret !== null) throw
|
|
301
|
+
if (user.mfaSecret !== null) throw await mfaChallengeRequired(auth, user.id);
|
|
248
302
|
|
|
249
303
|
const issued = await createSession(auth.sessions, {
|
|
250
304
|
userId: user.id,
|
|
@@ -286,11 +340,12 @@ export async function completeOAuthLogin(
|
|
|
286
340
|
fetch: input.fetch,
|
|
287
341
|
timeoutMs: input.timeoutMs,
|
|
288
342
|
});
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
343
|
+
const options = { fetch: input.fetch, timeoutMs: input.timeoutMs };
|
|
344
|
+
const { profile, userinfo } = await readOAuthProfile(provider, tokens, options);
|
|
345
|
+
const grants =
|
|
346
|
+
input.resolveGrants === undefined
|
|
347
|
+
? undefined
|
|
348
|
+
: await input.resolveGrants(profile, oauthGrantContext(profile, tokens, options, userinfo));
|
|
294
349
|
return await signInWithOAuth(auth, {
|
|
295
350
|
profile,
|
|
296
351
|
tokens,
|
package/src/oauth-profile.ts
CHANGED
|
@@ -133,11 +133,16 @@ async function githubPrimaryEmail(
|
|
|
133
133
|
return verified.find((entry) => entry.primary) ?? verified[0] ?? null;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
|
|
136
|
+
/**
|
|
137
|
+
* The provider's userinfo answer, whole: every claim, the ones `OAuthProfile` names and the ones it
|
|
138
|
+
* does not. Shared by the profile below and by `resolveGrants`' `userinfo()` (#670), so the two
|
|
139
|
+
* reads refuse the same three failures with the same codes — one endpoint, one reader.
|
|
140
|
+
*/
|
|
141
|
+
export async function userinfoBody(
|
|
137
142
|
provider: OAuthProviderId,
|
|
138
143
|
tokens: OAuthTokens,
|
|
139
144
|
options: OAuthProfileOptions,
|
|
140
|
-
): Promise<
|
|
145
|
+
): Promise<Record<string, unknown>> {
|
|
141
146
|
const config = providerFor(provider);
|
|
142
147
|
const url = config.userInfoUrl;
|
|
143
148
|
if (url === null) {
|
|
@@ -167,12 +172,30 @@ async function fromUserInfo(
|
|
|
167
172
|
fix: `confirm ${url} is the provider's real userinfo endpoint and not a proxy`,
|
|
168
173
|
});
|
|
169
174
|
}
|
|
175
|
+
return result.body;
|
|
176
|
+
}
|
|
170
177
|
|
|
171
|
-
|
|
172
|
-
|
|
178
|
+
/** GitHub's id is a number; OIDC's `sub` is a string. Both are stable, so both are accepted. */
|
|
179
|
+
export function userinfoAccountId(body: Readonly<Record<string, unknown>>): string {
|
|
173
180
|
const rawId = body['sub'] ?? body['id'];
|
|
174
|
-
|
|
175
|
-
|
|
181
|
+
return typeof rawId === 'number' ? String(rawId) : typeof rawId === 'string' ? rawId : '';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A profile, and the userinfo body it was read from when it was read from one. */
|
|
185
|
+
export interface OAuthProfileRead {
|
|
186
|
+
readonly profile: OAuthProfile;
|
|
187
|
+
/** `null` when the id token answered alone and no userinfo request was made. */
|
|
188
|
+
readonly userinfo: Record<string, unknown> | null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function fromUserInfo(
|
|
192
|
+
provider: OAuthProviderId,
|
|
193
|
+
tokens: OAuthTokens,
|
|
194
|
+
options: OAuthProfileOptions,
|
|
195
|
+
): Promise<OAuthProfileRead> {
|
|
196
|
+
const config = providerFor(provider);
|
|
197
|
+
const body = await userinfoBody(provider, tokens, options);
|
|
198
|
+
const providerAccountId = userinfoAccountId(body);
|
|
176
199
|
if (providerAccountId === '') {
|
|
177
200
|
throw oauthExchangeFailed({
|
|
178
201
|
provider,
|
|
@@ -193,13 +216,14 @@ async function fromUserInfo(
|
|
|
193
216
|
}
|
|
194
217
|
}
|
|
195
218
|
|
|
196
|
-
|
|
219
|
+
const profile: OAuthProfile = {
|
|
197
220
|
provider,
|
|
198
221
|
providerAccountId,
|
|
199
222
|
email,
|
|
200
223
|
emailVerified,
|
|
201
224
|
name: stringOrNull(body['name']) ?? stringOrNull(body['login']),
|
|
202
225
|
};
|
|
226
|
+
return { profile, userinfo: body };
|
|
203
227
|
}
|
|
204
228
|
|
|
205
229
|
/**
|
|
@@ -212,17 +236,29 @@ export async function oauthProfile(
|
|
|
212
236
|
tokens: OAuthTokens,
|
|
213
237
|
options: OAuthProfileOptions = {},
|
|
214
238
|
): Promise<OAuthProfile> {
|
|
239
|
+
return (await readOAuthProfile(provider, tokens, options)).profile;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* `oauthProfile`, keeping the userinfo body it fetched — so `resolveGrants`' `userinfo()` hands
|
|
244
|
+
* that body back instead of asking the provider a second time in the same login (#670).
|
|
245
|
+
*/
|
|
246
|
+
export async function readOAuthProfile(
|
|
247
|
+
provider: OAuthProviderId,
|
|
248
|
+
tokens: OAuthTokens,
|
|
249
|
+
options: OAuthProfileOptions = {},
|
|
250
|
+
): Promise<OAuthProfileRead> {
|
|
215
251
|
const claims = tokens.claims;
|
|
216
252
|
if (claims === null) return await fromUserInfo(provider, tokens, options);
|
|
217
253
|
|
|
218
254
|
const email = stringOrNull(claims.email);
|
|
219
255
|
const userInfoUrl = providerFor(provider).userInfoUrl;
|
|
220
256
|
if (email === null && userInfoUrl !== null) {
|
|
221
|
-
const
|
|
257
|
+
const read = await fromUserInfo(provider, tokens, options);
|
|
222
258
|
// Two surfaces, one identity — or this is not that identity. Overwriting the subject and
|
|
223
259
|
// keeping the address would link the account on an address belonging to whoever the second
|
|
224
260
|
// call described, so a disagreement ends the handshake instead of being reconciled.
|
|
225
|
-
if (profile.providerAccountId !== claims.sub) {
|
|
261
|
+
if (read.profile.providerAccountId !== claims.sub) {
|
|
226
262
|
throw oauthExchangeFailed({
|
|
227
263
|
provider,
|
|
228
264
|
stage: 'userinfo',
|
|
@@ -230,14 +266,15 @@ export async function oauthProfile(
|
|
|
230
266
|
fix: `confirm ${userInfoUrl} is ${provider}'s own userinfo endpoint and not a proxy that rewrites sub`,
|
|
231
267
|
});
|
|
232
268
|
}
|
|
233
|
-
return
|
|
269
|
+
return read;
|
|
234
270
|
}
|
|
235
271
|
|
|
236
|
-
|
|
272
|
+
const profile: OAuthProfile = {
|
|
237
273
|
provider,
|
|
238
274
|
providerAccountId: claims.sub,
|
|
239
275
|
email,
|
|
240
276
|
emailVerified: email !== null && idTokenEmailVerified(claims),
|
|
241
277
|
name: stringOrNull(claims.name),
|
|
242
278
|
};
|
|
279
|
+
return { profile, userinfo: null };
|
|
243
280
|
}
|
package/src/oauth-registry.ts
CHANGED
|
@@ -5,9 +5,24 @@
|
|
|
5
5
|
// account linking with it. The three built-ins register through the same call an app uses, so the
|
|
6
6
|
// opening does not create a second path.
|
|
7
7
|
|
|
8
|
+
import { renderCauseValue, renderFixLiteral } from '@ultimat3/core';
|
|
9
|
+
import { AuthError } from './errors';
|
|
8
10
|
import type { OAuthProvider } from './oauth';
|
|
9
11
|
import { BUILTIN_OAUTH_PROVIDERS } from './oauth-builtins';
|
|
10
|
-
import {
|
|
12
|
+
import { oauthProviderUnknown } from './oauth-errors';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Two `registerOAuthProvider` calls claiming one id. A silent replacement would let whichever
|
|
16
|
+
* module imported second decide where every login for that id goes — including which `issuers`
|
|
17
|
+
* an id token may claim — so the second registration refuses at boot instead.
|
|
18
|
+
*/
|
|
19
|
+
export const oauthProviderDuplicate = (provider: string): AuthError =>
|
|
20
|
+
new AuthError({
|
|
21
|
+
code: 'X_OAUTH_PROVIDER_DUPLICATE',
|
|
22
|
+
cause: `an oauth provider is already registered as ${renderCauseValue(provider)}, so the second registration would silently replace the first`,
|
|
23
|
+
fix: `give one of them a different id, or delete the duplicate registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')} }) call`,
|
|
24
|
+
meta: { provider },
|
|
25
|
+
});
|
|
11
26
|
|
|
12
27
|
const registry = new Map<string, OAuthProvider>();
|
|
13
28
|
|
package/src/oauth-route.ts
CHANGED
|
@@ -10,16 +10,20 @@
|
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
12
|
type Clock,
|
|
13
|
+
EnvMissingError,
|
|
13
14
|
isUltimateError,
|
|
14
15
|
logger,
|
|
16
|
+
renderCauseValue,
|
|
17
|
+
renderFixShellArg,
|
|
15
18
|
renderThrowable,
|
|
16
19
|
type UltimateError,
|
|
17
20
|
} from '@ultimat3/core';
|
|
18
21
|
import type { Auth, LoginResult } from './auth';
|
|
22
|
+
import { AuthError } from './errors';
|
|
19
23
|
import { beginOAuth, type OAuthProviderId } from './oauth';
|
|
20
24
|
import { BUILTIN_OAUTH_PROVIDER_IDS } from './oauth-builtins';
|
|
21
25
|
import { clearHandshakeCookie, handshakeCookie, readHandshakeCookie } from './oauth-cookie';
|
|
22
|
-
import {
|
|
26
|
+
import { oauthExchangeFailed, oauthProviderUnknown, restartAt } from './oauth-errors';
|
|
23
27
|
import type { OAuthClientCredentials, OAuthFetch } from './oauth-exchange';
|
|
24
28
|
import { oauthCredentials } from './oauth-exchange';
|
|
25
29
|
import { completeOAuthLogin, type ResolveOAuthGrants } from './oauth-login';
|
|
@@ -30,6 +34,30 @@ import {
|
|
|
30
34
|
} from './oauth-paths';
|
|
31
35
|
import { hasOAuthProvider } from './oauth-registry';
|
|
32
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The provider came back with `error=` and no code — almost always the user pressing Cancel.
|
|
39
|
+
* A separate code from `X_OAUTH_EXCHANGE_FAILED` on purpose: nothing was exchanged, nothing is
|
|
40
|
+
* misconfigured, and folding the single commonest non-success outcome of a login into the code
|
|
41
|
+
* that means "the client secret is wrong" makes both unreadable in a log and pages the wrong person.
|
|
42
|
+
*/
|
|
43
|
+
export const oauthDenied = (
|
|
44
|
+
provider: string,
|
|
45
|
+
reason: string,
|
|
46
|
+
description: string | null,
|
|
47
|
+
): AuthError =>
|
|
48
|
+
new AuthError({
|
|
49
|
+
code: 'X_OAUTH_DENIED',
|
|
50
|
+
// `reason` and `description` are query parameters off the callback URL — whatever the browser
|
|
51
|
+
// was redirected with, newlines and quotes included. `renderCauseValue` renders them as JSON
|
|
52
|
+
// string literals, so a forged `error_description` cannot forge a second log line or break the
|
|
53
|
+
// sentence around it. Both are already `string` by type: this is escaping, not throw-safety.
|
|
54
|
+
cause: `${provider} declined the authorization: ${renderCauseValue(reason)}${
|
|
55
|
+
description === null ? '' : ` (${renderCauseValue(description)})`
|
|
56
|
+
}`,
|
|
57
|
+
fix: `${restartAt(provider)} and approve the ${provider} consent screen`,
|
|
58
|
+
meta: { provider, reason },
|
|
59
|
+
});
|
|
60
|
+
|
|
33
61
|
/**
|
|
34
62
|
* What a router needs to mount one of these. Structural, like `RequestLike` in `session.ts`:
|
|
35
63
|
* `@ultimat3/http` binds to this shape, this package never binds to `@ultimat3/http`.
|
|
@@ -52,9 +80,9 @@ export interface OAuthLoginRoutes {
|
|
|
52
80
|
|
|
53
81
|
export interface OAuthLoginOptions {
|
|
54
82
|
/**
|
|
55
|
-
* The origin the provider redirects back to. Defaults to `APP_URL
|
|
56
|
-
*
|
|
57
|
-
*
|
|
83
|
+
* The origin the provider redirects back to. Defaults to `APP_URL`; with neither, the start leg
|
|
84
|
+
* refuses (`X_ENV_MISSING`). Never the request's own origin: that is the `Host` header, which
|
|
85
|
+
* the caller writes.
|
|
58
86
|
*/
|
|
59
87
|
readonly baseUrl?: string | undefined;
|
|
60
88
|
/** Defaults to the two env vars in the provider table, read per request, never at import. */
|
|
@@ -125,34 +153,44 @@ export function oauthRouteStatus(code: string): number {
|
|
|
125
153
|
return Object.hasOwn(OAUTH_ROUTE_STATUS, code) ? (OAUTH_ROUTE_STATUS[code] ?? 502) : 502;
|
|
126
154
|
}
|
|
127
155
|
|
|
156
|
+
/** The one sentence every refusal carries outward. The event name is where the rest went. */
|
|
157
|
+
const PUBLIC_CAUSE =
|
|
158
|
+
'the sign-in was refused; its cause and its fix are in this server\u2019s log under auth.oauth.refused';
|
|
159
|
+
|
|
160
|
+
/** A code this framework would have minted — the only kind spliced into a command. */
|
|
161
|
+
const FRAMEWORK_CODE = /^X_[A-Z0-9_]+$/;
|
|
162
|
+
|
|
128
163
|
/**
|
|
129
|
-
* What an anonymous caller is allowed to read
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
164
|
+
* What an anonymous caller is allowed to read: the code, its registered title and docs link, and
|
|
165
|
+
* one fixed sentence. Both legs are public by definition, and an authored `cause`/`fix` is written
|
|
166
|
+
* for the developer — it names env vars, endpoints and what a provider answered — so neither is
|
|
167
|
+
* served, on any code: no per-code judgement about which sentence is safe today.
|
|
133
168
|
*/
|
|
134
169
|
const publicBody = (coded: UltimateError): Record<string, string> => ({
|
|
135
170
|
code: coded.code,
|
|
136
171
|
title: coded.title,
|
|
137
|
-
cause:
|
|
138
|
-
fix: coded.
|
|
172
|
+
cause: PUBLIC_CAUSE,
|
|
173
|
+
fix: FRAMEWORK_CODE.test(coded.code)
|
|
174
|
+
? `x errors explain ${renderFixShellArg(coded.code, '<CODE>')} --json`
|
|
175
|
+
: 'x errors list --json',
|
|
139
176
|
docs: coded.docs,
|
|
140
177
|
});
|
|
141
178
|
|
|
142
179
|
/**
|
|
143
|
-
* Failure is JSON, never a redirect to a login page carrying `?error
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
180
|
+
* Failure is JSON, never a redirect to a login page carrying `?error=`: the status and the code
|
|
181
|
+
* are the answer a client branches on. The developer's half — the cause and the fix line — is
|
|
182
|
+
* logged as `auth.oauth.refused`, where only an operator reads it. An app that wants a rendered
|
|
183
|
+
* page wraps these two descriptors.
|
|
147
184
|
*/
|
|
148
185
|
function problem(error: unknown, extraCookies: readonly string[]): Response {
|
|
149
186
|
const coded = isUltimateError(error) ? error : uncoded(error);
|
|
187
|
+
const status = oauthRouteStatus(coded.code);
|
|
188
|
+
const fields = { code: coded.code, status, cause: coded.cause, fix: coded.fix };
|
|
189
|
+
if (status >= 500) logger.error('auth.oauth.refused', fields);
|
|
190
|
+
else logger.info('auth.oauth.refused', fields);
|
|
150
191
|
const headers = new Headers({ 'content-type': 'application/json; charset=utf-8' });
|
|
151
192
|
for (const cookie of extraCookies) headers.append('set-cookie', cookie);
|
|
152
|
-
return new Response(JSON.stringify(publicBody(coded)), {
|
|
153
|
-
status: oauthRouteStatus(coded.code),
|
|
154
|
-
headers,
|
|
155
|
-
});
|
|
193
|
+
return new Response(JSON.stringify(publicBody(coded)), { status, headers });
|
|
156
194
|
}
|
|
157
195
|
|
|
158
196
|
/**
|
|
@@ -244,10 +282,22 @@ function credentialsFor(
|
|
|
244
282
|
}
|
|
245
283
|
}
|
|
246
284
|
|
|
247
|
-
|
|
285
|
+
/**
|
|
286
|
+
* The declared origin, or a refusal. The request's own origin is the `Host` header, so a
|
|
287
|
+
* `redirect_uri` built from it is one the caller chose.
|
|
288
|
+
*/
|
|
289
|
+
function declaredOrigin(options: OAuthLoginOptions): string {
|
|
248
290
|
const env = options.env ?? Bun.env;
|
|
249
|
-
const declared = options.baseUrl ?? env['APP_URL']
|
|
250
|
-
|
|
291
|
+
const declared = (options.baseUrl ?? env['APP_URL'] ?? '').trim();
|
|
292
|
+
if (declared === '') {
|
|
293
|
+
throw new EnvMissingError({
|
|
294
|
+
cause:
|
|
295
|
+
'APP_URL is not set and oauthLogin() was given no baseUrl, so there is no declared origin to build the provider redirect_uri from',
|
|
296
|
+
fix: 'export APP_URL=http://localhost:3000 # the origin this app is served from',
|
|
297
|
+
meta: { key: 'APP_URL' },
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return declared.replace(/\/+$/, '');
|
|
251
301
|
}
|
|
252
302
|
|
|
253
303
|
/** The handshake's own options, assembled once so both legs seal and open it identically. */
|
|
@@ -265,12 +315,15 @@ async function startHandler(
|
|
|
265
315
|
request: Request,
|
|
266
316
|
): Promise<Response> {
|
|
267
317
|
try {
|
|
318
|
+
// Before the provider is looked at: asked after, a missing origin answered 500 for a provider
|
|
319
|
+
// this app enabled and 404 for one it did not.
|
|
320
|
+
const origin = declaredOrigin(options);
|
|
268
321
|
const provider = assertEnabled(auth, providerSegment(request, 'start'));
|
|
269
322
|
const credentials = credentialsFor(provider, options);
|
|
270
323
|
const handshake = beginOAuth({
|
|
271
324
|
provider,
|
|
272
325
|
clientId: credentials.clientId,
|
|
273
|
-
redirectUri: `${
|
|
326
|
+
redirectUri: `${origin}${oauthCallbackPath(provider)}`,
|
|
274
327
|
scopes: options.scopes,
|
|
275
328
|
});
|
|
276
329
|
return new Response(null, {
|
package/src/oauth.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// configs are pure data and live in `oauth-registry.ts`: importing this file performs no network
|
|
5
5
|
// I/O and reads no env.
|
|
6
6
|
|
|
7
|
+
import { timingSafeEqual } from '@ultimat3/core';
|
|
7
8
|
import { oauthStateInvalid } from './oauth-errors';
|
|
8
9
|
import { providerFor } from './oauth-registry';
|
|
9
|
-
import { base64Url, randomToken, sha256Bytes
|
|
10
|
+
import { base64Url, randomToken, sha256Bytes } from './tokens';
|
|
10
11
|
|
|
11
12
|
export interface OAuthProvider {
|
|
12
13
|
readonly id: string;
|
|
@@ -63,7 +64,7 @@ export function pkceChallenge(verifier: string): string {
|
|
|
63
64
|
return base64Url(sha256Bytes(verifier));
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
export function
|
|
67
|
+
export function pkcePair(): PkcePair {
|
|
67
68
|
// 32 random bytes -> 43 base64url chars, the RFC's minimum verifier length.
|
|
68
69
|
const verifier = randomToken(32);
|
|
69
70
|
return { verifier, challenge: pkceChallenge(verifier), method: 'S256' };
|
|
@@ -88,7 +89,7 @@ export interface BeginOAuthInput {
|
|
|
88
89
|
|
|
89
90
|
export function beginOAuth(input: BeginOAuthInput): OAuthHandshake {
|
|
90
91
|
const provider = providerFor(input.provider);
|
|
91
|
-
const pkce =
|
|
92
|
+
const pkce = pkcePair();
|
|
92
93
|
const state = randomToken(16);
|
|
93
94
|
const nonce = randomToken(16);
|
|
94
95
|
const url = new URL(provider.authorizeUrl);
|