@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.
@@ -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' } });
@@ -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
- /** Granted to a user this flow creates. An existing user's roles are never rewritten here. */
34
- readonly roles?: readonly string[] | undefined;
35
- readonly orgId?: string | null | undefined;
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.email;
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 restart the flow`,
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: input.orgId ?? null,
63
- roles: input.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, email, emailVerified } = input.profile;
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 user = await resolveUser(auth, input, linked);
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
- readonly roles?: readonly string[] | undefined;
166
- readonly orgId?: string | null | undefined;
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
- roles: input.roles,
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`;
@@ -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 { OAUTH_PROVIDERS, type OAuthProviderId } from './oauth';
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 = OAUTH_PROVIDERS[provider];
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 restart the flow`,
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 restart the flow`,
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(' ')} and restart the flow`,
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 = OAUTH_PROVIDERS[provider].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
+ }