@dnsid-ai/oidc 0.20.1
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/LICENSE +201 -0
- package/README.md +94 -0
- package/dist/index.cjs +911 -0
- package/dist/index.d.cts +386 -0
- package/dist/index.d.ts +386 -0
- package/dist/index.js +884 -0
- package/package.json +48 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import * as dnsPromises from "dns/promises";
|
|
3
|
+
import * as net from "net";
|
|
4
|
+
import { createSsrfSafeFetch, isUnsafeIp } from "@dnsid-ai/transport";
|
|
5
|
+
import { decodeJwt, importJWK, compactVerify } from "jose";
|
|
6
|
+
import { requireLocalDomain } from "@dnsid-ai/protocol";
|
|
7
|
+
import {
|
|
8
|
+
ArgumentError,
|
|
9
|
+
VerificationCode,
|
|
10
|
+
VerificationError,
|
|
11
|
+
SIGNING_ALGS,
|
|
12
|
+
fromBase64Url,
|
|
13
|
+
jwkThumbprint,
|
|
14
|
+
jwkSignatureAlg,
|
|
15
|
+
normalizeFQDN,
|
|
16
|
+
toArrayBuffer,
|
|
17
|
+
toBase64Url,
|
|
18
|
+
verifyWithKey,
|
|
19
|
+
parseCompactJose,
|
|
20
|
+
parseJoseObject,
|
|
21
|
+
withVerificationBudget,
|
|
22
|
+
waitForVerification
|
|
23
|
+
} from "@dnsid-ai/protocol";
|
|
24
|
+
var OAuthError = class extends Error {
|
|
25
|
+
/** The RFC 6749 error code, when the endpoint provided one. */
|
|
26
|
+
error;
|
|
27
|
+
/** Human-readable error description, when the endpoint provided one. */
|
|
28
|
+
errorDescription;
|
|
29
|
+
constructor(error, errorDescription) {
|
|
30
|
+
super(errorDescription ? `${error}: ${errorDescription}` : error ?? "OIDC token exchange failed");
|
|
31
|
+
this.name = "OAuthError";
|
|
32
|
+
this.error = error;
|
|
33
|
+
this.errorDescription = errorDescription;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
function validateOidcTimes(config) {
|
|
37
|
+
const lifetime = config.assertionLifetime === void 0 ? 300 : config.assertionLifetime;
|
|
38
|
+
const maximum = config.maxAssertionLifetime === void 0 ? 900 : config.maxAssertionLifetime;
|
|
39
|
+
const skew = config.clockSkew === void 0 ? 30 : config.clockSkew;
|
|
40
|
+
if (!Number.isFinite(lifetime) || lifetime <= 0 || !Number.isFinite(maximum) || maximum <= 0 || !Number.isFinite(skew) || skew < 0) throw new ArgumentError("invalid OIDC lifetime or clock skew");
|
|
41
|
+
}
|
|
42
|
+
function validateTokenTimes(claims, skew) {
|
|
43
|
+
const now = Date.now() / 1e3;
|
|
44
|
+
const fail = (message) => {
|
|
45
|
+
throw new VerificationError(message, { code: VerificationCode.RecordInvalid });
|
|
46
|
+
};
|
|
47
|
+
if (typeof claims.exp !== "number" || !Number.isFinite(claims.exp)) fail("OIDC token missing exp");
|
|
48
|
+
if (typeof claims.iat !== "number" || !Number.isFinite(claims.iat)) fail("OIDC token missing iat");
|
|
49
|
+
if (claims.nbf !== void 0 && (typeof claims.nbf !== "number" || !Number.isFinite(claims.nbf))) fail("OIDC token invalid nbf");
|
|
50
|
+
if (claims.exp <= claims.iat) fail("OIDC token exp must be after iat");
|
|
51
|
+
if (now >= claims.exp) fail("OIDC token is expired");
|
|
52
|
+
if (claims.iat > now + skew || claims.nbf !== void 0 && claims.nbf > now + skew) fail("OIDC token not yet valid");
|
|
53
|
+
}
|
|
54
|
+
var DEFAULT_SCOPE = "openid";
|
|
55
|
+
var DEFAULT_ASSERTION_LIFETIME_SECONDS = 300;
|
|
56
|
+
var DEFAULT_MAX_ASSERTION_LIFETIME_SECONDS = 900;
|
|
57
|
+
var DEFAULT_CLOCK_SKEW_SECONDS = 30;
|
|
58
|
+
var DEFAULT_TOKEN_ALGS = ["RS256"];
|
|
59
|
+
var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
|
|
60
|
+
var MAX_OIDC_JSON_BYTES = 1048576;
|
|
61
|
+
var RESERVED_ASSERTION_CLAIMS = /* @__PURE__ */ new Set(["iss", "sub", "aud", "iat", "exp", "jti", "fqdn"]);
|
|
62
|
+
var OIDCTokenMinter = class {
|
|
63
|
+
domain;
|
|
64
|
+
keyProvider;
|
|
65
|
+
fetch;
|
|
66
|
+
defaultEndpointOptions;
|
|
67
|
+
defaultScope;
|
|
68
|
+
assertionLifetime;
|
|
69
|
+
maxAssertionLifetime;
|
|
70
|
+
timeoutMs;
|
|
71
|
+
allowHttpLoopbackIssuer;
|
|
72
|
+
/**
|
|
73
|
+
* @throws ArgumentError if domain is not a valid agent FQDN, the endpoint
|
|
74
|
+
* options mix modes (see {@link OIDCTokenEndpointOptions}), or timeoutMs
|
|
75
|
+
* is not a positive number.
|
|
76
|
+
*/
|
|
77
|
+
constructor(opts) {
|
|
78
|
+
try {
|
|
79
|
+
this.domain = normalizeFQDN(opts.domain, true);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
throw new ArgumentError(`domain is not a valid agent FQDN: ${e.message}`);
|
|
82
|
+
}
|
|
83
|
+
this.keyProvider = opts.keyProvider;
|
|
84
|
+
this.fetch = opts.fetch ?? createSsrfSafeFetch();
|
|
85
|
+
this.defaultEndpointOptions = validateEndpointMode({
|
|
86
|
+
issuer: opts.issuer,
|
|
87
|
+
serverUrl: opts.serverUrl,
|
|
88
|
+
discoveryUrl: opts.discoveryUrl,
|
|
89
|
+
tokenEndpoint: opts.tokenEndpoint
|
|
90
|
+
});
|
|
91
|
+
this.defaultScope = opts.defaultScope;
|
|
92
|
+
validateOidcTimes(opts);
|
|
93
|
+
this.assertionLifetime = opts.assertionLifetime;
|
|
94
|
+
this.maxAssertionLifetime = opts.maxAssertionLifetime;
|
|
95
|
+
this.timeoutMs = validateTimeoutMs(opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS);
|
|
96
|
+
this.allowHttpLoopbackIssuer = opts.allowHttpLoopbackIssuer ?? false;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Mints a signed JWT bearer client assertion for the given issuer
|
|
100
|
+
* (iss/sub/fqdn = agent domain, aud = issuer, fresh jti).
|
|
101
|
+
*
|
|
102
|
+
* @throws ArgumentError if the issuer URL is invalid, the expiry is not
|
|
103
|
+
* positive or exceeds the maximum lifetime, or additionalClaims override
|
|
104
|
+
* a reserved claim.
|
|
105
|
+
* @throws ValidationError if the operational signing key is unsupported for JWS.
|
|
106
|
+
* @throws VerificationError (SignatureInvalid) if the produced signature does
|
|
107
|
+
* not verify against the active operational key.
|
|
108
|
+
*/
|
|
109
|
+
async createAssertion(opts) {
|
|
110
|
+
return createOIDCAssertionJWT(this.domain, this.keyProvider, opts, {
|
|
111
|
+
assertionLifetime: this.assertionLifetime,
|
|
112
|
+
maxAssertionLifetime: this.maxAssertionLifetime,
|
|
113
|
+
allowHttpLoopbackIssuer: this.allowHttpLoopbackIssuer
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Resolves the token endpoint (per the configured or per-call endpoint mode),
|
|
118
|
+
* mints a fresh assertion, and performs the JWT bearer token exchange.
|
|
119
|
+
*
|
|
120
|
+
* @returns The normalized token response.
|
|
121
|
+
* @throws ArgumentError if audience is missing or the options are inconsistent.
|
|
122
|
+
* @throws VerificationError if discovery or transport fails, the target
|
|
123
|
+
* resolves to an unsafe address, or the response is malformed.
|
|
124
|
+
* @throws OAuthError if the token endpoint returns an OAuth error response.
|
|
125
|
+
*/
|
|
126
|
+
async mintToken(opts) {
|
|
127
|
+
if (!opts.audience) throw new ArgumentError("audience is required");
|
|
128
|
+
const endpoint = await resolveOIDCTokenEndpoint(this.fetch, endpointOptionsForCall(this.defaultEndpointOptions, opts), {
|
|
129
|
+
allowHttpLoopbackIssuer: this.allowHttpLoopbackIssuer,
|
|
130
|
+
timeoutMs: this.timeoutMs
|
|
131
|
+
});
|
|
132
|
+
const assertion = await this.createAssertion({
|
|
133
|
+
issuer: endpoint.issuer,
|
|
134
|
+
additionalClaims: opts.additionalAssertionClaims
|
|
135
|
+
});
|
|
136
|
+
return exchangeOIDCTokenAt(this.fetch, {
|
|
137
|
+
issuer: endpoint.issuer,
|
|
138
|
+
tokenEndpoint: endpoint.tokenEndpoint,
|
|
139
|
+
assertion,
|
|
140
|
+
audience: opts.audience,
|
|
141
|
+
scope: resolveScope(opts.scope, opts.scopes, this.defaultScope),
|
|
142
|
+
allowHttpLoopbackIssuer: this.allowHttpLoopbackIssuer,
|
|
143
|
+
timeoutMs: this.timeoutMs
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
async function createOIDCTokenMinter(opts) {
|
|
148
|
+
return new OIDCTokenMinter({
|
|
149
|
+
...opts,
|
|
150
|
+
keyProvider: await resolveOIDCKeyProvider(opts)
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
async function mintOIDCToken(opts) {
|
|
154
|
+
const minter = await createOIDCTokenMinter(opts);
|
|
155
|
+
return minter.mintToken(opts);
|
|
156
|
+
}
|
|
157
|
+
async function createOIDCKeyProviderFromJWK(privateJwk) {
|
|
158
|
+
return PrivateJWKKeyProvider.fromJWK(privateJwk);
|
|
159
|
+
}
|
|
160
|
+
var OIDCProfile = class _OIDCProfile {
|
|
161
|
+
/** Builds a profile sharing an identity manager's domain, operational key provider, and identity resolver. */
|
|
162
|
+
static fromIdentityManager(identityManager, oidc) {
|
|
163
|
+
return new _OIDCProfile({
|
|
164
|
+
domain: requireLocalDomain(identityManager),
|
|
165
|
+
keyProvider: identityManager.getKeyProvider(),
|
|
166
|
+
identityResolver: identityManager,
|
|
167
|
+
oidc
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
domain;
|
|
171
|
+
keyProvider;
|
|
172
|
+
identityResolver;
|
|
173
|
+
fetch;
|
|
174
|
+
config;
|
|
175
|
+
/** @throws ArgumentError if domain is not a valid agent FQDN. */
|
|
176
|
+
constructor(opts) {
|
|
177
|
+
try {
|
|
178
|
+
this.domain = normalizeFQDN(opts.domain, true);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
throw new ArgumentError(`domain is not a valid agent FQDN: ${e.message}`);
|
|
181
|
+
}
|
|
182
|
+
this.keyProvider = opts.keyProvider ?? null;
|
|
183
|
+
this.identityResolver = opts.identityResolver;
|
|
184
|
+
this.fetch = opts.fetch ?? createSsrfSafeFetch();
|
|
185
|
+
this.config = Object.freeze({ ...opts.oidc });
|
|
186
|
+
validateOidcTimes(this.config);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Mints a signed JWT bearer client assertion for the given issuer
|
|
190
|
+
* (iss/sub/fqdn = agent domain, aud = issuer, fresh jti).
|
|
191
|
+
*
|
|
192
|
+
* @throws ArgumentError if the issuer URL is invalid, the expiry is not
|
|
193
|
+
* positive or exceeds the maximum lifetime, or additionalClaims override
|
|
194
|
+
* a reserved claim.
|
|
195
|
+
* @throws ValidationError if the operational signing key is unsupported for JWS.
|
|
196
|
+
* @throws VerificationError (SignatureInvalid) if the produced signature does
|
|
197
|
+
* not verify against the active operational key.
|
|
198
|
+
*/
|
|
199
|
+
async createOIDCAssertion(opts) {
|
|
200
|
+
if (!this.keyProvider) throw new ArgumentError("OIDC assertion signing requires a keyProvider");
|
|
201
|
+
return createOIDCAssertionJWT(this.domain, this.keyProvider, opts, {
|
|
202
|
+
assertionLifetime: this.config.assertionLifetime,
|
|
203
|
+
maxAssertionLifetime: this.config.maxAssertionLifetime,
|
|
204
|
+
allowHttpLoopbackIssuer: this.config.allowHttpLoopbackIssuer
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Fetches and validates the issuer's discovery document. The document's
|
|
209
|
+
* issuer must match exactly, and token_endpoint/jwks_uri must share the
|
|
210
|
+
* issuer's origin.
|
|
211
|
+
*
|
|
212
|
+
* @throws ArgumentError if the issuer is not an exact HTTPS URL.
|
|
213
|
+
* @throws VerificationError if the fetch fails, redirects, or the document is invalid.
|
|
214
|
+
*/
|
|
215
|
+
async discoverOIDCIssuer(issuer, options = {}) {
|
|
216
|
+
return withVerificationBudget((signal) => discoverExplicitOIDCIssuer(this.fetch, issuer, void 0, {
|
|
217
|
+
allowHttpLoopbackIssuer: this.config.allowHttpLoopbackIssuer,
|
|
218
|
+
timeoutMs: this.config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
|
|
219
|
+
signal
|
|
220
|
+
}), options);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Discovers the issuer and performs the RFC 7523 JWT bearer exchange. When
|
|
224
|
+
* `assertion` is supplied it is presented as-is (its aud must exactly match
|
|
225
|
+
* the discovered issuer); otherwise a fresh assertion is minted.
|
|
226
|
+
*
|
|
227
|
+
* @returns The normalized token response.
|
|
228
|
+
* @throws ArgumentError if audience is missing or a supplied assertion is
|
|
229
|
+
* not a valid JWT addressed to the issuer.
|
|
230
|
+
* @throws VerificationError if discovery or transport fails, or the response is malformed.
|
|
231
|
+
* @throws OAuthError if the token endpoint returns an OAuth error response.
|
|
232
|
+
*/
|
|
233
|
+
async exchangeOIDCToken(opts) {
|
|
234
|
+
if (!opts.assertion && !this.keyProvider) {
|
|
235
|
+
throw new ArgumentError("OIDC assertion signing requires a keyProvider");
|
|
236
|
+
}
|
|
237
|
+
if (!opts.audience) throw new ArgumentError("audience is required");
|
|
238
|
+
const doc = await this.discoverOIDCIssuer(opts.issuer);
|
|
239
|
+
const assertion = opts.assertion ?? await this.createOIDCAssertion({ issuer: doc.issuer });
|
|
240
|
+
if (opts.assertion) {
|
|
241
|
+
let assertionClaims;
|
|
242
|
+
try {
|
|
243
|
+
assertionClaims = decodeJwt(opts.assertion);
|
|
244
|
+
} catch {
|
|
245
|
+
throw new ArgumentError("OIDC assertion must be a valid JWT");
|
|
246
|
+
}
|
|
247
|
+
if (!audienceExactlyMatches(assertionClaims.aud, doc.issuer)) {
|
|
248
|
+
throw new ArgumentError("OIDC assertion audience must exactly match issuer");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return exchangeOIDCTokenAt(this.fetch, {
|
|
252
|
+
issuer: doc.issuer,
|
|
253
|
+
tokenEndpoint: doc.token_endpoint,
|
|
254
|
+
assertion,
|
|
255
|
+
audience: opts.audience,
|
|
256
|
+
scope: resolveScope(opts.scope, void 0, this.config.defaultScope),
|
|
257
|
+
allowHttpLoopbackIssuer: this.config.allowHttpLoopbackIssuer,
|
|
258
|
+
timeoutMs: this.config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
/** Like {@link OIDCProfile.exchangeOIDCToken} but always mints a fresh assertion, ignoring any supplied one. */
|
|
262
|
+
getOIDCToken(opts) {
|
|
263
|
+
const { assertion: _assertion, ...fresh } = opts;
|
|
264
|
+
return this.exchangeOIDCToken(fresh);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Verifies an OIDC token end-to-end: issuer allow-list, exact audience
|
|
268
|
+
* match, header and claim hygiene, signature against the issuer's published
|
|
269
|
+
* JWKS (restricted to allowedTokenAlgorithms), and timestamp checks with the
|
|
270
|
+
* configured clock skew. Unless `verifyDnsidSubject` is false, the token
|
|
271
|
+
* subject is then verified as a DNSid identity record via the profile's
|
|
272
|
+
* identityResolver.
|
|
273
|
+
*
|
|
274
|
+
* @returns The verified subject, claims, and (unless skipped) the DNSid
|
|
275
|
+
* verification result for the subject domain.
|
|
276
|
+
* @throws ArgumentError if the issuer URL is invalid or audience is missing.
|
|
277
|
+
* @throws VerificationError with a VerificationCode (RecordInvalid,
|
|
278
|
+
* SignatureInvalid, or TLSError) identifying the first check that failed —
|
|
279
|
+
* including when the issuer is not in allowedIssuers, or when subject
|
|
280
|
+
* verification is requested without an identityResolver.
|
|
281
|
+
*/
|
|
282
|
+
async verifyOIDCToken(token, opts) {
|
|
283
|
+
return withVerificationBudget((signal) => this.verifyTokenWithinBudget(token, { ...opts, signal }), opts);
|
|
284
|
+
}
|
|
285
|
+
async verifyTokenWithinBudget(token, opts) {
|
|
286
|
+
const issuer = validateExactOIDCIssuer(opts.issuer, this.config.allowHttpLoopbackIssuer);
|
|
287
|
+
if (!(this.config.allowedIssuers ?? []).includes(issuer)) {
|
|
288
|
+
throw new VerificationError("OIDC issuer is not allowed", { code: VerificationCode.RecordInvalid });
|
|
289
|
+
}
|
|
290
|
+
if (typeof opts.audience !== "string" || !opts.audience) throw new ArgumentError("audience is required");
|
|
291
|
+
let header;
|
|
292
|
+
let claims;
|
|
293
|
+
try {
|
|
294
|
+
const compact = parseCompactJose(token);
|
|
295
|
+
header = compact.header;
|
|
296
|
+
claims = parseJoseObject(compact.payload);
|
|
297
|
+
} catch {
|
|
298
|
+
throw new VerificationError("malformed JWT", { code: VerificationCode.RecordInvalid });
|
|
299
|
+
}
|
|
300
|
+
if (claims.iss !== issuer) throw new VerificationError("OIDC issuer mismatch", { code: VerificationCode.RecordInvalid });
|
|
301
|
+
if (Object.keys(header).some((k) => !["alg", "kid", "typ"].includes(k))) {
|
|
302
|
+
throw new VerificationError("unsupported OIDC token header", { code: VerificationCode.RecordInvalid });
|
|
303
|
+
}
|
|
304
|
+
if (!audienceExactlyMatches(claims.aud, opts.audience)) {
|
|
305
|
+
throw new VerificationError("OIDC audience mismatch", { code: VerificationCode.RecordInvalid });
|
|
306
|
+
}
|
|
307
|
+
const allowedAlgs = validateAllowedTokenAlgorithms(this.config.allowedTokenAlgorithms ?? DEFAULT_TOKEN_ALGS);
|
|
308
|
+
if (!allowedAlgs.includes(header.alg)) throw new VerificationError("OIDC token algorithm is not allowed", { code: VerificationCode.SignatureInvalid });
|
|
309
|
+
validateTokenTimes(claims, this.config.clockSkew ?? DEFAULT_CLOCK_SKEW_SECONDS);
|
|
310
|
+
const doc = await this.discoverOIDCIssuer(issuer, { signal: opts.signal });
|
|
311
|
+
const jwksResponse = await fetchOIDC(this.fetch, doc.jwks_uri, { redirect: "manual" }, {
|
|
312
|
+
signal: opts.signal,
|
|
313
|
+
allowHttpLoopbackIssuer: this.config.allowHttpLoopbackIssuer,
|
|
314
|
+
timeoutMs: this.config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS
|
|
315
|
+
});
|
|
316
|
+
if (jwksResponse.status !== 200) throw new VerificationError("OIDC JWKS fetch failed", { code: VerificationCode.TLSError });
|
|
317
|
+
const jwks = requireRecord(await waitForVerification(() => readLimitedJson(jwksResponse), opts.signal), "OIDC JWKS must be a JSON object");
|
|
318
|
+
if (!Array.isArray(jwks.keys)) {
|
|
319
|
+
throw new VerificationError("OIDC JWKS keys must be an array", { code: VerificationCode.RecordInvalid });
|
|
320
|
+
}
|
|
321
|
+
if (typeof header.alg !== "string") {
|
|
322
|
+
throw new VerificationError("OIDC token missing signing algorithm", { code: VerificationCode.SignatureInvalid });
|
|
323
|
+
}
|
|
324
|
+
if (!allowedAlgs.includes(header.alg)) {
|
|
325
|
+
throw new VerificationError(`OIDC token algorithm is not allowed: ${header.alg}`, { code: VerificationCode.SignatureInvalid });
|
|
326
|
+
}
|
|
327
|
+
const signingKey = jwks.keys.find((k) => typeof k === "object" && k !== null && k.kid === header.kid);
|
|
328
|
+
if (!signingKey || !oidcKeySupportsAlg(signingKey, header.alg)) {
|
|
329
|
+
throw new VerificationError("OIDC signing key mismatch", { code: VerificationCode.SignatureInvalid });
|
|
330
|
+
}
|
|
331
|
+
const clockSkew = this.config.clockSkew ?? DEFAULT_CLOCK_SKEW_SECONDS;
|
|
332
|
+
validateTokenTimes(claims, clockSkew);
|
|
333
|
+
let verifiedClaims;
|
|
334
|
+
try {
|
|
335
|
+
await compactVerify(token, await importJWK(signingKey, header.alg), { algorithms: allowedAlgs });
|
|
336
|
+
verifiedClaims = claims;
|
|
337
|
+
} catch (e) {
|
|
338
|
+
throw new VerificationError(`OIDC token verification failed: ${e.message}`, { code: VerificationCode.SignatureInvalid });
|
|
339
|
+
}
|
|
340
|
+
if (typeof verifiedClaims.sub !== "string" || !verifiedClaims.sub) {
|
|
341
|
+
throw new VerificationError("OIDC token missing sub", { code: VerificationCode.RecordInvalid });
|
|
342
|
+
}
|
|
343
|
+
if (opts.verifyDnsidSubject !== false && !this.identityResolver) {
|
|
344
|
+
throw new VerificationError("OIDC token subject verification requires an identityResolver", { code: VerificationCode.RecordInvalid });
|
|
345
|
+
}
|
|
346
|
+
const verifiedDomain = opts.verifyDnsidSubject === false ? void 0 : await waitForVerification(() => this.identityResolver.verifyDomain(verifiedClaims.sub, opts.peerCert, { signal: opts.signal }), opts.signal);
|
|
347
|
+
validateTokenTimes(verifiedClaims, clockSkew);
|
|
348
|
+
return { issuer, subject: verifiedClaims.sub, audience: opts.audience, verifiedDomain, claims: verifiedClaims };
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
function createOIDCProfile(opts) {
|
|
352
|
+
return new OIDCProfile(opts);
|
|
353
|
+
}
|
|
354
|
+
async function resolveOIDCKeyProvider(opts) {
|
|
355
|
+
if (opts.keyProvider && opts.privateJwk) {
|
|
356
|
+
throw new ArgumentError("provide either keyProvider or privateJwk, not both");
|
|
357
|
+
}
|
|
358
|
+
if (opts.keyProvider) return opts.keyProvider;
|
|
359
|
+
if (opts.privateJwk) return createOIDCKeyProviderFromJWK(opts.privateJwk);
|
|
360
|
+
throw new ArgumentError("keyProvider or privateJwk is required");
|
|
361
|
+
}
|
|
362
|
+
var PrivateJWKKeyProvider = class _PrivateJWKKeyProvider {
|
|
363
|
+
constructor(publicJwk, privateKey) {
|
|
364
|
+
this.publicJwk = publicJwk;
|
|
365
|
+
this.privateKey = privateKey;
|
|
366
|
+
}
|
|
367
|
+
publicJwk;
|
|
368
|
+
privateKey;
|
|
369
|
+
static async fromJWK(privateJwk) {
|
|
370
|
+
if (!privateJwk || typeof privateJwk !== "object" || typeof privateJwk.d !== "string" || privateJwk.d === "") {
|
|
371
|
+
throw new ArgumentError("privateJwk must be a private JWK with a d member");
|
|
372
|
+
}
|
|
373
|
+
const publicJwk = publicJwkFromPrivateJWK(privateJwk);
|
|
374
|
+
if (!publicJwk.kid) publicJwk.kid = await jwkThumbprint(publicJwk);
|
|
375
|
+
if (!publicJwk.alg) publicJwk.alg = jwkSignatureAlg(publicJwk);
|
|
376
|
+
if (!publicJwk.use) publicJwk.use = "sig";
|
|
377
|
+
const alg = jwkSignatureAlg(publicJwk);
|
|
378
|
+
const privateKey = await crypto.subtle.importKey(
|
|
379
|
+
"jwk",
|
|
380
|
+
privateJwk,
|
|
381
|
+
importAlgorithmForSigningAlg(alg, publicJwk),
|
|
382
|
+
false,
|
|
383
|
+
["sign"]
|
|
384
|
+
);
|
|
385
|
+
return new _PrivateJWKKeyProvider(publicJwk, privateKey);
|
|
386
|
+
}
|
|
387
|
+
async signingKey() {
|
|
388
|
+
return { ...this.publicJwk };
|
|
389
|
+
}
|
|
390
|
+
async jwk(kid) {
|
|
391
|
+
if (kid !== this.publicJwk.kid) throw new ArgumentError(`key not found: ${kid}`);
|
|
392
|
+
return this.signingKey();
|
|
393
|
+
}
|
|
394
|
+
async listKeyIds() {
|
|
395
|
+
return [this.publicJwk.kid];
|
|
396
|
+
}
|
|
397
|
+
async sign(payload) {
|
|
398
|
+
const sig = await crypto.subtle.sign(
|
|
399
|
+
signAlgorithmForSigningAlg(jwkSignatureAlg(this.publicJwk)),
|
|
400
|
+
this.privateKey,
|
|
401
|
+
toArrayBuffer(payload)
|
|
402
|
+
);
|
|
403
|
+
return new Uint8Array(sig);
|
|
404
|
+
}
|
|
405
|
+
async signKey(kid, payload) {
|
|
406
|
+
if (kid !== this.publicJwk.kid) throw new ArgumentError(`key not found: ${kid}`);
|
|
407
|
+
return this.sign(payload);
|
|
408
|
+
}
|
|
409
|
+
async generateKey() {
|
|
410
|
+
throw new ArgumentError("private JWK key provider does not support key generation");
|
|
411
|
+
}
|
|
412
|
+
async activate(_kid) {
|
|
413
|
+
throw new ArgumentError("private JWK key provider does not support key activation");
|
|
414
|
+
}
|
|
415
|
+
async supersede(_kid) {
|
|
416
|
+
throw new ArgumentError("private JWK key provider does not support key purging");
|
|
417
|
+
}
|
|
418
|
+
/** @deprecated Use supersede(). */
|
|
419
|
+
async purge(kid) {
|
|
420
|
+
await this.supersede(kid);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
function publicJwkFromPrivateJWK(privateJwk) {
|
|
424
|
+
const publicJwk = { kty: privateJwk.kty, kid: privateJwk.kid ?? "" };
|
|
425
|
+
copyStringMember(privateJwk, publicJwk, "alg");
|
|
426
|
+
copyStringMember(privateJwk, publicJwk, "use");
|
|
427
|
+
copyStringMember(privateJwk, publicJwk, "crv");
|
|
428
|
+
copyStringMember(privateJwk, publicJwk, "x");
|
|
429
|
+
copyStringMember(privateJwk, publicJwk, "y");
|
|
430
|
+
copyStringMember(privateJwk, publicJwk, "n");
|
|
431
|
+
copyStringMember(privateJwk, publicJwk, "e");
|
|
432
|
+
return publicJwk;
|
|
433
|
+
}
|
|
434
|
+
function copyStringMember(source, target, key) {
|
|
435
|
+
if (typeof source[key] === "string") target[key] = source[key];
|
|
436
|
+
}
|
|
437
|
+
async function createOIDCAssertionJWT(domain, keyProvider, opts, config) {
|
|
438
|
+
const issuer = validateExactOIDCIssuer(opts.issuer, config.allowHttpLoopbackIssuer);
|
|
439
|
+
validateOidcTimes(config);
|
|
440
|
+
const expiry = opts.expiry === void 0 ? config.assertionLifetime ?? DEFAULT_ASSERTION_LIFETIME_SECONDS : opts.expiry;
|
|
441
|
+
const maxLifetime = config.maxAssertionLifetime ?? DEFAULT_MAX_ASSERTION_LIFETIME_SECONDS;
|
|
442
|
+
if (!Number.isFinite(expiry) || expiry <= 0) throw new ArgumentError("OIDC assertion expiry must be positive");
|
|
443
|
+
if (expiry > maxLifetime) throw new ArgumentError("OIDC assertion expiry exceeds maximum lifetime");
|
|
444
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
445
|
+
if (now + expiry <= now) throw new ArgumentError("OIDC assertion expiry is below timestamp resolution");
|
|
446
|
+
const claims = {
|
|
447
|
+
iss: domain,
|
|
448
|
+
sub: domain,
|
|
449
|
+
aud: [issuer],
|
|
450
|
+
iat: now,
|
|
451
|
+
exp: now + expiry,
|
|
452
|
+
jti: crypto.randomUUID(),
|
|
453
|
+
fqdn: domain
|
|
454
|
+
};
|
|
455
|
+
for (const [k, v] of Object.entries(opts.additionalClaims ?? {})) {
|
|
456
|
+
if (RESERVED_ASSERTION_CLAIMS.has(k)) {
|
|
457
|
+
throw new ArgumentError(`additionalClaims must not override reserved claim: ${k}`);
|
|
458
|
+
}
|
|
459
|
+
claims[k] = v;
|
|
460
|
+
}
|
|
461
|
+
const signingKey = await keyProvider.signingKey();
|
|
462
|
+
const alg = jwkSignatureAlg(signingKey);
|
|
463
|
+
return signCompact({ alg, kid: signingKey.kid, typ: "JWT" }, claims, keyProvider, signingKey, alg);
|
|
464
|
+
}
|
|
465
|
+
function endpointOptionsForCall(defaults, overrides) {
|
|
466
|
+
if (overrides.tokenEndpoint !== void 0) {
|
|
467
|
+
return validateEndpointMode({
|
|
468
|
+
issuer: overrides.issuer ?? (overrides.serverUrl === void 0 ? defaults.issuer : void 0),
|
|
469
|
+
serverUrl: overrides.serverUrl,
|
|
470
|
+
discoveryUrl: overrides.discoveryUrl,
|
|
471
|
+
tokenEndpoint: overrides.tokenEndpoint
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
if (overrides.issuer !== void 0 || overrides.serverUrl !== void 0) {
|
|
475
|
+
return validateEndpointMode({
|
|
476
|
+
issuer: overrides.issuer,
|
|
477
|
+
serverUrl: overrides.serverUrl,
|
|
478
|
+
discoveryUrl: overrides.discoveryUrl
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
return validateEndpointMode({ ...defaults, discoveryUrl: overrides.discoveryUrl ?? defaults.discoveryUrl });
|
|
482
|
+
}
|
|
483
|
+
function validateEndpointMode(opts) {
|
|
484
|
+
if (opts.tokenEndpoint !== void 0) {
|
|
485
|
+
if (opts.tokenEndpoint === "") throw new ArgumentError("tokenEndpoint must not be empty");
|
|
486
|
+
if (opts.issuer === void 0) throw new ArgumentError("issuer is required when tokenEndpoint is configured");
|
|
487
|
+
if (opts.issuer === "") throw new ArgumentError("issuer must not be empty when tokenEndpoint is configured");
|
|
488
|
+
if (opts.serverUrl !== void 0 || opts.discoveryUrl !== void 0) {
|
|
489
|
+
throw new ArgumentError("tokenEndpoint mode supports issuer and tokenEndpoint only");
|
|
490
|
+
}
|
|
491
|
+
} else if (opts.issuer !== void 0) {
|
|
492
|
+
if (opts.issuer === "") throw new ArgumentError("issuer must not be empty");
|
|
493
|
+
if (opts.serverUrl !== void 0) throw new ArgumentError("provide either issuer or serverUrl, not both");
|
|
494
|
+
} else if (opts.serverUrl !== void 0) {
|
|
495
|
+
if (opts.serverUrl === "") throw new ArgumentError("serverUrl must not be empty");
|
|
496
|
+
} else if (opts.discoveryUrl !== void 0) {
|
|
497
|
+
throw new ArgumentError("discoveryUrl requires issuer or serverUrl");
|
|
498
|
+
}
|
|
499
|
+
return opts;
|
|
500
|
+
}
|
|
501
|
+
async function resolveOIDCTokenEndpoint(fetch, opts, fetchOptions) {
|
|
502
|
+
if (opts.tokenEndpoint) {
|
|
503
|
+
if (!opts.issuer) throw new ArgumentError("issuer is required when tokenEndpoint is configured");
|
|
504
|
+
const issuer = validateOIDCMintIssuerRoot(opts.issuer, fetchOptions.allowHttpLoopbackIssuer);
|
|
505
|
+
validateSameOriginEndpoint(opts.tokenEndpoint, issuer, "token_endpoint");
|
|
506
|
+
return { issuer, tokenEndpoint: opts.tokenEndpoint };
|
|
507
|
+
}
|
|
508
|
+
if (opts.issuer) {
|
|
509
|
+
const doc = await discoverMintOIDCIssuer(fetch, opts.issuer, opts.discoveryUrl, fetchOptions);
|
|
510
|
+
return { issuer: doc.issuer, tokenEndpoint: doc.token_endpoint, discoveryDocument: doc };
|
|
511
|
+
}
|
|
512
|
+
if (opts.serverUrl) {
|
|
513
|
+
const serverUrl = validateBaseUrl(opts.serverUrl, "serverUrl", fetchOptions.allowHttpLoopbackIssuer);
|
|
514
|
+
const doc = await discoverServerOIDCIssuer(fetch, serverUrl, opts.discoveryUrl, fetchOptions);
|
|
515
|
+
return { issuer: doc.issuer, tokenEndpoint: `${serverUrl}/token`, discoveryDocument: doc };
|
|
516
|
+
}
|
|
517
|
+
throw new ArgumentError("issuer, serverUrl, or tokenEndpoint is required");
|
|
518
|
+
}
|
|
519
|
+
async function discoverExplicitOIDCIssuer(fetch, issuer, discoveryUrl, fetchOptions) {
|
|
520
|
+
issuer = validateExactOIDCIssuer(issuer, fetchOptions.allowHttpLoopbackIssuer);
|
|
521
|
+
const doc = await fetchOIDCDiscovery(fetch, discoveryUrl ?? `${issuer}/.well-known/openid-configuration`, fetchOptions);
|
|
522
|
+
if (doc.issuer !== issuer) throw new VerificationError("OIDC discovery issuer mismatch", { code: VerificationCode.RecordInvalid });
|
|
523
|
+
validateSameOriginEndpoint(doc.token_endpoint, issuer, "token_endpoint");
|
|
524
|
+
validateSameOriginEndpoint(doc.jwks_uri, issuer, "jwks_uri");
|
|
525
|
+
return doc;
|
|
526
|
+
}
|
|
527
|
+
async function discoverMintOIDCIssuer(fetch, issuer, discoveryUrl, fetchOptions) {
|
|
528
|
+
issuer = validateOIDCMintIssuerRoot(issuer, fetchOptions.allowHttpLoopbackIssuer);
|
|
529
|
+
const doc = await fetchOIDCDiscovery(fetch, discoveryUrl ?? `${issuer}/.well-known/openid-configuration`, fetchOptions);
|
|
530
|
+
if (doc.issuer !== issuer) throw new VerificationError("OIDC discovery issuer mismatch", { code: VerificationCode.RecordInvalid });
|
|
531
|
+
validateSameOriginEndpoint(doc.token_endpoint, issuer, "token_endpoint");
|
|
532
|
+
return doc;
|
|
533
|
+
}
|
|
534
|
+
async function discoverServerOIDCIssuer(fetch, serverUrl, discoveryUrl, fetchOptions) {
|
|
535
|
+
const doc = await fetchOIDCDiscovery(fetch, discoveryUrl ?? `${serverUrl}/.well-known/openid-configuration`, fetchOptions);
|
|
536
|
+
if (typeof doc.issuer !== "string" || doc.issuer === "") {
|
|
537
|
+
throw new VerificationError("OIDC discovery document missing issuer", { code: VerificationCode.RecordInvalid });
|
|
538
|
+
}
|
|
539
|
+
const issuer = validateExactOIDCIssuer(doc.issuer, fetchOptions.allowHttpLoopbackIssuer);
|
|
540
|
+
return { ...doc, issuer };
|
|
541
|
+
}
|
|
542
|
+
async function fetchOIDCDiscovery(fetch, discoveryUrl, fetchOptions) {
|
|
543
|
+
validateAbsoluteUrl(discoveryUrl, "discoveryUrl", fetchOptions.allowHttpLoopbackIssuer);
|
|
544
|
+
const response = await fetchOIDC(fetch, discoveryUrl, { redirect: "manual" }, fetchOptions);
|
|
545
|
+
if (response.status >= 300 && response.status < 400) throw new VerificationError("OIDC discovery redirects are not allowed", { code: VerificationCode.TLSError });
|
|
546
|
+
if (response.status !== 200) throw new VerificationError("OIDC discovery failed", { code: VerificationCode.TLSError });
|
|
547
|
+
const doc = requireRecord(await readLimitedJson(response), "OIDC discovery document must be a JSON object");
|
|
548
|
+
if (typeof doc.issuer !== "string" || doc.issuer === "") {
|
|
549
|
+
throw new VerificationError("OIDC discovery document missing issuer", { code: VerificationCode.RecordInvalid });
|
|
550
|
+
}
|
|
551
|
+
return doc;
|
|
552
|
+
}
|
|
553
|
+
async function exchangeOIDCTokenAt(fetch, opts) {
|
|
554
|
+
const form = new URLSearchParams({
|
|
555
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
556
|
+
assertion: opts.assertion,
|
|
557
|
+
audience: opts.audience
|
|
558
|
+
});
|
|
559
|
+
if (opts.scope !== void 0) form.set("scope", opts.scope);
|
|
560
|
+
const response = await fetchOIDC(fetch, opts.tokenEndpoint, {
|
|
561
|
+
method: "POST",
|
|
562
|
+
redirect: "manual",
|
|
563
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
564
|
+
body: form
|
|
565
|
+
}, {
|
|
566
|
+
allowHttpLoopbackIssuer: opts.allowHttpLoopbackIssuer,
|
|
567
|
+
timeoutMs: opts.timeoutMs
|
|
568
|
+
});
|
|
569
|
+
if (response.status >= 300 && response.status < 400) throw new VerificationError("OIDC token endpoint redirects are not allowed", { code: VerificationCode.TLSError });
|
|
570
|
+
if (response.status !== 200) {
|
|
571
|
+
const body = await readLimitedJson(response).catch(() => ({}));
|
|
572
|
+
if (response.status >= 200 && response.status < 300) {
|
|
573
|
+
throw new VerificationError(`OIDC token endpoint returned unexpected successful status ${response.status}`, { code: VerificationCode.RecordInvalid });
|
|
574
|
+
}
|
|
575
|
+
const raw2 = requireRecord(body, "OIDC token response must be a JSON object");
|
|
576
|
+
throw new OAuthError(asString(raw2.error), asString(raw2.error_description));
|
|
577
|
+
}
|
|
578
|
+
const raw = requireRecord(await readLimitedJson(response), "OIDC token response must be a JSON object");
|
|
579
|
+
if (typeof raw.access_token !== "string" || !raw.access_token) {
|
|
580
|
+
throw new VerificationError("OIDC token response missing access_token", { code: VerificationCode.RecordInvalid });
|
|
581
|
+
}
|
|
582
|
+
if (typeof raw.token_type !== "string" || raw.token_type.toLowerCase() !== "bearer") {
|
|
583
|
+
throw new VerificationError("OIDC token response token_type must be Bearer", { code: VerificationCode.RecordInvalid });
|
|
584
|
+
}
|
|
585
|
+
return {
|
|
586
|
+
accessToken: raw.access_token,
|
|
587
|
+
idToken: asString(raw.id_token),
|
|
588
|
+
tokenType: raw.token_type,
|
|
589
|
+
expiresIn: typeof raw.expires_in === "number" ? raw.expires_in : void 0,
|
|
590
|
+
scope: asString(raw.scope),
|
|
591
|
+
issuer: opts.issuer,
|
|
592
|
+
tokenEndpoint: opts.tokenEndpoint,
|
|
593
|
+
raw
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
function resolveScope(scope, scopes, defaultScope) {
|
|
597
|
+
if (scope !== void 0 && scopes !== void 0) {
|
|
598
|
+
throw new ArgumentError("provide either scope or scopes, not both");
|
|
599
|
+
}
|
|
600
|
+
const value = scopes !== void 0 ? scopes.join(" ") : scope ?? defaultScope ?? DEFAULT_SCOPE;
|
|
601
|
+
return value === "" ? void 0 : value;
|
|
602
|
+
}
|
|
603
|
+
function validateExactOIDCIssuer(issuer, allowHttpLoopbackIssuer = false) {
|
|
604
|
+
let url;
|
|
605
|
+
try {
|
|
606
|
+
url = new URL(issuer);
|
|
607
|
+
} catch {
|
|
608
|
+
throw new ArgumentError("invalid OIDC issuer URL");
|
|
609
|
+
}
|
|
610
|
+
const path = url.pathname === "/" ? "" : url.pathname;
|
|
611
|
+
if (issuer !== `${url.protocol}//${url.host}${path}` || issuer.endsWith("/")) {
|
|
612
|
+
throw new ArgumentError("OIDC issuer must be an exact URL without query, fragment, or trailing slash");
|
|
613
|
+
}
|
|
614
|
+
if (url.protocol !== "https:" && !(allowHttpLoopbackIssuer && url.protocol === "http:" && isLocalhost(url.hostname))) {
|
|
615
|
+
throw new ArgumentError("OIDC issuer must use HTTPS");
|
|
616
|
+
}
|
|
617
|
+
return issuer;
|
|
618
|
+
}
|
|
619
|
+
function validateOIDCMintIssuerRoot(issuer, allowHttpLoopbackIssuer = false) {
|
|
620
|
+
let url;
|
|
621
|
+
try {
|
|
622
|
+
url = new URL(issuer);
|
|
623
|
+
} catch {
|
|
624
|
+
throw new ArgumentError("invalid OIDC issuer URL");
|
|
625
|
+
}
|
|
626
|
+
if (issuer !== `${url.protocol}//${url.host}`) {
|
|
627
|
+
throw new ArgumentError("OIDC issuer must be an exact issuer root with no path, query, fragment, or trailing slash");
|
|
628
|
+
}
|
|
629
|
+
const host = unbracketHost(url.hostname).toLowerCase().replace(/\.$/, "");
|
|
630
|
+
if (host === "api.dnsid.dev" || host === "api.dnsid.ai") {
|
|
631
|
+
throw new ArgumentError(`API host ${host} is not a valid OIDC issuer`);
|
|
632
|
+
}
|
|
633
|
+
if (url.protocol !== "https:" && !(allowHttpLoopbackIssuer && url.protocol === "http:" && isLocalhost(url.hostname))) {
|
|
634
|
+
throw new ArgumentError("OIDC issuer must use HTTPS");
|
|
635
|
+
}
|
|
636
|
+
return issuer;
|
|
637
|
+
}
|
|
638
|
+
function validateBaseUrl(raw, name, allowHttpLoopbackIssuer = false) {
|
|
639
|
+
const url = validateAbsoluteUrl(raw, name, allowHttpLoopbackIssuer);
|
|
640
|
+
if (url.search || url.hash) throw new ArgumentError(`${name} must not include query or fragment`);
|
|
641
|
+
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, "");
|
|
642
|
+
return `${url.protocol}//${url.host}${path}`;
|
|
643
|
+
}
|
|
644
|
+
function validateAbsoluteUrl(raw, name, allowHttpLoopbackIssuer = false) {
|
|
645
|
+
let url;
|
|
646
|
+
try {
|
|
647
|
+
url = new URL(raw);
|
|
648
|
+
} catch {
|
|
649
|
+
throw new ArgumentError(`invalid OIDC ${name} URL`);
|
|
650
|
+
}
|
|
651
|
+
const loopbackHttp = allowHttpLoopbackIssuer && url.protocol === "http:" && isLocalhost(url.hostname);
|
|
652
|
+
if (url.protocol !== "https:" && !loopbackHttp) {
|
|
653
|
+
throw new ArgumentError(`OIDC ${name} must use HTTPS`);
|
|
654
|
+
}
|
|
655
|
+
if (url.hash) throw new ArgumentError(`OIDC ${name} must not include a fragment`);
|
|
656
|
+
return url;
|
|
657
|
+
}
|
|
658
|
+
function validateSameOriginEndpoint(endpoint, issuer, name) {
|
|
659
|
+
if (typeof endpoint !== "string") throw new VerificationError(`OIDC ${name} is required`, { code: VerificationCode.RecordInvalid });
|
|
660
|
+
let url;
|
|
661
|
+
try {
|
|
662
|
+
url = new URL(endpoint);
|
|
663
|
+
} catch {
|
|
664
|
+
throw new VerificationError(`invalid OIDC ${name}`, { code: VerificationCode.RecordInvalid });
|
|
665
|
+
}
|
|
666
|
+
const issuerURL = new URL(issuer);
|
|
667
|
+
if (url.protocol !== issuerURL.protocol || url.host !== issuerURL.host) {
|
|
668
|
+
throw new VerificationError(`OIDC ${name} must use the issuer origin`, { code: VerificationCode.RecordInvalid });
|
|
669
|
+
}
|
|
670
|
+
if (!url.pathname || url.pathname === "/" || url.search || url.hash) {
|
|
671
|
+
throw new VerificationError(`invalid OIDC ${name}`, { code: VerificationCode.RecordInvalid });
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function validateTimeoutMs(timeoutMs) {
|
|
675
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
676
|
+
throw new ArgumentError("OIDC fetch timeout must be a positive number of milliseconds");
|
|
677
|
+
}
|
|
678
|
+
return timeoutMs;
|
|
679
|
+
}
|
|
680
|
+
function isLocalhost(hostname) {
|
|
681
|
+
const host = unbracketHost(hostname);
|
|
682
|
+
const family = net.isIP(host);
|
|
683
|
+
return host === "localhost" || family === 4 && host.split(".")[0] === "127" || family === 6 && host === "::1";
|
|
684
|
+
}
|
|
685
|
+
function audienceExactlyMatches(value, expected) {
|
|
686
|
+
return value === expected || Array.isArray(value) && value.length === 1 && value[0] === expected;
|
|
687
|
+
}
|
|
688
|
+
function asString(value) {
|
|
689
|
+
return typeof value === "string" ? value : void 0;
|
|
690
|
+
}
|
|
691
|
+
function oidcKeySupportsAlg(key, alg) {
|
|
692
|
+
if (key.alg !== void 0 && key.alg !== alg) return false;
|
|
693
|
+
if (key.kty === "RSA") return /^RS(256|384|512)$/.test(alg) || /^PS(256|384|512)$/.test(alg);
|
|
694
|
+
try {
|
|
695
|
+
return jwkSignatureAlg({ ...key, alg }) === alg;
|
|
696
|
+
} catch {
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
function validateAllowedTokenAlgorithms(algs) {
|
|
701
|
+
for (const alg of algs) {
|
|
702
|
+
if (!SIGNING_ALGS.has(alg)) {
|
|
703
|
+
throw new VerificationError(`unsupported OIDC token algorithm: ${alg}`, { code: VerificationCode.SignatureInvalid });
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return algs;
|
|
707
|
+
}
|
|
708
|
+
async function fetchOIDC(fetch, input, init, options = {}) {
|
|
709
|
+
const url = typeof input === "string" ? new URL(input) : input instanceof URL ? input : new URL(input.url);
|
|
710
|
+
const timeoutMs = validateTimeoutMs(options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS);
|
|
711
|
+
const allowHttpLoopbackIssuer = options.allowHttpLoopbackIssuer ?? false;
|
|
712
|
+
const loopbackHttp = allowHttpLoopbackIssuer && url.protocol === "http:" && isLocalhost(url.hostname);
|
|
713
|
+
if (url.protocol !== "https:" && !loopbackHttp) {
|
|
714
|
+
throw new VerificationError("OIDC request target is not allowed", { code: VerificationCode.TLSError });
|
|
715
|
+
}
|
|
716
|
+
const signal = options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
|
|
717
|
+
if (!loopbackHttp) await waitForVerification(() => rejectUnsafeOIDCTarget(url.hostname, timeoutMs), signal);
|
|
718
|
+
try {
|
|
719
|
+
return await waitForVerification(() => fetch(input, { ...init, signal }), signal);
|
|
720
|
+
} catch (e) {
|
|
721
|
+
throw new VerificationError(`OIDC request failed: ${e.message}`, { code: VerificationCode.TLSError });
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
async function rejectUnsafeOIDCTarget(hostname, timeoutMs) {
|
|
725
|
+
try {
|
|
726
|
+
const host = unbracketHost(hostname);
|
|
727
|
+
const literalFamily = net.isIP(host);
|
|
728
|
+
const addresses = literalFamily ? [{ address: host, family: literalFamily }] : await withTimeout(
|
|
729
|
+
dnsPromises.lookup(host, { all: true }),
|
|
730
|
+
timeoutMs,
|
|
731
|
+
`OIDC issuer DNS lookup timed out after ${timeoutMs}ms`
|
|
732
|
+
);
|
|
733
|
+
rejectUnsafeOIDCAddresses(addresses);
|
|
734
|
+
} catch (e) {
|
|
735
|
+
if (e instanceof VerificationError) throw e;
|
|
736
|
+
throw new VerificationError(`OIDC issuer DNS lookup failed: ${e.message}`, { code: VerificationCode.TLSError });
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
async function withTimeout(operation, timeoutMs, message) {
|
|
740
|
+
let timeout;
|
|
741
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
742
|
+
timeout = setTimeout(() => reject(new VerificationError(message, { code: VerificationCode.TLSError })), timeoutMs);
|
|
743
|
+
timeout.unref?.();
|
|
744
|
+
});
|
|
745
|
+
try {
|
|
746
|
+
return await Promise.race([operation, timeoutPromise]);
|
|
747
|
+
} finally {
|
|
748
|
+
if (timeout) clearTimeout(timeout);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
function rejectUnsafeOIDCAddresses(addresses) {
|
|
752
|
+
const unsafe = addresses.find(({ address }) => isUnsafeIp(address));
|
|
753
|
+
if (unsafe) {
|
|
754
|
+
throw new VerificationError(`OIDC request target resolves to unsafe IP address: ${unsafe.address}`, { code: VerificationCode.TLSError });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
function unbracketHost(hostname) {
|
|
758
|
+
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
759
|
+
}
|
|
760
|
+
function requireRecord(value, message) {
|
|
761
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
762
|
+
throw new VerificationError(message, { code: VerificationCode.RecordInvalid });
|
|
763
|
+
}
|
|
764
|
+
return value;
|
|
765
|
+
}
|
|
766
|
+
async function readLimitedJson(response) {
|
|
767
|
+
const body = response.body;
|
|
768
|
+
if (!body) return null;
|
|
769
|
+
const reader = body.getReader();
|
|
770
|
+
try {
|
|
771
|
+
const chunks = [];
|
|
772
|
+
let size = 0;
|
|
773
|
+
for (; ; ) {
|
|
774
|
+
const { done, value } = await reader.read();
|
|
775
|
+
if (done) break;
|
|
776
|
+
size += value.byteLength;
|
|
777
|
+
if (size > MAX_OIDC_JSON_BYTES) {
|
|
778
|
+
await reader.cancel();
|
|
779
|
+
throw new VerificationError("OIDC response exceeds maximum size", { code: VerificationCode.TLSError });
|
|
780
|
+
}
|
|
781
|
+
chunks.push(value);
|
|
782
|
+
}
|
|
783
|
+
const bytes = new Uint8Array(size);
|
|
784
|
+
let offset = 0;
|
|
785
|
+
for (const chunk of chunks) {
|
|
786
|
+
bytes.set(chunk, offset);
|
|
787
|
+
offset += chunk.byteLength;
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
791
|
+
} catch {
|
|
792
|
+
throw new VerificationError("OIDC response is not valid JSON", { code: VerificationCode.RecordInvalid });
|
|
793
|
+
}
|
|
794
|
+
} finally {
|
|
795
|
+
reader.releaseLock();
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
async function signCompact(header, payload, keyProvider, signingKey, alg) {
|
|
799
|
+
const headerB64 = toBase64Url(new TextEncoder().encode(JSON.stringify(header)));
|
|
800
|
+
const payloadB64 = toBase64Url(new TextEncoder().encode(JSON.stringify(payload)));
|
|
801
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
802
|
+
const sig = await keyProvider.sign(new TextEncoder().encode(signingInput));
|
|
803
|
+
if (!await verifyWithKey(signingInput, sig, signingKey, alg)) {
|
|
804
|
+
throw new VerificationError("OIDC assertion signature does not match active signing key", { code: VerificationCode.SignatureInvalid });
|
|
805
|
+
}
|
|
806
|
+
return `${signingInput}.${toBase64Url(sig)}`;
|
|
807
|
+
}
|
|
808
|
+
function importAlgorithmForSigningAlg(alg, key) {
|
|
809
|
+
switch (alg) {
|
|
810
|
+
case "EdDSA":
|
|
811
|
+
return { name: "Ed25519" };
|
|
812
|
+
case "ES256":
|
|
813
|
+
return { name: "ECDSA", namedCurve: "P-256" };
|
|
814
|
+
case "ES384":
|
|
815
|
+
return { name: "ECDSA", namedCurve: "P-384" };
|
|
816
|
+
case "ES512":
|
|
817
|
+
return { name: "ECDSA", namedCurve: "P-521" };
|
|
818
|
+
case "RS256":
|
|
819
|
+
case "RS384":
|
|
820
|
+
case "RS512":
|
|
821
|
+
return { name: "RSASSA-PKCS1-v1_5", hash: rsaHashForAlg(alg) };
|
|
822
|
+
case "PS256":
|
|
823
|
+
case "PS384":
|
|
824
|
+
case "PS512":
|
|
825
|
+
return { name: "RSA-PSS", hash: rsaHashForAlg(alg) };
|
|
826
|
+
default:
|
|
827
|
+
throw new VerificationError(`unsupported JWK signature algorithm: ${key.kid ? `${key.kid} ` : ""}${alg}`, {
|
|
828
|
+
code: VerificationCode.SignatureInvalid
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
function signAlgorithmForSigningAlg(alg) {
|
|
833
|
+
switch (alg) {
|
|
834
|
+
case "EdDSA":
|
|
835
|
+
return { name: "Ed25519" };
|
|
836
|
+
case "ES256":
|
|
837
|
+
return { name: "ECDSA", hash: "SHA-256" };
|
|
838
|
+
case "ES384":
|
|
839
|
+
return { name: "ECDSA", hash: "SHA-384" };
|
|
840
|
+
case "ES512":
|
|
841
|
+
return { name: "ECDSA", hash: "SHA-512" };
|
|
842
|
+
case "RS256":
|
|
843
|
+
case "RS384":
|
|
844
|
+
case "RS512":
|
|
845
|
+
return { name: "RSASSA-PKCS1-v1_5" };
|
|
846
|
+
case "PS256":
|
|
847
|
+
return { name: "RSA-PSS", saltLength: 32 };
|
|
848
|
+
case "PS384":
|
|
849
|
+
return { name: "RSA-PSS", saltLength: 48 };
|
|
850
|
+
case "PS512":
|
|
851
|
+
return { name: "RSA-PSS", saltLength: 64 };
|
|
852
|
+
default:
|
|
853
|
+
throw new VerificationError(`unsupported JWK signature algorithm: ${alg}`, { code: VerificationCode.SignatureInvalid });
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
function rsaHashForAlg(alg) {
|
|
857
|
+
if (alg.endsWith("256")) return "SHA-256";
|
|
858
|
+
if (alg.endsWith("384")) return "SHA-384";
|
|
859
|
+
return "SHA-512";
|
|
860
|
+
}
|
|
861
|
+
function decodeOIDCClaims(token) {
|
|
862
|
+
const parts = token.split(".");
|
|
863
|
+
if (parts.length < 2) throw new VerificationError("malformed JWT", { code: VerificationCode.RecordInvalid });
|
|
864
|
+
try {
|
|
865
|
+
const claims = JSON.parse(new TextDecoder().decode(fromBase64Url(parts[1])));
|
|
866
|
+
if (typeof claims !== "object" || claims === null || Array.isArray(claims)) {
|
|
867
|
+
throw new Error("invalid claims");
|
|
868
|
+
}
|
|
869
|
+
return claims;
|
|
870
|
+
} catch {
|
|
871
|
+
throw new VerificationError("malformed JWT", { code: VerificationCode.RecordInvalid });
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
export {
|
|
875
|
+
OAuthError,
|
|
876
|
+
OIDCProfile,
|
|
877
|
+
OIDCTokenMinter,
|
|
878
|
+
createOIDCKeyProviderFromJWK,
|
|
879
|
+
createOIDCProfile,
|
|
880
|
+
createOIDCTokenMinter,
|
|
881
|
+
decodeOIDCClaims,
|
|
882
|
+
mintOIDCToken,
|
|
883
|
+
validateExactOIDCIssuer
|
|
884
|
+
};
|