diff --git a/packages/better-auth/src/plugins/jwt/index.ts b/packages/better-auth/src/plugins/jwt/index.ts index f3bb69ef17..1894b0695c 100644 --- a/packages/better-auth/src/plugins/jwt/index.ts +++ b/packages/better-auth/src/plugins/jwt/index.ts @@ -13,9 +13,11 @@ import { schema } from "./schema"; import { getJwtToken, signJWT } from "./sign"; import type { JwtOptions } from "./types"; import { createJwk } from "./utils"; +import { verifyJWT as verifyJWTHelper } from "./verify"; export type * from "./types"; export { createJwk, generateExportedKeyPair } from "./utils"; +export { verifyJWT } from "./verify"; export const jwt = (options?: JwtOptions | undefined) => { // Remote url must be set when using signing function @@ -231,6 +233,50 @@ export const jwt = (options?: JwtOptions | undefined) => { return c.json({ token: jwt }); }, ), + verifyJWT: createAuthEndpoint( + "/verify-jwt", + { + method: "POST", + metadata: { + SERVER_ONLY: true, + $Infer: { + body: {} as { + token: string; + issuer?: string; + }, + response: {} as { + payload: { + sub: string; + aud: string; + [key: string]: any; + } | null; + }, + }, + }, + body: z.object({ + token: z.string(), + issuer: z.string().optional(), + }), + }, + async (ctx) => { + const overrideOptions = ctx.body.issuer + ? { + ...options, + jwt: { + ...options?.jwt, + issuer: ctx.body.issuer, + }, + } + : options; + + const payload = await verifyJWTHelper( + ctx.body.token, + overrideOptions, + ); + + return ctx.json({ payload }); + }, + ), }, hooks: { after: [ diff --git a/packages/better-auth/src/plugins/jwt/verify.ts b/packages/better-auth/src/plugins/jwt/verify.ts new file mode 100644 index 0000000000..e827bb0bc3 --- /dev/null +++ b/packages/better-auth/src/plugins/jwt/verify.ts @@ -0,0 +1,65 @@ +import type { GenericEndpointContext } from "@better-auth/core"; +import { getCurrentAuthContext } from "@better-auth/core/context"; +import { base64 } from "@better-auth/utils/base64"; +import type { JWTPayload } from "jose"; +import { importJWK, jwtVerify } from "jose"; +import { getJwksAdapter } from "./adapter"; +import type { JwtOptions } from "./types"; + +/** + * Verify a JWT token using the JWKS public keys + * Returns the payload if valid, null otherwise + */ +export async function verifyJWT( + token: string, + options?: JwtOptions, +): Promise<(T & Required>) | null> { + const ctx = await getCurrentAuthContext(); + try { + const parts = token.split("."); + if (parts.length !== 3) { + return null; + } + + const headerStr = new TextDecoder().decode(base64.decode(parts[0]!)); + const header = JSON.parse(headerStr); + const kid = header.kid; + + if (!kid) { + ctx.context.logger.debug("JWT missing kid in header"); + return null; + } + + // Get all JWKS keys + const adapter = getJwksAdapter(ctx.context.adapter, options); + const keys = await adapter.getAllKeys(ctx as GenericEndpointContext); + + if (!keys || keys.length === 0) { + ctx.context.logger.debug("No JWKS keys available"); + return null; + } + + const key = keys.find((k) => k.id === kid); + if (!key) { + ctx.context.logger.debug(`No JWKS key found for kid: ${kid}`); + return null; + } + + const publicKey = JSON.parse(key.publicKey); + const alg = key.alg ?? options?.jwks?.keyPairConfig?.alg ?? "EdDSA"; + const cryptoKey = await importJWK(publicKey, alg); + + const { payload } = await jwtVerify(token, cryptoKey, { + issuer: options?.jwt?.issuer ?? ctx.context.options.baseURL, + }); + + if (!payload.sub || !payload.aud) { + return null; + } + + return payload as T & Required>; + } catch (error) { + ctx.context.logger.debug("JWT verification failed", error); + return null; + } +} diff --git a/packages/better-auth/src/plugins/oidc-provider/index.ts b/packages/better-auth/src/plugins/oidc-provider/index.ts index 1328ec28ea..f77defdf3a 100644 --- a/packages/better-auth/src/plugins/oidc-provider/index.ts +++ b/packages/better-auth/src/plugins/oidc-provider/index.ts @@ -9,7 +9,7 @@ import { import { getCurrentAuthContext } from "@better-auth/core/context"; import { base64 } from "@better-auth/utils/base64"; import { createHash } from "@better-auth/utils/hash"; -import { importJWK, jwtVerify, SignJWT } from "jose"; +import { jwtVerify, SignJWT } from "jose"; import * as z from "zod"; import { APIError, getSessionFromCtx, sessionMiddleware } from "../../api"; import { parseSetCookieHeader } from "../../cookies"; @@ -20,8 +20,7 @@ import { } from "../../crypto"; import { mergeSchema } from "../../db"; import type { jwt } from "../jwt"; -import { getJwtToken } from "../jwt"; -import { getJwksAdapter } from "../jwt/adapter"; +import { getJwtToken, verifyJWT } from "../jwt"; import { authorize } from "./authorize"; import type { OAuthApplication } from "./schema"; import { schema } from "./schema"; @@ -41,68 +40,6 @@ const getJwtPlugin = (ctx: GenericEndpointContext) => { ) as ReturnType; }; -/** - * Verify a JWT token using the JWKS public keys - * Returns the payload if valid, null otherwise - */ -async function verifyJwtWithJWKS( - ctx: GenericEndpointContext, - token: string, - jwtPlugin: ReturnType, -): Promise<{ sub: string; aud: string } | null> { - try { - const parts = token.split("."); - if (parts.length !== 3) { - return null; - } - - const headerStr = new TextDecoder().decode(base64.decode(parts[0]!)); - const header = JSON.parse(headerStr); - const kid = header.kid; - - if (!kid) { - ctx.context.logger.debug("JWT missing kid in header"); - return null; - } - - // Get all JWKS keys - const adapter = getJwksAdapter(ctx.context.adapter, jwtPlugin.options); - const keys = await adapter.getAllKeys(ctx); - - if (!keys || keys.length === 0) { - ctx.context.logger.debug("No JWKS keys available"); - return null; - } - - const key = keys.find((k) => k.id === kid); - if (!key) { - ctx.context.logger.debug(`No JWKS key found for kid: ${kid}`); - return null; - } - - const publicKey = JSON.parse(key.publicKey); - const alg = - key.alg ?? jwtPlugin.options?.jwks?.keyPairConfig?.alg ?? "EdDSA"; - const cryptoKey = await importJWK(publicKey, alg); - - const { payload } = await jwtVerify(token, cryptoKey, { - issuer: jwtPlugin.options?.jwt?.issuer ?? ctx.context.options.baseURL, - }); - - if (!payload.sub || !payload.aud) { - return null; - } - - return { - sub: payload.sub as string, - aud: payload.aud as string, - }; - } catch (error) { - ctx.context.logger.debug("JWT verification failed", error); - return null; - } -} - /** * Get a client by ID, checking trusted clients first, then database */ @@ -1677,14 +1614,17 @@ export const oidcProvider = (options: OIDCOptions) => { const jwtPlugin = getJwtPlugin(ctx); if (jwtPlugin && jwtPlugin.options && options?.useJWTPlugin) { // For JWT plugin tokens, verify using JWKS - const verified = await verifyJwtWithJWKS( - ctx, + const verified = await verifyJWT( id_token_hint, - jwtPlugin, + jwtPlugin.options, ); if (verified) { validatedUserId = verified.sub; - validatedClientId = verified.aud; + validatedClientId = verified.aud + ? typeof verified.aud === "string" + ? verified.aud + : verified.aud[0]! + : null; } } else { // For HS256 tokens, we need the client_id to verify