better-auth
Version:
The most comprehensive authentication library for TypeScript.
1,425 lines (1,418 loc) • 57.8 kB
JavaScript
'use strict';
const z = require('zod/v4');
const jose = require('jose');
const betterCall = require('better-call');
require('./better-auth.DSI5WTAg.cjs');
const session = require('./better-auth.CLv80Pwz.cjs');
require('./better-auth.B6fIklBU.cjs');
const base64 = require('@better-auth/utils/base64');
require('@better-auth/utils/hmac');
require('./better-auth.B3274wGK.cjs');
require('@better-auth/utils/binary');
const cookies_index = require('./better-auth.D5q0JUiv.cjs');
const schema$1 = require('./better-auth.gN3g-znU.cjs');
const crypto_index = require('../crypto/index.cjs');
const hash = require('@better-auth/utils/hash');
require('@noble/ciphers/chacha.js');
require('@noble/ciphers/utils.js');
require('@noble/hashes/scrypt.js');
require('@better-auth/utils');
require('@better-auth/utils/hex');
require('@noble/hashes/utils.js');
const random = require('./better-auth.CYeOI8C-.cjs');
const sign = require('./better-auth.CMwM5enp.cjs');
require('@better-auth/utils/random');
require('kysely');
function _interopNamespaceCompat(e) {
if (e && typeof e === 'object' && 'default' in e) return e;
const n = Object.create(null);
if (e) {
for (const k in e) {
n[k] = e[k];
}
}
n.default = e;
return n;
}
const z__namespace = /*#__PURE__*/_interopNamespaceCompat(z);
const schema = {
oauthApplication: {
modelName: "oauthApplication",
fields: {
name: {
type: "string"
},
icon: {
type: "string",
required: false
},
metadata: {
type: "string",
required: false
},
clientId: {
type: "string",
unique: true
},
clientSecret: {
type: "string",
required: false
},
redirectURLs: {
type: "string"
},
type: {
type: "string"
},
disabled: {
type: "boolean",
required: false,
defaultValue: false
},
userId: {
type: "string",
required: false,
references: {
model: "user",
field: "id",
onDelete: "cascade"
}
},
createdAt: {
type: "date"
},
updatedAt: {
type: "date"
}
}
},
oauthAccessToken: {
modelName: "oauthAccessToken",
fields: {
accessToken: {
type: "string",
unique: true
},
refreshToken: {
type: "string",
unique: true
},
accessTokenExpiresAt: {
type: "date"
},
refreshTokenExpiresAt: {
type: "date"
},
clientId: {
type: "string",
references: {
model: "oauthApplication",
field: "clientId",
onDelete: "cascade"
}
},
userId: {
type: "string",
required: false,
references: {
model: "user",
field: "id",
onDelete: "cascade"
}
},
scopes: {
type: "string"
},
createdAt: {
type: "date"
},
updatedAt: {
type: "date"
}
}
},
oauthConsent: {
modelName: "oauthConsent",
fields: {
clientId: {
type: "string",
references: {
model: "oauthApplication",
field: "clientId",
onDelete: "cascade"
}
},
userId: {
type: "string",
references: {
model: "user",
field: "id",
onDelete: "cascade"
}
},
scopes: {
type: "string"
},
createdAt: {
type: "date"
},
updatedAt: {
type: "date"
},
consentGiven: {
type: "boolean"
}
}
}
};
function formatErrorURL(url, error, description) {
return `${url.includes("?") ? "&" : "?"}error=${error}&error_description=${description}`;
}
function getErrorURL(ctx, error, description) {
const baseURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
const formattedURL = formatErrorURL(baseURL, error, description);
return formattedURL;
}
async function authorize(ctx, options) {
const handleRedirect = (url) => {
const fromFetch = ctx.request?.headers.get("sec-fetch-mode") === "cors";
if (fromFetch) {
return ctx.json({
redirect: true,
url
});
} else {
throw ctx.redirect(url);
}
};
const opts = {
codeExpiresIn: 600,
defaultScope: "openid",
...options,
scopes: [
"openid",
"profile",
"email",
"offline_access",
...options?.scopes || []
]
};
if (!ctx.request) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "request not found",
error: "invalid_request"
});
}
const session$1 = await session.getSessionFromCtx(ctx);
if (!session$1) {
await ctx.setSignedCookie(
"oidc_login_prompt",
JSON.stringify(ctx.query),
ctx.context.secret,
{
maxAge: 600,
path: "/",
sameSite: "lax"
}
);
const queryFromURL = ctx.request.url?.split("?")[1];
return handleRedirect(`${options.loginPage}?${queryFromURL}`);
}
const query = ctx.query;
if (!query.client_id) {
const errorURL = getErrorURL(
ctx,
"invalid_client",
"client_id is required"
);
throw ctx.redirect(errorURL);
}
if (!query.response_type) {
getErrorURL(
ctx,
"invalid_request",
"response_type is required"
);
throw ctx.redirect(
getErrorURL(ctx, "invalid_request", "response_type is required")
);
}
const client = await getClient(
ctx.query.client_id,
ctx.context.adapter,
options.trustedClients || []
);
if (!client) {
const errorURL = getErrorURL(
ctx,
"invalid_client",
"client_id is required"
);
throw ctx.redirect(errorURL);
}
const redirectURI = client.redirectURLs.find(
(url) => url === ctx.query.redirect_uri
);
if (!redirectURI || !query.redirect_uri) {
throw new betterCall.APIError("BAD_REQUEST", {
message: "Invalid redirect URI"
});
}
if (client.disabled) {
const errorURL = getErrorURL(ctx, "client_disabled", "client is disabled");
throw ctx.redirect(errorURL);
}
if (query.response_type !== "code") {
const errorURL = getErrorURL(
ctx,
"unsupported_response_type",
"unsupported response type"
);
throw ctx.redirect(errorURL);
}
const requestScope = query.scope?.split(" ").filter((s) => s) || opts.defaultScope.split(" ");
const invalidScopes = requestScope.filter((scope) => {
return !opts.scopes.includes(scope);
});
if (invalidScopes.length) {
return handleRedirect(
formatErrorURL(
query.redirect_uri,
"invalid_scope",
`The following scopes are invalid: ${invalidScopes.join(", ")}`
)
);
}
if ((!query.code_challenge || !query.code_challenge_method) && options.requirePKCE) {
return handleRedirect(
formatErrorURL(query.redirect_uri, "invalid_request", "pkce is required")
);
}
if (!query.code_challenge_method) {
query.code_challenge_method = "plain";
}
if (![
"s256",
options.allowPlainCodeChallengeMethod ? "plain" : "s256"
].includes(query.code_challenge_method?.toLowerCase() || "")) {
return handleRedirect(
formatErrorURL(
query.redirect_uri,
"invalid_request",
"invalid code_challenge method"
)
);
}
const code = random.generateRandomString(32, "a-z", "A-Z", "0-9");
const codeExpiresInMs = opts.codeExpiresIn * 1e3;
const expiresAt = new Date(Date.now() + codeExpiresInMs);
try {
await ctx.context.internalAdapter.createVerificationValue(
{
value: JSON.stringify({
clientId: client.clientId,
redirectURI: query.redirect_uri,
scope: requestScope,
userId: session$1.user.id,
authTime: new Date(session$1.session.createdAt).getTime(),
/**
* If the prompt is set to `consent`, then we need
* to require the user to consent to the scopes.
*
* This means the code now needs to be treated as a
* consent request.
*
* once the user consents, the code will be updated
* with the actual code. This is to prevent the
* client from using the code before the user
* consents.
*/
requireConsent: query.prompt === "consent",
state: query.prompt === "consent" ? query.state : null,
codeChallenge: query.code_challenge,
codeChallengeMethod: query.code_challenge_method,
nonce: query.nonce
}),
identifier: code,
expiresAt
},
ctx
);
} catch (e) {
return handleRedirect(
formatErrorURL(
query.redirect_uri,
"server_error",
"An error occurred while processing the request"
)
);
}
const redirectURIWithCode = new URL(redirectURI);
redirectURIWithCode.searchParams.set("code", code);
redirectURIWithCode.searchParams.set("state", ctx.query.state);
if (query.prompt !== "consent") {
return handleRedirect(redirectURIWithCode.toString());
}
if (client.skipConsent) {
return handleRedirect(redirectURIWithCode.toString());
}
const hasAlreadyConsented = await ctx.context.adapter.findOne({
model: "oauthConsent",
where: [
{
field: "clientId",
value: client.clientId
},
{
field: "userId",
value: session$1.user.id
}
]
}).then((res) => !!res?.consentGiven);
if (hasAlreadyConsented) {
return handleRedirect(redirectURIWithCode.toString());
}
if (options?.consentPage) {
await ctx.setSignedCookie("oidc_consent_prompt", code, ctx.context.secret, {
maxAge: 600,
path: "/",
sameSite: "lax"
});
const urlParams = new URLSearchParams();
urlParams.set("consent_code", code);
urlParams.set("client_id", client.clientId);
urlParams.set("scope", requestScope.join(" "));
const consentURI = `${options.consentPage}?${urlParams.toString()}`;
return handleRedirect(consentURI);
}
const htmlFn = options?.getConsentHTML;
if (!htmlFn) {
throw new betterCall.APIError("INTERNAL_SERVER_ERROR", {
message: "No consent page provided"
});
}
return new Response(
htmlFn({
scopes: requestScope,
clientMetadata: client.metadata,
clientIcon: client?.icon,
clientId: client.clientId,
clientName: client.name,
code
}),
{
headers: {
"content-type": "text/html"
}
}
);
}
const defaultClientSecretHasher = async (clientSecret) => {
const hash$1 = await hash.createHash("SHA-256").digest(
new TextEncoder().encode(clientSecret)
);
const hashed = base64.base64Url.encode(new Uint8Array(hash$1), {
padding: false
});
return hashed;
};
const getJwtPlugin = (ctx) => {
return ctx.context.options.plugins?.find(
(plugin) => plugin.id === "jwt"
);
};
async function getClient(clientId, adapter, trustedClients = []) {
const trustedClient = trustedClients.find(
(client) => client.clientId === clientId
);
if (trustedClient) {
return trustedClient;
}
const dbClient = await adapter.findOne({
model: "oauthApplication",
where: [{ field: "clientId", value: clientId }]
}).then((res) => {
if (!res) {
return null;
}
return {
...res,
redirectURLs: (res.redirectURLs ?? "").split(","),
metadata: res.metadata ? JSON.parse(res.metadata) : {}
};
});
return dbClient;
}
const getMetadata = (ctx, options) => {
const jwtPlugin = getJwtPlugin(ctx);
const issuer = jwtPlugin && jwtPlugin.options?.jwt && jwtPlugin.options.jwt.issuer ? jwtPlugin.options.jwt.issuer : ctx.context.options.baseURL;
const baseURL = ctx.context.baseURL;
const supportedAlgs = options?.useJWTPlugin ? ["RS256", "EdDSA", "none"] : ["HS256", "none"];
return {
issuer,
authorization_endpoint: `${baseURL}/oauth2/authorize`,
token_endpoint: `${baseURL}/oauth2/token`,
userinfo_endpoint: `${baseURL}/oauth2/userinfo`,
jwks_uri: `${baseURL}/jwks`,
registration_endpoint: `${baseURL}/oauth2/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: supportedAlgs,
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 oidcProvider = (options) => {
const modelName = {
oauthClient: "oauthApplication",
oauthAccessToken: "oauthAccessToken",
oauthConsent: "oauthConsent"
};
const opts = {
codeExpiresIn: 600,
defaultScope: "openid",
accessTokenExpiresIn: 3600,
refreshTokenExpiresIn: 604800,
allowPlainCodeChallengeMethod: true,
storeClientSecret: "plain",
...options,
scopes: [
"openid",
"profile",
"email",
"offline_access",
...options?.scopes || []
]
};
const trustedClients = options.trustedClients || [];
async function storeClientSecret(ctx, clientSecret) {
if (opts.storeClientSecret === "encrypted") {
return await crypto_index.symmetricEncrypt({
key: ctx.context.secret,
data: clientSecret
});
}
if (opts.storeClientSecret === "hashed") {
return await defaultClientSecretHasher(clientSecret);
}
if (typeof opts.storeClientSecret === "object" && "hash" in opts.storeClientSecret) {
return await opts.storeClientSecret.hash(clientSecret);
}
if (typeof opts.storeClientSecret === "object" && "encrypt" in opts.storeClientSecret) {
return await opts.storeClientSecret.encrypt(clientSecret);
}
return clientSecret;
}
async function verifyStoredClientSecret(ctx, storedClientSecret, clientSecret) {
if (opts.storeClientSecret === "encrypted") {
return await crypto_index.symmetricDecrypt({
key: ctx.context.secret,
data: storedClientSecret
}) === clientSecret;
}
if (opts.storeClientSecret === "hashed") {
const hashedClientSecret = await defaultClientSecretHasher(clientSecret);
return hashedClientSecret === storedClientSecret;
}
if (typeof opts.storeClientSecret === "object" && "hash" in opts.storeClientSecret) {
const hashedClientSecret = await opts.storeClientSecret.hash(clientSecret);
return hashedClientSecret === storedClientSecret;
}
if (typeof opts.storeClientSecret === "object" && "decrypt" in opts.storeClientSecret) {
const decryptedClientSecret = await opts.storeClientSecret.decrypt(storedClientSecret);
return decryptedClientSecret === clientSecret;
}
return clientSecret === storedClientSecret;
}
return {
id: "oidc",
hooks: {
after: [
{
matcher() {
return true;
},
handler: session.createAuthMiddleware(async (ctx) => {
const cookie = await ctx.getSignedCookie(
"oidc_login_prompt",
ctx.context.secret
);
const cookieName = ctx.context.authCookies.sessionToken.name;
const parsedSetCookieHeader = cookies_index.parseSetCookieHeader(
ctx.context.responseHeaders?.get("set-cookie") || ""
);
const hasSessionToken = parsedSetCookieHeader.has(cookieName);
if (!cookie || !hasSessionToken) {
return;
}
ctx.setCookie("oidc_login_prompt", "", {
maxAge: 0
});
const sessionCookie = parsedSetCookieHeader.get(cookieName)?.value;
const sessionToken = sessionCookie?.split(".")[0];
if (!sessionToken) {
return;
}
const session = await ctx.context.internalAdapter.findSession(sessionToken);
if (!session) {
return;
}
ctx.query = JSON.parse(cookie);
ctx.query.prompt = "consent";
ctx.context.session = session;
const response = await authorize(ctx, opts);
return response;
})
}
]
},
endpoints: {
getOpenIdConfig: session.createAuthEndpoint(
"/.well-known/openid-configuration",
{
method: "GET",
metadata: {
isAction: false
}
},
async (ctx) => {
const metadata = getMetadata(ctx, options);
return ctx.json(metadata);
}
),
oAuth2authorize: session.createAuthEndpoint(
"/oauth2/authorize",
{
method: "GET",
query: z__namespace.record(z__namespace.string(), z__namespace.any()),
metadata: {
openapi: {
description: "Authorize an OAuth2 request",
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 authorize(ctx, opts);
}
),
oAuthConsent: session.createAuthEndpoint(
"/oauth2/consent",
{
method: "POST",
body: z__namespace.object({
accept: z__namespace.boolean(),
consent_code: z__namespace.string().optional()
}),
use: [session.sessionMiddleware],
metadata: {
openapi: {
description: "Handle OAuth2 consent. Supports both URL parameter-based flows (consent_code in body) and cookie-based flows (signed cookie).",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
accept: {
type: "boolean",
description: "Whether the user accepts or denies the consent request"
},
consent_code: {
type: "string",
description: "The consent code from the authorization request. Optional if using cookie-based flow."
}
},
required: ["accept"]
}
}
}
},
responses: {
"200": {
description: "Consent processed successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
redirectURI: {
type: "string",
format: "uri",
description: "The URI to redirect to, either with an authorization code or an error"
}
},
required: ["redirectURI"]
}
}
}
}
}
}
}
},
async (ctx) => {
let consentCode = ctx.body.consent_code || null;
if (!consentCode) {
consentCode = await ctx.getSignedCookie(
"oidc_consent_prompt",
ctx.context.secret
);
}
if (!consentCode) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "consent_code is required (either in body or cookie)",
error: "invalid_request"
});
}
const verification = await ctx.context.internalAdapter.findVerificationValue(
consentCode
);
if (!verification) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "Invalid code",
error: "invalid_request"
});
}
if (verification.expiresAt < /* @__PURE__ */ new Date()) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "Code expired",
error: "invalid_request"
});
}
ctx.setCookie("oidc_consent_prompt", "", {
maxAge: 0
});
const value = JSON.parse(verification.value);
if (!value.requireConsent) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "Consent not required",
error: "invalid_request"
});
}
if (!ctx.body.accept) {
await ctx.context.internalAdapter.deleteVerificationValue(
verification.id
);
return ctx.json({
redirectURI: `${value.redirectURI}?error=access_denied&error_description=User denied access`
});
}
const code = random.generateRandomString(32, "a-z", "A-Z", "0-9");
const codeExpiresInMs = opts.codeExpiresIn * 1e3;
const expiresAt = new Date(Date.now() + codeExpiresInMs);
await ctx.context.internalAdapter.updateVerificationValue(
verification.id,
{
value: JSON.stringify({
...value,
requireConsent: false
}),
identifier: code,
expiresAt
}
);
await ctx.context.adapter.create({
model: modelName.oauthConsent,
data: {
clientId: value.clientId,
userId: value.userId,
scopes: value.scope.join(" "),
consentGiven: true,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
}
});
const redirectURI = new URL(value.redirectURI);
redirectURI.searchParams.set("code", code);
if (value.state) redirectURI.searchParams.set("state", value.state);
return ctx.json({
redirectURI: redirectURI.toString()
});
}
),
oAuth2token: session.createAuthEndpoint(
"/oauth2/token",
{
method: "POST",
body: z__namespace.record(z__namespace.any(), z__namespace.any()),
metadata: {
isAction: false
}
},
async (ctx) => {
let { body } = ctx;
if (!body) {
throw new betterCall.APIError("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 betterCall.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.base64.decode(encoded));
if (!decoded.includes(":")) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "invalid authorization header format",
error: "invalid_client"
});
}
const [id, secret] = decoded.split(":");
if (!id || !secret) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "invalid authorization header format",
error: "invalid_client"
});
}
client_id = id;
client_secret = secret;
} catch (error) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "invalid authorization header format",
error: "invalid_client"
});
}
}
const now = Date.now();
const iat = Math.floor(now / 1e3);
const exp = iat + (opts.accessTokenExpiresIn ?? 3600);
const accessTokenExpiresAt = new Date(exp * 1e3);
const refreshTokenExpiresAt = new Date(
(iat + (opts.refreshTokenExpiresIn ?? 604800)) * 1e3
);
const {
grant_type,
code,
redirect_uri,
refresh_token,
code_verifier
} = body;
if (grant_type === "refresh_token") {
if (!refresh_token) {
throw new betterCall.APIError("BAD_REQUEST", {
error_description: "refresh_token is required",
error: "invalid_request"
});
}
const token = await ctx.context.adapter.findOne({
model: modelName.oauthAccessToken,
where: [
{
field: "refreshToken",
value: refresh_token.toString()
}
]
});
if (!token) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "invalid refresh token",
error: "invalid_grant"
});
}
if (token.clientId !== client_id?.toString()) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "invalid client_id",
error: "invalid_client"
});
}
if (token.refreshTokenExpiresAt < /* @__PURE__ */ new Date()) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "refresh token expired",
error: "invalid_grant"
});
}
const accessToken2 = random.generateRandomString(32, "a-z", "A-Z");
const newRefreshToken = random.generateRandomString(32, "a-z", "A-Z");
await ctx.context.adapter.create({
model: modelName.oauthAccessToken,
data: {
accessToken: accessToken2,
refreshToken: newRefreshToken,
accessTokenExpiresAt,
refreshTokenExpiresAt,
clientId: client_id.toString(),
userId: token.userId,
scopes: token.scopes,
createdAt: new Date(iat * 1e3),
updatedAt: new Date(iat * 1e3)
}
});
return ctx.json({
access_token: accessToken2,
token_type: "bearer",
expires_in: opts.accessTokenExpiresIn,
refresh_token: newRefreshToken,
scope: token.scopes
});
}
if (!code) {
throw new betterCall.APIError("BAD_REQUEST", {
error_description: "code is required",
error: "invalid_request"
});
}
if (options.requirePKCE && !code_verifier) {
throw new betterCall.APIError("BAD_REQUEST", {
error_description: "code verifier is missing",
error: "invalid_request"
});
}
const verificationValue = await ctx.context.internalAdapter.findVerificationValue(
code.toString()
);
if (!verificationValue) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "invalid code",
error: "invalid_grant"
});
}
if (verificationValue.expiresAt < /* @__PURE__ */ new Date()) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "code expired",
error: "invalid_grant"
});
}
await ctx.context.internalAdapter.deleteVerificationValue(
verificationValue.id
);
if (!client_id) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "client_id is required",
error: "invalid_client"
});
}
if (!grant_type) {
throw new betterCall.APIError("BAD_REQUEST", {
error_description: "grant_type is required",
error: "invalid_request"
});
}
if (grant_type !== "authorization_code") {
throw new betterCall.APIError("BAD_REQUEST", {
error_description: "grant_type must be 'authorization_code'",
error: "unsupported_grant_type"
});
}
if (!redirect_uri) {
throw new betterCall.APIError("BAD_REQUEST", {
error_description: "redirect_uri is required",
error: "invalid_request"
});
}
const client = await getClient(
client_id.toString(),
ctx.context.adapter,
trustedClients
);
if (!client) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "invalid client_id",
error: "invalid_client"
});
}
if (client.disabled) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "client is disabled",
error: "invalid_client"
});
}
const value = JSON.parse(
verificationValue.value
);
if (value.clientId !== client_id.toString()) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "invalid client_id",
error: "invalid_client"
});
}
if (value.redirectURI !== redirect_uri.toString()) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "invalid redirect_uri",
error: "invalid_client"
});
}
if (value.codeChallenge && !code_verifier) {
throw new betterCall.APIError("BAD_REQUEST", {
error_description: "code verifier is missing",
error: "invalid_request"
});
}
if (client.type === "public") {
if (!code_verifier) {
throw new betterCall.APIError("BAD_REQUEST", {
error_description: "code verifier is required for public clients",
error: "invalid_request"
});
}
} else {
if (!client.clientSecret || !client_secret) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "client_secret is required for confidential clients",
error: "invalid_client"
});
}
const isValidSecret = await verifyStoredClientSecret(
ctx,
client.clientSecret,
client_secret.toString()
);
if (!isValidSecret) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "invalid client_secret",
error: "invalid_client"
});
}
}
const challenge = value.codeChallengeMethod === "plain" ? code_verifier : await hash.createHash("SHA-256", "base64urlnopad").digest(
code_verifier
);
if (challenge !== value.codeChallenge) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "code verification failed",
error: "invalid_request"
});
}
const requestedScopes = value.scope;
await ctx.context.internalAdapter.deleteVerificationValue(
verificationValue.id
);
const accessToken = random.generateRandomString(32, "a-z", "A-Z");
const refreshToken = random.generateRandomString(32, "A-Z", "a-z");
await ctx.context.adapter.create({
model: modelName.oauthAccessToken,
data: {
accessToken,
refreshToken,
accessTokenExpiresAt,
refreshTokenExpiresAt,
clientId: client_id.toString(),
userId: value.userId,
scopes: requestedScopes.join(" "),
createdAt: new Date(iat * 1e3),
updatedAt: new Date(iat * 1e3)
}
});
const user = await ctx.context.internalAdapter.findUserById(
value.userId
);
if (!user) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "user not found",
error: "invalid_grant"
});
}
const profile = {
given_name: user.name.split(" ")[0],
family_name: user.name.split(" ")[1],
name: user.name,
profile: user.image,
updated_at: new Date(user.updatedAt).toISOString()
};
const email = {
email: user.email,
email_verified: user.emailVerified
};
const userClaims = {
...requestedScopes.includes("profile") ? profile : {},
...requestedScopes.includes("email") ? email : {}
};
const additionalUserClaims = options.getAdditionalUserInfoClaim ? await options.getAdditionalUserInfoClaim(
user,
requestedScopes,
client
) : {};
const payload = {
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",
// default to silver - ⚠︎ this should be configurable and should be validated against the client's metadata
...userClaims,
...additionalUserClaims
};
const expirationTime = Math.floor(Date.now() / 1e3) + opts.accessTokenExpiresIn;
let idToken;
if (options.useJWTPlugin) {
const jwtPlugin = getJwtPlugin(ctx);
if (!jwtPlugin) {
ctx.context.logger.error(
"OIDC: `useJWTPlugin` is enabled but the JWT plugin is not available. Make sure you have the JWT Plugin in your plugins array or set `useJWTPlugin` to false."
);
throw new betterCall.APIError("INTERNAL_SERVER_ERROR", {
error_description: "JWT plugin is not enabled",
error: "internal_server_error"
});
}
idToken = await sign.getJwtToken(
{
...ctx,
context: {
...ctx.context,
session: {
session: {
id: random.generateRandomString(32, "a-z", "A-Z"),
createdAt: new Date(iat * 1e3),
updatedAt: new Date(iat * 1e3),
userId: user.id,
expiresAt: accessTokenExpiresAt,
token: accessToken,
ipAddress: ctx.request?.headers.get("x-forwarded-for")
},
user
}
}
},
{
...jwtPlugin.options,
jwt: {
...jwtPlugin.options?.jwt,
getSubject: () => user.id,
audience: client_id.toString(),
issuer: ctx.context.options.baseURL,
expirationTime,
definePayload: () => payload
}
}
);
} else {
idToken = await new jose.SignJWT(payload).setProtectedHeader({ alg: "HS256" }).setIssuedAt(iat).setExpirationTime(accessTokenExpiresAt).sign(new TextEncoder().encode(client.clientSecret));
}
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"
}
}
);
}
),
oAuth2userInfo: session.createAuthEndpoint(
"/oauth2/userinfo",
{
method: "GET",
metadata: {
isAction: false,
openapi: {
description: "Get OAuth2 user information",
responses: {
"200": {
description: "User information retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
sub: {
type: "string",
description: "Subject identifier (user ID)"
},
email: {
type: "string",
format: "email",
nullable: true,
description: "User's email address, included if 'email' scope is granted"
},
name: {
type: "string",
nullable: true,
description: "User's full name, included if 'profile' scope is granted"
},
picture: {
type: "string",
format: "uri",
nullable: true,
description: "User's profile picture URL, included if 'profile' scope is granted"
},
given_name: {
type: "string",
nullable: true,
description: "User's given name, included if 'profile' scope is granted"
},
family_name: {
type: "string",
nullable: true,
description: "User's family name, included if 'profile' scope is granted"
},
email_verified: {
type: "boolean",
nullable: true,
description: "Whether the email is verified, included if 'email' scope is granted"
}
},
required: ["sub"]
}
}
}
}
}
}
}
},
async (ctx) => {
if (!ctx.request) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "request not found",
error: "invalid_request"
});
}
const authorization = ctx.request.headers.get("authorization");
if (!authorization) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "authorization header not found",
error: "invalid_request"
});
}
const token = authorization.replace("Bearer ", "");
const accessToken = await ctx.context.adapter.findOne({
model: modelName.oauthAccessToken,
where: [
{
field: "accessToken",
value: token
}
]
});
if (!accessToken) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "invalid access token",
error: "invalid_token"
});
}
if (accessToken.accessTokenExpiresAt < /* @__PURE__ */ new Date()) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "The Access Token expired",
error: "invalid_token"
});
}
const client = await getClient(
accessToken.clientId,
ctx.context.adapter,
trustedClients
);
if (!client) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "client not found",
error: "invalid_token"
});
}
const user = await ctx.context.internalAdapter.findUserById(
accessToken.userId
);
if (!user) {
throw new betterCall.APIError("UNAUTHORIZED", {
error_description: "user not found",
error: "invalid_token"
});
}
const requestedScopes = accessToken.scopes.split(" ");
const baseUserClaims = {
sub: user.id,
email: requestedScopes.includes("email") ? user.email : void 0,
name: requestedScopes.includes("profile") ? user.name : void 0,
picture: requestedScopes.includes("profile") ? user.image : void 0,
given_name: requestedScopes.includes("profile") ? user.name.split(" ")[0] : void 0,
family_name: requestedScopes.includes("profile") ? user.name.split(" ")[1] : void 0,
email_verified: requestedScopes.includes("email") ? user.emailVerified : void 0
};
const userClaims = options.getAdditionalUserInfoClaim ? await options.getAdditionalUserInfoClaim(
user,
requestedScopes,
client
) : baseUserClaims;
return ctx.json({
...baseUserClaims,
...userClaims
});
}
),
/**
* ### Endpoint
*
* POST `/oauth2/register`
*
* ### API Methods
*
* **server:**
* `auth.api.registerOAuthApplication`
*
* **client:**
* `authClient.oauth2.register`
*
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/oidc-provider#api-method-oauth2-register)
*/
registerOAuthApplication: session.createAuthEndpoint(
"/oauth2/register",
{
method: "POST",
body: z__namespace.object({
redirect_uris: z__namespace.array(z__namespace.string()).meta({
description: 'A list of redirect URIs. Eg: ["https://client.example.com/callback"]'
}),
token_endpoint_auth_method: z__namespace.enum(["none", "client_secret_basic", "client_secret_post"]).meta({
description: 'The authentication method for the token endpoint. Eg: "client_secret_basic"'
}).default("client_secret_basic").optional(),
grant_types: z__namespace.array(
z__namespace.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"
])
).meta({
description: 'The grant types supported by the application. Eg: ["authorization_code"]'
}).default(["authorization_code"]).optional(),
response_types: z__namespace.array(z__namespace.enum(["code", "token"])).meta({
description: 'The response types supported by the application. Eg: ["code"]'
}).default(["code"]).optional(),
client_name: z__namespace.string().meta({
description: 'The name of the application. Eg: "My App"'
}).optional(),
client_uri: z__namespace.string().meta({
description: 'The URI of the application. Eg: "https://client.example.com"'
}).optional(),
logo_uri: z__namespace.string().meta({
description: 'The URI of the application logo. Eg: "https://client.example.com/logo.png"'
}).optional(),
scope: z__namespace.string().meta({
description: 'The scopes supported by the application. Separated by spaces. Eg: "profile email"'
}).optional(),
contacts: z__namespace.array(z__namespace.string()).meta({
description: 'The contact information for the application. Eg: ["admin@example.com"]'
}).optional(),
tos_uri: z__namespace.string().meta({
description: 'The URI of the application terms of service. Eg: "https://client.example.com/tos"'
}).optional(),
policy_uri: z__namespace.string().meta({
description: 'The URI of the application privacy policy. Eg: "https://client.example.com/policy"'
}).optional(),
jwks_uri: z__namespace.string().meta({
description: 'The URI of the application JWKS. Eg: "https://client.example.com/jwks"'
}).optional(),
jwks: z__namespace.record(z__namespace.any(), z__namespace.any()).meta({
description: 'The JWKS of the application. Eg: {"keys": [{"kty": "RSA", "alg": "RS256", "use": "sig", "n": "...", "e": "..."}]}'
}).optional(),
metadata: z__namespace.record(z__namespace.any(), z__namespace.any()).meta({
description: 'The metadata of the application. Eg: {"key": "value"}'
}).optional(),
software_id: z__namespace.string().meta({
description: 'The software ID of the application. Eg: "my-software"'
}).optional(),
software_version: z__namespace.string().meta({
description: 'The software version of the application. Eg: "1.0.0"'
}).optional(),
software_statement: z__namespace.string().meta({
description: "The software statement of the application."
}).optional()
}),
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"
},
redirectURLs: {
type: "array",
items: { type: "string", format: "uri" },
description: "List of allowed redirect URLs"
},
type: {
type: "string",
description: "Type of the client",
enum: ["web"]
},
authenticationScheme: {
type: "string",
description: "Authentication scheme used by the client",
enum: ["client_secre