@ultimat3/auth 31.1.0 → 31.2.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 CHANGED
@@ -67,8 +67,14 @@ A rule marked `†` has its full text in [`docs/history/auth-rules.md`](../../do
67
67
  `deleteSessionsForOrg` joins through `x_users`; `x_sessions` does **not** gain an `org_id`.
68
68
  - The code flow carries `nonce` inside the id token. `assertOAuthCallback` checks an echoed one when
69
69
  present and never requires it; `verifyIdToken` is the real gate.
70
- - `sealHandshake`/`openHandshake` (a hand-built flow's cookie, not `oauthLogin`'s): the provider is
71
- an argument, expiry the server's clock, one cookie per provider (`handshakeCookieName`).
70
+ - `sealHandshake`/`openHandshake` (a hand-built flow's server-side store): the provider is an
71
+ argument, expiry the server's clock. **The handshake COOKIE is `oauthLogin`'s own**:
72
+ `handshakeCookieName` IS `identityStateCookieName` (`__Host-x_sso_<p>`) and `handshakeCookie`
73
+ seals `encodeOidcState`, so a built-in leg and a hand-built one read each other
74
+ (`oauth-handshake-cookie.test.ts`). The read-only `__Host-x_oauth_<p>` fallback goes in 32.0.0.
75
+ - **`oauthLogin({ callbackPath })` is the one movable path** (`sign-in-callback-path.ts`): mount,
76
+ `redirect_uri` and the checked URL from one pattern, refused if URL normalisation would rewrite
77
+ it; the start path never moves.
72
78
  - `takeVerification(purpose, identifier, tokenHash)` consumes **only on a hash match**, in one
73
79
  conditional statement (`consumed_at is null` on the UPDATE and its subselect,
74
80
  `order by created_at desc limit 1`); `consumeVerification` still compares in constant time.
@@ -84,7 +90,7 @@ A rule marked `†` has its full text in [`docs/history/auth-rules.md`](../../do
84
90
  - Link by address only when the provider **and** the local account both verified it
85
91
  (`link: 'verified-email'`, default; `'never'` is the only other value).
86
92
  - **One sign-in route implementation** (`identity-provider-route-core.ts`) at
87
- `/auth/sso/:provider[/callback]`; failure is coded JSON, success a 303 to `successPath`. †
93
+ `/auth/sso/:provider[/callback]` (the callback movable only by `callbackPath`); failure is coded JSON, success a 303 to `successPath`. †
88
94
  - Refresh is **not implemented**, so the framework **stores no provider token**: `accountFor`
89
95
  (`oauth-login.ts`) writes `accessToken: null, refreshToken: null`. The columns and `AuthAccount`
90
96
  fields stay; an app that stores tokens implements `linkAccount` itself.
@@ -173,7 +179,9 @@ A rule marked `†` has its full text in [`docs/history/auth-rules.md`](../../do
173
179
  | `mfa-secret.ts` | the TOTP secret at rest: seal on write, open on read, the one-shot that seals older rows |
174
180
  | `directory.ts` | `describeUser` (allow-list projection), `listOrgUsers`, external-id lookup |
175
181
  | `privileges.ts` | `updatePrivileges` — the grant, the rotation it requires, and the sessions a changed password ends |
176
- | `oauth-cookie.ts` | the handshake's home between the two legs: seal, open, the cookie |
182
+ | `oauth-cookie.ts` | the handshake's server-side codec: seal, open, secret, TTL |
183
+ | `oauth-handshake-cookie.ts` | the handshake as a cookie — the sign-in routes' own name and seal, plus the one-release legacy read |
184
+ | `sign-in-callback-path.ts` | the callback pattern: default or `callbackPath`, validated; mount, `redirect_uri`, provider segment |
177
185
  | `oauth-exchange.ts` | `oauthCredentials` + the one POST to the token endpoint |
178
186
  | `id-token.ts` | id token → claims this handshake may believe |
179
187
  | `*-fixture.ts` | test-only, off `index.ts`: `id-token-` (the string-input JWT builder), `auth-` (KDF params, password, `AuthError` catcher for `auth*.test.ts`), `oauth-login-` (adapter, clock, profile) |
package/README.md CHANGED
@@ -866,11 +866,35 @@ router that can match a `:param` — and `runtime.routes` is the one an app has.
866
866
  framework's own routes and before the pages; every field it shares the file with is in
867
867
  [`wiki/Configuration.md`](../../wiki/Configuration.md#runtime-overrides--appsappruntimets).
868
868
 
869
- **The paths are not configurable.** `X_OAUTH_STATE_INVALID` quotes `oauthStartPath()`, the same
870
- declaration the mount reads. A movable base path is that sentence going stale again. Since 31.0.0
871
- the base is `/auth/sso`: `oauthLogin` is a front door over `identityProviderRoutes`' legs, so OAuth
872
- and SAML share the paths, the sealed state cookie and the refusal body (re-register the callback
873
- URL at each IdP — `wiki/Upgrading.md`).
869
+ **The start path is fixed; the callback path is not.** `X_OAUTH_STATE_INVALID` quotes
870
+ `oauthStartPath()`, the same declaration the start mount reads — a movable start path is that
871
+ sentence going stale again. Since 31.0.0 the base is `/auth/sso`: `oauthLogin` is a front door over
872
+ `identityProviderRoutes`' legs, so OAuth and SAML share the paths, the sealed state cookie and the
873
+ refusal body. The callback defaults to `/auth/sso/<provider>/callback`, which each IdP must have
874
+ registered (`wiki/Upgrading.md`) — or keep the URI it already has with
875
+ [`oauthLogin({ callbackPath })`](#keeping-a-registered-callback-url).
876
+
877
+ ### Keeping a registered callback URL
878
+
879
+ `oauthLogin(auth, { callbackPath })` moves the callback
880
+ leg — its mount (`callback.path`), the `redirect_uri` every provider is sent, and the URL the
881
+ callback checks it against — for an app whose IdP registration cannot change:
882
+
883
+ ```ts
884
+ import { type Auth, oauthLogin } from '@ultimat3/auth';
885
+
886
+ declare const auth: Auth; // your defineAuth(...)
887
+ declare const router: { get(path: string, handle: (request: Request) => Promise<Response>): void };
888
+
889
+ const { start, callback } = oauthLogin(auth, { callbackPath: '/auth/oauth/:provider/callback' });
890
+ router.get(start.path, start.handle); // '/auth/sso/:provider' — the start leg never moves
891
+ router.get(callback.path, callback.handle); // mount callback.path, never OAUTH_CALLBACK_ROUTE_PATH
892
+ ```
893
+
894
+ One whole `:provider` segment, absolute, no query, fragment, wildcard or other parameter, no
895
+ segment URL normalisation would rewrite (`.`, `..`, empty, `%`-encoded, `\`), and not the start
896
+ path — anything else is `X_CONFIG_INVALID` at the call. The start leg stays
897
+ `/auth/sso/:provider`: it is a link in your own page, and the `fix:` lines name it.
874
898
 
875
899
  `oauthLogin` needs an origin to build the provider's `redirect_uri` from: `baseUrl`, or `APP_URL`.
876
900
  With neither, the start leg refuses with `X_ENV_MISSING` rather than trust a request header.
@@ -907,11 +931,16 @@ SameSite=Lax` under a `__Host-` name, and expired against the server's clock rat
907
931
  client's copy of `Max-Age`. `sealHandshake` / `openHandshake` are the same codec without the
908
932
  cookie, for an app that would rather keep it server-side.
909
933
 
910
- `oauthLogin` itself no longer uses this cookie (31.0.0): its state rides the sign-in routes' own
911
- `__Host-x_sso_<provider>`, sealed the same way and claimed once in `auth.totpReplay`. The
912
- `handshakeCookie` family stays for a hand-built flow over `beginOAuth` / `completeOAuthLogin`.
934
+ **One cookie, both doors.** `handshakeCookie` writes exactly what `oauthLogin`'s start leg writes —
935
+ the name `__Host-x_sso_<provider>`, the seal, the encoding — so either leg can be the built-in one
936
+ and the other hand-built: `oauthLogin().start` with a callback over `readHandshakeCookie` /
937
+ `completeOAuthLogin`, or a `beginOAuth` + `handshakeCookie` start with `oauthLogin().callback`.
938
+ 31.1 and before wrote `__Host-x_oauth_<provider>` from this family, which the start leg never
939
+ read and never wrote. `readHandshakeCookie` still opens that cookie, read-only, for one release —
940
+ a handshake begun on an old pod mid-deploy finishes on a new one — and stops in 32.0.0.
941
+ `clearHandshakeCookie` clears only the current name; a legacy one expires on its own `Max-Age`.
913
942
 
914
- **One cookie per provider:** `handshakeCookieName(provider)` → `__Host-x_oauth_github`. A browser
943
+ **One cookie per provider:** `handshakeCookieName(provider)` → `__Host-x_sso_github`. A browser
915
944
  is one cookie jar and a user is allowed two tabs, so a single shared name means the `google`
916
945
  redirect overwrites a `github` handshake still in flight — and the github callback then opens
917
946
  google's and fails `X_OAUTH_STATE_INVALID` for a reason no restart clears. `handshakeCookie` takes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "31.1.0",
3
+ "version": "31.2.0",
4
4
  "description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -48,8 +48,8 @@
48
48
  "test": "bun test"
49
49
  },
50
50
  "dependencies": {
51
- "@ultimat3/core": "31.1.0",
52
- "@ultimat3/db": "31.1.0",
53
- "@ultimat3/schema": "31.1.0"
51
+ "@ultimat3/core": "31.2.0",
52
+ "@ultimat3/db": "31.2.0",
53
+ "@ultimat3/schema": "31.2.0"
54
54
  }
55
55
  }
@@ -7,8 +7,14 @@ import { type Clock, hmac, readCookie, serializeSetCookie, timingSafeEqual } fro
7
7
  import type { IdentityProvider } from './identity-provider';
8
8
  import { DEFAULT_HANDSHAKE_TTL_MS, handshakeSecret } from './oauth-cookie';
9
9
  import { oauthStateInvalid } from './oauth-errors';
10
+ import { assertFiniteAuthCount } from './policy-numbers';
10
11
  import { base64Url, base64UrlBytes } from './tokens';
11
12
 
13
+ /**
14
+ * The ONE name every sign-in state cookie is written and read under — the built-in legs and the
15
+ * exported `handshakeCookie` family alike (`handshakeCookieName` is this function). Two names
16
+ * once let a built-in start leg and a hand-built callback miss each other entirely.
17
+ */
12
18
  export const IDENTITY_STATE_COOKIE_PREFIX = '__Host-x_sso';
13
19
 
14
20
  export const identityStateCookieName = (provider: string): string =>
@@ -18,56 +24,41 @@ export interface IdentityStateOptions {
18
24
  readonly clock: Clock;
19
25
  /** Defaults to `SESSION_SECRET`. */
20
26
  readonly secret?: string | undefined;
27
+ /** Defaults to `DEFAULT_HANDSHAKE_TTL_MS`; the caller screens it. */
28
+ readonly ttlMs?: number | undefined;
21
29
  }
22
30
 
31
+ type CallbackMethod = IdentityProvider['callbackMethod'];
32
+
23
33
  const sign = (body: string, secret: string): string => hmac('sha256', secret, body, 'hex');
24
34
 
25
- export function identityStateCookie(
26
- provider: IdentityProvider,
35
+ const sameSite = (method: CallbackMethod) => (method === 'POST' ? 'None' : 'Lax');
36
+
37
+ /** `[id, state, issuedAt]`, signed: the value both cookie writers put under the one name. */
38
+ export function sealIdentityState(
39
+ id: string,
27
40
  state: string,
28
41
  options: IdentityStateOptions,
29
42
  ): string {
30
43
  const body = base64Url(
31
- new TextEncoder().encode(JSON.stringify([provider.id, state, options.clock.now().getTime()])),
44
+ new TextEncoder().encode(JSON.stringify([id, state, options.clock.now().getTime()])),
32
45
  );
33
- const sealed = `${body}.${sign(body, options.secret ?? handshakeSecret())}`;
34
- return serializeSetCookie(identityStateCookieName(provider.id), sealed, {
35
- maxAge: Math.floor(DEFAULT_HANDSHAKE_TTL_MS / 1000),
36
- sameSite: provider.callbackMethod === 'POST' ? 'None' : 'Lax',
37
- });
46
+ return `${body}.${sign(body, options.secret ?? handshakeSecret())}`;
38
47
  }
39
48
 
40
- /**
41
- * Sent with every callback answer: the state it carried is spent whatever the outcome. Takes the id
42
- * and method rather than a provider, so a refusal before the provider resolved can still clear it.
43
- */
44
- export const clearIdentityStateCookie = (
49
+ /** The state `sealIdentityState` sealed for `id`, or `X_OAUTH_STATE_INVALID` for every failure. */
50
+ export function openIdentityState(
51
+ sealed: string,
45
52
  id: string,
46
- callbackMethod: IdentityProvider['callbackMethod'],
47
- ): string =>
48
- serializeSetCookie(identityStateCookieName(id), '', {
49
- maxAge: 0,
50
- sameSite: callbackMethod === 'POST' ? 'None' : 'Lax',
51
- });
52
-
53
- /** The state `begin` returned, or `X_OAUTH_STATE_INVALID` — one refusal for every way it fails. */
54
- export function readIdentityState(
55
- request: Request,
56
- provider: IdentityProvider,
57
53
  options: IdentityStateOptions,
58
54
  ): string {
59
- const sealed = readCookie(request.headers.get('cookie'), identityStateCookieName(provider.id));
60
- if (sealed === null) throw oauthStateInvalid(provider.id, 'no sign-in state cookie arrived');
61
55
  const dot = sealed.lastIndexOf('.');
62
56
  const body = sealed.slice(0, Math.max(dot, 0));
63
57
  if (
64
58
  dot <= 0 ||
65
59
  !timingSafeEqual(sign(body, options.secret ?? handshakeSecret()), sealed.slice(dot + 1))
66
60
  ) {
67
- throw oauthStateInvalid(
68
- provider.id,
69
- 'the sign-in state was tampered with, or the secret rotated',
70
- );
61
+ throw oauthStateInvalid(id, 'the sign-in state was tampered with, or the secret rotated');
71
62
  }
72
63
  let parsed: unknown;
73
64
  try {
@@ -76,14 +67,63 @@ export function readIdentityState(
76
67
  parsed = null;
77
68
  }
78
69
  if (!Array.isArray(parsed) || parsed.length !== 3) {
79
- throw oauthStateInvalid(provider.id, 'the sign-in state is not readable');
70
+ throw oauthStateInvalid(id, 'the sign-in state is not readable');
80
71
  }
81
- const [id, state, issuedAt] = parsed as readonly unknown[];
82
- if (id !== provider.id || typeof state !== 'string' || typeof issuedAt !== 'number') {
83
- throw oauthStateInvalid(provider.id, 'the sign-in state belongs to another provider');
72
+ const [sealedId, state, issuedAt] = parsed as readonly unknown[];
73
+ if (sealedId !== id || typeof state !== 'string' || typeof issuedAt !== 'number') {
74
+ throw oauthStateInvalid(id, 'the sign-in state belongs to another provider');
84
75
  }
85
- if (options.clock.now().getTime() - issuedAt > DEFAULT_HANDSHAKE_TTL_MS) {
86
- throw oauthStateInvalid(provider.id, 'the sign-in state expired before the IdP answered');
76
+ // Screened at the reader: `now - issuedAt > NaN` is false forever, so a NaN deadline never expires.
77
+ const ttlMs = assertFiniteAuthCount(
78
+ 'oauth.handshake.ttlMs',
79
+ options.ttlMs ?? DEFAULT_HANDSHAKE_TTL_MS,
80
+ 'the server-side replay deadline is a comparison that is false for every sign-in state, so a lifted one stays redeemable',
81
+ 1,
82
+ );
83
+ if (options.clock.now().getTime() - issuedAt > ttlMs) {
84
+ throw oauthStateInvalid(id, 'the sign-in state expired before the IdP answered');
87
85
  }
88
86
  return state;
89
87
  }
88
+
89
+ /** A sealed value as a `Set-Cookie`; `name` defaults to the one name. */
90
+ export const serializeIdentityState = (
91
+ id: string,
92
+ value: string,
93
+ callbackMethod: CallbackMethod,
94
+ maxAge: number,
95
+ name: string = identityStateCookieName(id),
96
+ ): string => serializeSetCookie(name, value, { maxAge, sameSite: sameSite(callbackMethod) });
97
+
98
+ export const identityStateCookie = (
99
+ provider: IdentityProvider,
100
+ state: string,
101
+ options: IdentityStateOptions,
102
+ ): string =>
103
+ serializeIdentityState(
104
+ provider.id,
105
+ sealIdentityState(provider.id, state, options),
106
+ provider.callbackMethod,
107
+ Math.floor(DEFAULT_HANDSHAKE_TTL_MS / 1000),
108
+ );
109
+
110
+ /**
111
+ * Sent with every callback answer: the state it carried is spent whatever the outcome. Takes the id
112
+ * and method rather than a provider, so a refusal before the provider resolved can still clear it.
113
+ */
114
+ export const clearIdentityStateCookie = (
115
+ id: string,
116
+ callbackMethod: CallbackMethod,
117
+ name?: string,
118
+ ): string => serializeIdentityState(id, '', callbackMethod, 0, name);
119
+
120
+ /** The state `begin` returned, or `X_OAUTH_STATE_INVALID` — one refusal for every way it fails. */
121
+ export function readIdentityState(
122
+ request: Request,
123
+ provider: IdentityProvider,
124
+ options: IdentityStateOptions,
125
+ ): string {
126
+ const sealed = readCookie(request.headers.get('cookie'), identityStateCookieName(provider.id));
127
+ if (sealed === null) throw oauthStateInvalid(provider.id, 'no sign-in state cookie arrived');
128
+ return openIdentityState(sealed, provider.id, options);
129
+ }
@@ -43,12 +43,23 @@ export interface OidcIdentityProviderConfig {
43
43
  const FIELDS = ['state', 'nonce', 'verifier', 'redirectUri', 'authorizeUrl'] as const;
44
44
 
45
45
  /** The handshake, and when it was issued — the instant its single-use claim is keyed on. */
46
- interface StoredHandshake {
46
+ export interface StoredHandshake {
47
47
  readonly handshake: OAuthHandshake;
48
48
  readonly issuedAt: number;
49
49
  }
50
50
 
51
- function readHandshake(provider: OAuthProviderId, state: string): StoredHandshake {
51
+ /**
52
+ * The handshake as the sign-in state string. The ONE encoding: `begin` writes it, and so does
53
+ * `handshakeCookie`, so a hand-built leg and a built-in one read each other's cookie.
54
+ */
55
+ export const encodeOidcState = (handshake: OAuthHandshake, issuedAt: number): string =>
56
+ JSON.stringify({
57
+ ...Object.fromEntries(FIELDS.map((field) => [field, handshake[field]])),
58
+ issuedAt,
59
+ });
60
+
61
+ /** `encodeOidcState`'s inverse, bound to `provider`; `X_OAUTH_STATE_INVALID` for anything else. */
62
+ export function decodeOidcState(provider: OAuthProviderId, state: string): StoredHandshake {
52
63
  let parsed: unknown;
53
64
  try {
54
65
  parsed = JSON.parse(state);
@@ -91,21 +102,18 @@ export function oidcIdentityProvider(config: OidcIdentityProviderConfig): Identi
91
102
  redirectUri: callbackUrl,
92
103
  scopes: config.scopes,
93
104
  });
94
- const state = {
95
- ...Object.fromEntries(FIELDS.map((field) => [field, handshake[field]])),
96
- issuedAt: clock.now().getTime(),
97
- };
98
- return { location: handshake.authorizeUrl, state: JSON.stringify(state) };
105
+ const state = encodeOidcState(handshake, clock.now().getTime());
106
+ return { location: handshake.authorizeUrl, state };
99
107
  },
100
108
  answers(state, params) {
101
109
  try {
102
- return timingSafeEqual(readHandshake(id, state).handshake.state, params['state'] ?? '');
110
+ return timingSafeEqual(decodeOidcState(id, state).handshake.state, params['state'] ?? '');
103
111
  } catch {
104
112
  return false;
105
113
  }
106
114
  },
107
115
  async complete({ state, params, callbackUrl, clock, replay }): Promise<VerifiedIdentity> {
108
- const { handshake, issuedAt } = readHandshake(id, state);
116
+ const { handshake, issuedAt } = decodeOidcState(id, state);
109
117
  if (handshake.redirectUri !== callbackUrl) {
110
118
  throw oauthStateInvalid(id, 'the handshake was started for another callback URL');
111
119
  }
@@ -24,12 +24,9 @@ import {
24
24
  import { oauthRouteStatus } from './identity-provider-status';
25
25
  import { oauthStateInvalid } from './oauth-errors';
26
26
  import type { OAuthGrants } from './oauth-login';
27
- import {
28
- OAUTH_CALLBACK_ROUTE_PATH,
29
- OAUTH_START_ROUTE_PATH,
30
- oauthCallbackPath,
31
- } from './oauth-paths';
27
+ import { OAUTH_START_ROUTE_PATH } from './oauth-paths';
32
28
  import { samlResponseInvalid } from './saml-errors';
29
+ import { type CallbackPath, callbackPath } from './sign-in-callback-path';
33
30
 
34
31
  export interface SignInRouteDescriptor {
35
32
  readonly method: 'GET' | 'POST';
@@ -85,6 +82,8 @@ export interface SignInRoutePolicy {
85
82
  /** A throw with no code, logged and turned into the refusal served. Its text never is. */
86
83
  uncoded(error: unknown): UltimateError;
87
84
  grants(identity: VerifiedIdentity): Promise<OAuthGrants | undefined> | OAuthGrants | undefined;
85
+ /** The callback leg's pattern; `oauthLogin({ callbackPath })` only. Default: the framework's. */
86
+ readonly callbackPath?: CallbackPath | undefined;
88
87
  }
89
88
 
90
89
  export interface SignInRoutes {
@@ -120,9 +119,9 @@ function problem(policy: SignInRoutePolicy, error: unknown, cookies: readonly st
120
119
  return new Response(JSON.stringify(body), { status, headers });
121
120
  }
122
121
 
123
- function segmentOf(request: Request, leg: 'start' | 'callback'): string {
122
+ function startSegment(request: Request): string {
124
123
  const parts = new URL(request.url).pathname.split('/').filter((part) => part !== '');
125
- return parts[leg === 'start' ? parts.length - 1 : parts.length - 2] ?? '';
124
+ return parts[parts.length - 1] ?? '';
126
125
  }
127
126
 
128
127
  function origin(options: SignInRouteOptions): string {
@@ -169,6 +168,7 @@ export function signInRoutes(
169
168
  options: SignInRouteOptions,
170
169
  policy: SignInRoutePolicy,
171
170
  ): SignInRoutes {
171
+ const back = policy.callbackPath ?? callbackPath();
172
172
  const seal = {
173
173
  clock: auth.clock,
174
174
  ...(options.secret === undefined ? {} : { secret: options.secret }),
@@ -179,8 +179,8 @@ export function signInRoutes(
179
179
  // Before the provider: asked after, a missing origin answered 500 for an enabled provider
180
180
  // and 404 for one that is not — an oracle for the app's configuration.
181
181
  const base = origin(options);
182
- const provider = policy.resolve(segmentOf(request, 'start'), 'start');
183
- const callbackUrl = `${base}${oauthCallbackPath(provider.id)}`;
182
+ const provider = policy.resolve(startSegment(request), 'start');
183
+ const callbackUrl = `${base}${back.pathFor(provider.id)}`;
184
184
  const redirect = await provider.begin({ callbackUrl, clock: auth.clock });
185
185
  const headers = new Headers({ location: redirect.location });
186
186
  headers.append('set-cookie', identityStateCookie(provider, redirect.state, seal));
@@ -191,7 +191,7 @@ export function signInRoutes(
191
191
  };
192
192
 
193
193
  const callback = async (request: Request): Promise<Response> => {
194
- const segment = segmentOf(request, 'callback');
194
+ const segment = back.providerOf(new URL(request.url).pathname);
195
195
  // Set only once the answer is bound to this browser's state; then cleared on every outcome,
196
196
  // since that answer is spent either way. An unbound answer — a junk cross-site POST riding the
197
197
  // SameSite=None cookie — clears nothing, so it cannot end a sign-in in flight.
@@ -213,7 +213,7 @@ export function signInRoutes(
213
213
  const identity = await provider.complete({
214
214
  state,
215
215
  params,
216
- callbackUrl: `${base}${oauthCallbackPath(provider.id)}`,
216
+ callbackUrl: `${base}${back.pathFor(provider.id)}`,
217
217
  clock: auth.clock,
218
218
  replay: auth.totpReplay,
219
219
  });
@@ -247,7 +247,7 @@ export function signInRoutes(
247
247
  };
248
248
  return Object.freeze({
249
249
  start: route('GET', OAUTH_START_ROUTE_PATH, policy.names.start, start),
250
- callback: route('GET', OAUTH_CALLBACK_ROUTE_PATH, policy.names.callback, callback),
251
- acs: route('POST', OAUTH_CALLBACK_ROUTE_PATH, policy.names.acs, callback),
250
+ callback: route('GET', back.pattern, policy.names.callback, callback),
251
+ acs: route('POST', back.pattern, policy.names.acs, callback),
252
252
  });
253
253
  }
package/src/index.ts CHANGED
@@ -243,15 +243,11 @@ export {
243
243
  GITHUB_PROVIDER,
244
244
  GOOGLE_PROVIDER,
245
245
  } from './oauth-builtins';
246
- export type { HandshakeCookieOptions, HandshakeSealOptions } from './oauth-cookie';
246
+ export type { HandshakeSealOptions } from './oauth-cookie';
247
247
  export {
248
- clearHandshakeCookie,
249
248
  DEFAULT_HANDSHAKE_TTL_MS,
250
- handshakeCookie,
251
- handshakeCookieName,
252
249
  handshakeSecret,
253
250
  openHandshake,
254
- readHandshakeCookie,
255
251
  sealHandshake,
256
252
  } from './oauth-cookie';
257
253
  export type { DiscoverOAuthProviderInput } from './oauth-discovery';
@@ -273,6 +269,13 @@ export type {
273
269
  } from './oauth-exchange';
274
270
  export { exchangeOAuthCode, oauthCredentials } from './oauth-exchange';
275
271
  export type { OAuthGrantContext } from './oauth-grant-context';
272
+ export type { HandshakeCookieOptions } from './oauth-handshake-cookie';
273
+ export {
274
+ clearHandshakeCookie,
275
+ handshakeCookie,
276
+ handshakeCookieName,
277
+ readHandshakeCookie,
278
+ } from './oauth-handshake-cookie';
276
279
  export type {
277
280
  CompleteOAuthLoginInput,
278
281
  OAuthGrants,
@@ -1,40 +1,17 @@
1
- // Single responsibility: where the handshake lives between the redirect and the callback. The
2
- // two legs of an authorization-code login are separate HTTP requests, so `beginOAuth`'s state,
3
- // nonce and PKCE verifier have to survive the round trip — and a framework that leaves that to
4
- // the app gets one hand-rolled store per app, which is exactly where PKCE quietly stops proving
5
- // anything. Sealed here and opened here, so the cookie below and a server-side store share one
6
- // format at one trust level.
1
+ // Single responsibility: the handshake's server-side codec (`sealHandshake` / `openHandshake`), its
2
+ // secret and its deadline. The two legs of an authorization-code login are separate HTTP requests,
3
+ // so `beginOAuth`'s state, nonce and PKCE verifier have to survive the round trip — and a framework
4
+ // that leaves that to the app gets one hand-rolled store per app, which is exactly where PKCE
5
+ // quietly stops proving anything. The cookie is `oauth-handshake-cookie.ts`.
7
6
 
8
7
  import type { Clock } from '@ultimat3/core';
9
- import {
10
- EnvMissingError,
11
- hmac,
12
- readCookie,
13
- serializeSetCookie,
14
- systemClock,
15
- timingSafeEqual,
16
- } from '@ultimat3/core';
8
+ import { EnvMissingError, hmac, systemClock, timingSafeEqual } from '@ultimat3/core';
17
9
  import type { OAuthHandshake, OAuthProviderId } from './oauth';
18
10
  import { oauthStateInvalid } from './oauth-errors';
19
11
  import { hasOAuthProvider } from './oauth-registry';
20
12
  import { assertFiniteAuthCount } from './policy-numbers';
21
- import type { RequestLike } from './session';
22
13
  import { base64Url } from './tokens';
23
14
 
24
- /** `__Host-` for the same reason the session cookie carries it: no subdomain can plant one. */
25
- export const OAUTH_HANDSHAKE_COOKIE_PREFIX = '__Host-x_oauth';
26
-
27
- /**
28
- * One cookie per provider, because a browser is one cookie jar and a user is allowed two tabs.
29
- * Under a single shared name, starting a `google` login while a `github` one is mid-flight
30
- * overwrites the github handshake — and the github callback then opens google's, fails
31
- * `X_OAUTH_STATE_INVALID` and tells the user to restart the flow that just collided again.
32
- * Scoping the name makes the two handshakes independent instead of the last writer's.
33
- */
34
- export function handshakeCookieName(provider: OAuthProviderId): string {
35
- return `${OAUTH_HANDSHAKE_COOKIE_PREFIX}_${provider}`;
36
- }
37
-
38
15
  /** Long enough to read a consent screen, short enough that a lifted cookie is already stale. */
39
16
  export const DEFAULT_HANDSHAKE_TTL_MS = 10 * 60 * 1000;
40
17
 
@@ -49,7 +26,7 @@ export const DEFAULT_HANDSHAKE_TTL_MS = 10 * 60 * 1000;
49
26
  * Screened at every reader rather than once at seal time: the two legs are two HTTP requests and
50
27
  * two calls, so a check on the way out is one the way back never runs.
51
28
  */
52
- function handshakeTtlMs(options: HandshakeSealOptions | undefined): number {
29
+ export function handshakeTtlMs(options: HandshakeSealOptions | undefined): number {
53
30
  return assertFiniteAuthCount(
54
31
  'oauth.handshake.ttlMs',
55
32
  options?.ttlMs ?? DEFAULT_HANDSHAKE_TTL_MS,
@@ -91,15 +68,6 @@ export interface HandshakeSealOptions {
91
68
  readonly ttlMs?: number | undefined;
92
69
  }
93
70
 
94
- export interface HandshakeCookieOptions extends HandshakeSealOptions {
95
- /**
96
- * Defaults to `handshakeCookieName(provider)`. Overriding it opts out of the per-provider
97
- * scoping, so both legs have to pass the same one — a name set on the redirect and defaulted on
98
- * the callback reads a cookie that is not there.
99
- */
100
- readonly name?: string | undefined;
101
- }
102
-
103
71
  /**
104
72
  * Signed, not encrypted, for the reason the cursor codec is: every field here is already the
105
73
  * browser's own — `state` travelled in the URL it was just sent to, and the verifier and nonce
@@ -182,50 +150,6 @@ export function openHandshake(
182
150
  return { provider, state, nonce, verifier, redirectUri, authorizeUrl };
183
151
  }
184
152
 
185
- /**
186
- * Set on the redirect. `SameSite=Lax` is the one attribute that differs in reasoning from the
187
- * session cookie's: the callback is a top-level cross-site GET from the provider, which `Lax`
188
- * still attaches the cookie to and `Strict` would strip — leaving every login to fail its state
189
- * check. A provider answering with `response_mode=form_post` POSTs instead, and this cookie
190
- * would not reach it; neither built-in provider is configured that way.
191
- */
192
- export function handshakeCookie(
193
- handshake: OAuthHandshake,
194
- options?: HandshakeCookieOptions,
195
- ): string {
196
- const maxAge = Math.floor(handshakeTtlMs(options) / 1000);
197
- // The handshake already names its provider, so the two legs cannot disagree about the name.
198
- const name = options?.name ?? handshakeCookieName(handshake.provider);
199
- return serializeSetCookie(name, sealHandshake(handshake, options), { maxAge });
200
- }
201
-
202
- /**
203
- * Send this with the callback's response, always — an authorization code is single-use, so the
204
- * handshake that authorised it must not outlive it. A mismatched attribute set leaves a live twin.
205
- *
206
- * `provider` is required for the reason `openHandshake`'s is: clearing the unscoped name would
207
- * clear nothing, and clearing every provider's would cancel a login running in another tab.
208
- */
209
- export function clearHandshakeCookie(provider: OAuthProviderId, name?: string): string {
210
- return serializeSetCookie(name ?? handshakeCookieName(provider), '', { maxAge: 0 });
211
- }
212
-
213
- /** Reads the cookie set on the redirect and returns the handshake `completeOAuthLogin` takes. */
214
- export function readHandshakeCookie(
215
- request: RequestLike,
216
- provider: OAuthProviderId,
217
- options?: HandshakeCookieOptions,
218
- ): OAuthHandshake {
219
- const sealed = readCookie(
220
- request.headers.get('cookie'),
221
- options?.name ?? handshakeCookieName(provider),
222
- );
223
- if (sealed === null) {
224
- throw oauthStateInvalid(provider, 'no handshake cookie arrived with the callback');
225
- }
226
- return openHandshake(sealed, provider, options);
227
- }
228
-
229
153
  /** Keyed SHA-256. Untruncated, unlike a cursor's: a cookie has room and this authorises a login. */
230
154
  function sign(body: string, secret: string): string {
231
155
  return hmac('sha256', secret, body, 'hex');
@@ -0,0 +1,107 @@
1
+ // Single responsibility: the OAuth handshake as a cookie, for a hand-built leg over `beginOAuth` /
2
+ // `completeOAuthLogin`. Same name, same seal, same encoding as `oauthLogin`'s own state cookie
3
+ // (`identity-provider-cookie.ts`, `encodeOidcState`), so either leg can be the built-in one: a
4
+ // second name once left a built-in start leg and a hand-built callback unable to find each other.
5
+
6
+ import { readCookie, systemClock } from '@ultimat3/core';
7
+ import {
8
+ clearIdentityStateCookie,
9
+ identityStateCookieName,
10
+ openIdentityState,
11
+ sealIdentityState,
12
+ serializeIdentityState,
13
+ } from './identity-provider-cookie';
14
+ import { decodeOidcState, encodeOidcState } from './identity-provider-oidc';
15
+ import type { OAuthHandshake, OAuthProviderId } from './oauth';
16
+ import { type HandshakeSealOptions, handshakeTtlMs, openHandshake } from './oauth-cookie';
17
+ import { oauthStateInvalid } from './oauth-errors';
18
+ import type { RequestLike } from './session';
19
+
20
+ /**
21
+ * One cookie per provider, because a browser is one cookie jar and a user is allowed two tabs.
22
+ * Under a single shared name, starting a `google` login while a `github` one is mid-flight
23
+ * overwrites the github handshake — and the github callback then opens google's, fails
24
+ * `X_OAUTH_STATE_INVALID` and tells the user to restart the flow that just collided again.
25
+ * The name is the sign-in routes' own: `__Host-x_sso_<provider>`.
26
+ */
27
+ export const handshakeCookieName = (provider: OAuthProviderId): string =>
28
+ identityStateCookieName(provider);
29
+
30
+ /**
31
+ * The name, and the `sealHandshake` format, this family wrote up to 31.1. Read — never written —
32
+ * so a handshake begun on an old pod finishes on a new one during the deploy. Its life is the
33
+ * handshake TTL (10 min by default), so one release of reading it is the whole transition.
34
+ * Delete with the fallback below in 32.0.0.
35
+ */
36
+ const LEGACY_HANDSHAKE_COOKIE_PREFIX = '__Host-x_oauth';
37
+
38
+ export interface HandshakeCookieOptions extends HandshakeSealOptions {
39
+ /**
40
+ * Defaults to `handshakeCookieName(provider)`. Overriding it opts out of the per-provider
41
+ * scoping, so both legs have to pass the same one — a name set on the redirect and defaulted on
42
+ * the callback reads a cookie that is not there.
43
+ */
44
+ readonly name?: string | undefined;
45
+ }
46
+
47
+ /**
48
+ * Set on the redirect. `SameSite=Lax`: the callback is a top-level cross-site GET from the
49
+ * provider, which `Lax` still attaches the cookie to and `Strict` would strip. A provider answering
50
+ * with `response_mode=form_post` POSTs instead, and this cookie would not reach it.
51
+ */
52
+ export function handshakeCookie(
53
+ handshake: OAuthHandshake,
54
+ options?: HandshakeCookieOptions,
55
+ ): string {
56
+ const maxAge = Math.floor(handshakeTtlMs(options) / 1000);
57
+ const clock = options?.clock ?? systemClock;
58
+ const state = encodeOidcState(handshake, clock.now().getTime());
59
+ const sealed = sealIdentityState(handshake.provider, state, { ...options, clock });
60
+ // The handshake already names its provider, so the two legs cannot disagree about the name.
61
+ return serializeIdentityState(handshake.provider, sealed, 'GET', maxAge, options?.name);
62
+ }
63
+
64
+ /**
65
+ * Send this with the callback's response, always — an authorization code is single-use, so the
66
+ * handshake that authorised it must not outlive it. A mismatched attribute set leaves a live twin.
67
+ *
68
+ * `provider` is required: clearing every provider's would cancel a login running in another tab.
69
+ * A legacy `__Host-x_oauth_<p>` cookie is not cleared — one `Set-Cookie` names one cookie; it
70
+ * expires on its own `Max-Age`, and the code it guarded is spent.
71
+ */
72
+ export const clearHandshakeCookie = (provider: OAuthProviderId, name?: string): string =>
73
+ clearIdentityStateCookie(provider, 'GET', name);
74
+
75
+ /** Opens the current format, else the legacy one; the first refusal is the one reported. */
76
+ function open(sealed: string, provider: OAuthProviderId, options?: HandshakeSealOptions) {
77
+ const clock = options?.clock ?? systemClock;
78
+ try {
79
+ const ttlMs = handshakeTtlMs(options);
80
+ const state = openIdentityState(sealed, provider, { ...options, clock, ttlMs });
81
+ return decodeOidcState(provider, state).handshake;
82
+ } catch (refusal) {
83
+ try {
84
+ return openHandshake(sealed, provider, options);
85
+ } catch {
86
+ throw refusal;
87
+ }
88
+ }
89
+ }
90
+
91
+ /** Reads the cookie set on the redirect — by either leg — and returns the handshake. */
92
+ export function readHandshakeCookie(
93
+ request: RequestLike,
94
+ provider: OAuthProviderId,
95
+ options?: HandshakeCookieOptions,
96
+ ): OAuthHandshake {
97
+ const header = request.headers.get('cookie');
98
+ const sealed =
99
+ readCookie(header, options?.name ?? handshakeCookieName(provider)) ??
100
+ (options?.name === undefined
101
+ ? readCookie(header, `${LEGACY_HANDSHAKE_COOKIE_PREFIX}_${provider}`)
102
+ : null);
103
+ if (sealed === null) {
104
+ throw oauthStateInvalid(provider, 'no handshake cookie arrived with the callback');
105
+ }
106
+ return open(sealed, provider, options);
107
+ }
@@ -5,7 +5,9 @@
5
5
  // line that names a route nothing serves impossible rather than merely discouraged.
6
6
 
7
7
  /**
8
- * Not configurable, on purpose: a movable base path is a `fix:` line that can go stale again.
8
+ * The base and the start path are not configurable, on purpose: a movable start path is a `fix:`
9
+ * line that can go stale again. The callback pattern is the one exception — `oauthLogin({
10
+ * callbackPath })`, for a `redirect_uri` an IdP already has registered — and no `fix:` quotes it.
9
11
  * `/auth/sso` since 31.0.0: every `IdentityProvider` — OIDC and SAML — signs in through it.
10
12
  */
11
13
  export const OAUTH_BASE_PATH = '/auth/sso';
@@ -30,6 +30,7 @@ import type { OAuthClientCredentials, OAuthFetch } from './oauth-exchange';
30
30
  import { oauthCredentials } from './oauth-exchange';
31
31
  import type { ResolveOAuthGrants } from './oauth-login';
32
32
  import { hasOAuthProvider } from './oauth-registry';
33
+ import { callbackPath } from './sign-in-callback-path';
33
34
 
34
35
  /**
35
36
  * What a router needs to mount one of these. Structural, like `RequestLike` in `session.ts`:
@@ -97,6 +98,15 @@ export interface OAuthLoginOptions {
97
98
  * branch: `providerJwks(providerFor('github'))` throws, because GitHub publishes no `jwks_uri`.
98
99
  */
99
100
  readonly idTokenKeys?: ((provider: OAuthProviderId) => IdTokenKeys | undefined) | undefined;
101
+ /**
102
+ * The callback leg's mount pattern, one whole `:provider` segment — and so the `redirect_uri`
103
+ * every provider is sent and must have registered. Defaults to `OAUTH_CALLBACK_ROUTE_PATH`
104
+ * (`/auth/sso/:provider/callback`). Set it to keep a URI already registered at an IdP you cannot
105
+ * edit, e.g. 30.x's `'/auth/oauth/:provider/callback'`, and mount `callback.path`, never the
106
+ * constant. The start leg does not move: it is a link in your own page, and `fix:` lines name it.
107
+ * A pattern a router cannot mount is `X_CONFIG_INVALID` here, at the call.
108
+ */
109
+ readonly callbackPath?: string | undefined;
100
110
  readonly env?: Readonly<Record<string, string | undefined>> | undefined;
101
111
  }
102
112
 
@@ -171,6 +181,7 @@ const asGet = (descriptor: SignInRouteDescriptor): AuthRouteDescriptor =>
171
181
  */
172
182
  export function oauthLogin(auth: Auth, options: OAuthLoginOptions = {}): OAuthLoginRoutes {
173
183
  const routes = signInRoutes(auth, options, {
184
+ callbackPath: callbackPath(options.callbackPath),
174
185
  resolve(segment, leg) {
175
186
  if (!hasOAuthProvider(segment) || !auth.providers.includes(segment)) throw unknown(segment);
176
187
  // Asked on the callback only: the start leg verifies nothing.
@@ -0,0 +1,61 @@
1
+ // Single responsibility: the callback leg's path pattern — the default, or an app's own
2
+ // (`oauthLogin({ callbackPath })`) — refused at construction when a router could not mount it or the
3
+ // provider could not be read back out of it. One value yields the mount, the `redirect_uri` sent
4
+ // to the IdP and the provider segment of an arriving request, so the three cannot disagree.
5
+
6
+ import { ConfigInvalidError, renderCauseValue } from '@ultimat3/core';
7
+ import { OAUTH_CALLBACK_ROUTE_PATH, OAUTH_START_ROUTE_PATH } from './oauth-paths';
8
+
9
+ const PARAM = ':provider';
10
+
11
+ export interface CallbackPath {
12
+ /** The mount pattern, `:provider` included. */
13
+ readonly pattern: string;
14
+ /** The concrete path for one provider: what `redirect_uri` carries. */
15
+ pathFor(provider: string): string;
16
+ /** The provider segment of a request this pattern matched, counted from the END of the path. */
17
+ providerOf(pathname: string): string;
18
+ }
19
+
20
+ const split = (path: string): string[] => path.split('/').filter((part) => part !== '');
21
+
22
+ function refuse(pattern: string, why: string): ConfigInvalidError {
23
+ return new ConfigInvalidError({
24
+ cause: `oauthLogin({ callbackPath: ${renderCauseValue(pattern)} }) ${why}`,
25
+ fix: "oauthLogin(auth, { callbackPath: '/auth/oauth/:provider/callback' }) // absolute, one whole ':provider' segment, no other parameter, no ./../empty/encoded segment, not the start path",
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Counted from the end so a router mounting the routes under a prefix still finds the segment —
31
+ * the default's `parts.length - 2` before this file existed.
32
+ */
33
+ export function callbackPath(pattern: string = OAUTH_CALLBACK_ROUTE_PATH): CallbackPath {
34
+ if (!pattern.startsWith('/')) throw refuse(pattern, 'is not an absolute path');
35
+ if (/[?#*]/.test(pattern)) throw refuse(pattern, 'carries a query, fragment or wildcard');
36
+ if (pattern === OAUTH_START_ROUTE_PATH) throw refuse(pattern, 'is the start leg’s own path');
37
+ const parts = split(pattern);
38
+ const params = parts.filter((part) => part.includes(':'));
39
+ if (params.length !== 1 || params[0] !== PARAM) {
40
+ throw refuse(pattern, 'must carry exactly one parameter, the whole segment :provider');
41
+ }
42
+ // A browser and a router both see the NORMALISED path: `..`, `.`, `\` and `%2e` move segments,
43
+ // an empty one shifts the count, and `%2f` is a slash to some routers and not to others.
44
+ const sample = pattern.replace(PARAM, 'provider');
45
+ if (
46
+ pattern.includes('%') ||
47
+ pattern.slice(1).split('/').includes('') ||
48
+ new URL(sample, 'https://x.invalid').pathname !== sample
49
+ ) {
50
+ throw refuse(pattern, 'changes under URL normalisation, which would move or drop :provider');
51
+ }
52
+ const fromEnd = parts.length - 1 - parts.indexOf(PARAM);
53
+ return Object.freeze({
54
+ pattern,
55
+ pathFor: (provider: string) => pattern.replace(PARAM, provider),
56
+ providerOf(pathname: string) {
57
+ const segments = split(pathname);
58
+ return segments[segments.length - 1 - fromEnd] ?? '';
59
+ },
60
+ });
61
+ }