feat(oauth-provider): compute at_hash in id tokens per OIDC Core §3.1.3.6 (#9079)

This commit is contained in:
Gustavo Valverde
2026-04-11 10:29:20 +00:00
committed by GitHub
parent b70f025bfa
commit 6f2948e87b
6 changed files with 387 additions and 45 deletions
+10
View File
@@ -0,0 +1,10 @@
---
"@better-auth/oauth-provider": minor
"better-auth": minor
---
feat(oauth-provider): compute `at_hash` in ID tokens per OIDC Core §3.1.3.6
ID tokens issued alongside an access token now include the `at_hash` claim, which cryptographically binds the two tokens to prevent token substitution attacks. The hash algorithm is selected based on the actual signing key's algorithm (EdDSA/Ed25519 uses SHA-512, RS/ES/PS384 uses SHA-384, RS/ES/PS512 uses SHA-512, all others use SHA-256).
A new `resolveSigningKey()` export is available from `better-auth/plugins` to resolve the current JWKS signing key (including its algorithm). When using a custom `jwt.sign` callback, the signed ID token's header is validated against the declared algorithm to prevent `at_hash` mismatches.
@@ -16,7 +16,7 @@ import type { JwtOptions } from "./types";
import { createJwk } from "./utils";
import { verifyJWT as verifyJWTHelper } from "./verify";
export { signJWT } from "./sign";
export { resolveSigningKey, signJWT } from "./sign";
export type * from "./types";
export { createJwk, generateExportedKeyPair, toExpJWT } from "./utils";
export { verifyJWT } from "./verify";
+46 -22
View File
@@ -4,7 +4,7 @@ import type { JWTPayload } from "jose";
import { importJWK, SignJWT } from "jose";
import { symmetricDecrypt } from "../../crypto";
import { getJwksAdapter } from "./adapter";
import type { JwtOptions } from "./types";
import type { JwtOptions, ResolvedSigningKey } from "./types";
import { createJwk, toExpJWT } from "./utils";
type JWTPayloadWithOptional = {
@@ -61,11 +61,52 @@ type JWTPayloadWithOptional = {
[propName: string]: unknown | undefined;
};
/**
* Resolves the JWKS signing key, decrypts it, and imports it
* for use with jose's SignJWT. Returns null when signing is
* delegated to a custom jwt.sign callback.
*
* Callers that need the signing algorithm before constructing
* the JWT payload (e.g. for OIDC at_hash) should call this
* first, read `.alg`, then pass the result to `signJWT` via
* the `resolvedKey` option to avoid a redundant DB lookup.
*/
export async function resolveSigningKey(
ctx: GenericEndpointContext,
options?: JwtOptions,
): Promise<ResolvedSigningKey | null> {
if (options?.jwt?.sign) {
return null;
}
const adapter = getJwksAdapter(ctx.context.adapter, options);
let key = await adapter.getLatestKey(ctx);
if (!key || (key.expiresAt && key.expiresAt < new Date())) {
key = await createJwk(ctx, options);
}
const privateKeyEncryptionEnabled =
!options?.jwks?.disablePrivateKeyEncryption;
const privateWebKey = privateKeyEncryptionEnabled
? await symmetricDecrypt({
key: ctx.context.secretConfig,
data: JSON.parse(key.privateKey),
}).catch(() => {
throw new BetterAuthError(
"Failed to decrypt private key. Make sure the secret currently in use is the same as the one used to encrypt the private key. If you are using a different secret, either clean up your JWKS or disable private key encryption.",
);
})
: key.privateKey;
const alg = key.alg ?? options?.jwks?.keyPairConfig?.alg ?? "EdDSA";
const privateKey = await importJWK(JSON.parse(privateWebKey), alg);
return { alg, kid: key.id, privateKey };
}
export async function signJWT(
ctx: GenericEndpointContext,
config: {
options?: JwtOptions | undefined;
payload: JWTPayloadWithOptional;
/** Pre-resolved key from resolveSigningKey. Skips redundant DB lookup. */
resolvedKey?: ResolvedSigningKey;
},
) {
const { options } = config;
@@ -113,31 +154,14 @@ export async function signJWT(
return options.jwt.sign(jwtPayload);
}
const adapter = getJwksAdapter(ctx.context.adapter, options);
let key = await adapter.getLatestKey(ctx);
if (!key || (key.expiresAt && key.expiresAt < new Date())) {
key = await createJwk(ctx, options);
}
const privateKeyEncryptionEnabled =
!options?.jwks?.disablePrivateKeyEncryption;
const privateWebKey = privateKeyEncryptionEnabled
? await symmetricDecrypt({
key: ctx.context.secretConfig,
data: JSON.parse(key.privateKey),
}).catch(() => {
throw new BetterAuthError(
"Failed to decrypt private key. Make sure the secret currently in use is the same as the one used to encrypt the private key. If you are using a different secret, either clean up your JWKS or disable private key encryption.",
);
})
: key.privateKey;
const alg = key.alg ?? options?.jwks?.keyPairConfig?.alg ?? "EdDSA";
const privateKey = await importJWK(JSON.parse(privateWebKey), alg);
// Use pre-resolved key if available, otherwise resolve from DB
const { alg, kid, privateKey } =
config.resolvedKey ?? (await resolveSigningKey(ctx, options))!;
const jwt = new SignJWT(payload)
.setProtectedHeader({
alg,
kid: key.id,
kid,
})
.setExpirationTime(exp)
.setIssuer(iss ?? defaultIss)
@@ -206,3 +206,16 @@ export interface Jwk {
alg?: JWSAlgorithms | undefined;
crv?: ("Ed25519" | "P-256" | "P-521") | undefined;
}
/**
* A fully resolved signing key ready for JWT signing.
* Produced by `resolveSigningKey`, consumed by `signJWT`.
* Separates key resolution from signing so callers can
* read the `alg` before constructing the JWT payload
* (required for OIDC hash claims like at_hash).
*/
export interface ResolvedSigningKey {
alg: string;
kid: string;
privateKey: CryptoKey | Uint8Array;
}
+236 -3
View File
@@ -10,7 +10,7 @@ import {
} from "better-auth/oauth2";
import { jwt } from "better-auth/plugins/jwt";
import { getTestInstance } from "better-auth/test";
import { createLocalJWKSet, decodeJwt, jwtVerify } from "jose";
import { base64url, createLocalJWKSet, decodeJwt, jwtVerify } from "jose";
import { beforeAll, describe, expect, it } from "vitest";
import { oauthProviderClient } from "./client";
import { oauthProvider } from "./oauth";
@@ -1490,7 +1490,6 @@ describe("oauth token - config", async () => {
const refreshedTokens = await client.oauth2.token(
{
resource: validAudience,
// @ts-expect-error refresh token is sent
refresh_token: tokens.data?.refresh_token,
grant_type: "refresh_token",
client_id: oauthClient?.client_id,
@@ -1600,7 +1599,6 @@ describe("oauth token - config", async () => {
// Refresh token
const refreshedTokens = await client.oauth2.token(
{
// @ts-expect-error refresh token is sent
refresh_token: tokens.data?.refresh_token,
grant_type: "refresh_token",
client_id: oauthClient?.client_id,
@@ -2235,3 +2233,238 @@ describe("scope preservation through authorization code flow", async () => {
expect(tokens.data?.scope).toBe(requestedScopes.join(" "));
});
});
describe("at_hash in id tokens", async () => {
const authServerBaseUrl = "http://localhost:3000";
const rpBaseUrl = "http://localhost:5000";
const validAudience = "https://myapi.example.com";
const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({
baseURL: authServerBaseUrl,
plugins: [
jwt({
jwt: {
issuer: authServerBaseUrl,
},
}),
oauthProvider({
loginPage: "/login",
consentPage: "/consent",
validAudiences: [validAudience],
silenceWarnings: {
oauthAuthServerConfig: true,
openidConfig: true,
},
}),
],
});
const { headers } = await signInWithTestUser();
const client = createAuthClient({
plugins: [oauthProviderClient(), jwtClient()],
baseURL: authServerBaseUrl,
fetchOptions: {
customFetchImpl,
headers,
},
});
let oauthClient: OAuthClient | null;
const providerId = "test";
const redirectUri = `${rpBaseUrl}/api/auth/oauth2/callback/${providerId}`;
const state = "123";
beforeAll(async () => {
const response = await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
skip_consent: true,
},
});
expect(response?.client_id).toBeDefined();
expect(response?.client_secret).toBeDefined();
oauthClient = response;
});
async function getTokens(scopes: string[], resource?: string) {
if (!oauthClient?.client_id || !oauthClient?.client_secret) {
throw Error("beforeAll not run properly");
}
const codeVerifier = generateRandomString(32);
const url = await createAuthorizationURL({
id: providerId,
options: {
clientId: oauthClient.client_id,
clientSecret: oauthClient.client_secret,
redirectURI: redirectUri,
},
redirectURI: "",
authorizationEndpoint: `${authServerBaseUrl}/api/auth/oauth2/authorize`,
state,
scopes,
codeVerifier,
});
let callbackRedirectUrl = "";
await client.$fetch(url.toString(), {
onError(context) {
callbackRedirectUrl = context.response.headers.get("Location") || "";
},
});
const callbackUrl = new URL(callbackRedirectUrl);
const code = callbackUrl.searchParams.get("code")!;
const { body, headers: reqHeaders } = createAuthorizationCodeRequest({
code,
codeVerifier,
redirectURI: redirectUri,
options: {
clientId: oauthClient.client_id,
clientSecret: oauthClient.client_secret,
redirectURI: redirectUri,
},
});
const params = new URLSearchParams(body.toString());
if (resource) {
params.set("resource", resource);
}
const tokens = await client.$fetch<{
access_token?: string;
id_token?: string;
[key: string]: unknown;
}>("/oauth2/token", {
method: "POST",
body: params.toString(),
headers: reqHeaders,
});
return tokens.data!;
}
it("should include at_hash when id token is issued with access token", async ({
expect,
}) => {
const tokens = await getTokens(["openid"]);
expect(tokens.id_token).toBeDefined();
expect(tokens.access_token).toBeDefined();
const decoded = decodeJwt(tokens.id_token!);
expect(decoded.at_hash).toBeDefined();
expect(typeof decoded.at_hash).toBe("string");
expect((decoded.at_hash as string).length).toBeGreaterThan(0);
});
/**
* EdDSA (Ed25519) uses SHA-512 per RFC 8032.
* at_hash = base64url(left-half(SHA-512(access_token)))
*/
it("at_hash should match manual computation for EdDSA", async ({
expect,
}) => {
const tokens = await getTokens(["openid"]);
const decoded = decodeJwt(tokens.id_token!);
const digest = new Uint8Array(
await crypto.subtle.digest(
"SHA-512",
new TextEncoder().encode(tokens.access_token!),
),
);
const expectedAtHash = base64url.encode(digest.slice(0, digest.length / 2));
expect(decoded.at_hash).toBe(expectedAtHash);
});
it("at_hash should not be present without openid scope", async ({
expect,
}) => {
const tokens = await getTokens(["offline_access"], validAudience);
expect(tokens.id_token).toBeUndefined();
});
it("customIdTokenClaims should not receive accessToken", async ({
expect,
}) => {
let receivedKeys: string[] = [];
const {
auth: testAuth,
signInWithTestUser: signIn,
customFetchImpl: fetchImpl,
} = await getTestInstance({
baseURL: authServerBaseUrl,
plugins: [
jwt({ jwt: { issuer: authServerBaseUrl } }),
oauthProvider({
loginPage: "/login",
consentPage: "/consent",
silenceWarnings: {
oauthAuthServerConfig: true,
openidConfig: true,
},
customIdTokenClaims: (info) => {
receivedKeys = Object.keys(info);
return {};
},
}),
],
});
const { headers: testHeaders } = await signIn();
const testClient = createAuthClient({
plugins: [oauthProviderClient(), jwtClient()],
baseURL: authServerBaseUrl,
fetchOptions: { customFetchImpl: fetchImpl, headers: testHeaders },
});
const testOauthClient = await testAuth.api.adminCreateOAuthClient({
headers: testHeaders,
body: { redirect_uris: [redirectUri], skip_consent: true },
});
const codeVerifier = generateRandomString(32);
const url = await createAuthorizationURL({
id: providerId,
options: {
clientId: testOauthClient!.client_id!,
clientSecret: testOauthClient!.client_secret!,
redirectURI: redirectUri,
},
redirectURI: "",
authorizationEndpoint: `${authServerBaseUrl}/api/auth/oauth2/authorize`,
state,
scopes: ["openid"],
codeVerifier,
});
let callbackUrl = "";
await testClient.$fetch(url.toString(), {
onError(context) {
callbackUrl = context.response.headers.get("Location") || "";
},
});
const code = new URL(callbackUrl).searchParams.get("code")!;
const { body, headers: reqHeaders } = createAuthorizationCodeRequest({
code,
codeVerifier,
redirectURI: redirectUri,
options: {
clientId: testOauthClient!.client_id!,
clientSecret: testOauthClient!.client_secret!,
redirectURI: redirectUri,
},
});
await testClient.$fetch("/oauth2/token", {
method: "POST",
body,
headers: reqHeaders,
});
expect(receivedKeys).toContain("user");
expect(receivedKeys).toContain("scopes");
expect(receivedKeys).toContain("metadata");
expect(receivedKeys).not.toContain("accessToken");
});
});
+81 -19
View File
@@ -2,10 +2,10 @@ import type { GenericEndpointContext } from "@better-auth/core";
import { APIError } from "better-auth/api";
import { generateRandomString } from "better-auth/crypto";
import { generateCodeChallenge } from "better-auth/oauth2";
import { signJWT, toExpJWT } from "better-auth/plugins";
import { resolveSigningKey, signJWT, toExpJWT } from "better-auth/plugins";
import type { Session, User } from "better-auth/types";
import type { JWTPayload } from "jose";
import { SignJWT } from "jose";
import { base64url, decodeProtectedHeader, SignJWT } from "jose";
import type {
OAuthOptions,
OAuthRefreshToken,
@@ -119,6 +119,31 @@ async function createJwtAccessToken(
});
}
/**
* Computes an OIDC hash (at_hash, c_hash) per OIDC Core §3.1.3.6.
* Hashes the token, takes the left half, and base64url-encodes it.
*/
async function computeOidcHash(
token: string,
signingAlg: string,
): Promise<string> {
let hashAlg: string;
if (signingAlg === "EdDSA") {
hashAlg = "SHA-512";
} else if (signingAlg.endsWith("384")) {
hashAlg = "SHA-384";
} else if (signingAlg.endsWith("512")) {
hashAlg = "SHA-512";
} else {
hashAlg = "SHA-256";
}
const digest = new Uint8Array(
await crypto.subtle.digest(hashAlg, new TextEncoder().encode(token)),
);
return base64url.encode(digest.slice(0, digest.length / 2));
}
/**
* Creates a user id token in code_authorization with scope of 'openid'
* and hybrid/implicit (not yet implemented) flows
@@ -132,6 +157,7 @@ async function createIdToken(
nonce?: string,
sessionId?: string,
authTime?: Date,
accessToken?: string,
) {
const iat = Math.floor(Date.now() / 1000);
const exp = iat + (opts.idTokenExpiresIn ?? 36000);
@@ -156,11 +182,26 @@ async function createIdToken(
? undefined
: getJwtPlugin(ctx.context).options;
// Resolve the signing key once: used for both at_hash and signing
const resolvedKey =
!opts.disableJwtPlugin && !jwtPluginOptions?.jwt?.sign
? await resolveSigningKey(ctx, jwtPluginOptions)
: undefined;
// For custom signer, alg is guaranteed set by JWT plugin init validation
const signingAlg = opts.disableJwtPlugin
? "HS256"
: (resolvedKey?.alg ?? jwtPluginOptions?.jwks?.keyPairConfig?.alg!);
const atHash = accessToken
? await computeOidcHash(accessToken, signingAlg)
: undefined;
const payload: JWTPayload = {
...userClaims,
auth_time: authTimeSec,
acr,
...customClaims,
at_hash: atHash,
iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL,
sub: resolvedSub,
aud: client.clientId,
@@ -176,8 +217,8 @@ async function createIdToken(
return undefined;
}
return opts.disableJwtPlugin
? new SignJWT(payload)
const idToken = opts.disableJwtPlugin
? await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.sign(
new TextEncoder().encode(
@@ -188,10 +229,28 @@ async function createIdToken(
),
),
)
: signJWT(ctx, {
: await signJWT(ctx, {
options: jwtPluginOptions,
payload,
resolvedKey: resolvedKey ?? undefined,
});
// When using a custom jwt.sign callback, validate that the actual
// signing algorithm matches what was used for at_hash (OIDC Core §3.1.3.6)
if (idToken && atHash && jwtPluginOptions?.jwt?.sign) {
const header = decodeProtectedHeader(idToken);
if (header.alg !== signingAlg) {
throw new APIError("INTERNAL_SERVER_ERROR", {
error_description:
`ID token signed with "${header.alg}" but at_hash was computed ` +
`for "${signingAlg}". Ensure jwt.sign uses the algorithm ` +
`declared in keyPairConfig.alg.`,
error: "server_error",
});
}
}
return idToken;
}
/**
@@ -421,8 +480,8 @@ async function createUserTokens(
)
: undefined;
// Sign jwt and refresh tokens in parallel
const [accessToken, refreshToken, idToken] = await Promise.all([
// Create access token and refresh token in parallel
const [accessToken, refreshToken] = await Promise.all([
isJwtAccessToken
? createJwtAccessToken(
ctx,
@@ -471,20 +530,23 @@ async function createUserTokens(
authTime,
)
: undefined,
isIdToken
? createIdToken(
ctx,
opts,
user,
client,
scopes,
nonce,
sessionId,
authTime,
)
: undefined,
]);
// ID token created after access token so at_hash can be computed
const idToken = isIdToken
? await createIdToken(
ctx,
opts,
user,
client,
scopes,
nonce,
sessionId,
authTime,
accessToken,
)
: undefined;
return ctx.json(
{
access_token: accessToken,