From 4fdecaec3b4efb1d28574432019e45cc5ecef891 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sat, 27 Dec 2025 12:53:01 -0500 Subject: [PATCH 1/4] feat(jose): add key derivation support to createJWT --- .../core/src/actions/callback/access-token.ts | 4 ++-- .../core/src/actions/signIn/authorization.ts | 3 +-- packages/jose/src/assert.ts | 7 +++++-- packages/jose/src/encrypt.ts | 4 ++-- packages/jose/src/index.ts | 19 ++++++++++++------- packages/jose/src/secret.ts | 15 +++++++++++++-- packages/jose/src/sign.ts | 5 +++-- packages/jose/test/index.test.ts | 14 ++++++++++++++ 8 files changed, 52 insertions(+), 19 deletions(-) diff --git a/packages/core/src/actions/callback/access-token.ts b/packages/core/src/actions/callback/access-token.ts index fc540c82..32196087 100644 --- a/packages/core/src/actions/callback/access-token.ts +++ b/packages/core/src/actions/callback/access-token.ts @@ -1,7 +1,7 @@ +import { formatZodError } from "@/utils.js" import { AuthInternalError, OAuthProtocolError } from "@/errors.js" import { OAuthAccessToken, OAuthAccessTokenErrorResponse, OAuthAccessTokenResponse } from "@/schemas.js" import type { OAuthProviderCredentials } from "@/@types/index.js" -import { formatZodError } from "@/utils.js" /** * Make a request to the OAuth provider to the token endpoint to exchange the authorization code provided @@ -22,7 +22,7 @@ export const createAccessToken = async ( ) => { const parsed = OAuthAccessToken.safeParse({ ...oauthConfig, redirectURI, code, codeVerifier }) if (!parsed.success) { - const msg = formatZodError(parsed.error).toString() + const msg = JSON.stringify(formatZodError(parsed.error), null, 2) throw new AuthInternalError("INVALID_OAUTH_CONFIGURATION", msg) } const { accessToken, clientId, clientSecret, code: codeParsed, redirectURI: redirectParsed } = parsed.data diff --git a/packages/core/src/actions/signIn/authorization.ts b/packages/core/src/actions/signIn/authorization.ts index d2f8decd..1c765d06 100644 --- a/packages/core/src/actions/signIn/authorization.ts +++ b/packages/core/src/actions/signIn/authorization.ts @@ -1,7 +1,7 @@ import { isValidURL } from "@/assert.js" import { OAuthAuthorization } from "@/schemas.js" -import { equals, formatZodError, getNormalizedOriginPath, sanitizeURL, toCastCase } from "@/utils.js" import { AuthInternalError, AuthSecurityError, isAuthSecurityError } from "@/errors.js" +import { equals, formatZodError, getNormalizedOriginPath, sanitizeURL, toCastCase } from "@/utils.js" import type { OAuthProviderCredentials } from "@/@types/index.js" /** @@ -26,7 +26,6 @@ export const createAuthorizationURL = ( const parsed = OAuthAuthorization.safeParse({ ...oauthConfig, redirectURI, state, codeChallenge, codeChallengeMethod }) if (!parsed.success) { const msg = JSON.stringify(formatZodError(parsed.error), null, 2) - console.log("OAuth Authorization URL Creation Error:", msg) throw new AuthInternalError("INVALID_OAUTH_CONFIGURATION", msg) } const { authorizeURL, ...options } = parsed.data diff --git a/packages/jose/src/assert.ts b/packages/jose/src/assert.ts index 2572ddca..83f56c31 100644 --- a/packages/jose/src/assert.ts +++ b/packages/jose/src/assert.ts @@ -12,11 +12,14 @@ export const isFalsy = (value: unknown): boolean => { return value === null || value === undefined || value === false || value === 0 || value === "" || Number.isNaN(value) } +export const isObject = (value: unknown): value is Record => { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + export const isInvalidPayload = (payload: unknown): boolean => { return ( isFalsy(payload) || - typeof payload !== "object" || - Array.isArray(payload) || + !isObject(payload) || (typeof payload === "object" && payload !== null && !Array.isArray(payload) && Object.keys(payload).length === 0) ) } diff --git a/packages/jose/src/encrypt.ts b/packages/jose/src/encrypt.ts index 28f1bcb5..c3447686 100644 --- a/packages/jose/src/encrypt.ts +++ b/packages/jose/src/encrypt.ts @@ -22,7 +22,7 @@ export interface EncryptedPayload { */ export const encryptJWE = async (payload: string, secret: SecretInput) => { try { - if(isFalsy(payload)) { + if (isFalsy(payload)) { throw new InvalidPayloadError("The payload must be a non-empty string") } const secretKey = createSecret(secret) @@ -52,7 +52,7 @@ export const encryptJWE = async (payload: string, secret: SecretInput) => { */ export const decryptJWE = async (token: string, secret: SecretInput) => { try { - if(isFalsy(token)) { + if (isFalsy(token)) { throw new InvalidPayloadError("The token must be a non-empty string") } const secretKey = createSecret(secret) diff --git a/packages/jose/src/index.ts b/packages/jose/src/index.ts index d6dbbb96..f8332787 100644 --- a/packages/jose/src/index.ts +++ b/packages/jose/src/index.ts @@ -7,12 +7,14 @@ import { createJWE } from "@/encrypt.js" import { isAuraJoseError } from "@/assert.js" import { JWTDecodingError, JWTEncodingError } from "./errors.js" import type { KeyObject } from "node:crypto" +import { getSecrets } from "./secret.js" export * from "@/sign.js" export * from "@/encrypt.js" export * from "@/deriveKey.js" export type SecretInput = KeyObject | Uint8Array | string +export type DerivedKeyInput = { jws: SecretInput; jwe: SecretInput } /** * Encode a JWT signed and encrypted token. The token first signed using JWS @@ -28,10 +30,11 @@ export type SecretInput = KeyObject | Uint8Array | string * @param secret - Secret key used for both signing and encrypting the JWT * @returns Promise resolving to the signed and encrypted JWT string */ -export const encodeJWT = async (token: JWTPayload, secret: SecretInput) => { +export const encodeJWT = async (token: JWTPayload, secret: SecretInput | DerivedKeyInput) => { try { - const { signJWS } = createJWS(secret) - const { encryptJWE } = createJWE(secret) + const { jweSecret, jwsSecret } = getSecrets(secret) + const { signJWS } = createJWS(jwsSecret) + const { encryptJWE } = createJWE(jweSecret) const signed = await signJWS(token) return await encryptJWE(signed) } catch (error) { @@ -54,10 +57,11 @@ export const encodeJWT = async (token: JWTPayload, secret: SecretInput) => { * @param secret * @returns */ -export const decodeJWT = async (token: string, secret: SecretInput) => { +export const decodeJWT = async (token: string, secret: SecretInput | DerivedKeyInput) => { try { - const { verifyJWS } = createJWS(secret) - const { decryptJWE } = createJWE(secret) + const { jweSecret, jwsSecret } = getSecrets(secret) + const { verifyJWS } = createJWS(jwsSecret) + const { decryptJWE } = createJWE(jweSecret) const decrypted = await decryptJWE(token) return await verifyJWS(decrypted) } catch (error) { @@ -76,7 +80,8 @@ export const decodeJWT = async (token: string, secret: SecretInput) => { * @param secret - Secret key used for signing, verifying, encrypting and decrypting the JWT * @returns JWT handler object with `signJWS/encryptJWE` and `verifyJWS/decryptJWE` methods */ -export const createJWT = (secret: SecretInput) => { +export const createJWT = (secret: SecretInput | DerivedKeyInput) => { + return { encodeJWT: async (payload: JWTPayload) => encodeJWT(payload, secret), decodeJWT: async (token: string) => decodeJWT(token, secret), diff --git a/packages/jose/src/secret.ts b/packages/jose/src/secret.ts index fb18a64f..2de227cc 100644 --- a/packages/jose/src/secret.ts +++ b/packages/jose/src/secret.ts @@ -1,5 +1,6 @@ import { InvalidSecretError } from "@/errors.js" -import type { SecretInput } from "@/index.js" +import { isObject } from "@/assert.js" +import type { DerivedKeyInput, SecretInput } from "@/index.js" /** * Create a secret in Uint8Array format @@ -8,7 +9,7 @@ import type { SecretInput } from "@/index.js" * @returns The secret in Uint8Array format */ export const createSecret = (secret: SecretInput) => { - if (secret === undefined) throw new Error("Secret is required") + if (secret === undefined) throw new InvalidSecretError("Secret is required") if (typeof secret === "string") { if (new TextEncoder().encode(secret).byteLength < 32) { throw new InvalidSecretError("Secret string must be at least 32 characters long") @@ -17,3 +18,13 @@ export const createSecret = (secret: SecretInput) => { } return secret } + + +export const getSecrets = (secret: SecretInput | DerivedKeyInput) => { + const jwsSecret = isObject(secret) && "jws" in secret ? secret.jws : secret + const jweSecret = isObject(secret) && "jwe" in secret ? secret.jwe : secret + return { + jwsSecret, + jweSecret, + } +} \ No newline at end of file diff --git a/packages/jose/src/sign.ts b/packages/jose/src/sign.ts index c16be8c6..d08e9bfa 100644 --- a/packages/jose/src/sign.ts +++ b/packages/jose/src/sign.ts @@ -52,9 +52,9 @@ export const signJWS = async (payload: JWTPayload, secret: SecretInput): Promise */ export const verifyJWS = async (token: string, secret: SecretInput): Promise => { try { - if(isFalsy(token)) { + if (isFalsy(token)) { throw new InvalidPayloadError("The token must be a non-empty string") - } + } const secretKey = createSecret(secret) const { payload } = await jwtVerify(token, secretKey) return payload @@ -74,6 +74,7 @@ export const verifyJWS = async (token: string, secret: SecretInput): Promise { + return { signJWS: (payload: JWTPayload) => signJWS(payload, secret), verifyJWS: (payload: string) => verifyJWS(payload, secret), diff --git a/packages/jose/test/index.test.ts b/packages/jose/test/index.test.ts index 1988eb93..9f01b660 100644 --- a/packages/jose/test/index.test.ts +++ b/packages/jose/test/index.test.ts @@ -148,6 +148,20 @@ describe("JWTs", () => { const { encodeJWT } = createJWT("short") await expect(encodeJWT(payload)).rejects.toThrow("Secret string must be at least 32 characters long") }) + + test("create a signed and encrypted JWT using createJWT with separate JWS and JWE secrets", async () => { + const jwsSecretKey = crypto.randomBytes(32) + const jweSecretKey = crypto.randomBytes(32) + + const { encodeJWT, decodeJWT } = createJWT({ jws: jwsSecretKey, jwe: jweSecretKey }) + + const jwt = await encodeJWT(payload) + expect(jwt).toBeDefined() + const decodedPayload = await decodeJWT(jwt) + expect(decodedPayload.sub).toBe(payload.sub) + expect(decodedPayload.name).toBe(payload.name) + expect(decodedPayload.email).toBe(payload.email) + }) }) describe("createSecret", () => { From 41dc6185858c08ef80d330de5da4a00d0c5d2d2e Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sat, 27 Dec 2025 13:00:51 -0500 Subject: [PATCH 2/4] chore(jose): update createJWT implementation --- packages/core/src/jose.ts | 5 +++-- packages/jose/src/index.ts | 1 - packages/jose/src/secret.ts | 3 +-- packages/jose/src/sign.ts | 1 - packages/jose/test/index.test.ts | 7 ++++--- 5 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/core/src/jose.ts b/packages/core/src/jose.ts index cbd318fe..f43bc623 100644 --- a/packages/core/src/jose.ts +++ b/packages/core/src/jose.ts @@ -20,10 +20,11 @@ export const createJoseInstance = (secret?: string) => { } const salt = process.env.AURA_AUTH_SALT ?? createDerivedSalt(secret) - const { derivedKey: derivedSessionKey } = createDeriveKey(secret, salt, "session") + const { derivedKey: derivedSigningKey } = createDeriveKey(secret, salt, "signing") + const { derivedKey: derivedEncryptionKey } = createDeriveKey(secret, salt, "encryption") const { derivedKey: derivedCsrfTokenKey } = createDeriveKey(secret, salt, "csrfToken") - const { decodeJWT, encodeJWT } = createJWT(derivedSessionKey) + const { decodeJWT, encodeJWT } = createJWT({ jws: derivedSigningKey, jwe: derivedEncryptionKey }) const { signJWS, verifyJWS } = createJWS(derivedCsrfTokenKey) return { diff --git a/packages/jose/src/index.ts b/packages/jose/src/index.ts index f8332787..1ee89814 100644 --- a/packages/jose/src/index.ts +++ b/packages/jose/src/index.ts @@ -81,7 +81,6 @@ export const decodeJWT = async (token: string, secret: SecretInput | DerivedKeyI * @returns JWT handler object with `signJWS/encryptJWE` and `verifyJWS/decryptJWE` methods */ export const createJWT = (secret: SecretInput | DerivedKeyInput) => { - return { encodeJWT: async (payload: JWTPayload) => encodeJWT(payload, secret), decodeJWT: async (token: string) => decodeJWT(token, secret), diff --git a/packages/jose/src/secret.ts b/packages/jose/src/secret.ts index 2de227cc..deeae0a5 100644 --- a/packages/jose/src/secret.ts +++ b/packages/jose/src/secret.ts @@ -19,7 +19,6 @@ export const createSecret = (secret: SecretInput) => { return secret } - export const getSecrets = (secret: SecretInput | DerivedKeyInput) => { const jwsSecret = isObject(secret) && "jws" in secret ? secret.jws : secret const jweSecret = isObject(secret) && "jwe" in secret ? secret.jwe : secret @@ -27,4 +26,4 @@ export const getSecrets = (secret: SecretInput | DerivedKeyInput) => { jwsSecret, jweSecret, } -} \ No newline at end of file +} diff --git a/packages/jose/src/sign.ts b/packages/jose/src/sign.ts index d08e9bfa..d6f3de5d 100644 --- a/packages/jose/src/sign.ts +++ b/packages/jose/src/sign.ts @@ -74,7 +74,6 @@ export const verifyJWS = async (token: string, secret: SecretInput): Promise { - return { signJWS: (payload: JWTPayload) => signJWS(payload, secret), verifyJWS: (payload: string) => verifyJWS(payload, secret), diff --git a/packages/jose/test/index.test.ts b/packages/jose/test/index.test.ts index 9f01b660..4d5d1d4c 100644 --- a/packages/jose/test/index.test.ts +++ b/packages/jose/test/index.test.ts @@ -150,10 +150,11 @@ describe("JWTs", () => { }) test("create a signed and encrypted JWT using createJWT with separate JWS and JWE secrets", async () => { - const jwsSecretKey = crypto.randomBytes(32) - const jweSecretKey = crypto.randomBytes(32) + const secret = crypto.randomBytes(32) + const { derivedKey: derivedSigningKey } = createDeriveKey(secret, "salt", "signing") + const { derivedKey: derivedEncryptionKey } = createDeriveKey(secret, "salt", "encryption") - const { encodeJWT, decodeJWT } = createJWT({ jws: jwsSecretKey, jwe: jweSecretKey }) + const { encodeJWT, decodeJWT } = createJWT({ jws: derivedSigningKey, jwe: derivedEncryptionKey }) const jwt = await encodeJWT(payload) expect(jwt).toBeDefined() From 286db546c8f23d2826ae2ca4d4d1dd30461d0d29 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sat, 27 Dec 2025 13:04:13 -0500 Subject: [PATCH 3/4] feat(core): re-export `encryptJWE` and `decryptJWE` --- packages/core/src/@types/index.ts | 2 ++ packages/core/src/actions/callback/userinfo.ts | 4 ++-- packages/core/src/jose.ts | 14 ++++++++++---- packages/core/src/utils.ts | 5 +---- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/core/src/@types/index.ts b/packages/core/src/@types/index.ts index 200d62a4..e669ba1a 100644 --- a/packages/core/src/@types/index.ts +++ b/packages/core/src/@types/index.ts @@ -190,6 +190,8 @@ export interface JoseInstance { encodeJWT: (payload: JWTPayload) => Promise signJWS: (payload: JWTPayload) => Promise verifyJWS: (payload: string) => Promise + encryptJWE: (payload: string) => Promise + decryptJWE: (payload: string) => Promise } /** diff --git a/packages/core/src/actions/callback/userinfo.ts b/packages/core/src/actions/callback/userinfo.ts index 3e576c20..5801e4d3 100644 --- a/packages/core/src/actions/callback/userinfo.ts +++ b/packages/core/src/actions/callback/userinfo.ts @@ -49,10 +49,10 @@ export const getUserInfo = async (oauthConfig: OAuthProviderCredentials, accessT } return oauthConfig?.profile ? oauthConfig.profile(json) : getDefaultUserInfo(json) } catch (error) { - if(isOAuthProtocolError(error)) { + if (isOAuthProtocolError(error)) { throw error } - if(isNativeError(error)) { + if (isNativeError(error)) { throw new OAuthProtocolError("invalid_request", error.message, "", { cause: error }) } throw new OAuthProtocolError("invalid_request", "Failed to fetch user information.", "", { cause: error }) diff --git a/packages/core/src/jose.ts b/packages/core/src/jose.ts index f43bc623..5b0cb839 100644 --- a/packages/core/src/jose.ts +++ b/packages/core/src/jose.ts @@ -1,7 +1,7 @@ import "dotenv/config" -import { createJWT, createJWS, createDeriveKey } from "@aura-stack/jose" -import { createDerivedSalt } from "./secure.js" -import { AuthInternalError } from "./errors.js" +import { createJWT, createJWS, createJWE, createDeriveKey } from "@aura-stack/jose" +import { createDerivedSalt } from "@/secure.js" +import { AuthInternalError } from "@/errors.js" export type { JWTPayload } from "@aura-stack/jose/jose" /** @@ -16,7 +16,10 @@ export type { JWTPayload } from "@aura-stack/jose/jose" export const createJoseInstance = (secret?: string) => { secret ??= process.env.AURA_AUTH_SECRET! if (!secret) { - throw new AuthInternalError("JOSE_INITIALIZATION_FAILED", "AURA_AUTH_SECRET environment variable is not set and no secret was provided.") + throw new AuthInternalError( + "JOSE_INITIALIZATION_FAILED", + "AURA_AUTH_SECRET environment variable is not set and no secret was provided." + ) } const salt = process.env.AURA_AUTH_SALT ?? createDerivedSalt(secret) @@ -26,11 +29,14 @@ export const createJoseInstance = (secret?: string) => { const { decodeJWT, encodeJWT } = createJWT({ jws: derivedSigningKey, jwe: derivedEncryptionKey }) const { signJWS, verifyJWS } = createJWS(derivedCsrfTokenKey) + const { encryptJWE, decryptJWE } = createJWE(derivedEncryptionKey) return { decodeJWT, encodeJWT, signJWS, verifyJWS, + encryptJWE, + decryptJWE, } } diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index bd192ab1..2c5a32dd 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -111,10 +111,7 @@ export const isValidRelativePath = (path: string | undefined | null): boolean => export const onErrorHandler: RouterConfig["onError"] = (error) => { if (isRouterError(error)) { const { message, status, statusText } = error - return Response.json( - { type: "ROUTER_ERROR", code: "ROUTER_INTERNAL_ERROR", message }, - { status, statusText } - ) + return Response.json({ type: "ROUTER_ERROR", code: "ROUTER_INTERNAL_ERROR", message }, { status, statusText }) } if (isInvalidZodSchemaError(error)) { return Response.json({ type: "ROUTER_ERROR", code: "INVALID_REQUEST", message: error.errors }, { status: 422 }) From f6f2e59b359d17ff062cdc5daf888c430cbd6bb0 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sat, 27 Dec 2025 13:09:46 -0500 Subject: [PATCH 4/4] docs: update `CHANGELOG.md` files --- packages/core/CHANGELOG.md | 4 ++++ packages/jose/CHANGELOG.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 61a010ee..a2d984a3 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Re-export the `encryptJWE` and `decryptJWE` functions for JWEs (Json Web Encryption) from the `jose` instance created from `createAuth` function. These functions are used internally for session and csrf token management and can be consumed for external reasons designed by the users. [#45](https://github.com/aura-stack-ts/auth/pull/45) + ### Changed - Updated `cookies` configuration option in `createAuth` function to support granular per-cookie settings for all internal cookies used by Aura Auth (e.g., `state`, `redirect_to`, `code_verifier`, `sessionToken`, and `csrfToken`) using the overrides object. Renamed `name` to `prefix` field to add to all of the cookies (without cookie prefixes). [#43](https://github.com/aura-stack-ts/auth/pull/43) diff --git a/packages/jose/CHANGELOG.md b/packages/jose/CHANGELOG.md index cce70f7b..9c11272e 100644 --- a/packages/jose/CHANGELOG.md +++ b/packages/jose/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Added Key derivation support to `createJWT`, `encodeJWT` and `decodeJWT` functions which allows to pass the separated keys for signing and encrypting the JWTs using the `jws` and `jwe` properties as argument in the functions. [#45](https://github.com/aura-stack-ts/auth/pull/45) + --- ## [0.1.0] - 2025-12-28