feat(jwt): add JWT verification endpoint and refactor verification logic (#6122)

This commit is contained in:
Alex Yang
2025-11-21 00:57:30 +00:00
committed by GitHub
parent c50e27ac86
commit 18cf7273e1
3 changed files with 120 additions and 69 deletions
@@ -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: [
@@ -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<T extends JWTPayload = JWTPayload>(
token: string,
options?: JwtOptions,
): Promise<(T & Required<Pick<JWTPayload, "sub" | "aud">>) | 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<Pick<JWTPayload, "sub" | "aud">>;
} catch (error) {
ctx.context.logger.debug("JWT verification failed", error);
return null;
}
}
@@ -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<typeof jwt>;
};
/**
* 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<typeof jwt>,
): 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