mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-22 11:49:20 -05:00
feat(jwt): add JWT verification endpoint and refactor verification logic (#6122)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user