@kaperkunde/auth 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaperkunde
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,13 @@
1
- # Temporary Holding Version
1
+ # @kaperkunde/auth
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Better Auth as the OAuth 2.1 authorization server for an app's MCP
4
+ connector, plus the rules and words the sign-in forms share.
5
+
6
+ | Import | What it gives |
7
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
8
+ | `@kaperkunde/auth/preset` | `mcpAuthPreset({ urls, scopes })` to spread into `betterAuth({…})`: jwt (its `/token` closed, no set-auth-jwt header), mcp (code + refresh only, open registration, an hour's access token), cimd, and the production rate limit with room for the OAuth endpoints. `discoveryRoute(auth.handler)` for the root `.well-known` routes |
9
+ | `@kaperkunde/auth` | `mcpUrls` (issuer, resource, JWKS over loopback, resource metadata); `parseScopeList`, `effectiveScopes` (token ∩ live consent, null once disconnected); `bearerChallenge`, `mcpAuthError`, `withCors`, `bearerTokenFrom`, `calledToolNames`; `hashOpaqueToken` + `opaqueTokenClaims` (refuses revoked, expired, DPoP-bound, disabled-client, expired-session and other-resource tokens); `validatePassword`, `withResetPasswordEmail`, `authErrorMessage`, `oauthRedirectFrom` |
10
+
11
+ The app keeps what is its own: the database adapter, its hooks, the scope
12
+ names and their descriptions, and the lookups (`oauthConsent`,
13
+ `oauthAccessToken`) through its own client, whose rows these functions judge.
@@ -0,0 +1,99 @@
1
+ import { n as mcpUrls, t as McpUrls } from "./urls-Cbyx1eIp.mjs";
2
+ /** The headers an MCP route answers with, for clients in the browser too. */
3
+ export declare const MCP_CORS_HEADERS: Readonly<Record<string, string>>;
4
+ /** A response with CORS headers set over its own. */
5
+ export declare function withCors(response: Response, headers?: Readonly<Record<string, string>>): Response;
6
+ /**
7
+ * The WWW-Authenticate value that sends a client to sign in (RFC 6750 and
8
+ * 9728): where the resource's metadata is, and the scopes to ask for. A
9
+ * client reads `scope` to build its authorization request, and adds
10
+ * offline_access itself when it is listed.
11
+ */
12
+ export declare function bearerChallenge({ urls, scopes, error, description }: {
13
+ urls: Pick<McpUrls, "resourceMetadataUrl">;
14
+ scopes: readonly string[];
15
+ error?: "invalid_token" | "insufficient_scope" | "invalid_request";
16
+ description?: string;
17
+ }): string;
18
+ /**
19
+ * A JSON-RPC error answer with its challenge: 401 to sign in again, 403 for
20
+ * a token without the scope a call needs.
21
+ */
22
+ export declare function mcpAuthError({ status, message, challenge }: {
23
+ status: 401 | 403;
24
+ message: string;
25
+ challenge: string;
26
+ }): Response;
27
+ /** The token of an `Authorization: Bearer …` request, or null. */
28
+ export declare function bearerTokenFrom(request: Request): string | null;
29
+ /** Tool names a JSON-RPC body calls (one request or a batch), for scope checks and rate buckets. */
30
+ export declare function calledToolNames(request: Request): Promise<string[]>;
31
+ /** One password rule for sign-up, reset and change-password forms. */
32
+ export declare function validatePassword(password: string, verify: string, { minLength }?: {
33
+ minLength?: number;
34
+ }): string | null;
35
+ /** Query parameter carrying the account's address on the password-reset link. */
36
+ export declare const RESET_PASSWORD_EMAIL_PARAM = "email";
37
+ /**
38
+ * Adds the account's address to the callback on a Better Auth reset link,
39
+ * so the form can sign the person in once the new password is set. The
40
+ * address comes from the user row Better Auth resolved, never from whoever
41
+ * asked, so a caller's redirectTo cannot put someone else's address on it.
42
+ */
43
+ export declare function withResetPasswordEmail(url: string, email: string): string;
44
+ export declare const UNEXPECTED_ERROR = "Something went wrong on our side. Try again in a minute.";
45
+ type AuthError = {
46
+ code?: string;
47
+ message?: string;
48
+ status?: number;
49
+ error?: string;
50
+ };
51
+ /**
52
+ * Better Auth answers with codes; people read sentences. A server error is
53
+ * always the unexpected-error sentence, never Better Auth's own message.
54
+ * `overrides` lets an app word a code its own way.
55
+ */
56
+ export declare function authErrorMessage(error: AuthError | null | undefined, fallback: string, overrides?: Readonly<Record<string, string>>): string;
57
+ /** Where an authorization continues after signing in for a connecting app, if it does. */
58
+ export declare function oauthRedirectFrom(data: unknown): string | null;
59
+ /**
60
+ * Access tokens for clients that did not name the resource they wanted (RFC
61
+ * 8707). The authorization server issues an opaque token then, rather than a
62
+ * JWT bound to the resource, and keeps a SHA-256 of it. The app looks the
63
+ * row up by `hashOpaqueToken` with its own database client and hands it to
64
+ * `opaqueTokenClaims`, which answers JWT-shaped claims or null.
65
+ */
66
+ export declare function hashOpaqueToken(token: string): string;
67
+ /** Whether a bearer token is the opaque kind (a JWT has dots). */
68
+ export declare function isOpaqueToken(token: string): boolean;
69
+ type OpaqueTokenRow = {
70
+ clientId: string;
71
+ userId: string | null;
72
+ scopes: unknown;
73
+ resources?: unknown;
74
+ expiresAt: Date;
75
+ revoked?: Date | boolean | null;
76
+ /** DPoP-bound tokens need a proof a plain Bearer request cannot carry. */
77
+ confirmation?: unknown;
78
+ client?: {
79
+ disabled?: boolean | null;
80
+ } | null;
81
+ session?: {
82
+ expiresAt: Date;
83
+ } | null;
84
+ };
85
+ export declare function opaqueTokenClaims(row: OpaqueTokenRow | null | undefined, resource: string, now?: Date): Record<string, unknown> | null;
86
+ /**
87
+ * Scopes as Better Auth stores them (an array, a space- or comma-separated
88
+ * string, or JSON text on MySQL), as a list.
89
+ */
90
+ export declare function parseScopeList(value: unknown): string[];
91
+ /**
92
+ * What a request may do: the token's scopes, narrowed to what the person
93
+ * still allows the app. Access tokens are self-contained and refresh tokens
94
+ * carry their original scopes, so the consent row is the only live record:
95
+ * checking it on every request makes Disconnect take effect at once
96
+ * (`consented` null), and intersecting makes a downgrade take effect too.
97
+ */
98
+ export declare function effectiveScopes(tokenScopes: unknown, consented: unknown | null): string[] | null;
99
+ export { type McpUrls, type OpaqueTokenRow, mcpUrls };
package/dist/index.mjs ADDED
@@ -0,0 +1,200 @@
1
+ import { createHash } from "node:crypto";
2
+ // src/challenge.ts
3
+ /** The headers an MCP route answers with, for clients in the browser too. */
4
+ const MCP_CORS_HEADERS = {
5
+ "Access-Control-Allow-Origin": "*",
6
+ "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
7
+ "Access-Control-Allow-Headers": "Authorization, Content-Type, Accept, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id, Last-Event-ID",
8
+ "Access-Control-Expose-Headers": "WWW-Authenticate, Mcp-Session-Id"
9
+ };
10
+ /** A response with CORS headers set over its own. */
11
+ function withCors(response, headers = MCP_CORS_HEADERS) {
12
+ const merged = new Headers(response.headers);
13
+ for (const [key, value] of Object.entries(headers)) merged.set(key, value);
14
+ return new Response(response.body, {
15
+ status: response.status,
16
+ statusText: response.statusText,
17
+ headers: merged
18
+ });
19
+ }
20
+ function quoted(value) {
21
+ return `"${value.replace(/["\\]/g, "\\$&")}"`;
22
+ }
23
+ /**
24
+ * The WWW-Authenticate value that sends a client to sign in (RFC 6750 and
25
+ * 9728): where the resource's metadata is, and the scopes to ask for. A
26
+ * client reads `scope` to build its authorization request, and adds
27
+ * offline_access itself when it is listed.
28
+ */
29
+ function bearerChallenge({ urls, scopes, error, description }) {
30
+ return `Bearer ${[
31
+ ...error ? [`error=${quoted(error)}`] : [],
32
+ ...description ? [`error_description=${quoted(description)}`] : [],
33
+ `resource_metadata=${quoted(urls.resourceMetadataUrl)}`,
34
+ `scope=${quoted(scopes.join(" "))}`
35
+ ].join(", ")}`;
36
+ }
37
+ /**
38
+ * A JSON-RPC error answer with its challenge: 401 to sign in again, 403 for
39
+ * a token without the scope a call needs.
40
+ */
41
+ function mcpAuthError({ status, message, challenge }) {
42
+ return withCors(new Response(JSON.stringify({
43
+ jsonrpc: "2.0",
44
+ error: {
45
+ code: -32e3,
46
+ message
47
+ },
48
+ id: null
49
+ }), {
50
+ status,
51
+ headers: {
52
+ "Content-Type": "application/json",
53
+ "WWW-Authenticate": challenge
54
+ }
55
+ }));
56
+ }
57
+ /** The token of an `Authorization: Bearer …` request, or null. */
58
+ function bearerTokenFrom(request) {
59
+ return (request.headers.get("authorization") ?? "").match(/^Bearer\s+(.+)$/i)?.[1]?.trim() || null;
60
+ }
61
+ /** Tool names a JSON-RPC body calls (one request or a batch), for scope checks and rate buckets. */
62
+ async function calledToolNames(request) {
63
+ if (request.method !== "POST") return [];
64
+ let body;
65
+ try {
66
+ body = await request.clone().json();
67
+ } catch {
68
+ return [];
69
+ }
70
+ return (Array.isArray(body) ? body : [body]).flatMap((message) => {
71
+ const m = message;
72
+ return m?.method === "tools/call" && typeof m.params?.name === "string" ? [m.params.name] : [];
73
+ });
74
+ }
75
+ // src/forms.ts
76
+ /** One password rule for sign-up, reset and change-password forms. */
77
+ function validatePassword(password, verify, { minLength = 8 } = {}) {
78
+ if (password.length < minLength) return `Password must be at least ${minLength} characters.`;
79
+ if (password !== verify) return "Passwords do not match.";
80
+ return null;
81
+ }
82
+ /** Query parameter carrying the account's address on the password-reset link. */
83
+ const RESET_PASSWORD_EMAIL_PARAM = "email";
84
+ /**
85
+ * Adds the account's address to the callback on a Better Auth reset link,
86
+ * so the form can sign the person in once the new password is set. The
87
+ * address comes from the user row Better Auth resolved, never from whoever
88
+ * asked, so a caller's redirectTo cannot put someone else's address on it.
89
+ */
90
+ function withResetPasswordEmail(url, email) {
91
+ try {
92
+ const link = new URL(url);
93
+ const callback = new URL(link.searchParams.get("callbackURL") || "/reset-password", link.origin);
94
+ callback.searchParams.set(RESET_PASSWORD_EMAIL_PARAM, email);
95
+ link.searchParams.set("callbackURL", `${callback.pathname}${callback.search}`);
96
+ return link.toString();
97
+ } catch {
98
+ return url;
99
+ }
100
+ }
101
+ const UNEXPECTED_ERROR = "Something went wrong on our side. Try again in a minute.";
102
+ const MESSAGES = {
103
+ INVALID_EMAIL_OR_PASSWORD: "Wrong email or password.",
104
+ USER_ALREADY_EXISTS: "An account with this email already exists. Sign in instead.",
105
+ USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: "An account with this email already exists. Sign in instead.",
106
+ PASSWORD_TOO_SHORT: "Use at least 8 characters for your password.",
107
+ PASSWORD_TOO_LONG: "That password is too long. Use at most 128 characters.",
108
+ INVALID_EMAIL: "Enter a valid email address.",
109
+ INVALID_PASSWORD: "Your current password is wrong.",
110
+ CREDENTIAL_ACCOUNT_NOT_FOUND: "This account has no password to change.",
111
+ INVALID_TOKEN: "This link has expired or was already used. Ask for a new one.",
112
+ EMAIL_NOT_VERIFIED: "Confirm your email address first: we sent you a link."
113
+ };
114
+ /**
115
+ * Better Auth answers with codes; people read sentences. A server error is
116
+ * always the unexpected-error sentence, never Better Auth's own message.
117
+ * `overrides` lets an app word a code its own way.
118
+ */
119
+ function authErrorMessage(error, fallback, overrides = {}) {
120
+ if (!error) return fallback;
121
+ if (error.status === 429) return "Too many attempts. Wait a minute and try again.";
122
+ if (error.status !== void 0 && error.status >= 500) return UNEXPECTED_ERROR;
123
+ const code = error.code ?? "";
124
+ if (overrides[code]) return overrides[code];
125
+ if (MESSAGES[code]) return MESSAGES[code];
126
+ if (error.error === "invalid_signature" || code === "INVALID_SIGNATURE") return "This request to connect an app has expired. Go back to the app and start connecting again.";
127
+ return fallback;
128
+ }
129
+ /** Where an authorization continues after signing in for a connecting app, if it does. */
130
+ function oauthRedirectFrom(data) {
131
+ if (!data || typeof data !== "object") return null;
132
+ const record = data;
133
+ const url = typeof record.url === "string" ? record.url : record.redirect_uri;
134
+ return typeof url === "string" && (record.redirect === true || record.redirect_uri) ? url : null;
135
+ }
136
+ // src/scopes.ts
137
+ /**
138
+ * Scopes as Better Auth stores them (an array, a space- or comma-separated
139
+ * string, or JSON text on MySQL), as a list.
140
+ */
141
+ function parseScopeList(value) {
142
+ if (Array.isArray(value)) return value.filter((entry) => typeof entry === "string");
143
+ if (typeof value !== "string" || !value.trim()) return [];
144
+ const trimmed = value.trim();
145
+ if (trimmed.startsWith("[")) try {
146
+ return parseScopeList(JSON.parse(trimmed));
147
+ } catch {
148
+ return [];
149
+ }
150
+ return trimmed.split(/[\s,]+/).filter(Boolean);
151
+ }
152
+ /**
153
+ * What a request may do: the token's scopes, narrowed to what the person
154
+ * still allows the app. Access tokens are self-contained and refresh tokens
155
+ * carry their original scopes, so the consent row is the only live record:
156
+ * checking it on every request makes Disconnect take effect at once
157
+ * (`consented` null), and intersecting makes a downgrade take effect too.
158
+ */
159
+ function effectiveScopes(tokenScopes, consented) {
160
+ if (consented === null || consented === void 0) return null;
161
+ const allowed = new Set(parseScopeList(consented));
162
+ return parseScopeList(tokenScopes).filter((scope) => allowed.has(scope));
163
+ }
164
+ // src/opaque-token.ts
165
+ /**
166
+ * Access tokens for clients that did not name the resource they wanted (RFC
167
+ * 8707). The authorization server issues an opaque token then, rather than a
168
+ * JWT bound to the resource, and keeps a SHA-256 of it. The app looks the
169
+ * row up by `hashOpaqueToken` with its own database client and hands it to
170
+ * `opaqueTokenClaims`, which answers JWT-shaped claims or null.
171
+ */
172
+ function hashOpaqueToken(token) {
173
+ return createHash("sha256").update(token).digest("base64url");
174
+ }
175
+ /** Whether a bearer token is the opaque kind (a JWT has dots). */
176
+ function isOpaqueToken(token) {
177
+ return Boolean(token) && !token.includes(".");
178
+ }
179
+ function opaqueTokenClaims(row, resource, now = /* @__PURE__ */ new Date()) {
180
+ if (!row || !row.userId || row.revoked || row.expiresAt <= now || row.confirmation || row.client?.disabled || row.session && row.session.expiresAt <= now) return null;
181
+ const resources = parseScopeList(row.resources);
182
+ if (resources.length > 0 && !resources.includes(resource)) return null;
183
+ return {
184
+ sub: row.userId,
185
+ azp: row.clientId,
186
+ scope: parseScopeList(row.scopes).join(" "),
187
+ exp: Math.floor(row.expiresAt.getTime() / 1e3)
188
+ };
189
+ }
190
+ // src/urls.ts
191
+ function mcpUrls({ siteUrl, resourcePath = "/mcp", port = process.env.PORT?.trim() || "3000", jwksUrl = process.env.MCP_JWKS_URL?.trim() }) {
192
+ const issuer = siteUrl.replace(/\/+$/, "");
193
+ return {
194
+ issuer,
195
+ resource: `${issuer}${resourcePath}`,
196
+ jwksUrl: jwksUrl || `http://127.0.0.1:${port}/api/auth/jwks`,
197
+ resourceMetadataUrl: `${issuer}/.well-known/oauth-protected-resource${resourcePath}`
198
+ };
199
+ }
200
+ export { MCP_CORS_HEADERS, RESET_PASSWORD_EMAIL_PARAM, UNEXPECTED_ERROR, authErrorMessage, bearerChallenge, bearerTokenFrom, calledToolNames, effectiveScopes, hashOpaqueToken, isOpaqueToken, mcpAuthError, mcpUrls, oauthRedirectFrom, opaqueTokenClaims, parseScopeList, validatePassword, withCors, withResetPasswordEmail };
@@ -0,0 +1,60 @@
1
+ import { t as McpUrls } from "./urls-Cbyx1eIp.mjs";
2
+ /**
3
+ * The Better Auth options an app needs to be an OAuth 2.1 authorization
4
+ * server for its MCP connector, as one object spread into `betterAuth({…})`:
5
+ *
6
+ * - `jwt` signs the access tokens, with its own `/token` endpoint closed and
7
+ * no set-auth-jwt header on session responses, so a signed-in browser is
8
+ * never handed a token of the same shape;
9
+ * - `mcp`: authorization code and refresh token only (every token belongs to
10
+ * a person who approved it), open dynamic registration, an hour's access
11
+ * token and a refresh token for `refreshDays`;
12
+ * - `cimd` for clients that identify with a metadata document (Claude);
13
+ * - the rate limit, in production only, with room for the OAuth endpoints
14
+ * every connector reaches from one provider's egress range.
15
+ *
16
+ * betterAuth({ ...mcpAuthPreset({ urls, scopes, consentPage }), emailAndPassword: … })
17
+ *
18
+ * An app with its own `plugins` passes them in `extraPlugins`.
19
+ */
20
+ export declare function mcpAuthPreset({ urls, scopes, loginPage, consentPage, refreshDays, rateLimited, extraPlugins, disabledPaths }: {
21
+ urls: Pick<McpUrls, "issuer" | "resource">;
22
+ scopes: readonly string[];
23
+ loginPage?: string;
24
+ consentPage?: string;
25
+ refreshDays?: number;
26
+ rateLimited?: boolean;
27
+ extraPlugins?: readonly unknown[];
28
+ disabledPaths?: readonly string[];
29
+ }): {
30
+ disabledPaths: string[];
31
+ rateLimit: {
32
+ enabled: boolean;
33
+ window: number;
34
+ max: number;
35
+ customRules: {
36
+ "/oauth2/token": {
37
+ window: number;
38
+ max: number;
39
+ };
40
+ "/oauth2/register": {
41
+ window: number;
42
+ max: number;
43
+ };
44
+ "/jwks": false;
45
+ };
46
+ };
47
+ plugins: unknown[];
48
+ };
49
+ /**
50
+ * Route handlers for the OAuth discovery documents at the site root. Better
51
+ * Auth serves them from its plugins, but only for requests reaching its
52
+ * handler under /api/auth; MCP clients look for them at the root, so these
53
+ * hand the request on untouched and add CORS for browser clients.
54
+ *
55
+ * export const { GET, OPTIONS } = discoveryRoute(auth.handler)
56
+ */
57
+ export declare function discoveryRoute(handler: (request: Request) => Promise<Response>): {
58
+ GET(request: Request): Promise<Response>;
59
+ OPTIONS(): Response;
60
+ };
@@ -0,0 +1,101 @@
1
+ import { cimd } from "@better-auth/cimd";
2
+ import { fetchClientMetadataResource } from "@better-auth/cimd/node";
3
+ import { mcp } from "@better-auth/mcp";
4
+ import { jwt } from "better-auth/plugins";
5
+ // src/preset.ts
6
+ /**
7
+ * The Better Auth options an app needs to be an OAuth 2.1 authorization
8
+ * server for its MCP connector, as one object spread into `betterAuth({…})`:
9
+ *
10
+ * - `jwt` signs the access tokens, with its own `/token` endpoint closed and
11
+ * no set-auth-jwt header on session responses, so a signed-in browser is
12
+ * never handed a token of the same shape;
13
+ * - `mcp`: authorization code and refresh token only (every token belongs to
14
+ * a person who approved it), open dynamic registration, an hour's access
15
+ * token and a refresh token for `refreshDays`;
16
+ * - `cimd` for clients that identify with a metadata document (Claude);
17
+ * - the rate limit, in production only, with room for the OAuth endpoints
18
+ * every connector reaches from one provider's egress range.
19
+ *
20
+ * betterAuth({ ...mcpAuthPreset({ urls, scopes, consentPage }), emailAndPassword: … })
21
+ *
22
+ * An app with its own `plugins` passes them in `extraPlugins`.
23
+ */
24
+ function mcpAuthPreset({ urls, scopes, loginPage = "/login", consentPage = "/oauth/consent", refreshDays = 60, rateLimited = process.env.NODE_ENV === "production" && process.env.E2E?.trim() !== "1", extraPlugins = [], disabledPaths = [] }) {
25
+ return {
26
+ disabledPaths: ["/token", ...disabledPaths],
27
+ rateLimit: {
28
+ enabled: rateLimited,
29
+ window: 60,
30
+ max: 20,
31
+ customRules: {
32
+ "/oauth2/token": {
33
+ window: 60,
34
+ max: 300
35
+ },
36
+ "/oauth2/register": {
37
+ window: 60,
38
+ max: 60
39
+ },
40
+ "/jwks": false
41
+ }
42
+ },
43
+ plugins: [
44
+ jwt({
45
+ jwt: { issuer: urls.issuer },
46
+ disableSettingJwtHeader: true
47
+ }),
48
+ mcp({
49
+ loginPage,
50
+ consentPage,
51
+ resource: urls.resource,
52
+ scopes: [...scopes],
53
+ grantTypes: ["authorization_code", "refresh_token"],
54
+ allowDynamicClientRegistration: true,
55
+ allowUnauthenticatedClientRegistration: true,
56
+ accessTokenExpiresIn: 3600,
57
+ refreshTokenExpiresIn: 86400 * refreshDays
58
+ }),
59
+ cimd({
60
+ fetchClientMetadataResource,
61
+ metadataProfile: "mcp-2026-07-28"
62
+ }),
63
+ ...extraPlugins
64
+ ]
65
+ };
66
+ }
67
+ const DISCOVERY_CORS = {
68
+ "Access-Control-Allow-Origin": "*",
69
+ "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
70
+ "Access-Control-Allow-Headers": "Content-Type, MCP-Protocol-Version",
71
+ "Access-Control-Max-Age": "86400"
72
+ };
73
+ /**
74
+ * Route handlers for the OAuth discovery documents at the site root. Better
75
+ * Auth serves them from its plugins, but only for requests reaching its
76
+ * handler under /api/auth; MCP clients look for them at the root, so these
77
+ * hand the request on untouched and add CORS for browser clients.
78
+ *
79
+ * export const { GET, OPTIONS } = discoveryRoute(auth.handler)
80
+ */
81
+ function discoveryRoute(handler) {
82
+ return {
83
+ async GET(request) {
84
+ const response = await handler(request);
85
+ const headers = new Headers(response.headers);
86
+ for (const [key, value] of Object.entries(DISCOVERY_CORS)) headers.set(key, value);
87
+ return new Response(response.body, {
88
+ status: response.status,
89
+ statusText: response.statusText,
90
+ headers
91
+ });
92
+ },
93
+ OPTIONS() {
94
+ return new Response(null, {
95
+ status: 204,
96
+ headers: DISCOVERY_CORS
97
+ });
98
+ }
99
+ };
100
+ }
101
+ export { discoveryRoute, mcpAuthPreset };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The addresses an MCP connector's OAuth needs. The issuer is the site
3
+ * origin itself, not Better Auth's /api/auth base path, so the authorization
4
+ * server's metadata sits at the plain /.well-known path every MCP client
5
+ * probes first; the endpoints it lists still point into /api/auth/oauth2/*.
6
+ */
7
+ type McpUrls = {
8
+ /** The site origin. */
9
+ issuer: string;
10
+ /** The protected resource tokens are bound to: the connector URL a person pastes, byte for byte. */
11
+ resource: string;
12
+ /** Where the route verifies access tokens. Over loopback: the keys belong to this process. */
13
+ jwksUrl: string;
14
+ /** RFC 9728's metadata document for the resource. */
15
+ resourceMetadataUrl: string;
16
+ };
17
+ declare function mcpUrls({ siteUrl, resourcePath, port, jwksUrl }: {
18
+ siteUrl: string;
19
+ resourcePath?: string;
20
+ port?: string;
21
+ jwksUrl?: string;
22
+ }): McpUrls;
23
+ export { mcpUrls as n, McpUrls as t };
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@kaperkunde/auth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Better Auth as the OAuth 2.1 server for an MCP connector: the preset, discovery routes, scope and consent checks, challenges, opaque tokens, and the auth forms' rules and messages.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/kaperkunde/common-public.git",
10
+ "directory": "packages/auth"
11
+ },
12
+ "sideEffects": false,
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.mts",
19
+ "import": "./dist/index.mjs",
20
+ "default": "./dist/index.mjs"
21
+ },
22
+ "./preset": {
23
+ "types": "./dist/preset.d.mts",
24
+ "import": "./dist/preset.mjs",
25
+ "default": "./dist/preset.mjs"
26
+ }
27
+ },
28
+ "peerDependencies": {
29
+ "@better-auth/cimd": "^1.7.7",
30
+ "@better-auth/mcp": "^1.7.7",
31
+ "better-auth": "^1.7.7"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "@better-auth/cimd": {
35
+ "optional": true
36
+ },
37
+ "@better-auth/mcp": {
38
+ "optional": true
39
+ },
40
+ "better-auth": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "devDependencies": {
45
+ "@better-auth/cimd": "1.7.7",
46
+ "@better-auth/mcp": "1.7.7",
47
+ "@better-auth/oauth-provider": "1.7.7",
48
+ "better-auth": "1.7.7"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public",
52
+ "provenance": true
53
+ },
54
+ "scripts": {
55
+ "build": "tsdown src/index.ts src/preset.ts --format esm --dts --clean",
56
+ "typecheck": "tsc --noEmit -p tsconfig.json"
57
+ }
6
58
  }