From 9194d7869a0945abe8d7a768d1d8ac0a033fc111 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 24 Apr 2026 10:59:44 +0200 Subject: [PATCH] fix(tokens): issueTokenPair signs via signToken dispatcher (RS256 in prod) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression root cause: cloud#299 retired the SIGNING_KEY worker binding on the relayauth worker as part of HS256 sunset (phase 122 step 3). The JWKS bugfix (#32) and the SIGNING_KEY-gate fix in the JWKS route were shipped. But issueTokenPair in routes/tokens.ts was still calling signHs256Jwt(claims, env.SIGNING_KEY, env.SIGNING_KEY_ID) directly — bypassing the signToken(claims, env) dispatcher added in phase 120. With SIGNING_KEY undefined at runtime, signHs256Jwt's crypto.subtle.importKey call threw "DataError: Imported HMAC key length (0) must be non-zero", producing a 500 on every POST /v1/tokens. Sage (and every other api-key caller) hit this 500 as soon as they tried to mint a delegated token, cascading into production failures: "I ran into an issue processing your request" in the Slack path was the user-facing manifestation. Fix - Replace the two direct signHs256Jwt calls with signToken(claims, env). signToken dispatches on RELAYAUTH_SIGNING_ALG (RS256 in production per infra/relayauth.ts:55) and signs via RELAYAUTH_SIGNING_KEY_PEM, which is still bound. - Delete the now-unused signHs256Jwt helper. (encodeBase64UrlJson + encodeBase64UrlBytes stay; other code paths use them.) No behavior change for any caller that was working before #299. The issued access + refresh tokens are now RS256-signed (they already were, via the JWKS published key, for OTHER code paths — but NOT for /v1/tokens, until now). Tests - tokens-route.test.ts: 33/33 pass (no changes to test fixtures needed — RS256 signing is invisible to the assertions which only check response shape / status codes). - tsc --noEmit clean on packages/server. Deploy - Publish @relayauth/server@0.2.5 - Bump cloud's @relayauth/* dep to ^0.2.5 - Cloud deploy restores /v1/tokens production flow. Co-Authored-By: Claude Opus 4.7 --- packages/server/src/routes/tokens.ts | 38 ++++++++-------------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/packages/server/src/routes/tokens.ts b/packages/server/src/routes/tokens.ts index f161024..3465ed3 100644 --- a/packages/server/src/routes/tokens.ts +++ b/packages/server/src/routes/tokens.ts @@ -5,6 +5,7 @@ import { Hono } from "hono"; import type { AppEnv } from "../env.js"; import { authenticateAndAuthorizeFromContext } from "../lib/auth.js"; import { decodeBase64UrlJson, verifyHs256Signature } from "../lib/jwt.js"; +import { signToken } from "../lib/sign.js"; import type { StoredIdentity } from "../storage/identity-types.js"; import type { AuthStorage, RevocationStorage } from "../storage/index.js"; @@ -375,8 +376,15 @@ async function issueTokenPair( sid: sessionId, }; - const accessToken = await signHs256Jwt(accessClaims, env.SIGNING_KEY, env.SIGNING_KEY_ID); - const refreshToken = await signHs256Jwt(refreshClaims, env.SIGNING_KEY, env.SIGNING_KEY_ID); + // Dispatch to the RELAYAUTH_SIGNING_ALG-configured signer (RS256 in + // production post-phase-120). The old code called signHs256Jwt directly + // with env.SIGNING_KEY, which regressed after cloud#299 removed the + // SIGNING_KEY worker binding — crypto.subtle.importKey then threw + // DataError "Imported HMAC key length (0) must be non-zero" and + // /v1/tokens returned 500. signToken(...) respects the env's signing-alg + // config and uses RELAYAUTH_SIGNING_KEY_PEM when RS256. + const accessToken = await signToken(accessClaims, env); + const refreshToken = await signToken(refreshClaims, env); await persistIssuedToken(storage, identity.id, accessClaims); await persistIssuedToken(storage, identity.id, refreshClaims); @@ -636,32 +644,6 @@ function isValidClaims(value: RelayAuthTokenClaims): boolean { && (value.token_type === "access" || value.token_type === "refresh"); } -async function signHs256Jwt( - claims: RelayAuthTokenClaims, - signingKey: string, - signingKeyId: string, -): Promise { - const header: JwtHeader = { - alg: "HS256", - typ: "JWT", - kid: signingKeyId, - }; - - const encodedHeader = encodeBase64UrlJson(header); - const encodedPayload = encodeBase64UrlJson(claims); - const unsigned = `${encodedHeader}.${encodedPayload}`; - - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(signingKey), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(unsigned)); - return `${unsigned}.${encodeBase64UrlBytes(new Uint8Array(signature))}`; -} - function encodeBase64UrlJson(value: unknown): string { return encodeBase64UrlBytes(new TextEncoder().encode(JSON.stringify(value))); }