@better-auth/cimd 1.7.0-beta.6 → 1.7.0-beta.8
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.d.mts +30 -9
- package/dist/index.mjs +36 -74
- package/package.json +7 -7
package/dist/index.d.mts
CHANGED
|
@@ -30,6 +30,15 @@ interface CimdOptions {
|
|
|
30
30
|
* @default ["redirect_uris", "post_logout_redirect_uris", "client_uri"]
|
|
31
31
|
*/
|
|
32
32
|
originBoundFields?: string[];
|
|
33
|
+
/**
|
|
34
|
+
* Permit loopback `client_id` URLs (`localhost`, `127.0.0.0/8`, `::1`,
|
|
35
|
+
* `*.localhost`), including plain HTTP, so an auth server can fetch a
|
|
36
|
+
* metadata document hosted on the same machine. Off by default; enable
|
|
37
|
+
* only for local development.
|
|
38
|
+
*
|
|
39
|
+
* @default false
|
|
40
|
+
*/
|
|
41
|
+
allowLoopback?: boolean;
|
|
33
42
|
/**
|
|
34
43
|
* Pre-fetch gate called before a metadata document is requested. Return
|
|
35
44
|
* `false` to reject the `client_id` URL.
|
|
@@ -86,19 +95,31 @@ interface ClientIdMetadataDocumentResult {
|
|
|
86
95
|
error?: string;
|
|
87
96
|
warnings?: string[];
|
|
88
97
|
}
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
interface ClientIdUrlOptions {
|
|
99
|
+
/**
|
|
100
|
+
* Permit loopback `client_id` URLs (`localhost`, `127.0.0.0/8`, `::1`,
|
|
101
|
+
* `*.localhost`) and plain HTTP for them. Off by default.
|
|
102
|
+
*/
|
|
103
|
+
allowLoopback?: boolean;
|
|
104
|
+
}
|
|
91
105
|
/**
|
|
92
|
-
* Detect URL-formatted client_id (Client ID Metadata Document pattern).
|
|
93
|
-
*
|
|
94
|
-
*
|
|
106
|
+
* Detect a URL-formatted client_id (Client ID Metadata Document pattern).
|
|
107
|
+
*
|
|
108
|
+
* HTTPS URLs always match; plain HTTP matches only loopback hosts, and only
|
|
109
|
+
* when `allowLoopback` is set. This is a routing predicate, not a security
|
|
110
|
+
* gate: it performs no DNS resolution, so callers MUST also run
|
|
111
|
+
* {@link validateClientIdUrl} (and a fetch-time policy) before fetching.
|
|
95
112
|
*/
|
|
96
|
-
declare function isUrlClientId(clientId: string): boolean;
|
|
113
|
+
declare function isUrlClientId(clientId: string, options?: ClientIdUrlOptions): boolean;
|
|
97
114
|
/**
|
|
98
115
|
* Validate a client_id URL per IETF draft §3.
|
|
99
|
-
* Returns null on success, error string on failure.
|
|
116
|
+
* Returns null on success, an error string on failure.
|
|
117
|
+
*
|
|
118
|
+
* Loopback hosts are rejected unless `allowLoopback` is set; every other
|
|
119
|
+
* non-public host (private, link-local, cloud-metadata, IPv6 tunnels) is
|
|
120
|
+
* rejected.
|
|
100
121
|
*/
|
|
101
|
-
declare function validateClientIdUrl(url: string): string | null;
|
|
122
|
+
declare function validateClientIdUrl(url: string, options?: ClientIdUrlOptions): string | null;
|
|
102
123
|
/**
|
|
103
124
|
* Validate a fetched Client ID Metadata Document per §4.1.
|
|
104
125
|
*
|
|
@@ -142,4 +163,4 @@ declare const cimd: (options?: CimdOptions) => {
|
|
|
142
163
|
init(ctx: better_auth0.AuthContext): void;
|
|
143
164
|
};
|
|
144
165
|
//#endregion
|
|
145
|
-
export { type CimdOptions, type ClientIdMetadataDocumentResult, cimd, cimdClientDiscovery, createCimdResolver,
|
|
166
|
+
export { type CimdOptions, type ClientIdMetadataDocumentResult, type ClientIdUrlOptions, cimd, cimdClientDiscovery, createCimdResolver, isUrlClientId, validateCimdMetadata, validateClientIdUrl };
|
package/dist/index.mjs
CHANGED
|
@@ -2,8 +2,9 @@ import { checkOAuthClient, extendOAuthProvider, oauthToSchema } from "@better-au
|
|
|
2
2
|
import { BetterAuthError } from "@better-auth/core/error";
|
|
3
3
|
import { toExpJWT } from "better-auth/plugins";
|
|
4
4
|
import { APIError } from "better-call";
|
|
5
|
+
import { isLoopbackHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
|
|
5
6
|
//#region src/validate-metadata-document.ts
|
|
6
|
-
const DOT_SEGMENT_RE =
|
|
7
|
+
const DOT_SEGMENT_RE = /\/(?:\.|%2e)(?:\.|%2e)?(?:\/|$|#|\?)/i;
|
|
7
8
|
const PROHIBITED_FIELDS = new Set(["client_secret", "client_secret_expires_at"]);
|
|
8
9
|
const SYMMETRIC_AUTH_METHODS = new Set([
|
|
9
10
|
"client_secret_post",
|
|
@@ -12,80 +13,35 @@ const SYMMETRIC_AUTH_METHODS = new Set([
|
|
|
12
13
|
]);
|
|
13
14
|
const ALLOWED_GRANT_TYPES = new Set(["authorization_code", "refresh_token"]);
|
|
14
15
|
const ALLOWED_RESPONSE_TYPES = new Set(["code"]);
|
|
15
|
-
/** Hostnames that are considered "localhost" for development flows. */
|
|
16
|
-
function isLocalhost(hostname) {
|
|
17
|
-
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1" || hostname.endsWith(".localhost");
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Check whether a dotted-decimal IPv4 address is private, reserved, or
|
|
21
|
-
* otherwise non-routable for a public SSRF target. Covers the subset of
|
|
22
|
-
* RFC 6890 special-purpose ranges that an adversarial `client_id` URL
|
|
23
|
-
* could point at to reach internal infrastructure or disrupt fetches.
|
|
24
|
-
*/
|
|
25
|
-
function isPrivateIpv4(host) {
|
|
26
|
-
const parts = host.split(".");
|
|
27
|
-
if (parts.length !== 4 || parts.some((p) => !/^\d{1,3}$/.test(p))) return false;
|
|
28
|
-
const a = Number(parts[0]);
|
|
29
|
-
const b = Number(parts[1]);
|
|
30
|
-
const c = Number(parts[2]);
|
|
31
|
-
return a === 127 || a === 10 || a === 0 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254 || a === 100 && b >= 64 && b <= 127 || a === 198 && (b === 18 || b === 19) || a === 192 && b === 0 && c === 2 || a === 198 && b === 51 && c === 100 || a === 203 && b === 0 && c === 113 || a === 192 && b === 88 && c === 99 || a >= 224 && a <= 239 || a >= 240;
|
|
32
|
-
}
|
|
33
|
-
const V4_MAPPED_DOTTED_RE = /^(?:0{0,4}:){0,4}:?(?:0{0,4}:)?ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/;
|
|
34
|
-
const V4_MAPPED_HEX_RE = /^(?:0{0,4}:){0,4}:?(?:0{0,4}:)?ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/;
|
|
35
16
|
/**
|
|
36
|
-
*
|
|
37
|
-
* e.g. "a9fe" "a9fe" -> "169.254.169.254"
|
|
38
|
-
*/
|
|
39
|
-
function hexGroupsToIpv4(hi, lo) {
|
|
40
|
-
const h = Number.parseInt(hi, 16);
|
|
41
|
-
const l = Number.parseInt(lo, 16);
|
|
42
|
-
return `${h >> 8 & 255}.${h & 255}.${l >> 8 & 255}.${l & 255}`;
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Check whether a hostname is private/reserved per RFC 6890.
|
|
17
|
+
* Detect a URL-formatted client_id (Client ID Metadata Document pattern).
|
|
46
18
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
19
|
+
* HTTPS URLs always match; plain HTTP matches only loopback hosts, and only
|
|
20
|
+
* when `allowLoopback` is set. This is a routing predicate, not a security
|
|
21
|
+
* gate: it performs no DNS resolution, so callers MUST also run
|
|
22
|
+
* {@link validateClientIdUrl} (and a fetch-time policy) before fetching.
|
|
51
23
|
*/
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
const host = lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
|
|
55
|
-
if (host === "::1") return true;
|
|
56
|
-
if (isPrivateIpv4(host)) return true;
|
|
57
|
-
if (host.includes(":")) {
|
|
58
|
-
const dottedMatch = host.match(V4_MAPPED_DOTTED_RE);
|
|
59
|
-
if (dottedMatch && isPrivateIpv4(dottedMatch[1])) return true;
|
|
60
|
-
const hexMatch = host.match(V4_MAPPED_HEX_RE);
|
|
61
|
-
if (hexMatch) {
|
|
62
|
-
if (isPrivateIpv4(hexGroupsToIpv4(hexMatch[1], hexMatch[2]))) return true;
|
|
63
|
-
}
|
|
64
|
-
if (/^fe[89ab]/.test(host)) return true;
|
|
65
|
-
if (host.startsWith("fc") || host.startsWith("fd")) return true;
|
|
66
|
-
}
|
|
67
|
-
if (host === "metadata.google.internal") return true;
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* Detect URL-formatted client_id (Client ID Metadata Document pattern).
|
|
72
|
-
* HTTPS always accepted; HTTP accepted for localhost variants
|
|
73
|
-
* (localhost, 127.0.0.1, [::1], *.localhost) for development.
|
|
74
|
-
*/
|
|
75
|
-
function isUrlClientId(clientId) {
|
|
76
|
-
if (clientId.startsWith("https://")) return true;
|
|
77
|
-
if (!clientId.startsWith("http://")) return false;
|
|
24
|
+
function isUrlClientId(clientId, options) {
|
|
25
|
+
let parsed;
|
|
78
26
|
try {
|
|
79
|
-
|
|
27
|
+
parsed = new URL(clientId);
|
|
80
28
|
} catch {
|
|
81
29
|
return false;
|
|
82
30
|
}
|
|
31
|
+
if (parsed.protocol === "https:") return true;
|
|
32
|
+
if (parsed.protocol !== "http:") return false;
|
|
33
|
+
if (!options?.allowLoopback) return false;
|
|
34
|
+
return isLoopbackHost(parsed.hostname);
|
|
83
35
|
}
|
|
84
36
|
/**
|
|
85
37
|
* Validate a client_id URL per IETF draft §3.
|
|
86
|
-
* Returns null on success, error string on failure.
|
|
38
|
+
* Returns null on success, an error string on failure.
|
|
39
|
+
*
|
|
40
|
+
* Loopback hosts are rejected unless `allowLoopback` is set; every other
|
|
41
|
+
* non-public host (private, link-local, cloud-metadata, IPv6 tunnels) is
|
|
42
|
+
* rejected.
|
|
87
43
|
*/
|
|
88
|
-
function validateClientIdUrl(url) {
|
|
44
|
+
function validateClientIdUrl(url, options) {
|
|
89
45
|
if (DOT_SEGMENT_RE.test(url)) return "client_id URL MUST NOT contain dot segments";
|
|
90
46
|
if (url.includes("#")) return "client_id URL MUST NOT contain a fragment";
|
|
91
47
|
let parsed;
|
|
@@ -95,10 +51,14 @@ function validateClientIdUrl(url) {
|
|
|
95
51
|
return "client_id is not a valid URL";
|
|
96
52
|
}
|
|
97
53
|
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "client_id URL must use HTTPS";
|
|
98
|
-
if (parsed.protocol === "http:" && !isLocalhost(parsed.hostname)) return "client_id URL must use HTTPS (HTTP allowed only for localhost)";
|
|
99
54
|
if (parsed.username || parsed.password) return "client_id URL MUST NOT contain credentials";
|
|
100
55
|
if (parsed.pathname === "/" || parsed.pathname === "") return "client_id URL MUST contain a path component";
|
|
101
|
-
if (
|
|
56
|
+
if (isLoopbackHost(parsed.hostname)) {
|
|
57
|
+
if (!options?.allowLoopback) return "client_id URL must not target a loopback address (set allowLoopback to enable local development)";
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (parsed.protocol !== "https:") return "client_id URL must use HTTPS (HTTP is allowed only for loopback in development)";
|
|
61
|
+
if (!isPublicRoutableHost(parsed.hostname)) return "client_id URL must not target a private or reserved address";
|
|
102
62
|
return null;
|
|
103
63
|
}
|
|
104
64
|
/** Warning: §3 SHOULD NOT have a query string. */
|
|
@@ -180,7 +140,7 @@ function validateCimdMetadata(fetchUrl, raw, originBoundFields) {
|
|
|
180
140
|
valid: false,
|
|
181
141
|
error: `${field} must use HTTP(S)`
|
|
182
142
|
};
|
|
183
|
-
if (!
|
|
143
|
+
if (!isPublicRoutableHost(parsed.hostname)) return {
|
|
184
144
|
valid: false,
|
|
185
145
|
error: `${field} must not point to a private or reserved address`
|
|
186
146
|
};
|
|
@@ -234,7 +194,7 @@ function validateCimdMetadata(fetchUrl, raw, originBoundFields) {
|
|
|
234
194
|
valid: false,
|
|
235
195
|
error: `all values for ${key} must use HTTP(S)`
|
|
236
196
|
};
|
|
237
|
-
const localhostAllowed = (key === "redirect_uris" || key === "post_logout_redirect_uris") &&
|
|
197
|
+
const localhostAllowed = (key === "redirect_uris" || key === "post_logout_redirect_uris") && isLoopbackHost(uri.hostname);
|
|
238
198
|
if (uri.origin !== clientIdOrigin && !localhostAllowed) return {
|
|
239
199
|
valid: false,
|
|
240
200
|
error: `${key} value "${val}" must have the same origin as client_id (${clientIdOrigin})`
|
|
@@ -448,7 +408,7 @@ async function refreshMetadataDocumentClient(ctx, clientIdUrl, existing, cimdOpt
|
|
|
448
408
|
* and return the parsed metadata.
|
|
449
409
|
*/
|
|
450
410
|
async function fetchAndValidateMetadataDocument(ctx, clientIdUrl, cimdOptions) {
|
|
451
|
-
const urlError = validateClientIdUrl(clientIdUrl);
|
|
411
|
+
const urlError = validateClientIdUrl(clientIdUrl, { allowLoopback: cimdOptions.allowLoopback });
|
|
452
412
|
if (urlError) throw new APIError("BAD_REQUEST", {
|
|
453
413
|
error: "invalid_client",
|
|
454
414
|
error_description: urlError
|
|
@@ -545,7 +505,7 @@ function isStale(existing, refreshRate) {
|
|
|
545
505
|
function createCimdResolver(cimdOptions = {}) {
|
|
546
506
|
const refreshRate = cimdOptions.refreshRate ?? "60m";
|
|
547
507
|
return async (ctx, clientId, existing) => {
|
|
548
|
-
if (!isUrlClientId(clientId)) return null;
|
|
508
|
+
if (!isUrlClientId(clientId, { allowLoopback: cimdOptions.allowLoopback })) return null;
|
|
549
509
|
const provider = ctx.context.getPlugin("oauth-provider");
|
|
550
510
|
if (!provider) throw new BetterAuthError("cimd discovery invoked without the oauth-provider plugin installed");
|
|
551
511
|
const oauthOptions = provider.options;
|
|
@@ -556,7 +516,7 @@ function createCimdResolver(cimdOptions = {}) {
|
|
|
556
516
|
}
|
|
557
517
|
//#endregion
|
|
558
518
|
//#region src/version.ts
|
|
559
|
-
const PACKAGE_VERSION = "1.7.0-beta.
|
|
519
|
+
const PACKAGE_VERSION = "1.7.0-beta.8";
|
|
560
520
|
//#endregion
|
|
561
521
|
//#region src/index.ts
|
|
562
522
|
/**
|
|
@@ -568,10 +528,12 @@ const PACKAGE_VERSION = "1.7.0-beta.6";
|
|
|
568
528
|
* alongside whatever else is configured.
|
|
569
529
|
*/
|
|
570
530
|
function cimdClientDiscovery(options = {}) {
|
|
531
|
+
const resolver = createCimdResolver(options);
|
|
532
|
+
const allowLoopback = options.allowLoopback ?? false;
|
|
571
533
|
return {
|
|
572
534
|
id: "cimd",
|
|
573
|
-
matches: isUrlClientId,
|
|
574
|
-
resolve:
|
|
535
|
+
matches: (clientId) => isUrlClientId(clientId, { allowLoopback }),
|
|
536
|
+
resolve: resolver,
|
|
575
537
|
discoveryMetadata: { client_id_metadata_document_supported: true }
|
|
576
538
|
};
|
|
577
539
|
}
|
|
@@ -597,4 +559,4 @@ const cimd = (options = {}) => {
|
|
|
597
559
|
};
|
|
598
560
|
};
|
|
599
561
|
//#endregion
|
|
600
|
-
export { cimd, cimdClientDiscovery, createCimdResolver,
|
|
562
|
+
export { cimd, cimdClientDiscovery, createCimdResolver, isUrlClientId, validateCimdMetadata, validateClientIdUrl };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/cimd",
|
|
3
|
-
"version": "1.7.0-beta.
|
|
3
|
+
"version": "1.7.0-beta.8",
|
|
4
4
|
"description": "Client ID Metadata Document plugin for Better Auth",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,15 +48,15 @@
|
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"listhen": "^1.9.0",
|
|
50
50
|
"tsdown": "0.21.1",
|
|
51
|
-
"@better-auth/core": "1.7.0-beta.
|
|
52
|
-
"@better-auth/oauth-provider": "1.7.0-beta.
|
|
53
|
-
"better-auth": "1.7.0-beta.
|
|
51
|
+
"@better-auth/core": "1.7.0-beta.8",
|
|
52
|
+
"@better-auth/oauth-provider": "1.7.0-beta.8",
|
|
53
|
+
"better-auth": "1.7.0-beta.8"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"better-call": "1.3.6",
|
|
57
|
-
"@better-auth/core": "^1.7.0-beta.
|
|
58
|
-
"@better-auth/oauth-provider": "^1.7.0-beta.
|
|
59
|
-
"better-auth": "^1.7.0-beta.
|
|
57
|
+
"@better-auth/core": "^1.7.0-beta.8",
|
|
58
|
+
"@better-auth/oauth-provider": "^1.7.0-beta.8",
|
|
59
|
+
"better-auth": "^1.7.0-beta.8"
|
|
60
60
|
},
|
|
61
61
|
"scripts": {
|
|
62
62
|
"build": "tsdown",
|