better-auth
Version:
The most comprehensive authentication framework for TypeScript.
710 lines (708 loc) • 25.8 kB
JavaScript
import { generateRandomString } from "../../crypto/random.mjs";
import "../../crypto/index.mjs";
import { parseSetCookieHeader } from "../../cookies/cookie-utils.mjs";
import "../../cookies/index.mjs";
import { getSessionFromCtx } from "../../api/routes/session.mjs";
import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
import "../../utils/index.mjs";
import { APIError } from "../../api/index.mjs";
import { getBaseURL } from "../../utils/url.mjs";
import { parsePrompt } from "../oidc-provider/utils/prompt.mjs";
import { schema } from "../oidc-provider/schema.mjs";
import { oidcProvider } from "../oidc-provider/index.mjs";
import { authorizeMCPOAuth } from "./authorize.mjs";
import { isProduction, logger } from "@better-auth/core/env";
import * as z from "zod";
import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
import { getWebcryptoSubtle } from "@better-auth/utils";
import { createHash } from "@better-auth/utils/hash";
import { SignJWT } from "jose";
import { base64 } from "@better-auth/utils/base64";
//#region src/plugins/mcp/index.ts
const getMCPProviderMetadata = (ctx, options) => {
const issuer = ctx.context.options.baseURL;
const baseURL = ctx.context.baseURL;
if (!issuer || !baseURL) throw new APIError("INTERNAL_SERVER_ERROR", {
error: "invalid_issuer",
error_description: "issuer or baseURL is not set. If you're the app developer, please make sure to set the `baseURL` in your auth config."
});
return {
issuer,
authorization_endpoint: `${baseURL}/mcp/authorize`,
token_endpoint: `${baseURL}/mcp/token`,
userinfo_endpoint: `${baseURL}/mcp/userinfo`,
jwks_uri: `${baseURL}/mcp/jwks`,
registration_endpoint: `${baseURL}/mcp/register`,
scopes_supported: [
"openid",
"profile",
"email",
"offline_access"
],
response_types_supported: ["code"],
response_modes_supported: ["query"],
grant_types_supported: ["authorization_code", "refresh_token"],
acr_values_supported: ["urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:bronze"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256", "none"],
token_endpoint_auth_methods_supported: [
"client_secret_basic",
"client_secret_post",
"none"
],
code_challenge_methods_supported: ["S256"],
claims_supported: [
"sub",
"iss",
"aud",
"exp",
"nbf",
"iat",
"jti",
"email",
"email_verified",
"name"
],
...options?.metadata
};
};
const getMCPProtectedResourceMetadata = (ctx, options) => {
const baseURL = ctx.context.baseURL;
const origin = new URL(baseURL).origin;
return {
resource: options?.resource ?? origin,
authorization_servers: [origin],
jwks_uri: options?.oidcConfig?.metadata?.jwks_uri ?? `${baseURL}/mcp/jwks`,
scopes_supported: options?.oidcConfig?.metadata?.scopes_supported ?? [
"openid",
"profile",
"email",
"offline_access"
],
bearer_methods_supported: ["header"],
resource_signing_alg_values_supported: ["RS256", "none"]
};
};
const registerMcpClientBodySchema = z.object({
redirect_uris: z.array(z.string()),
token_endpoint_auth_method: z.enum([
"none",
"client_secret_basic",
"client_secret_post"
]).default("client_secret_basic").optional(),
grant_types: z.array(z.enum([
"authorization_code",
"implicit",
"password",
"client_credentials",
"refresh_token",
"urn:ietf:params:oauth:grant-type:jwt-bearer",
"urn:ietf:params:oauth:grant-type:saml2-bearer"
])).default(["authorization_code"]).optional(),
response_types: z.array(z.enum(["code", "token"])).default(["code"]).optional(),
client_name: z.string().optional(),
client_uri: z.string().optional(),
logo_uri: z.string().optional(),
scope: z.string().optional(),
contacts: z.array(z.string()).optional(),
tos_uri: z.string().optional(),
policy_uri: z.string().optional(),
jwks_uri: z.string().optional(),
jwks: z.record(z.string(), z.any()).optional(),
metadata: z.record(z.any(), z.any()).optional(),
software_id: z.string().optional(),
software_version: z.string().optional(),
software_statement: z.string().optional()
});
const mcpOAuthTokenBodySchema = z.record(z.any(), z.any());
const mcp = (options) => {
const opts = {
codeExpiresIn: 600,
defaultScope: "openid",
accessTokenExpiresIn: 3600,
refreshTokenExpiresIn: 604800,
allowPlainCodeChallengeMethod: true,
...options.oidcConfig,
loginPage: options.loginPage,
scopes: [
"openid",
"profile",
"email",
"offline_access",
...options.oidcConfig?.scopes || []
]
};
const modelName = {
oauthClient: "oauthApplication",
oauthAccessToken: "oauthAccessToken",
oauthConsent: "oauthConsent"
};
const provider = oidcProvider(opts);
return {
id: "mcp",
hooks: { after: [{
matcher() {
return true;
},
handler: createAuthMiddleware(async (ctx) => {
const cookie = await ctx.getSignedCookie("oidc_login_prompt", ctx.context.secret);
const cookieName = ctx.context.authCookies.sessionToken.name;
const parsedSetCookieHeader = parseSetCookieHeader(ctx.context.responseHeaders?.get("set-cookie") || "");
const hasSessionToken = parsedSetCookieHeader.has(cookieName);
if (!cookie || !hasSessionToken) return;
ctx.setCookie("oidc_login_prompt", "", { maxAge: 0 });
const sessionToken = (parsedSetCookieHeader.get(cookieName)?.value)?.split(".")[0];
if (!sessionToken) return;
const session = await ctx.context.internalAdapter.findSession(sessionToken) || ctx.context.newSession;
if (!session) return;
const promptSet = parsePrompt(String(ctx.query?.prompt));
if (promptSet.has("login")) {
const newPromptSet = new Set(promptSet);
newPromptSet.delete("login");
ctx.query = {
...ctx.query,
prompt: Array.from(newPromptSet).join(" ")
};
}
ctx.context.session = session;
return await authorizeMCPOAuth(ctx, opts);
})
}] },
endpoints: {
oAuthConsent: provider.endpoints.oAuthConsent,
getMcpOAuthConfig: createAuthEndpoint("/.well-known/oauth-authorization-server", {
method: "GET",
metadata: HIDE_METADATA
}, async (c) => {
try {
const metadata = getMCPProviderMetadata(c, options);
return c.json(metadata);
} catch (e) {
console.log(e);
return c.json(null);
}
}),
getMCPProtectedResource: createAuthEndpoint("/.well-known/oauth-protected-resource", {
method: "GET",
metadata: HIDE_METADATA
}, async (c) => {
const metadata = getMCPProtectedResourceMetadata(c, options);
return c.json(metadata);
}),
mcpOAuthAuthorize: createAuthEndpoint("/mcp/authorize", {
method: "GET",
query: z.record(z.string(), z.any()),
metadata: { openapi: {
description: "Authorize an OAuth2 request using MCP",
responses: { "200": {
description: "Authorization response generated successfully",
content: { "application/json": { schema: {
type: "object",
additionalProperties: true,
description: "Authorization response, contents depend on the authorize function implementation"
} } }
} }
} }
}, async (ctx) => {
return authorizeMCPOAuth(ctx, opts);
}),
mcpOAuthToken: createAuthEndpoint("/mcp/token", {
method: "POST",
body: mcpOAuthTokenBodySchema,
metadata: {
...HIDE_METADATA,
allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"]
}
}, async (ctx) => {
ctx.setHeader("Access-Control-Allow-Origin", "*");
ctx.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
ctx.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
ctx.setHeader("Access-Control-Max-Age", "86400");
let { body } = ctx;
if (!body) throw ctx.error("BAD_REQUEST", {
error_description: "request body not found",
error: "invalid_request"
});
if (body instanceof FormData) body = Object.fromEntries(body.entries());
if (!(body instanceof Object)) throw new APIError("BAD_REQUEST", {
error_description: "request body is not an object",
error: "invalid_request"
});
let { client_id, client_secret } = body;
const authorization = ctx.request?.headers.get("authorization") || null;
if (authorization && !client_id && !client_secret && authorization.startsWith("Basic ")) try {
const encoded = authorization.replace("Basic ", "");
const decoded = new TextDecoder().decode(base64.decode(encoded));
if (!decoded.includes(":")) throw new APIError("UNAUTHORIZED", {
error_description: "invalid authorization header format",
error: "invalid_client"
});
const [id, secret] = decoded.split(":");
if (!id || !secret) throw new APIError("UNAUTHORIZED", {
error_description: "invalid authorization header format",
error: "invalid_client"
});
client_id = id;
client_secret = secret;
} catch {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid authorization header format",
error: "invalid_client"
});
}
const { grant_type, code, redirect_uri, refresh_token, code_verifier } = body;
if (grant_type === "refresh_token") {
if (!refresh_token) throw new APIError("BAD_REQUEST", {
error_description: "refresh_token is required",
error: "invalid_request"
});
const token = await ctx.context.adapter.findOne({
model: "oauthAccessToken",
where: [{
field: "refreshToken",
value: refresh_token.toString()
}]
});
if (!token) throw new APIError("UNAUTHORIZED", {
error_description: "invalid refresh token",
error: "invalid_grant"
});
if (token.clientId !== client_id?.toString()) throw new APIError("UNAUTHORIZED", {
error_description: "invalid client_id",
error: "invalid_client"
});
if (token.refreshTokenExpiresAt < /* @__PURE__ */ new Date()) throw new APIError("UNAUTHORIZED", {
error_description: "refresh token expired",
error: "invalid_grant"
});
const accessToken$1 = generateRandomString(32, "a-z", "A-Z");
const newRefreshToken = generateRandomString(32, "a-z", "A-Z");
const accessTokenExpiresAt$1 = new Date(Date.now() + opts.accessTokenExpiresIn * 1e3);
const refreshTokenExpiresAt$1 = new Date(Date.now() + opts.refreshTokenExpiresIn * 1e3);
await ctx.context.adapter.create({
model: modelName.oauthAccessToken,
data: {
accessToken: accessToken$1,
refreshToken: newRefreshToken,
accessTokenExpiresAt: accessTokenExpiresAt$1,
refreshTokenExpiresAt: refreshTokenExpiresAt$1,
clientId: client_id.toString(),
userId: token.userId,
scopes: token.scopes,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
}
});
return ctx.json({
access_token: accessToken$1,
token_type: "bearer",
expires_in: opts.accessTokenExpiresIn,
refresh_token: newRefreshToken,
scope: token.scopes
});
}
if (!code) throw new APIError("BAD_REQUEST", {
error_description: "code is required",
error: "invalid_request"
});
if (opts.requirePKCE && !code_verifier) throw new APIError("BAD_REQUEST", {
error_description: "code verifier is missing",
error: "invalid_request"
});
/**
* We need to check if the code is valid before we can proceed
* with the rest of the request.
*/
const verificationValue = await ctx.context.internalAdapter.findVerificationValue(code.toString());
if (!verificationValue) throw new APIError("UNAUTHORIZED", {
error_description: "invalid code",
error: "invalid_grant"
});
if (verificationValue.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("UNAUTHORIZED", {
error_description: "code expired",
error: "invalid_grant"
});
await ctx.context.internalAdapter.deleteVerificationValue(verificationValue.id);
if (!client_id) throw new APIError("UNAUTHORIZED", {
error_description: "client_id is required",
error: "invalid_client"
});
if (!grant_type) throw new APIError("BAD_REQUEST", {
error_description: "grant_type is required",
error: "invalid_request"
});
if (grant_type !== "authorization_code") throw new APIError("BAD_REQUEST", {
error_description: "grant_type must be 'authorization_code'",
error: "unsupported_grant_type"
});
if (!redirect_uri) throw new APIError("BAD_REQUEST", {
error_description: "redirect_uri is required",
error: "invalid_request"
});
const client = await ctx.context.adapter.findOne({
model: modelName.oauthClient,
where: [{
field: "clientId",
value: client_id.toString()
}]
}).then((res) => {
if (!res) return null;
return {
...res,
redirectUrls: res.redirectUrls.split(","),
metadata: res.metadata ? JSON.parse(res.metadata) : {}
};
});
if (!client) throw new APIError("UNAUTHORIZED", {
error_description: "invalid client_id",
error: "invalid_client"
});
if (client.disabled) throw new APIError("UNAUTHORIZED", {
error_description: "client is disabled",
error: "invalid_client"
});
if (client.type === "public") {
if (!code_verifier) throw new APIError("BAD_REQUEST", {
error_description: "code verifier is required for public clients",
error: "invalid_request"
});
} else {
if (!client_secret) throw new APIError("UNAUTHORIZED", {
error_description: "client_secret is required for confidential clients",
error: "invalid_client"
});
if (!(client.clientSecret === client_secret.toString())) throw new APIError("UNAUTHORIZED", {
error_description: "invalid client_secret",
error: "invalid_client"
});
}
const value = JSON.parse(verificationValue.value);
if (value.clientId !== client_id.toString()) throw new APIError("UNAUTHORIZED", {
error_description: "invalid client_id",
error: "invalid_client"
});
if (value.redirectURI !== redirect_uri.toString()) throw new APIError("UNAUTHORIZED", {
error_description: "invalid redirect_uri",
error: "invalid_client"
});
if (value.codeChallenge && !code_verifier) throw new APIError("BAD_REQUEST", {
error_description: "code verifier is missing",
error: "invalid_request"
});
if ((value.codeChallengeMethod === "plain" ? code_verifier : await createHash("SHA-256", "base64urlnopad").digest(code_verifier)) !== value.codeChallenge) throw new APIError("UNAUTHORIZED", {
error_description: "code verification failed",
error: "invalid_request"
});
const requestedScopes = value.scope;
await ctx.context.internalAdapter.deleteVerificationValue(verificationValue.id);
const accessToken = generateRandomString(32, "a-z", "A-Z");
const refreshToken = generateRandomString(32, "A-Z", "a-z");
const accessTokenExpiresAt = new Date(Date.now() + opts.accessTokenExpiresIn * 1e3);
const refreshTokenExpiresAt = new Date(Date.now() + opts.refreshTokenExpiresIn * 1e3);
await ctx.context.adapter.create({
model: modelName.oauthAccessToken,
data: {
accessToken,
refreshToken,
accessTokenExpiresAt,
refreshTokenExpiresAt,
clientId: client_id.toString(),
userId: value.userId,
scopes: requestedScopes.join(" "),
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
}
});
const user = await ctx.context.internalAdapter.findUserById(value.userId);
if (!user) throw new APIError("UNAUTHORIZED", {
error_description: "user not found",
error: "invalid_grant"
});
let secretKey = {
alg: "HS256",
key: await getWebcryptoSubtle().generateKey({
name: "HMAC",
hash: "SHA-256"
}, true, ["sign", "verify"])
};
const profile = {
given_name: user.name.split(" ")[0],
family_name: user.name.split(" ")[1],
name: user.name,
profile: user.image,
updated_at: Math.floor(new Date(user.updatedAt).getTime() / 1e3)
};
const email = {
email: user.email,
email_verified: user.emailVerified
};
const userClaims = {
...requestedScopes.includes("profile") ? profile : {},
...requestedScopes.includes("email") ? email : {}
};
const additionalUserClaims = opts.getAdditionalUserInfoClaim ? await opts.getAdditionalUserInfoClaim(user, requestedScopes, client) : {};
const idToken = await new SignJWT({
sub: user.id,
aud: client_id.toString(),
iat: Date.now(),
auth_time: ctx.context.session ? new Date(ctx.context.session.session.createdAt).getTime() : void 0,
nonce: value.nonce,
acr: "urn:mace:incommon:iap:silver",
...userClaims,
...additionalUserClaims
}).setProtectedHeader({ alg: secretKey.alg }).setIssuedAt().setExpirationTime(Math.floor(Date.now() / 1e3) + opts.accessTokenExpiresIn).sign(secretKey.key);
return ctx.json({
access_token: accessToken,
token_type: "Bearer",
expires_in: opts.accessTokenExpiresIn,
refresh_token: requestedScopes.includes("offline_access") ? refreshToken : void 0,
scope: requestedScopes.join(" "),
id_token: requestedScopes.includes("openid") ? idToken : void 0
}, { headers: {
"Cache-Control": "no-store",
Pragma: "no-cache"
} });
}),
registerMcpClient: createAuthEndpoint("/mcp/register", {
method: "POST",
body: registerMcpClientBodySchema,
metadata: { openapi: {
description: "Register an OAuth2 application",
responses: { "200": {
description: "OAuth2 application registered successfully",
content: { "application/json": { schema: {
type: "object",
properties: {
name: {
type: "string",
description: "Name of the OAuth2 application"
},
icon: {
type: "string",
nullable: true,
description: "Icon URL for the application"
},
metadata: {
type: "object",
additionalProperties: true,
nullable: true,
description: "Additional metadata for the application"
},
clientId: {
type: "string",
description: "Unique identifier for the client"
},
clientSecret: {
type: "string",
description: "Secret key for the client. Not included for public clients."
},
redirectUrls: {
type: "array",
items: {
type: "string",
format: "uri"
},
description: "List of allowed redirect URLs"
},
type: {
type: "string",
description: "Type of the client",
enum: ["web", "public"]
},
authenticationScheme: {
type: "string",
description: "Authentication scheme used by the client",
enum: ["client_secret", "none"]
},
disabled: {
type: "boolean",
description: "Whether the client is disabled",
enum: [false]
},
userId: {
type: "string",
nullable: true,
description: "ID of the user who registered the client, null if registered anonymously"
},
createdAt: {
type: "string",
format: "date-time",
description: "Creation timestamp"
},
updatedAt: {
type: "string",
format: "date-time",
description: "Last update timestamp"
}
},
required: [
"name",
"clientId",
"redirectUrls",
"type",
"authenticationScheme",
"disabled",
"createdAt",
"updatedAt"
]
} } }
} }
} }
}, async (ctx) => {
const body = ctx.body;
const session = await getSessionFromCtx(ctx);
ctx.setHeader("Access-Control-Allow-Origin", "*");
ctx.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
ctx.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
ctx.setHeader("Access-Control-Max-Age", "86400");
ctx.headers?.set("Access-Control-Max-Age", "86400");
if ((!body.grant_types || body.grant_types.includes("authorization_code") || body.grant_types.includes("implicit")) && (!body.redirect_uris || body.redirect_uris.length === 0)) throw new APIError("BAD_REQUEST", {
error: "invalid_redirect_uri",
error_description: "Redirect URIs are required for authorization_code and implicit grant types"
});
if (body.grant_types && body.response_types) {
if (body.grant_types.includes("authorization_code") && !body.response_types.includes("code")) throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description: "When 'authorization_code' grant type is used, 'code' response type must be included"
});
if (body.grant_types.includes("implicit") && !body.response_types.includes("token")) throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description: "When 'implicit' grant type is used, 'token' response type must be included"
});
}
const clientId = opts.generateClientId?.() || generateRandomString(32, "a-z", "A-Z");
const clientSecret = opts.generateClientSecret?.() || generateRandomString(32, "a-z", "A-Z");
const clientType = body.token_endpoint_auth_method === "none" ? "public" : "web";
const finalClientSecret = clientType === "public" ? "" : clientSecret;
await ctx.context.adapter.create({
model: modelName.oauthClient,
data: {
name: body.client_name,
icon: body.logo_uri,
metadata: body.metadata ? JSON.stringify(body.metadata) : null,
clientId,
clientSecret: finalClientSecret,
redirectUrls: body.redirect_uris.join(","),
type: clientType,
authenticationScheme: body.token_endpoint_auth_method || "client_secret_basic",
disabled: false,
userId: session?.session.userId,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
}
});
const responseData = {
client_id: clientId,
client_id_issued_at: Math.floor(Date.now() / 1e3),
redirect_uris: body.redirect_uris,
token_endpoint_auth_method: body.token_endpoint_auth_method || "client_secret_basic",
grant_types: body.grant_types || ["authorization_code"],
response_types: body.response_types || ["code"],
client_name: body.client_name,
client_uri: body.client_uri,
logo_uri: body.logo_uri,
scope: body.scope,
contacts: body.contacts,
tos_uri: body.tos_uri,
policy_uri: body.policy_uri,
jwks_uri: body.jwks_uri,
jwks: body.jwks,
software_id: body.software_id,
software_version: body.software_version,
software_statement: body.software_statement,
metadata: body.metadata,
...clientType !== "public" ? {
client_secret: finalClientSecret,
client_secret_expires_at: 0
} : {}
};
return new Response(JSON.stringify(responseData), {
status: 201,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
Pragma: "no-cache"
}
});
}),
getMcpSession: createAuthEndpoint("/mcp/get-session", {
method: "GET",
requireHeaders: true
}, async (c) => {
const accessToken = c.headers?.get("Authorization")?.replace("Bearer ", "");
if (!accessToken) {
c.headers?.set("WWW-Authenticate", "Bearer");
return c.json(null);
}
const accessTokenData = await c.context.adapter.findOne({
model: modelName.oauthAccessToken,
where: [{
field: "accessToken",
value: accessToken
}]
});
if (!accessTokenData) return c.json(null);
return c.json(accessTokenData);
})
},
schema,
options
};
};
const withMcpAuth = (auth, handler) => {
return async (req) => {
const baseURL = getBaseURL(auth.options.baseURL, auth.options.basePath);
if (!baseURL && !isProduction) logger.warn("Unable to get the baseURL, please check your config!");
const session = await auth.api.getMcpSession({ headers: req.headers });
const wwwAuthenticateValue = `Bearer resource_metadata="${baseURL}/.well-known/oauth-protected-resource"`;
if (!session) return Response.json({
jsonrpc: "2.0",
error: {
code: -32e3,
message: "Unauthorized: Authentication required",
"www-authenticate": wwwAuthenticateValue
},
id: null
}, {
status: 401,
headers: {
"WWW-Authenticate": wwwAuthenticateValue,
"Access-Control-Expose-Headers": "WWW-Authenticate"
}
});
return handler(req, session);
};
};
const oAuthDiscoveryMetadata = (auth) => {
return async (request) => {
const res = await auth.api.getMcpOAuthConfig();
return new Response(JSON.stringify(res), {
status: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400"
}
});
};
};
const oAuthProtectedResourceMetadata = (auth) => {
return async (request) => {
const res = await auth.api.getMCPProtectedResource();
return new Response(JSON.stringify(res), {
status: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400"
}
});
};
};
//#endregion
export { getMCPProtectedResourceMetadata, getMCPProviderMetadata, mcp, oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata, withMcpAuth };
//# sourceMappingURL=index.mjs.map