@better-auth/cimd 1.7.0-beta.5 → 1.7.0-beta.7
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 +38 -16
- package/dist/index.mjs +45 -85
- 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.
|
|
@@ -74,8 +83,9 @@ type CimdResolver = (ctx: GenericEndpointContext, clientId: string, existing: Sc
|
|
|
74
83
|
* Build the `resolve` function for a CIMD {@link ClientDiscovery}.
|
|
75
84
|
*
|
|
76
85
|
* Exposed for advanced composition. Most users should call
|
|
77
|
-
* {@link cimdClientDiscovery} (to
|
|
78
|
-
* `oauthProvider({ clientDiscovery })`) or install the
|
|
86
|
+
* {@link cimdClientDiscovery} (to contribute a complete discovery through
|
|
87
|
+
* `oauthProvider({ extensions: [{ clientDiscovery }] })`) or install the
|
|
88
|
+
* `cimd()` plugin.
|
|
79
89
|
*/
|
|
80
90
|
declare function createCimdResolver(cimdOptions?: CimdOptions): CimdResolver;
|
|
81
91
|
//#endregion
|
|
@@ -85,19 +95,31 @@ interface ClientIdMetadataDocumentResult {
|
|
|
85
95
|
error?: string;
|
|
86
96
|
warnings?: string[];
|
|
87
97
|
}
|
|
88
|
-
|
|
89
|
-
|
|
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
|
+
}
|
|
90
105
|
/**
|
|
91
|
-
* Detect URL-formatted client_id (Client ID Metadata Document pattern).
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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.
|
|
94
112
|
*/
|
|
95
|
-
declare function isUrlClientId(clientId: string): boolean;
|
|
113
|
+
declare function isUrlClientId(clientId: string, options?: ClientIdUrlOptions): boolean;
|
|
96
114
|
/**
|
|
97
115
|
* Validate a client_id URL per IETF draft §3.
|
|
98
|
-
* 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.
|
|
99
121
|
*/
|
|
100
|
-
declare function validateClientIdUrl(url: string): string | null;
|
|
122
|
+
declare function validateClientIdUrl(url: string, options?: ClientIdUrlOptions): string | null;
|
|
101
123
|
/**
|
|
102
124
|
* Validate a fetched Client ID Metadata Document per §4.1.
|
|
103
125
|
*
|
|
@@ -118,12 +140,12 @@ declare module "@better-auth/core" {
|
|
|
118
140
|
/**
|
|
119
141
|
* Build a {@link ClientDiscovery} for Client ID Metadata Documents.
|
|
120
142
|
*
|
|
121
|
-
* Users who prefer explicit composition can
|
|
122
|
-
* `oauthProvider({ clientDiscovery })`; most users should
|
|
123
|
-
* {@link cimd} plugin instead, which
|
|
124
|
-
* is
|
|
143
|
+
* Users who prefer explicit composition can contribute the result through
|
|
144
|
+
* `oauthProvider({ extensions: [{ clientDiscovery }] })`; most users should
|
|
145
|
+
* install the {@link cimd} plugin instead, which contributes this discovery
|
|
146
|
+
* alongside whatever else is configured.
|
|
125
147
|
*/
|
|
126
|
-
declare function cimdClientDiscovery(options?: CimdOptions): ClientDiscovery
|
|
148
|
+
declare function cimdClientDiscovery(options?: CimdOptions): ClientDiscovery;
|
|
127
149
|
/**
|
|
128
150
|
* Client ID Metadata Document plugin.
|
|
129
151
|
*
|
|
@@ -141,4 +163,4 @@ declare const cimd: (options?: CimdOptions) => {
|
|
|
141
163
|
init(ctx: better_auth0.AuthContext): void;
|
|
142
164
|
};
|
|
143
165
|
//#endregion
|
|
144
|
-
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
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { checkOAuthClient, extendOAuthProvider, oauthToSchema } from "@better-auth/oauth-provider";
|
|
1
2
|
import { BetterAuthError } from "@better-auth/core/error";
|
|
2
3
|
import { toExpJWT } from "better-auth/plugins";
|
|
3
|
-
import { checkOAuthClient, oauthToSchema } from "@better-auth/oauth-provider";
|
|
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
|
|
@@ -538,13 +498,14 @@ function isStale(existing, refreshRate) {
|
|
|
538
498
|
* Build the `resolve` function for a CIMD {@link ClientDiscovery}.
|
|
539
499
|
*
|
|
540
500
|
* Exposed for advanced composition. Most users should call
|
|
541
|
-
* {@link cimdClientDiscovery} (to
|
|
542
|
-
* `oauthProvider({ clientDiscovery })`) or install the
|
|
501
|
+
* {@link cimdClientDiscovery} (to contribute a complete discovery through
|
|
502
|
+
* `oauthProvider({ extensions: [{ clientDiscovery }] })`) or install the
|
|
503
|
+
* `cimd()` plugin.
|
|
543
504
|
*/
|
|
544
505
|
function createCimdResolver(cimdOptions = {}) {
|
|
545
506
|
const refreshRate = cimdOptions.refreshRate ?? "60m";
|
|
546
507
|
return async (ctx, clientId, existing) => {
|
|
547
|
-
if (!isUrlClientId(clientId)) return null;
|
|
508
|
+
if (!isUrlClientId(clientId, { allowLoopback: cimdOptions.allowLoopback })) return null;
|
|
548
509
|
const provider = ctx.context.getPlugin("oauth-provider");
|
|
549
510
|
if (!provider) throw new BetterAuthError("cimd discovery invoked without the oauth-provider plugin installed");
|
|
550
511
|
const oauthOptions = provider.options;
|
|
@@ -555,22 +516,24 @@ function createCimdResolver(cimdOptions = {}) {
|
|
|
555
516
|
}
|
|
556
517
|
//#endregion
|
|
557
518
|
//#region src/version.ts
|
|
558
|
-
const PACKAGE_VERSION = "1.7.0-beta.
|
|
519
|
+
const PACKAGE_VERSION = "1.7.0-beta.7";
|
|
559
520
|
//#endregion
|
|
560
521
|
//#region src/index.ts
|
|
561
522
|
/**
|
|
562
523
|
* Build a {@link ClientDiscovery} for Client ID Metadata Documents.
|
|
563
524
|
*
|
|
564
|
-
* Users who prefer explicit composition can
|
|
565
|
-
* `oauthProvider({ clientDiscovery })`; most users should
|
|
566
|
-
* {@link cimd} plugin instead, which
|
|
567
|
-
* is
|
|
525
|
+
* Users who prefer explicit composition can contribute the result through
|
|
526
|
+
* `oauthProvider({ extensions: [{ clientDiscovery }] })`; most users should
|
|
527
|
+
* install the {@link cimd} plugin instead, which contributes this discovery
|
|
528
|
+
* alongside whatever else is configured.
|
|
568
529
|
*/
|
|
569
530
|
function cimdClientDiscovery(options = {}) {
|
|
531
|
+
const resolver = createCimdResolver(options);
|
|
532
|
+
const allowLoopback = options.allowLoopback ?? false;
|
|
570
533
|
return {
|
|
571
534
|
id: "cimd",
|
|
572
|
-
matches: isUrlClientId,
|
|
573
|
-
resolve:
|
|
535
|
+
matches: (clientId) => isUrlClientId(clientId, { allowLoopback }),
|
|
536
|
+
resolve: resolver,
|
|
574
537
|
discoveryMetadata: { client_id_metadata_document_supported: true }
|
|
575
538
|
};
|
|
576
539
|
}
|
|
@@ -591,12 +554,9 @@ const cimd = (options = {}) => {
|
|
|
591
554
|
id: "cimd",
|
|
592
555
|
version: PACKAGE_VERSION,
|
|
593
556
|
init(ctx) {
|
|
594
|
-
|
|
595
|
-
if (!provider) throw new BetterAuthError("The cimd plugin requires the oauth-provider plugin.");
|
|
596
|
-
const existing = provider.options.clientDiscovery;
|
|
597
|
-
provider.options.clientDiscovery = Array.isArray(existing) ? [...existing, discovery] : existing ? [existing, discovery] : discovery;
|
|
557
|
+
extendOAuthProvider(ctx, { clientDiscovery: discovery });
|
|
598
558
|
}
|
|
599
559
|
};
|
|
600
560
|
};
|
|
601
561
|
//#endregion
|
|
602
|
-
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.7",
|
|
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.7",
|
|
52
|
+
"@better-auth/oauth-provider": "1.7.0-beta.7",
|
|
53
|
+
"better-auth": "1.7.0-beta.7"
|
|
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.7",
|
|
58
|
+
"@better-auth/oauth-provider": "^1.7.0-beta.7",
|
|
59
|
+
"better-auth": "^1.7.0-beta.7"
|
|
60
60
|
},
|
|
61
61
|
"scripts": {
|
|
62
62
|
"build": "tsdown",
|