From 6f2948e87bb5fa14bd2174a91f7143e1eced1b87 Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Sat, 11 Apr 2026 11:29:20 +0100 Subject: [PATCH] =?UTF-8?q?feat(oauth-provider):=20compute=20`at=5Fhash`?= =?UTF-8?q?=20in=20id=20tokens=20per=20OIDC=20Core=20=C2=A73.1.3.6=20(#907?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/oauth-provider-at-hash.md | 10 + packages/better-auth/src/plugins/jwt/index.ts | 2 +- packages/better-auth/src/plugins/jwt/sign.ts | 68 +++-- packages/better-auth/src/plugins/jwt/types.ts | 13 + packages/oauth-provider/src/token.test.ts | 239 +++++++++++++++++- packages/oauth-provider/src/token.ts | 100 ++++++-- 6 files changed, 387 insertions(+), 45 deletions(-) create mode 100644 .changeset/oauth-provider-at-hash.md diff --git a/.changeset/oauth-provider-at-hash.md b/.changeset/oauth-provider-at-hash.md new file mode 100644 index 0000000000..e682180fcc --- /dev/null +++ b/.changeset/oauth-provider-at-hash.md @@ -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. diff --git a/packages/better-auth/src/plugins/jwt/index.ts b/packages/better-auth/src/plugins/jwt/index.ts index 001457664f..d798c9ac53 100644 --- a/packages/better-auth/src/plugins/jwt/index.ts +++ b/packages/better-auth/src/plugins/jwt/index.ts @@ -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"; diff --git a/packages/better-auth/src/plugins/jwt/sign.ts b/packages/better-auth/src/plugins/jwt/sign.ts index f69bd9c98d..a702e89ff9 100644 --- a/packages/better-auth/src/plugins/jwt/sign.ts +++ b/packages/better-auth/src/plugins/jwt/sign.ts @@ -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 { + 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) diff --git a/packages/better-auth/src/plugins/jwt/types.ts b/packages/better-auth/src/plugins/jwt/types.ts index a89876ab92..c8918ae661 100644 --- a/packages/better-auth/src/plugins/jwt/types.ts +++ b/packages/better-auth/src/plugins/jwt/types.ts @@ -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; +} diff --git a/packages/oauth-provider/src/token.test.ts b/packages/oauth-provider/src/token.test.ts index bbf63f8615..c1962fa8ed 100644 --- a/packages/oauth-provider/src/token.test.ts +++ b/packages/oauth-provider/src/token.test.ts @@ -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"); + }); +}); diff --git a/packages/oauth-provider/src/token.ts b/packages/oauth-provider/src/token.ts index 24a41204d0..e63b5ee353 100644 --- a/packages/oauth-provider/src/token.ts +++ b/packages/oauth-provider/src/token.ts @@ -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 { + 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,