@ever-works/oidc-identity 1.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/dist/index.cjs +3303 -0
- package/dist/index.d.cts +1468 -0
- package/dist/index.d.ts +1468 -0
- package/dist/index.js +3244 -0
- package/dist/testing/fake-oidc-provider.cjs +651 -0
- package/dist/testing/fake-oidc-provider.d.cts +321 -0
- package/dist/testing/fake-oidc-provider.d.ts +321 -0
- package/dist/testing/fake-oidc-provider.js +622 -0
- package/package.json +88 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,1468 @@
|
|
|
1
|
+
import { IdentityTokenRejectionCode, JsonSchema, IPlugin, IIdentityProviderPlugin, PluginCategory, PluginContext, IdentityProviderCheck, VerifiedIdTokenClaims, VerifiedAccessTokenClaims, VerifiedLogoutTokenClaims, PluginHealthCheck, PluginManifest } from '@ever-works/plugin';
|
|
2
|
+
import { JWSHeaderParameters, CryptoKey, KeyObject } from 'jose';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* APW-12 T6 — the discovery half of the `identity-provider` capability: reading
|
|
6
|
+
* the provider's OpenID Connect discovery document, and the outbound rule every
|
|
7
|
+
* call to Ever ID obeys.
|
|
8
|
+
*
|
|
9
|
+
* Spec: FR-3 (what **Test connection** reads and reports), FR-14 (the discovery
|
|
10
|
+
* document is cached for 3,600 seconds; a changed issuer turns sign-in off until
|
|
11
|
+
* an administrator re-tests), FR-15 (5,000 ms timeout; discovery and key fetches
|
|
12
|
+
* retry once after 1,000 ms) and FR-16 (nothing here may reach a log with token
|
|
13
|
+
* material in it). Plan §4.3 is the table these numbers come from.
|
|
14
|
+
*
|
|
15
|
+
* ## Why this reads the document itself instead of `openid-client.discovery()`
|
|
16
|
+
*
|
|
17
|
+
* Plan §4.2 names `openid-client` as the flow's tool, and it stays that: T7 and
|
|
18
|
+
* T8 build the `Configuration` (`buildAuthorizationUrl`,
|
|
19
|
+
* `authorizationCodeGrant`) from the document this module read. The diagnostic
|
|
20
|
+
* read cannot be that call, for two measured reasons:
|
|
21
|
+
*
|
|
22
|
+
* 1. **FR-3 wants a row per check, not an exception.** `openid-client`'s
|
|
23
|
+
* `discovery()` rejects when the discovered `issuer` differs from the
|
|
24
|
+
* address it fetched (`build/index.js` — "discovered metadata issuer does
|
|
25
|
+
* not match the expected issuer"), and that rejection is precisely the
|
|
26
|
+
* `issuerMatch` row FR-3 asks **Test connection** to render. A call that
|
|
27
|
+
* throws cannot report "the endpoints are present but S256 is missing"
|
|
28
|
+
* either: it is all-or-nothing.
|
|
29
|
+
* 2. **FR-15's retry and testability.** `discovery()` bounds itself with
|
|
30
|
+
* `AbortSignal.timeout()` — a native timer a fake clock cannot drive — and
|
|
31
|
+
* performs no retry; §4.3 asks for one retry after 1,000 ms.
|
|
32
|
+
*
|
|
33
|
+
* Both reasons are about the *diagnostic* path only. Nothing here duplicates a
|
|
34
|
+
* security decision: the document this module returns is the document T7/T8 feed
|
|
35
|
+
* to `openid-client`, and the numbers below are the same §4.3 numbers.
|
|
36
|
+
*
|
|
37
|
+
* ## The numbers, and the one place they come from
|
|
38
|
+
*
|
|
39
|
+
* The constants below mirror `EVER_ID_LIMITS` in
|
|
40
|
+
* `packages/contracts/src/apps/ever-id.ts` — plan §4.3's single source. This
|
|
41
|
+
* package does not depend on `@ever-works/contracts` (T5 transcribed the
|
|
42
|
+
* settings-schema bounds the same way and said so), so the values are
|
|
43
|
+
* transcribed here rather than imported, and
|
|
44
|
+
* `src/__tests__/test-connection.spec.ts` reads the contracts source off disk and
|
|
45
|
+
* asserts the two agree, so a drift on either side reddens a test instead of
|
|
46
|
+
* shipping.
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* FR-15 — every call to Ever ID times out after 5,000 ms.
|
|
50
|
+
*
|
|
51
|
+
* This is the per-attempt bound. For **Test connection** it is also the bound on
|
|
52
|
+
* the whole run (FR-3, ACC-12-03): `testConnection` passes a deadline so the
|
|
53
|
+
* retry can never push the answer past 5 seconds.
|
|
54
|
+
*/
|
|
55
|
+
declare const OIDC_OUTBOUND_TIMEOUT_MS = 5000;
|
|
56
|
+
/** FR-15 — discovery and key fetches retry **once** after 1,000 ms. */
|
|
57
|
+
declare const OIDC_OUTBOUND_RETRY_DELAY_MS = 1000;
|
|
58
|
+
/** FR-15 — "retry once": two attempts, never three. */
|
|
59
|
+
declare const OIDC_OUTBOUND_MAX_ATTEMPTS = 2;
|
|
60
|
+
/** FR-14 — the discovery document is cached for 3,600 seconds. */
|
|
61
|
+
declare const OIDC_DISCOVERY_CACHE_SECONDS = 3600;
|
|
62
|
+
/** FR-13 — signing keys are cached for 600 seconds. */
|
|
63
|
+
declare const OIDC_JWKS_CACHE_SECONDS = 600;
|
|
64
|
+
/** FR-13 — an unknown key id triggers at most one refresh per 30 seconds. */
|
|
65
|
+
declare const OIDC_JWKS_UNKNOWN_KID_COOLDOWN_SECONDS = 30;
|
|
66
|
+
/** FR-13 — cached keys stay usable for at most 21,600 seconds, then fail closed. */
|
|
67
|
+
declare const OIDC_JWKS_MAX_STALE_SECONDS = 21600;
|
|
68
|
+
/**
|
|
69
|
+
* FR-11 / FR-3 — the only signing algorithms Ever ID may use, in one place.
|
|
70
|
+
*
|
|
71
|
+
* FR-11 accepts an ID token signed with one of these three; FR-3 reports failure
|
|
72
|
+
* when the provider advertises none of them. The key cache (§4.3) rejects `none`
|
|
73
|
+
* and `HS*` **before** it looks a key up, which is this same list read as an
|
|
74
|
+
* allow-list.
|
|
75
|
+
*/
|
|
76
|
+
declare const OIDC_IDENTITY_SIGNING_ALGS: readonly ["RS256", "ES256", "EdDSA"];
|
|
77
|
+
/** One of {@link OIDC_IDENTITY_SIGNING_ALGS}. */
|
|
78
|
+
type OidcIdentitySigningAlg = (typeof OIDC_IDENTITY_SIGNING_ALGS)[number];
|
|
79
|
+
/**
|
|
80
|
+
* The subset of the token-rejection vocabulary (`@ever-works/plugin`) this module
|
|
81
|
+
* can raise.
|
|
82
|
+
*
|
|
83
|
+
* `providerUnavailable` is the honest answer when the provider cannot be reached
|
|
84
|
+
* or its keys can no longer be trusted; T7/T8 rethrow it as
|
|
85
|
+
* `IdentityTokenRejectedError` with the same code, so the closed set the contract
|
|
86
|
+
* publishes stays the only vocabulary a caller branches on.
|
|
87
|
+
*/
|
|
88
|
+
type OidcProviderUnavailableCode = Extract<IdentityTokenRejectionCode, 'providerUnavailable'>;
|
|
89
|
+
/**
|
|
90
|
+
* Why the provider is unavailable — a closed set, never a provider's own text.
|
|
91
|
+
*
|
|
92
|
+
* FR-16 forbids a message carrying token material, and the cheapest way to keep
|
|
93
|
+
* that promise in a path that has a client secret in scope is to have no free
|
|
94
|
+
* text at all: `OidcProviderUnavailableError.message` **is** the code, and this
|
|
95
|
+
* reason names which rule failed. T12 maps it onto the facade's
|
|
96
|
+
* `IdentityProviderUnavailableError`; T32 records it in `availability`.
|
|
97
|
+
*/
|
|
98
|
+
type OidcProviderUnavailableReason =
|
|
99
|
+
/** FR-2/FR-3 — the integration is not configured, so nothing can be read. */
|
|
100
|
+
'notConfigured'
|
|
101
|
+
/** FR-14 — the discovery document could not be read. */
|
|
102
|
+
| 'discoveryFailed'
|
|
103
|
+
/** FR-14 — the provider advertises a different issuer than the configured one. */
|
|
104
|
+
| 'issuerDrift'
|
|
105
|
+
/** FR-3 — discovery answered, but a required capability is missing. */
|
|
106
|
+
| 'discoveryIncomplete'
|
|
107
|
+
/** FR-13 — no key set has ever been fetched, so nothing may be verified. */
|
|
108
|
+
| 'keysUnavailable'
|
|
109
|
+
/** FR-13 — the last good key fetch is older than 21,600 seconds: fail closed. */
|
|
110
|
+
| 'keysStale';
|
|
111
|
+
/**
|
|
112
|
+
* The single error a discovery or key-set failure raises (FR-14, FR-13).
|
|
113
|
+
*
|
|
114
|
+
* `message` is the code and nothing else — no issuer echo, no HTTP body, no
|
|
115
|
+
* client secret (FR-16). Extending `Error` is load-bearing exactly as it is for
|
|
116
|
+
* `IdentityTokenRejectedError`: callers catch it as an `Error` and branch on
|
|
117
|
+
* `code`, and a reason they do not recognise must degrade to "unavailable",
|
|
118
|
+
* never to a pass.
|
|
119
|
+
*/
|
|
120
|
+
declare class OidcProviderUnavailableError extends Error {
|
|
121
|
+
readonly reason: OidcProviderUnavailableReason;
|
|
122
|
+
readonly code: OidcProviderUnavailableCode;
|
|
123
|
+
constructor(reason: OidcProviderUnavailableReason);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The discovery document, as far as this package reads it.
|
|
127
|
+
*
|
|
128
|
+
* Only the fields FR-3 and T7/T8's flow need are named; the index signature keeps
|
|
129
|
+
* every other claim (`userinfo_endpoint`, `end_session_endpoint`,
|
|
130
|
+
* `revocation_endpoint`, `token_endpoint_auth_methods_supported`, …) verbatim, so
|
|
131
|
+
* a later task reads a claim without re-fetching and without this interface
|
|
132
|
+
* having to grow first.
|
|
133
|
+
*/
|
|
134
|
+
interface OidcDiscoveryDocument {
|
|
135
|
+
/** The issuer the provider claims — FR-3 compares it to the configured one, exactly. */
|
|
136
|
+
issuer: string;
|
|
137
|
+
authorization_endpoint?: string;
|
|
138
|
+
token_endpoint?: string;
|
|
139
|
+
jwks_uri?: string;
|
|
140
|
+
end_session_endpoint?: string;
|
|
141
|
+
device_authorization_endpoint?: string;
|
|
142
|
+
code_challenge_methods_supported?: string[];
|
|
143
|
+
id_token_signing_alg_values_supported?: string[];
|
|
144
|
+
backchannel_logout_supported?: boolean;
|
|
145
|
+
backchannel_logout_session_supported?: boolean;
|
|
146
|
+
[claim: string]: unknown;
|
|
147
|
+
}
|
|
148
|
+
/** The shape of a response this module needs — a structural subset of `Response`. */
|
|
149
|
+
interface OidcHttpResponse {
|
|
150
|
+
readonly ok: boolean;
|
|
151
|
+
readonly status: number;
|
|
152
|
+
json(): Promise<unknown>;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* What this module hands the fetch seam.
|
|
156
|
+
*
|
|
157
|
+
* `signal` and `headers` are what T6 needed; `method` and `body` were added by
|
|
158
|
+
* **T7**, because the code exchange is the one call that is not a GET — and a
|
|
159
|
+
* spec that cannot see the request it is asserting on cannot prove FR-15's
|
|
160
|
+
* `client_secret_basic`, the four form fields of RFC 6749 §4.1.3 or the absence
|
|
161
|
+
* of a retry. Both are optional and both default to a bodyless GET, so every
|
|
162
|
+
* T6 fake that ignores them keeps compiling and keeps its behaviour.
|
|
163
|
+
*/
|
|
164
|
+
interface OidcFetchInit {
|
|
165
|
+
readonly signal: AbortSignal;
|
|
166
|
+
readonly headers: Record<string, string>;
|
|
167
|
+
/** The HTTP method; the callers that pass one pass `POST`. Defaults to a GET. */
|
|
168
|
+
readonly method?: string;
|
|
169
|
+
/** The already-encoded request body, sent only when present. */
|
|
170
|
+
readonly body?: string;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The seam every outbound call goes through.
|
|
174
|
+
*
|
|
175
|
+
* Optional everywhere it is used, and defaulting to the runtime's `fetch`: the
|
|
176
|
+
* platform's plugin loader constructs a plugin with no arguments
|
|
177
|
+
* (`PluginLoaderService.loadPluginModule`, `plugin-loader.service.ts:364` —
|
|
178
|
+
* `new PluginClass()`), so an injected fetch is a test's business and never a
|
|
179
|
+
* production requirement.
|
|
180
|
+
*/
|
|
181
|
+
type OidcFetchImpl = (url: string, init: OidcFetchInit) => Promise<OidcHttpResponse>;
|
|
182
|
+
/** One JSON request. */
|
|
183
|
+
interface OidcOutboundRequest {
|
|
184
|
+
readonly url: string;
|
|
185
|
+
/** Extra headers; `accept: application/json` is always sent. */
|
|
186
|
+
readonly headers?: Record<string, string>;
|
|
187
|
+
/**
|
|
188
|
+
* The HTTP method. Absent means a GET — the discovery read and the key fetch
|
|
189
|
+
* (FR-3, FR-13). T7's code exchange passes `POST`, the one call that is not.
|
|
190
|
+
*/
|
|
191
|
+
readonly method?: string;
|
|
192
|
+
/** The request body, encoded by the caller; sent only when present. */
|
|
193
|
+
readonly body?: string;
|
|
194
|
+
/**
|
|
195
|
+
* Sent as `content-type` when a body is sent.
|
|
196
|
+
*
|
|
197
|
+
* The token endpoint of RFC 6749 §4.1.3 takes
|
|
198
|
+
* `application/x-www-form-urlencoded`; a provider that checks the header — as
|
|
199
|
+
* ZITADEL and Keycloak both do — answers `415` without it.
|
|
200
|
+
*/
|
|
201
|
+
readonly contentType?: string;
|
|
202
|
+
}
|
|
203
|
+
/** Every injectable knob of an outbound call. */
|
|
204
|
+
interface OidcOutboundOptions {
|
|
205
|
+
readonly fetchImpl?: OidcFetchImpl;
|
|
206
|
+
/** The clock, in epoch milliseconds. Defaults to `Date.now`. */
|
|
207
|
+
readonly now?: () => number;
|
|
208
|
+
readonly timeoutMs?: number;
|
|
209
|
+
readonly retryDelayMs?: number;
|
|
210
|
+
/**
|
|
211
|
+
* Absolute epoch-ms deadline the **whole** call must fit inside.
|
|
212
|
+
*
|
|
213
|
+
* FR-3 gives **Test connection** 5 seconds for every check, while FR-15 gives
|
|
214
|
+
* discovery one retry after 1 second — with a 5,000 ms per-attempt timeout the
|
|
215
|
+
* two together would allow 11 seconds. The deadline is how the two are kept:
|
|
216
|
+
* the retry happens whenever it fits, and the answer is reported at the
|
|
217
|
+
* deadline either way. Callers with no such bound (the key fetch, FR-13) leave
|
|
218
|
+
* it undefined and get §4.3 literally.
|
|
219
|
+
*/
|
|
220
|
+
readonly deadlineAt?: number;
|
|
221
|
+
}
|
|
222
|
+
/** Why a JSON GET did not produce a body. */
|
|
223
|
+
type OidcOutboundFailure = 'timeout' | 'httpStatus' | 'network' | 'invalidBody';
|
|
224
|
+
/** The outcome of {@link fetchJsonWithOneRetry}. */
|
|
225
|
+
interface OidcOutboundResult {
|
|
226
|
+
readonly ok: boolean;
|
|
227
|
+
/** Present as soon as a response was received, pass or fail. */
|
|
228
|
+
readonly status?: number;
|
|
229
|
+
/** The parsed body, when `ok`. */
|
|
230
|
+
readonly body?: unknown;
|
|
231
|
+
/** Why it failed, when not `ok`. */
|
|
232
|
+
readonly failure?: OidcOutboundFailure;
|
|
233
|
+
/** How many attempts were started — 1 or 2 (FR-15). */
|
|
234
|
+
readonly attempts: number;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* One request, **never retried** — FR-15's "the code exchange is never retried".
|
|
238
|
+
*
|
|
239
|
+
* Same per-attempt bound as {@link fetchJsonWithOneRetry}
|
|
240
|
+
* ({@link OIDC_OUTBOUND_TIMEOUT_MS}, on a `setTimeout` so an injected clock and
|
|
241
|
+
* vitest's fake timers can drive it) and the same never-rejects contract, but a
|
|
242
|
+
* single attempt by construction rather than by option: a security-relevant
|
|
243
|
+
* "no second try" is safer as a different function than as a flag a caller can
|
|
244
|
+
* pass wrongly. FR-15 gives the token endpoint 5,000 ms and one attempt, and
|
|
245
|
+
* this is that rule.
|
|
246
|
+
*
|
|
247
|
+
* `deadlineAt` is honoured when a caller passes one, and reports how many
|
|
248
|
+
* attempts had started when the bound was reached — `1` once the request is in
|
|
249
|
+
* flight, `0` if it never was.
|
|
250
|
+
*/
|
|
251
|
+
declare function fetchJsonOnce(request: OidcOutboundRequest, options?: OidcOutboundOptions): Promise<OidcOutboundResult>;
|
|
252
|
+
/** Why a discovery read produced no document. */
|
|
253
|
+
type OidcDiscoveryFailure = OidcOutboundFailure
|
|
254
|
+
/** The configured issuer is not an address a discovery document can be read from. */
|
|
255
|
+
| 'invalidIssuer'
|
|
256
|
+
/** A 200 answered with a body that is not an object carrying a string `issuer`. */
|
|
257
|
+
| 'invalidDocument';
|
|
258
|
+
/** The outcome of one discovery read (FR-3, FR-14). */
|
|
259
|
+
type OidcDiscoveryRead = {
|
|
260
|
+
readonly ok: true;
|
|
261
|
+
readonly document: OidcDiscoveryDocument;
|
|
262
|
+
readonly fetchedAt: number;
|
|
263
|
+
/**
|
|
264
|
+
* FR-3: whether the document's `issuer` equals the configured issuer
|
|
265
|
+
* **exactly**. A `false` here is a reported check row, not an exception —
|
|
266
|
+
* and every other read path in this class refuses to hand the document on
|
|
267
|
+
* when it is false (FR-14's disable).
|
|
268
|
+
*/
|
|
269
|
+
readonly issuerMatches: boolean;
|
|
270
|
+
readonly attempts: number;
|
|
271
|
+
} | {
|
|
272
|
+
readonly ok: false;
|
|
273
|
+
readonly failure: OidcDiscoveryFailure;
|
|
274
|
+
readonly attempts: number;
|
|
275
|
+
readonly status?: number;
|
|
276
|
+
};
|
|
277
|
+
/** Construction options for {@link OidcDiscoveryReader}. */
|
|
278
|
+
interface OidcDiscoveryOptions extends OidcOutboundOptions {
|
|
279
|
+
/** The administrator's configured issuer address (FR-2), compared exactly. */
|
|
280
|
+
readonly issuerUrl: string;
|
|
281
|
+
/** FR-14's 3,600 seconds; overridable so a spec can pin the boundary. */
|
|
282
|
+
readonly cacheSeconds?: number;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The discovery document, read, cached for 3,600 seconds and refused on drift
|
|
286
|
+
* (FR-14).
|
|
287
|
+
*
|
|
288
|
+
* Two entry points, and the difference between them is the point:
|
|
289
|
+
*
|
|
290
|
+
* - {@link read} **always** fetches. It is what **Test connection** calls, because
|
|
291
|
+
* an administrator pressing the button is asking the provider a question and a
|
|
292
|
+
* cached answer would defeat FR-14's "until an administrator re-tests".
|
|
293
|
+
* - {@link get} serves the cache and refreshes it when it has expired. It is what
|
|
294
|
+
* the flow calls, and it fails closed — `providerUnavailable` — whenever the
|
|
295
|
+
* document could not be read or the provider's issuer has drifted, so a
|
|
296
|
+
* sign-in can never proceed against a provider whose identity changed under
|
|
297
|
+
* it.
|
|
298
|
+
*/
|
|
299
|
+
declare class OidcDiscoveryReader {
|
|
300
|
+
private readonly options;
|
|
301
|
+
private document;
|
|
302
|
+
private fetchedAtMs;
|
|
303
|
+
constructor(options: OidcDiscoveryOptions);
|
|
304
|
+
/** The configured issuer this reader answers for (FR-7: one issuer set per installation). */
|
|
305
|
+
get issuerUrl(): string;
|
|
306
|
+
/**
|
|
307
|
+
* When the last successful read happened, in epoch ms, or `null`.
|
|
308
|
+
*
|
|
309
|
+
* This is the in-process source of plan §4.2's `availability.discoveryRefreshedAt`
|
|
310
|
+
* and of plan §9.1's health view; T32 persists it.
|
|
311
|
+
*/
|
|
312
|
+
get lastRefreshedAt(): number | null;
|
|
313
|
+
/** The cached document while FR-14's 3,600 seconds have not elapsed, else `null`. */
|
|
314
|
+
cachedDocument(): OidcDiscoveryDocument | null;
|
|
315
|
+
/**
|
|
316
|
+
* Read the document from the provider now (FR-3's read, FR-14's 3,600-second cache
|
|
317
|
+
* entry point).
|
|
318
|
+
*
|
|
319
|
+
* A successful read replaces the cache even when the issuer drifts: the drift is
|
|
320
|
+
* what an administrator needs to see, and {@link get} refuses it either way.
|
|
321
|
+
*/
|
|
322
|
+
read(options?: {
|
|
323
|
+
deadlineAt?: number;
|
|
324
|
+
}): Promise<OidcDiscoveryRead>;
|
|
325
|
+
/**
|
|
326
|
+
* The document, from the cache while it is fresh and from the provider once it is
|
|
327
|
+
* not (FR-14). Throws {@link OidcProviderUnavailableError} rather than returning
|
|
328
|
+
* `null`: every caller of this method is on a path that must not continue without
|
|
329
|
+
* a document.
|
|
330
|
+
*/
|
|
331
|
+
get(): Promise<OidcDiscoveryDocument>;
|
|
332
|
+
/** FR-14: a different issuer turns sign-in off until an administrator re-tests. */
|
|
333
|
+
private assertIssuerMatches;
|
|
334
|
+
private now;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* APW-12 T5 — the `oidc-identity` plugin settings (plan §4.2, setting key by
|
|
339
|
+
* setting key; spec FR-2 holds the same list in prose).
|
|
340
|
+
*
|
|
341
|
+
* Every constraint below is transcribed from plan §4.2's table — the same
|
|
342
|
+
* numbers as `EVER_ID_LIMITS` in `packages/contracts/src/apps/ever-id.ts`,
|
|
343
|
+
* which plan §4.3 makes the single source for the *runtime* checks. Nothing is
|
|
344
|
+
* invented here: no key, bound, default or requirement appears that the table
|
|
345
|
+
* does not state, so a tightening of the schema cannot silently refuse a
|
|
346
|
+
* configuration the plan allows.
|
|
347
|
+
*
|
|
348
|
+
* Three properties of this schema are load-bearing, and are pinned by
|
|
349
|
+
* `src/__tests__/settings.schema.spec.ts` rather than asserted in prose here:
|
|
350
|
+
*
|
|
351
|
+
* - **`clientSecret` is `x-secret`** (Constitution VII, spec FR-16 / ACC-12-03).
|
|
352
|
+
* The marker is the only thing that makes the platform treat the value as a
|
|
353
|
+
* secret: `packages/plugin/src/api/api-response.types.ts:129` turns it into
|
|
354
|
+
* `secret: true` on the descriptor every API response is built from, and
|
|
355
|
+
* `packages/agent/src/plugins/services/plugin-operations.service.ts:1792`
|
|
356
|
+
* and `:1932` mask exactly those fields. Drop the marker and the secret
|
|
357
|
+
* becomes an ordinary setting, readable back out of the settings API.
|
|
358
|
+
* - **every key is `x-scope: 'global'`** (plan §4.2, FR-7). One issuer set per
|
|
359
|
+
* installation, configured by a platform admin: the schema is admin-tier, so
|
|
360
|
+
* `SettingsSchemaValidatorService.filterSchemaByScope`
|
|
361
|
+
* (`packages/agent/src/plugins/services/settings-schema-validator.service.ts:247`)
|
|
362
|
+
* never offers these keys to a user- or work-scoped read or write.
|
|
363
|
+
* - **the issuer rule is split across schema and runtime.** JSON Schema is
|
|
364
|
+
* static, so the schema carries the half that does not depend on the
|
|
365
|
+
* environment — `https` anywhere, `http` only for `localhost`/`127.0.0.1` —
|
|
366
|
+
* while the plan's remaining half (`http` is refused outside development)
|
|
367
|
+
* is a `NODE_ENV` check.
|
|
368
|
+
*
|
|
369
|
+
* **That runtime half now exists** (2026-09-21), in
|
|
370
|
+
* `oidc-identity.plugin.ts`: `allowsInsecureIssuer` is an allow-list of
|
|
371
|
+
* `development` / `test`, applied in `resolveSettings` (the chokepoint every
|
|
372
|
+
* public method goes through, so a refused issuer refuses discovery, the key
|
|
373
|
+
* fetch, the code exchange, both verifiers and `testConnection` together) and
|
|
374
|
+
* again where `allowInsecureRequests` is called. Until then the sentence above
|
|
375
|
+
* said T6 owned the check, T6 never wrote it, and a comment at the
|
|
376
|
+
* `allowInsecureRequests` call site asserted the gate as though it were in
|
|
377
|
+
* place — which is what kept it unnoticed. Do not restate a gate here without
|
|
378
|
+
* naming the function that enforces it.
|
|
379
|
+
*
|
|
380
|
+
* **A bound in this schema is a write-time check, and for an `x-envVar` key that
|
|
381
|
+
* is not `x-secret` it never runs.** `PluginSettingsService.filterEnvVarFields`
|
|
382
|
+
* strips every key that is `x-envVar` and not `x-secret` from settings writes, so
|
|
383
|
+
* the validator never sees it. (`clientSecret` is both, so it is kept and IS
|
|
384
|
+
* validated on write.) The value is read from its environment variable by `parseEnvValue`,
|
|
385
|
+
* which turns an `integer` key into `Number(value)` and checks nothing else. So
|
|
386
|
+
* `clockSkewSeconds`'s `minimum: 0` / `maximum: 120` (FR-2) does not reach the
|
|
387
|
+
* value the plugin reads. That bound is enforced at run time by
|
|
388
|
+
* `isClockSkewInBounds` in `oidc-identity.plugin.ts`, applied in
|
|
389
|
+
* `resolveSettings` against `OIDC_MAX_CLOCK_SKEW_SECONDS` (2026-09-25). A value
|
|
390
|
+
* outside it turns the integration off as `notConfigured`, with the field name
|
|
391
|
+
* logged and the value not logged. `src/__tests__/clock-skew-gate.spec.ts` pins
|
|
392
|
+
* that constant against this schema's `maximum` and `EVER_ID_LIMITS`. Until then
|
|
393
|
+
* the plugin applied the stored value unchecked, and an out-of-range, `NaN` or
|
|
394
|
+
* string value widened or switched off every token time check.
|
|
395
|
+
*/
|
|
396
|
+
/**
|
|
397
|
+
* `https://…`, or `http://localhost…` / `http://127.0.0.1…` with an optional
|
|
398
|
+
* port and path (plan §4.2 `issuerUrl`, spec FR-2). Anything else is refused by
|
|
399
|
+
* the settings validator before it is ever persisted.
|
|
400
|
+
*/
|
|
401
|
+
declare const oidcIdentityIssuerUrlPattern = "^(https://[^\\s]+|http://(localhost|127\\.0\\.0\\.1)(:\\d{1,5})?(/[^\\s]*)?)$";
|
|
402
|
+
/** `https://…` only — the **Manage in Ever ID ↗** target (plan §4.2). */
|
|
403
|
+
declare const oidcIdentityHttpsUrlPattern = "^https://[^\\s]+$";
|
|
404
|
+
/** One local client allowed to exchange an Ever ID token for a session (FR-39). */
|
|
405
|
+
interface OidcIdentityLocalClient {
|
|
406
|
+
kind: 'cli' | 'node';
|
|
407
|
+
clientId: string;
|
|
408
|
+
}
|
|
409
|
+
/** A remembered delegated client and the name the card shows it under (FR-48). */
|
|
410
|
+
interface OidcIdentityDelegatedClientName {
|
|
411
|
+
clientId: string;
|
|
412
|
+
displayName?: string;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Platform-written availability state (plan §4.2, §9.2). Stored in the plugin's
|
|
416
|
+
* own settings row so every replica reads the same state — a re-test on one
|
|
417
|
+
* replica clears the flag on all of them (FR-5). Never user-writable: the
|
|
418
|
+
* property is `x-hidden`.
|
|
419
|
+
*/
|
|
420
|
+
interface OidcIdentityAvailability {
|
|
421
|
+
unavailableSince?: string;
|
|
422
|
+
discoveryRefreshedAt?: string;
|
|
423
|
+
jwksRefreshedAt?: string;
|
|
424
|
+
lastLogoutNoticeAt?: string;
|
|
425
|
+
}
|
|
426
|
+
/** The settings this plugin reads once resolved (plan §4.2). */
|
|
427
|
+
interface OidcIdentitySettings {
|
|
428
|
+
issuerUrl: string;
|
|
429
|
+
clientId: string;
|
|
430
|
+
clientSecret: string;
|
|
431
|
+
allowedIssuers?: string[];
|
|
432
|
+
apiAudience?: string;
|
|
433
|
+
localClients?: OidcIdentityLocalClient[];
|
|
434
|
+
delegatedClientNames?: OidcIdentityDelegatedClientName[];
|
|
435
|
+
accountManagementUrl?: string;
|
|
436
|
+
signUpAllowed?: boolean;
|
|
437
|
+
clockSkewSeconds?: number;
|
|
438
|
+
displayName?: string;
|
|
439
|
+
availability?: OidcIdentityAvailability;
|
|
440
|
+
}
|
|
441
|
+
/** Every top-level key of plan §4.2, in the table's own order. */
|
|
442
|
+
declare const OIDC_IDENTITY_SETTING_KEYS: readonly ["issuerUrl", "clientId", "clientSecret", "allowedIssuers", "apiAudience", "localClients", "delegatedClientNames", "accountManagementUrl", "signUpAllowed", "clockSkewSeconds", "displayName", "availability"];
|
|
443
|
+
/** Union of {@link OIDC_IDENTITY_SETTING_KEYS}. */
|
|
444
|
+
type OidcIdentitySettingKey = (typeof OIDC_IDENTITY_SETTING_KEYS)[number];
|
|
445
|
+
declare const oidcIdentitySettingsSchema: JsonSchema;
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* FR-9 — the `state` a sign-in carries is **at least 32 random bytes**
|
|
449
|
+
* (`EVER_ID_LIMITS.stateBytes`; plan §3.5 seals it in the transaction cookie).
|
|
450
|
+
*
|
|
451
|
+
* Exported so the number is pinnable by a spec that reads the contract off disk,
|
|
452
|
+
* exactly as T6 pinned FR-13's three numbers: the behavioural cases in
|
|
453
|
+
* `src/__tests__/authorization-request.spec.ts` assert against the literals
|
|
454
|
+
* `32`/`32`/`64` in FR-9's own words, and this constant is asserted against
|
|
455
|
+
* `EVER_ID_LIMITS` separately.
|
|
456
|
+
*/
|
|
457
|
+
declare const OIDC_STATE_BYTES = 32;
|
|
458
|
+
/** FR-9 — the `nonce` a sign-in carries is **at least 32 random bytes** (`EVER_ID_LIMITS.nonceBytes`). */
|
|
459
|
+
declare const OIDC_NONCE_BYTES = 32;
|
|
460
|
+
/**
|
|
461
|
+
* FR-9 — the PKCE code verifier is **64 characters** (`EVER_ID_LIMITS.codeVerifierLength`).
|
|
462
|
+
*
|
|
463
|
+
* Randomness is drawn as {@link OIDC_CODE_VERIFIER_BYTES} bytes and encoded
|
|
464
|
+
* base64url, which is 64 characters for 48 bytes. The two constants are separate
|
|
465
|
+
* because they are two different requirements — "64 characters" is FR-9's, and
|
|
466
|
+
* the byte count is this package's choice of an encoding that produces exactly
|
|
467
|
+
* that — and the spec asserts that the encoding of 48 bytes really is 64
|
|
468
|
+
* characters rather than assuming it.
|
|
469
|
+
*/
|
|
470
|
+
declare const OIDC_CODE_VERIFIER_LENGTH = 64;
|
|
471
|
+
/** How many random bytes base64url encode to {@link OIDC_CODE_VERIFIER_LENGTH} characters. */
|
|
472
|
+
declare const OIDC_CODE_VERIFIER_BYTES = 48;
|
|
473
|
+
/**
|
|
474
|
+
* FR-2 — the default clock-skew tolerance, in seconds
|
|
475
|
+
* (`EVER_ID_LIMITS.defaultClockSkewSeconds`; the schema's `clockSkewSeconds`
|
|
476
|
+
* default is the same number, and the spec asserts the two agree).
|
|
477
|
+
*/
|
|
478
|
+
declare const OIDC_DEFAULT_CLOCK_SKEW_SECONDS = 60;
|
|
479
|
+
/**
|
|
480
|
+
* FR-2 — the widest clock-skew tolerance an administrator may configure, in
|
|
481
|
+
* seconds (`EVER_ID_LIMITS.maxClockSkewSeconds`). The schema's `clockSkewSeconds`
|
|
482
|
+
* maximum is the same number, and `src/__tests__/clock-skew-gate.spec.ts` asserts
|
|
483
|
+
* that all three agree.
|
|
484
|
+
*
|
|
485
|
+
* {@link isClockSkewInBounds} applies it at run time. The schema bound alone never
|
|
486
|
+
* reaches the value this plugin reads (see that function).
|
|
487
|
+
*/
|
|
488
|
+
declare const OIDC_MAX_CLOCK_SKEW_SECONDS = 120;
|
|
489
|
+
/**
|
|
490
|
+
* FR-11 — an `iat` may be **no earlier than 600 seconds ago**
|
|
491
|
+
* (`EVER_ID_LIMITS.idTokenMaxAgeSeconds`; plan §4.3's "ID token times" row).
|
|
492
|
+
*/
|
|
493
|
+
declare const OIDC_ID_TOKEN_MAX_AGE_SECONDS = 600;
|
|
494
|
+
/**
|
|
495
|
+
* FR-11 — `sub` is **1–255 characters**.
|
|
496
|
+
*
|
|
497
|
+
* The bound is asymmetric on purpose: an empty subject identifies nobody, and a
|
|
498
|
+
* longer one is not a subject any provider's directory would mint — FR-31 stores
|
|
499
|
+
* the value, so the column's width is a decision this check is allowed to make.
|
|
500
|
+
*/
|
|
501
|
+
declare const OIDC_SUBJECT_MAX_LENGTH = 255;
|
|
502
|
+
/**
|
|
503
|
+
* FR-2 — the audience a delegated or exchanged access token must carry when an
|
|
504
|
+
* administrator configured none (`EVER_ID_DEFAULT_API_AUDIENCE`; plan §4.2's
|
|
505
|
+
* `apiAudience` row, APW-11 §2.4).
|
|
506
|
+
*
|
|
507
|
+
* Until T8 this literal lived only inside `getPublicConfig`; it is a constant here
|
|
508
|
+
* because two methods now read the same default — that projection and
|
|
509
|
+
* {@link OidcIdentityPlugin.verifyAccessToken}'s audience rule — and a default
|
|
510
|
+
* spelled twice is a default that can drift between what the web is told and what
|
|
511
|
+
* a token is checked against.
|
|
512
|
+
*/
|
|
513
|
+
declare const OIDC_DEFAULT_API_AUDIENCE = "ever-works";
|
|
514
|
+
/**
|
|
515
|
+
* FR-45 — a delegated token's lifetime (`exp` − `iat`) is **at most 3,600 seconds**
|
|
516
|
+
* (`EVER_ID_LIMITS.delegatedTokenMaxLifetimeSeconds`; plan §4.3's "Access token
|
|
517
|
+
* (delegated)" row).
|
|
518
|
+
*
|
|
519
|
+
* Exported although `verifyAccessToken` takes the ceiling as an argument rather
|
|
520
|
+
* than applying a constant: the number belongs to the caller's token family, and
|
|
521
|
+
* this is the value that family is capped at. A client that mints a longer-lived
|
|
522
|
+
* token is refused with `lifetimeTooLong` (ACC-12-35).
|
|
523
|
+
*/
|
|
524
|
+
declare const OIDC_ACCESS_TOKEN_MAX_LIFETIME_SECONDS = 3600;
|
|
525
|
+
/**
|
|
526
|
+
* FR-40 — an exchange token's `iat` may be **no older than 300 seconds**
|
|
527
|
+
* (`EVER_ID_LIMITS.exchangeTokenMaxAgeSeconds`).
|
|
528
|
+
*
|
|
529
|
+
* Passed by the local-client exchange as `maxAgeSeconds`; a plain delegated read
|
|
530
|
+
* (FR-45) passes none, because that row has no age rule — its bound is the
|
|
531
|
+
* lifetime ceiling above.
|
|
532
|
+
*/
|
|
533
|
+
declare const OIDC_ACCESS_TOKEN_MAX_AGE_SECONDS = 300;
|
|
534
|
+
/**
|
|
535
|
+
* FR-33 — a sign-out notice's `iat` may be **no older than 300 seconds**
|
|
536
|
+
* (`EVER_ID_LIMITS.logoutTokenMaxAgeSeconds`).
|
|
537
|
+
*
|
|
538
|
+
* Unlike the two numbers above this one is not a caller's choice: FR-33 fixes it
|
|
539
|
+
* for every back-channel logout notice, so {@link OidcIdentityPlugin.verifyLogoutToken}
|
|
540
|
+
* applies it itself.
|
|
541
|
+
*/
|
|
542
|
+
declare const OIDC_LOGOUT_TOKEN_MAX_AGE_SECONDS = 300;
|
|
543
|
+
/**
|
|
544
|
+
* FR-33 — a `jti` must not have been seen in the last **600 seconds**
|
|
545
|
+
* (`EVER_ID_LIMITS.replayWindowSeconds`).
|
|
546
|
+
*
|
|
547
|
+
* Exported although this plugin deliberately does **not** enforce it: the window
|
|
548
|
+
* needs a store, plan §4.1 leaves it with the caller ("the caller keeps the
|
|
549
|
+
* 600-second replay window, not the plugin"), and T13's replay service is where it
|
|
550
|
+
* lives. The constant is here so the one number has one spelling in this package
|
|
551
|
+
* — the alternative is a literal in the API that nothing cross-checks.
|
|
552
|
+
*/
|
|
553
|
+
declare const OIDC_LOGOUT_TOKEN_REPLAY_WINDOW_SECONDS = 600;
|
|
554
|
+
/**
|
|
555
|
+
* FR-33 — the `events` member a back-channel logout notice must carry
|
|
556
|
+
* (OpenID Connect Back-Channel Logout 1.0 §2.4's event identifier).
|
|
557
|
+
*
|
|
558
|
+
* One spelling, because three things have to agree on it: this plugin's
|
|
559
|
+
* acceptance rule, the fake provider that mints notices, and any future caller
|
|
560
|
+
* that has to explain a refusal.
|
|
561
|
+
*/
|
|
562
|
+
declare const OIDC_BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
563
|
+
/**
|
|
564
|
+
* The seams a spec (or a future caller) may inject.
|
|
565
|
+
*
|
|
566
|
+
* All three are optional and all three default to the runtime: the plugin loader
|
|
567
|
+
* constructs a plugin with no arguments (`PluginLoaderService.loadPluginModule`,
|
|
568
|
+
* `plugin-loader.service.ts:364`), so nothing here is a production requirement.
|
|
569
|
+
* They exist because T6's two properties — FR-3's 5-second bound and FR-13's
|
|
570
|
+
* 600/30/21,600-second ladder — are only provable against a clock and a socket the
|
|
571
|
+
* test owns; a spec that really waited 21,600 seconds is not a spec.
|
|
572
|
+
*
|
|
573
|
+
* `randomBytes` is T7's addition, and for the same reason: FR-9's "fresh `state`
|
|
574
|
+
* and `nonce` of at least 32 random bytes each" is a claim about *freshness* and
|
|
575
|
+
* *byte count*, and neither can be asserted against a value the spec cannot see
|
|
576
|
+
* the source of. With the seam injected, a case can pin the exact bytes the
|
|
577
|
+
* request was built from; with it left alone, the default is the platform
|
|
578
|
+
* CSPRNG and the case asserts what can be asserted of any output — that two
|
|
579
|
+
* calls never agree, and that each value decodes to 32 bytes.
|
|
580
|
+
*/
|
|
581
|
+
interface OidcIdentityPluginOptions {
|
|
582
|
+
/** The outbound HTTP seam; defaults to the runtime `fetch` (see `discovery.ts`). */
|
|
583
|
+
readonly fetchImpl?: OidcFetchImpl;
|
|
584
|
+
/** The clock, in epoch milliseconds; defaults to `Date.now`. */
|
|
585
|
+
readonly now?: () => number;
|
|
586
|
+
/** The randomness seam, in bytes; defaults to `node:crypto`'s `randomBytes`. */
|
|
587
|
+
readonly randomBytes?: (length: number) => Uint8Array;
|
|
588
|
+
/**
|
|
589
|
+
* The deployment environment, for the ONE rule that depends on it: whether a
|
|
590
|
+
* non-TLS (`http://localhost`) issuer is usable at all
|
|
591
|
+
* ({@link allowsInsecureIssuer}). Defaults to `process.env.NODE_ENV` read at call
|
|
592
|
+
* time, so a spec that flips the variable does not have to construct the
|
|
593
|
+
* plugin again. A spec that wants the refusing branch passes any value that is
|
|
594
|
+
* not in {@link NON_PRODUCTION_NODE_ENVS} — `nodeEnv: 'production'` is the
|
|
595
|
+
* readable one.
|
|
596
|
+
*/
|
|
597
|
+
readonly nodeEnv?: string;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* APW-12 T5 — the `oidc-identity` plugin, scaffolded as an identity and completed
|
|
601
|
+
* by T6, T7 and T8 (see the history paragraph below).
|
|
602
|
+
*
|
|
603
|
+
* What the scaffold itself had to get right, and what every later task kept:
|
|
604
|
+
*
|
|
605
|
+
* - the manifest the `everworks.plugin` block in `package.json` also declares
|
|
606
|
+
* (they must agree — `packages/agent/src/plugins/services/plugin-manifest-validator.service.ts`
|
|
607
|
+
* extracts the block from disk and `PluginClassValidatorService` compares the
|
|
608
|
+
* loaded class against it);
|
|
609
|
+
* - the settings schema of plan §4.2;
|
|
610
|
+
* - the two lifecycle hooks the loader calls. Both are **prototype methods on
|
|
611
|
+
* purpose**: `PluginClassValidatorService.isPluginClass`
|
|
612
|
+
* (`packages/agent/src/plugins/services/plugin-class-validator.service.ts:42-55`)
|
|
613
|
+
* accepts a class only when `onLoad` and `onUnload` sit on its prototype, so
|
|
614
|
+
* an arrow-function property would make this plugin undiscoverable.
|
|
615
|
+
*
|
|
616
|
+
* What did NOT exist before **T8**, and why the docstring is kept as history: three
|
|
617
|
+
* of the seven methods of `IIdentityProviderPlugin` — `verifyAccessToken`,
|
|
618
|
+
* `verifyLogoutToken` and `buildEndSessionUrl` — and `healthCheck`. T6 landed the
|
|
619
|
+
* two configuration methods — `testConnection` (FR-3) and `getPublicConfig`
|
|
620
|
+
* (FR-2) — plus the discovery reader and the key cache they and the flow share
|
|
621
|
+
* (`src/discovery.ts`, `src/jwks-cache.ts`); **T7** landed the two sign-in
|
|
622
|
+
* methods, `buildAuthorizationRequest` (FR-8/FR-9/FR-10) and
|
|
623
|
+
* `exchangeAuthorizationCode` (FR-11/FR-12/FR-19's single completion is the
|
|
624
|
+
* caller's), plus `src/scopes.ts`. **T8** landed the three token/sign-out methods
|
|
625
|
+
* (FR-40/FR-45's access tokens, FR-33's sign-out notice, FR-36's end-session
|
|
626
|
+
* address) and `healthCheck`, so the class now claims the interface below rather
|
|
627
|
+
* than five of its seven methods — and
|
|
628
|
+
* `isIdentityProviderPlugin` (`packages/plugin/src/contracts/capabilities/identity-provider.interface.ts:284`)
|
|
629
|
+
* answers `true` for it, which is what it was written to answer once every method
|
|
630
|
+
* exists.
|
|
631
|
+
*
|
|
632
|
+
* Claiming `IIdentityProviderPlugin` in the `implements` clause is not decoration:
|
|
633
|
+
* it makes `tsc` refuse a member whose name, arity or nullability has drifted from
|
|
634
|
+
* plan §4.1, which is the failure the guard above cannot see. What it does **not**
|
|
635
|
+
* change is the plugin's posture: `autoEnable: false`, so the installation still
|
|
636
|
+
* opts in explicitly (FR-5's kill switch is the plugin toggle, plan §9.2).
|
|
637
|
+
*/
|
|
638
|
+
declare class OidcIdentityPlugin implements IPlugin, IIdentityProviderPlugin {
|
|
639
|
+
readonly id = "oidc-identity";
|
|
640
|
+
readonly name = "OpenID Connect identity (Ever ID)";
|
|
641
|
+
readonly version = "1.0.0";
|
|
642
|
+
/**
|
|
643
|
+
* The plan §4.2 category, `'identity'`.
|
|
644
|
+
*
|
|
645
|
+
* The assertion is needed only because this package lands **before T4**,
|
|
646
|
+
* which appends `'identity'` to `PLUGIN_CATEGORIES`
|
|
647
|
+
* (`packages/plugin/src/contracts/plugin-manifest.types.ts:5-117` — the tuple
|
|
648
|
+
* currently closes with `'build'`). Until that append lands the loader's own
|
|
649
|
+
* `isPluginCategory` check rejects the manifest, so this plugin is
|
|
650
|
+
* discoverable only after T4; the value itself is already exact, and the
|
|
651
|
+
* assertion is the one place the type union is narrower than the plan.
|
|
652
|
+
*
|
|
653
|
+
* **Landed 2026-09-18 (T4).** `'identity'` is now the **last** member of
|
|
654
|
+
* `PLUGIN_CATEGORIES`, so the loader's `isPluginCategory` accepts this
|
|
655
|
+
* manifest and discovery finds the package instead of skipping it with
|
|
656
|
+
* `Invalid category` (T4's report measures it: 103 → 104 plugins). The
|
|
657
|
+
* paragraph above is kept because that is what this file had to say while
|
|
658
|
+
* the append was missing, and the cast below stays — it resolves to the
|
|
659
|
+
* same member, and dropping it would remove an assertion for nothing.
|
|
660
|
+
*/
|
|
661
|
+
readonly category: PluginCategory;
|
|
662
|
+
/** Plan §4.2: exactly one capability, `identity-provider`. */
|
|
663
|
+
readonly capabilities: readonly string[];
|
|
664
|
+
/** One issuer set per installation, configured by a platform admin (FR-7, FR-2). */
|
|
665
|
+
readonly configurationMode = "admin-only";
|
|
666
|
+
readonly settingsSchema: JsonSchema;
|
|
667
|
+
/**
|
|
668
|
+
* The context `onLoad` was handed — the only way this plugin reads its own
|
|
669
|
+
* resolved settings (plan §4.1: no method takes a configuration object, so a
|
|
670
|
+
* caller cannot hand the plugin a different issuer, client id or secret than the
|
|
671
|
+
* one an administrator saved; FR-7).
|
|
672
|
+
*/
|
|
673
|
+
private context?;
|
|
674
|
+
/** The discovery reader for the currently configured issuer (FR-14's cache lives in it). */
|
|
675
|
+
private discoveryReader?;
|
|
676
|
+
/** Which issuer {@link discoveryReader} was built for — a settings change replaces it. */
|
|
677
|
+
private discoveryIssuerUrl?;
|
|
678
|
+
/** The key cache for the currently configured `jwks_uri` (FR-13's ladder lives in it). */
|
|
679
|
+
private jwksCache?;
|
|
680
|
+
/** Which `jwks_uri` {@link jwksCache} was built for — a provider that moves its keys replaces it. */
|
|
681
|
+
private jwksUri?;
|
|
682
|
+
/** FR-13 / plan §9.2: when the key set was last fetched successfully, in epoch ms. T32 persists it. */
|
|
683
|
+
private jwksRefreshedAtMs;
|
|
684
|
+
/** FR-14/FR-5: when the provider was first seen unusable, in epoch ms. T32 persists it. */
|
|
685
|
+
private unavailableSinceMs;
|
|
686
|
+
/** Which rule made it unusable — a closed set, never a provider's own text (FR-16). */
|
|
687
|
+
private unavailableReason;
|
|
688
|
+
private readonly fetchImpl?;
|
|
689
|
+
private readonly now;
|
|
690
|
+
private readonly randomBytes?;
|
|
691
|
+
private readonly nodeEnv?;
|
|
692
|
+
constructor(options?: OidcIdentityPluginOptions);
|
|
693
|
+
onLoad(context: PluginContext): Promise<void>;
|
|
694
|
+
onUnload(): Promise<void>;
|
|
695
|
+
/**
|
|
696
|
+
* FR-3 — read the provider's discovery document and report every check.
|
|
697
|
+
*
|
|
698
|
+
* The rules this method exists to keep, in the order they matter:
|
|
699
|
+
*
|
|
700
|
+
* - **5 seconds, always** (FR-3, ACC-12-03). The read is handed a deadline
|
|
701
|
+
* computed when the run starts, so FR-15's single retry can only ever fit
|
|
702
|
+
* inside that budget, and the answer is reported at the deadline whether or
|
|
703
|
+
* not the provider answered in time.
|
|
704
|
+
* - **A row per check, always** — the same seven ids in FR-3's order, an
|
|
705
|
+
* unconfigured integration and an unreachable provider included. A short or
|
|
706
|
+
* varying list would make spec §6.7's admin surface render differently
|
|
707
|
+
* depending on what went wrong.
|
|
708
|
+
* - **Never the secret** (FR-16). No `detail` quotes a value the provider sent
|
|
709
|
+
* except a numeric HTTP status; every one of them names a field, and the
|
|
710
|
+
* settings object — which is where `clientSecret` lives — is never returned,
|
|
711
|
+
* logged or embedded.
|
|
712
|
+
*
|
|
713
|
+
* The run also writes the availability record plan §4.2 keeps: the five checks
|
|
714
|
+
* of {@link FR3_REQUIRED_CHECK_IDS} passing clears `unavailableSince`, any of them
|
|
715
|
+
* failing sets it (FR-14's "until an administrator re-tests"). T32 persists it;
|
|
716
|
+
* {@link getAvailability} is the in-process view until then.
|
|
717
|
+
*/
|
|
718
|
+
testConnection(): Promise<IdentityProviderCheck[]>;
|
|
719
|
+
/**
|
|
720
|
+
* FR-2's non-secret projection — what the web needs to render the button and
|
|
721
|
+
* what a local client needs before it can sign in.
|
|
722
|
+
*
|
|
723
|
+
* The contract's five fields and nothing else: the client secret is not among
|
|
724
|
+
* them (FR-16), and neither is anything that would let a caller address the
|
|
725
|
+
* provider differently from the configuration an administrator saved (FR-7). The
|
|
726
|
+
* defaults are FR-2's: display name `Ever ID`, audience `ever-works`, sign-up on.
|
|
727
|
+
*/
|
|
728
|
+
getPublicConfig(): Promise<{
|
|
729
|
+
issuer: string;
|
|
730
|
+
displayName: string;
|
|
731
|
+
localClients: OidcIdentityLocalClient[];
|
|
732
|
+
apiAudience: string;
|
|
733
|
+
signUpAllowed: boolean;
|
|
734
|
+
}>;
|
|
735
|
+
/**
|
|
736
|
+
* FR-8/FR-9/FR-10/FR-12's first half — the authorization request a browser is
|
|
737
|
+
* sent to, and the three secrets the transaction cookie carries.
|
|
738
|
+
*
|
|
739
|
+
* What this method is responsible for, and what it is deliberately not:
|
|
740
|
+
*
|
|
741
|
+
* - **PKCE S256 only** (FR-8). `code_challenge_method=S256` is a literal in
|
|
742
|
+
* the parameters below and the challenge is the SHA-256 of the verifier,
|
|
743
|
+
* computed by `openid-client`'s `calculatePKCECodeChallenge` — the same
|
|
744
|
+
* transformation the provider will apply to the verifier at the token
|
|
745
|
+
* endpoint. `plain` is never offered and never sent; a provider that
|
|
746
|
+
* advertises only `plain` is refused by FR-3's check row and by the
|
|
747
|
+
* availability record, not by this method.
|
|
748
|
+
* - **Fresh `state` and `nonce` of 32 random bytes, and a 64-character
|
|
749
|
+
* verifier, per call** (FR-9). Every call draws new bytes from
|
|
750
|
+
* {@link OidcIdentityPluginOptions.randomBytes} (the platform CSPRNG by
|
|
751
|
+
* default) and encodes them base64url: 32 bytes → a 43-character value
|
|
752
|
+
* whose decoded length is exactly the 32 FR-9 asks for, 48 bytes → exactly
|
|
753
|
+
* the 64 characters FR-9 asks of the verifier. Nothing is reused between
|
|
754
|
+
* calls and nothing is derived from the clock, the issuer or the client
|
|
755
|
+
* id — a `state` an attacker can predict is a `state` that binds nothing.
|
|
756
|
+
* - **The exact redirect address, never rebuilt** (FR-10). `redirectUri` is
|
|
757
|
+
* copied into the query parameter byte-for-byte: no trailing-slash
|
|
758
|
+
* normalisation, no parsing and re-serialising, no default. Deriving that
|
|
759
|
+
* address from the configured public web address — and refusing anything
|
|
760
|
+
* that came from request input — is the caller's rule (plan §5.1's
|
|
761
|
+
* `POST /authorize`), and this method cannot enforce it because the
|
|
762
|
+
* address is not part of the plugin's settings (plan §4.2 has no redirect
|
|
763
|
+
* key: the one address per deployment lives in platform configuration).
|
|
764
|
+
* - **`openid email profile`, and never `offline_access`** (plan §4.2,
|
|
765
|
+
* FR-38) — the scope comes from `src/scopes.ts` as one constant rather
|
|
766
|
+
* than being assembled here.
|
|
767
|
+
*
|
|
768
|
+
* The endpoint comes from the discovery document this plugin already read
|
|
769
|
+
* (FR-14's 3,600-second cache, and its issuer-drift refusal): a request is
|
|
770
|
+
* never built against an issuer whose document advertises a different issuer,
|
|
771
|
+
* so a provider cannot move this installation's sign-in by publishing a
|
|
772
|
+
* document somewhere else.
|
|
773
|
+
*
|
|
774
|
+
* Errors are T6's availability vocabulary rather than token rejections —
|
|
775
|
+
* nothing here verifies a token, and a caller needs to know *which*
|
|
776
|
+
* configuration or document problem stopped the sign-in from starting:
|
|
777
|
+
* `notConfigured`, `discoveryFailed`, `issuerDrift` or `discoveryIncomplete`
|
|
778
|
+
* (a document with no `authorization_endpoint`). Every one of them maps to
|
|
779
|
+
* plan §5.2's `404 everIdDisabled` / `503 providerUnavailable` at the façade.
|
|
780
|
+
*/
|
|
781
|
+
buildAuthorizationRequest(input: {
|
|
782
|
+
redirectUri: string;
|
|
783
|
+
prompt?: 'login';
|
|
784
|
+
maxAgeSeconds?: number;
|
|
785
|
+
}): Promise<{
|
|
786
|
+
url: string;
|
|
787
|
+
state: string;
|
|
788
|
+
nonce: string;
|
|
789
|
+
codeVerifier: string;
|
|
790
|
+
}>;
|
|
791
|
+
/**
|
|
792
|
+
* FR-11/FR-12 — redeem an authorization code and answer the ID token's claims,
|
|
793
|
+
* or refuse.
|
|
794
|
+
*
|
|
795
|
+
* The order below is the spec's order, and each step is load-bearing:
|
|
796
|
+
*
|
|
797
|
+
* 1. **the settings resolve** — an unconfigured integration cannot exchange
|
|
798
|
+
* anything, and this method has no way to say so except
|
|
799
|
+
* `providerUnavailable` (see the error contract below);
|
|
800
|
+
* 2. **FR-12's `iss` equality, before anything is redeemed.** When the
|
|
801
|
+
* authorization response carries `iss`, it must equal the configured
|
|
802
|
+
* issuer exactly. Checking it first is not an optimisation: a code whose
|
|
803
|
+
* response named another issuer is a code this installation must not
|
|
804
|
+
* spend, and a refusal here touches the network zero times;
|
|
805
|
+
* 3. **the discovery document** (FR-14's cache, and its issuer-drift
|
|
806
|
+
* refusal) — the token endpoint and `jwks_uri` come from it;
|
|
807
|
+
* 4. **the code exchange** — one POST, `client_secret_basic`, 5,000 ms, and
|
|
808
|
+
* **no retry** (FR-15's "the code exchange is never retried": a second
|
|
809
|
+
* attempt would be a second redemption of a single-use code, which is
|
|
810
|
+
* FR-19's "a sign-in transaction completes at most once" seen from the
|
|
811
|
+
* provider's side);
|
|
812
|
+
* 5. **the signature and the algorithm**, through T6's key cache
|
|
813
|
+
* (`jwks-cache.ts`), whose three codes and whose 21,600-second
|
|
814
|
+
* fail-closed end are reported with the same codes here;
|
|
815
|
+
* 6. **FR-11's claim rules** (plan §4.3), against the injected clock and the
|
|
816
|
+
* administrator's `clockSkewSeconds` (default
|
|
817
|
+
* {@link OIDC_DEFAULT_CLOCK_SKEW_SECONDS}).
|
|
818
|
+
*
|
|
819
|
+
* ## The error contract, and why it differs from {@link buildAuthorizationRequest}
|
|
820
|
+
*
|
|
821
|
+
* **Everything this method throws is an `IdentityTokenRejectedError`** — the
|
|
822
|
+
* contract's single refusal type, `message` equal to `code` and nothing else
|
|
823
|
+
* (FR-16). That includes the provider-unavailable conditions: T6's discovery
|
|
824
|
+
* and key-cache errors are caught below and rethrown as
|
|
825
|
+
* `providerUnavailable`, and any `jwks-cache` refusal keeps its own code
|
|
826
|
+
* (`badAlg`, `badSignature`). The reason is the caller: the callback handler
|
|
827
|
+
* has one decision to make — plan §5.2's "ID token rejected → 401" versus
|
|
828
|
+
* "endpoint unavailable → 503" — and one error type with a closed code set is
|
|
829
|
+
* the shape that decision is written against. The builder, which answers a
|
|
830
|
+
* question rather than a verdict, keeps the precise availability reason
|
|
831
|
+
* instead.
|
|
832
|
+
*
|
|
833
|
+
* A refusal never partially succeeds: nothing is returned, nothing is stored,
|
|
834
|
+
* and no claim of a refused token reaches the caller.
|
|
835
|
+
*
|
|
836
|
+
* ## `maxAuthAgeSeconds` (FR-25)
|
|
837
|
+
*
|
|
838
|
+
* Supplied, it applies FR-25's rule — `auth_time` no older than 300 seconds
|
|
839
|
+
* plus the skew — and answers `tooOld`, including when the token carries no
|
|
840
|
+
* `auth_time` at all: a connect flow that cannot show the authentication was
|
|
841
|
+
* recent must re-authenticate rather than proceed. Absent (a plain sign-in),
|
|
842
|
+
* `auth_time` is not required and is passed through as `null` when missing.
|
|
843
|
+
*/
|
|
844
|
+
exchangeAuthorizationCode(input: {
|
|
845
|
+
code: string;
|
|
846
|
+
redirectUri: string;
|
|
847
|
+
codeVerifier: string;
|
|
848
|
+
expectedNonce: string;
|
|
849
|
+
receivedIssuer?: string;
|
|
850
|
+
maxAuthAgeSeconds?: number;
|
|
851
|
+
}): Promise<VerifiedIdTokenClaims>;
|
|
852
|
+
/**
|
|
853
|
+
* FR-40 (the local-client exchange) and FR-45 (the App Launcher's delegated
|
|
854
|
+
* read) — verify an Ever ID access token and answer the claims both callers
|
|
855
|
+
* branch on.
|
|
856
|
+
*
|
|
857
|
+
* The rules, in the order they are applied, and where each one comes from:
|
|
858
|
+
*
|
|
859
|
+
* 1. **the settings resolve** — as in {@link exchangeAuthorizationCode}, an
|
|
860
|
+
* unconfigured integration answers `providerUnavailable`, the only code
|
|
861
|
+
* this method has for "the plugin cannot ask the provider anything";
|
|
862
|
+
* 2. **the discovery document** (FR-14's cache and issuer-drift refusal) and
|
|
863
|
+
* its `jwks_uri` — the same read the exchange performs, and the same
|
|
864
|
+
* `providerUnavailable` when the document names no key set;
|
|
865
|
+
* 3. **the signature and the algorithm** through T6's key cache, including
|
|
866
|
+
* FR-13's ladder and its fail-closed end (`badAlg`, `badSignature`,
|
|
867
|
+
* `providerUnavailable`);
|
|
868
|
+
* 4. **FR-11's issuer rule**, which FR-45 makes a rule for access tokens too:
|
|
869
|
+
* `iss` equals the discovery issuer **and** is allow-listed (`badIssuer`);
|
|
870
|
+
* 5. **the audience** — `aud` contains the configured API audience, or plan
|
|
871
|
+
* §4.2's `ever-works` default (`badAudience`);
|
|
872
|
+
* 6. **the scopes** — every entry of `requiredScopes` is present (`missingScope`,
|
|
873
|
+
* FR-46's code: a valid token that lacks the scope its caller needs);
|
|
874
|
+
* 7. **the times** — a readable `exp` that has not passed (with the skew), an
|
|
875
|
+
* `iat` no later than now plus the skew, and an `iat` no older than
|
|
876
|
+
* `maxAgeSeconds` when the caller gives one (`expired`, `notYetValid`,
|
|
877
|
+
* `tooOld`);
|
|
878
|
+
* 8. **the lifetime ceiling** — `exp − iat ≤ maxLifetimeSeconds`
|
|
879
|
+
* (`lifetimeTooLong`; ACC-12-35's "lifetime over 3,600 seconds");
|
|
880
|
+
* 9. **the authorised party** — when `allowedAuthorizedParties` is given (the
|
|
881
|
+
* exchange path, FR-40), `azp` must be one of them
|
|
882
|
+
* (`badAuthorizedParty`); absent, the claim is not read at all, which is
|
|
883
|
+
* FR-45's rule set exactly.
|
|
884
|
+
*
|
|
885
|
+
* ## The two arguments that make one method serve both token families
|
|
886
|
+
*
|
|
887
|
+
* The contract has one `verifyAccessToken` and two rules of §4.3: the delegated
|
|
888
|
+
* read passes `{ requiredScopes: ['apps:read'], maxLifetimeSeconds: 3_600 }`
|
|
889
|
+
* (FR-44/FR-45), the exchange passes
|
|
890
|
+
* `{ requiredScopes: ['ever-works:session'], maxLifetimeSeconds: 3_600, maxAgeSeconds: 300, allowedAuthorizedParties: <the configured local client ids> }`
|
|
891
|
+
* (FR-40). Nothing here decides which family a token belongs to — that is the
|
|
892
|
+
* caller's, and it is a decision about *why* the token is being presented, not
|
|
893
|
+
* about the token.
|
|
894
|
+
*
|
|
895
|
+
* ## What is deliberately left to the caller
|
|
896
|
+
*
|
|
897
|
+
* **The `jti` replay window** (FR-40/FR-33's 600 seconds). The parsed `jti` is
|
|
898
|
+
* returned and never enforced: it needs a store with a unique index, which is
|
|
899
|
+
* T13's `ever-id-replay.service.ts`, and a plugin that held that state in
|
|
900
|
+
* process memory would enforce a different window per replica. Plan §4.1 says
|
|
901
|
+
* the same thing from the contract side ("the caller keeps the 600-second replay
|
|
902
|
+
* window, not the plugin").
|
|
903
|
+
*
|
|
904
|
+
* **Whether the subject is a connected identity of an active account** — FR-40's
|
|
905
|
+
* last clause and FR-45's. That is a database question and it belongs to the
|
|
906
|
+
* services that own those rows.
|
|
907
|
+
*
|
|
908
|
+
* A refusal never partially succeeds: no claim of a refused token is returned.
|
|
909
|
+
*/
|
|
910
|
+
verifyAccessToken(token: string, input: {
|
|
911
|
+
requiredScopes: string[];
|
|
912
|
+
maxLifetimeSeconds: number;
|
|
913
|
+
maxAgeSeconds?: number;
|
|
914
|
+
allowedAuthorizedParties?: string[];
|
|
915
|
+
}): Promise<VerifiedAccessTokenClaims>;
|
|
916
|
+
/**
|
|
917
|
+
* FR-33 — verify a back-channel logout notice and answer the three claims the
|
|
918
|
+
* session teardown keys on.
|
|
919
|
+
*
|
|
920
|
+
* Validated exactly like an ID token (FR-33's "validated like FR-11 and FR-13":
|
|
921
|
+
* the algorithm allow-list, the signature against Ever ID's published keys,
|
|
922
|
+
* FR-13's ladder, FR-11's issuer rule, and the times), plus FR-33's own five:
|
|
923
|
+
*
|
|
924
|
+
* - the **back-channel logout event** is present ({@link OIDC_BACKCHANNEL_LOGOUT_EVENT});
|
|
925
|
+
* - **no `nonce`** — the claim's mere presence is refused
|
|
926
|
+
* (`nonceInLogoutToken`), because a notice is not an authentication and a
|
|
927
|
+
* token that can be replayed as one is a token that should never verify;
|
|
928
|
+
* - **`sid` or `sub`**, at least one, or there is no session to end
|
|
929
|
+
* (`badLogoutEvent`);
|
|
930
|
+
* - **`jti` required** (`badLogoutEvent`) — without it the 600-second replay
|
|
931
|
+
* window has no key, and a notice that cannot be bounded must be refused
|
|
932
|
+
* rather than treated as new;
|
|
933
|
+
* - **`iat` within the skew and no older than 300 seconds** (`notYetValid`,
|
|
934
|
+
* `tooOld`).
|
|
935
|
+
*
|
|
936
|
+
* ## `exp`, and the one place a logout token differs from an ID token
|
|
937
|
+
*
|
|
938
|
+
* Plan §4.3's ID-token row requires a readable `exp`; FR-33's logout-token row
|
|
939
|
+
* does not mention one, and OpenID Connect Back-Channel Logout 1.0 §2.4 does not
|
|
940
|
+
* put `exp` in the claim set a logout token **must** carry (`iss`, `aud`, `iat`,
|
|
941
|
+
* `jti`, `events`). FR-53 makes that difference load-bearing: a provider that
|
|
942
|
+
* publishes a standards-compliant notice would have every sign-out refused as
|
|
943
|
+
* `expired` if this method demanded an `exp` the provider never promised, and a
|
|
944
|
+
* refused sign-out is a session that stays open. So `exp` is **checked when it
|
|
945
|
+
* is there** and not required, and the freshness bound FR-33 does state — `iat`
|
|
946
|
+
* within the skew and no older than 300 seconds — is enforced either way. This
|
|
947
|
+
* is a deliberate reading and is pinned by a case in
|
|
948
|
+
* `src/__tests__/logout-token.spec.ts` in both directions.
|
|
949
|
+
*
|
|
950
|
+
* ## What is deliberately left to the caller
|
|
951
|
+
*
|
|
952
|
+
* The `jti` **replay window** (FR-33's 600 seconds; see
|
|
953
|
+
* {@link verifyAccessToken}) and everything that happens to a session once this
|
|
954
|
+
* method has answered — FR-34's "ends the sessions carrying it", FR-35's "never
|
|
955
|
+
* ends a session opened by another method" and the 5-second bound are the
|
|
956
|
+
* API's, not a token verifier's.
|
|
957
|
+
*/
|
|
958
|
+
verifyLogoutToken(token: string): Promise<VerifiedLogoutTokenClaims>;
|
|
959
|
+
/**
|
|
960
|
+
* FR-36 — the address that ends the person's Ever ID session, or **`null`** when
|
|
961
|
+
* the provider publishes no end-session endpoint.
|
|
962
|
+
*
|
|
963
|
+
* The `null` is the one answer in this plugin that is neither a set of claims
|
|
964
|
+
* nor a refusal, and it means one thing only: the discovery document the plugin
|
|
965
|
+
* already read has no `end_session_endpoint`, so there is nowhere to send the
|
|
966
|
+
* person and the caller renders "sign out of Ever Works" alone. It is **not**
|
|
967
|
+
* an error path — inventing a URL, or answering the issuer's address and hoping,
|
|
968
|
+
* would send a browser somewhere the provider never agreed to serve.
|
|
969
|
+
*
|
|
970
|
+
* What is sent, and what is not:
|
|
971
|
+
*
|
|
972
|
+
* - `client_id` — how the provider knows which relying party is asking. The
|
|
973
|
+
* alternative identifier, `id_token_hint`, is deliberately **not** sent:
|
|
974
|
+
* FR-31 forbids this platform from storing an Ever ID token, so there is no
|
|
975
|
+
* ID token to hint with, and OpenID Connect RP-Initiated Logout 1.0 names
|
|
976
|
+
* `client_id` as the parameter to use in exactly that case.
|
|
977
|
+
* - `post_logout_redirect_uri` — copied **byte for byte**, the same rule
|
|
978
|
+
* FR-10 puts on the authorization request's `redirect_uri` and for the same
|
|
979
|
+
* reason: the address an administrator registered is the address that
|
|
980
|
+
* travels, never one this package rebuilt.
|
|
981
|
+
* - `state` — carried verbatim. "Validated" in FR-36 is about the value the
|
|
982
|
+
* API sealed into the sign-out transaction (plan §3.4/§3.5, T13's seal
|
|
983
|
+
* service) being the value that comes back; the plugin neither mints nor
|
|
984
|
+
* checks it, and must not, because it holds no per-session state.
|
|
985
|
+
*
|
|
986
|
+
* Errors are {@link OidcProviderUnavailableError} — T6's availability
|
|
987
|
+
* vocabulary, exactly as in {@link buildAuthorizationRequest} — rather than an
|
|
988
|
+
* `IdentityTokenRejectedError`: nothing here verifies a token, and a caller
|
|
989
|
+
* needs to know which configuration or document problem stopped the sign-out:
|
|
990
|
+
* `notConfigured`, `discoveryFailed`, `issuerDrift`, or `discoveryIncomplete`
|
|
991
|
+
* (an `end_session_endpoint` that is published but is not a URL).
|
|
992
|
+
*/
|
|
993
|
+
buildEndSessionUrl(input: {
|
|
994
|
+
postLogoutRedirectUri: string;
|
|
995
|
+
state: string;
|
|
996
|
+
}): Promise<string | null>;
|
|
997
|
+
/**
|
|
998
|
+
* Plan §9.2's plugin health view, in the platform's own lifecycle shape
|
|
999
|
+
* (`PluginHealthCheck`, `packages/plugin/src/contracts/lifecycle.types.ts:24`).
|
|
1000
|
+
*
|
|
1001
|
+
* The three states, and why each one is the honest answer:
|
|
1002
|
+
*
|
|
1003
|
+
* - **`unhealthy`** — an administrator's configuration could not be read (a
|
|
1004
|
+
* required setting is missing or blank, or `resolveSettings` refused a set
|
|
1005
|
+
* value: a non-TLS issuer outside development, or a `clockSkewSeconds`
|
|
1006
|
+
* outside FR-2's bound — the `configuration` row names the field, never the
|
|
1007
|
+
* value), or FR-14/FR-5's availability record
|
|
1008
|
+
* says the provider was last seen unusable. The second is a real outage of
|
|
1009
|
+
* this capability: sign-in is off until a **Test connection** passes.
|
|
1010
|
+
* - **`healthy`** — the settings resolve and this process has successfully
|
|
1011
|
+
* read the discovery document or the key set.
|
|
1012
|
+
* - **`unknown`** — the settings resolve but nothing has been read yet. A
|
|
1013
|
+
* plugin that has not been called is not a broken plugin, and reporting
|
|
1014
|
+
* `unhealthy` for it would make every installation that does not use Ever ID
|
|
1015
|
+
* render a red health row (the plugin ships `autoEnable: false`).
|
|
1016
|
+
*
|
|
1017
|
+
* Everything it answers is a **name, a closed code or a number** (FR-16): the
|
|
1018
|
+
* check ids are this file's own, the unavailability reason is
|
|
1019
|
+
* {@link OidcProviderUnavailableReason}'s closed set, the timestamps are epoch
|
|
1020
|
+
* milliseconds, and no message carries the issuer, the client id, the client
|
|
1021
|
+
* secret or any part of a token. `duration` is measured on the injected clock
|
|
1022
|
+
* around the one await this method performs, so a caller (and a spec) can see
|
|
1023
|
+
* how long the settings read took.
|
|
1024
|
+
*/
|
|
1025
|
+
healthCheck(): Promise<PluginHealthCheck>;
|
|
1026
|
+
/**
|
|
1027
|
+
* The in-process availability record plan §4.2 stores under `availability`.
|
|
1028
|
+
*
|
|
1029
|
+
* Additive surface for T32, which persists these into the plugin's settings row so
|
|
1030
|
+
* every replica agrees within 60 seconds (FR-5): this is the same record one
|
|
1031
|
+
* process holds, exposed so the persistence task does not have to re-derive when a
|
|
1032
|
+
* run passed.
|
|
1033
|
+
*
|
|
1034
|
+
* `jwksRefreshedAt` is T7's addition — plan §9.2's health view has carried the
|
|
1035
|
+
* field since T5 wrote it into the settings schema
|
|
1036
|
+
* (`settings.schema.ts:262`) and nothing produced it until the key cache was
|
|
1037
|
+
* wired to the flow; it is `null` until a key set has been fetched, which is
|
|
1038
|
+
* exactly what plan §9.2's admin surface renders as "unknown".
|
|
1039
|
+
*/
|
|
1040
|
+
getAvailability(): {
|
|
1041
|
+
unavailableSince: string | null;
|
|
1042
|
+
unavailableReason: OidcProviderUnavailableReason | null;
|
|
1043
|
+
discoveryRefreshedAt: string | null;
|
|
1044
|
+
jwksRefreshedAt: string | null;
|
|
1045
|
+
discoveryCacheSeconds: number;
|
|
1046
|
+
};
|
|
1047
|
+
/**
|
|
1048
|
+
* The settings this plugin reads, or the field names that are missing.
|
|
1049
|
+
*
|
|
1050
|
+
* `context.getSettings()` resolves the admin-tier values, secrets included
|
|
1051
|
+
* (`SettingsSchemaValidatorService`, plan §4.2); a throw from the platform is
|
|
1052
|
+
* treated exactly like an absent configuration, because the fail-closed answer is
|
|
1053
|
+
* the same and neither path may surface the platform's own error text (FR-16).
|
|
1054
|
+
*/
|
|
1055
|
+
private resolveSettings;
|
|
1056
|
+
/** One reader per configured issuer: a settings change starts a fresh cache (FR-14). */
|
|
1057
|
+
private readerFor;
|
|
1058
|
+
/**
|
|
1059
|
+
* The discovery document the exchange and T8's two verifiers run against, with
|
|
1060
|
+
* T6's availability errors translated to the one refusal type their callers
|
|
1061
|
+
* branch on (see {@link exchangeAuthorizationCode}'s error contract).
|
|
1062
|
+
*
|
|
1063
|
+
* Named for the verification path rather than the exchange since T8: the read is
|
|
1064
|
+
* the same one FR-14's cache and issuer-drift refusal describe, and an access
|
|
1065
|
+
* token or a sign-out notice needs the same document for the same reason — the
|
|
1066
|
+
* `jwks_uri` and the issuer its claims are compared against.
|
|
1067
|
+
*/
|
|
1068
|
+
private discoveryForVerification;
|
|
1069
|
+
/**
|
|
1070
|
+
* The one POST of the sign-in flow (FR-15, FR-19).
|
|
1071
|
+
*
|
|
1072
|
+
* `client_secret_basic` is plan §4.2's authentication method for this provider,
|
|
1073
|
+
* and `ClientSecretBasic` from `openid-client` is what produces the header —
|
|
1074
|
+
* the same function the library's own grant calls use, so the encoding of the
|
|
1075
|
+
* credentials (`application/x-www-form-urlencoded` then base64, RFC 6749
|
|
1076
|
+
* §2.3.1) is not re-implemented here. `client_id` is therefore **not** in the
|
|
1077
|
+
* body: RFC 6749 §4.1.3 requires it only "if the client is not authenticating
|
|
1078
|
+
* with the authorization server", and sending it twice is a habit providers
|
|
1079
|
+
* tolerate rather than a requirement they state.
|
|
1080
|
+
*
|
|
1081
|
+
* The response is read as JSON and the `id_token` taken from it. Every way the
|
|
1082
|
+
* call can fail is one code — `providerUnavailable` — because the closed
|
|
1083
|
+
* vocabulary has no code for "the provider refused the grant" and a precise
|
|
1084
|
+
* code with the wrong meaning is worse than a coarse one that is right (see the
|
|
1085
|
+
* report for T4/T25: an `invalid_grant` therefore reaches the API as 503 today).
|
|
1086
|
+
*/
|
|
1087
|
+
private redeemAuthorizationCode;
|
|
1088
|
+
/**
|
|
1089
|
+
* FR-11's first two clauses, through T6's key cache: the algorithm allow-list
|
|
1090
|
+
* and the signature, with the cache's own ladder (600 s, 30 s, 21,600 s) and
|
|
1091
|
+
* its `providerUnavailable` end.
|
|
1092
|
+
*
|
|
1093
|
+
* **One implementation for all three token kinds.** T7 wrote it for the ID token
|
|
1094
|
+
* and named it for one; T8's `verifyAccessToken` and `verifyLogoutToken` call the
|
|
1095
|
+
* same method, because "the algorithm is one of RS256/ES256/EdDSA and the
|
|
1096
|
+
* signature verifies against a key Ever ID published, inside FR-13's ladder" is
|
|
1097
|
+
* one rule and FR-45/FR-33 both adopt it by reference ("validated like FR-11 and
|
|
1098
|
+
* FR-13"). A second copy would be a second place for the ladder to drift.
|
|
1099
|
+
*
|
|
1100
|
+
* Both of T6's error types become `IdentityTokenRejectedError`: `badAlg` and
|
|
1101
|
+
* `badSignature` keep their code, and anything the cache could not answer
|
|
1102
|
+
* because the provider is unreachable or its keys are stale is
|
|
1103
|
+
* `providerUnavailable`. An error that is neither is a bug in this package
|
|
1104
|
+
* rather than a verdict about a token, so it propagates instead of being
|
|
1105
|
+
* disguised as a provider outage — no claims are returned either way, which is
|
|
1106
|
+
* what fail-closed means here.
|
|
1107
|
+
*/
|
|
1108
|
+
private verifyProviderSignature;
|
|
1109
|
+
/** One key cache per resolved `jwks_uri`; a provider that moves its keys starts a fresh one (FR-13). */
|
|
1110
|
+
private jwksFor;
|
|
1111
|
+
/**
|
|
1112
|
+
* The `openid-client` configuration for one document and one resolved settings
|
|
1113
|
+
* object — used for `buildAuthorizationUrl`, which is a pure URL builder (no
|
|
1114
|
+
* socket, no timer), and for nothing else.
|
|
1115
|
+
*
|
|
1116
|
+
* The rest of the flow does not go through `openid-client`'s `authorizationCodeGrant`,
|
|
1117
|
+
* and that is a measured decision rather than an omission: that call validates
|
|
1118
|
+
* the ID token itself against its own remote key set, which caches for 600
|
|
1119
|
+
* seconds and **keeps using a stale set indefinitely** — the opposite of FR-13's
|
|
1120
|
+
* 21,600-second fail-closed end that `jwks-cache.ts` exists to enforce — and it
|
|
1121
|
+
* applies its own `clockTolerance` to the time claims, which would decide
|
|
1122
|
+
* FR-11's skew edges instead of the administrator's configured
|
|
1123
|
+
* `clockSkewSeconds`. Plan §4.2 names the library for this flow; where its
|
|
1124
|
+
* built-in policy contradicts the spec, the spec's own module wins and the
|
|
1125
|
+
* library keeps the parts that are exactly right (`buildAuthorizationUrl`,
|
|
1126
|
+
* `calculatePKCECodeChallenge`, `ClientSecretBasic`) — the same trade T6
|
|
1127
|
+
* documented for `discovery()`.
|
|
1128
|
+
*/
|
|
1129
|
+
private configurationFor;
|
|
1130
|
+
/** Exactly `length` random bytes, from the injected seam or the platform CSPRNG (FR-9). */
|
|
1131
|
+
private random;
|
|
1132
|
+
/** Set or clear plan §4.2's `unavailableSince` from the five checks that gate it. */
|
|
1133
|
+
private applyAvailability;
|
|
1134
|
+
/** FR-14/FR-5: the provider is off from the first failing run until a passing one. */
|
|
1135
|
+
private recordUnavailable;
|
|
1136
|
+
/**
|
|
1137
|
+
* One log line per run, and it carries counts and check **ids** only.
|
|
1138
|
+
*
|
|
1139
|
+
* Ids come from the closed contract set, never from the provider, so nothing a
|
|
1140
|
+
* provider chose to send can reach a log through this path (FR-16).
|
|
1141
|
+
*/
|
|
1142
|
+
private logChecks;
|
|
1143
|
+
getManifest(): PluginManifest;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* APW-12 T7 — the scopes a sign-in asks Ever ID for, and the one it never asks for.
|
|
1148
|
+
*
|
|
1149
|
+
* Plan §4.2: "`src/scopes.ts`: sign-in scopes `openid email profile`; the plugin
|
|
1150
|
+
* never requests `offline_access` (spec FR-38)."
|
|
1151
|
+
*
|
|
1152
|
+
* Both halves of that sentence are requirements, and they are not symmetrical:
|
|
1153
|
+
*
|
|
1154
|
+
* - **`openid email profile`** is what a sign-in needs. `openid` is what makes
|
|
1155
|
+
* the response an OpenID Connect authentication (and what obliges the
|
|
1156
|
+
* provider to return an ID token at all, FR-11); `email` and `profile` carry
|
|
1157
|
+
* `email`, `email_verified` and `name`, which FR-23/FR-24/FR-25 branch on
|
|
1158
|
+
* when an unknown pair is offered account creation or a connection. Asking
|
|
1159
|
+
* for anything else would be asking for access the sign-in path does not
|
|
1160
|
+
* read.
|
|
1161
|
+
* - **`offline_access` is never requested.** FR-38 states the reason in one
|
|
1162
|
+
* line — "Ever Works never stores Ever ID refresh tokens in P1" — and the
|
|
1163
|
+
* cheapest way to keep that promise is not to be able to receive one: a
|
|
1164
|
+
* provider only issues a refresh token to a client that asked for
|
|
1165
|
+
* `offline_access`, so a request that never carries the scope can never
|
|
1166
|
+
* produce a token this platform would have to decide what to do with.
|
|
1167
|
+
*
|
|
1168
|
+
* ## Why this is its own module
|
|
1169
|
+
*
|
|
1170
|
+
* The scope string is a wire value three separate places need to agree on: the
|
|
1171
|
+
* authorization request this package builds (T7), the `GET /client-config`
|
|
1172
|
+
* answer a local client reads before it signs in (FR-39, plan §5.1) and the
|
|
1173
|
+
* documentation that tells a person what Ever Works asks for. It is a constant
|
|
1174
|
+
* rather than a literal because a second spelling of `'openid email profile'`
|
|
1175
|
+
* is a second thing that can drift.
|
|
1176
|
+
*
|
|
1177
|
+
* **T8** added the two scopes on the verifying side —
|
|
1178
|
+
* {@link OIDC_DELEGATED_READ_SCOPE} (`apps:read`, FR-44) and
|
|
1179
|
+
* {@link OIDC_SESSION_EXCHANGE_SCOPE} (`ever-works:session`, FR-39/FR-40) — for
|
|
1180
|
+
* the same reason, one step further along the flow: `verifyAccessToken` is handed
|
|
1181
|
+
* `requiredScopes` by its caller, and the caller and the provider's registration
|
|
1182
|
+
* have to spell them identically.
|
|
1183
|
+
*
|
|
1184
|
+
* Nothing here is a secret and nothing here is provider-specific: the same three
|
|
1185
|
+
* sign-in scopes are what any OpenID Connect relying party asks for, which is why
|
|
1186
|
+
* this file names no issuer.
|
|
1187
|
+
*/
|
|
1188
|
+
/**
|
|
1189
|
+
* The three sign-in scopes, in the order the authorization request sends them.
|
|
1190
|
+
*
|
|
1191
|
+
* The order is fixed rather than incidental: the request is asserted
|
|
1192
|
+
* byte-for-byte against this list by `src/__tests__/authorization-request.spec.ts`,
|
|
1193
|
+
* so a reordering is a visible change rather than a silent one.
|
|
1194
|
+
*/
|
|
1195
|
+
declare const OIDC_SIGN_IN_SCOPES: readonly ["openid", "email", "profile"];
|
|
1196
|
+
/** Union of {@link OIDC_SIGN_IN_SCOPES}. */
|
|
1197
|
+
type OidcSignInScope = (typeof OIDC_SIGN_IN_SCOPES)[number];
|
|
1198
|
+
/**
|
|
1199
|
+
* The `scope` parameter of every authorization request: `openid email profile`.
|
|
1200
|
+
*
|
|
1201
|
+
* Space-delimited, per RFC 6749 §3.3 — and `buildAuthorizationRequest` sends
|
|
1202
|
+
* **this** string, never one assembled at the call site.
|
|
1203
|
+
*/
|
|
1204
|
+
declare const OIDC_SIGN_IN_SCOPE: string;
|
|
1205
|
+
/**
|
|
1206
|
+
* The scopes Ever Works never asks for (FR-38).
|
|
1207
|
+
*
|
|
1208
|
+
* Named rather than inlined so the refusal is testable: `isSignInScope` answers
|
|
1209
|
+
* `false` for a request that carries one, and the spec asserts both that a
|
|
1210
|
+
* built request does not, and that the guard refuses a string that does.
|
|
1211
|
+
*/
|
|
1212
|
+
declare const OIDC_NEVER_REQUESTED_SCOPES: readonly ["offline_access"];
|
|
1213
|
+
/** One of {@link OIDC_NEVER_REQUESTED_SCOPES}. */
|
|
1214
|
+
type OidcNeverRequestedScope = (typeof OIDC_NEVER_REQUESTED_SCOPES)[number];
|
|
1215
|
+
/**
|
|
1216
|
+
* APW-12 **T8** — the two scopes Ever Works *verifies* rather than requests.
|
|
1217
|
+
*
|
|
1218
|
+
* They are the other side of the same wire vocabulary: a sign-in **asks** for
|
|
1219
|
+
* `openid email profile`, and a token that arrives from Ever ID is **checked**
|
|
1220
|
+
* against one of these. Both spellings live in this file because a scope string
|
|
1221
|
+
* three components have to agree on is exactly the thing that should have one
|
|
1222
|
+
* definition — FR-40's exchange scope is read by the local client, by the API
|
|
1223
|
+
* endpoint that verifies the token and by the provider's own registration, and
|
|
1224
|
+
* FR-44's delegated scope by the App Launcher, the marked endpoint and the
|
|
1225
|
+
* provider.
|
|
1226
|
+
*
|
|
1227
|
+
* The values are `EVER_ID_SCOPES` in `packages/contracts/src/apps/ever-id.ts`,
|
|
1228
|
+
* transcribed rather than imported for the reason this package depends on
|
|
1229
|
+
* `@ever-works/plugin` alone (see `discovery.ts`), and
|
|
1230
|
+
* `src/__tests__/access-token.spec.ts` reads that file off disk and asserts the
|
|
1231
|
+
* two agree.
|
|
1232
|
+
*/
|
|
1233
|
+
declare const OIDC_DELEGATED_READ_SCOPE = "apps:read";
|
|
1234
|
+
/**
|
|
1235
|
+
* FR-39/FR-40 — the scope a local client's access token must carry to be
|
|
1236
|
+
* exchanged for an Ever Works session (`EVER_ID_SCOPES.SESSION_EXCHANGE`).
|
|
1237
|
+
*/
|
|
1238
|
+
declare const OIDC_SESSION_EXCHANGE_SCOPE = "ever-works:session";
|
|
1239
|
+
/** The two scopes {@link OIDC_DELEGATED_READ_SCOPE} and {@link OIDC_SESSION_EXCHANGE_SCOPE} name, in one list. */
|
|
1240
|
+
declare const OIDC_TOKEN_SCOPES: readonly ["apps:read", "ever-works:session"];
|
|
1241
|
+
/** Union of {@link OIDC_TOKEN_SCOPES}. */
|
|
1242
|
+
type OidcTokenScope = (typeof OIDC_TOKEN_SCOPES)[number];
|
|
1243
|
+
/**
|
|
1244
|
+
* Is `value` exactly the sign-in scope set — the three of
|
|
1245
|
+
* {@link OIDC_SIGN_IN_SCOPES}, and nothing else?
|
|
1246
|
+
*
|
|
1247
|
+
* Deliberately an equality rather than a "contains" test: a request carrying an
|
|
1248
|
+
* extra scope (a refresh-token scope, a delegated-read scope, a scope a caller
|
|
1249
|
+
* appended) is not the sign-in request this package means to send, and the
|
|
1250
|
+
* honest answer for it is `false`. It is exported so the spec — and any future
|
|
1251
|
+
* caller that has a scope string in hand rather than the constant — can ask the
|
|
1252
|
+
* question the plugin answers by construction.
|
|
1253
|
+
*/
|
|
1254
|
+
declare function isSignInScope(value: string): boolean;
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* APW-12 T6 — the signing-key cache of the `identity-provider` capability.
|
|
1258
|
+
*
|
|
1259
|
+
* Spec FR-13, in its own words and in one place, because every number below is
|
|
1260
|
+
* one of its clauses:
|
|
1261
|
+
*
|
|
1262
|
+
* - "Signing keys are cached for **600 seconds**";
|
|
1263
|
+
* - "An unknown key ID triggers **at most one refresh per 30 seconds**";
|
|
1264
|
+
* - "While Ever ID is unreachable, cached keys stay usable for at most
|
|
1265
|
+
* **21,600 seconds** after the last successful refresh; after that,
|
|
1266
|
+
* validation **fails closed**";
|
|
1267
|
+
* - "A key Ever ID removes stops validating at the next successful refresh."
|
|
1268
|
+
*
|
|
1269
|
+
* FR-15 adds the shape of the fetch itself: 5,000 ms timeout, one retry after
|
|
1270
|
+
* 1,000 ms. Plan §4.3 is the same table; `EVER_ID_LIMITS.jwksCacheSeconds`,
|
|
1271
|
+
* `.jwksUnknownKidCooldownSeconds` and `.jwksMaxStaleSeconds` are the same numbers
|
|
1272
|
+
* in `packages/contracts/src/apps/ever-id.ts`, and
|
|
1273
|
+
* `src/__tests__/jwks-cache.spec.ts` reads that file off disk and asserts the two
|
|
1274
|
+
* agree (this package deliberately does not depend on `@ever-works/contracts` —
|
|
1275
|
+
* see `discovery.ts`).
|
|
1276
|
+
*
|
|
1277
|
+
* ## What is deliberately *not* here
|
|
1278
|
+
*
|
|
1279
|
+
* Claim validation. `iss`, `aud`, `azp`, `exp`, `iat`, `nonce` and the scope rules
|
|
1280
|
+
* of §4.3 belong to the methods that know which kind of token they were handed
|
|
1281
|
+
* (T7's `exchangeAuthorizationCode`, T8's `verifyAccessToken` /
|
|
1282
|
+
* `verifyLogoutToken`). This class answers one question — "is this signature good
|
|
1283
|
+
* under a key Ever ID published, and may we still trust the set we have?" — and
|
|
1284
|
+
* answers it with the three codes of the closed vocabulary those methods rethrow:
|
|
1285
|
+
* `badAlg`, `badSignature` and `providerUnavailable`.
|
|
1286
|
+
*
|
|
1287
|
+
* ## Why the ladder is implemented here rather than left to `jose`
|
|
1288
|
+
*
|
|
1289
|
+
* `jose`'s `createRemoteJWKSet` already defaults to a 600-second cache and a
|
|
1290
|
+
* 30-second cooldown, but it *keeps using a stale set indefinitely* when the
|
|
1291
|
+
* refetch fails — the opposite of FR-13's 21,600-second fail-closed end. It also
|
|
1292
|
+
* owns its own timer, which FR-15's 5,000 ms bound and a fake clock both need to
|
|
1293
|
+
* see. So the fetch, the clock and the ladder are ours, and `jose` does the two
|
|
1294
|
+
* things it is the dependency for: selecting a key from a JWK Set
|
|
1295
|
+
* (`createLocalJWKSet`) and verifying a compact JWS (`compactVerify`).
|
|
1296
|
+
*/
|
|
1297
|
+
/**
|
|
1298
|
+
* Every code this class can raise, taken from the contract's closed set.
|
|
1299
|
+
*
|
|
1300
|
+
* Typed as a subset of `IdentityTokenRejectionCode` on purpose: T7/T8 rethrow
|
|
1301
|
+
* these as `IdentityTokenRejectedError` with the same code, so a code invented
|
|
1302
|
+
* here would not compile.
|
|
1303
|
+
*/
|
|
1304
|
+
type OidcJwksFailureCode = Extract<IdentityTokenRejectionCode, 'badAlg' | 'badSignature'>;
|
|
1305
|
+
/**
|
|
1306
|
+
* The error a refused token raises (FR-11's signature and algorithm rules).
|
|
1307
|
+
*
|
|
1308
|
+
* `message` is the code and nothing else (FR-16). `providerUnavailable` is not
|
|
1309
|
+
* here because it is not about the token: {@link OidcProviderUnavailableError}
|
|
1310
|
+
* already carries it, with the reason that produced it.
|
|
1311
|
+
*/
|
|
1312
|
+
declare class OidcJwksVerificationError extends Error {
|
|
1313
|
+
readonly code: OidcJwksFailureCode;
|
|
1314
|
+
constructor(code: OidcJwksFailureCode);
|
|
1315
|
+
}
|
|
1316
|
+
/** Construction options for {@link OidcJwksCache}. */
|
|
1317
|
+
interface OidcJwksCacheOptions {
|
|
1318
|
+
/** `jwks_uri` from the discovery document (FR-3's third endpoint check). */
|
|
1319
|
+
readonly jwksUri: string;
|
|
1320
|
+
/** The fetch seam, defaulting to the runtime `fetch` (see `discovery.ts`). */
|
|
1321
|
+
readonly fetchImpl?: OidcFetchImpl;
|
|
1322
|
+
/** The clock, in epoch milliseconds. Defaults to `Date.now`. */
|
|
1323
|
+
readonly now?: () => number;
|
|
1324
|
+
/** FR-15's 5,000 ms per attempt. */
|
|
1325
|
+
readonly timeoutMs?: number;
|
|
1326
|
+
/** FR-15's 1,000 ms before the single retry. */
|
|
1327
|
+
readonly retryDelayMs?: number;
|
|
1328
|
+
/** FR-13's 600 seconds. */
|
|
1329
|
+
readonly cacheSeconds?: number;
|
|
1330
|
+
/** FR-13's 30 seconds, applied to the unknown-`kid` refetch. */
|
|
1331
|
+
readonly unknownKidCooldownSeconds?: number;
|
|
1332
|
+
/** FR-13's 21,600 seconds: the point past which validation fails closed. */
|
|
1333
|
+
readonly maxStaleSeconds?: number;
|
|
1334
|
+
/** FR-11's algorithm allow-list; `none` and `HS*` are refused before any lookup. */
|
|
1335
|
+
readonly algorithms?: readonly string[];
|
|
1336
|
+
/**
|
|
1337
|
+
* Called after every **successful** key fetch, with the epoch-ms instant.
|
|
1338
|
+
*
|
|
1339
|
+
* This is the in-process source of plan §4.2's `availability.jwksRefreshedAt`
|
|
1340
|
+
* (plan §9.2's health view); T32 persists it, and the plugin owns the wiring.
|
|
1341
|
+
*/
|
|
1342
|
+
readonly onRefreshed?: (refreshedAt: number) => void;
|
|
1343
|
+
}
|
|
1344
|
+
/** What {@link OidcJwksCache.verify} answers: the header it verified and the claims it carried. */
|
|
1345
|
+
interface OidcJwksVerification {
|
|
1346
|
+
readonly header: JWSHeaderParameters;
|
|
1347
|
+
readonly payload: Record<string, unknown>;
|
|
1348
|
+
/** The `kid` the signature was verified with, when the token named one. */
|
|
1349
|
+
readonly kid: string | null;
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* The cached signing keys, and the rules that decide when they may still be used
|
|
1353
|
+
* (FR-13).
|
|
1354
|
+
*
|
|
1355
|
+
* One instance per resolved `jwks_uri`. Everything is per-process by design: keys
|
|
1356
|
+
* are public material a provider republishes on demand, so a replica that has not
|
|
1357
|
+
* fetched yet fetches on first use rather than sharing cache state (contrast the
|
|
1358
|
+
* *availability* record, which plan §9.2 keeps in persisted settings precisely
|
|
1359
|
+
* because replicas must agree on it).
|
|
1360
|
+
*/
|
|
1361
|
+
declare class OidcJwksCache {
|
|
1362
|
+
private readonly options;
|
|
1363
|
+
private jwks;
|
|
1364
|
+
private resolveKey;
|
|
1365
|
+
private refreshedAtMs;
|
|
1366
|
+
private lastAttemptAtMs;
|
|
1367
|
+
private lastUnknownKidRefreshAtMs;
|
|
1368
|
+
private lastFailure;
|
|
1369
|
+
private availableKeyCount;
|
|
1370
|
+
constructor(options: OidcJwksCacheOptions);
|
|
1371
|
+
/**
|
|
1372
|
+
* When the last **successful** fetch happened, in epoch ms, or `null`.
|
|
1373
|
+
*
|
|
1374
|
+
* FR-13's staleness clock starts here: an attempt that failed does not move
|
|
1375
|
+
* this value, which is exactly why cached keys survive a provider outage.
|
|
1376
|
+
*/
|
|
1377
|
+
get lastRefreshedAt(): number | null;
|
|
1378
|
+
/** When the last fetch was **attempted**, successful or not, or `null`. */
|
|
1379
|
+
get lastAttemptAt(): number | null;
|
|
1380
|
+
/** Why the last fetch failed, or `null` when the last one succeeded. */
|
|
1381
|
+
get lastFetchFailure(): OidcOutboundFailure | 'invalidDocument' | null;
|
|
1382
|
+
/** How many keys the cached set holds; `0` until the first successful fetch. */
|
|
1383
|
+
get keyCount(): number;
|
|
1384
|
+
/**
|
|
1385
|
+
* Verify a compact JWS against the cached key set, and answer its claims.
|
|
1386
|
+
*
|
|
1387
|
+
* The order is the plan §4.3 order and each step is load-bearing:
|
|
1388
|
+
*
|
|
1389
|
+
* 1. the protected header must parse (a truncated token is `badSignature`);
|
|
1390
|
+
* 2. `alg` must be one of FR-11's three — `none` and `HS256` are refused
|
|
1391
|
+
* **before** any key lookup, so a token can never talk us into treating a
|
|
1392
|
+
* public key as a shared secret;
|
|
1393
|
+
* 3. the key comes from the cache, refreshing once under FR-13's rules;
|
|
1394
|
+
* 4. `compactVerify` checks the signature, with the same allow-list handed to
|
|
1395
|
+
* it so the two can never disagree.
|
|
1396
|
+
*
|
|
1397
|
+
* Claim rules are the caller's (see the class docstring).
|
|
1398
|
+
*/
|
|
1399
|
+
verify(token: string): Promise<OidcJwksVerification>;
|
|
1400
|
+
/**
|
|
1401
|
+
* The key for a token's header, refreshing at most once when the `kid` is unknown
|
|
1402
|
+
* (FR-13).
|
|
1403
|
+
*
|
|
1404
|
+
* A `kid` nobody published cannot be served from the cache, and the two ways out
|
|
1405
|
+
* are ranked: refresh if FR-13's cooldown allows it, and otherwise refuse. A
|
|
1406
|
+
* refusal is `badSignature` — never `providerUnavailable` — while the cached set
|
|
1407
|
+
* is still inside its 21,600 seconds, because the set *is* trustworthy and the
|
|
1408
|
+
* token is simply not signed by anything in it.
|
|
1409
|
+
*/
|
|
1410
|
+
resolveSigningKey(header: JWSHeaderParameters, token: string): Promise<CryptoKey | KeyObject | Uint8Array>;
|
|
1411
|
+
/**
|
|
1412
|
+
* Fetch the key set now, replacing the cache **only** on success.
|
|
1413
|
+
*
|
|
1414
|
+
* Answers whether a usable set is in place afterwards. A failed fetch leaves the
|
|
1415
|
+
* previous set exactly as it was, which is what FR-13's 21,600-second window
|
|
1416
|
+
* describes; it never clears the cache and it never throws to a caller that has a
|
|
1417
|
+
* token to refuse.
|
|
1418
|
+
*/
|
|
1419
|
+
refresh(): Promise<boolean>;
|
|
1420
|
+
/**
|
|
1421
|
+
* Make sure the cache is fresh enough to answer from (FR-13's 600 seconds).
|
|
1422
|
+
*
|
|
1423
|
+
* An expired cache is refreshed; a cache that cannot be refreshed stays usable
|
|
1424
|
+
* while {@link assertTrusted} allows it, and throws `providerUnavailable` once it
|
|
1425
|
+
* does not.
|
|
1426
|
+
*/
|
|
1427
|
+
private ensureUsableKeySet;
|
|
1428
|
+
/** FR-13's 600 seconds: `age < cacheSeconds`. */
|
|
1429
|
+
private isCacheFresh;
|
|
1430
|
+
/**
|
|
1431
|
+
* FR-13's 21,600 seconds, and the fail-closed end of the ladder.
|
|
1432
|
+
*
|
|
1433
|
+
* Two distinct refusals, because they are two distinct operational facts: no key
|
|
1434
|
+
* set has ever been fetched (`keysUnavailable` — usually a provider that has
|
|
1435
|
+
* never answered), or the last good fetch is older than plan §4.3's window
|
|
1436
|
+
* (`keysStale` — plan §9.2's "JWKS stale beyond 21,600 s: every validation fails
|
|
1437
|
+
* closed").
|
|
1438
|
+
*/
|
|
1439
|
+
private assertTrusted;
|
|
1440
|
+
/**
|
|
1441
|
+
* FR-13's "at most one refresh per 30 seconds" for an unknown key id.
|
|
1442
|
+
*
|
|
1443
|
+
* Read as the rule it protects: the unknown-`kid` refetch is the one an attacker
|
|
1444
|
+
* can trigger at will by presenting a token with a made-up `kid`, so that trigger
|
|
1445
|
+
* is the one the cooldown bounds. A *successful* cache refresh is not counted
|
|
1446
|
+
* against it, because the 600-second cache already bounds those — counting them
|
|
1447
|
+
* would refuse the first token signed by a freshly rotated key for up to 30
|
|
1448
|
+
* seconds after any refresh, which is the opposite of ACC-12-08 ("a token signed
|
|
1449
|
+
* with a newly rotated key validates after one key refresh").
|
|
1450
|
+
*/
|
|
1451
|
+
private canRefreshForUnknownKid;
|
|
1452
|
+
/**
|
|
1453
|
+
* Ask jose for the key matching a header, or `null` when nothing matches.
|
|
1454
|
+
*
|
|
1455
|
+
* `JWKSNoMatchingKey` is the expected "unknown kid" answer and becomes `null` so
|
|
1456
|
+
* the caller can try FR-13's one refresh. Every other jose failure — a malformed
|
|
1457
|
+
* set, several keys matching one header — is a refusal, and none of them ever
|
|
1458
|
+
* surfaces with jose's message attached (FR-16).
|
|
1459
|
+
*/
|
|
1460
|
+
private findKey;
|
|
1461
|
+
private algorithms;
|
|
1462
|
+
private cacheSeconds;
|
|
1463
|
+
private maxStaleSeconds;
|
|
1464
|
+
private unknownKidCooldownSeconds;
|
|
1465
|
+
private now;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
export { OIDC_ACCESS_TOKEN_MAX_AGE_SECONDS, OIDC_ACCESS_TOKEN_MAX_LIFETIME_SECONDS, OIDC_BACKCHANNEL_LOGOUT_EVENT, OIDC_CODE_VERIFIER_BYTES, OIDC_CODE_VERIFIER_LENGTH, OIDC_DEFAULT_API_AUDIENCE, OIDC_DEFAULT_CLOCK_SKEW_SECONDS, OIDC_DELEGATED_READ_SCOPE, OIDC_DISCOVERY_CACHE_SECONDS, OIDC_IDENTITY_SETTING_KEYS, OIDC_IDENTITY_SIGNING_ALGS, OIDC_ID_TOKEN_MAX_AGE_SECONDS, OIDC_JWKS_CACHE_SECONDS, OIDC_JWKS_MAX_STALE_SECONDS, OIDC_JWKS_UNKNOWN_KID_COOLDOWN_SECONDS, OIDC_LOGOUT_TOKEN_MAX_AGE_SECONDS, OIDC_LOGOUT_TOKEN_REPLAY_WINDOW_SECONDS, OIDC_MAX_CLOCK_SKEW_SECONDS, OIDC_NEVER_REQUESTED_SCOPES, OIDC_NONCE_BYTES, OIDC_OUTBOUND_MAX_ATTEMPTS, OIDC_OUTBOUND_RETRY_DELAY_MS, OIDC_OUTBOUND_TIMEOUT_MS, OIDC_SESSION_EXCHANGE_SCOPE, OIDC_SIGN_IN_SCOPE, OIDC_SIGN_IN_SCOPES, OIDC_STATE_BYTES, OIDC_SUBJECT_MAX_LENGTH, OIDC_TOKEN_SCOPES, type OidcDiscoveryDocument, type OidcDiscoveryFailure, type OidcDiscoveryRead, OidcDiscoveryReader, type OidcFetchImpl, type OidcFetchInit, type OidcHttpResponse, type OidcIdentityAvailability, type OidcIdentityDelegatedClientName, type OidcIdentityLocalClient, OidcIdentityPlugin, type OidcIdentitySettingKey, type OidcIdentitySettings, type OidcIdentitySigningAlg, OidcJwksCache, type OidcJwksCacheOptions, type OidcJwksFailureCode, type OidcJwksVerification, OidcJwksVerificationError, type OidcNeverRequestedScope, type OidcOutboundFailure, OidcProviderUnavailableError, type OidcProviderUnavailableReason, type OidcSignInScope, type OidcTokenScope, OidcIdentityPlugin as default, fetchJsonOnce, isSignInScope, oidcIdentityHttpsUrlPattern, oidcIdentityIssuerUrlPattern, oidcIdentitySettingsSchema };
|