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