@ultimat3/auth 29.3.2 → 30.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 (67) hide show
  1. package/CLAUDE.md +46 -58
  2. package/README.md +162 -9
  3. package/package.json +20 -5
  4. package/src/adapter.ts +88 -2
  5. package/src/api-key-resolver.ts +3 -3
  6. package/src/auth.ts +29 -7
  7. package/src/authorization-server-clients.ts +169 -0
  8. package/src/authorization-server-config.ts +239 -0
  9. package/src/authorization-server-errors.ts +23 -0
  10. package/src/authorization-server-postgres-sql.ts +107 -0
  11. package/src/authorization-server-postgres.ts +222 -0
  12. package/src/authorization-server-register.ts +98 -0
  13. package/src/authorization-server-request.ts +126 -0
  14. package/src/authorization-server-routes.ts +181 -0
  15. package/src/authorization-server-schema.ts +82 -0
  16. package/src/authorization-server-store.ts +189 -0
  17. package/src/authorization-server-tokens.ts +118 -0
  18. package/src/authorization-server-types.ts +108 -0
  19. package/src/authorization-server-wire.ts +135 -0
  20. package/src/authorization-server.ts +178 -0
  21. package/src/base64url.ts +40 -0
  22. package/src/builtin-adapter-violation.ts +12 -9
  23. package/src/builtin-adapter.ts +31 -5
  24. package/src/cbor-encode.ts +52 -0
  25. package/src/cbor.ts +129 -0
  26. package/src/client-scope.ts +2 -5
  27. package/src/client.ts +16 -0
  28. package/src/cose-key.ts +129 -0
  29. package/src/errors.ts +51 -0
  30. package/src/grant-authorization-code.ts +113 -0
  31. package/src/grant-client-credentials.ts +55 -0
  32. package/src/grant-device.ts +188 -0
  33. package/src/index.ts +108 -3
  34. package/src/jwks.ts +81 -12
  35. package/src/jwt-bearer-resolver.ts +80 -0
  36. package/src/limiter-install.ts +31 -2
  37. package/src/memory-adapter.ts +18 -0
  38. package/src/mfa-challenge.ts +13 -7
  39. package/src/mfa-replay-postgres.ts +71 -0
  40. package/src/mfa.ts +24 -13
  41. package/src/oauth-bearer-resolver.ts +44 -0
  42. package/src/oauth-cookie.ts +2 -1
  43. package/src/oauth-discovery.ts +17 -6
  44. package/src/oauth-exchange.ts +23 -12
  45. package/src/oauth-profile.ts +20 -9
  46. package/src/oauth-route.ts +5 -5
  47. package/src/passkey-assertion.ts +60 -0
  48. package/src/passkey-challenge.ts +59 -0
  49. package/src/passkey-client.ts +156 -0
  50. package/src/passkey-config.ts +92 -0
  51. package/src/passkey-errors.ts +89 -0
  52. package/src/passkey-evidence.ts +74 -0
  53. package/src/passkey-login.ts +127 -0
  54. package/src/passkey-memory.ts +77 -0
  55. package/src/passkey-postgres.ts +155 -0
  56. package/src/passkey-step-up.ts +135 -0
  57. package/src/passkey-verify.ts +180 -0
  58. package/src/passkey-wire.ts +61 -0
  59. package/src/soft-authenticator.ts +208 -0
  60. package/src/subject-target.ts +73 -0
  61. package/src/tables.ts +46 -0
  62. package/src/token-endpoints.ts +61 -0
  63. package/src/tokens.ts +2 -25
  64. package/src/verification-purposes.ts +61 -0
  65. package/src/verify.ts +86 -12
  66. package/src/webauthn-data.ts +111 -0
  67. package/src/workload.ts +67 -15
@@ -32,9 +32,9 @@ export interface ApiKeyResolverOptions extends ApiKeyActorOptions {
32
32
  * `defineAppMcp()` — one resolver for both.
33
33
  *
34
34
  * `store` is a THUNK, read when a token is presented and never captured: a mount is declared when
35
- * its module is evaluated, and `postgresAuthAdapter()` takes the process's database client, which
36
- * boot installs later. `() => memoryAuthAdapter()` would be a new, empty store per request — hold
37
- * the instance and return it.
35
+ * its module is evaluated, and which store serves (memory or Postgres) is often decided at boot.
36
+ * `() => memoryAuthAdapter()` would be a new, empty store per request — hold the instance and
37
+ * return it.
38
38
  */
39
39
  export function apiKeyResolver(
40
40
  store: () => ApiKeyVerifyStore,
package/src/auth.ts CHANGED
@@ -7,13 +7,18 @@ import { type Clock, systemClock, timingSafeEqual, uuidV7 } from '@ultimat3/core
7
7
  import { t } from '@ultimat3/schema';
8
8
  import type { AuthAdapter, AuthSession, AuthUser } from './adapter';
9
9
  import { normaliseEmail } from './email';
10
- import { mfaRequiredUnenforceable, sessionUnknown } from './errors';
11
- import { installedAuthLimiter } from './limiter-install';
10
+ import { mfaRequiredUnenforceable, sessionUnknown, totpReplayGuardInvalid } from './errors';
11
+ import { installedAuthLimiter, installedTotpReplay } from './limiter-install';
12
12
  import { openLoginAttempt } from './login-attempt';
13
13
  import { type HandleDirectory, loginIdentity } from './login-identity';
14
14
  import { memoryTotpReplayGuard, type TotpReplayGuard } from './mfa';
15
15
  import { mfaChallengeRequired } from './mfa-challenge';
16
16
  import type { OAuthProviderId } from './oauth';
17
+ import {
18
+ type PasskeyPolicy,
19
+ type PasskeyPolicyInput,
20
+ resolvePasskeyPolicy,
21
+ } from './passkey-config';
17
22
  import {
18
23
  checkPasswordStrength,
19
24
  DEFAULT_PASSWORD_POLICY,
@@ -82,7 +87,8 @@ export const AccountSchema = t.object({
82
87
 
83
88
  export const VerificationSchema = t.object({
84
89
  id: t.string,
85
- purpose: t.enum(['email-verify', 'password-reset']),
90
+ // Open: any `defineVerificationPurpose` name, so a declared invite row parses as its own data.
91
+ purpose: t.string,
86
92
  identifier: t.email,
87
93
  expiresAt: t.date,
88
94
  consumedAt: t.optional(t.date),
@@ -149,9 +155,9 @@ export interface AuthConfigInput {
149
155
  readonly orgLimiter?: AuthLimiter | undefined;
150
156
  readonly mfa?: Partial<AuthMfaPolicy> | undefined;
151
157
  /**
152
- * Which TOTP steps have been spent, read and written by `completeMfa`. Omitted means
153
- * `memoryTotpReplayGuard()` — one process' worth of memory, so a code is single-use per replica;
154
- * a fleet that needs it single-use everywhere passes a shared guard with the same two methods.
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
160
+ * `memoryTotpReplayGuard()` — one process' worth of memory, so a code is single-use per replica.
155
161
  */
156
162
  readonly totpReplay?: TotpReplayGuard | undefined;
157
163
  /**
@@ -169,6 +175,13 @@ export interface AuthConfigInput {
169
175
  * by id — `register(auth, { id })` creates the auth user under the app's id.
170
176
  */
171
177
  readonly handles?: HandleDirectory | undefined;
178
+ /**
179
+ * The relying party passkeys are verified against: `{ rpId: 'example.com', origins:
180
+ * ['https://example.com'] }`. Absent means no passkeys — `passkeyLogin(auth)` and
181
+ * `passkeyStepUp(auth)` refuse at definition (`X_CONFIG_INVALID`), as does a block whose
182
+ * origins are not on its rp id.
183
+ */
184
+ readonly passkeys?: PasskeyPolicyInput | undefined;
172
185
  }
173
186
 
174
187
  export interface Auth {
@@ -186,6 +199,7 @@ export interface Auth {
186
199
  readonly providers: readonly OAuthProviderId[];
187
200
  readonly link: OAuthLinkPolicy;
188
201
  readonly handles?: HandleDirectory | undefined;
202
+ readonly passkeys?: PasskeyPolicy | undefined;
189
203
  }
190
204
 
191
205
  export function defineAuth(config: AuthConfigInput): Auth {
@@ -228,6 +242,13 @@ export function defineAuth(config: AuthConfigInput): Auth {
228
242
  const declaredMfa: { readonly required?: unknown } = config.mfa ?? {};
229
243
  if (declaredMfa.required === true) throw mfaRequiredUnenforceable();
230
244
  const mfa: AuthMfaPolicy = { issuer: config.mfa?.issuer ?? DEFAULT_MFA_ISSUER, required: false };
245
+ const totpReplay = config.totpReplay ?? installedTotpReplay() ?? memoryTotpReplayGuard();
246
+ // Unreachable from TypeScript and reachable from a guard written before `claim`, which would
247
+ // otherwise be found at the first second factor, as a TypeError, on a production sign-in.
248
+ const declaredReplay: { readonly claim?: unknown } = totpReplay;
249
+ if (typeof declaredReplay.claim !== 'function') throw totpReplayGuardInvalid();
250
+ const passkeys =
251
+ config.passkeys === undefined ? undefined : resolvePasskeyPolicy(config.passkeys);
231
252
  return Object.freeze({
232
253
  adapter: config.adapter,
233
254
  clock,
@@ -240,7 +261,7 @@ export function defineAuth(config: AuthConfigInput): Auth {
240
261
  orgRateLimit: orgLimiter.policy,
241
262
  orgLimiter,
242
263
  mfa,
243
- totpReplay: config.totpReplay ?? memoryTotpReplayGuard(),
264
+ totpReplay,
244
265
  // BREAKING (majors only): the default is `[]`, never the live registry.
245
266
  //
246
267
  // It was `oauthProviderIds()`, so `defineAuth({ providers })`'s own documented purpose — the
@@ -254,6 +275,7 @@ export function defineAuth(config: AuthConfigInput): Auth {
254
275
  providers: config.providers ?? [],
255
276
  link: config.link ?? 'verified-email',
256
277
  ...(config.handles === undefined ? {} : { handles: config.handles }),
278
+ ...(passkeys === undefined ? {} : { passkeys }),
257
279
  });
258
280
  }
259
281
 
@@ -0,0 +1,169 @@
1
+ // Single responsibility: who a `client_id` is — a registered client from the store, or an https
2
+ // URL whose metadata document (MCP 2025-11-25) is fetched via `outbound({ dial: 'public' })`,
3
+ // size-capped, metered per caller, cached oldest-first — and proving a confidential one's secret.
4
+ // The secret is hashed at rest and compared hash to hash in constant time, never with `===`.
5
+
6
+ import { logger, stringField, timingSafeEqual } from '@ultimat3/core';
7
+ import { outbound } from '@ultimat3/core/outbound';
8
+ import type { ResolvedAuthServerConfig } from './authorization-server-config';
9
+ import type {
10
+ AuthServerClient,
11
+ AuthServerGrant,
12
+ ClientAuthMethod,
13
+ } from './authorization-server-types';
14
+ import { isRecord } from './json';
15
+ import { randomToken, sha256Hex } from './tokens';
16
+
17
+ /** A metadata document is a few hundred bytes; 5 KiB is RFC 7591 metadata with room to spare. */
18
+ export const CLIENT_METADATA_MAX_BYTES = 5 * 1024;
19
+ const CLIENT_METADATA_TTL_MS = 5 * 60 * 1000;
20
+ const CLIENT_METADATA_CACHE_MAX = 1_000;
21
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
22
+
23
+ /** https anywhere, http only on loopback (RFC 8252 §7.3), never a fragment (RFC 6749 §3.1.2). */
24
+ export function isRedirectUri(value: string): boolean {
25
+ const url = URL.parse(value);
26
+ if (url === null || url.hash !== '' || value.includes('#')) return false;
27
+ return url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK.has(url.hostname));
28
+ }
29
+
30
+ const isMetadataClientId = (clientId: string): boolean => {
31
+ const url = URL.parse(clientId);
32
+ return url !== null && url.protocol === 'https:' && url.pathname !== '/' && url.hash === '';
33
+ };
34
+
35
+ const strings = (value: unknown): readonly string[] | null =>
36
+ Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : null;
37
+
38
+ /** The document, read as a public client or not at all. A secret in it would be published. */
39
+ function clientFromDocument(clientId: string, body: unknown, at: Date): AuthServerClient | null {
40
+ if (!isRecord(body) || body['client_id'] !== clientId) return null;
41
+ const redirects = strings(body['redirect_uris']);
42
+ if (redirects === null || redirects.length === 0 || !redirects.every(isRedirectUri)) return null;
43
+ const method = body['token_endpoint_auth_method'];
44
+ if (method !== undefined && method !== 'none') return null;
45
+ const name = body['client_name'];
46
+ return {
47
+ clientId,
48
+ name: typeof name === 'string' && name !== '' ? name : new URL(clientId).host,
49
+ secretHash: null,
50
+ authMethod: 'none',
51
+ redirectUris: redirects,
52
+ grantTypes: ['authorization_code', 'refresh_token'],
53
+ scopes: [],
54
+ resources: [],
55
+ createdAt: at,
56
+ };
57
+ }
58
+
59
+ export interface ClientDirectory {
60
+ /**
61
+ * `callerKey` (the caller's address) meters metadata fetches through `config.limiter`: an
62
+ * unauthenticated POST naming `https://<anyone>/<path>` would otherwise be one outbound GET per
63
+ * path. Rejects with `X_ACCOUNT_LOCKED` once the caller is over the limit.
64
+ */
65
+ find(clientId: string, callerKey?: string): Promise<AuthServerClient | null>;
66
+ }
67
+
68
+ export function clientDirectory(config: ResolvedAuthServerConfig): ClientDirectory {
69
+ const cache = new Map<string, { client: AuthServerClient | null; untilMs: number }>();
70
+ async function fetchDocument(
71
+ clientId: string,
72
+ callerKey: string | undefined,
73
+ ): Promise<AuthServerClient | null> {
74
+ const nowMs = config.clock.now().getTime();
75
+ const hit = cache.get(clientId);
76
+ if (hit !== undefined && hit.untilMs > nowMs) return hit.client;
77
+ if (callerKey !== undefined) await config.limiter.reserve(`oauth-client-metadata:${callerKey}`);
78
+ let client: AuthServerClient | null = null;
79
+ try {
80
+ const answer = await outbound(
81
+ {
82
+ url: clientId,
83
+ vendor: 'oauth-client-metadata',
84
+ dial: 'public',
85
+ deadlineMs: 5_000,
86
+ maxBytes: CLIENT_METADATA_MAX_BYTES,
87
+ redirect: 'error',
88
+ headers: { accept: 'application/json' },
89
+ },
90
+ { fetch: config.fetch, resolve: config.resolveHost },
91
+ );
92
+ client =
93
+ answer.status === 200
94
+ ? clientFromDocument(clientId, JSON.parse(answer.text()), config.clock.now())
95
+ : null;
96
+ } catch (error) {
97
+ // An unreachable, oversized or private-address document is an unknown client: the consent
98
+ // page shows the refusal and nothing redirects. The code, never the message — it names a host.
99
+ const code = stringField(error, 'code');
100
+ logger.warn('auth.server.client_metadata_unreadable', code === undefined ? {} : { code });
101
+ }
102
+ // Oldest first: a Map iterates in insertion order, so re-inserting keeps the order honest.
103
+ cache.delete(clientId);
104
+ const oldest = cache.keys().next();
105
+ if (cache.size >= CLIENT_METADATA_CACHE_MAX && oldest.done !== true) cache.delete(oldest.value);
106
+ cache.set(clientId, { client, untilMs: nowMs + CLIENT_METADATA_TTL_MS });
107
+ return client;
108
+ }
109
+ return {
110
+ find: (clientId, callerKey) =>
111
+ isMetadataClientId(clientId)
112
+ ? fetchDocument(clientId, callerKey)
113
+ : config.store.getClient(clientId),
114
+ };
115
+ }
116
+
117
+ export interface RegisterClientInput {
118
+ readonly name: string;
119
+ /** `'none'` (default) is public: PKCE only. A secret method is a confidential client. */
120
+ readonly authMethod?: ClientAuthMethod | undefined;
121
+ readonly redirectUris?: readonly string[] | undefined;
122
+ readonly grantTypes?: readonly AuthServerGrant[] | undefined;
123
+ readonly scopes?: readonly string[] | undefined;
124
+ readonly resources?: readonly string[] | undefined;
125
+ }
126
+
127
+ export interface RegisteredClient {
128
+ readonly client: AuthServerClient;
129
+ /** Shown ONCE, to whoever registered. Only its hash is stored. `null` for a public client. */
130
+ readonly clientSecret: string | null;
131
+ }
132
+
133
+ /** Screening is the caller's: the route answers RFC 7591 errors, the app's task trusts itself. */
134
+ export async function storeClient(
135
+ config: ResolvedAuthServerConfig,
136
+ input: RegisterClientInput,
137
+ ): Promise<RegisteredClient> {
138
+ const method = input.authMethod ?? 'none';
139
+ const clientSecret = method === 'none' ? null : randomToken(32);
140
+ const client: AuthServerClient = {
141
+ clientId: `c_${randomToken(16)}`,
142
+ name: input.name,
143
+ secretHash: clientSecret === null ? null : sha256Hex(clientSecret),
144
+ authMethod: method,
145
+ redirectUris: [...(input.redirectUris ?? [])],
146
+ grantTypes: [...(input.grantTypes ?? ['authorization_code', 'refresh_token'])],
147
+ scopes: [...(input.scopes ?? [])],
148
+ resources: [...(input.resources ?? [])],
149
+ createdAt: config.clock.now(),
150
+ };
151
+ await config.store.putClient(client);
152
+ return { client, clientSecret };
153
+ }
154
+
155
+ /** What a token request presented about the client. */
156
+ export interface ClientCredentials {
157
+ readonly clientId: string;
158
+ readonly secret: string | null;
159
+ }
160
+
161
+ /** A public client presents no secret; a confidential one must, and it must match. */
162
+ export function clientAuthenticates(
163
+ client: AuthServerClient,
164
+ presented: ClientCredentials,
165
+ ): boolean {
166
+ if (client.secretHash === null) return presented.secret === null;
167
+ if (presented.secret === null) return false;
168
+ return timingSafeEqual(sha256Hex(presented.secret), client.secretHash);
169
+ }
@@ -0,0 +1,239 @@
1
+ // Single responsibility: what `authorizationServer()` is configured with, screened once at
2
+ // construction into the resolved form every grant reads. A bad field is `X_AUTH_SERVER_INVALID`
3
+ // naming it — at boot, never on the first token request a partner makes.
4
+
5
+ import type { Clock } from '@ultimat3/core';
6
+ import { renderCauseValue, systemClock } from '@ultimat3/core';
7
+ import type { DialResolve, OutboundFetch } from '@ultimat3/core/outbound';
8
+ import type { AuthServerStore } from './authorization-server-store';
9
+ import type { AuthServerGrant } from './authorization-server-types';
10
+ import { DEVICE_GRANT } from './authorization-server-types';
11
+ import { AuthError } from './errors';
12
+ import { type AuthLimiter, memoryAuthLimiter } from './rate-limit';
13
+
14
+ export interface AuthServerLifetimes {
15
+ /** Access-token life. Short: an access token is not revocable faster than a lookup. */
16
+ readonly accessSeconds: number;
17
+ /** A refresh token unused this long is dead. The app's retention window. */
18
+ readonly refreshIdleDays: number;
19
+ /** Required once `client_credentials` is enabled: a machine's token life. */
20
+ readonly clientSeconds?: number | undefined;
21
+ /** Device-code life. Defaults to 600, RFC 8628's own example. */
22
+ readonly deviceSeconds?: number | undefined;
23
+ }
24
+
25
+ /** One line per decision the server made — the wrapper turns these into `mcp.auth.*`. */
26
+ export interface AuthServerEvent {
27
+ readonly kind:
28
+ | 'issued'
29
+ | 'refused'
30
+ | 'code-replayed'
31
+ | 'refresh-replayed'
32
+ | 'revoked'
33
+ | 'grant-disabled';
34
+ /** The RFC 6749 `error` the wire answered, on a refusal. */
35
+ readonly error?: string | undefined;
36
+ readonly clientId?: string | undefined;
37
+ readonly grantType?: string | undefined;
38
+ }
39
+
40
+ export interface AuthorizationServerOptions {
41
+ /** RFC 8414 issuer: https (http only on loopback), no query, no fragment. */
42
+ readonly issuer: string;
43
+ readonly store: AuthServerStore;
44
+ /** The app's consent PAGE: the advertised `authorization_endpoint`. */
45
+ readonly consentPath: string;
46
+ readonly lifetimes: AuthServerLifetimes;
47
+ /** Every scope this server may grant. A client and a person only ever narrow it. */
48
+ readonly scopes: readonly string[];
49
+ /**
50
+ * RFC 8707 resource indicators this server mints for. A request naming any other is
51
+ * `invalid_target`; one naming none gets an unbound grant, which a binding resolver refuses.
52
+ */
53
+ readonly resources?: readonly string[] | undefined;
54
+ /** The resource a request naming none is bound to. Must be one of `resources`. */
55
+ readonly defaultResource?: string | undefined;
56
+ /** `'metadata-document'` (default): https client ids are fetched. `'dynamic'` adds RFC 7591. */
57
+ readonly registration?: 'metadata-document' | 'dynamic' | undefined;
58
+ /** Defaults to `['authorization_code', 'refresh_token']`. Each further grant is named. */
59
+ readonly grants?: readonly AuthServerGrant[] | undefined;
60
+ /** The app's device verification PAGE. Required once the device grant is enabled. */
61
+ readonly devicePath?: string | undefined;
62
+ readonly clock?: Clock | undefined;
63
+ /** Counts registrations and user-code guesses per caller key. Defaults to per-process memory. */
64
+ readonly limiter?: AuthLimiter | undefined;
65
+ /** How metadata documents are fetched — injected by a test; production dials the internet. */
66
+ readonly fetch?: OutboundFetch | undefined;
67
+ readonly resolveHost?: DialResolve | undefined;
68
+ readonly onEvent?: ((event: AuthServerEvent) => void) | undefined;
69
+ }
70
+
71
+ export interface ResolvedAuthServerConfig {
72
+ readonly issuer: string;
73
+ /** The issuer's path, `''` at the root: every endpoint mounts under it. */
74
+ readonly basePath: string;
75
+ readonly origin: string;
76
+ readonly store: AuthServerStore;
77
+ readonly consentPath: string;
78
+ readonly devicePath: string | null;
79
+ readonly accessSeconds: number;
80
+ readonly refreshIdleMs: number;
81
+ readonly clientSeconds: number | null;
82
+ readonly deviceSeconds: number;
83
+ readonly scopes: ReadonlySet<string>;
84
+ readonly resources: ReadonlySet<string>;
85
+ readonly defaultResource: string | null;
86
+ readonly dynamic: boolean;
87
+ readonly grants: ReadonlySet<AuthServerGrant>;
88
+ readonly clock: Clock;
89
+ readonly limiter: AuthLimiter;
90
+ readonly fetch: OutboundFetch | undefined;
91
+ readonly resolveHost: DialResolve | undefined;
92
+ readonly emit: (event: AuthServerEvent) => void;
93
+ }
94
+
95
+ const KNOWN_GRANTS: ReadonlySet<string> = new Set<AuthServerGrant>([
96
+ 'authorization_code',
97
+ 'refresh_token',
98
+ 'client_credentials',
99
+ DEVICE_GRANT,
100
+ ]);
101
+
102
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
103
+ const PAGE_PATH = /^\/[A-Za-z0-9\-._~/]*$/;
104
+ /** RFC 6749 §3.3 scope-token. */
105
+ export const SCOPE_TOKEN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
106
+ const DAY_MS = 24 * 60 * 60 * 1000;
107
+
108
+ const invalid = (field: string, why: string, fix: string): AuthError =>
109
+ new AuthError({
110
+ code: 'X_AUTH_SERVER_INVALID',
111
+ cause: `authorizationServer({ ${field} }) ${why}`,
112
+ fix,
113
+ meta: { field },
114
+ });
115
+
116
+ /** A lifetime: a whole number of at least 1, so `NaN` and `Infinity` are refused, never stored. */
117
+ const lifetimeSeconds = (field: string, value: number | undefined): number => {
118
+ if (value !== undefined && Number.isSafeInteger(value) && value >= 1) return value;
119
+ throw invalid(
120
+ field,
121
+ `is ${renderCauseValue(value)}, not a whole number of at least 1`,
122
+ `authorizationServer({ lifetimes: { ${field.replace('lifetimes.', '')}: 600 } }) # a positive whole number`,
123
+ );
124
+ };
125
+
126
+ const pagePath = (field: string, value: string): string => {
127
+ if (PAGE_PATH.test(value) && !value.includes('//')) return value;
128
+ throw invalid(
129
+ field,
130
+ `is ${renderCauseValue(value)}, not an absolute path on the issuer's origin`,
131
+ `authorizationServer({ ${field}: '/oauth/consent' }) # the path of the app's own page`,
132
+ );
133
+ };
134
+
135
+ function issuerUrl(value: string): URL {
136
+ const url = URL.parse(value);
137
+ const secure =
138
+ url !== null &&
139
+ (url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK.has(url.hostname)));
140
+ if (url !== null && secure && url.search === '' && url.hash === '' && !value.endsWith('/')) {
141
+ return url;
142
+ }
143
+ throw invalid(
144
+ 'issuer',
145
+ `is ${renderCauseValue(value)}; RFC 8414 needs https (http only on loopback), no query, no fragment, no trailing slash`,
146
+ "authorizationServer({ issuer: 'https://www.example.com' }) # the app's public origin",
147
+ );
148
+ }
149
+
150
+ /** RFC 8707 §2: an absolute URI with no fragment. */
151
+ function resourceIndicator(value: string): string {
152
+ const url = URL.parse(value);
153
+ if (url !== null && url.hash === '' && !value.includes('#')) return value;
154
+ throw invalid(
155
+ 'resources',
156
+ `holds ${renderCauseValue(value)}, not an absolute URI without a fragment`,
157
+ "authorizationServer({ resources: ['https://www.example.com/mcp'] }) # canonical URLs, no fragment",
158
+ );
159
+ }
160
+
161
+ function defaultResourceOf(options: AuthorizationServerOptions): string | null {
162
+ const fallback = options.defaultResource;
163
+ if (fallback === undefined) return null;
164
+ if ((options.resources ?? []).includes(fallback)) return fallback;
165
+ throw invalid(
166
+ 'defaultResource',
167
+ `is ${renderCauseValue(fallback)}, which is not one of resources`,
168
+ 'authorizationServer({ resources: [defaultResource], defaultResource }) # or drop defaultResource',
169
+ );
170
+ }
171
+
172
+ export function resolveAuthServerConfig(
173
+ options: AuthorizationServerOptions,
174
+ ): ResolvedAuthServerConfig {
175
+ const url = issuerUrl(options.issuer);
176
+ const grants = new Set<AuthServerGrant>(
177
+ options.grants ?? ['authorization_code', 'refresh_token'],
178
+ );
179
+ for (const grant of grants) {
180
+ if (!KNOWN_GRANTS.has(grant)) {
181
+ throw invalid(
182
+ 'grants',
183
+ `names ${renderCauseValue(grant)}, which this server does not implement`,
184
+ `authorizationServer({ grants: [${[...KNOWN_GRANTS].map((g) => `'${g}'`).join(', ')}] }) # only these are implemented`,
185
+ );
186
+ }
187
+ }
188
+ for (const scope of options.scopes) {
189
+ if (!SCOPE_TOKEN.test(scope)) {
190
+ throw invalid(
191
+ 'scopes',
192
+ `holds ${renderCauseValue(scope)}, not an RFC 6749 scope-token`,
193
+ "authorizationServer({ scopes: ['cases:read'] }) # printable ASCII, no space, quote or backslash",
194
+ );
195
+ }
196
+ }
197
+ const lifetimes = options.lifetimes;
198
+ const device = grants.has(DEVICE_GRANT);
199
+ if (device && options.devicePath === undefined) {
200
+ throw invalid(
201
+ 'devicePath',
202
+ 'is missing while the device grant is enabled, so verification_uri would name no page',
203
+ "authorizationServer({ grants, devicePath: '/oauth/device' }) # the app's verification page",
204
+ );
205
+ }
206
+ const clock = options.clock ?? systemClock;
207
+ return {
208
+ issuer: options.issuer,
209
+ basePath: url.pathname === '/' ? '' : url.pathname,
210
+ origin: url.origin,
211
+ store: options.store,
212
+ consentPath: pagePath('consentPath', options.consentPath),
213
+ devicePath:
214
+ options.devicePath === undefined ? null : pagePath('devicePath', options.devicePath),
215
+ accessSeconds: lifetimeSeconds('lifetimes.accessSeconds', lifetimes.accessSeconds),
216
+ refreshIdleMs: lifetimeSeconds('lifetimes.refreshIdleDays', lifetimes.refreshIdleDays) * DAY_MS,
217
+ clientSeconds: grants.has('client_credentials')
218
+ ? lifetimeSeconds('lifetimes.clientSeconds', lifetimes.clientSeconds)
219
+ : null,
220
+ deviceSeconds: lifetimeSeconds('lifetimes.deviceSeconds', lifetimes.deviceSeconds ?? 600),
221
+ scopes: new Set(options.scopes),
222
+ resources: new Set((options.resources ?? []).map(resourceIndicator)),
223
+ defaultResource: defaultResourceOf(options),
224
+ dynamic: options.registration === 'dynamic',
225
+ grants,
226
+ clock,
227
+ limiter:
228
+ options.limiter ??
229
+ memoryAuthLimiter(clock, {
230
+ maxAttempts: 10,
231
+ windowMs: 60_000,
232
+ lockoutMs: 60_000,
233
+ scope: 'process',
234
+ }),
235
+ fetch: options.fetch,
236
+ resolveHost: options.resolveHost,
237
+ emit: options.onEvent ?? (() => {}),
238
+ };
239
+ }
@@ -0,0 +1,23 @@
1
+ // Single responsibility: the authorization server's coded refusals — the ones an APP's code meets
2
+ // (a consent approved for a request the server refuses, a grant it never enabled). What a remote
3
+ // client meets is RFC 6749 JSON on the wire (`authorization-server-wire.ts`), never an `X_` code.
4
+
5
+ import { renderCauseValue } from '@ultimat3/core';
6
+ import { AuthError } from './errors';
7
+
8
+ /** 422: the consent page approved what `readRequest` would have refused to show. */
9
+ export const consentInvalid = (reason: string): AuthError =>
10
+ new AuthError({
11
+ code: 'X_AUTH_CONSENT_INVALID',
12
+ cause: `the consent was refused: ${reason}`,
13
+ fix: 'server.readRequest(url) # on the consent page: render its refusal when it has one, and approve only the request it returned',
14
+ callerFix: 'start the sign-in again from the application that sent you here',
15
+ });
16
+
17
+ /** Logged, never thrown at a client: the wire answers `unsupported_grant_type`. */
18
+ export const grantDisabled = (grantType: string): AuthError =>
19
+ new AuthError({
20
+ code: 'X_AUTH_GRANT_DISABLED',
21
+ cause: `a client asked for grant_type ${renderCauseValue(grantType)}, which authorizationServer({ grants }) does not enable`,
22
+ fix: "authorizationServer({ grants: ['authorization_code', 'refresh_token', '<grant>'] }) # with its lifetime — or the client uses an enabled grant",
23
+ });
@@ -0,0 +1,107 @@
1
+ // The statements `postgresAuthServerStore` runs, spelled out so an operator can run the exact one a
2
+ // log line names. Every single-use transition is ONE conditional statement: the `where … is null`
3
+ // is re-evaluated after a lock wait, so two racing redemptions cannot both see a live row.
4
+
5
+ export const SQL_OAUTH_CLIENT_PUT = `
6
+ insert into x_oauth_clients
7
+ (client_id, name, secret_hash, auth_method, redirect_uris, grant_types, scopes, resources, created_at)
8
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
9
+ on conflict (client_id) do update set
10
+ name = excluded.name, secret_hash = excluded.secret_hash, auth_method = excluded.auth_method,
11
+ redirect_uris = excluded.redirect_uris, grant_types = excluded.grant_types,
12
+ scopes = excluded.scopes, resources = excluded.resources
13
+ `;
14
+
15
+ export const SQL_OAUTH_CLIENT_GET = 'select * from x_oauth_clients where client_id = $1';
16
+
17
+ export const SQL_OAUTH_CODE_PUT = `
18
+ insert into x_oauth_codes
19
+ (code_hash, grant_id, client_id, subject, scopes, resource, redirect_uri, code_challenge, expires_at)
20
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
21
+ `;
22
+
23
+ export const SQL_OAUTH_CODE_TAKE = `
24
+ update x_oauth_codes set used_at = $2 where code_hash = $1 and used_at is null returning *
25
+ `;
26
+
27
+ export const SQL_OAUTH_CODE_GET = 'select * from x_oauth_codes where code_hash = $1';
28
+
29
+ export const SQL_OAUTH_GRANT_PUT = `
30
+ insert into x_oauth_grants (id, client_id, subject, scopes, resource, created_at, refreshed_at)
31
+ values ($1, $2, $3, $4, $5, $6, $7)
32
+ `;
33
+
34
+ export const SQL_OAUTH_GRANT_GET = 'select * from x_oauth_grants where id = $1';
35
+
36
+ export const SQL_OAUTH_GRANT_TOUCH = 'update x_oauth_grants set refreshed_at = $2 where id = $1';
37
+
38
+ export const SQL_OAUTH_GRANT_REVOKE = `
39
+ update x_oauth_grants set revoked_at = $2 where id = $1 and revoked_at is null
40
+ `;
41
+
42
+ export const SQL_OAUTH_GRANTS_REVOKE = `
43
+ with revoked as (
44
+ update x_oauth_grants set revoked_at = $3
45
+ where subject = $1 and ($2::text is null or client_id = $2) and revoked_at is null
46
+ returning 1
47
+ )
48
+ select count(*)::int as n from revoked
49
+ `;
50
+
51
+ export const SQL_OAUTH_TOKEN_PUT = `
52
+ insert into x_oauth_tokens (token_hash, kind, grant_id, created_at, expires_at)
53
+ values ($1, $2, $3, $4, $5)
54
+ `;
55
+
56
+ export const SQL_OAUTH_TOKEN_GET = 'select * from x_oauth_tokens where token_hash = $1';
57
+
58
+ /** Retire the live one and insert its successor in ONE statement: no successor without the retire. */
59
+ export const SQL_OAUTH_REFRESH_ROTATE = `
60
+ with retired as (
61
+ update x_oauth_tokens set retired_at = $2
62
+ where token_hash = $1 and kind = 'refresh' and retired_at is null
63
+ returning grant_id
64
+ )
65
+ insert into x_oauth_tokens (token_hash, kind, grant_id, created_at, expires_at)
66
+ select $3, 'refresh', grant_id, $4, $5 from retired
67
+ returning grant_id
68
+ `;
69
+
70
+ export const SQL_OAUTH_DEVICE_PUT = `
71
+ insert into x_oauth_devices
72
+ (device_code_hash, user_code_hash, client_id, scopes, resource, interval_seconds, created_at, expires_at)
73
+ values ($1, $2, $3, $4, $5, $6, $7, $8)
74
+ `;
75
+
76
+ export const SQL_OAUTH_DEVICE_BY_USER_CODE =
77
+ 'select * from x_oauth_devices where user_code_hash = $1';
78
+
79
+ export const SQL_OAUTH_DEVICE_GET = 'select * from x_oauth_devices where device_code_hash = $1';
80
+
81
+ export const SQL_OAUTH_DEVICE_DECIDE = `
82
+ update x_oauth_devices set status = $2, subject = $3, granted_scopes = $4
83
+ where user_code_hash = $1 and status = 'pending' and expires_at > $5
84
+ returning *
85
+ `;
86
+
87
+ export const SQL_OAUTH_DEVICE_POLLED = `
88
+ update x_oauth_devices set last_polled_at = $2 where device_code_hash = $1
89
+ `;
90
+
91
+ export const SQL_OAUTH_DEVICE_CONSUME = `
92
+ update x_oauth_devices set status = 'consumed'
93
+ where device_code_hash = $1 and status = 'approved'
94
+ returning 1
95
+ `;
96
+
97
+ /** Four deletes in one statement; the count is everything that went. */
98
+ export const SQL_OAUTH_PURGE = `
99
+ with codes as (delete from x_oauth_codes where expires_at < $1 returning 1),
100
+ tokens as (delete from x_oauth_tokens where expires_at < $1 returning 1),
101
+ devices as (delete from x_oauth_devices where expires_at < $1 returning 1),
102
+ grants as (
103
+ delete from x_oauth_grants where revoked_at < $1 or refreshed_at < $2 returning 1
104
+ )
105
+ select ((select count(*) from codes) + (select count(*) from tokens)
106
+ + (select count(*) from devices) + (select count(*) from grants))::int as n
107
+ `;