@ultimat3/auth 29.4.0 → 30.1.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
@@ -0,0 +1,222 @@
1
+ // The shared authorization-server store: five tables over a structural `PgExecutor` (the pool is
2
+ // the host's). A fleet needs it — a code minted on one replica is redeemed on another — and every
3
+ // single-use rule is one conditional statement in `authorization-server-postgres-sql.ts`.
4
+
5
+ import type { PgExecutor } from '@ultimat3/core';
6
+ import * as SQL from './authorization-server-postgres-sql';
7
+ import type { AuthServerStore } from './authorization-server-store';
8
+ import type {
9
+ AuthCodeRecord,
10
+ AuthGrantRecord,
11
+ AuthServerClient,
12
+ AuthServerGrant,
13
+ AuthTokenRecord,
14
+ ClientAuthMethod,
15
+ DeviceCodeRecord,
16
+ DeviceStatus,
17
+ } from './authorization-server-types';
18
+
19
+ type Row = Readonly<Record<string, unknown>>;
20
+
21
+ const list = (value: unknown): readonly string[] =>
22
+ typeof value === 'string' && value !== '' ? value.split(' ') : [];
23
+ const join = (values: readonly string[]): string => values.join(' ');
24
+ const text = (value: unknown): string => (typeof value === 'string' ? value : String(value));
25
+ const textOrNull = (value: unknown): string | null => (value === null ? null : text(value));
26
+ /** Every Postgres client answers `timestamptz` as a `Date` or an ISO string; both read the same. */
27
+ const date = (value: unknown): Date => (value instanceof Date ? value : new Date(text(value)));
28
+ const dateOrNull = (value: unknown): Date | null => (value === null ? null : date(value));
29
+
30
+ const toClient = (row: Row): AuthServerClient => ({
31
+ clientId: text(row['client_id']),
32
+ name: text(row['name']),
33
+ secretHash: textOrNull(row['secret_hash']),
34
+ authMethod: text(row['auth_method']) as ClientAuthMethod,
35
+ redirectUris: list(row['redirect_uris']),
36
+ grantTypes: list(row['grant_types']) as readonly AuthServerGrant[],
37
+ scopes: list(row['scopes']),
38
+ resources: list(row['resources']),
39
+ createdAt: date(row['created_at']),
40
+ });
41
+
42
+ const toCode = (row: Row): AuthCodeRecord => ({
43
+ codeHash: text(row['code_hash']),
44
+ grantId: text(row['grant_id']),
45
+ clientId: text(row['client_id']),
46
+ subject: text(row['subject']),
47
+ scopes: list(row['scopes']),
48
+ resource: textOrNull(row['resource']),
49
+ redirectUri: text(row['redirect_uri']),
50
+ codeChallenge: text(row['code_challenge']),
51
+ expiresAt: date(row['expires_at']),
52
+ usedAt: dateOrNull(row['used_at']),
53
+ });
54
+
55
+ const toGrant = (row: Row): AuthGrantRecord => ({
56
+ id: text(row['id']),
57
+ clientId: text(row['client_id']),
58
+ subject: text(row['subject']),
59
+ scopes: list(row['scopes']),
60
+ resource: textOrNull(row['resource']),
61
+ createdAt: date(row['created_at']),
62
+ refreshedAt: date(row['refreshed_at']),
63
+ revokedAt: dateOrNull(row['revoked_at']),
64
+ });
65
+
66
+ const toToken = (row: Row): AuthTokenRecord => ({
67
+ tokenHash: text(row['token_hash']),
68
+ kind: text(row['kind']) === 'refresh' ? 'refresh' : 'access',
69
+ grantId: text(row['grant_id']),
70
+ createdAt: date(row['created_at']),
71
+ expiresAt: date(row['expires_at']),
72
+ retiredAt: dateOrNull(row['retired_at']),
73
+ });
74
+
75
+ const STATUSES: ReadonlySet<string> = new Set<DeviceStatus>([
76
+ 'pending',
77
+ 'approved',
78
+ 'denied',
79
+ 'consumed',
80
+ ]);
81
+
82
+ const toDevice = (row: Row): DeviceCodeRecord => ({
83
+ deviceCodeHash: text(row['device_code_hash']),
84
+ userCodeHash: text(row['user_code_hash']),
85
+ clientId: text(row['client_id']),
86
+ scopes: list(row['scopes']),
87
+ resource: textOrNull(row['resource']),
88
+ // A status this file never writes is a hand-edited row: read as `denied`, which mints nothing.
89
+ status: STATUSES.has(text(row['status'])) ? (text(row['status']) as DeviceStatus) : 'denied',
90
+ subject: textOrNull(row['subject']),
91
+ grantedScopes: row['granted_scopes'] === null ? null : list(row['granted_scopes']),
92
+ intervalSeconds: Number(row['interval_seconds']),
93
+ lastPolledAt: dateOrNull(row['last_polled_at']),
94
+ createdAt: date(row['created_at']),
95
+ expiresAt: date(row['expires_at']),
96
+ });
97
+
98
+ export interface PostgresAuthServerStoreOptions {
99
+ /** Read on every call, so a declaration evaluated before the boot opened the pool still works. */
100
+ readonly executor: PgExecutor;
101
+ }
102
+
103
+ export function postgresAuthServerStore(options: PostgresAuthServerStoreOptions): AuthServerStore {
104
+ const exec = options.executor;
105
+ const rows = (sql: string, params: readonly unknown[]) => exec.query<Row>(sql, params);
106
+ const first = async <T>(sql: string, params: readonly unknown[], map: (row: Row) => T) => {
107
+ const row = (await rows(sql, params))[0];
108
+ return row === undefined ? null : map(row);
109
+ };
110
+ const count = async (sql: string, params: readonly unknown[]): Promise<number> =>
111
+ Number((await rows(sql, params))[0]?.['n'] ?? 0);
112
+ return {
113
+ async putClient(c) {
114
+ await rows(SQL.SQL_OAUTH_CLIENT_PUT, [
115
+ c.clientId,
116
+ c.name,
117
+ c.secretHash,
118
+ c.authMethod,
119
+ join(c.redirectUris),
120
+ join(c.grantTypes),
121
+ join(c.scopes),
122
+ join(c.resources),
123
+ c.createdAt,
124
+ ]);
125
+ },
126
+ getClient: (id) => first(SQL.SQL_OAUTH_CLIENT_GET, [id], toClient),
127
+ async putCode(c) {
128
+ await rows(SQL.SQL_OAUTH_CODE_PUT, [
129
+ c.codeHash,
130
+ c.grantId,
131
+ c.clientId,
132
+ c.subject,
133
+ join(c.scopes),
134
+ c.resource,
135
+ c.redirectUri,
136
+ c.codeChallenge,
137
+ c.expiresAt,
138
+ ]);
139
+ },
140
+ async takeCode(hash, at) {
141
+ const taken = await first(SQL.SQL_OAUTH_CODE_TAKE, [hash, at], toCode);
142
+ if (taken !== null) return { code: { ...taken, usedAt: null }, fresh: true };
143
+ const spent = await first(SQL.SQL_OAUTH_CODE_GET, [hash], toCode);
144
+ return spent === null ? null : { code: spent, fresh: false };
145
+ },
146
+ async putGrant(g) {
147
+ await rows(SQL.SQL_OAUTH_GRANT_PUT, [
148
+ g.id,
149
+ g.clientId,
150
+ g.subject,
151
+ join(g.scopes),
152
+ g.resource,
153
+ g.createdAt,
154
+ g.refreshedAt,
155
+ ]);
156
+ },
157
+ getGrant: (id) => first(SQL.SQL_OAUTH_GRANT_GET, [id], toGrant),
158
+ async touchGrant(id, at) {
159
+ await rows(SQL.SQL_OAUTH_GRANT_TOUCH, [id, at]);
160
+ },
161
+ async revokeGrant(id, at) {
162
+ await rows(SQL.SQL_OAUTH_GRANT_REVOKE, [id, at]);
163
+ },
164
+ revokeGrants: (filter, at) =>
165
+ count(SQL.SQL_OAUTH_GRANTS_REVOKE, [filter.subject, filter.clientId ?? null, at]),
166
+ async putToken(t) {
167
+ await rows(SQL.SQL_OAUTH_TOKEN_PUT, [
168
+ t.tokenHash,
169
+ t.kind,
170
+ t.grantId,
171
+ t.createdAt,
172
+ t.expiresAt,
173
+ ]);
174
+ },
175
+ getToken: (hash) => first(SQL.SQL_OAUTH_TOKEN_GET, [hash], toToken),
176
+ async rotateRefresh(hash, next, at) {
177
+ const rotated = await rows(SQL.SQL_OAUTH_REFRESH_ROTATE, [
178
+ hash,
179
+ at,
180
+ next.tokenHash,
181
+ next.createdAt,
182
+ next.expiresAt,
183
+ ]);
184
+ const grantId = rotated[0]?.['grant_id'];
185
+ if (grantId !== undefined) return { outcome: 'rotated', grantId: text(grantId) };
186
+ const held = await first(SQL.SQL_OAUTH_TOKEN_GET, [hash], toToken);
187
+ if (held === null || held.kind !== 'refresh') return { outcome: 'unknown' };
188
+ return { outcome: 'replayed', grantId: held.grantId };
189
+ },
190
+ async putDevice(d) {
191
+ await rows(SQL.SQL_OAUTH_DEVICE_PUT, [
192
+ d.deviceCodeHash,
193
+ d.userCodeHash,
194
+ d.clientId,
195
+ join(d.scopes),
196
+ d.resource,
197
+ d.intervalSeconds,
198
+ d.createdAt,
199
+ d.expiresAt,
200
+ ]);
201
+ },
202
+ deviceByUserCode: (hash) => first(SQL.SQL_OAUTH_DEVICE_BY_USER_CODE, [hash], toDevice),
203
+ decideDevice: (hash, decision, at) =>
204
+ first(
205
+ SQL.SQL_OAUTH_DEVICE_DECIDE,
206
+ decision.status === 'approved'
207
+ ? [hash, 'approved', decision.subject, join(decision.scopes), at]
208
+ : [hash, 'denied', null, null, at],
209
+ toDevice,
210
+ ),
211
+ async pollDevice(hash, at) {
212
+ // Two polls racing may both read the old stamp: harmless, `consumeDevice` is the single use.
213
+ 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
+ return device;
216
+ },
217
+ async consumeDevice(hash) {
218
+ return (await rows(SQL.SQL_OAUTH_DEVICE_CONSUME, [hash])).length === 1;
219
+ },
220
+ purge: (before, idleBefore) => count(SQL.SQL_OAUTH_PURGE, [before, idleBefore]),
221
+ };
222
+ }
@@ -0,0 +1,98 @@
1
+ // Single responsibility: RFC 7591 dynamic client registration, for the MCP clients that still use
2
+ // it — on only under `registration: 'dynamic'`, counted per address. Never a machine client: an
3
+ // open endpoint handing out `client_credentials` would let anyone mint itself a scope.
4
+
5
+ import { isRedirectUri, storeClient } from './authorization-server-clients';
6
+ import type { ResolvedAuthServerConfig } from './authorization-server-config';
7
+ import type { AuthServerGrant, ClientAuthMethod } from './authorization-server-types';
8
+ import { DEVICE_GRANT } from './authorization-server-types';
9
+ import type { AuthServerHttpContext, AuthServerHttpRequest } from './authorization-server-wire';
10
+ import { jsonAnswer, throttledAnswer } from './authorization-server-wire';
11
+ import { isRecord } from './json';
12
+
13
+ const SELF_SERVICE_GRANTS: ReadonlySet<string> = new Set<AuthServerGrant>([
14
+ 'authorization_code',
15
+ 'refresh_token',
16
+ DEVICE_GRANT,
17
+ ]);
18
+ const METHODS: ReadonlySet<string> = new Set<ClientAuthMethod>([
19
+ 'none',
20
+ 'client_secret_basic',
21
+ 'client_secret_post',
22
+ ]);
23
+
24
+ const metadataError = (error: string, description: string): Response =>
25
+ jsonAnswer(400, { error, error_description: description });
26
+
27
+ const stringList = (value: unknown): readonly string[] | null =>
28
+ Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : null;
29
+
30
+ export async function registerHandler(
31
+ config: ResolvedAuthServerConfig,
32
+ request: AuthServerHttpRequest,
33
+ ctx: AuthServerHttpContext,
34
+ ): Promise<Response> {
35
+ try {
36
+ await config.limiter.reserve(`oauth-register:${ctx.ip ?? 'unknown'}`);
37
+ } catch (error) {
38
+ const answer = throttledAnswer(error, 'too many registrations');
39
+ if (answer === null) throw error;
40
+ return answer;
41
+ }
42
+ let body: unknown;
43
+ try {
44
+ body = JSON.parse(new TextDecoder().decode(await request.bodyBytes()));
45
+ } catch {
46
+ return metadataError('invalid_client_metadata', 'the body is not JSON');
47
+ }
48
+ if (!isRecord(body)) return metadataError('invalid_client_metadata', 'the body is not an object');
49
+ const grants = stringList(body['grant_types'] ?? ['authorization_code', 'refresh_token']);
50
+ if (
51
+ grants === null ||
52
+ !grants.every((g) => SELF_SERVICE_GRANTS.has(g) && config.grants.has(g as AuthServerGrant))
53
+ ) {
54
+ return metadataError(
55
+ 'invalid_client_metadata',
56
+ 'a grant_type is not one this server registers',
57
+ );
58
+ }
59
+ const redirects = stringList(body['redirect_uris'] ?? []);
60
+ const needsRedirect = grants.includes('authorization_code');
61
+ if (
62
+ redirects === null ||
63
+ (needsRedirect && redirects.length === 0) ||
64
+ !redirects.every(isRedirectUri)
65
+ ) {
66
+ return metadataError(
67
+ 'invalid_redirect_uri',
68
+ 'redirect_uris must be https, or http on loopback',
69
+ );
70
+ }
71
+ const method = body['token_endpoint_auth_method'] ?? 'none';
72
+ if (typeof method !== 'string' || !METHODS.has(method)) {
73
+ return metadataError('invalid_client_metadata', 'token_endpoint_auth_method is not supported');
74
+ }
75
+ const scope = body['scope'];
76
+ const scopes = typeof scope === 'string' ? scope.split(' ').filter((s) => s !== '') : [];
77
+ if (!scopes.every((s) => config.scopes.has(s))) {
78
+ return metadataError('invalid_client_metadata', 'a scope is not one this server grants');
79
+ }
80
+ const name = body['client_name'];
81
+ const { client, clientSecret } = await storeClient(config, {
82
+ name: typeof name === 'string' && name !== '' ? name.slice(0, 200) : 'unnamed client',
83
+ authMethod: method as ClientAuthMethod,
84
+ redirectUris: redirects,
85
+ grantTypes: grants as readonly AuthServerGrant[],
86
+ scopes,
87
+ });
88
+ return jsonAnswer(201, {
89
+ client_id: client.clientId,
90
+ ...(clientSecret === null ? {} : { client_secret: clientSecret, client_secret_expires_at: 0 }),
91
+ client_id_issued_at: Math.floor(client.createdAt.getTime() / 1000),
92
+ client_name: client.name,
93
+ redirect_uris: client.redirectUris,
94
+ grant_types: client.grantTypes,
95
+ token_endpoint_auth_method: client.authMethod,
96
+ ...(scopes.length === 0 ? {} : { scope: scopes.join(' ') }),
97
+ });
98
+ }
@@ -0,0 +1,126 @@
1
+ // Single responsibility: screen an authorization request (RFC 6749 §4.1.1 + PKCE + RFC 8707) into
2
+ // what a consent page may show, or the refusal it must show instead. Pure over the client it is
3
+ // handed. An unknown client or an unregistered redirect is SHOWN and never redirected to
4
+ // (RFC 6749 §4.1.2.1); every later refusal goes back to the registered redirect with `iss`.
5
+
6
+ import type { ResolvedAuthServerConfig } from './authorization-server-config';
7
+ import type { AuthorizationRequest, AuthServerClient } from './authorization-server-types';
8
+
9
+ export type ReadRequestResult =
10
+ | { readonly ok: true; readonly request: AuthorizationRequest }
11
+ | {
12
+ readonly ok: false;
13
+ readonly error: string;
14
+ readonly description: string;
15
+ /** Where the browser goes with the error; `null` = render it, redirect nowhere. */
16
+ readonly redirectTo: string | null;
17
+ };
18
+
19
+ /** RFC 7636 §4.2: 43–128 characters of the unreserved set, base64url of a SHA-256 in practice. */
20
+ const CODE_CHALLENGE = /^[A-Za-z0-9\-._~]{43,128}$/;
21
+
22
+ /** `redirectUri` plus the params, an existing query kept (RFC 6749 §3.1.2). */
23
+ export function redirectWith(
24
+ redirectUri: string,
25
+ params: Readonly<Record<string, string>>,
26
+ ): string {
27
+ const url = new URL(redirectUri);
28
+ for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
29
+ return url.toString();
30
+ }
31
+
32
+ /** One value per parameter, or the refusal: RFC 6749 §3.1 forbids a repeated one. */
33
+ export function singleParams(params: URLSearchParams): Map<string, string> | string {
34
+ const single = new Map<string, string>();
35
+ for (const [key, value] of params) {
36
+ if (single.has(key)) return key;
37
+ single.set(key, value);
38
+ }
39
+ return single;
40
+ }
41
+
42
+ /** The requested scopes narrowed to what the server and the client allow, or `null` = refused. */
43
+ export function screenScopes(
44
+ config: ResolvedAuthServerConfig,
45
+ client: AuthServerClient,
46
+ requested: string | undefined,
47
+ ): readonly string[] | null {
48
+ const allowed = (scope: string): boolean =>
49
+ config.scopes.has(scope) && (client.scopes.length === 0 || client.scopes.includes(scope));
50
+ if (requested === undefined || requested.trim() === '') {
51
+ const fallback = client.scopes.length > 0 ? client.scopes : [...config.scopes];
52
+ return fallback.filter(allowed);
53
+ }
54
+ const asked = [...new Set(requested.split(' ').filter((scope) => scope !== ''))];
55
+ return asked.every(allowed) ? asked : null;
56
+ }
57
+
58
+ /** RFC 8707: none (the default resource, else an unbound grant), or one this server declared. */
59
+ export function screenResource(
60
+ config: ResolvedAuthServerConfig,
61
+ resource: string | undefined,
62
+ ): string | null | false {
63
+ if (resource === undefined) return config.defaultResource;
64
+ return config.resources.has(resource) ? resource : false;
65
+ }
66
+
67
+ export function screenAuthorization(
68
+ config: ResolvedAuthServerConfig,
69
+ client: AuthServerClient | null,
70
+ params: URLSearchParams,
71
+ ): ReadRequestResult {
72
+ const single = singleParams(params);
73
+ const shown = (error: string, description: string): ReadRequestResult => ({
74
+ ok: false,
75
+ error,
76
+ description,
77
+ redirectTo: null,
78
+ });
79
+ if (typeof single === 'string') {
80
+ return shown('invalid_request', `the ${single} parameter is repeated`);
81
+ }
82
+ if (client === null) return shown('invalid_client', 'the client is not known to this server');
83
+ const redirectUri = single.get('redirect_uri');
84
+ // Byte for byte: no normalisation, no prefix, no trailing-slash leniency. Not a secret, so `===`.
85
+ if (redirectUri === undefined || !client.redirectUris.includes(redirectUri)) {
86
+ return shown('invalid_request', 'the redirect_uri is not one this client registered');
87
+ }
88
+ const state = single.get('state') ?? null;
89
+ const back = (error: string, description: string): ReadRequestResult => ({
90
+ ok: false,
91
+ error,
92
+ description,
93
+ redirectTo: redirectWith(redirectUri, {
94
+ error,
95
+ error_description: description,
96
+ iss: config.issuer,
97
+ ...(state === null ? {} : { state }),
98
+ }),
99
+ });
100
+ if (single.get('response_type') !== 'code') {
101
+ return back('unsupported_response_type', 'only response_type=code is supported');
102
+ }
103
+ if (!client.grantTypes.includes('authorization_code')) {
104
+ return back('unauthorized_client', 'this client may not use the authorization code grant');
105
+ }
106
+ const challenge = single.get('code_challenge');
107
+ if (challenge === undefined || !CODE_CHALLENGE.test(challenge)) {
108
+ return back('invalid_request', 'a PKCE code_challenge is required');
109
+ }
110
+ // S256 only, and an absent method is `plain` per RFC 7636 §4.3 — refused, not defaulted.
111
+ if (single.get('code_challenge_method') !== 'S256') {
112
+ return back('invalid_request', 'code_challenge_method must be S256');
113
+ }
114
+ const scopes = screenScopes(config, client, single.get('scope'));
115
+ if (scopes === null || scopes.length === 0) {
116
+ return back('invalid_scope', 'a requested scope is not one this client may be granted');
117
+ }
118
+ const resource = screenResource(config, single.get('resource'));
119
+ if (resource === false) {
120
+ return back('invalid_target', 'the resource is not one this server issues tokens for');
121
+ }
122
+ return {
123
+ ok: true,
124
+ request: { client, redirectUri, scopes, resource, state, codeChallenge: challenge },
125
+ };
126
+ }
@@ -0,0 +1,181 @@
1
+ // Single responsibility: the server's wire, as plain routes an app spreads into `runtime.routes` —
2
+ // the RFC 8414 metadata document, `POST /oauth/token` (every enabled grant), `/oauth/revoke`,
3
+ // `/oauth/introspect`, and, when enabled, `/oauth/register` and `/oauth/device`. Each answers
4
+ // RFC 6749 `{ error, error_description }`; the `X_` code a refusal is logged under stays off it.
5
+
6
+ import { logger } from '@ultimat3/core';
7
+ import { type ClientDirectory, clientAuthenticates } from './authorization-server-clients';
8
+ import type { ResolvedAuthServerConfig } from './authorization-server-config';
9
+ import { grantDisabled } from './authorization-server-errors';
10
+ import { registerHandler } from './authorization-server-register';
11
+ import { refused, type TokenOutcome } from './authorization-server-tokens';
12
+ import type { AuthServerClient, AuthServerGrant } from './authorization-server-types';
13
+ import { DEVICE_GRANT } from './authorization-server-types';
14
+ import {
15
+ type AuthServerHttpContext,
16
+ type AuthServerHttpRequest,
17
+ type AuthServerRoute,
18
+ jsonAnswer,
19
+ outcomeAnswer,
20
+ readClientCredentials,
21
+ readForm,
22
+ throttledAnswer,
23
+ } from './authorization-server-wire';
24
+ import { exchangeCode, exchangeRefresh } from './grant-authorization-code';
25
+ import { exchangeClientCredentials } from './grant-client-credentials';
26
+ import { pollDevice, startDevice } from './grant-device';
27
+ import { introspectToken, revokeToken } from './token-endpoints';
28
+
29
+ type ClientStep = (
30
+ config: ResolvedAuthServerConfig,
31
+ form: ReadonlyMap<string, string>,
32
+ client: AuthServerClient,
33
+ ) => Promise<TokenOutcome>;
34
+
35
+ const GRANT_STEPS = new Map<AuthServerGrant, ClientStep>([
36
+ ['authorization_code', exchangeCode],
37
+ ['refresh_token', exchangeRefresh],
38
+ ['client_credentials', exchangeClientCredentials],
39
+ [DEVICE_GRANT, pollDevice],
40
+ ]);
41
+
42
+ export const AUTH_SERVER_METADATA_WELL_KNOWN = '/.well-known/oauth-authorization-server';
43
+
44
+ export function authServerMetadata(config: ResolvedAuthServerConfig): Record<string, unknown> {
45
+ const at = (path: string): string => `${config.origin}${config.basePath}${path}`;
46
+ const methods = ['none', 'client_secret_basic', 'client_secret_post'];
47
+ return {
48
+ issuer: config.issuer,
49
+ authorization_endpoint: `${config.origin}${config.consentPath}`,
50
+ token_endpoint: at('/oauth/token'),
51
+ revocation_endpoint: at('/oauth/revoke'),
52
+ introspection_endpoint: at('/oauth/introspect'),
53
+ ...(config.dynamic ? { registration_endpoint: at('/oauth/register') } : {}),
54
+ ...(config.grants.has(DEVICE_GRANT)
55
+ ? { device_authorization_endpoint: at('/oauth/device') }
56
+ : {}),
57
+ response_types_supported: ['code'],
58
+ response_modes_supported: ['query'],
59
+ grant_types_supported: [...config.grants],
60
+ code_challenge_methods_supported: ['S256'],
61
+ token_endpoint_auth_methods_supported: methods,
62
+ revocation_endpoint_auth_methods_supported: methods,
63
+ introspection_endpoint_auth_methods_supported: methods.slice(1),
64
+ scopes_supported: [...config.scopes].sort(),
65
+ authorization_response_iss_parameter_supported: true,
66
+ client_id_metadata_document_supported: true,
67
+ };
68
+ }
69
+
70
+ /** Form → authenticated client → the step. Every refusal is logged once, the `error` only. */
71
+ function clientRoute(
72
+ config: ResolvedAuthServerConfig,
73
+ directory: ClientDirectory,
74
+ name: string,
75
+ pick: (form: ReadonlyMap<string, string>) => ClientStep | TokenOutcome,
76
+ success = 200,
77
+ ): AuthServerRoute['handler'] {
78
+ return async (request: AuthServerHttpRequest, ctx: AuthServerHttpContext) => {
79
+ const form = await readForm(request);
80
+ let outcome: TokenOutcome;
81
+ try {
82
+ outcome = await (async (): Promise<TokenOutcome> => {
83
+ if (!(form instanceof Map)) return refused('invalid_request', form.refused);
84
+ const credentials = readClientCredentials(request, form);
85
+ if ('refused' in credentials) return refused('invalid_request', credentials.refused);
86
+ // Before the client is authenticated, so the address is the only key there is.
87
+ const client = await directory.find(credentials.clientId, ctx.ip ?? 'unknown');
88
+ if (client === null || !clientAuthenticates(client, credentials)) {
89
+ return refused('invalid_client', 'client authentication failed', 401);
90
+ }
91
+ const step = pick(form);
92
+ return typeof step === 'function' ? step(config, form, client) : step;
93
+ })();
94
+ } catch (error) {
95
+ const answer = throttledAnswer(error, 'too many client metadata fetches');
96
+ if (answer === null) throw error;
97
+ logger.info('auth.server.refused', { endpoint: name, error: 'temporarily_unavailable' });
98
+ config.emit({ kind: 'refused', error: 'temporarily_unavailable', grantType: name });
99
+ return answer;
100
+ }
101
+ if (outcome.ok) {
102
+ config.emit({ kind: 'issued', grantType: name });
103
+ } else {
104
+ logger.info('auth.server.refused', { endpoint: name, error: outcome.error });
105
+ config.emit({ kind: 'refused', error: outcome.error, grantType: name });
106
+ }
107
+ return outcomeAnswer(outcome, success);
108
+ };
109
+ }
110
+
111
+ function tokenStep(config: ResolvedAuthServerConfig) {
112
+ return (form: ReadonlyMap<string, string>): ClientStep | TokenOutcome => {
113
+ const grantType = form.get('grant_type');
114
+ if (grantType === undefined) return refused('invalid_request', 'grant_type is required');
115
+ const step = GRANT_STEPS.get(grantType as AuthServerGrant);
116
+ if (step === undefined || !config.grants.has(grantType as AuthServerGrant)) {
117
+ const error = grantDisabled(grantType);
118
+ logger.warn('auth.server.grant_disabled', { code: error.code, cause: error.cause });
119
+ config.emit({ kind: 'grant-disabled', grantType });
120
+ return refused('unsupported_grant_type', 'this server does not enable that grant');
121
+ }
122
+ return step;
123
+ };
124
+ }
125
+
126
+ export function authServerRoutes(
127
+ config: ResolvedAuthServerConfig,
128
+ directory: ClientDirectory,
129
+ ): readonly AuthServerRoute[] {
130
+ const base = config.basePath;
131
+ const post = (
132
+ path: string,
133
+ name: string,
134
+ handler: AuthServerRoute['handler'],
135
+ ): AuthServerRoute => ({
136
+ method: 'POST',
137
+ path: `${base}${path}`,
138
+ handler,
139
+ // Cookie-less and client-authenticated or PKCE-bound: CSRF has nothing to protect here.
140
+ meta: { name: `oauth:${name}`, auth: 'public', csrf: 'cookieless', authenticate: () => null },
141
+ });
142
+ const document = authServerMetadata(config);
143
+ const routes: AuthServerRoute[] = [
144
+ {
145
+ method: 'GET',
146
+ path: `${AUTH_SERVER_METADATA_WELL_KNOWN}${base}`,
147
+ handler: async () =>
148
+ jsonAnswer(200, document, {
149
+ 'access-control-allow-origin': '*',
150
+ 'cache-control': 'max-age=300',
151
+ }),
152
+ meta: { name: 'oauth:metadata', auth: 'public' },
153
+ },
154
+ post('/oauth/token', 'token', clientRoute(config, directory, 'token', tokenStep(config))),
155
+ post(
156
+ '/oauth/revoke',
157
+ 'revoke',
158
+ clientRoute(config, directory, 'revoke', () => revokeToken),
159
+ ),
160
+ post(
161
+ '/oauth/introspect',
162
+ 'introspect',
163
+ clientRoute(config, directory, 'introspect', () => introspectToken),
164
+ ),
165
+ ];
166
+ if (config.dynamic) {
167
+ routes.push(
168
+ post('/oauth/register', 'register', (request, ctx) => registerHandler(config, request, ctx)),
169
+ );
170
+ }
171
+ if (config.grants.has(DEVICE_GRANT)) {
172
+ routes.push(
173
+ post(
174
+ '/oauth/device',
175
+ 'device',
176
+ clientRoute(config, directory, 'device', () => startDevice),
177
+ ),
178
+ );
179
+ }
180
+ return routes;
181
+ }