@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.
- package/CLAUDE.md +46 -58
- package/README.md +162 -9
- package/package.json +20 -5
- package/src/adapter.ts +88 -2
- package/src/api-key-resolver.ts +3 -3
- package/src/auth.ts +29 -7
- package/src/authorization-server-clients.ts +169 -0
- package/src/authorization-server-config.ts +239 -0
- package/src/authorization-server-errors.ts +23 -0
- package/src/authorization-server-postgres-sql.ts +107 -0
- package/src/authorization-server-postgres.ts +222 -0
- package/src/authorization-server-register.ts +98 -0
- package/src/authorization-server-request.ts +126 -0
- package/src/authorization-server-routes.ts +181 -0
- package/src/authorization-server-schema.ts +82 -0
- package/src/authorization-server-store.ts +189 -0
- package/src/authorization-server-tokens.ts +118 -0
- package/src/authorization-server-types.ts +108 -0
- package/src/authorization-server-wire.ts +135 -0
- package/src/authorization-server.ts +178 -0
- package/src/base64url.ts +40 -0
- package/src/builtin-adapter-violation.ts +12 -9
- package/src/builtin-adapter.ts +31 -5
- package/src/cbor-encode.ts +52 -0
- package/src/cbor.ts +129 -0
- package/src/client-scope.ts +2 -5
- package/src/client.ts +16 -0
- package/src/cose-key.ts +129 -0
- package/src/errors.ts +51 -0
- package/src/grant-authorization-code.ts +113 -0
- package/src/grant-client-credentials.ts +55 -0
- package/src/grant-device.ts +188 -0
- package/src/index.ts +108 -3
- package/src/jwks.ts +81 -12
- package/src/jwt-bearer-resolver.ts +80 -0
- package/src/limiter-install.ts +31 -2
- package/src/memory-adapter.ts +18 -0
- package/src/mfa-challenge.ts +13 -7
- package/src/mfa-replay-postgres.ts +71 -0
- package/src/mfa.ts +24 -13
- package/src/oauth-bearer-resolver.ts +44 -0
- package/src/oauth-cookie.ts +2 -1
- package/src/oauth-discovery.ts +17 -6
- package/src/oauth-exchange.ts +23 -12
- package/src/oauth-profile.ts +20 -9
- package/src/oauth-route.ts +5 -5
- package/src/passkey-assertion.ts +60 -0
- package/src/passkey-challenge.ts +59 -0
- package/src/passkey-client.ts +156 -0
- package/src/passkey-config.ts +92 -0
- package/src/passkey-errors.ts +89 -0
- package/src/passkey-evidence.ts +74 -0
- package/src/passkey-login.ts +127 -0
- package/src/passkey-memory.ts +77 -0
- package/src/passkey-postgres.ts +155 -0
- package/src/passkey-step-up.ts +135 -0
- package/src/passkey-verify.ts +180 -0
- package/src/passkey-wire.ts +61 -0
- package/src/soft-authenticator.ts +208 -0
- package/src/subject-target.ts +73 -0
- package/src/tables.ts +46 -0
- package/src/token-endpoints.ts +61 -0
- package/src/tokens.ts +2 -25
- package/src/verification-purposes.ts +61 -0
- package/src/verify.ts +86 -12
- package/src/webauthn-data.ts +111 -0
- package/src/workload.ts +67 -15
package/src/api-key-resolver.ts
CHANGED
|
@@ -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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
|
|
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,
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
|
|
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
|
+
`;
|