@better-auth/oauth-provider 1.7.0-rc.5 → 1.7.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/authorize-BP2_wo6r.mjs +5710 -0
- package/dist/client-resource.d.mts +5 -7
- package/dist/client-resource.mjs +4 -4
- package/dist/client.d.mts +4 -2
- package/dist/client.mjs +2 -2
- package/dist/index.d.mts +9 -9
- package/dist/index.mjs +5 -5992
- package/dist/internal.d.mts +2 -1
- package/dist/internal.mjs +1 -2
- package/dist/introspect-Cs1_yhqH.mjs +2551 -0
- package/dist/{oauth-CjcHkAmR.d.mts → oauth-1Ud-hvZY.d.mts} +13 -26
- package/dist/{oauth-4LGMyr3C.d.mts → oauth-D8xpKR0_.d.mts} +143 -95
- package/dist/{resource-challenge-CiJTlsEh.mjs → resource-challenge-Damwi0Or.mjs} +1 -1
- package/dist/rolldown-runtime-wcPFST8Q.mjs +13 -0
- package/dist/{signed-query-BQAwsV_w.mjs → signed-query-Df1MNiSH.mjs} +1 -1
- package/dist/{utils-jZEA4KQb.mjs → utils-CCi1maVd.mjs} +4 -4
- package/dist/{version-CwxXc1Y6.mjs → version-C0TE7qRr.mjs} +1 -1
- package/package.json +7 -7
- package/dist/client-assertion-Cy8ZcH7P.mjs +0 -469
- package/dist/register-BjyzmoSI.mjs +0 -1557
|
@@ -16,7 +16,7 @@ function canonicalizeOAuthQueryParams(params) {
|
|
|
16
16
|
}
|
|
17
17
|
function setSignedOAuthQueryParameterNames(params) {
|
|
18
18
|
params.delete(signedQueryParameterNameParam);
|
|
19
|
-
const signedParameterNames = [
|
|
19
|
+
const signedParameterNames = [...new Set([...params.keys(), signedQueryParameterNameParam])].sort();
|
|
20
20
|
for (const parameterName of signedParameterNames) params.append(signedQueryParameterNameParam, parameterName);
|
|
21
21
|
}
|
|
22
22
|
function getSignedOAuthQueryParameterNames(params) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as canonicalizeOAuthQueryParams } from "./signed-query-
|
|
1
|
+
import { n as canonicalizeOAuthQueryParams } from "./signed-query-Df1MNiSH.mjs";
|
|
2
2
|
import { constantTimeEqual, makeSignature, symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto";
|
|
3
3
|
import { APIError } from "better-call";
|
|
4
4
|
import { logger } from "@better-auth/core/env";
|
|
@@ -151,7 +151,7 @@ function getExtensionGrantTypes(opts) {
|
|
|
151
151
|
return getOAuthProviderExtensions(opts).flatMap((extension) => Object.keys(extension.grants ?? {}));
|
|
152
152
|
}
|
|
153
153
|
function getSupportedGrantTypes(opts) {
|
|
154
|
-
return Array.from(
|
|
154
|
+
return Array.from(new Set([...opts.grantTypes ?? DEFAULT_GRANT_TYPES, ...getExtensionGrantTypes(opts)]));
|
|
155
155
|
}
|
|
156
156
|
function getExtensionGrantHandler(opts, grantType) {
|
|
157
157
|
for (const extension of getOAuthProviderExtensions(opts)) {
|
|
@@ -169,7 +169,7 @@ function getExtensionTokenEndpointAuthMethods(opts) {
|
|
|
169
169
|
* endpoints, which never accept public clients, omit it (the default).
|
|
170
170
|
*/
|
|
171
171
|
function getSupportedAuthMethods(opts, settings) {
|
|
172
|
-
return Array.from(
|
|
172
|
+
return Array.from(new Set([
|
|
173
173
|
...settings?.includeNone ? ["none"] : [],
|
|
174
174
|
...BUILT_IN_CONFIDENTIAL_AUTH_METHODS,
|
|
175
175
|
...getExtensionTokenEndpointAuthMethods(opts)
|
|
@@ -707,7 +707,7 @@ async function extractClientCredentials(ctx, opts, expectedAudience) {
|
|
|
707
707
|
confirmation: result.confirmation
|
|
708
708
|
};
|
|
709
709
|
}
|
|
710
|
-
const { verifyClientAssertion: verify } = await import("./
|
|
710
|
+
const { verifyClientAssertion: verify } = await import("./authorize-BP2_wo6r.mjs").then((n) => n.o);
|
|
711
711
|
return {
|
|
712
712
|
kind: "pre_verified",
|
|
713
713
|
method: "private_key_jwt",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/oauth-provider",
|
|
3
|
-
"version": "1.7.0
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "An oauth provider plugin for Better Auth",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -70,17 +70,17 @@
|
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"listhen": "^1.9.0",
|
|
73
|
-
"tsdown": "0.
|
|
74
|
-
"@better-auth/core": "1.7.0
|
|
75
|
-
"better-auth": "1.7.0
|
|
76
|
-
"
|
|
73
|
+
"tsdown": "0.21.10",
|
|
74
|
+
"@better-auth/core": "1.7.0",
|
|
75
|
+
"@better-auth/memory-adapter": "1.7.0",
|
|
76
|
+
"better-auth": "1.7.0"
|
|
77
77
|
},
|
|
78
78
|
"peerDependencies": {
|
|
79
79
|
"@better-auth/utils": "0.4.2",
|
|
80
80
|
"@better-fetch/fetch": "1.3.1",
|
|
81
81
|
"better-call": "1.4.0",
|
|
82
|
-
"@better-auth/core": "^1.7.0
|
|
83
|
-
"better-auth": "^1.7.0
|
|
82
|
+
"@better-auth/core": "^1.7.0",
|
|
83
|
+
"better-auth": "^1.7.0"
|
|
84
84
|
},
|
|
85
85
|
"scripts": {
|
|
86
86
|
"build": "tsdown",
|
|
@@ -1,469 +0,0 @@
|
|
|
1
|
-
import { P as getClientDiscoveries, a as getClient } from "./utils-jZEA4KQb.mjs";
|
|
2
|
-
import { isPublicRoutableHost } from "@better-auth/core/utils/host";
|
|
3
|
-
import { APIError } from "better-call";
|
|
4
|
-
import { CLIENT_ASSERTION_TYPE, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS } from "@better-auth/core/oauth2";
|
|
5
|
-
import { base64Url } from "@better-auth/utils/base64";
|
|
6
|
-
import { createHash } from "@better-auth/utils/hash";
|
|
7
|
-
import { createLocalJWKSet, decodeJwt, decodeProtectedHeader, jwtVerify } from "jose";
|
|
8
|
-
//#region \0rolldown/runtime.js
|
|
9
|
-
var __defProp = Object.defineProperty;
|
|
10
|
-
var __exportAll = (all, no_symbols) => {
|
|
11
|
-
let target = {};
|
|
12
|
-
for (var name in all) __defProp(target, name, {
|
|
13
|
-
get: all[name],
|
|
14
|
-
enumerable: true
|
|
15
|
-
});
|
|
16
|
-
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
17
|
-
return target;
|
|
18
|
-
};
|
|
19
|
-
//#endregion
|
|
20
|
-
//#region src/client-jwks.ts
|
|
21
|
-
const EC_PRIVATE_KEY_JWT_ALGORITHM_BY_CURVE = {
|
|
22
|
-
"P-256": "ES256",
|
|
23
|
-
"P-384": "ES384",
|
|
24
|
-
"P-521": "ES512"
|
|
25
|
-
};
|
|
26
|
-
const OKP_PRIVATE_KEY_JWT_SIGNING_CURVES = ["Ed25519"];
|
|
27
|
-
const PRIVATE_JWK_MEMBER_NAMES = [
|
|
28
|
-
"d",
|
|
29
|
-
"p",
|
|
30
|
-
"q",
|
|
31
|
-
"dp",
|
|
32
|
-
"dq",
|
|
33
|
-
"qi",
|
|
34
|
-
"oth"
|
|
35
|
-
];
|
|
36
|
-
function isRecord(value) {
|
|
37
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
38
|
-
}
|
|
39
|
-
function hasStringMember(key, memberName) {
|
|
40
|
-
return typeof key[memberName] === "string" && key[memberName].length > 0;
|
|
41
|
-
}
|
|
42
|
-
function isSupportedEcSigningCurve(curve) {
|
|
43
|
-
return typeof curve === "string" && Object.prototype.hasOwnProperty.call(EC_PRIVATE_KEY_JWT_ALGORITHM_BY_CURVE, curve);
|
|
44
|
-
}
|
|
45
|
-
function isSupportedOkpSigningCurve(curve) {
|
|
46
|
-
return OKP_PRIVATE_KEY_JWT_SIGNING_CURVES.some((signingCurve) => signingCurve === curve);
|
|
47
|
-
}
|
|
48
|
-
function isSupportedPublicJwk(key) {
|
|
49
|
-
switch (key.kty) {
|
|
50
|
-
case "RSA": return hasStringMember(key, "n") && hasStringMember(key, "e");
|
|
51
|
-
case "EC": return isSupportedEcSigningCurve(key.crv) && hasStringMember(key, "x") && hasStringMember(key, "y");
|
|
52
|
-
case "OKP": return isSupportedOkpSigningCurve(key.crv) && hasStringMember(key, "x");
|
|
53
|
-
default: return false;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
function hasSupportedPrivateKeyJwtAlgorithm(key) {
|
|
57
|
-
if (key.alg === void 0) return true;
|
|
58
|
-
if (typeof key.alg !== "string" || !PRIVATE_KEY_JWT_SIGNING_ALGORITHMS.some((algorithm) => algorithm === key.alg)) return false;
|
|
59
|
-
switch (key.kty) {
|
|
60
|
-
case "RSA": return key.alg.startsWith("RS") || key.alg.startsWith("PS");
|
|
61
|
-
case "EC": return isSupportedEcSigningCurve(key.crv) && EC_PRIVATE_KEY_JWT_ALGORITHM_BY_CURVE[key.crv] === key.alg;
|
|
62
|
-
case "OKP": return isSupportedOkpSigningCurve(key.crv) && key.alg === "EdDSA";
|
|
63
|
-
default: return false;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* Validates an OAuth client's public asymmetric JWK set.
|
|
68
|
-
*
|
|
69
|
-
* This boundary accepts only the RFC 7517 `{ keys: [...] }` representation.
|
|
70
|
-
* It performs no I/O and returns the validated set for downstream JOSE
|
|
71
|
-
* verification.
|
|
72
|
-
*
|
|
73
|
-
* @internal
|
|
74
|
-
*/
|
|
75
|
-
function validatePublicClientJwks(input) {
|
|
76
|
-
const keys = isRecord(input) && Array.isArray(input.keys) ? input.keys : void 0;
|
|
77
|
-
if (!keys?.length) return {
|
|
78
|
-
valid: false,
|
|
79
|
-
error: "jwks must be an RFC 7517 JWK Set object with a non-empty keys array"
|
|
80
|
-
};
|
|
81
|
-
for (const key of keys) {
|
|
82
|
-
if (!isRecord(key)) return {
|
|
83
|
-
valid: false,
|
|
84
|
-
error: "jwks keys must be supported public JWKs with required key parameters"
|
|
85
|
-
};
|
|
86
|
-
if (key.kty === "oct" || "k" in key || PRIVATE_JWK_MEMBER_NAMES.some((name) => name in key)) return {
|
|
87
|
-
valid: false,
|
|
88
|
-
error: "jwks must contain only public asymmetric keys"
|
|
89
|
-
};
|
|
90
|
-
if (!isSupportedPublicJwk(key)) return {
|
|
91
|
-
valid: false,
|
|
92
|
-
error: "jwks keys must be supported public JWKs with required key parameters"
|
|
93
|
-
};
|
|
94
|
-
if (!hasSupportedPrivateKeyJwtAlgorithm(key)) return {
|
|
95
|
-
valid: false,
|
|
96
|
-
error: "jwks key alg must be supported for private_key_jwt and compatible with its key type and signing curve"
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
return {
|
|
100
|
-
valid: true,
|
|
101
|
-
jwks: { keys }
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
//#endregion
|
|
105
|
-
//#region src/utils/client-assertion.ts
|
|
106
|
-
var client_assertion_exports = /* @__PURE__ */ __exportAll({
|
|
107
|
-
consumeClientAssertion: () => consumeClientAssertion,
|
|
108
|
-
isPrivateHostname: () => isPrivateHostname,
|
|
109
|
-
verifyClientAssertion: () => verifyClientAssertion
|
|
110
|
-
});
|
|
111
|
-
const jwksCaches = /* @__PURE__ */ new WeakMap();
|
|
112
|
-
const JWKS_CACHE_TTL_MS = 300 * 1e3;
|
|
113
|
-
const JWKS_CACHE_MAX_ENTRIES = 500;
|
|
114
|
-
const JWKS_FETCH_TIMEOUT_MS = 5e3;
|
|
115
|
-
const MAX_JWKS_RESPONSE_BYTES = 64 * 1024;
|
|
116
|
-
const JSON_CONTENT_TYPE = /^application\/(?:[-\w.]+\+)?json\s*(?:;|$)/i;
|
|
117
|
-
function setJwksCache(jwksCache, cacheKey, jwks, fetchedAt) {
|
|
118
|
-
jwksCache.set(cacheKey, {
|
|
119
|
-
jwks,
|
|
120
|
-
fetchedAt
|
|
121
|
-
});
|
|
122
|
-
if (jwksCache.size > JWKS_CACHE_MAX_ENTRIES) {
|
|
123
|
-
const oldest = jwksCache.keys().next().value;
|
|
124
|
-
if (oldest !== void 0) jwksCache.delete(oldest);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
function getJwksCache(opts) {
|
|
128
|
-
const existingCache = jwksCaches.get(opts);
|
|
129
|
-
if (existingCache) return existingCache;
|
|
130
|
-
const cache = /* @__PURE__ */ new Map();
|
|
131
|
-
jwksCaches.set(opts, cache);
|
|
132
|
-
return cache;
|
|
133
|
-
}
|
|
134
|
-
function getJwksCacheKey(client) {
|
|
135
|
-
return `${client.clientDiscoveryId ?? "managed"}:${client.jwksUri ?? ""}`;
|
|
136
|
-
}
|
|
137
|
-
const ALGORITHMS_LIST = [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS];
|
|
138
|
-
/**
|
|
139
|
-
* SSRF gate for user-supplied server-side fetch targets (`jwks_uri`,
|
|
140
|
-
* `backchannel_logout_uri`): returns true when the host is NOT publicly
|
|
141
|
-
* routable. That covers loopback, RFC 1918 private, link-local (including AWS
|
|
142
|
-
* IMDS `169.254.169.254`), shared-address-space (carrier-grade NAT),
|
|
143
|
-
* IPv4-mapped IPv6, 6to4/NAT64/Teredo tunnels, every other RFC 6890
|
|
144
|
-
* special-purpose range, and cloud-metadata FQDNs.
|
|
145
|
-
*
|
|
146
|
-
* Delegates to the audited single source of truth so this check cannot drift
|
|
147
|
-
* into the kind of encoding bypass that bespoke regexes invite. This is a
|
|
148
|
-
* syntactic check only: it does not resolve DNS, so a public name that
|
|
149
|
-
* resolves to a private address at fetch time is not caught here.
|
|
150
|
-
*/
|
|
151
|
-
function isPrivateHostname(hostname) {
|
|
152
|
-
return !isPublicRoutableHost(hostname);
|
|
153
|
-
}
|
|
154
|
-
function validateJwksUri(ctx, jwksUri, clientIdUrlOrigin) {
|
|
155
|
-
const parsed = new URL(jwksUri);
|
|
156
|
-
if (parsed.protocol !== "https:") throw new APIError("BAD_REQUEST", {
|
|
157
|
-
error_description: "jwks_uri must use HTTPS",
|
|
158
|
-
error: "invalid_client"
|
|
159
|
-
});
|
|
160
|
-
if (parsed.username || parsed.password) throw new APIError("BAD_REQUEST", {
|
|
161
|
-
error_description: "jwks_uri must not contain credentials",
|
|
162
|
-
error: "invalid_client"
|
|
163
|
-
});
|
|
164
|
-
if (jwksUri.includes("#")) throw new APIError("BAD_REQUEST", {
|
|
165
|
-
error_description: "jwks_uri must not include a fragment component",
|
|
166
|
-
error: "invalid_client"
|
|
167
|
-
});
|
|
168
|
-
if (isPrivateHostname(parsed.hostname)) throw new APIError("BAD_REQUEST", {
|
|
169
|
-
error_description: "jwks_uri must not point to a private or reserved address",
|
|
170
|
-
error: "invalid_client"
|
|
171
|
-
});
|
|
172
|
-
if (clientIdUrlOrigin && parsed.origin === clientIdUrlOrigin) return;
|
|
173
|
-
if (!ctx.context.isTrustedOrigin(parsed.href)) throw new APIError("BAD_REQUEST", {
|
|
174
|
-
error_description: "client jwks_uri is not trusted",
|
|
175
|
-
error: "invalid_client"
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
function urlClientIdOrigin(clientId) {
|
|
179
|
-
try {
|
|
180
|
-
const parsed = new URL(clientId);
|
|
181
|
-
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return;
|
|
182
|
-
return parsed.origin;
|
|
183
|
-
} catch {
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
async function readBoundedResponseBody(response) {
|
|
188
|
-
const contentLength = response.headers.get("content-length");
|
|
189
|
-
if (contentLength !== null && Number.isFinite(Number(contentLength)) && Number(contentLength) > MAX_JWKS_RESPONSE_BYTES) {
|
|
190
|
-
await response.body?.cancel();
|
|
191
|
-
throw new Error("JWKS response exceeds 64 KiB");
|
|
192
|
-
}
|
|
193
|
-
if (!response.body) return "";
|
|
194
|
-
const reader = response.body.getReader();
|
|
195
|
-
const chunks = [];
|
|
196
|
-
let totalBytes = 0;
|
|
197
|
-
while (true) {
|
|
198
|
-
const { done, value } = await reader.read();
|
|
199
|
-
if (done) break;
|
|
200
|
-
totalBytes += value.byteLength;
|
|
201
|
-
if (totalBytes > MAX_JWKS_RESPONSE_BYTES) {
|
|
202
|
-
await reader.cancel();
|
|
203
|
-
throw new Error("JWKS response exceeds 64 KiB");
|
|
204
|
-
}
|
|
205
|
-
chunks.push(value);
|
|
206
|
-
}
|
|
207
|
-
const bytes = new Uint8Array(totalBytes);
|
|
208
|
-
let offset = 0;
|
|
209
|
-
for (const chunk of chunks) {
|
|
210
|
-
bytes.set(chunk, offset);
|
|
211
|
-
offset += chunk.byteLength;
|
|
212
|
-
}
|
|
213
|
-
return new TextDecoder().decode(bytes);
|
|
214
|
-
}
|
|
215
|
-
async function fetchJwksFromUri(jwksUri, fetchClientMetadataResource = globalThis.fetch) {
|
|
216
|
-
const controller = new AbortController();
|
|
217
|
-
const timeout = setTimeout(() => controller.abort(), JWKS_FETCH_TIMEOUT_MS);
|
|
218
|
-
try {
|
|
219
|
-
const response = await fetchClientMetadataResource(jwksUri, {
|
|
220
|
-
signal: controller.signal,
|
|
221
|
-
headers: { accept: "application/json" },
|
|
222
|
-
redirect: "error"
|
|
223
|
-
});
|
|
224
|
-
if (response.redirected) throw new Error("JWKS fetch redirected");
|
|
225
|
-
if (response.status !== 200) throw new Error(`JWKS fetch returned ${response.status}`);
|
|
226
|
-
const contentType = response.headers.get("content-type");
|
|
227
|
-
if (!contentType || !JSON_CONTENT_TYPE.test(contentType)) throw new Error("JWKS response must use a JSON media type");
|
|
228
|
-
const responseBody = await readBoundedResponseBody(response);
|
|
229
|
-
let parsedBody;
|
|
230
|
-
try {
|
|
231
|
-
parsedBody = JSON.parse(responseBody);
|
|
232
|
-
} catch {
|
|
233
|
-
return { valid: false };
|
|
234
|
-
}
|
|
235
|
-
const result = validatePublicClientJwks(parsedBody);
|
|
236
|
-
if (!result.valid) return { valid: false };
|
|
237
|
-
return {
|
|
238
|
-
valid: true,
|
|
239
|
-
jwks: result.jwks
|
|
240
|
-
};
|
|
241
|
-
} finally {
|
|
242
|
-
clearTimeout(timeout);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
function createClientJwksFetchError() {
|
|
246
|
-
return new APIError("BAD_REQUEST", {
|
|
247
|
-
error_description: "failed to fetch client JWKS",
|
|
248
|
-
error: "invalid_client"
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
async function fetchClientJwks(ctx, opts, client) {
|
|
252
|
-
if (client.jwks) return JSON.parse(client.jwks);
|
|
253
|
-
if (!client.jwksUri) throw new APIError("BAD_REQUEST", {
|
|
254
|
-
error_description: "client has no JWKS configured",
|
|
255
|
-
error: "invalid_client"
|
|
256
|
-
});
|
|
257
|
-
const discovery = client.clientDiscoveryId ? getClientDiscoveries(opts).find((candidate) => candidate.id === client.clientDiscoveryId) : void 0;
|
|
258
|
-
if (client.clientDiscoveryId && !discovery?.fetchClientMetadataResource) throw new APIError("BAD_REQUEST", {
|
|
259
|
-
error_description: "client discovery does not provide a metadata resource transport",
|
|
260
|
-
error: "invalid_client"
|
|
261
|
-
});
|
|
262
|
-
validateJwksUri(ctx, client.jwksUri, client.clientDiscoveryId ? urlClientIdOrigin(client.clientId) : void 0);
|
|
263
|
-
const now = Date.now();
|
|
264
|
-
const cacheKey = getJwksCacheKey(client);
|
|
265
|
-
const jwksCache = getJwksCache(opts);
|
|
266
|
-
const cached = jwksCache.get(cacheKey);
|
|
267
|
-
if (cached && now - cached.fetchedAt < JWKS_CACHE_TTL_MS) return cached.jwks;
|
|
268
|
-
let result;
|
|
269
|
-
try {
|
|
270
|
-
result = await fetchJwksFromUri(client.jwksUri, discovery?.fetchClientMetadataResource);
|
|
271
|
-
} catch {
|
|
272
|
-
const staleLimitMs = JWKS_CACHE_TTL_MS * 2;
|
|
273
|
-
if (cached && now - cached.fetchedAt < staleLimitMs) return cached.jwks;
|
|
274
|
-
throw createClientJwksFetchError();
|
|
275
|
-
}
|
|
276
|
-
if (!result.valid) throw createClientJwksFetchError();
|
|
277
|
-
setJwksCache(jwksCache, cacheKey, result.jwks, now);
|
|
278
|
-
return result.jwks;
|
|
279
|
-
}
|
|
280
|
-
/**
|
|
281
|
-
* Refetch JWKS from jwks_uri when signature verification fails with cached keys.
|
|
282
|
-
* Handles key rotation: the client may have published a new key that isn't in our cache yet.
|
|
283
|
-
*/
|
|
284
|
-
async function refetchClientJwks(opts, client) {
|
|
285
|
-
if (!client.jwksUri) return null;
|
|
286
|
-
const discovery = client.clientDiscoveryId ? getClientDiscoveries(opts).find((candidate) => candidate.id === client.clientDiscoveryId) : void 0;
|
|
287
|
-
if (client.clientDiscoveryId && !discovery?.fetchClientMetadataResource) return null;
|
|
288
|
-
try {
|
|
289
|
-
const result = await fetchJwksFromUri(client.jwksUri, discovery?.fetchClientMetadataResource);
|
|
290
|
-
if (!result.valid) return null;
|
|
291
|
-
setJwksCache(getJwksCache(opts), getJwksCacheKey(client), result.jwks, Date.now());
|
|
292
|
-
return result.jwks;
|
|
293
|
-
} catch {
|
|
294
|
-
return null;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
/**
|
|
298
|
-
* Enforces the assertion-hygiene claims every client-assertion authentication
|
|
299
|
-
* method must check, independent of how the signature is verified or where the
|
|
300
|
-
* verification keys come from:
|
|
301
|
-
* - `aud` MUST include `expectedAudience` (RFC 7523 §3 rule 3),
|
|
302
|
-
* - `exp` MUST be present, unexpired, and at most `assertionMaxLifetime`
|
|
303
|
-
* seconds away (RFC 7523 §3 rule 4),
|
|
304
|
-
* - `iat`, when present, MUST be within `assertionMaxLifetime`,
|
|
305
|
-
* - `jti` MUST be present and single-use; this consumes a replay tombstone keyed
|
|
306
|
-
* by `` `${namespace}:${jti}` ``, inserted under the adapter's primary key so a
|
|
307
|
-
* replay across workers fails atomically.
|
|
308
|
-
*
|
|
309
|
-
* A custom {@link OAuthClientAuthenticationStrategy} should call this after
|
|
310
|
-
* verifying the assertion signature, so an extension method inherits the same
|
|
311
|
-
* replay, lifetime, and audience guarantees as the built-in `private_key_jwt`
|
|
312
|
-
* path, which calls it too.
|
|
313
|
-
*
|
|
314
|
-
* @param params.namespace Scopes the replay tombstone to the method and client,
|
|
315
|
-
* e.g. `` `${method}:${clientId}` ``, so the same `jti` can recur across
|
|
316
|
-
* distinct methods or clients but never within one.
|
|
317
|
-
*/
|
|
318
|
-
async function consumeClientAssertion(ctx, opts, params) {
|
|
319
|
-
const { namespace, payload, expectedAudience } = params;
|
|
320
|
-
if (!(Array.isArray(payload.aud) ? payload.aud : payload.aud != null ? [payload.aud] : []).includes(expectedAudience)) throw new APIError("BAD_REQUEST", {
|
|
321
|
-
error_description: "client assertion aud does not match the endpoint",
|
|
322
|
-
error: "invalid_client"
|
|
323
|
-
});
|
|
324
|
-
const maxLifetime = opts.assertionMaxLifetime ?? 300;
|
|
325
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
326
|
-
if (typeof payload.exp !== "number") throw new APIError("BAD_REQUEST", {
|
|
327
|
-
error_description: "client assertion must include exp claim",
|
|
328
|
-
error: "invalid_client"
|
|
329
|
-
});
|
|
330
|
-
if (payload.exp <= now) throw new APIError("BAD_REQUEST", {
|
|
331
|
-
error_description: "client assertion has expired",
|
|
332
|
-
error: "invalid_client"
|
|
333
|
-
});
|
|
334
|
-
if (payload.exp - now > maxLifetime) throw new APIError("BAD_REQUEST", {
|
|
335
|
-
error_description: `client assertion exp is too far in the future (max ${maxLifetime}s)`,
|
|
336
|
-
error: "invalid_client"
|
|
337
|
-
});
|
|
338
|
-
if (typeof payload.iat === "number" && now - payload.iat > maxLifetime) throw new APIError("BAD_REQUEST", {
|
|
339
|
-
error_description: `client assertion iat is too far in the past (max ${maxLifetime}s)`,
|
|
340
|
-
error: "invalid_client"
|
|
341
|
-
});
|
|
342
|
-
if (typeof payload.jti !== "string" || payload.jti.length === 0) throw new APIError("BAD_REQUEST", {
|
|
343
|
-
error_description: "client assertion must include jti claim",
|
|
344
|
-
error: "invalid_client"
|
|
345
|
-
});
|
|
346
|
-
const jtiDigest = await createHash("SHA-256").digest(new TextEncoder().encode(`${namespace}:${payload.jti}`));
|
|
347
|
-
const jtiId = base64Url.encode(new Uint8Array(jtiDigest).slice(0, 24), { padding: false });
|
|
348
|
-
try {
|
|
349
|
-
await ctx.context.adapter.create({
|
|
350
|
-
model: "oauthClientAssertion",
|
|
351
|
-
data: {
|
|
352
|
-
id: jtiId,
|
|
353
|
-
expiresAt: /* @__PURE__ */ new Date(payload.exp * 1e3)
|
|
354
|
-
},
|
|
355
|
-
forceAllowId: true
|
|
356
|
-
});
|
|
357
|
-
} catch (createErr) {
|
|
358
|
-
let alreadyUsed = false;
|
|
359
|
-
try {
|
|
360
|
-
alreadyUsed = Boolean(await ctx.context.adapter.findOne({
|
|
361
|
-
model: "oauthClientAssertion",
|
|
362
|
-
where: [{
|
|
363
|
-
field: "id",
|
|
364
|
-
value: jtiId
|
|
365
|
-
}]
|
|
366
|
-
}));
|
|
367
|
-
} catch {}
|
|
368
|
-
if (alreadyUsed) throw new APIError("BAD_REQUEST", {
|
|
369
|
-
error_description: "client assertion jti has already been used",
|
|
370
|
-
error: "invalid_client"
|
|
371
|
-
});
|
|
372
|
-
throw createErr;
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
/**
|
|
376
|
-
* Verifies a client assertion JWT for `private_key_jwt` authentication.
|
|
377
|
-
*
|
|
378
|
-
* Validates: signature, iss=client_id, sub=client_id, aud=token_endpoint,
|
|
379
|
-
* exp, assertion max lifetime, jti uniqueness (replay prevention).
|
|
380
|
-
*/
|
|
381
|
-
async function verifyClientAssertion(ctx, opts, clientAssertion, clientAssertionType, clientIdHint, expectedAudience) {
|
|
382
|
-
if (clientAssertionType !== CLIENT_ASSERTION_TYPE) throw new APIError("BAD_REQUEST", {
|
|
383
|
-
error_description: "unsupported client_assertion_type",
|
|
384
|
-
error: "invalid_client"
|
|
385
|
-
});
|
|
386
|
-
let header;
|
|
387
|
-
try {
|
|
388
|
-
header = decodeProtectedHeader(clientAssertion);
|
|
389
|
-
} catch {
|
|
390
|
-
throw new APIError("BAD_REQUEST", {
|
|
391
|
-
error_description: "malformed client assertion: invalid JWT header",
|
|
392
|
-
error: "invalid_client"
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
if (!header.alg || !ALGORITHMS_LIST.includes(header.alg)) throw new APIError("BAD_REQUEST", {
|
|
396
|
-
error_description: `unsupported assertion signing algorithm: ${header.alg}`,
|
|
397
|
-
error: "invalid_client"
|
|
398
|
-
});
|
|
399
|
-
let unverified;
|
|
400
|
-
try {
|
|
401
|
-
unverified = decodeJwt(clientAssertion);
|
|
402
|
-
} catch {
|
|
403
|
-
throw new APIError("BAD_REQUEST", {
|
|
404
|
-
error_description: "malformed client assertion: invalid JWT payload",
|
|
405
|
-
error: "invalid_client"
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
const clientId = unverified.sub ?? unverified.iss;
|
|
409
|
-
if (!clientId) throw new APIError("BAD_REQUEST", {
|
|
410
|
-
error_description: "client assertion must contain sub or iss claim identifying the client",
|
|
411
|
-
error: "invalid_client"
|
|
412
|
-
});
|
|
413
|
-
if (clientIdHint && clientIdHint !== clientId) throw new APIError("BAD_REQUEST", {
|
|
414
|
-
error_description: "client_id in body does not match assertion sub/iss",
|
|
415
|
-
error: "invalid_client"
|
|
416
|
-
});
|
|
417
|
-
const client = await getClient(ctx, opts, clientId);
|
|
418
|
-
if (!client) throw new APIError("BAD_REQUEST", {
|
|
419
|
-
error_description: "unknown client",
|
|
420
|
-
error: "invalid_client"
|
|
421
|
-
});
|
|
422
|
-
if (client.disabled) throw new APIError("BAD_REQUEST", {
|
|
423
|
-
error_description: "client is disabled",
|
|
424
|
-
error: "invalid_client"
|
|
425
|
-
});
|
|
426
|
-
if (client.tokenEndpointAuthMethod !== "private_key_jwt") throw new APIError("BAD_REQUEST", {
|
|
427
|
-
error_description: "client is not registered for private_key_jwt authentication",
|
|
428
|
-
error: "invalid_client"
|
|
429
|
-
});
|
|
430
|
-
const jwks = await fetchClientJwks(ctx, opts, client);
|
|
431
|
-
const audience = expectedAudience ?? `${ctx.context.baseURL}/oauth2/token`;
|
|
432
|
-
const verifyOpts = {
|
|
433
|
-
issuer: clientId,
|
|
434
|
-
subject: clientId,
|
|
435
|
-
audience,
|
|
436
|
-
algorithms: ALGORITHMS_LIST
|
|
437
|
-
};
|
|
438
|
-
let payload;
|
|
439
|
-
try {
|
|
440
|
-
({payload} = await jwtVerify(clientAssertion, createLocalJWKSet(jwks), verifyOpts));
|
|
441
|
-
} catch (verifyErr) {
|
|
442
|
-
if (verifyErr instanceof Error && /no matching key|no applicable key/i.test(verifyErr.message)) {
|
|
443
|
-
const refreshed = await refetchClientJwks(opts, client);
|
|
444
|
-
if (refreshed) try {
|
|
445
|
-
({payload} = await jwtVerify(clientAssertion, createLocalJWKSet(refreshed), verifyOpts));
|
|
446
|
-
} catch {
|
|
447
|
-
throw new APIError("UNAUTHORIZED", {
|
|
448
|
-
error_description: "client assertion signature verification failed",
|
|
449
|
-
error: "invalid_client"
|
|
450
|
-
});
|
|
451
|
-
}
|
|
452
|
-
else throw new APIError("UNAUTHORIZED", {
|
|
453
|
-
error_description: "client assertion signature verification failed",
|
|
454
|
-
error: "invalid_client"
|
|
455
|
-
});
|
|
456
|
-
} else throw new APIError("UNAUTHORIZED", {
|
|
457
|
-
error_description: "client assertion signature verification failed",
|
|
458
|
-
error: "invalid_client"
|
|
459
|
-
});
|
|
460
|
-
}
|
|
461
|
-
await consumeClientAssertion(ctx, opts, {
|
|
462
|
-
namespace: `private_key_jwt:${clientId}`,
|
|
463
|
-
payload,
|
|
464
|
-
expectedAudience: audience
|
|
465
|
-
});
|
|
466
|
-
return { clientId };
|
|
467
|
-
}
|
|
468
|
-
//#endregion
|
|
469
|
-
export { __exportAll as a, validatePublicClientJwks as i, consumeClientAssertion as n, isPrivateHostname as r, client_assertion_exports as t };
|