@better-auth/cimd 1.7.0-beta.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/LICENSE.md +20 -0
- package/README.md +17 -0
- package/dist/index.d.mts +144 -0
- package/dist/index.mjs +602 -0
- package/package.json +70 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
Copyright (c) 2024 - present, Bereket Engida
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
5
|
+
this software and associated documentation files (the “Software”), to deal in
|
|
6
|
+
the Software without restriction, including without limitation the rights to
|
|
7
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
8
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
9
|
+
subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
16
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
17
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
18
|
+
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
19
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
20
|
+
DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Better Auth CIMD Plugin
|
|
2
|
+
|
|
3
|
+
Client ID Metadata Document plugin for [Better Auth](https://www.better-auth.com): unauthenticated dynamic client discovery over HTTPS, the mechanism [MCP](https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents-flow) uses for authorization servers to discover clients without prior registration.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @better-auth/cimd
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Documentation
|
|
12
|
+
|
|
13
|
+
For full documentation, visit [better-auth.com/docs/plugins/cimd](https://www.better-auth.com/docs/plugins/cimd).
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
MIT
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { ClientDiscovery, SchemaClient, Scope } from "@better-auth/oauth-provider";
|
|
2
|
+
import * as better_auth0 from "better-auth";
|
|
3
|
+
import { GenericEndpointContext } from "@better-auth/core";
|
|
4
|
+
|
|
5
|
+
//#region src/types.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Options for the Client ID Metadata Document plugin.
|
|
8
|
+
*
|
|
9
|
+
* @see https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
|
|
10
|
+
*/
|
|
11
|
+
interface CimdOptions {
|
|
12
|
+
/**
|
|
13
|
+
* How frequently to re-fetch a client's metadata document to pick up
|
|
14
|
+
* changes from the client.
|
|
15
|
+
*
|
|
16
|
+
* Accepts a number of seconds or a duration string (e.g. `"60m"`,
|
|
17
|
+
* `"1d"`).
|
|
18
|
+
*
|
|
19
|
+
* @default "60m"
|
|
20
|
+
*/
|
|
21
|
+
refreshRate?: number | string;
|
|
22
|
+
/**
|
|
23
|
+
* Metadata fields whose URL values must share the same origin as the
|
|
24
|
+
* `client_id` URL. Prevents a client from claiming URIs on a different
|
|
25
|
+
* domain.
|
|
26
|
+
*
|
|
27
|
+
* Pass an empty array to disable origin binding (not recommended for
|
|
28
|
+
* production).
|
|
29
|
+
*
|
|
30
|
+
* @default ["redirect_uris", "post_logout_redirect_uris", "client_uri"]
|
|
31
|
+
*/
|
|
32
|
+
originBoundFields?: string[];
|
|
33
|
+
/**
|
|
34
|
+
* Pre-fetch gate called before a metadata document is requested. Return
|
|
35
|
+
* `false` to reject the `client_id` URL.
|
|
36
|
+
*
|
|
37
|
+
* Use this for origin allowlists, per-host rate limiting, or integrating
|
|
38
|
+
* with an external trust service. Hostname-based DNS defenses (beyond
|
|
39
|
+
* the built-in IP-literal check) belong here, since the plugin is
|
|
40
|
+
* runtime-agnostic and does not perform DNS resolution.
|
|
41
|
+
*
|
|
42
|
+
* @default always allow
|
|
43
|
+
*/
|
|
44
|
+
allowFetch?: (url: string, ctx: GenericEndpointContext) => boolean | Promise<boolean>;
|
|
45
|
+
/**
|
|
46
|
+
* Called after a client is created from a metadata document for the
|
|
47
|
+
* first time. Use this to assign trust levels, prefetch logos, or
|
|
48
|
+
* perform other post-creation processing.
|
|
49
|
+
*/
|
|
50
|
+
onClientCreated?: (data: {
|
|
51
|
+
client: SchemaClient<Scope[]>;
|
|
52
|
+
metadata: Record<string, unknown>;
|
|
53
|
+
ctx: GenericEndpointContext;
|
|
54
|
+
}) => void | Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Called after a client is refreshed from a re-fetched metadata
|
|
57
|
+
* document. Use this for change-detection logging or updating derived
|
|
58
|
+
* fields.
|
|
59
|
+
*/
|
|
60
|
+
onClientRefreshed?: (data: {
|
|
61
|
+
client: SchemaClient<Scope[]>;
|
|
62
|
+
metadata: Record<string, unknown>;
|
|
63
|
+
ctx: GenericEndpointContext;
|
|
64
|
+
}) => void | Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/resolver.d.ts
|
|
68
|
+
/**
|
|
69
|
+
* Signature of the `resolve` function on a {@link ClientDiscovery}. Kept
|
|
70
|
+
* here to avoid a circular import back into `@better-auth/oauth-provider`.
|
|
71
|
+
*/
|
|
72
|
+
type CimdResolver = (ctx: GenericEndpointContext, clientId: string, existing: SchemaClient<Scope[]> | null) => Promise<SchemaClient<Scope[]> | null>;
|
|
73
|
+
/**
|
|
74
|
+
* Build the `resolve` function for a CIMD {@link ClientDiscovery}.
|
|
75
|
+
*
|
|
76
|
+
* Exposed for advanced composition. Most users should call
|
|
77
|
+
* {@link cimdClientDiscovery} (to pass a complete discovery to
|
|
78
|
+
* `oauthProvider({ clientDiscovery })`) or install the `cimd()` plugin.
|
|
79
|
+
*/
|
|
80
|
+
declare function createCimdResolver(cimdOptions?: CimdOptions): CimdResolver;
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/validate-metadata-document.d.ts
|
|
83
|
+
interface ClientIdMetadataDocumentResult {
|
|
84
|
+
valid: boolean;
|
|
85
|
+
error?: string;
|
|
86
|
+
warnings?: string[];
|
|
87
|
+
}
|
|
88
|
+
/** Hostnames that are considered "localhost" for development flows. */
|
|
89
|
+
declare function isLocalhost(hostname: string): boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Detect URL-formatted client_id (Client ID Metadata Document pattern).
|
|
92
|
+
* HTTPS always accepted; HTTP accepted for localhost variants
|
|
93
|
+
* (localhost, 127.0.0.1, [::1], *.localhost) for development.
|
|
94
|
+
*/
|
|
95
|
+
declare function isUrlClientId(clientId: string): boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Validate a client_id URL per IETF draft §3.
|
|
98
|
+
* Returns null on success, error string on failure.
|
|
99
|
+
*/
|
|
100
|
+
declare function validateClientIdUrl(url: string): string | null;
|
|
101
|
+
/**
|
|
102
|
+
* Validate a fetched Client ID Metadata Document per §4.1.
|
|
103
|
+
*
|
|
104
|
+
* @param fetchUrl - The URL the document was fetched from.
|
|
105
|
+
* @param raw - The parsed JSON body of the response.
|
|
106
|
+
* @param originBoundFields - Fields whose URL values must share the same origin as the `client_id` URL.
|
|
107
|
+
*/
|
|
108
|
+
declare function validateCimdMetadata(fetchUrl: string, raw: unknown, originBoundFields?: string[]): ClientIdMetadataDocumentResult;
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/index.d.ts
|
|
111
|
+
declare module "@better-auth/core" {
|
|
112
|
+
interface BetterAuthPluginRegistry<AuthOptions, Options> {
|
|
113
|
+
cimd: {
|
|
114
|
+
creator: typeof cimd;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Build a {@link ClientDiscovery} for Client ID Metadata Documents.
|
|
120
|
+
*
|
|
121
|
+
* Users who prefer explicit composition can pass the result directly to
|
|
122
|
+
* `oauthProvider({ clientDiscovery })`; most users should install the
|
|
123
|
+
* {@link cimd} plugin instead, which appends this discovery to whatever
|
|
124
|
+
* is already configured.
|
|
125
|
+
*/
|
|
126
|
+
declare function cimdClientDiscovery(options?: CimdOptions): ClientDiscovery<Scope[]>;
|
|
127
|
+
/**
|
|
128
|
+
* Client ID Metadata Document plugin.
|
|
129
|
+
*
|
|
130
|
+
* Adds unauthenticated dynamic client discovery over HTTPS to an
|
|
131
|
+
* `oauth-provider` instance. Clients identify themselves by providing
|
|
132
|
+
* an HTTPS URL as their `client_id`; the plugin fetches and validates
|
|
133
|
+
* the document at that URL, then creates a public client record.
|
|
134
|
+
*
|
|
135
|
+
* See {@link https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ | the IETF draft}
|
|
136
|
+
* and {@link https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents-flow | the MCP authorization spec}.
|
|
137
|
+
*/
|
|
138
|
+
declare const cimd: (options?: CimdOptions) => {
|
|
139
|
+
id: "cimd";
|
|
140
|
+
version: string;
|
|
141
|
+
init(ctx: better_auth0.AuthContext): void;
|
|
142
|
+
};
|
|
143
|
+
//#endregion
|
|
144
|
+
export { type CimdOptions, type ClientIdMetadataDocumentResult, cimd, cimdClientDiscovery, createCimdResolver, isLocalhost, isUrlClientId, validateCimdMetadata, validateClientIdUrl };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
import { BetterAuthError } from "@better-auth/core/error";
|
|
2
|
+
import { toExpJWT } from "better-auth/plugins";
|
|
3
|
+
import { checkOAuthClient, oauthToSchema } from "@better-auth/oauth-provider";
|
|
4
|
+
import { APIError } from "better-call";
|
|
5
|
+
//#region src/validate-metadata-document.ts
|
|
6
|
+
const DOT_SEGMENT_RE = /\/\.\.?(?:\/|$|#|\?)/;
|
|
7
|
+
const PROHIBITED_FIELDS = new Set(["client_secret", "client_secret_expires_at"]);
|
|
8
|
+
const SYMMETRIC_AUTH_METHODS = new Set([
|
|
9
|
+
"client_secret_post",
|
|
10
|
+
"client_secret_basic",
|
|
11
|
+
"client_secret_jwt"
|
|
12
|
+
]);
|
|
13
|
+
const ALLOWED_GRANT_TYPES = new Set(["authorization_code", "refresh_token"]);
|
|
14
|
+
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
|
+
/**
|
|
36
|
+
* Convert two hex groups from an IPv4-mapped IPv6 address to dotted-decimal IPv4.
|
|
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.
|
|
46
|
+
*
|
|
47
|
+
* Handles bracketed IPv6 (as returned by URL.hostname), IPv4-mapped
|
|
48
|
+
* IPv6 in both dotted-decimal and hex-normalized forms, and cloud
|
|
49
|
+
* metadata hostnames. No DNS resolution, so it runs identically on
|
|
50
|
+
* Node, Bun, Deno, and Workers.
|
|
51
|
+
*/
|
|
52
|
+
function isPrivateHost(hostname) {
|
|
53
|
+
const lower = hostname.toLowerCase();
|
|
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;
|
|
78
|
+
try {
|
|
79
|
+
return isLocalhost(new URL(clientId).hostname);
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Validate a client_id URL per IETF draft §3.
|
|
86
|
+
* Returns null on success, error string on failure.
|
|
87
|
+
*/
|
|
88
|
+
function validateClientIdUrl(url) {
|
|
89
|
+
if (DOT_SEGMENT_RE.test(url)) return "client_id URL MUST NOT contain dot segments";
|
|
90
|
+
if (url.includes("#")) return "client_id URL MUST NOT contain a fragment";
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = new URL(url);
|
|
94
|
+
} catch {
|
|
95
|
+
return "client_id is not a valid URL";
|
|
96
|
+
}
|
|
97
|
+
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
|
+
if (parsed.username || parsed.password) return "client_id URL MUST NOT contain credentials";
|
|
100
|
+
if (parsed.pathname === "/" || parsed.pathname === "") return "client_id URL MUST contain a path component";
|
|
101
|
+
if (!isLocalhost(parsed.hostname) && isPrivateHost(parsed.hostname)) return "client_id URL must not resolve to a private or reserved address";
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
/** Warning: §3 SHOULD NOT have a query string. */
|
|
105
|
+
function checkUrlQueryWarning(url) {
|
|
106
|
+
try {
|
|
107
|
+
if (new URL(url).search) return "client_id URL SHOULD NOT contain a query string (§3)";
|
|
108
|
+
} catch {}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
function isAbsoluteHttpUri(uri) {
|
|
112
|
+
try {
|
|
113
|
+
const parsed = new URL(uri);
|
|
114
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Validate a fetched Client ID Metadata Document per §4.1.
|
|
121
|
+
*
|
|
122
|
+
* @param fetchUrl - The URL the document was fetched from.
|
|
123
|
+
* @param raw - The parsed JSON body of the response.
|
|
124
|
+
* @param originBoundFields - Fields whose URL values must share the same origin as the `client_id` URL.
|
|
125
|
+
*/
|
|
126
|
+
function validateCimdMetadata(fetchUrl, raw, originBoundFields) {
|
|
127
|
+
if (!raw || typeof raw !== "object") return {
|
|
128
|
+
valid: false,
|
|
129
|
+
error: "metadata document is not a JSON object"
|
|
130
|
+
};
|
|
131
|
+
const doc = raw;
|
|
132
|
+
const warnings = [];
|
|
133
|
+
if (doc.client_id !== fetchUrl) return {
|
|
134
|
+
valid: false,
|
|
135
|
+
error: `client_id "${String(doc.client_id)}" does not match the metadata document URL`
|
|
136
|
+
};
|
|
137
|
+
for (const field of PROHIBITED_FIELDS) if (field in doc) return {
|
|
138
|
+
valid: false,
|
|
139
|
+
error: `metadata document MUST NOT contain "${field}"`
|
|
140
|
+
};
|
|
141
|
+
const ALLOWED_AUTH_METHODS = new Set(["none", "private_key_jwt"]);
|
|
142
|
+
if (doc.token_endpoint_auth_method !== void 0 && typeof doc.token_endpoint_auth_method !== "string") return {
|
|
143
|
+
valid: false,
|
|
144
|
+
error: "token_endpoint_auth_method must be a string"
|
|
145
|
+
};
|
|
146
|
+
if (typeof doc.token_endpoint_auth_method === "string") {
|
|
147
|
+
if (SYMMETRIC_AUTH_METHODS.has(doc.token_endpoint_auth_method)) return {
|
|
148
|
+
valid: false,
|
|
149
|
+
error: `symmetric auth method "${doc.token_endpoint_auth_method}" is prohibited for Client ID Metadata Document clients`
|
|
150
|
+
};
|
|
151
|
+
if (!ALLOWED_AUTH_METHODS.has(doc.token_endpoint_auth_method)) return {
|
|
152
|
+
valid: false,
|
|
153
|
+
error: "token_endpoint_auth_method must be \"none\" or \"private_key_jwt\" for Client ID Metadata Document clients"
|
|
154
|
+
};
|
|
155
|
+
if (doc.token_endpoint_auth_method === "private_key_jwt" && !doc.jwks && !doc.jwks_uri) return {
|
|
156
|
+
valid: false,
|
|
157
|
+
error: "private_key_jwt requires either jwks or jwks_uri in the metadata document"
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (!Array.isArray(doc.redirect_uris) || doc.redirect_uris.length === 0 || !doc.redirect_uris.every((uri) => typeof uri === "string" && isAbsoluteHttpUri(uri))) return {
|
|
161
|
+
valid: false,
|
|
162
|
+
error: "redirect_uris must be a non-empty array of absolute HTTP(S) URIs"
|
|
163
|
+
};
|
|
164
|
+
if (doc.grant_types !== void 0 && !(Array.isArray(doc.grant_types) && doc.grant_types.every((g) => typeof g === "string" && ALLOWED_GRANT_TYPES.has(g)))) return {
|
|
165
|
+
valid: false,
|
|
166
|
+
error: `grant_types must be a subset of [${[...ALLOWED_GRANT_TYPES].map((g) => `"${g}"`).join(", ")}]`
|
|
167
|
+
};
|
|
168
|
+
if (doc.response_types !== void 0 && !(Array.isArray(doc.response_types) && doc.response_types.every((r) => typeof r === "string" && ALLOWED_RESPONSE_TYPES.has(r)))) return {
|
|
169
|
+
valid: false,
|
|
170
|
+
error: "response_types must be a subset of [\"code\"]"
|
|
171
|
+
};
|
|
172
|
+
for (const field of ["client_uri", "logo_uri"]) {
|
|
173
|
+
if (doc[field] !== void 0 && typeof doc[field] !== "string") return {
|
|
174
|
+
valid: false,
|
|
175
|
+
error: `${field} must be a string`
|
|
176
|
+
};
|
|
177
|
+
if (typeof doc[field] === "string") try {
|
|
178
|
+
const parsed = new URL(doc[field]);
|
|
179
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return {
|
|
180
|
+
valid: false,
|
|
181
|
+
error: `${field} must use HTTP(S)`
|
|
182
|
+
};
|
|
183
|
+
if (!isLocalhost(parsed.hostname) && isPrivateHost(parsed.hostname)) return {
|
|
184
|
+
valid: false,
|
|
185
|
+
error: `${field} must not point to a private or reserved address`
|
|
186
|
+
};
|
|
187
|
+
} catch {
|
|
188
|
+
return {
|
|
189
|
+
valid: false,
|
|
190
|
+
error: `${field} is not a valid URL`
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const fieldsToCheck = originBoundFields ?? [
|
|
195
|
+
"redirect_uris",
|
|
196
|
+
"post_logout_redirect_uris",
|
|
197
|
+
"client_uri"
|
|
198
|
+
];
|
|
199
|
+
let clientIdOrigin;
|
|
200
|
+
try {
|
|
201
|
+
clientIdOrigin = new URL(fetchUrl).origin;
|
|
202
|
+
} catch {
|
|
203
|
+
return {
|
|
204
|
+
valid: false,
|
|
205
|
+
error: "client_id is not a valid URL"
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
for (const key of fieldsToCheck) {
|
|
209
|
+
const value = doc[key];
|
|
210
|
+
if (value === void 0) continue;
|
|
211
|
+
let values;
|
|
212
|
+
if (typeof value === "string") values = [value];
|
|
213
|
+
else if (Array.isArray(value)) {
|
|
214
|
+
if (!value.every((v) => typeof v === "string")) return {
|
|
215
|
+
valid: false,
|
|
216
|
+
error: `${key} must be a string or an array of strings`
|
|
217
|
+
};
|
|
218
|
+
values = value;
|
|
219
|
+
} else return {
|
|
220
|
+
valid: false,
|
|
221
|
+
error: `${key} must be a string or an array of strings`
|
|
222
|
+
};
|
|
223
|
+
for (const val of values) {
|
|
224
|
+
let uri;
|
|
225
|
+
try {
|
|
226
|
+
uri = new URL(val);
|
|
227
|
+
} catch {
|
|
228
|
+
return {
|
|
229
|
+
valid: false,
|
|
230
|
+
error: `${key} contains an invalid URL: "${val}"`
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
if (uri.protocol !== "https:" && uri.protocol !== "http:") return {
|
|
234
|
+
valid: false,
|
|
235
|
+
error: `all values for ${key} must use HTTP(S)`
|
|
236
|
+
};
|
|
237
|
+
const localhostAllowed = (key === "redirect_uris" || key === "post_logout_redirect_uris") && isLocalhost(uri.hostname);
|
|
238
|
+
if (uri.origin !== clientIdOrigin && !localhostAllowed) return {
|
|
239
|
+
valid: false,
|
|
240
|
+
error: `${key} value "${val}" must have the same origin as client_id (${clientIdOrigin})`
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const queryWarning = checkUrlQueryWarning(fetchUrl);
|
|
245
|
+
if (queryWarning) warnings.push(queryWarning);
|
|
246
|
+
return {
|
|
247
|
+
valid: true,
|
|
248
|
+
...warnings.length > 0 ? { warnings } : {}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
//#endregion
|
|
252
|
+
//#region src/client-store.ts
|
|
253
|
+
const FETCH_TIMEOUT_MS = 5e3;
|
|
254
|
+
const MAX_RESPONSE_BYTES = 5 * 1024;
|
|
255
|
+
/**
|
|
256
|
+
* Accepts `application/json` and the draft's `application/<AS-defined>+json`
|
|
257
|
+
* form. Parameters (charset, etc.) are allowed after the subtype.
|
|
258
|
+
*/
|
|
259
|
+
const JSON_CONTENT_TYPE_RE = /^application\/(?:[-\w.]+\+)?json\s*(?:;|$)/i;
|
|
260
|
+
function tooLargeError() {
|
|
261
|
+
return new APIError("BAD_REQUEST", {
|
|
262
|
+
error: "invalid_client",
|
|
263
|
+
error_description: `Metadata document exceeds ${MAX_RESPONSE_BYTES / 1024}KB size limit`
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Stream a Response body into a decoded string, aborting as soon as the
|
|
268
|
+
* running byte count exceeds `max`. Guarantees no more than `max + one
|
|
269
|
+
* chunk` bytes ever sit in memory before the request is canceled.
|
|
270
|
+
*/
|
|
271
|
+
async function readBodyWithLimit(response, max) {
|
|
272
|
+
const reader = response.body?.getReader();
|
|
273
|
+
if (!reader) {
|
|
274
|
+
const text = await response.text();
|
|
275
|
+
if (new TextEncoder().encode(text).byteLength > max) throw tooLargeError();
|
|
276
|
+
return text;
|
|
277
|
+
}
|
|
278
|
+
const chunks = [];
|
|
279
|
+
let total = 0;
|
|
280
|
+
while (true) {
|
|
281
|
+
const { done, value } = await reader.read();
|
|
282
|
+
if (done) break;
|
|
283
|
+
total += value.byteLength;
|
|
284
|
+
if (total > max) {
|
|
285
|
+
await reader.cancel();
|
|
286
|
+
throw tooLargeError();
|
|
287
|
+
}
|
|
288
|
+
chunks.push(value);
|
|
289
|
+
}
|
|
290
|
+
const merged = new Uint8Array(total);
|
|
291
|
+
let offset = 0;
|
|
292
|
+
for (const chunk of chunks) {
|
|
293
|
+
merged.set(chunk, offset);
|
|
294
|
+
offset += chunk.byteLength;
|
|
295
|
+
}
|
|
296
|
+
return new TextDecoder().decode(merged);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* RFC 7591 / CIMD fields accepted from external metadata documents.
|
|
300
|
+
*
|
|
301
|
+
* Security-sensitive fields — `require_pkce`, `disabled`, `skip_consent`,
|
|
302
|
+
* `enable_end_session` — are deliberately excluded. An attacker-controlled
|
|
303
|
+
* document MUST NOT be able to weaken the server's PKCE policy or escalate
|
|
304
|
+
* admin-only flags.
|
|
305
|
+
*/
|
|
306
|
+
const ALLOWED_METADATA_FIELDS = new Set([
|
|
307
|
+
"client_id",
|
|
308
|
+
"redirect_uris",
|
|
309
|
+
"token_endpoint_auth_method",
|
|
310
|
+
"grant_types",
|
|
311
|
+
"response_types",
|
|
312
|
+
"client_name",
|
|
313
|
+
"client_uri",
|
|
314
|
+
"logo_uri",
|
|
315
|
+
"scope",
|
|
316
|
+
"contacts",
|
|
317
|
+
"tos_uri",
|
|
318
|
+
"policy_uri",
|
|
319
|
+
"software_id",
|
|
320
|
+
"software_version",
|
|
321
|
+
"software_statement",
|
|
322
|
+
"post_logout_redirect_uris",
|
|
323
|
+
"subject_type",
|
|
324
|
+
"type",
|
|
325
|
+
"jwks",
|
|
326
|
+
"jwks_uri"
|
|
327
|
+
]);
|
|
328
|
+
/**
|
|
329
|
+
* Extract only recognized RFC 7591 / CIMD fields from the metadata document.
|
|
330
|
+
* Prevents arbitrary attacker-controlled fields from leaking into the DB.
|
|
331
|
+
*/
|
|
332
|
+
function toOAuthClientBody(metadata) {
|
|
333
|
+
const filtered = {};
|
|
334
|
+
for (const key of ALLOWED_METADATA_FIELDS) if (key in metadata) filtered[key] = metadata[key];
|
|
335
|
+
return {
|
|
336
|
+
...filtered,
|
|
337
|
+
token_endpoint_auth_method: filtered.token_endpoint_auth_method ?? "none"
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Create a new client from a Client ID Metadata Document.
|
|
342
|
+
* Called when a URL-format client_id is encountered for the first time.
|
|
343
|
+
*
|
|
344
|
+
* Writes the DB record directly rather than routing through
|
|
345
|
+
* `createOAuthClientEndpoint`, because CIMD clients must use the URL
|
|
346
|
+
* as their `clientId` (not a generated random ID).
|
|
347
|
+
*/
|
|
348
|
+
async function createMetadataDocumentClient(ctx, clientIdUrl, cimdOptions, oauthOptions) {
|
|
349
|
+
const metadata = await fetchAndValidateMetadataDocument(ctx, clientIdUrl, cimdOptions);
|
|
350
|
+
const oauthClient = toOAuthClientBody(metadata);
|
|
351
|
+
await checkOAuthClient(oauthClient, oauthOptions, { isRegister: true });
|
|
352
|
+
const isPrivateKeyJwt = oauthClient.token_endpoint_auth_method === "private_key_jwt";
|
|
353
|
+
const iat = Math.floor(Date.now() / 1e3);
|
|
354
|
+
const schema = oauthToSchema({
|
|
355
|
+
...oauthClient,
|
|
356
|
+
disabled: void 0,
|
|
357
|
+
skip_consent: void 0,
|
|
358
|
+
enable_end_session: void 0,
|
|
359
|
+
jwks: isPrivateKeyJwt ? oauthClient.jwks : void 0,
|
|
360
|
+
jwks_uri: isPrivateKeyJwt ? oauthClient.jwks_uri : void 0,
|
|
361
|
+
client_id: clientIdUrl,
|
|
362
|
+
client_secret: void 0,
|
|
363
|
+
client_secret_expires_at: void 0,
|
|
364
|
+
client_id_issued_at: iat,
|
|
365
|
+
public: !isPrivateKeyJwt
|
|
366
|
+
});
|
|
367
|
+
const model = oauthOptions.schema?.oauthClient?.modelName ?? "oauthClient";
|
|
368
|
+
let client;
|
|
369
|
+
try {
|
|
370
|
+
client = await ctx.context.adapter.create({
|
|
371
|
+
model,
|
|
372
|
+
data: {
|
|
373
|
+
...schema,
|
|
374
|
+
createdAt: /* @__PURE__ */ new Date(iat * 1e3),
|
|
375
|
+
updatedAt: /* @__PURE__ */ new Date(iat * 1e3)
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
} catch (err) {
|
|
379
|
+
const existing = await ctx.context.adapter.findOne({
|
|
380
|
+
model,
|
|
381
|
+
where: [{
|
|
382
|
+
field: "clientId",
|
|
383
|
+
value: clientIdUrl
|
|
384
|
+
}]
|
|
385
|
+
});
|
|
386
|
+
if (existing) return existing;
|
|
387
|
+
throw err;
|
|
388
|
+
}
|
|
389
|
+
await cimdOptions.onClientCreated?.({
|
|
390
|
+
client,
|
|
391
|
+
metadata,
|
|
392
|
+
ctx
|
|
393
|
+
});
|
|
394
|
+
return client;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Refresh an existing client by re-fetching its metadata document.
|
|
398
|
+
*
|
|
399
|
+
* Admin-controlled fields (`disabled`, `skip_consent`, `enable_end_session`)
|
|
400
|
+
* are never overwritten from the document — they are read from `existing`
|
|
401
|
+
* and preserved so admin decisions survive a refresh.
|
|
402
|
+
*/
|
|
403
|
+
async function refreshMetadataDocumentClient(ctx, clientIdUrl, existing, cimdOptions, oauthOptions) {
|
|
404
|
+
const metadata = await fetchAndValidateMetadataDocument(ctx, clientIdUrl, cimdOptions);
|
|
405
|
+
const oauthClient = toOAuthClientBody(metadata);
|
|
406
|
+
await checkOAuthClient(oauthClient, oauthOptions, { isRegister: true });
|
|
407
|
+
const isPrivateKeyJwt = oauthClient.token_endpoint_auth_method === "private_key_jwt";
|
|
408
|
+
const schema = oauthToSchema({
|
|
409
|
+
...oauthClient,
|
|
410
|
+
jwks: isPrivateKeyJwt ? oauthClient.jwks : void 0,
|
|
411
|
+
jwks_uri: isPrivateKeyJwt ? oauthClient.jwks_uri : void 0,
|
|
412
|
+
client_id: clientIdUrl,
|
|
413
|
+
client_secret: void 0,
|
|
414
|
+
client_secret_expires_at: void 0,
|
|
415
|
+
public: !isPrivateKeyJwt
|
|
416
|
+
});
|
|
417
|
+
const preservedAdminFields = {
|
|
418
|
+
disabled: existing.disabled,
|
|
419
|
+
skipConsent: existing.skipConsent,
|
|
420
|
+
enableEndSession: existing.enableEndSession
|
|
421
|
+
};
|
|
422
|
+
const model = oauthOptions.schema?.oauthClient?.modelName ?? "oauthClient";
|
|
423
|
+
const client = await ctx.context.adapter.update({
|
|
424
|
+
model,
|
|
425
|
+
where: [{
|
|
426
|
+
field: "clientId",
|
|
427
|
+
value: clientIdUrl
|
|
428
|
+
}],
|
|
429
|
+
update: {
|
|
430
|
+
...schema,
|
|
431
|
+
...preservedAdminFields,
|
|
432
|
+
updatedAt: /* @__PURE__ */ new Date(Math.floor(Date.now() / 1e3) * 1e3)
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
if (!client) throw new APIError("BAD_REQUEST", {
|
|
436
|
+
error: "invalid_client",
|
|
437
|
+
error_description: "client no longer exists"
|
|
438
|
+
});
|
|
439
|
+
await cimdOptions.onClientRefreshed?.({
|
|
440
|
+
client,
|
|
441
|
+
metadata,
|
|
442
|
+
ctx
|
|
443
|
+
});
|
|
444
|
+
return client;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Fetch a Client ID Metadata Document, validate it against the spec,
|
|
448
|
+
* and return the parsed metadata.
|
|
449
|
+
*/
|
|
450
|
+
async function fetchAndValidateMetadataDocument(ctx, clientIdUrl, cimdOptions) {
|
|
451
|
+
const urlError = validateClientIdUrl(clientIdUrl);
|
|
452
|
+
if (urlError) throw new APIError("BAD_REQUEST", {
|
|
453
|
+
error: "invalid_client",
|
|
454
|
+
error_description: urlError
|
|
455
|
+
});
|
|
456
|
+
if (cimdOptions.allowFetch) {
|
|
457
|
+
if (!await cimdOptions.allowFetch(clientIdUrl, ctx)) throw new APIError("BAD_REQUEST", {
|
|
458
|
+
error: "invalid_client",
|
|
459
|
+
error_description: "client_id URL is not permitted by the server's fetch policy"
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
let response;
|
|
463
|
+
try {
|
|
464
|
+
response = await fetch(clientIdUrl, {
|
|
465
|
+
headers: { Accept: "application/json" },
|
|
466
|
+
redirect: "error",
|
|
467
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
468
|
+
});
|
|
469
|
+
} catch (err) {
|
|
470
|
+
throw new APIError("BAD_REQUEST", {
|
|
471
|
+
error: "invalid_client",
|
|
472
|
+
error_description: err instanceof DOMException && err.name === "TimeoutError" ? `Metadata document fetch timed out after ${FETCH_TIMEOUT_MS}ms` : "Failed to fetch metadata document (network error or redirect blocked)"
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
if (!response.ok) throw new APIError("BAD_REQUEST", {
|
|
476
|
+
error: "invalid_client",
|
|
477
|
+
error_description: `Metadata document fetch returned HTTP ${response.status}`
|
|
478
|
+
});
|
|
479
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
480
|
+
if (!JSON_CONTENT_TYPE_RE.test(contentType)) throw new APIError("BAD_REQUEST", {
|
|
481
|
+
error: "invalid_client",
|
|
482
|
+
error_description: `Metadata document must be JSON (got Content-Type "${contentType || "(none)"}")`
|
|
483
|
+
});
|
|
484
|
+
const contentLengthHeader = response.headers.get("content-length");
|
|
485
|
+
if (contentLengthHeader) {
|
|
486
|
+
const declared = Number.parseInt(contentLengthHeader, 10);
|
|
487
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
488
|
+
await response.body?.cancel();
|
|
489
|
+
throw tooLargeError();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const bodyText = await readBodyWithLimit(response, MAX_RESPONSE_BYTES);
|
|
493
|
+
let data;
|
|
494
|
+
try {
|
|
495
|
+
data = JSON.parse(bodyText);
|
|
496
|
+
} catch {
|
|
497
|
+
throw new APIError("BAD_REQUEST", {
|
|
498
|
+
error: "invalid_client",
|
|
499
|
+
error_description: "Metadata document is not valid JSON"
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
const validation = validateCimdMetadata(clientIdUrl, data, cimdOptions.originBoundFields);
|
|
503
|
+
if (!validation.valid) throw new APIError("BAD_REQUEST", {
|
|
504
|
+
error: "invalid_client",
|
|
505
|
+
error_description: validation.error ?? "Invalid metadata document"
|
|
506
|
+
});
|
|
507
|
+
return data;
|
|
508
|
+
}
|
|
509
|
+
//#endregion
|
|
510
|
+
//#region src/resolver.ts
|
|
511
|
+
function toDate(value) {
|
|
512
|
+
if (value instanceof Date) return Number.isFinite(value.getTime()) ? value : null;
|
|
513
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
514
|
+
const parsed = new Date(value);
|
|
515
|
+
return Number.isFinite(parsed.getTime()) ? parsed : null;
|
|
516
|
+
}
|
|
517
|
+
if (typeof value === "bigint") {
|
|
518
|
+
const parsed = new Date(Number(value));
|
|
519
|
+
return Number.isFinite(parsed.getTime()) ? parsed : null;
|
|
520
|
+
}
|
|
521
|
+
if (typeof value === "string") {
|
|
522
|
+
const asNumber = Number(value);
|
|
523
|
+
if (Number.isFinite(asNumber)) {
|
|
524
|
+
const parsed = new Date(asNumber);
|
|
525
|
+
return Number.isFinite(parsed.getTime()) ? parsed : null;
|
|
526
|
+
}
|
|
527
|
+
const parsed = new Date(value);
|
|
528
|
+
return Number.isFinite(parsed.getTime()) ? parsed : null;
|
|
529
|
+
}
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
function isStale(existing, refreshRate) {
|
|
533
|
+
const updatedAt = toDate(existing.updatedAt) ?? toDate(existing.createdAt) ?? /* @__PURE__ */ new Date(0);
|
|
534
|
+
const updatedSec = Math.floor(updatedAt.getTime() / 1e3);
|
|
535
|
+
return (typeof refreshRate === "number" ? updatedSec + refreshRate : toExpJWT(refreshRate, updatedSec)) < Math.floor(Date.now() / 1e3);
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Build the `resolve` function for a CIMD {@link ClientDiscovery}.
|
|
539
|
+
*
|
|
540
|
+
* Exposed for advanced composition. Most users should call
|
|
541
|
+
* {@link cimdClientDiscovery} (to pass a complete discovery to
|
|
542
|
+
* `oauthProvider({ clientDiscovery })`) or install the `cimd()` plugin.
|
|
543
|
+
*/
|
|
544
|
+
function createCimdResolver(cimdOptions = {}) {
|
|
545
|
+
const refreshRate = cimdOptions.refreshRate ?? "60m";
|
|
546
|
+
return async (ctx, clientId, existing) => {
|
|
547
|
+
if (!isUrlClientId(clientId)) return null;
|
|
548
|
+
const provider = ctx.context.getPlugin("oauth-provider");
|
|
549
|
+
if (!provider) throw new BetterAuthError("cimd discovery invoked without the oauth-provider plugin installed");
|
|
550
|
+
const oauthOptions = provider.options;
|
|
551
|
+
if (!existing) return await createMetadataDocumentClient(ctx, clientId, cimdOptions, oauthOptions);
|
|
552
|
+
if (isStale(existing, refreshRate)) return await refreshMetadataDocumentClient(ctx, clientId, existing, cimdOptions, oauthOptions);
|
|
553
|
+
return existing;
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
//#endregion
|
|
557
|
+
//#region src/version.ts
|
|
558
|
+
const PACKAGE_VERSION = "1.7.0-beta.0";
|
|
559
|
+
//#endregion
|
|
560
|
+
//#region src/index.ts
|
|
561
|
+
/**
|
|
562
|
+
* Build a {@link ClientDiscovery} for Client ID Metadata Documents.
|
|
563
|
+
*
|
|
564
|
+
* Users who prefer explicit composition can pass the result directly to
|
|
565
|
+
* `oauthProvider({ clientDiscovery })`; most users should install the
|
|
566
|
+
* {@link cimd} plugin instead, which appends this discovery to whatever
|
|
567
|
+
* is already configured.
|
|
568
|
+
*/
|
|
569
|
+
function cimdClientDiscovery(options = {}) {
|
|
570
|
+
return {
|
|
571
|
+
id: "cimd",
|
|
572
|
+
matches: isUrlClientId,
|
|
573
|
+
resolve: createCimdResolver(options),
|
|
574
|
+
discoveryMetadata: { client_id_metadata_document_supported: true }
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Client ID Metadata Document plugin.
|
|
579
|
+
*
|
|
580
|
+
* Adds unauthenticated dynamic client discovery over HTTPS to an
|
|
581
|
+
* `oauth-provider` instance. Clients identify themselves by providing
|
|
582
|
+
* an HTTPS URL as their `client_id`; the plugin fetches and validates
|
|
583
|
+
* the document at that URL, then creates a public client record.
|
|
584
|
+
*
|
|
585
|
+
* See {@link https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ | the IETF draft}
|
|
586
|
+
* and {@link https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents-flow | the MCP authorization spec}.
|
|
587
|
+
*/
|
|
588
|
+
const cimd = (options = {}) => {
|
|
589
|
+
const discovery = cimdClientDiscovery(options);
|
|
590
|
+
return {
|
|
591
|
+
id: "cimd",
|
|
592
|
+
version: PACKAGE_VERSION,
|
|
593
|
+
init(ctx) {
|
|
594
|
+
const provider = ctx.getPlugin("oauth-provider");
|
|
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;
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
};
|
|
601
|
+
//#endregion
|
|
602
|
+
export { cimd, cimdClientDiscovery, createCimdResolver, isLocalhost, isUrlClientId, validateCimdMetadata, validateClientIdUrl };
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@better-auth/cimd",
|
|
3
|
+
"version": "1.7.0-beta.0",
|
|
4
|
+
"description": "Client ID Metadata Document plugin for Better Auth",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://www.better-auth.com/docs/plugins/cimd",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/better-auth/better-auth.git",
|
|
11
|
+
"directory": "packages/cimd"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"auth",
|
|
15
|
+
"cimd",
|
|
16
|
+
"client-id-metadata-document",
|
|
17
|
+
"mcp",
|
|
18
|
+
"oauth",
|
|
19
|
+
"oauth2",
|
|
20
|
+
"oidc",
|
|
21
|
+
"typescript",
|
|
22
|
+
"better-auth"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"main": "./dist/index.mjs",
|
|
32
|
+
"module": "./dist/index.mjs",
|
|
33
|
+
"types": "./dist/index.d.mts",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"dev-source": "./src/index.ts",
|
|
37
|
+
"types": "./dist/index.d.mts",
|
|
38
|
+
"default": "./dist/index.mjs"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"typesVersions": {
|
|
42
|
+
"*": {
|
|
43
|
+
"*": [
|
|
44
|
+
"./dist/index.d.mts"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"listhen": "^1.9.0",
|
|
50
|
+
"tsdown": "0.21.1",
|
|
51
|
+
"@better-auth/oauth-provider": "1.7.0-beta.1",
|
|
52
|
+
"@better-auth/core": "1.7.0-beta.1",
|
|
53
|
+
"better-auth": "1.7.0-beta.1"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"better-call": "1.3.5",
|
|
57
|
+
"@better-auth/oauth-provider": "^1.7.0-beta.1",
|
|
58
|
+
"@better-auth/core": "^1.7.0-beta.1",
|
|
59
|
+
"better-auth": "^1.7.0-beta.1"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "tsdown",
|
|
63
|
+
"dev": "tsdown --watch",
|
|
64
|
+
"lint:package": "publint run --strict --pack false",
|
|
65
|
+
"lint:types": "attw --profile esm-only --pack .",
|
|
66
|
+
"typecheck": "tsc --project tsconfig.json",
|
|
67
|
+
"test": "vitest",
|
|
68
|
+
"coverage": "vitest run --coverage --coverage.provider=istanbul"
|
|
69
|
+
}
|
|
70
|
+
}
|