UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

1,096 lines (1,094 loc) • 42 kB
import { mergeSchema } from "../../db/schema.mjs"; import "../../db/index.mjs"; import { generateRandomString } from "../../crypto/random.mjs"; import { symmetricDecrypt, symmetricEncrypt } from "../../crypto/index.mjs"; import { parseSetCookieHeader } from "../../cookies/cookie-utils.mjs"; import { expireCookie } from "../../cookies/index.mjs"; import { getSessionFromCtx, sessionMiddleware } from "../../api/routes/session.mjs"; import { HIDE_METADATA } from "../../utils/hide-metadata.mjs"; import "../../utils/index.mjs"; import { APIError } from "../../api/index.mjs"; import { getJwtToken } from "../jwt/sign.mjs"; import { verifyJWT } from "../jwt/verify.mjs"; import "../jwt/index.mjs"; import { parsePrompt } from "./utils/prompt.mjs"; import { authorize } from "./authorize.mjs"; import { schema } from "./schema.mjs"; import { defaultClientSecretHasher } from "./utils.mjs"; import { getCurrentAuthContext } from "@better-auth/core/context"; import * as z from "zod"; import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api"; import { createHash } from "@better-auth/utils/hash"; import { SignJWT, jwtVerify } from "jose"; import { base64 } from "@better-auth/utils/base64"; //#region src/plugins/oidc-provider/index.ts const getJwtPlugin = (ctx) => { return ctx.context.getPlugin("jwt"); }; /** * Get a client by ID, checking trusted clients first, then database */ async function getClient(clientId, trustedClients = []) { const { context: { adapter } } = await getCurrentAuthContext(); const trustedClient = trustedClients.find((client) => client.clientId === clientId); if (trustedClient) return trustedClient; return adapter.findOne({ model: "oauthApplication", where: [{ field: "clientId", value: clientId }] }).then((res) => { if (!res) return null; return { clientId: res.clientId, clientSecret: res.clientSecret, type: res.type, name: res.name, icon: res.icon, disabled: res.disabled, redirectUrls: (res.redirectUrls ?? "").split(","), metadata: res.metadata ? JSON.parse(res.metadata) : {} }; }); } 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`, end_session_endpoint: `${baseURL}/oauth2/endsession`, 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 oAuthConsentBodySchema = z.object({ accept: z.boolean(), consent_code: z.string().optional().nullish() }); const oAuth2TokenBodySchema = z.record(z.any(), z.any()); const registerOAuthApplicationBodySchema = z.object({ redirect_uris: z.array(z.string()).meta({ description: "A list of redirect URIs. Eg: [\"https://client.example.com/callback\"]" }), token_endpoint_auth_method: z.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.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" ])).meta({ description: "The grant types supported by the application. Eg: [\"authorization_code\"]" }).default(["authorization_code"]).optional(), response_types: z.array(z.enum(["code", "token"])).meta({ description: "The response types supported by the application. Eg: [\"code\"]" }).default(["code"]).optional(), client_name: z.string().meta({ description: "The name of the application. Eg: \"My App\"" }).optional(), client_uri: z.string().meta({ description: "The URI of the application. Eg: \"https://client.example.com\"" }).optional(), logo_uri: z.string().meta({ description: "The URI of the application logo. Eg: \"https://client.example.com/logo.png\"" }).optional(), scope: z.string().meta({ description: "The scopes supported by the application. Separated by spaces. Eg: \"profile email\"" }).optional(), contacts: z.array(z.string()).meta({ description: "The contact information for the application. Eg: [\"admin@example.com\"]" }).optional(), tos_uri: z.string().meta({ description: "The URI of the application terms of service. Eg: \"https://client.example.com/tos\"" }).optional(), policy_uri: z.string().meta({ description: "The URI of the application privacy policy. Eg: \"https://client.example.com/policy\"" }).optional(), jwks_uri: z.string().meta({ description: "The URI of the application JWKS. Eg: \"https://client.example.com/jwks\"" }).optional(), jwks: z.record(z.any(), z.any()).meta({ description: "The JWKS of the application. Eg: {\"keys\": [{\"kty\": \"RSA\", \"alg\": \"RS256\", \"use\": \"sig\", \"n\": \"...\", \"e\": \"...\"}]}" }).optional(), metadata: z.record(z.any(), z.any()).meta({ description: "The metadata of the application. Eg: {\"key\": \"value\"}" }).optional(), software_id: z.string().meta({ description: "The software ID of the application. Eg: \"my-software\"" }).optional(), software_version: z.string().meta({ description: "The software version of the application. Eg: \"1.0.0\"" }).optional(), software_statement: z.string().meta({ description: "The software statement of the application." }).optional() }); const DEFAULT_CODE_EXPIRES_IN = 600; const DEFAULT_ACCESS_TOKEN_EXPIRES_IN = 3600; const DEFAULT_REFRESH_TOKEN_EXPIRES_IN = 604800; /** * OpenID Connect (OIDC) plugin for Better Auth. This plugin implements the * authorization code flow and the token exchange flow. It also implements the * userinfo endpoint. * * @param options - The options for the OIDC plugin. * @returns A Better Auth plugin. */ const oidcProvider = (options) => { const modelName = { oauthClient: "oauthApplication", oauthAccessToken: "oauthAccessToken", oauthConsent: "oauthConsent" }; const opts = { codeExpiresIn: DEFAULT_CODE_EXPIRES_IN, defaultScope: "openid", accessTokenExpiresIn: DEFAULT_ACCESS_TOKEN_EXPIRES_IN, refreshTokenExpiresIn: DEFAULT_REFRESH_TOKEN_EXPIRES_IN, allowPlainCodeChallengeMethod: true, storeClientSecret: "plain", ...options, scopes: [ "openid", "profile", "email", "offline_access", ...options?.scopes || [] ] }; const trustedClients = options.trustedClients || []; /** * Store client secret according to the configured storage method */ async function storeClientSecret(ctx, clientSecret) { if (opts.storeClientSecret === "encrypted") return await 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; } /** * Verify stored client secret against provided client secret */ async function verifyStoredClientSecret(ctx, storedClientSecret, clientSecret) { if (opts.storeClientSecret === "encrypted") return await symmetricDecrypt({ key: ctx.context.secret, data: storedClientSecret }) === clientSecret; if (opts.storeClientSecret === "hashed") return await defaultClientSecretHasher(clientSecret) === storedClientSecret; if (typeof opts.storeClientSecret === "object" && "hash" in opts.storeClientSecret) return await opts.storeClientSecret.hash(clientSecret) === storedClientSecret; if (typeof opts.storeClientSecret === "object" && "decrypt" in opts.storeClientSecret) return await opts.storeClientSecret.decrypt(storedClientSecret) === clientSecret; return clientSecret === storedClientSecret; } return { id: "oidc", hooks: { after: [{ matcher() { return true; }, handler: createAuthMiddleware(async (ctx) => { const loginPromptCookie = 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 (!loginPromptCookie || !hasSessionToken) return; expireCookie(ctx, { name: "oidc_login_prompt", attributes: { path: "/" } }); 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; ctx.query = JSON.parse(loginPromptCookie); 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 authorize(ctx, opts); }) }] }, endpoints: { getOpenIdConfig: createAuthEndpoint("/.well-known/openid-configuration", { method: "GET", operationId: "getOpenIdConfig", metadata: HIDE_METADATA }, async (ctx) => { const metadata = getMetadata(ctx, options); return ctx.json(metadata); }), oAuth2authorize: createAuthEndpoint("/oauth2/authorize", { method: "GET", operationId: "oauth2Authorize", query: z.record(z.string(), z.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: createAuthEndpoint("/oauth2/consent", { method: "POST", operationId: "oauth2Consent", body: oAuthConsentBodySchema, use: [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) { const cookieValue = await ctx.getSignedCookie("oidc_consent_prompt", ctx.context.secret); if (cookieValue) consentCode = cookieValue; } if (!consentCode) throw new 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 APIError("UNAUTHORIZED", { error_description: "Invalid code", error: "invalid_request" }); if (verification.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("UNAUTHORIZED", { error_description: "Code expired", error: "invalid_request" }); expireCookie(ctx, { name: "oidc_consent_prompt", attributes: { path: "/" } }); const value = JSON.parse(verification.value); if (!value.requireConsent) throw new 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 = generateRandomString(32, "a-z", "A-Z", "0-9"); const codeExpiresInMs = (opts?.codeExpiresIn ?? DEFAULT_CODE_EXPIRES_IN) * 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: createAuthEndpoint("/oauth2/token", { method: "POST", operationId: "oauth2Token", body: oAuth2TokenBodySchema, metadata: { ...HIDE_METADATA, allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"] } }, async (ctx) => { let { body } = ctx; if (!body) throw new 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 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 now = Date.now(); const iat = Math.floor(now / 1e3); const exp = iat + (opts.accessTokenExpiresIn ?? 3600); const accessTokenExpiresAt = /* @__PURE__ */ new Date(exp * 1e3); const refreshTokenExpiresAt = /* @__PURE__ */ 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 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 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"); await ctx.context.adapter.create({ model: modelName.oauthAccessToken, data: { accessToken: accessToken$1, refreshToken: newRefreshToken, accessTokenExpiresAt, refreshTokenExpiresAt, clientId: client_id.toString(), userId: token.userId, scopes: token.scopes, createdAt: /* @__PURE__ */ new Date(iat * 1e3), updatedAt: /* @__PURE__ */ new Date(iat * 1e3) } }); 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 (options.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 getClient(client_id.toString(), trustedClients); 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" }); 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 (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.clientSecret || !client_secret) throw new APIError("UNAUTHORIZED", { error_description: "client_secret is required for confidential clients", error: "invalid_client" }); if (!await verifyStoredClientSecret(ctx, client.clientSecret, client_secret.toString())) throw new APIError("UNAUTHORIZED", { error_description: "invalid client_secret", error: "invalid_client" }); } 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"); 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(iat * 1e3), updatedAt: /* @__PURE__ */ new Date(iat * 1e3) } }); const user = await ctx.context.internalAdapter.findUserById(value.userId); if (!user) throw new 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, 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 }; const expirationTime = Math.floor(Date.now() / 1e3) + (opts?.accessTokenExpiresIn ?? DEFAULT_ACCESS_TOKEN_EXPIRES_IN); 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 APIError("INTERNAL_SERVER_ERROR", { error_description: "JWT plugin is not enabled", error: "internal_server_error" }); } idToken = await getJwtToken({ ...ctx, context: { ...ctx.context, session: { session: { id: generateRandomString(32, "a-z", "A-Z"), createdAt: /* @__PURE__ */ new Date(iat * 1e3), updatedAt: /* @__PURE__ */ 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: jwtPlugin.options?.jwt?.issuer ?? ctx.context.options.baseURL, expirationTime, definePayload: () => payload } }); } else idToken = await new 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: createAuthEndpoint("/oauth2/userinfo", { method: "GET", operationId: "oauth2Userinfo", metadata: { ...HIDE_METADATA, 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 APIError("UNAUTHORIZED", { error_description: "request not found", error: "invalid_request" }); const authorization = ctx.request.headers.get("authorization"); if (!authorization) throw new 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 APIError("UNAUTHORIZED", { error_description: "invalid access token", error: "invalid_token" }); if (accessToken.accessTokenExpiresAt < /* @__PURE__ */ new Date()) throw new APIError("UNAUTHORIZED", { error_description: "The Access Token expired", error: "invalid_token" }); const client = await getClient(accessToken.clientId, trustedClients); if (!client) throw new APIError("UNAUTHORIZED", { error_description: "client not found", error: "invalid_token" }); const user = await ctx.context.internalAdapter.findUserById(accessToken.userId); if (!user) throw new 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 }); }), registerOAuthApplication: createAuthEndpoint("/oauth2/register", { method: "POST", body: registerOAuthApplicationBodySchema, 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_secret"] }, 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", "clientSecret", "redirectURLs", "type", "authenticationScheme", "disabled", "createdAt", "updatedAt" ] } } } } } } } }, async (ctx) => { const body = ctx.body; const session = await getSessionFromCtx(ctx); if (!session && !options.allowDynamicClientRegistration) throw new APIError("UNAUTHORIZED", { error: "invalid_token", error_description: "Authentication required for client registration" }); 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 = options.generateClientId?.() || generateRandomString(32, "a-z", "A-Z"); const clientSecret = options.generateClientSecret?.() || generateRandomString(32, "a-z", "A-Z"); const storedClientSecret = await storeClientSecret(ctx, clientSecret); const client = 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: storedClientSecret, redirectUrls: body.redirect_uris.join(","), type: "web", authenticationScheme: body.token_endpoint_auth_method || "client_secret_basic", disabled: false, userId: session?.session.userId, createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date() } }); return ctx.json({ client_id: clientId, ...client.type !== "public" ? { client_secret: clientSecret, client_secret_expires_at: 0 } : {}, client_id_issued_at: Math.floor(Date.now() / 1e3), client_secret_expires_at: 0, 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 }, { status: 201, headers: { "Cache-Control": "no-store", Pragma: "no-cache" } }); }), getOAuthClient: createAuthEndpoint("/oauth2/client/:id", { method: "GET", use: [sessionMiddleware], metadata: { openapi: { description: "Get OAuth2 client details", responses: { "200": { description: "OAuth2 client retrieved successfully", content: { "application/json": { schema: { type: "object", properties: { clientId: { type: "string", description: "Unique identifier for the client" }, name: { type: "string", description: "Name of the OAuth2 application" }, icon: { type: "string", nullable: true, description: "Icon URL for the application" } }, required: ["clientId", "name"] } } } } } } } }, async (ctx) => { const client = await getClient(ctx.params.id, trustedClients); if (!client) throw new APIError("NOT_FOUND", { error_description: "client not found", error: "not_found" }); return ctx.json({ clientId: client.clientId, name: client.name, icon: client.icon || null }); }), endSession: createAuthEndpoint("/oauth2/endsession", { method: ["GET", "POST"], query: z.object({ id_token_hint: z.string().optional(), logout_hint: z.string().optional(), client_id: z.string().optional(), post_logout_redirect_uri: z.string().optional(), state: z.string().optional(), ui_locales: z.string().optional() }).optional(), metadata: { ...HIDE_METADATA, openapi: { description: "RP-Initiated Logout endpoint. Logs out the end-user and optionally redirects to a post-logout URI.", parameters: [ { name: "id_token_hint", in: "query", description: "Previously issued ID Token passed as a hint about the End-User's current authenticated session", required: false, schema: { type: "string" } }, { name: "logout_hint", in: "query", description: "Hint to the Authorization Server about the End-User that is logging out", required: false, schema: { type: "string" } }, { name: "client_id", in: "query", description: "OAuth 2.0 Client Identifier. Required if post_logout_redirect_uri is used without id_token_hint", required: false, schema: { type: "string" } }, { name: "post_logout_redirect_uri", in: "query", description: "URL to which the RP is requesting that the End-User's User Agent be redirected after a logout has been performed", required: false, schema: { type: "string", format: "uri" } }, { name: "state", in: "query", description: "Opaque value used by the RP to maintain state between the logout request and the callback", required: false, schema: { type: "string" } }, { name: "ui_locales", in: "query", description: "End-User's preferred languages and scripts for the user interface", required: false, schema: { type: "string" } } ], responses: { "302": { description: "Redirect to post_logout_redirect_uri or logout confirmation page" }, "200": { description: "Logout completed successfully" } } } } }, async (ctx) => { const { id_token_hint, client_id, post_logout_redirect_uri, state } = ctx.query || {}; let validatedClientId = null; let validatedUserId = null; if (id_token_hint) try { const jwtPlugin = getJwtPlugin(ctx); if (jwtPlugin && jwtPlugin.options && options?.useJWTPlugin) { const verified = await verifyJWT(id_token_hint, jwtPlugin.options); if (verified) { validatedUserId = verified.sub; validatedClientId = verified.aud ? typeof verified.aud === "string" ? verified.aud : verified.aud[0] : null; } } else if (client_id) { const client = await getClient(client_id, trustedClients); if (client && client.clientSecret) try { const { payload } = await jwtVerify(id_token_hint, new TextEncoder().encode(client.clientSecret)); validatedUserId = payload.sub; validatedClientId = payload.aud; } catch {} } } catch { ctx.context.logger.debug("Invalid id_token_hint provided to end_session endpoint"); } if (client_id) { if (!await getClient(client_id, trustedClients)) throw new APIError("BAD_REQUEST", { error: "invalid_client", error_description: "Invalid client_id" }); if (validatedClientId && validatedClientId !== client_id) throw new APIError("BAD_REQUEST", { error: "invalid_request", error_description: "client_id does not match the ID Token's audience" }); validatedClientId = client_id; } if (post_logout_redirect_uri) { if (!validatedClientId) throw new APIError("BAD_REQUEST", { error: "invalid_request", error_description: "client_id is required when using post_logout_redirect_uri without a valid id_token_hint" }); const client = await getClient(validatedClientId, trustedClients); if (!client) throw new APIError("BAD_REQUEST", { error: "invalid_client", error_description: "Invalid client" }); if (!client.redirectUrls.some((registeredUri) => post_logout_redirect_uri === registeredUri)) throw new APIError("BAD_REQUEST", { error: "invalid_request", error_description: "post_logout_redirect_uri is not registered for this client" }); } const session = await getSessionFromCtx(ctx); if (validatedUserId || session) { const userId = validatedUserId || session?.user.id; if (userId) await ctx.context.adapter.deleteMany({ model: modelName.oauthAccessToken, where: [{ field: "userId", value: userId }] }); } if (session) { await ctx.context.internalAdapter.deleteSession(session.session.token); expireCookie(ctx, ctx.context.authCookies.sessionToken); } if (post_logout_redirect_uri) try { const redirectUrl = new URL(post_logout_redirect_uri); if (state) redirectUrl.searchParams.set("state", state); return ctx.redirect(redirectUrl.toString()); } catch { throw new APIError("BAD_REQUEST", { error: "invalid_request", error_description: "Invalid post_logout_redirect_uri format" }); } return ctx.json({ success: true, message: "Logout successful" }); }) }, schema: mergeSchema(schema, options?.schema), get options() { return opts; } }; }; //#endregion export { getClient, getMetadata, oidcProvider }; //# sourceMappingURL=index.mjs.map