From 3d083e618f74340f89665d771ac85bb9fe9019d4 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 24 Apr 2026 11:16:41 +0200 Subject: [PATCH 1/3] chore(server): make auth signing RS256-only --- packages/server/src/engine/audit-logger.ts | 51 +++------- packages/server/src/entrypoints/node.ts | 20 +--- packages/server/src/env.ts | 3 - packages/server/src/lib/auth.ts | 99 ++++++------------- packages/server/src/lib/jwt.ts | 30 ------ packages/server/src/lib/sign.ts | 70 ++----------- packages/server/src/lib/token-verifier.ts | 45 +++++++++ .../server/src/middleware/api-key-auth.ts | 2 +- packages/server/src/middleware/scope.ts | 57 +++-------- packages/server/src/routes/api-keys.ts | 6 +- packages/server/src/routes/identities.ts | 4 +- packages/server/src/routes/jwks.ts | 16 --- packages/server/src/routes/policies.ts | 10 +- .../server/src/routes/role-assignments.ts | 96 +++--------------- packages/server/src/routes/roles.ts | 10 +- packages/server/src/routes/tokens.ts | 90 ++++------------- packages/server/src/server.ts | 9 +- packages/server/src/storage/sqlite.ts | 4 - 18 files changed, 163 insertions(+), 459 deletions(-) create mode 100644 packages/server/src/lib/token-verifier.ts diff --git a/packages/server/src/engine/audit-logger.ts b/packages/server/src/engine/audit-logger.ts index 4b8233d..8fb6499 100644 --- a/packages/server/src/engine/audit-logger.ts +++ b/packages/server/src/engine/audit-logger.ts @@ -4,7 +4,8 @@ import type { MiddlewareHandler } from "hono"; import type { AppEnv } from "../env.js"; import type { AuthStorage, AuditStorage } from "../storage/index.js"; import { resolveAuditStorage, resolveContextStorage } from "../storage/index.js"; -import { decodeBase64UrlJson, verifyHs256Signature } from "../lib/jwt.js"; +import { decodeBase64UrlJson } from "../lib/jwt.js"; +import { verifyRs256Token } from "../lib/token-verifier.js"; export type ExtendedAuditAction = | AuditAction @@ -23,11 +24,6 @@ type TokenValidationResult = | { ok: true; claims: RelayAuthTokenClaims } | { ok: false; claims?: RelayAuthTokenClaims; reason: string }; -type JwtHeader = { - alg?: string; - typ?: string; -}; - const AUDIT_ACTIONS = new Set([ "token.issued", "token.refreshed", @@ -116,7 +112,7 @@ export function createAuditMiddleware(): MiddlewareHandler { const authorization = c.req.header("Authorization"); if (authorization) { - const validation = await validateAuthorizationHeader(authorization, c.env.SIGNING_KEY); + const validation = await validateAuthorizationHeader(authorization, c.env); if (validation.claims) { const { claims } = validation; const metadata: Record = { @@ -240,7 +236,7 @@ function validateOptionalString(value: unknown): string | undefined { async function validateAuthorizationHeader( authorization: string, - signingKey: string, + env: AppEnv["Bindings"], ): Promise { const [scheme, token] = authorization.split(/\s+/, 2); if (scheme !== "Bearer" || !token) { @@ -252,39 +248,18 @@ async function validateAuthorizationHeader( return { ok: false, reason: "invalid_token_shape" }; } - const [encodedHeader, encodedPayload, signature] = parts; - const header = decodeBase64UrlJson(encodedHeader); + const [, encodedPayload] = parts; const claims = decodeBase64UrlJson(encodedPayload); - if (!header || !claims || header.alg !== "HS256") { - return { ok: false, claims: claims ?? undefined, reason: "invalid_token_header" }; - } - - const isValidSignature = await verifyHs256Signature( - `${encodedHeader}.${encodedPayload}`, - signature, - signingKey, - ); - if (!isValidSignature) { - return { ok: false, claims, reason: "invalid_token_signature" }; - } - const now = Math.floor(Date.now() / 1000); - if (typeof claims.exp !== "number" || claims.exp <= now) { - return { ok: false, claims, reason: "token_expired" }; - } - - if ( - typeof claims.sub !== "string" || - typeof claims.org !== "string" || - typeof claims.wks !== "string" || - typeof claims.sponsorId !== "string" || - !Array.isArray(claims.sponsorChain) || - typeof claims.jti !== "string" - ) { - return { ok: false, claims, reason: "invalid_token_claims" }; + try { + const verifiedClaims = await verifyRs256Token(token, env); + return { ok: true, claims: verifiedClaims }; + } catch { + if (!claims) { + return { ok: false, reason: "invalid_token_claims" }; + } + return { ok: false, claims, reason: "invalid_token" }; } - - return { ok: true, claims }; } function extractClientIp( diff --git a/packages/server/src/entrypoints/node.ts b/packages/server/src/entrypoints/node.ts index 207f2c5..000c1e3 100644 --- a/packages/server/src/entrypoints/node.ts +++ b/packages/server/src/entrypoints/node.ts @@ -1,21 +1,7 @@ -import type { StartServerOptions } from "../server.js"; import { startServer } from "../server.js"; -export type StartLocalServerOptions = StartServerOptions & { - signingKey?: string; -}; +export type { StartServerOptions as StartLocalServerOptions } from "../server.js"; -/** - * Backwards-compatible entry point. Accepts the old `signingKey` option - * and maps it to the new config-based API. - */ -export function startLocalServer(opts: StartLocalServerOptions = {}) { - const { signingKey, ...rest } = opts; - return startServer({ - ...rest, - config: { - ...rest.config, - ...(signingKey !== undefined ? { SIGNING_KEY: signingKey } : {}), - }, - }); +export function startLocalServer(opts: import("../server.js").StartServerOptions = {}) { + return startServer(opts); } diff --git a/packages/server/src/env.ts b/packages/server/src/env.ts index bb8fa66..4c14e1b 100644 --- a/packages/server/src/env.ts +++ b/packages/server/src/env.ts @@ -2,12 +2,9 @@ import type { RelayAuthTokenClaims } from "@relayauth/types"; import type { AuthStorage } from "./storage/index.js"; export type AppConfig = { - SIGNING_KEY: string; - SIGNING_KEY_ID: string; INTERNAL_SECRET: string; BASE_URL?: string; ALLOWED_ORIGINS?: string; - RELAYAUTH_SIGNING_ALG?: string; RELAYAUTH_SIGNING_KEY_PEM?: string; RELAYAUTH_SIGNING_KEY_PEM_PUBLIC?: string; RELAYAUTH_ENV_STAGE?: string; diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index a0cabd6..f92e106 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -3,21 +3,13 @@ import { parseScope } from "@relayauth/sdk"; import type { Context } from "hono"; import { hashApiKey } from "./api-keys.js"; -import { decodeBase64UrlJson, verifyHs256Signature } from "./jwt.js"; +import { decodeBase64UrlJson } from "./jwt.js"; import { emitObserverEvent, now as observerNow } from "./events.js"; +import { verifyRs256Token } from "./token-verifier.js"; import type { AppEnv } from "../env.js"; import type { AuthStorage, ApiKeyStorage } from "../storage/index.js"; import type { StoredApiKey } from "../storage/api-key-types.js"; -// NOTE: This module duplicates some JWT verification logic from @relayauth/core TokenVerifier. -// This is intentional: core uses asymmetric JWKS (RS256/EdDSA) while this uses symmetric HMAC (HS256). -// TODO: Extract shared claims validation into @relayauth/core to reduce duplication. - -type JwtHeader = { - alg?: string; - typ?: string; -}; - type AuthenticateFailure = { ok: false; error: string; @@ -33,7 +25,7 @@ type AuthenticateSuccess = { export async function authenticate( authorization: string | undefined, - signingKey: string, + env: AppEnv["Bindings"], ): Promise< | AuthenticateSuccess | AuthenticateFailure @@ -49,7 +41,7 @@ export async function authenticate( return { ok: false, error: "Invalid Authorization header", code: "invalid_authorization", status: 401 }; } - const claims = await verifyToken(token, signingKey); + const claims = await verifyToken(token, env); if (!claims) { return { ok: false, error: "Invalid access token", code: "invalid_token", status: 401 }; } @@ -59,30 +51,30 @@ export async function authenticate( export async function authenticateBearerOrApiKey( request: Request, - signingKey: string, + env: AppEnv["Bindings"], storage: ApiKeyStorage | AuthStorage, ): Promise; export async function authenticateBearerOrApiKey( authorization: string | undefined, apiKey: string | undefined, - signingKey: string, + env: AppEnv["Bindings"], storage: ApiKeyStorage | AuthStorage, ): Promise; export async function authenticateBearerOrApiKey( requestOrAuthorization: Request | string | undefined, - apiKeyOrSigningKey: string | undefined, - signingKeyOrStorage: string | ApiKeyStorage | AuthStorage, + apiKeyOrEnv: string | AppEnv["Bindings"] | undefined, + envOrStorage: AppEnv["Bindings"] | ApiKeyStorage | AuthStorage, maybeStorage?: ApiKeyStorage | AuthStorage, ): Promise { - const { authorization, apiKey, signingKey, storage } = resolveBearerOrApiKeyArgs( + const { authorization, apiKey, env, storage } = resolveBearerOrApiKeyArgs( requestOrAuthorization, - apiKeyOrSigningKey, - signingKeyOrStorage, + apiKeyOrEnv, + envOrStorage, maybeStorage, ); const apiKeyStorage = resolveApiKeyStorage(storage); const bearerAuth = authorization - ? await authenticate(authorization, signingKey) + ? await authenticate(authorization, env) : null; if (bearerAuth?.ok) { @@ -133,14 +125,14 @@ export async function authenticateBearerOrApiKey( export async function authenticateAndAuthorize( authorization: string | undefined, - signingKey: string, + env: AppEnv["Bindings"], requiredScope: string, matchScopeFn: (required: string, granted: string[]) => boolean, ): Promise< | { ok: true; claims: RelayAuthTokenClaims } | { ok: false; error: string; code: string; status: 401 | 403 } > { - const auth = await authenticate(authorization, signingKey); + const auth = await authenticate(authorization, env); if (!auth.ok) { return auth; } @@ -161,14 +153,13 @@ export async function authenticateAndAuthorize( */ export async function authenticateFromContext( c: Context, - signingKey: string, ): Promise { const apiKeyClaims = c.get("apiKeyClaims"); if (apiKeyClaims) { return { ok: true, claims: apiKeyClaims, via: "api_key" }; } - return authenticate(c.req.header("authorization"), signingKey); + return authenticate(c.req.header("authorization"), c.env); } /** @@ -177,14 +168,13 @@ export async function authenticateFromContext( */ export async function authenticateAndAuthorizeFromContext( c: Context, - signingKey: string, requiredScope: string, matchScopeFn: (required: string, granted: string[]) => boolean, ): Promise< | { ok: true; claims: RelayAuthTokenClaims } | { ok: false; error: string; code: string; status: 401 | 403 } > { - const auth = await authenticateFromContext(c, signingKey); + const auth = await authenticateFromContext(c); if (!auth.ok) { return auth; } @@ -217,51 +207,24 @@ export function authorizeClaims( return { ok: true, claims }; } -async function verifyToken(token: string, signingKey: string): Promise { +async function verifyToken(token: string, env: AppEnv["Bindings"]): Promise { const parts = token.split("."); if (parts.length !== 3) { emitTokenInvalid("malformed_token"); return null; } - const [encodedHeader, encodedPayload, signature] = parts; - const header = decodeBase64UrlJson(encodedHeader); + const [, encodedPayload] = parts; const payload = decodeBase64UrlJson(encodedPayload); - if (!header || !payload || header.alg !== "HS256") { - emitTokenInvalid("invalid_header", payload); - return null; - } - - const isValidSignature = await verifyHs256Signature( - `${encodedHeader}.${encodedPayload}`, - signature, - signingKey, - ); - if (!isValidSignature) { - emitTokenInvalid("invalid_signature", payload); - return null; - } - - const now = Math.floor(Date.now() / 1000); - if (typeof payload.exp !== "number" || payload.exp <= now) { - emitTokenInvalid("token_expired", payload); - return null; - } - if ( - typeof payload.sub !== "string" || - typeof payload.org !== "string" || - typeof payload.wks !== "string" || - typeof payload.sponsorId !== "string" || - !Array.isArray(payload.sponsorChain) || - !Array.isArray(payload.scopes) - ) { - emitTokenInvalid("invalid_claims", payload); + try { + const claims = await verifyRs256Token(token, env); + emitTokenVerified(claims, Math.floor(Date.now() / 1000)); + return claims; + } catch { + emitTokenInvalid("invalid_token", payload); return null; } - - emitTokenVerified(payload, now); - return payload; } function emitTokenVerified(claims: RelayAuthTokenClaims, nowSeconds: number): void { @@ -378,28 +341,28 @@ export { decodeBase64UrlJson } from "./jwt.js"; function resolveBearerOrApiKeyArgs( requestOrAuthorization: Request | string | undefined, - apiKeyOrSigningKey: string | undefined, - signingKeyOrStorage: string | ApiKeyStorage | AuthStorage, + apiKeyOrEnv: string | AppEnv["Bindings"] | undefined, + envOrStorage: AppEnv["Bindings"] | ApiKeyStorage | AuthStorage, maybeStorage?: ApiKeyStorage | AuthStorage, ): { authorization: string | undefined; apiKey: string | undefined; - signingKey: string; + env: AppEnv["Bindings"]; storage: ApiKeyStorage | AuthStorage; } { if (requestOrAuthorization instanceof Request) { return { authorization: requestOrAuthorization.headers.get("authorization") ?? undefined, apiKey: requestOrAuthorization.headers.get("x-api-key") ?? undefined, - signingKey: apiKeyOrSigningKey ?? "", - storage: signingKeyOrStorage as ApiKeyStorage | AuthStorage, + env: apiKeyOrEnv as AppEnv["Bindings"], + storage: envOrStorage as ApiKeyStorage | AuthStorage, }; } return { authorization: requestOrAuthorization, - apiKey: apiKeyOrSigningKey, - signingKey: signingKeyOrStorage as string, + apiKey: apiKeyOrEnv as string | undefined, + env: envOrStorage as AppEnv["Bindings"], storage: maybeStorage as ApiKeyStorage | AuthStorage, }; } diff --git a/packages/server/src/lib/jwt.ts b/packages/server/src/lib/jwt.ts index 840ec8a..1f6b530 100644 --- a/packages/server/src/lib/jwt.ts +++ b/packages/server/src/lib/jwt.ts @@ -1,8 +1,3 @@ -/** - * Shared JWT utility functions for base64url decoding and HS256 signature verification. - * Centralized here to ensure security fixes propagate to all consumers. - */ - export function decodeBase64Url(value: string): string { const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "="); @@ -25,28 +20,3 @@ export function decodeBase64UrlToBytes(value: string): Uint8Array { } return bytes; } - -export async function verifyHs256Signature( - value: string, - signature: string, - signingKey: string, -): Promise { - try { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(signingKey), - { name: "HMAC", hash: "SHA-256" }, - false, - ["verify"], - ); - - return crypto.subtle.verify( - "HMAC", - key, - decodeBase64UrlToBytes(signature).buffer as ArrayBuffer, - new TextEncoder().encode(value), - ); - } catch { - return false; - } -} diff --git a/packages/server/src/lib/sign.ts b/packages/server/src/lib/sign.ts index 92996c4..9ae6e14 100644 --- a/packages/server/src/lib/sign.ts +++ b/packages/server/src/lib/sign.ts @@ -1,66 +1,21 @@ import type { RelayAuthTokenClaims } from "@relayauth/types"; import type { AppConfig } from "../env.js"; import { rsaPublicJwkFromPem } from "./jwk.js"; -import { - encodeBytesAsBase64Url, - encodeJsonAsBase64Url, - keyIdFromPublicJwk, - signRs256, -} from "./sign-rs256.js"; +import { keyIdFromPublicJwk, signRs256 } from "./sign-rs256.js"; type SigningEnv = Pick< AppConfig, - | "SIGNING_KEY" - | "SIGNING_KEY_ID" - | "RELAYAUTH_SIGNING_ALG" - | "RELAYAUTH_SIGNING_KEY_PEM" - | "RELAYAUTH_SIGNING_KEY_PEM_PUBLIC" + "RELAYAUTH_SIGNING_KEY_PEM" | "RELAYAUTH_SIGNING_KEY_PEM_PUBLIC" >; -const textEncoder = new TextEncoder(); - export async function signToken(claims: RelayAuthTokenClaims, env: SigningEnv): Promise { - const algorithm = normalizeSigningAlgorithm(env.RELAYAUTH_SIGNING_ALG); - - if (algorithm === "RS256") { - const privateKeyPem = resolveRs256PrivateKeyPem(env); - if (!privateKeyPem) { - throw new Error("RELAYAUTH_SIGNING_KEY_PEM must be set when RELAYAUTH_SIGNING_ALG=RS256"); - } - - const kid = await resolveRs256KeyId(env); - return signRs256(claims, privateKeyPem, kid); - } - - return signHs256(claims, env); -} - -async function signHs256(claims: RelayAuthTokenClaims, env: SigningEnv): Promise { - const signingKey = env.SIGNING_KEY?.trim(); - if (!signingKey) { - throw new Error("SIGNING_KEY must be set when RELAYAUTH_SIGNING_ALG=HS256"); + const privateKeyPem = resolveRs256PrivateKeyPem(env); + if (!privateKeyPem) { + throw new Error("RELAYAUTH_SIGNING_KEY_PEM must be set"); } - const encodedHeader = encodeJsonAsBase64Url({ - alg: "HS256", - typ: "JWT", - kid: env.SIGNING_KEY_ID, - }); - const encodedPayload = encodeJsonAsBase64Url(claims); - const signingInput = `${encodedHeader}.${encodedPayload}`; - const key = await crypto.subtle.importKey( - "raw", - textEncoder.encode(signingKey), - { - name: "HMAC", - hash: "SHA-256", - }, - false, - ["sign"], - ); - const signature = await crypto.subtle.sign("HMAC", key, textEncoder.encode(signingInput)); - - return `${signingInput}.${encodeBytesAsBase64Url(signature)}`; + const kid = await resolveRs256KeyId(env); + return signRs256(claims, privateKeyPem, kid); } async function resolveRs256KeyId(env: SigningEnv): Promise { @@ -70,7 +25,7 @@ async function resolveRs256KeyId(env: SigningEnv): Promise { return keyIdFromPublicJwk(jwk); } - return env.SIGNING_KEY_ID?.trim() || "rs256-key"; + return "rs256-key"; } function resolveRs256PrivateKeyPem(env: SigningEnv): string | undefined { @@ -81,12 +36,3 @@ function resolveRs256PrivateKeyPem(env: SigningEnv): string | undefined { return globalThis.process?.env?.RELAYAUTH_SIGNING_KEY_PEM?.trim(); } - -function normalizeSigningAlgorithm(value: string | undefined): "HS256" | "RS256" { - const normalized = value?.trim().toUpperCase() || "HS256"; - if (normalized === "HS256" || normalized === "RS256") { - return normalized; - } - - throw new Error(`Unsupported signing algorithm: ${value}`); -} diff --git a/packages/server/src/lib/token-verifier.ts b/packages/server/src/lib/token-verifier.ts new file mode 100644 index 0000000..dc18eac --- /dev/null +++ b/packages/server/src/lib/token-verifier.ts @@ -0,0 +1,45 @@ +import type { RelayAuthTokenClaims } from "@relayauth/types"; +import { TokenVerifier, type VerifyOptions } from "@relayauth/sdk"; +import type { AppConfig } from "../env.js"; +import { rsaPublicJwkFromPem } from "./jwk.js"; +import { keyIdFromPublicJwk } from "./sign-rs256.js"; + +type VerifierEnv = Pick; +type VerifyTokenOptions = Omit; + +export async function verifyRs256Token( + token: string, + env: VerifierEnv, + options: VerifyTokenOptions = {}, +): Promise { + const verifier = new TokenVerifier({ + ...options, + jwksUrl: await resolveJwksUrl(env), + }); + + return verifier.verify(token); +} + +async function resolveJwksUrl(env: VerifierEnv): Promise { + const baseUrl = env.BASE_URL?.trim(); + if (baseUrl) { + return new URL("/.well-known/jwks.json", baseUrl).toString(); + } + + const publicKeyPem = env.RELAYAUTH_SIGNING_KEY_PEM_PUBLIC?.trim(); + if (!publicKeyPem) { + return "http://127.0.0.1:8787/.well-known/jwks.json"; + } + + const keyWithPlaceholderKid = await rsaPublicJwkFromPem(publicKeyPem, ""); + const jwks = { + keys: [ + { + ...keyWithPlaceholderKid, + kid: await keyIdFromPublicJwk(keyWithPlaceholderKid), + }, + ], + }; + + return `data:application/json,${encodeURIComponent(JSON.stringify(jwks))}`; +} diff --git a/packages/server/src/middleware/api-key-auth.ts b/packages/server/src/middleware/api-key-auth.ts index 6f726ec..4fdaf10 100644 --- a/packages/server/src/middleware/api-key-auth.ts +++ b/packages/server/src/middleware/api-key-auth.ts @@ -26,7 +26,7 @@ export function apiKeyAuth(): MiddlewareHandler { const auth = await authenticateBearerOrApiKey( c.req.header("authorization"), apiKey, - c.env.SIGNING_KEY, + c.env, c.get("storage"), ); if (!auth.ok) { diff --git a/packages/server/src/middleware/scope.ts b/packages/server/src/middleware/scope.ts index 79c42a3..1be7529 100644 --- a/packages/server/src/middleware/scope.ts +++ b/packages/server/src/middleware/scope.ts @@ -4,7 +4,8 @@ import type { Context, MiddlewareHandler } from "hono"; import type { AppEnv } from "../env.js"; import { emitObserverEvent, now as observerNow } from "../lib/events.js"; -import { decodeBase64UrlJson, verifyHs256Signature } from "../lib/jwt.js"; +import { decodeBase64UrlJson } from "../lib/jwt.js"; +import { verifyRs256Token } from "../lib/token-verifier.js"; export type ScopeMiddlewareOptions = { onError?: (error: Error) => Response | Promise | void | Promise; @@ -17,12 +18,6 @@ type ScopeContextVariables = { scopeChecker: ScopeChecker; }; -type JwtHeader = { - alg?: string; - kid?: string; - typ?: string; -}; - export function requireScope( scope: string, options?: ScopeMiddlewareOptions, @@ -58,9 +53,9 @@ function createScopeMiddleware( const apiKeyClaims = c.get("apiKeyClaims"); const claims = apiKeyClaims ? apiKeyClaims - : await verifyHs256Token( + : await verifyBearerToken( extractBearerToken(c.req.header("Authorization")), - c.env.SIGNING_KEY, + c.env, ); const scopeChecker = ScopeChecker.fromToken(claims); @@ -182,9 +177,9 @@ function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } -async function verifyHs256Token( +async function verifyBearerToken( token: string, - signingKey: string, + env: AppEnv["Bindings"], ): Promise { const parts = token.split("."); if (parts.length !== 3) { @@ -192,43 +187,17 @@ async function verifyHs256Token( throw new RelayAuthError("Invalid access token", "invalid_token", 401); } - const [encodedHeader, encodedPayload, signature] = parts; - const header = decodeBase64UrlJson(encodedHeader); + const [, encodedPayload] = parts; const payload = decodeBase64UrlJson(encodedPayload); - if (!header || !payload || header.alg !== "HS256") { - emitTokenInvalid("invalid_header", payload); - throw new RelayAuthError("Invalid access token", "invalid_token", 401); - } - const isValid = await verifyHs256Signature( - `${encodedHeader}.${encodedPayload}`, - signature, - signingKey, - ); - if (!isValid) { - emitTokenInvalid("invalid_signature", payload); - throw new RelayAuthError("Invalid access token", "invalid_token", 401); - } - - const now = Math.floor(Date.now() / 1000); - if (typeof payload.exp !== "number" || payload.exp <= now) { - emitTokenInvalid("token_expired", payload); - throw new RelayAuthError("Token expired", "token_expired", 401); - } - - if ( - typeof payload.sub !== "string" || - typeof payload.org !== "string" || - typeof payload.wks !== "string" || - typeof payload.sponsorId !== "string" || - !Array.isArray(payload.sponsorChain) - ) { - emitTokenInvalid("invalid_claims", payload); + try { + const claims = await verifyRs256Token(token, env); + emitTokenVerified(claims, Math.floor(Date.now() / 1000)); + return claims; + } catch { + emitTokenInvalid("invalid_token", payload); throw new RelayAuthError("Invalid access token", "invalid_token", 401); } - - emitTokenVerified(payload, now); - return payload; } function emitTokenVerified(claims: RelayAuthTokenClaims, nowSeconds: number): void { diff --git a/packages/server/src/routes/api-keys.ts b/packages/server/src/routes/api-keys.ts index 5706847..44fa68e 100644 --- a/packages/server/src/routes/api-keys.ts +++ b/packages/server/src/routes/api-keys.ts @@ -27,7 +27,7 @@ const apiKeys = new Hono(); apiKeys.post("/", async (c) => { const auth = await authenticateAndAuthorize( c.req.header("authorization"), - c.env.SIGNING_KEY, + c.env, "relayauth:api-key:manage:*", matchScope, ); @@ -96,7 +96,7 @@ apiKeys.post("/", async (c) => { apiKeys.get("/", async (c) => { const auth = await authenticateAndAuthorize( c.req.header("authorization"), - c.env.SIGNING_KEY, + c.env, "relayauth:api-key:read:*", matchScope, ); @@ -135,7 +135,7 @@ apiKeys.get("/", async (c) => { apiKeys.post("/:apiKeyId/revoke", async (c) => { const auth = await authenticateAndAuthorize( c.req.header("authorization"), - c.env.SIGNING_KEY, + c.env, "relayauth:api-key:manage:*", matchScope, ); diff --git a/packages/server/src/routes/identities.ts b/packages/server/src/routes/identities.ts index b183d1a..8b9d5fa 100644 --- a/packages/server/src/routes/identities.ts +++ b/packages/server/src/routes/identities.ts @@ -128,7 +128,6 @@ const INSERT_AUDIT_EVENT_SQL = ` identities.get("/", async (c) => { const auth = await authenticateAndAuthorizeFromContext( c, - c.env.SIGNING_KEY, "relayauth:identity:read:*", matchScope, ); @@ -163,7 +162,6 @@ identities.get("/", async (c) => { identities.get("/:id", async (c) => { const auth = await authenticateAndAuthorizeFromContext( c, - c.env.SIGNING_KEY, "relayauth:identity:read:*", matchScope, ); @@ -487,7 +485,7 @@ async function authenticateBearerOrApiKeyAndAuthorize( const auth = await authenticateBearerOrApiKey( c.req.raw, - c.env.SIGNING_KEY, + c.env, c.get("storage"), ); if (!auth.ok) { diff --git a/packages/server/src/routes/jwks.ts b/packages/server/src/routes/jwks.ts index 4d0e177..d6db5e4 100644 --- a/packages/server/src/routes/jwks.ts +++ b/packages/server/src/routes/jwks.ts @@ -19,22 +19,6 @@ const jwks = new Hono(); jwks.get("/jwks.json", async (c) => { const keys: PublishedJwk[] = []; - // HS256 is published as algorithm metadata (kid only — never the secret; - // publishing the symmetric key would allow token forgery). It only appears - // when the deployment actually has an HS256 signing secret bound; otherwise - // the entry is misleading because no token can be signed or verified with - // an algorithm the server isn't configured for. This gate is what lets a - // deployment retire HS256 by simply unbinding SIGNING_KEY. - const hs256Secret = c.env.SIGNING_KEY?.trim(); - if (hs256Secret) { - keys.push({ - kty: "oct", - use: "sig", - alg: "HS256", - kid: c.env.SIGNING_KEY_ID, - }); - } - const rsaPublicPem = c.env.RELAYAUTH_SIGNING_KEY_PEM_PUBLIC?.trim(); if (rsaPublicPem) { const rsaKeyWithPlaceholderKid = await rsaPublicJwkFromPem(rsaPublicPem, ""); diff --git a/packages/server/src/routes/policies.ts b/packages/server/src/routes/policies.ts index dc0e733..d3b60f9 100644 --- a/packages/server/src/routes/policies.ts +++ b/packages/server/src/routes/policies.ts @@ -30,7 +30,7 @@ const policies = new Hono(); policies.post("/", async (c) => { const auth = await authenticateAndAuthorize( c.req.header("authorization"), - c.env.SIGNING_KEY, + c.env, "relayauth:policy:manage:*", matchScope, ); @@ -63,7 +63,7 @@ policies.post("/", async (c) => { policies.get("/", async (c) => { const auth = await authenticateAndAuthorize( c.req.header("authorization"), - c.env.SIGNING_KEY, + c.env, "relayauth:policy:read:*", matchScope, ); @@ -90,7 +90,7 @@ policies.get("/", async (c) => { policies.get("/:id", async (c) => { const auth = await authenticateAndAuthorize( c.req.header("authorization"), - c.env.SIGNING_KEY, + c.env, "relayauth:policy:read:*", matchScope, ); @@ -115,7 +115,7 @@ policies.get("/:id", async (c) => { policies.patch("/:id", async (c) => { const auth = await authenticateAndAuthorize( c.req.header("authorization"), - c.env.SIGNING_KEY, + c.env, "relayauth:policy:manage:*", matchScope, ); @@ -150,7 +150,7 @@ policies.patch("/:id", async (c) => { policies.delete("/:id", async (c) => { const auth = await authenticateAndAuthorize( c.req.header("authorization"), - c.env.SIGNING_KEY, + c.env, "relayauth:policy:manage:*", matchScope, ); diff --git a/packages/server/src/routes/role-assignments.ts b/packages/server/src/routes/role-assignments.ts index c81423e..f2dfd54 100644 --- a/packages/server/src/routes/role-assignments.ts +++ b/packages/server/src/routes/role-assignments.ts @@ -9,6 +9,7 @@ import { } from "../engine/role-assignments.js"; import { getRole } from "../engine/roles.js"; import { emitObserverEvent, now as observerNow } from "../lib/events.js"; +import { verifyRs256Token } from "../lib/token-verifier.js"; import type { StoredIdentity } from "../storage/identity-types.js"; import type { AuthStorage } from "../storage/index.js"; @@ -16,17 +17,12 @@ type AssignRoleRequest = { roleId?: string; }; -type JwtHeader = { - alg?: string; - typ?: string; -}; - const roleAssignments = new Hono(); roleAssignments.post("/:id/roles", async (c) => { const auth = await authenticateAndAuthorize( c, - c.env.SIGNING_KEY, + c.env, ["relayauth:identity:manage:*", "relayauth:role:manage:*"], ); if (!auth.ok) { @@ -82,7 +78,7 @@ roleAssignments.post("/:id/roles", async (c) => { roleAssignments.delete("/:id/roles/:roleId", async (c) => { const auth = await authenticateAndAuthorize( c, - c.env.SIGNING_KEY, + c.env, ["relayauth:identity:manage:*", "relayauth:role:manage:*"], ); if (!auth.ok) { @@ -134,7 +130,7 @@ roleAssignments.delete("/:id/roles/:roleId", async (c) => { roleAssignments.get("/:id/roles", async (c) => { const auth = await authenticateAndAuthorize( c, - c.env.SIGNING_KEY, + c.env, ["relayauth:identity:read:*", "relayauth:role:read:*"], ); if (!auth.ok) { @@ -164,13 +160,13 @@ export default roleAssignments; async function authenticateAndAuthorize( c: Context, - signingKey: string, + env: AppEnv["Bindings"], requiredScopes: string[], ): Promise< | { ok: true; claims: RelayAuthTokenClaims } | { ok: false; error: string; status: 401 | 403 } > { - const auth = await authenticate(c, signingKey); + const auth = await authenticate(c, env); if (!auth.ok) { return auth; } @@ -187,7 +183,7 @@ async function authenticateAndAuthorize( async function authenticate( c: Context, - signingKey: string, + env: AppEnv["Bindings"], ): Promise< | { ok: true; claims: RelayAuthTokenClaims } | { ok: false; error: string; status: 401 } @@ -212,7 +208,7 @@ async function authenticate( return { ok: false, error: "Invalid Authorization header", status: 401 }; } - const claims = await verifyToken(token, signingKey); + const claims = await verifyToken(token, env); if (!claims) { return { ok: false, error: "Invalid access token", status: 401 }; } @@ -220,50 +216,24 @@ async function authenticate( return { ok: true, claims }; } -async function verifyToken(token: string, signingKey: string): Promise { +async function verifyToken(token: string, env: AppEnv["Bindings"]): Promise { const parts = token.split("."); if (parts.length !== 3) { emitTokenInvalid("malformed_token"); return null; } - const [encodedHeader, encodedPayload, signature] = parts; - const header = decodeBase64UrlJson(encodedHeader); + const [, encodedPayload] = parts; const payload = decodeBase64UrlJson(encodedPayload); - if (!header || !payload || header.alg !== "HS256") { - emitTokenInvalid("invalid_header", payload); - return null; - } - - const isValidSignature = await verifyHs256Signature( - `${encodedHeader}.${encodedPayload}`, - signature, - signingKey, - ); - if (!isValidSignature) { - emitTokenInvalid("invalid_signature", payload); - return null; - } - - const now = Math.floor(Date.now() / 1000); - if (typeof payload.exp !== "number" || payload.exp <= now) { - emitTokenInvalid("token_expired", payload); - return null; - } - if ( - typeof payload.sub !== "string" || - typeof payload.org !== "string" || - typeof payload.wks !== "string" || - typeof payload.sponsorId !== "string" || - !Array.isArray(payload.sponsorChain) - ) { - emitTokenInvalid("invalid_claims", payload); + try { + const claims = await verifyRs256Token(token, env); + emitTokenVerified(claims, Math.floor(Date.now() / 1000)); + return claims; + } catch { + emitTokenInvalid("invalid_token", payload); return null; } - - emitTokenVerified(payload, now); - return payload; } function decodeBase64UrlJson(value: string): T | null { @@ -274,46 +244,12 @@ function decodeBase64UrlJson(value: string): T | null { } } -async function verifyHs256Signature( - value: string, - signature: string, - signingKey: string, -): Promise { - try { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(signingKey), - { name: "HMAC", hash: "SHA-256" }, - false, - ["verify"], - ); - - return crypto.subtle.verify( - "HMAC", - key, - decodeBase64UrlToBytes(signature), - new TextEncoder().encode(value), - ); - } catch { - return false; - } -} - function decodeBase64Url(value: string): string { const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "="); return atob(padded); } -function decodeBase64UrlToBytes(value: string): Uint8Array { - const decoded = decodeBase64Url(value); - const bytes = new Uint8Array(decoded.length); - for (let i = 0; i < decoded.length; i += 1) { - bytes[i] = decoded.charCodeAt(i); - } - return bytes; -} - function emitTokenVerified(claims: RelayAuthTokenClaims, nowSeconds: number): void { emitObserverEvent({ type: "token.verified", diff --git a/packages/server/src/routes/roles.ts b/packages/server/src/routes/roles.ts index 56bf328..d0f0d9f 100644 --- a/packages/server/src/routes/roles.ts +++ b/packages/server/src/routes/roles.ts @@ -26,7 +26,7 @@ const roles = new Hono(); roles.post("/", async (c) => { const auth = await authenticateAndAuthorize( c.req.header("authorization"), - c.env.SIGNING_KEY, + c.env, "relayauth:role:manage:*", matchScope, ); @@ -57,7 +57,7 @@ roles.post("/", async (c) => { roles.get("/", async (c) => { const auth = await authenticateAndAuthorize( c.req.header("authorization"), - c.env.SIGNING_KEY, + c.env, "relayauth:role:read:*", matchScope, ); @@ -84,7 +84,7 @@ roles.get("/", async (c) => { roles.get("/:id", async (c) => { const auth = await authenticateAndAuthorize( c.req.header("authorization"), - c.env.SIGNING_KEY, + c.env, "relayauth:role:read:*", matchScope, ); @@ -109,7 +109,7 @@ roles.get("/:id", async (c) => { roles.patch("/:id", async (c) => { const auth = await authenticateAndAuthorize( c.req.header("authorization"), - c.env.SIGNING_KEY, + c.env, "relayauth:role:manage:*", matchScope, ); @@ -144,7 +144,7 @@ roles.patch("/:id", async (c) => { roles.delete("/:id", async (c) => { const auth = await authenticateAndAuthorize( c.req.header("authorization"), - c.env.SIGNING_KEY, + c.env, "relayauth:role:manage:*", matchScope, ); diff --git a/packages/server/src/routes/tokens.ts b/packages/server/src/routes/tokens.ts index f161024..18ecb33 100644 --- a/packages/server/src/routes/tokens.ts +++ b/packages/server/src/routes/tokens.ts @@ -1,10 +1,11 @@ import type { RelayAuthTokenClaims, TokenPair } from "@relayauth/types"; -import { matchScope } from "@relayauth/sdk"; +import { matchScope, type VerifyOptions } from "@relayauth/sdk"; 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 { verifyRs256Token } from "../lib/token-verifier.js"; import type { StoredIdentity } from "../storage/identity-types.js"; import type { AuthStorage, RevocationStorage } from "../storage/index.js"; @@ -25,12 +26,6 @@ type RevokeTokenRequest = { sessionId?: string; }; -type JwtHeader = { - alg?: string; - typ?: string; - kid?: string; -}; - type TokenRow = { id?: string | null; token_id?: string | null; @@ -105,7 +100,6 @@ const INSERT_TOKEN_SQL = ` tokens.post("/", async (c) => { const auth = await authenticateAndAuthorizeFromContext( c, - c.env.SIGNING_KEY, "relayauth:token:create:*", matchScope, ); @@ -167,7 +161,7 @@ tokens.post("/refresh", async (c) => { } const storage = getSqlStorage(c.get("storage")); - const verification = await verifyLegacyToken(refreshToken, c.env.SIGNING_KEY); + const verification = await verifyToken(refreshToken, c.env, { audience: [REFRESH_AUDIENCE] }); if (!verification.ok) { return c.json({ error: verification.error }, 401); } @@ -236,7 +230,6 @@ tokens.post("/refresh", async (c) => { tokens.post("/revoke", async (c) => { const auth = await authenticateAndAuthorizeFromContext( c, - c.env.SIGNING_KEY, "relayauth:token:manage:*", matchScope, ); @@ -292,7 +285,6 @@ tokens.post("/revoke", async (c) => { tokens.get("/introspect", async (c) => { const auth = await authenticateAndAuthorizeFromContext( c, - c.env.SIGNING_KEY, "relayauth:token:read:*", matchScope, ); @@ -306,7 +298,7 @@ tokens.get("/introspect", async (c) => { } const storage = getSqlStorage(c.get("storage")); - const verification = await verifyLegacyToken(token, c.env.SIGNING_KEY); + const verification = await verifyToken(token, c.env); if (!verification.ok) { return c.json(null, 200); } @@ -375,8 +367,8 @@ 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); + const accessToken = await signToken(accessClaims, env); + const refreshToken = await signToken(refreshClaims, env); await persistIssuedToken(storage, identity.id, accessClaims); await persistIssuedToken(storage, identity.id, refreshClaims); @@ -557,27 +549,21 @@ async function cascadeRevokeSession( }); } -async function verifyLegacyToken( +async function verifyToken( token: string, - signingKey: string, + env: AppEnv["Bindings"], + options: Omit = {}, ): Promise< | { ok: true; claims: RelayAuthTokenClaims } | { ok: false; error: string } > { - const parts = token.split("."); - if (parts.length !== 3) { - return { ok: false, error: "Invalid token" }; - } - - const [encodedHeader, encodedPayload, signature] = parts; - const header = decodeBase64UrlJson(encodedHeader); - const claims = decodeBase64UrlJson(encodedPayload); - if (!header || !claims || header.alg !== "HS256") { - return { ok: false, error: "Invalid token" }; - } - - const validSignature = await verifyHs256Signature(`${encodedHeader}.${encodedPayload}`, signature, signingKey); - if (!validSignature) { + let claims: RelayAuthTokenClaims; + try { + claims = await verifyRs256Token(token, env, { + issuer: EXPECTED_ISSUER, + ...options, + }); + } catch { return { ok: false, error: "Invalid token" }; } @@ -636,48 +622,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))); -} - -function encodeBase64UrlBytes(bytes: Uint8Array): string { - let binary = ""; - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - - return btoa(binary) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/g, ""); -} - function normalizeScopes(value: unknown, fallback: string[]): string[] { if (!Array.isArray(value)) { return [...fallback]; diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 5244940..e6db9e2 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -35,8 +35,6 @@ export type CreateAppOptions = { storage?: AuthStorage; config?: Partial; defaultBindings?: Partial; - signingKey?: string; - signingKeyId?: string; internalSecret?: string; baseUrl?: string; allowedOrigins?: string; @@ -57,8 +55,6 @@ function normalizeConfig(options: CreateAppOptions): Partial { return { ...(options.defaultBindings ?? {}), ...(options.config ?? {}), - ...(options.signingKey !== undefined ? { SIGNING_KEY: options.signingKey } : {}), - ...(options.signingKeyId !== undefined ? { SIGNING_KEY_ID: options.signingKeyId } : {}), ...(options.internalSecret !== undefined ? { INTERNAL_SECRET: options.internalSecret } : {}), ...(options.baseUrl !== undefined ? { BASE_URL: options.baseUrl } : {}), ...(options.allowedOrigins !== undefined ? { ALLOWED_ORIGINS: options.allowedOrigins } : {}), @@ -214,12 +210,11 @@ export async function startServer(options: StartServerOptions = {}) { ? storage.INTERNAL_SECRET : "internal-test-secret"); const config: AppConfig = { - SIGNING_KEY: options.config?.SIGNING_KEY ?? process.env.SIGNING_KEY ?? "dev-secret", - SIGNING_KEY_ID: options.config?.SIGNING_KEY_ID ?? process.env.SIGNING_KEY_ID ?? "dev-key", INTERNAL_SECRET: internalSecret, BASE_URL: options.config?.BASE_URL ?? process.env.BASE_URL ?? `http://127.0.0.1:${port}`, ALLOWED_ORIGINS: options.config?.ALLOWED_ORIGINS ?? process.env.ALLOWED_ORIGINS, - RELAYAUTH_SIGNING_ALG: options.config?.RELAYAUTH_SIGNING_ALG ?? process.env.RELAYAUTH_SIGNING_ALG, + RELAYAUTH_SIGNING_KEY_PEM: + options.config?.RELAYAUTH_SIGNING_KEY_PEM ?? process.env.RELAYAUTH_SIGNING_KEY_PEM, RELAYAUTH_SIGNING_KEY_PEM_PUBLIC: options.config?.RELAYAUTH_SIGNING_KEY_PEM_PUBLIC ?? process.env.RELAYAUTH_SIGNING_KEY_PEM_PUBLIC, RELAYAUTH_ENV_STAGE: options.config?.RELAYAUTH_ENV_STAGE ?? process.env.RELAYAUTH_ENV_STAGE, diff --git a/packages/server/src/storage/sqlite.ts b/packages/server/src/storage/sqlite.ts index 31eb2c2..5ce9c6d 100644 --- a/packages/server/src/storage/sqlite.ts +++ b/packages/server/src/storage/sqlite.ts @@ -84,8 +84,6 @@ export type SqliteStorage = AuthStorage & { /** D1-compatible shim for test helpers — wraps better-sqlite3 in D1's async API */ DB: D1Shim; INTERNAL_SECRET: string; - SIGNING_KEY?: string; - SIGNING_KEY_ID?: string; BASE_URL?: string; ALLOWED_ORIGINS?: string; close(): Promise | void; @@ -821,8 +819,6 @@ export function createSqliteStorage(dbPath?: string): SqliteStorage { }), DB, INTERNAL_SECRET: DEFAULT_INTERNAL_SECRET, - SIGNING_KEY: undefined, - SIGNING_KEY_ID: undefined, BASE_URL: undefined, ALLOWED_ORIGINS: undefined, close: () => provider.close(), From 7dad1f199f06071044efad8709aa641675e3d8e4 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 24 Apr 2026 11:24:07 +0200 Subject: [PATCH 2/3] test(server): migrate fixtures to RS256 --- .../server/src/__tests__/audit-logger.test.ts | 6 +- .../__tests__/auth-bearer-or-apikey.test.ts | 27 ++-- .../src/__tests__/create-identity.test.ts | 19 +-- .../src/__tests__/dev-environment.test.ts | 20 ++- .../src/__tests__/discovery-bridge.test.ts | 24 +++- .../server/src/__tests__/e2e/audit.test.ts | 4 + .../server/src/__tests__/e2e/rbac.test.ts | 2 +- .../__tests__/e2e/sdk-verification.test.ts | 21 ++-- .../server/src/__tests__/jwks-rsa.test.ts | 77 +----------- .../src/__tests__/scope-middleware.test.ts | 6 +- .../server/src/__tests__/sign-rs256.test.ts | 50 ++------ packages/server/src/__tests__/test-helpers.ts | 36 ++++-- .../server/src/__tests__/tokens-route.test.ts | 115 ++++++------------ packages/server/src/routes/tokens.ts | 9 +- scripts/generate-dev-token.sh | 13 +- 15 files changed, 169 insertions(+), 260 deletions(-) diff --git a/packages/server/src/__tests__/audit-logger.test.ts b/packages/server/src/__tests__/audit-logger.test.ts index e7cfdf3..e01df11 100644 --- a/packages/server/src/__tests__/audit-logger.test.ts +++ b/packages/server/src/__tests__/audit-logger.test.ts @@ -8,6 +8,8 @@ import { createSqliteStorage } from "../storage/sqlite.js"; import { createTestRequest, generateTestToken, + TEST_RS256_PRIVATE_KEY_PEM, + TEST_RS256_PUBLIC_KEY_PEM, } from "./test-helpers.js"; type ExtendedAuditAction = @@ -51,9 +53,9 @@ function normalizeSql(query: string): string { function createBindings(overrides: Partial = {}): AppEnv["Bindings"] { return { - SIGNING_KEY: "dev-secret", - SIGNING_KEY_ID: "dev-key", INTERNAL_SECRET: "internal-test-secret", + RELAYAUTH_SIGNING_KEY_PEM: TEST_RS256_PRIVATE_KEY_PEM, + RELAYAUTH_SIGNING_KEY_PEM_PUBLIC: TEST_RS256_PUBLIC_KEY_PEM, ...overrides, }; } diff --git a/packages/server/src/__tests__/auth-bearer-or-apikey.test.ts b/packages/server/src/__tests__/auth-bearer-or-apikey.test.ts index 6ddcfea..17dba15 100644 --- a/packages/server/src/__tests__/auth-bearer-or-apikey.test.ts +++ b/packages/server/src/__tests__/auth-bearer-or-apikey.test.ts @@ -2,7 +2,12 @@ import assert from "node:assert/strict"; import crypto from "node:crypto"; import test from "node:test"; import type { RelayAuthTokenClaims } from "@relayauth/types"; -import { generateTestToken } from "./test-helpers.js"; +import type { AppEnv } from "../env.js"; +import { + generateTestToken, + TEST_RS256_PRIVATE_KEY_PEM, + TEST_RS256_PUBLIC_KEY_PEM, +} from "./test-helpers.js"; type StoredApiKey = { id: string; @@ -33,10 +38,16 @@ type AuthenticateSuccess = { type AuthenticateBearerOrApiKey = ( authorization: string | undefined, apiKey: string | undefined, - signingKey: string, + env: AppEnv["Bindings"], apiKeys: ApiKeyStorageLike, ) => Promise; +const TEST_BINDINGS: AppEnv["Bindings"] = { + INTERNAL_SECRET: "internal-test-secret", + RELAYAUTH_SIGNING_KEY_PEM: TEST_RS256_PRIVATE_KEY_PEM, + RELAYAUTH_SIGNING_KEY_PEM_PUBLIC: TEST_RS256_PUBLIC_KEY_PEM, +}; + function hashApiKey(apiKey: string): string { return crypto.createHash("sha256").update(apiKey).digest("hex"); } @@ -110,7 +121,7 @@ test("changing one byte of the plaintext API key fails verification via SHA-256 const auth = await authenticateBearerOrApiKey( undefined, mutatedLastChar, - "dev-secret", + TEST_BINDINGS, storage, ); @@ -136,7 +147,7 @@ test("authenticateBearerOrApiKey accepts a valid bearer JWT and returns its clai const auth = await authenticateBearerOrApiKey( authorization, undefined, - "dev-secret", + TEST_BINDINGS, storage, ); @@ -159,7 +170,7 @@ test("authenticateBearerOrApiKey accepts a valid x-api-key and returns synthesiz const auth = await authenticateBearerOrApiKey( undefined, plaintext, - "dev-secret", + TEST_BINDINGS, storage, ); @@ -178,7 +189,7 @@ test("authenticateBearerOrApiKey returns 401 when neither bearer nor x-api-key i const auth = await authenticateBearerOrApiKey( undefined, undefined, - "dev-secret", + TEST_BINDINGS, storage, ); @@ -206,7 +217,7 @@ test("when both are present and conflict, a valid bearer wins and API-key lookup const auth = await authenticateBearerOrApiKey( authorization, "rak_test_auth_plaintext_fixture", - "dev-secret", + TEST_BINDINGS, storage, ); @@ -227,7 +238,7 @@ test("authenticateBearerOrApiKey updates last_used_at when an API key authentica const auth = await authenticateBearerOrApiKey( undefined, "rak_test_auth_plaintext_fixture", - "dev-secret", + TEST_BINDINGS, storage, ); diff --git a/packages/server/src/__tests__/create-identity.test.ts b/packages/server/src/__tests__/create-identity.test.ts index 6d24167..b91448c 100644 --- a/packages/server/src/__tests__/create-identity.test.ts +++ b/packages/server/src/__tests__/create-identity.test.ts @@ -8,6 +8,7 @@ import { createTestApp, createTestRequest, generateTestIdentity, + generateTestToken, seedOrgBudget, seedStoredIdentity, } from "./test-helpers.js"; @@ -25,22 +26,6 @@ type CreatedIdentity = AgentIdentity & { budget?: IdentityBudget; }; -function base64UrlEncode(value: string | Buffer): string { - return Buffer.from(value).toString("base64url"); -} - -function signHs256(payload: Record, secret: string): string { - const header = { alg: "HS256", typ: "JWT" }; - const encodedHeader = base64UrlEncode(JSON.stringify(header)); - const encodedPayload = base64UrlEncode(JSON.stringify(payload)); - const unsigned = `${encodedHeader}.${encodedPayload}`; - const signature = crypto - .createHmac("sha256", secret) - .update(unsigned) - .digest("base64url"); - return `${unsigned}.${signature}`; -} - function createAuthToken(overrides: Partial = {}): string { const now = Math.floor(Date.now() / 1000); const sponsorId = overrides.sponsorId ?? "user_sponsor_1"; @@ -66,7 +51,7 @@ function createAuthToken(overrides: Partial = {}): string budget: overrides.budget, }; - return signHs256(payload as Record, "dev-secret"); + return generateTestToken(payload); } diff --git a/packages/server/src/__tests__/dev-environment.test.ts b/packages/server/src/__tests__/dev-environment.test.ts index 5ad8820..c81ec14 100644 --- a/packages/server/src/__tests__/dev-environment.test.ts +++ b/packages/server/src/__tests__/dev-environment.test.ts @@ -79,10 +79,16 @@ test("seed data script creates valid test identities", async () => { test("dev token generator produces valid JWT structure", async () => { await access(paths.tokenScript); + const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); + const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); const token = execFileSync("bash", [paths.tokenScript], { cwd: repoRoot, encoding: "utf8", + env: { + ...process.env, + RELAYAUTH_SIGNING_KEY_PEM: privateKeyPem, + }, }).trim(); const parts = token.split("."); @@ -92,14 +98,16 @@ test("dev token generator produces valid JWT structure", async () => { const header = decodeJwtPart(encodedHeader); const payload = decodeJwtPart(encodedPayload); - assert.deepEqual(header, { alg: "HS256", typ: "JWT", kid: "dev-key" }); + assert.deepEqual(header, { alg: "RS256", typ: "JWT" }); assert.match(signature, /^[A-Za-z0-9_-]+$/, "JWT signature should be base64url encoded"); - const expectedSignature = crypto - .createHmac("sha256", "dev-secret") - .update(`${encodedHeader}.${encodedPayload}`) - .digest("base64url"); - assert.equal(signature, expectedSignature, "JWT should be signed with the dev secret"); + const validSignature = crypto.verify( + "RSA-SHA256", + Buffer.from(`${encodedHeader}.${encodedPayload}`), + publicKey, + Buffer.from(signature, "base64url"), + ); + assert.equal(validSignature, true, "JWT should be signed with the configured RSA private key"); assert.equal(payload.sub, "agent_dev_admin"); assert.equal(payload.org, "org_dev"); diff --git a/packages/server/src/__tests__/discovery-bridge.test.ts b/packages/server/src/__tests__/discovery-bridge.test.ts index 7427fe7..bacf0d7 100644 --- a/packages/server/src/__tests__/discovery-bridge.test.ts +++ b/packages/server/src/__tests__/discovery-bridge.test.ts @@ -40,6 +40,9 @@ test("POST /v1/discovery/bridge fetches an external agent card and returns Agent const originalFetch = globalThis.fetch; globalThis.fetch = (async (input: RequestInfo | URL) => { const url = input instanceof URL ? input.toString() : input.toString(); + if (url.startsWith("data:")) { + return originalFetch(input); + } assert.equal(url, "https://agent.example.com/.well-known/agent-card.json"); return new Response( @@ -105,6 +108,10 @@ test("POST /v1/discovery/bridge blocks redirects to private hosts before followi globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { calls += 1; const url = input instanceof URL ? input.toString() : input.toString(); + if (url.startsWith("data:")) { + calls -= 1; + return originalFetch(input, init); + } if (calls === 1) { assert.equal(url, "https://agent.example.com/.well-known/agent-card.json"); @@ -138,11 +145,16 @@ test("POST /v1/discovery/bridge blocks redirects to private hosts before followi test("POST /v1/discovery/bridge returns 422 for invalid agent cards", async () => { const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => - new Response(JSON.stringify({ url: "https://agent.example.com/rpc" }), { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = input instanceof URL ? input.toString() : input.toString(); + if (url.startsWith("data:")) { + return originalFetch(input); + } + return new Response(JSON.stringify({ url: "https://agent.example.com/rpc" }), { status: 200, headers: { "content-type": "application/json" }, - })) as typeof fetch; + }); + }) as typeof fetch; try { const app = createTestApp(); @@ -161,7 +173,11 @@ test("POST /v1/discovery/bridge returns 422 for invalid agent cards", async () = test("POST /v1/discovery/bridge returns 502 when the upstream agent card cannot be reached", async () => { const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = input instanceof URL ? input.toString() : input.toString(); + if (url.startsWith("data:")) { + return originalFetch(input); + } throw new Error("connect ECONNREFUSED"); }) as typeof fetch; diff --git a/packages/server/src/__tests__/e2e/audit.test.ts b/packages/server/src/__tests__/e2e/audit.test.ts index 0f17a72..c4e6366 100644 --- a/packages/server/src/__tests__/e2e/audit.test.ts +++ b/packages/server/src/__tests__/e2e/audit.test.ts @@ -294,6 +294,10 @@ test("Audit & Observability E2E", async (t) => { const originalFetch = globalThis.fetch; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : input instanceof URL ? input.toString() : String(input); + if (url.startsWith("data:")) { + return originalFetch(input, init); + } const request = input instanceof Request ? input : new Request(String(input), init); const body = await request.text(); requests.push({ request, body }); diff --git a/packages/server/src/__tests__/e2e/rbac.test.ts b/packages/server/src/__tests__/e2e/rbac.test.ts index f4e5283..2e709f3 100644 --- a/packages/server/src/__tests__/e2e/rbac.test.ts +++ b/packages/server/src/__tests__/e2e/rbac.test.ts @@ -643,7 +643,7 @@ function createScopeIssuanceApp(storage: AuthStorage): Hono { }); app.post("/subagents", async (c) => { - const auth = await authenticate(c.req.header("Authorization"), c.env.SIGNING_KEY); + const auth = await authenticate(c.req.header("Authorization"), c.env); if (!auth.ok) { return c.json({ error: auth.error, code: "invalid_authorization" }, auth.status); } diff --git a/packages/server/src/__tests__/e2e/sdk-verification.test.ts b/packages/server/src/__tests__/e2e/sdk-verification.test.ts index 50a7f70..a87ddf3 100644 --- a/packages/server/src/__tests__/e2e/sdk-verification.test.ts +++ b/packages/server/src/__tests__/e2e/sdk-verification.test.ts @@ -62,8 +62,6 @@ const WORKSPACE_ID = "ws_sdk_verification_e2e"; const ADMIN_SCOPE = "relayauth:admin:*"; const READ_SCOPE = "relayauth:identity:read:*"; const DEFAULT_AUDIENCE = ["relayauth-sdk", "relay-api"]; -const SIGNING_KEY = "dev-secret"; - test("SDK & Verification E2E", async (t) => { const harness = await createSdkVerificationHarness(); t.after(async () => { @@ -329,17 +327,14 @@ async function createSdkVerificationHarness() { const baseUrl = `http://relayauth-sdk-verification.${randomUUID()}.local`; const jwksUrl = `${baseUrl}/.well-known/jwks.json`; const revocationUrl = `${baseUrl}/v1/tokens/revocation`; - const adminToken = generateTestToken( - { - sub: "agent_sdk_admin", - org: ORG_ID, - wks: WORKSPACE_ID, - scopes: ["relayauth:*:*:*"], - sponsorId: "user_sdk_admin", - sponsorChain: ["user_sdk_admin", "agent_sdk_admin"], - }, - SIGNING_KEY, - ); + const adminToken = generateTestToken({ + sub: "agent_sdk_admin", + org: ORG_ID, + wks: WORKSPACE_ID, + scopes: ["relayauth:*:*:*"], + sponsorId: "user_sdk_admin", + sponsorChain: ["user_sdk_admin", "agent_sdk_admin"], + }); const fetchHarness = createFetchDispatchHarness(baseUrl, dispatch); async function dispatch(request: Request): Promise { diff --git a/packages/server/src/__tests__/jwks-rsa.test.ts b/packages/server/src/__tests__/jwks-rsa.test.ts index ffc673a..f86c8ef 100644 --- a/packages/server/src/__tests__/jwks-rsa.test.ts +++ b/packages/server/src/__tests__/jwks-rsa.test.ts @@ -11,8 +11,6 @@ type ExtendedBindings = AppEnv["Bindings"] & Record; function createBindings(overrides: Partial = {}): ExtendedBindings { return { - SIGNING_KEY: "legacy-shared-secret", - SIGNING_KEY_ID: "legacy-production", INTERNAL_SECRET: "internal-test-secret", ...overrides, }; @@ -69,82 +67,22 @@ test("JWKS publishes an RSA public JWK when RELAYAUTH_SIGNING_KEY_PEM_PUBLIC is assert.equal("d" in rsaKey, false, "JWKS must never expose the private exponent"); }); -test("JWKS keeps the existing HS256 metadata when RELAYAUTH_SIGNING_KEY_PEM_PUBLIC is unset", async () => { +test("JWKS returns an empty key set when RELAYAUTH_SIGNING_KEY_PEM_PUBLIC is unset", async () => { const response = await requestJwks(); const body = await assertJsonResponse<{ keys: JsonWebKey[] }>(response, 200); - assert.deepEqual(body.keys, [ - { - kty: "oct", - use: "sig", - alg: "HS256", - kid: "legacy-production", - }, - ]); + assert.deepEqual(body.keys, []); }); -test("JWKS returns both the legacy HS256 metadata and the RSA public JWK during the transition window", async () => { +test("JWKS never advertises HS256 metadata", async () => { const response = await requestJwks({ RELAYAUTH_SIGNING_KEY_PEM_PUBLIC: createPublicKeyPem(), }); const body = await assertJsonResponse<{ keys: JsonWebKey[] }>(response, 200); - const hs256Key = body.keys.find((key) => key.kty === "oct" && key.alg === "HS256"); - const rsaKey = body.keys.find((key) => key.kty === "RSA" && key.alg === "RS256"); - - assert.ok(hs256Key, "expected the legacy HS256 metadata to remain published"); - assert.equal(hs256Key?.kid, "legacy-production"); - assert.ok(rsaKey, "expected the RSA public JWK to be published alongside the HS256 metadata"); - assert.equal(rsaKey?.use, "sig"); - assert.equal("d" in (rsaKey ?? {}), false, "the RSA JWK must not contain private key material"); -}); - -test("JWKS suppresses the HS256 metadata once SIGNING_KEY is unbound (post-sunset)", async () => { - const storage = createTestStorage(); - const app = createApp({ storage }); - try { - // Mirror a deployment that has retired HS256 by removing the SIGNING_KEY - // worker binding while keeping SIGNING_KEY_ID for legacy kid accounting. - const response = await app.request( - createTestRequest("GET", "/.well-known/jwks.json"), - undefined, - { - SIGNING_KEY_ID: "legacy-production", - INTERNAL_SECRET: "internal-test-secret", - RELAYAUTH_SIGNING_KEY_PEM_PUBLIC: createPublicKeyPem(), - } as AppEnv["Bindings"], - ); - const body = await assertJsonResponse<{ keys: JsonWebKey[] }>(response, 200); - - assert.equal(body.keys.length, 1, "expected only the RSA key once HS256 is unbound"); - assert.equal(body.keys[0]?.kty, "RSA"); - assert.equal(body.keys[0]?.alg, "RS256"); - assert.equal( - body.keys.some((key) => key.alg === "HS256"), - false, - "JWKS must not advertise HS256 when no HS256 secret is configured", - ); - } finally { - await storage.close(); - } -}); -test("JWKS returns an empty key set when neither HS256 nor RS256 material is configured", async () => { - const storage = createTestStorage(); - const app = createApp({ storage }); - try { - const response = await app.request( - createTestRequest("GET", "/.well-known/jwks.json"), - undefined, - { - SIGNING_KEY_ID: "legacy-production", - INTERNAL_SECRET: "internal-test-secret", - } as AppEnv["Bindings"], - ); - const body = await assertJsonResponse<{ keys: JsonWebKey[] }>(response, 200); - assert.deepEqual(body.keys, []); - } finally { - await storage.close(); - } + assert.equal(body.keys.some((key) => key.alg === "HS256" || key.kty === "oct"), false); + assert.equal(body.keys.length, 1); + assert.equal(body.keys[0]?.alg, "RS256"); }); test("JWKS RSA `kid` is the RFC 7638 JWK thumbprint (deterministic, no YYYY-MM component)", async () => { @@ -197,9 +135,6 @@ test("Signed RS256 token header.kid equals the published JWKS RSA kid (month-rol }; const token = await signToken(claims, { - SIGNING_KEY: "legacy-shared-secret", - SIGNING_KEY_ID: "legacy-production", - RELAYAUTH_SIGNING_ALG: "RS256", RELAYAUTH_SIGNING_KEY_PEM: privateKeyPem, RELAYAUTH_SIGNING_KEY_PEM_PUBLIC: publicKeyPem, }); diff --git a/packages/server/src/__tests__/scope-middleware.test.ts b/packages/server/src/__tests__/scope-middleware.test.ts index 1cd10be..e734947 100644 --- a/packages/server/src/__tests__/scope-middleware.test.ts +++ b/packages/server/src/__tests__/scope-middleware.test.ts @@ -8,6 +8,8 @@ import { assertJsonResponse, createTestRequest, generateTestToken, + TEST_RS256_PRIVATE_KEY_PEM, + TEST_RS256_PUBLIC_KEY_PEM, } from "./test-helpers.js"; type ScopeMiddlewareOptions = { @@ -27,9 +29,9 @@ type ScopeContextVars = { function createBindings(overrides: Partial = {}): AppEnv["Bindings"] { return { - SIGNING_KEY: "dev-secret", - SIGNING_KEY_ID: "dev-key", INTERNAL_SECRET: "internal-test-secret", + RELAYAUTH_SIGNING_KEY_PEM: TEST_RS256_PRIVATE_KEY_PEM, + RELAYAUTH_SIGNING_KEY_PEM_PUBLIC: TEST_RS256_PUBLIC_KEY_PEM, ...overrides, }; } diff --git a/packages/server/src/__tests__/sign-rs256.test.ts b/packages/server/src/__tests__/sign-rs256.test.ts index d8eb3dc..fd00157 100644 --- a/packages/server/src/__tests__/sign-rs256.test.ts +++ b/packages/server/src/__tests__/sign-rs256.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { createHash, createHmac, createPublicKey, generateKeyPairSync } from "node:crypto"; +import { createHash, createPublicKey, generateKeyPairSync } from "node:crypto"; import test from "node:test"; import type { RelayAuthTokenClaims } from "@relayauth/types"; import { RelayAuthError } from "../../../sdk/typescript/src/errors.js"; @@ -219,42 +219,21 @@ test("tampering with an RS256 token payload fails verification", async (t) => { ); }); -test("signToken(claims, env) dispatches to RS256 when RELAYAUTH_SIGNING_ALG=RS256 and otherwise falls back to HS256", async (t) => { +test("signToken(claims, env) signs RS256 tokens only", async () => { const { signToken } = await loadSignModule(); const rs256Fixture = createRsaFixture(); const rs256Claims = createClaims({ jti: "tok_dispatch_rs256" }); - await t.test("RS256 path", async () => { - const token = await signToken(rs256Claims, { - SIGNING_KEY: "legacy-shared-secret", - SIGNING_KEY_ID: "legacy-production", - RELAYAUTH_SIGNING_ALG: "RS256", - RELAYAUTH_SIGNING_KEY_PEM: rs256Fixture.privateKeyPem, - RELAYAUTH_SIGNING_KEY_PEM_PUBLIC: rs256Fixture.publicKeyPem, - }); - - const [encodedHeader] = token.split("."); - const header = decodeBase64UrlJson<{ alg?: string; kid?: string }>(encodedHeader); - - assert.equal(header.alg, "RS256"); + const token = await signToken(rs256Claims, { + RELAYAUTH_SIGNING_KEY_PEM: rs256Fixture.privateKeyPem, + RELAYAUTH_SIGNING_KEY_PEM_PUBLIC: rs256Fixture.publicKeyPem, }); - await t.test("HS256 fallback path", async () => { - const token = await signToken(createClaims({ jti: "tok_dispatch_hs256" }), { - SIGNING_KEY: "legacy-shared-secret", - SIGNING_KEY_ID: "legacy-production", - }); - - const [encodedHeader, encodedPayload, encodedSignature] = token.split("."); - const header = decodeBase64UrlJson<{ alg?: string; kid?: string }>(encodedHeader); - const expectedSignature = createHmac("sha256", "legacy-shared-secret") - .update(`${encodedHeader}.${encodedPayload}`) - .digest("base64url"); + const [encodedHeader] = token.split("."); + const header = decodeBase64UrlJson<{ alg?: string; kid?: string }>(encodedHeader); - assert.equal(header.alg, "HS256"); - assert.equal(header.kid, "legacy-production"); - assert.equal(encodedSignature, expectedSignature); - }); + assert.equal(header.alg, "RS256"); + assert.ok(typeof header.kid === "string" && header.kid.length > 0); }); test("signToken(claims, env) reads RELAYAUTH_SIGNING_KEY_PEM from process.env at RS256 sign time when bindings omit it", async () => { @@ -266,9 +245,6 @@ test("signToken(claims, env) reads RELAYAUTH_SIGNING_KEY_PEM from process.env at try { const token = await signToken(createClaims({ jti: "tok_dispatch_process_env" }), { - SIGNING_KEY: "legacy-shared-secret", - SIGNING_KEY_ID: "legacy-production", - RELAYAUTH_SIGNING_ALG: "RS256", RELAYAUTH_SIGNING_KEY_PEM_PUBLIC: rs256Fixture.publicKeyPem, }); @@ -286,19 +262,17 @@ test("signToken(claims, env) reads RELAYAUTH_SIGNING_KEY_PEM from process.env at } }); -test("signToken(claims, env) rejects unknown RELAYAUTH_SIGNING_ALG values with a clear error", async () => { +test("signToken(claims, env) requires RS256 private key material", async () => { const { signToken } = await loadSignModule(); await assert.rejects( () => signToken(createClaims({ jti: "tok_dispatch_invalid_alg" }), { - SIGNING_KEY: "legacy-shared-secret", - SIGNING_KEY_ID: "legacy-production", - RELAYAUTH_SIGNING_ALG: "ES256", + RELAYAUTH_SIGNING_KEY_PEM_PUBLIC: createRsaFixture().publicKeyPem, }), (error) => { assert.match( error instanceof Error ? error.message : String(error), - /Unsupported signing algorithm: ES256/, + /RELAYAUTH_SIGNING_KEY_PEM must be set/, ); return true; }, diff --git a/packages/server/src/__tests__/test-helpers.ts b/packages/server/src/__tests__/test-helpers.ts index f1fbcf3..c05046a 100644 --- a/packages/server/src/__tests__/test-helpers.ts +++ b/packages/server/src/__tests__/test-helpers.ts @@ -17,7 +17,10 @@ import { createSqliteStorage } from "../storage/sqlite.js"; import type { IdentityBudget, StoredIdentity } from "../storage/identity-types.js"; import { createApp } from "../server.js"; -type TestBindings = Pick; +type TestBindings = Pick< + AppEnv["Bindings"], + "INTERNAL_SECRET" | "RELAYAUTH_SIGNING_KEY_PEM" | "RELAYAUTH_SIGNING_KEY_PEM_PUBLIC" +>; type TestStorage = AuthStorage & Partial>; @@ -43,15 +46,24 @@ function base64UrlEncode(value: string | Buffer): string { return Buffer.from(value).toString("base64url"); } -function signHs256(payload: Record, secret: string): string { - const header = { alg: "HS256", typ: "JWT" }; +const TEST_RSA_KEY_PAIR = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); + +export const TEST_RS256_PRIVATE_KEY_PEM = TEST_RSA_KEY_PAIR.privateKey + .export({ type: "pkcs8", format: "pem" }) + .toString(); + +export const TEST_RS256_PUBLIC_KEY_PEM = TEST_RSA_KEY_PAIR.publicKey + .export({ type: "spki", format: "pem" }) + .toString(); + +function signRs256(payload: Record): string { + const header = { alg: "RS256", typ: "JWT" }; const encodedHeader = base64UrlEncode(JSON.stringify(header)); const encodedPayload = base64UrlEncode(JSON.stringify(payload)); const unsigned = `${encodedHeader}.${encodedPayload}`; const signature = crypto - .createHmac("sha256", secret) - .update(unsigned) - .digest("base64url"); + .sign("RSA-SHA256", Buffer.from(unsigned), TEST_RS256_PRIVATE_KEY_PEM) + .toString("base64url"); return `${unsigned}.${signature}`; } @@ -78,7 +90,7 @@ export function createTestStorage(): TestStorage { export function generateTestToken( claims: Partial = {}, - secret = "dev-secret", + _legacySecret = "dev-secret", ): string { const now = Math.floor(Date.now() / 1000); const sub = claims.sub ?? "agent_test"; @@ -106,7 +118,7 @@ export function generateTestToken( budget: claims.budget, }; - return signHs256(payload, secret); + return signRs256(payload); } export function generateTestIdentity(overrides: Partial = {}): AgentIdentity { @@ -193,13 +205,13 @@ export function mockKV(): KVNamespace { export function createTestApp(bindingsOverrides: Partial = {}): TestApp { const storage = createTestStorage(); const bindings: TestBindings = { - SIGNING_KEY: bindingsOverrides.SIGNING_KEY ?? "dev-secret", - SIGNING_KEY_ID: bindingsOverrides.SIGNING_KEY_ID ?? "dev-key", INTERNAL_SECRET: bindingsOverrides.INTERNAL_SECRET ?? storage.INTERNAL_SECRET, + RELAYAUTH_SIGNING_KEY_PEM: + bindingsOverrides.RELAYAUTH_SIGNING_KEY_PEM ?? TEST_RS256_PRIVATE_KEY_PEM, + RELAYAUTH_SIGNING_KEY_PEM_PUBLIC: + bindingsOverrides.RELAYAUTH_SIGNING_KEY_PEM_PUBLIC ?? TEST_RS256_PUBLIC_KEY_PEM, }; - storage.SIGNING_KEY = bindings.SIGNING_KEY; - storage.SIGNING_KEY_ID = bindings.SIGNING_KEY_ID; storage.INTERNAL_SECRET = bindings.INTERNAL_SECRET; const app = createApp({ diff --git a/packages/server/src/__tests__/tokens-route.test.ts b/packages/server/src/__tests__/tokens-route.test.ts index b839945..bcaff4c 100644 --- a/packages/server/src/__tests__/tokens-route.test.ts +++ b/packages/server/src/__tests__/tokens-route.test.ts @@ -4,8 +4,6 @@ import test from "node:test"; import type { RelayAuthTokenClaims, TokenPair } from "@relayauth/types"; -import { RelayAuthError } from "../../../sdk/typescript/src/errors.js"; -import { TokenVerifier } from "../../../sdk/typescript/src/verify.js"; import type { StoredIdentity } from "../storage/identity-types.js"; import { assertJsonResponse, @@ -15,6 +13,7 @@ import { listRevokedTokenIds, seedActiveTokens, seedStoredIdentity, + TEST_RS256_PRIVATE_KEY_PEM, } from "./test-helpers.js"; type JwtHeader = { @@ -33,28 +32,17 @@ function base64UrlEncode(value: string | Buffer): string { return Buffer.from(value).toString("base64url"); } -function signLegacyHs256Jwt( - claims: RelayAuthTokenClaims, - { - secret = "dev-secret", - keyId = "dev-key", - }: { - secret?: string; - keyId?: string; - } = {}, -): string { +function signRs256Jwt(claims: RelayAuthTokenClaims): string { const header = { - alg: "HS256", + alg: "RS256", typ: "JWT", - kid: keyId, }; const encodedHeader = base64UrlEncode(JSON.stringify(header)); const encodedPayload = base64UrlEncode(JSON.stringify(claims)); const unsigned = `${encodedHeader}.${encodedPayload}`; const signature = crypto - .createHmac("sha256", secret) - .update(unsigned) - .digest("base64url"); + .sign("RSA-SHA256", Buffer.from(unsigned), TEST_RS256_PRIVATE_KEY_PEM) + .toString("base64url"); return `${unsigned}.${signature}`; } @@ -84,7 +72,7 @@ function createAuthToken(overrides: Partial = {}): string const sponsorId = overrides.sponsorId ?? "user_admin_worker"; const sub = overrides.sub ?? "agent_admin_worker"; - return signLegacyHs256Jwt({ + return signRs256Jwt({ sub, org: overrides.org ?? "org_tokens_route", wks: overrides.wks ?? "ws_tokens_route", @@ -143,7 +131,7 @@ function createTokenClaims( }; } -function createLegacyPhase0TokenPair( +function createRs256TokenPair( identity: StoredIdentity, { accessScopes = ["specialist:invoke"], @@ -184,8 +172,8 @@ function createLegacyPhase0TokenPair( return { pair: { - accessToken: signLegacyHs256Jwt(accessClaims), - refreshToken: signLegacyHs256Jwt(refreshClaims), + accessToken: signRs256Jwt(accessClaims), + refreshToken: signRs256Jwt(refreshClaims), accessTokenExpiresAt: new Date(accessClaims.exp * 1000).toISOString(), refreshTokenExpiresAt: new Date(refreshClaims.exp * 1000).toISOString(), tokenType: "Bearer", @@ -242,52 +230,19 @@ function assertTokenClaimsMatchSpec( assert.equal(typeof claims.iat, "number"); assert.equal(typeof claims.exp, "number"); assert.ok(claims.exp > claims.iat, "exp must be after iat"); - assert.equal("workspace_id" in claims, false, "legacy workspace_id alias should not be present"); - assert.equal("agent_name" in claims, false, "legacy agent_name alias should not be present"); - assert.equal("sponsor" in claims, false, "legacy sponsor claim should not be present"); + assert.equal("workspace_id" in claims, false, "RS256 workspace_id alias should not be present"); + assert.equal("agent_name" in claims, false, "RS256 agent_name alias should not be present"); + assert.equal("sponsor" in claims, false, "RS256 sponsor claim should not be present"); if (tokenType === "refresh") { assert.deepEqual(claims.aud, ["relayauth"]); } } -async function assertPhase0LegacyHs256Algorithm(token: string, audience: string[]): Promise { +function assertRs256Algorithm(token: string, _audience?: string[]): void { const header = decodeJwtJsonSegment(token, 0); - assert.deepEqual(header, { - alg: "HS256", - typ: "JWT", - kid: "dev-key", - }); - - // Phase 121 added dual-verify to @relayauth/sdk: HS256 is accepted by default - // during the migration window. This assertion is checking the *post-sunset* - // posture — that a verifier with HS256 acceptance disabled rejects these - // legacy tokens. That matches what phase 122 flips in production. - const previousFlag = process.env.RELAYAUTH_VERIFIER_ACCEPT_HS256; - process.env.RELAYAUTH_VERIFIER_ACCEPT_HS256 = "false"; - try { - const verifier = new TokenVerifier({ - jwksUrl: "https://relayauth.test/.well-known/jwks.json", - issuer: "https://relayauth.dev", - audience, - }); - - await assert.rejects( - () => verifier.verify(token), - (error: unknown) => { - assert.ok(error instanceof RelayAuthError); - assert.equal(error.code, "invalid_token"); - return true; - }, - "spec-compliant verifiers (HS256 acceptance disabled) should reject legacy HS256 tokens", - ); - } finally { - if (previousFlag === undefined) { - delete process.env.RELAYAUTH_VERIFIER_ACCEPT_HS256; - } else { - process.env.RELAYAUTH_VERIFIER_ACCEPT_HS256 = previousFlag; - } - } + assert.equal(header.alg, "RS256"); + assert.equal(header.typ, "JWT"); } async function createHarness({ @@ -339,7 +294,7 @@ async function requestRoute( } test("POST /v1/tokens", async (t) => { - await t.test("issues a Phase 0 legacy HS256 token pair with token-format claim shape", async () => { + await t.test("issues a Phase 0 RS256 token pair with token-format claim shape", async () => { const { app, identity, authHeaders } = await createHarness(); const response = await requestRoute(app, "POST", "/v1/tokens", { @@ -378,8 +333,8 @@ test("POST /v1/tokens", async (t) => { expectedScopes: ["relayauth:token:refresh"], }); - await assertPhase0LegacyHs256Algorithm(body.accessToken, ["specialist"]); - await assertPhase0LegacyHs256Algorithm(body.refreshToken, ["relayauth"]); + await assertRs256Algorithm(body.accessToken, ["specialist"]); + await assertRs256Algorithm(body.refreshToken, ["relayauth"]); }); await t.test("returns 401 when Authorization is missing", async () => { @@ -461,9 +416,9 @@ test("POST /v1/tokens", async (t) => { }); test("POST /v1/tokens/refresh", async (t) => { - await t.test("refreshes a legacy HS256 token pair without requiring a bearer token", async () => { + await t.test("refreshes a RS256 token pair without requiring a bearer token", async () => { const { app, identity } = await createHarness(); - const { pair, accessClaims, refreshClaims } = createLegacyPhase0TokenPair(identity); + const { pair, accessClaims, refreshClaims } = createRs256TokenPair(identity); await seedActiveTokens(app, identity.id, [accessClaims.jti, refreshClaims.jti]); const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { @@ -494,8 +449,8 @@ test("POST /v1/tokens/refresh", async (t) => { assert.notEqual(nextAccessClaims.jti, accessClaims.jti); assert.notEqual(nextRefreshClaims.jti, refreshClaims.jti); - await assertPhase0LegacyHs256Algorithm(body.accessToken, ["specialist"]); - await assertPhase0LegacyHs256Algorithm(body.refreshToken, ["relayauth"]); + await assertRs256Algorithm(body.accessToken, ["specialist"]); + await assertRs256Algorithm(body.refreshToken, ["relayauth"]); }); await t.test("returns 400 when refreshToken is missing", async () => { @@ -527,7 +482,7 @@ test("POST /v1/tokens/refresh", async (t) => { await t.test("returns 401 when the refresh token is expired", async () => { const { app, identity } = await createHarness(); const now = Math.floor(Date.now() / 1000) - (2 * 3600); - const { pair, refreshClaims } = createLegacyPhase0TokenPair(identity, { + const { pair, refreshClaims } = createRs256TokenPair(identity, { issuedAt: now, accessExpiresInSeconds: 60, refreshExpiresInSeconds: 60, @@ -547,7 +502,7 @@ test("POST /v1/tokens/refresh", async (t) => { await t.test("returns 401 when the refresh token has been revoked", async () => { const { app, identity } = await createHarness(); - const { pair, refreshClaims } = createLegacyPhase0TokenPair(identity); + const { pair, refreshClaims } = createRs256TokenPair(identity); await seedActiveTokens(app, identity.id, [refreshClaims.jti]); const revocations = app.storage.revocations as typeof app.storage.revocations & { @@ -568,7 +523,7 @@ test("POST /v1/tokens/refresh", async (t) => { await t.test("revokes the old refresh JTI after a successful refresh", async () => { const { app, identity } = await createHarness(); - const { pair, accessClaims, refreshClaims } = createLegacyPhase0TokenPair(identity); + const { pair, accessClaims, refreshClaims } = createRs256TokenPair(identity); await seedActiveTokens(app, identity.id, [accessClaims.jti, refreshClaims.jti]); assert.deepEqual(await listRevokedTokenIds(app), []); @@ -587,7 +542,7 @@ test("POST /v1/tokens/refresh", async (t) => { await t.test("detects refresh-token re-use and cascade-revokes the session", async () => { const { app, identity } = await createHarness(); - const { pair, accessClaims, refreshClaims } = createLegacyPhase0TokenPair(identity); + const { pair, accessClaims, refreshClaims } = createRs256TokenPair(identity); await seedActiveTokens(app, identity.id, [accessClaims.jti, refreshClaims.jti]); const firstResponse = await requestRoute(app, "POST", "/v1/tokens/refresh", { @@ -626,7 +581,7 @@ test("POST /v1/tokens/refresh", async (t) => { const jti = `tok_${crypto.randomUUID().replace(/-/g, "")}`; await seedActiveTokens(app, identity.id, [jti]); - const evilRefresh = signLegacyHs256Jwt({ + const evilRefresh = signRs256Jwt({ sub: identity.id, org: identity.orgId, wks: identity.workspaceId, @@ -655,7 +610,7 @@ test("POST /v1/tokens/refresh", async (t) => { const jti = `tok_${crypto.randomUUID().replace(/-/g, "")}`; await seedActiveTokens(app, identity.id, [jti]); - const wrongAudRefresh = signLegacyHs256Jwt({ + const wrongAudRefresh = signRs256Jwt({ sub: identity.id, org: identity.orgId, wks: identity.workspaceId, @@ -680,7 +635,7 @@ test("POST /v1/tokens/refresh", async (t) => { await t.test("rejects a refresh token whose exp is beyond clock-skew in the past", async () => { const { app, identity } = await createHarness(); const past = Math.floor(Date.now() / 1000) - 1000; - const expiredRefresh = signLegacyHs256Jwt({ + const expiredRefresh = signRs256Jwt({ sub: identity.id, org: identity.orgId, wks: identity.workspaceId, @@ -710,7 +665,7 @@ test("POST /v1/tokens/refresh", async (t) => { const sid = `sess_${crypto.randomUUID().replace(/-/g, "")}`; await seedActiveTokens(app, identity.id, [jti]); - const skewedRefresh = signLegacyHs256Jwt({ + const skewedRefresh = signRs256Jwt({ sub: identity.id, org: identity.orgId, wks: identity.workspaceId, @@ -720,7 +675,7 @@ test("POST /v1/tokens/refresh", async (t) => { token_type: "refresh", iss: "https://relayauth.dev", aud: ["relayauth"], - exp: now - 30, // 30s past exp, should be accepted within skew + exp: now - 20, // 20s past exp, should be accepted within verifier and route skew iat: now - 120, jti, sid, @@ -768,7 +723,7 @@ test("POST /v1/tokens enforces max sponsor-chain depth", async (t) => { test("POST /v1/tokens/revoke", async (t) => { await t.test("revokes a token id and persists the revocation", async () => { const { app, identity, authHeaders } = await createHarness(); - const { accessClaims } = createLegacyPhase0TokenPair(identity); + const { accessClaims } = createRs256TokenPair(identity); await seedActiveTokens(app, identity.id, [accessClaims.jti]); const response = await requestRoute(app, "POST", "/v1/tokens/revoke", { @@ -847,7 +802,7 @@ test("POST /v1/tokens/revoke", async (t) => { test("GET /v1/tokens/introspect", async (t) => { await t.test("returns raw claims for an active access token", async () => { const { app, identity, authHeaders } = await createHarness(); - const { pair, accessClaims } = createLegacyPhase0TokenPair(identity); + const { pair, accessClaims } = createRs256TokenPair(identity); await seedActiveTokens(app, identity.id, [accessClaims.jti]); const response = await requestRoute( @@ -894,7 +849,7 @@ test("GET /v1/tokens/introspect", async (t) => { await t.test("returns null for an expired access token", async () => { const { app, identity, authHeaders } = await createHarness(); const now = Math.floor(Date.now() / 1000) - (2 * 3600); - const { pair, accessClaims } = createLegacyPhase0TokenPair(identity, { + const { pair, accessClaims } = createRs256TokenPair(identity, { issuedAt: now, accessExpiresInSeconds: 60, }); @@ -915,7 +870,7 @@ test("GET /v1/tokens/introspect", async (t) => { await t.test("returns null for a revoked access token", async () => { const { app, identity, authHeaders } = await createHarness(); - const { pair, accessClaims } = createLegacyPhase0TokenPair(identity); + const { pair, accessClaims } = createRs256TokenPair(identity); await seedActiveTokens(app, identity.id, [accessClaims.jti]); const revocations = app.storage.revocations as typeof app.storage.revocations & { diff --git a/packages/server/src/routes/tokens.ts b/packages/server/src/routes/tokens.ts index 18ecb33..f004cf1 100644 --- a/packages/server/src/routes/tokens.ts +++ b/packages/server/src/routes/tokens.ts @@ -1,5 +1,5 @@ import type { RelayAuthTokenClaims, TokenPair } from "@relayauth/types"; -import { matchScope, type VerifyOptions } from "@relayauth/sdk"; +import { matchScope, TokenExpiredError, type VerifyOptions } from "@relayauth/sdk"; import { Hono } from "hono"; import type { AppEnv } from "../env.js"; @@ -563,8 +563,11 @@ async function verifyToken( issuer: EXPECTED_ISSUER, ...options, }); - } catch { - return { ok: false, error: "Invalid token" }; + } catch (error) { + return { + ok: false, + error: error instanceof TokenExpiredError ? "Token expired" : "Invalid token", + }; } if (!isValidClaims(claims)) { diff --git a/scripts/generate-dev-token.sh b/scripts/generate-dev-token.sh index e4e0f39..7990f63 100755 --- a/scripts/generate-dev-token.sh +++ b/scripts/generate-dev-token.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -header='{"alg":"HS256","typ":"JWT","kid":"dev-key"}' +header='{"alg":"RS256","typ":"JWT"}' now="$(date +%s)" exp="$((now + ${RELAYAUTH_TTL_SECONDS:-3600}))" jti="dev-${now}-${RANDOM}" @@ -13,7 +13,11 @@ scopes_json="${RELAYAUTH_SCOPES_JSON:-[\"*:*:*:*\"]}" issuer="${RELAYAUTH_ISSUER:-relayauth:dev}" audience_json="${RELAYAUTH_AUDIENCE_JSON:-[\"relayauth\",\"relayfile\"]}" token_type="${RELAYAUTH_TOKEN_TYPE:-access}" -secret="${SIGNING_KEY:-dev-secret}" +private_key_pem="${RELAYAUTH_SIGNING_KEY_PEM:-}" +if [[ -z "${private_key_pem}" ]]; then + printf 'RELAYAUTH_SIGNING_KEY_PEM is required to generate RS256 dev tokens\n' >&2 + exit 1 +fi payload="{\"sub\":\"${subject}\",\"org\":\"${org}\",\"wks\":\"${workspace}\",\"scopes\":${scopes_json},\"sponsorId\":\"${sponsor}\",\"sponsorChain\":[\"${sponsor}\"],\"token_type\":\"${token_type}\",\"iss\":\"${issuer}\",\"aud\":${audience_json},\"iat\":${now},\"exp\":${exp},\"jti\":\"${jti}\"}" base64url() { @@ -23,6 +27,9 @@ base64url() { header_b64="$(printf '%s' "${header}" | base64url)" payload_b64="$(printf '%s' "${payload}" | base64url)" unsigned="${header_b64}.${payload_b64}" -signature="$(printf '%s' "${unsigned}" | openssl dgst -sha256 -hmac "${secret}" -binary | base64url)" +private_key_file="$(mktemp)" +trap 'rm -f "${private_key_file}"' EXIT +printf '%s' "${private_key_pem}" > "${private_key_file}" +signature="$(printf '%s' "${unsigned}" | openssl dgst -sha256 -sign "${private_key_file}" -binary | base64url)" printf '%s.%s\n' "${unsigned}" "${signature}" From 0aea32f0b0b7f86f602cbf8c0fc2bf95e2797e1a Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 24 Apr 2026 11:24:35 +0200 Subject: [PATCH 3/3] docs: update RS256 configuration examples --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 89bab49..5a6ae66 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,12 @@ npm install @relayauth/sdk ### Verify a token ```ts -import { TokenVerifier } from "@relayauth/core"; +import { TokenVerifier } from "@relayauth/sdk"; -const verifier = new TokenVerifier({ signingKey: process.env.SIGNING_KEY! }); +const verifier = new TokenVerifier({ + jwksUrl: "https://relayauth.example.com/.well-known/jwks.json", + issuer: "https://relayauth.dev", +}); const claims = await verifier.verify(token); // claims.scopes → ["relayfile:fs:read:/github/*", "relayfile:fs:write:/github/*/reviews/*"] ``` @@ -55,7 +58,7 @@ checker.check("relayfile:fs:write:/slack/channels/general/messages/reply.json"); ### Generate a dev token ```bash -SIGNING_KEY=my-secret \ +RELAYAUTH_SIGNING_KEY_PEM="$(cat private.pem)" \ RELAYAUTH_SUB=review-agent \ RELAYAUTH_SCOPES_JSON='["relayfile:fs:read:/github/*", "relayfile:fs:write:/github/*/reviews/*"]' \ ./scripts/generate-dev-token.sh @@ -65,7 +68,9 @@ RELAYAUTH_SCOPES_JSON='["relayfile:fs:read:/github/*", "relayfile:fs:write:/gith ```bash npm install -SIGNING_KEY=my-secret npm run start +RELAYAUTH_SIGNING_KEY_PEM="$(cat private.pem)" \ +RELAYAUTH_SIGNING_KEY_PEM_PUBLIC="$(cat public.pem)" \ + npm run start ``` ## Scope Format