UNPKG

better-auth

Version:

The most comprehensive authentication library for TypeScript.

1,425 lines (1,418 loc) 57.8 kB
'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