@ultimat3/auth 30.9.0 → 31.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.
Files changed (46) hide show
  1. package/CLAUDE.md +55 -109
  2. package/README.md +100 -25
  3. package/package.json +5 -4
  4. package/src/adapter-subpath.ts +34 -0
  5. package/src/api-keys.ts +14 -1
  6. package/src/auth.ts +3 -3
  7. package/src/authorization-server-clients.ts +19 -1
  8. package/src/authorization-server-config.ts +13 -4
  9. package/src/authorization-server-installed.ts +34 -0
  10. package/src/authorization-server-postgres.ts +4 -2
  11. package/src/authorization-server-store.ts +14 -4
  12. package/src/authorization-server.ts +30 -10
  13. package/src/client-scope.ts +1 -1
  14. package/src/errors.ts +9 -1
  15. package/src/grant-authorization-code.ts +21 -16
  16. package/src/grant-device.ts +2 -2
  17. package/src/identity-provider-cookie.ts +89 -0
  18. package/src/identity-provider-oidc.ts +142 -0
  19. package/src/identity-provider-route-core.ts +253 -0
  20. package/src/identity-provider-routes.ts +78 -0
  21. package/src/identity-provider-status.ts +45 -0
  22. package/src/identity-provider.ts +112 -0
  23. package/src/index.ts +34 -54
  24. package/src/jwks.ts +2 -2
  25. package/src/kdf-gate.ts +24 -14
  26. package/src/limiter-install.ts +26 -16
  27. package/src/memory-adapter.ts +5 -5
  28. package/src/mfa.ts +1 -1
  29. package/src/oauth-discovery.ts +1 -1
  30. package/src/oauth-errors.ts +26 -2
  31. package/src/oauth-exchange.ts +2 -2
  32. package/src/oauth-paths.ts +5 -2
  33. package/src/oauth-route.ts +60 -297
  34. package/src/rate-limit.ts +7 -2
  35. package/src/saml-assertion.ts +156 -0
  36. package/src/saml-c14n.ts +118 -0
  37. package/src/saml-certificate.ts +47 -0
  38. package/src/saml-dom.ts +66 -0
  39. package/src/saml-dsig.ts +165 -0
  40. package/src/saml-email-trust.ts +49 -0
  41. package/src/saml-errors.ts +96 -0
  42. package/src/saml-metadata.ts +80 -0
  43. package/src/saml-provider.ts +179 -0
  44. package/src/saml-request.ts +51 -0
  45. package/src/saml-response.ts +131 -0
  46. package/src/saml-xml.ts +328 -0
package/src/api-keys.ts CHANGED
@@ -13,6 +13,13 @@ import { randomToken, sha256Hex } from './tokens';
13
13
 
14
14
  export const API_KEY_NAMESPACE = 'ult';
15
15
 
16
+ /**
17
+ * `lastUsedAt` is a dashboard's "last used", not an audit trail: a verify writes it at most once
18
+ * per minute, so N requests a second on one key are N reads, not N row-locked UPDATEs. The same
19
+ * trade sessions make with `idleSlideMs`.
20
+ */
21
+ export const API_KEY_TOUCH_MS = 60_000;
22
+
16
23
  /** `ult_<env>_<id>_<secret>` — the first three segments are the displayable prefix. */
17
24
  export const API_KEY_PREFIX_SEGMENTS = 3;
18
25
 
@@ -115,6 +122,9 @@ export async function verifyApiKey(
115
122
  if (parsed === null) throw apiKeyInvalid();
116
123
  const record = await store.findApiKeyById(parsed.id);
117
124
  if (record === null) throw apiKeyInvalid();
125
+ // The env label is part of what was issued: a `dev` key relabelled `ult_prod_…` is a forgery of
126
+ // the triage signal, refused like any other bad key.
127
+ if (parsed.prefix !== record.prefix) throw apiKeyInvalid();
118
128
  if (record.revokedAt !== null) throw apiKeyInvalid();
119
129
  const now = clock.now();
120
130
  if (record.expiresAt !== null && now.getTime() >= record.expiresAt.getTime()) {
@@ -125,7 +135,10 @@ export async function verifyApiKey(
125
135
  if (record.userId !== null && (owner === null || owner.disabledAt !== null)) {
126
136
  throw apiKeyInvalid();
127
137
  }
128
- await store.touchApiKey(record.id, now);
138
+ const last = record.lastUsedAt;
139
+ if (last === null || now.getTime() - last.getTime() >= API_KEY_TOUCH_MS) {
140
+ await store.touchApiKey(record.id, now);
141
+ }
129
142
  return { record, owner };
130
143
  }
131
144
 
package/src/auth.ts CHANGED
@@ -156,13 +156,13 @@ export interface AuthConfigInput {
156
156
  readonly mfa?: Partial<AuthMfaPolicy> | undefined;
157
157
  /**
158
158
  * Which TOTP steps have been spent, claimed by `completeMfa`. Omitted means the guard the host
159
- * installed (`configureTotpReplay`; a framework boot's is `postgresTotpReplayGuard`), then
159
+ * installed (`installTotpReplay`; a framework boot's is `postgresTotpReplayGuard`), then
160
160
  * `memoryTotpReplayGuard()` — one process' worth of memory, so a code is single-use per replica.
161
161
  */
162
162
  readonly totpReplay?: TotpReplayGuard | undefined;
163
163
  /**
164
164
  * The OAuth providers this app serves login routes for. Defaults to `[]` — an empty list is
165
- * "no OAuth", and every `/auth/oauth/<id>` answers `X_OAUTH_PROVIDER_UNKNOWN`. Never the live
165
+ * "no OAuth", and every `/auth/sso/<id>` answers `X_OAUTH_PROVIDER_UNKNOWN`. Never the live
166
166
  * registry, which a dependency can write into.
167
167
  */
168
168
  readonly providers?: readonly OAuthProviderId[] | undefined;
@@ -214,7 +214,7 @@ export function defineAuth(config: AuthConfigInput): Auth {
214
214
  assertPasswordPolicy(password);
215
215
  assertRateLimitPolicy(rateLimit);
216
216
  // Three answers in precedence order, and the middle one is why the seam exists: what this call
217
- // passed, then what the HOST installed (`configureAuthLimiters`, filled by the boot that owns
217
+ // passed, then what the HOST installed (`installAuthLimiters`, filled by the boot that owns
218
218
  // the database connection), then one process' worth of state. Without the middle arm a
219
219
  // scaffolded app had to remember to build a shared limiter itself, which is the opposite of
220
220
  // what this framework promises — and `x new` scaffolds two replicas.
@@ -18,6 +18,11 @@ import { randomToken, sha256Hex } from './tokens';
18
18
  export const CLIENT_METADATA_MAX_BYTES = 5 * 1024;
19
19
  const CLIENT_METADATA_TTL_MS = 5 * 60 * 1000;
20
20
  const CLIENT_METADATA_CACHE_MAX = 1_000;
21
+ /**
22
+ * One attempt: a document that failed is an unknown client NOW, and the person on the consent page
23
+ * is waiting. A retry would also multiply every metered fetch by the vendor's attempt count.
24
+ */
25
+ const ONE_ATTEMPT = Object.freeze({ attempts: 1, base: 0, max: 0, jitter: 'none' as const });
21
26
  const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
22
27
 
23
28
  /** https anywhere, http only on loopback (RFC 8252 §7.3), never a fragment (RFC 6749 §3.1.2). */
@@ -67,6 +72,18 @@ export interface ClientDirectory {
67
72
 
68
73
  export function clientDirectory(config: ResolvedAuthServerConfig): ClientDirectory {
69
74
  const cache = new Map<string, { client: AuthServerClient | null; untilMs: number }>();
75
+ /** Misses in flight, by client id: concurrent reads of one uncached document share one GET. */
76
+ const inflight = new Map<string, Promise<AuthServerClient | null>>();
77
+ function fetchShared(clientId: string, callerKey: string | undefined) {
78
+ const nowMs = config.clock.now().getTime();
79
+ const hit = cache.get(clientId);
80
+ if (hit !== undefined && hit.untilMs > nowMs) return Promise.resolve(hit.client);
81
+ const joined = inflight.get(clientId);
82
+ if (joined !== undefined) return joined;
83
+ const started = fetchDocument(clientId, callerKey).finally(() => inflight.delete(clientId));
84
+ inflight.set(clientId, started);
85
+ return started;
86
+ }
70
87
  async function fetchDocument(
71
88
  clientId: string,
72
89
  callerKey: string | undefined,
@@ -86,6 +103,7 @@ export function clientDirectory(config: ResolvedAuthServerConfig): ClientDirecto
86
103
  maxBytes: CLIENT_METADATA_MAX_BYTES,
87
104
  redirect: 'error',
88
105
  headers: { accept: 'application/json' },
106
+ retry: ONE_ATTEMPT,
89
107
  },
90
108
  { fetch: config.fetch, resolve: config.resolveHost },
91
109
  );
@@ -109,7 +127,7 @@ export function clientDirectory(config: ResolvedAuthServerConfig): ClientDirecto
109
127
  return {
110
128
  find: (clientId, callerKey) =>
111
129
  isMetadataClientId(clientId)
112
- ? fetchDocument(clientId, callerKey)
130
+ ? fetchShared(clientId, callerKey)
113
131
  : config.store.getClient(clientId),
114
132
  };
115
133
  }
@@ -3,8 +3,9 @@
3
3
  // naming it — at boot, never on the first token request a partner makes.
4
4
 
5
5
  import type { Clock } from '@ultimat3/core';
6
- import { renderCauseValue, systemClock } from '@ultimat3/core';
6
+ import { liveClock, renderCauseValue } from '@ultimat3/core';
7
7
  import type { DialResolve, OutboundFetch } from '@ultimat3/core/outbound';
8
+ import { installedAuthServerStore } from './authorization-server-installed';
8
9
  import type { AuthServerStore } from './authorization-server-store';
9
10
  import type { AuthServerGrant } from './authorization-server-types';
10
11
  import { DEVICE_GRANT } from './authorization-server-types';
@@ -40,7 +41,11 @@ export interface AuthServerEvent {
40
41
  export interface AuthorizationServerOptions {
41
42
  /** RFC 8414 issuer: https (http only on loopback), no query, no fragment. */
42
43
  readonly issuer: string;
43
- readonly store: AuthServerStore;
44
+ /**
45
+ * Where clients, codes, grants and tokens live. Omitted: the `authServerStore` port — what
46
+ * `runtime.ts` installed, else Postgres over the app's pool (`installedAuthServerStore`).
47
+ */
48
+ readonly store?: AuthServerStore | undefined;
44
49
  /** The app's consent PAGE: the advertised `authorization_endpoint`. */
45
50
  readonly consentPath: string;
46
51
  readonly lifetimes: AuthServerLifetimes;
@@ -203,12 +208,16 @@ export function resolveAuthServerConfig(
203
208
  "authorizationServer({ grants, devicePath: '/oauth/device' }) # the app's verification page",
204
209
  );
205
210
  }
206
- const clock = options.clock ?? systemClock;
211
+ const clock = options.clock ?? liveClock;
207
212
  return {
208
213
  issuer: options.issuer,
209
214
  basePath: url.pathname === '/' ? '' : url.pathname,
210
215
  origin: url.origin,
211
- store: options.store,
216
+ // A getter, read per call: `runtime.ts` may import the module that builds this server BEFORE
217
+ // the boot installs `runtime.authServerStore`, and a captured default would ignore it.
218
+ get store(): AuthServerStore {
219
+ return options.store ?? installedAuthServerStore();
220
+ },
212
221
  consentPath: pagePath('consentPath', options.consentPath),
213
222
  devicePath:
214
223
  options.devicePath === undefined ? null : pagePath('devicePath', options.devicePath),
@@ -0,0 +1,34 @@
1
+ // Single responsibility: the `authServerStore` port's slot — where `authorizationServer()` keeps its
2
+ // clients, codes, grants and tokens when the app names no store. The boot installs `runtime.ts`'s;
3
+ // with none installed it is the env default, `postgresAuthServerStore` over the app's pool.
4
+
5
+ import { adapterSlot } from '@ultimat3/core/adapter';
6
+ import { dbExecutor } from '@ultimat3/db';
7
+ import { postgresAuthServerStore } from './authorization-server-postgres';
8
+ import type { AuthServerStore } from './authorization-server-store';
9
+
10
+ /** Core's one slot shape (`adapter-slot.ts`): installs stack, each release removes its own frame. */
11
+ const stores = /* @__PURE__ */ adapterSlot<AuthServerStore | undefined>(() => undefined);
12
+
13
+ /**
14
+ * The `authServerStore` port's installer — on `@ultimat3/auth/adapter` only: the boot calls it with
15
+ * `runtime.ts`'s `authServerStore`. Answers the release that removes THIS install and no other.
16
+ */
17
+ export function installAuthServerStore(store: AuthServerStore): () => void {
18
+ return stores.install(store);
19
+ }
20
+
21
+ /**
22
+ * What a server declaring no `store` reads at EACH call — never captured, because `runtime.ts`
23
+ * may import the module that builds the server before the boot installs this port. Nothing installed: Postgres over the ambient
24
+ * pool, the store every app wrote by hand before 31.0.0 (`postgresAuthServerStore({ executor:
25
+ * dbExecutor() })`); building it dials nothing.
26
+ */
27
+ export function installedAuthServerStore(): AuthServerStore {
28
+ return stores.current() ?? postgresAuthServerStore({ executor: dbExecutor() });
29
+ }
30
+
31
+ /** Test seam: every install dropped. On `./adapter`, beside the installer. */
32
+ export function resetAuthServerStore(): void {
33
+ stores.reset();
34
+ }
@@ -137,6 +137,7 @@ export function postgresAuthServerStore(options: PostgresAuthServerStoreOptions)
137
137
  c.expiresAt,
138
138
  ]);
139
139
  },
140
+ getCode: (hash) => first(SQL.SQL_OAUTH_CODE_GET, [hash], toCode),
140
141
  async takeCode(hash, at) {
141
142
  const taken = await first(SQL.SQL_OAUTH_CODE_TAKE, [hash, at], toCode);
142
143
  if (taken !== null) return { code: { ...taken, usedAt: null }, fresh: true };
@@ -208,10 +209,11 @@ export function postgresAuthServerStore(options: PostgresAuthServerStoreOptions)
208
209
  : [hash, 'denied', null, null, at],
209
210
  toDevice,
210
211
  ),
211
- async pollDevice(hash, at) {
212
+ async pollDevice(hash, clientId, at) {
212
213
  // Two polls racing may both read the old stamp: harmless, `consumeDevice` is the single use.
213
214
  const device = await first(SQL.SQL_OAUTH_DEVICE_GET, [hash], toDevice);
214
- if (device !== null) await rows(SQL.SQL_OAUTH_DEVICE_POLLED, [hash, at]);
215
+ if (device === null || device.clientId !== clientId) return null;
216
+ await rows(SQL.SQL_OAUTH_DEVICE_POLLED, [hash, at]);
215
217
  return device;
216
218
  },
217
219
  async consumeDevice(hash) {
@@ -26,6 +26,11 @@ export interface AuthServerStore {
26
26
  putClient(client: AuthServerClient): Promise<void>;
27
27
  getClient(clientId: string): Promise<AuthServerClient | null>;
28
28
  putCode(code: AuthCodeRecord): Promise<void>;
29
+ /**
30
+ * The code as stored, used or not, WITHOUT spending it: the grant checks client, PKCE and
31
+ * redirect on this read so a request that could not redeem the code never burns it.
32
+ */
33
+ getCode(codeHash: string): Promise<AuthCodeRecord | null>;
29
34
  /** Marks the code used at `at` in the step that reads it. `fresh: false` is a replay. */
30
35
  takeCode(
31
36
  codeHash: string,
@@ -53,8 +58,12 @@ export interface AuthServerStore {
53
58
  decision: DeviceDecision,
54
59
  at: Date,
55
60
  ): Promise<DeviceCodeRecord | null>;
56
- /** Stamps `lastPolledAt = at` and answers the row as it was BEFORE the stamp. */
57
- pollDevice(deviceCodeHash: string, at: Date): Promise<DeviceCodeRecord | null>;
61
+ /**
62
+ * The device code of `clientId`: stamps `lastPolledAt = at` and answers the row as it was BEFORE
63
+ * the stamp. Another client's poll is `null` and stamps nothing, so it cannot `slow_down` the
64
+ * device the code was issued to.
65
+ */
66
+ pollDevice(deviceCodeHash: string, clientId: string, at: Date): Promise<DeviceCodeRecord | null>;
58
67
  /** Approved → consumed. `false` when another poll took it first. */
59
68
  consumeDevice(deviceCodeHash: string, at: Date): Promise<boolean>;
60
69
  /**
@@ -90,6 +99,7 @@ export function memoryAuthServerStore(): AuthServerStore {
90
99
  async putCode(code) {
91
100
  codes.set(code.codeHash, code);
92
101
  },
102
+ getCode: async (hash) => codes.get(hash) ?? null,
93
103
  async takeCode(hash, at) {
94
104
  const code = codes.get(hash);
95
105
  if (code === undefined) return null;
@@ -153,9 +163,9 @@ export function memoryAuthServerStore(): AuthServerStore {
153
163
  devices.set(device.deviceCodeHash, decided);
154
164
  return decided;
155
165
  },
156
- async pollDevice(hash, at) {
166
+ async pollDevice(hash, clientId, at) {
157
167
  const device = devices.get(hash);
158
- if (device === undefined) return null;
168
+ if (device === undefined || device.clientId !== clientId) return null;
159
169
  devices.set(hash, { ...device, lastPolledAt: at });
160
170
  return device;
161
171
  },
@@ -35,17 +35,26 @@ export interface AuthorizationServer {
35
35
  readonly routes: readonly AuthServerRoute[];
36
36
  /** The RFC 8414 document the metadata route serves. */
37
37
  readonly metadata: Readonly<Record<string, unknown>>;
38
- /** The consent page's read of its own URL: what to show, or the refusal to show instead. */
39
- readRequest(url: string | URL): Promise<ReadRequestResult>;
38
+ /**
39
+ * The consent page's read of its own URL: what to show, or the refusal to show instead.
40
+ * `callerKey` (the request's address) meters a metadata-document fetch per caller, as the token
41
+ * route does — a page load naming `https://<anyone>/<path>` is otherwise one outbound GET each.
42
+ */
43
+ readRequest(url: string | URL, options?: ReadRequestOptions): Promise<ReadRequestResult>;
40
44
  /** The consent action's approval: the redirect URL carrying `code`, `state` and `iss`. */
41
45
  approve(input: {
42
46
  readonly subject: string;
47
+ /** The deciding request's address, for the re-read's metadata fetch — see `readRequest`. */
48
+ readonly callerKey?: string | undefined;
43
49
  readonly request: AuthorizationRequest;
44
50
  /** What the person granted — the request's scopes or fewer. Defaults to all of them. */
45
51
  readonly scopes?: readonly string[] | undefined;
46
52
  }): Promise<string>;
47
53
  /** The redirect URL carrying `error=access_denied`. */
48
- deny(input: { readonly request: AuthorizationRequest }): Promise<string>;
54
+ deny(input: {
55
+ readonly request: AuthorizationRequest;
56
+ readonly callerKey?: string | undefined;
57
+ }): Promise<string>;
49
58
  /** `key` is the caller's bucket for user-code guesses: their address or session id. */
50
59
  readDevice(input: { readonly userCode: string; readonly key: string }): Promise<DeviceReading>;
51
60
  approveDevice(input: {
@@ -68,6 +77,11 @@ export interface AuthorizationServer {
68
77
  purge(input: { readonly before: Date }): Promise<number>;
69
78
  }
70
79
 
80
+ export interface ReadRequestOptions {
81
+ /** The caller's address: the limiter key for a metadata-document fetch. */
82
+ readonly callerKey?: string | undefined;
83
+ }
84
+
71
85
  /** The request as the query a client would have sent — what `approve` screens a second time. */
72
86
  function requestParams(request: AuthorizationRequest): URLSearchParams {
73
87
  const params = new URLSearchParams({
@@ -86,14 +100,20 @@ function requestParams(request: AuthorizationRequest): URLSearchParams {
86
100
  export function authorizationServer(options: AuthorizationServerOptions): AuthorizationServer {
87
101
  const config = resolveAuthServerConfig(options);
88
102
  const directory = clientDirectory(config);
89
- const read = async (params: URLSearchParams): Promise<ReadRequestResult> => {
103
+ const read = async (
104
+ params: URLSearchParams,
105
+ callerKey: string | undefined,
106
+ ): Promise<ReadRequestResult> => {
90
107
  const clientId = params.get('client_id');
91
- const client = clientId === null ? null : await directory.find(clientId);
108
+ const client = clientId === null ? null : await directory.find(clientId, callerKey);
92
109
  return screenAuthorization(config, client, params);
93
110
  };
94
111
  /** The request handed back is re-screened against the store: a forged object proves nothing. */
95
- const rescreen = async (request: AuthorizationRequest): Promise<AuthorizationRequest> => {
96
- const again = await read(requestParams(request));
112
+ const rescreen = async (
113
+ request: AuthorizationRequest,
114
+ callerKey: string | undefined,
115
+ ): Promise<AuthorizationRequest> => {
116
+ const again = await read(requestParams(request), callerKey);
97
117
  if (!again.ok) throw consentInvalid(again.description);
98
118
  return again.request;
99
119
  };
@@ -105,10 +125,10 @@ export function authorizationServer(options: AuthorizationServerOptions): Author
105
125
  issuer: config.issuer,
106
126
  routes: authServerRoutes(config, directory),
107
127
  metadata: authServerMetadata(config),
108
- readRequest: (url) => read(new URL(url).searchParams),
128
+ readRequest: (url, options) => read(new URL(url).searchParams, options?.callerKey),
109
129
  async approve(input) {
110
130
  const subject = subjectOf(input.subject);
111
- const request = await rescreen(input.request);
131
+ const request = await rescreen(input.request, input.callerKey);
112
132
  const scopes = [...new Set(input.scopes ?? request.scopes)];
113
133
  if (scopes.length === 0 || !scopes.every((scope) => request.scopes.includes(scope))) {
114
134
  throw consentInvalid('the approved scopes are empty or not a subset of the requested ones');
@@ -146,7 +166,7 @@ export function authorizationServer(options: AuthorizationServerOptions): Author
146
166
  });
147
167
  },
148
168
  async deny(input) {
149
- const request = await rescreen(input.request);
169
+ const request = await rescreen(input.request, input.callerKey);
150
170
  return redirectWith(request.redirectUri, {
151
171
  error: 'access_denied',
152
172
  iss: config.issuer,
@@ -47,7 +47,7 @@ function scopeKey(options: ClientScopeOptions): string {
47
47
  throw new AuthError({
48
48
  code: 'X_CONFIG_INVALID',
49
49
  cause: `clientScopeOf({ secret }) was passed a key shorter than the ${String(MIN_SECRET_LENGTH)} characters required, so the scope id would be keyed by a guessable secret`,
50
- fix: 'omit secret so clientScopeOf reads SESSION_SECRET, or pass one generated with: openssl rand -hex 32',
50
+ fix: 'openssl rand -hex 32 # pass the output as clientScopeOf({ secret }), or omit secret to read SESSION_SECRET',
51
51
  meta: { option: 'secret', minLength: MIN_SECRET_LENGTH },
52
52
  });
53
53
  }
package/src/errors.ts CHANGED
@@ -39,6 +39,9 @@ export const AUTH_OWNED_ERROR_CODES = [
39
39
  'X_PASSKEY_INVALID',
40
40
  'X_PASSKEY_CHALLENGE_EXPIRED',
41
41
  'X_PASSKEY_STEP_UP_REQUIRED',
42
+ 'X_SAML_RESPONSE_INVALID',
43
+ 'X_SAML_METADATA_INVALID',
44
+ 'X_IDENTITY_PROVIDER_UNKNOWN',
42
45
  ] as const;
43
46
 
44
47
  /**
@@ -99,6 +102,11 @@ export const AUTH_ERROR_TITLES: Readonly<Record<AuthOwnedErrorCode, string>> = {
99
102
  'a passkey ceremony named a challenge that is unknown, already used or past its ttl',
100
103
  X_PASSKEY_STEP_UP_REQUIRED:
101
104
  'a sensitive action ran without a fresh user-verified passkey proof for its digest',
105
+ X_SAML_RESPONSE_INVALID:
106
+ 'a saml response failed its signature, wrapping, audience, recipient, time, InResponseTo or replay check',
107
+ X_SAML_METADATA_INVALID:
108
+ 'saml IdP metadata has no entity id, HTTP-Redirect sign-on endpoint or signing certificate',
109
+ X_IDENTITY_PROVIDER_UNKNOWN: 'the URL named an identity provider the sso routes were not given',
102
110
  };
103
111
 
104
112
  // Registered unconditionally, in one call: a second package claiming a code auth owns has to fail
@@ -182,7 +190,7 @@ export const kdfOverloaded = (active: number, queued: number): AuthError =>
182
190
  new AuthError({
183
191
  code: 'X_OVERLOADED',
184
192
  cause: `${active} password hashes are already running and ${queued} more are queued`,
185
- fix: 'retry after the Retry-After header; widen the ceiling with configureKdfGate({ maxConcurrent, maxQueued }) only if the box has the memory — every argon2id hash holds ~19 MiB while it runs',
193
+ fix: 'retry after the Retry-After header; widen the ceiling with installKdfGate({ maxConcurrent, maxQueued }) from @ultimat3/auth/adapter, in apps/web/runtime.ts, only if the box has the memory — every argon2id hash holds ~19 MiB while it runs',
186
194
  meta: { active, queued, retryAfterSeconds: 1 },
187
195
  });
188
196
 
@@ -1,7 +1,7 @@
1
1
  // Single responsibility: the two person-grant legs of `POST /oauth/token` — a code for tokens
2
- // (RFC 6749 §4.1.3 + PKCE) and a refresh token for new ones (§6, OAuth 2.1 rotation). The rules:
3
- // a code is single use and its replay revokes what it minted; a retired refresh token's replay
4
- // revokes the whole grant; the redirect and the resource must be the ones the code was bound to.
2
+ // (RFC 6749 §4.1.3 + PKCE) and a refresh token for new ones (§6, OAuth 2.1 rotation). A code is
3
+ // spent only by a request that could redeem it, and its replay revokes what it minted; a retired
4
+ // refresh token's replay revokes the whole grant.
5
5
 
6
6
  import type { ResolvedAuthServerConfig } from './authorization-server-config';
7
7
  import {
@@ -30,28 +30,33 @@ export async function exchangeCode(
30
30
  return refused('invalid_request', 'code and code_verifier are required');
31
31
  }
32
32
  const now = config.clock.now();
33
- const taken = await config.store.takeCode(sha256Hex(code), now);
34
- if (taken === null) return invalidGrant('the code is unknown');
35
- const record = taken.code;
36
- if (!taken.fresh) {
37
- // Somebody holds a copy of a code that was already redeemed: whatever it minted is suspect.
38
- await config.store.revokeGrant(record.grantId, now);
39
- config.emit({ kind: 'code-replayed', clientId: record.clientId });
40
- return invalidGrant('the code was already used; the grant it minted is revoked');
41
- }
42
- if (record.clientId !== client.clientId)
33
+ const hash = sha256Hex(code);
34
+ // Read, check, THEN spend: a request that could not have redeemed the code — another client, no
35
+ // verifier, another redirect — must neither burn it nor trip the replay revocation below.
36
+ const record = await config.store.getCode(hash);
37
+ if (record === null) return invalidGrant('the code is unknown');
38
+ if (record.clientId !== client.clientId) {
43
39
  return invalidGrant('the code was issued to another client');
44
- if (record.expiresAt.getTime() <= now.getTime()) return invalidGrant('the code expired');
45
- if (form.get('redirect_uri') !== record.redirectUri) {
46
- return invalidGrant('redirect_uri is not the one the code was issued for');
47
40
  }
48
41
  if (!pkceMatches(verifier, record.codeChallenge)) {
49
42
  return invalidGrant('the code_verifier does not match the code_challenge');
50
43
  }
44
+ if (form.get('redirect_uri') !== record.redirectUri) {
45
+ return invalidGrant('redirect_uri is not the one the code was issued for');
46
+ }
51
47
  const resource = form.get('resource');
52
48
  if (resource !== undefined && resource !== record.resource) {
53
49
  return refused('invalid_target', 'the resource is not the one the grant is bound to');
54
50
  }
51
+ const taken = await config.store.takeCode(hash, now);
52
+ if (taken === null) return invalidGrant('the code is unknown');
53
+ if (!taken.fresh) {
54
+ // A second holder of the code AND its verifier: whatever it minted is suspect.
55
+ await config.store.revokeGrant(record.grantId, now);
56
+ config.emit({ kind: 'code-replayed', clientId: record.clientId });
57
+ return invalidGrant('the code was already used; the grant it minted is revoked');
58
+ }
59
+ if (record.expiresAt.getTime() <= now.getTime()) return invalidGrant('the code expired');
55
60
  const grant = await config.store.getGrant(record.grantId);
56
61
  if (grant === null || grant.revokedAt !== null) return invalidGrant('the grant is revoked');
57
62
  const refresh = await firstRefresh(config, grant.id);
@@ -154,8 +154,8 @@ export async function pollDevice(
154
154
  if (deviceCode === undefined) return refused('invalid_request', 'device_code is required');
155
155
  const now = config.clock.now();
156
156
  const hash = sha256Hex(deviceCode);
157
- const device = await config.store.pollDevice(hash, now);
158
- if (device === null || device.clientId !== client.clientId) {
157
+ const device = await config.store.pollDevice(hash, client.clientId, now);
158
+ if (device === null) {
159
159
  return refused('invalid_grant', 'the device code is unknown');
160
160
  }
161
161
  if (device.expiresAt.getTime() <= now.getTime()) {
@@ -0,0 +1,89 @@
1
+ // Single responsibility: where an `IdentityRedirect.state` lives between the two legs — HMAC-
2
+ // signed with `SESSION_SECRET`, bound to its provider, aged on the server's clock, one cookie per
3
+ // provider. SAML answers with a cross-site POST no `SameSite=Lax` cookie rides, so a POST-callback
4
+ // provider's cookie is `SameSite=None`; `Secure` and `__Host-` keep it off every other site.
5
+
6
+ import { type Clock, hmac, readCookie, serializeSetCookie, timingSafeEqual } from '@ultimat3/core';
7
+ import type { IdentityProvider } from './identity-provider';
8
+ import { DEFAULT_HANDSHAKE_TTL_MS, handshakeSecret } from './oauth-cookie';
9
+ import { oauthStateInvalid } from './oauth-errors';
10
+ import { base64Url, base64UrlBytes } from './tokens';
11
+
12
+ export const IDENTITY_STATE_COOKIE_PREFIX = '__Host-x_sso';
13
+
14
+ export const identityStateCookieName = (provider: string): string =>
15
+ `${IDENTITY_STATE_COOKIE_PREFIX}_${provider}`;
16
+
17
+ export interface IdentityStateOptions {
18
+ readonly clock: Clock;
19
+ /** Defaults to `SESSION_SECRET`. */
20
+ readonly secret?: string | undefined;
21
+ }
22
+
23
+ const sign = (body: string, secret: string): string => hmac('sha256', secret, body, 'hex');
24
+
25
+ export function identityStateCookie(
26
+ provider: IdentityProvider,
27
+ state: string,
28
+ options: IdentityStateOptions,
29
+ ): string {
30
+ const body = base64Url(
31
+ new TextEncoder().encode(JSON.stringify([provider.id, state, options.clock.now().getTime()])),
32
+ );
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
+ });
38
+ }
39
+
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 = (
45
+ 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
+ options: IdentityStateOptions,
58
+ ): 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
+ const dot = sealed.lastIndexOf('.');
62
+ const body = sealed.slice(0, Math.max(dot, 0));
63
+ if (
64
+ dot <= 0 ||
65
+ !timingSafeEqual(sign(body, options.secret ?? handshakeSecret()), sealed.slice(dot + 1))
66
+ ) {
67
+ throw oauthStateInvalid(
68
+ provider.id,
69
+ 'the sign-in state was tampered with, or the secret rotated',
70
+ );
71
+ }
72
+ let parsed: unknown;
73
+ try {
74
+ parsed = JSON.parse(new TextDecoder().decode(base64UrlBytes(body) ?? new Uint8Array()));
75
+ } catch {
76
+ parsed = null;
77
+ }
78
+ if (!Array.isArray(parsed) || parsed.length !== 3) {
79
+ throw oauthStateInvalid(provider.id, 'the sign-in state is not readable');
80
+ }
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');
84
+ }
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');
87
+ }
88
+ return state;
89
+ }