@kaperkunde/auth 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +12 -2
- package/dist/index.d.mts +99 -0
- package/dist/index.mjs +200 -0
- package/dist/preset.d.mts +60 -0
- package/dist/preset.mjs +101 -0
- package/dist/urls-Cbyx1eIp.d.mts +23 -0
- package/package.json +53 -3
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
|
-
#
|
|
1
|
+
# @kaperkunde/auth
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
package/dist/index.d.mts
ADDED
|
@@ -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
|
+
};
|
package/dist/preset.mjs
ADDED
|
@@ -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,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaperkunde/auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
},
|
|
21
|
+
"./preset": {
|
|
22
|
+
"types": "./dist/preset.d.mts",
|
|
23
|
+
"import": "./dist/preset.mjs"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@better-auth/cimd": "^1.7.7",
|
|
28
|
+
"@better-auth/mcp": "^1.7.7",
|
|
29
|
+
"better-auth": "^1.7.7"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"@better-auth/cimd": {
|
|
33
|
+
"optional": true
|
|
34
|
+
},
|
|
35
|
+
"@better-auth/mcp": {
|
|
36
|
+
"optional": true
|
|
37
|
+
},
|
|
38
|
+
"better-auth": {
|
|
39
|
+
"optional": true
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@better-auth/cimd": "1.7.7",
|
|
44
|
+
"@better-auth/mcp": "1.7.7",
|
|
45
|
+
"@better-auth/oauth-provider": "1.7.7",
|
|
46
|
+
"better-auth": "1.7.7"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public",
|
|
50
|
+
"provenance": true
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsdown src/index.ts src/preset.ts --format esm --dts --clean",
|
|
54
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
55
|
+
}
|
|
6
56
|
}
|