diff --git a/apps/astro/astro.config.mjs b/apps/astro/astro.config.mjs index 4a7f5f96..f93d903f 100644 --- a/apps/astro/astro.config.mjs +++ b/apps/astro/astro.config.mjs @@ -8,7 +8,6 @@ import tailwindcss from "@tailwindcss/vite" export default defineConfig({ integrations: [react()], vite: { - // @ts-expect-error plugins: [tailwindcss()], }, output: "server", diff --git a/apps/tanstack-start/src/lib/auth-server.ts b/apps/tanstack-start/src/lib/auth-server.ts index 32f4f015..610c211b 100644 --- a/apps/tanstack-start/src/lib/auth-server.ts +++ b/apps/tanstack-start/src/lib/auth-server.ts @@ -25,7 +25,7 @@ export const signOutFn = createServerFn({ method: "POST" }).handler(async () => console.error("[error:server] signOut", error) return null }) - throw redirect({ to: "/", headers: response.headers, reloadDocument: true }) + throw redirect({ to: "/", headers: response?.headers, reloadDocument: true }) }) export const signInFn = createServerFn({ method: "POST" }) @@ -46,6 +46,6 @@ export const signInFn = createServerFn({ method: "POST" }) return null }) throw redirect({ - href: response.signInURL, + href: response?.signInURL, }) }) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 4c94b2a2..91cc58f2 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] +### Changed + +- The built-in JOSE functions now provide type safety and type inference based on the `User` type. Additionally, `encryptJWE` and `decryptJWE` now accept and return `JWTPayload`. [#123](https://github.com/aura-stack-ts/auth/pull/123) + --- ## [0.5.0] - 2026-03-19 diff --git a/packages/core/src/jose.ts b/packages/core/src/jose.ts index bed51d0f..0b794525 100644 --- a/packages/core/src/jose.ts +++ b/packages/core/src/jose.ts @@ -6,8 +6,12 @@ import { createDeriveKey, createSecret, type JWTVerifyOptions, - type DecodedJWTPayloadOptions, + type DecodeJWTOptions, type TypedJWTPayload, + type EncodeJWTOptions, + type JWTHeaderParameters, + type JWEHeaderParameters, + type JWTDecryptOptions, } from "@aura-stack/jose" import { AuthInternalError } from "@/errors.ts" export { base64url, type JWTPayload } from "@aura-stack/jose/jose" @@ -49,54 +53,44 @@ export const createJoseInstance = (secret?: string) => { } const jose = (async () => { - const derivedSigningKey = await createDeriveKey(secret, salt, "signing") - const derivedEncryptionKey = await createDeriveKey(secret, salt, "encryption") - const derivedCsrfTokenKey = await createDeriveKey(secret, salt, "csrfToken") + const [derivedSigningKey, derivedEncryptionKey, derivedCsrfTokenKey] = await Promise.all([ + createDeriveKey(secret, salt, "signing"), + createDeriveKey(secret, salt, "encryption"), + createDeriveKey(secret, salt, "csrfToken"), + ]) return { - jwt: createJWT({ jws: derivedSigningKey, jwe: derivedEncryptionKey }), - jws: createJWS(derivedCsrfTokenKey), - jwe: createJWE(derivedEncryptionKey), + jwt: createJWT({ sign: derivedSigningKey, encrypt: derivedEncryptionKey }), + jws: createJWS(derivedCsrfTokenKey), + jwe: createJWE(derivedEncryptionKey), } })() jose.catch(() => {}) return { - decodeJWT: async (token: string, options?: DecodedJWTPayloadOptions) => { + encodeJWT: async (payload: TypedJWTPayload>, options?: EncodeJWTOptions) => { const { jwt } = await jose - return jwt.decodeJWT(token, options) + return jwt.encodeJWT(payload, options) }, - encodeJWT: async (payload: TypedJWTPayload>) => { + decodeJWT: async (token: string, options?: DecodeJWTOptions) => { const { jwt } = await jose - return jwt.encodeJWT(payload) + return jwt.decodeJWT(token, options) }, - signJWS: async (...args: Parameters["signJWS"]>) => { + signJWS: async (payload: TypedJWTPayload>, options?: JWTHeaderParameters) => { const { jws } = await jose - return jws.signJWS(...args) + return jws.signJWS(payload, options) }, - verifyJWS: async (...args: Parameters["verifyJWS"]>) => { + verifyJWS: async (token: string, options?: JWTVerifyOptions) => { const { jws } = await jose - return jws.verifyJWS(...args) + return jws.verifyJWS(token, options) }, - encryptJWE: async (...args: Parameters["encryptJWE"]>) => { + encryptJWE: async (payload: TypedJWTPayload>, options?: JWEHeaderParameters) => { const { jwe } = await jose - return jwe.encryptJWE(...args) + return jwe.encryptJWE(payload, options) }, - decryptJWE: async (...args: Parameters["decryptJWE"]>) => { + decryptJWE: async (token: string, options?: JWTDecryptOptions) => { const { jwe } = await jose - return jwe.decryptJWE(...args) + return jwe.decryptJWE(token, options) }, } } - -export const jwtVerificationOptions: JWTVerifyOptions = { - algorithms: ["HS256"], - typ: "JWT", -} - -export const decodeJWTOptions: DecodedJWTPayloadOptions = { - jws: jwtVerificationOptions, - jwt: { - typ: "JWT", - }, -} diff --git a/packages/core/src/secure.ts b/packages/core/src/secure.ts index 2aca7c4e..798f84d7 100644 --- a/packages/core/src/secure.ts +++ b/packages/core/src/secure.ts @@ -1,7 +1,7 @@ import { equals } from "@/utils.ts" import { AuthSecurityError } from "@/errors.ts" import { isJWTPayloadWithToken, timingSafeEqual } from "@/assert.ts" -import { jwtVerificationOptions, base64url, encoder, getRandomBytes, getSubtleCrypto } from "@/jose.ts" +import { base64url, encoder, getRandomBytes, getSubtleCrypto } from "@/jose.ts" import type { AuthRuntimeConfig } from "@/@types/index.ts" /** @deprecated use `createSecretValue` instead */ @@ -49,7 +49,7 @@ export const createCSRF = async (jose: AuthRuntimeConfig["jose"], csrfCookie?: s try { const token = generateSecure(32) if (csrfCookie) { - await jose.verifyJWS(csrfCookie, jwtVerificationOptions) + await jose.verifyJWS(csrfCookie) return csrfCookie } return jose.signJWS({ token }) @@ -61,8 +61,8 @@ export const createCSRF = async (jose: AuthRuntimeConfig["jose"], csrfCookie?: s export const verifyCSRF = async (jose: AuthRuntimeConfig["jose"], cookie: string, header: string): Promise => { try { - const cookiePayload = await jose.verifyJWS(cookie, jwtVerificationOptions) - const headerPayload = await jose.verifyJWS(header, jwtVerificationOptions) + const cookiePayload = await jose.verifyJWS(cookie) + const headerPayload = await jose.verifyJWS(header) if (!isJWTPayloadWithToken(cookiePayload)) { throw new AuthSecurityError("CSRF_TOKEN_INVALID", "Cookie payload missing token field.") diff --git a/packages/jose/CHANGELOG.md b/packages/jose/CHANGELOG.md index 68ba8822..562ae1dd 100644 --- a/packages/jose/CHANGELOG.md +++ b/packages/jose/CHANGELOG.md @@ -8,6 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Added `compactEncryptJWE`, `decryptCompactJWE`, and `createCompactJWE` to handle compact JWE (JSON Web Encryption) serialization, and introduced `EncodeJWTOptions` and `DecodeJWTOptions` to configure additional payload headers and verification options for signing and encryption flows. [#123](https://github.com/aura-stack-ts/auth/pull/123) + +### Changed + +- Updated `encryptJWE` and `decryptJWE` to accept `JWTPayload` values and no longer support string payloads. Use the compact JWE functions for string payloads. [#123](https://github.com/aura-stack-ts/auth/pull/123) + --- ## [0.4.0] - 2026-03-19 diff --git a/packages/jose/src/encrypt.ts b/packages/jose/src/encrypt.ts index 35df2ed5..56263d9b 100644 --- a/packages/jose/src/encrypt.ts +++ b/packages/jose/src/encrypt.ts @@ -1,20 +1,21 @@ -import { base64url, EncryptJWT, jwtDecrypt, type JWTDecryptOptions } from "jose" +import { + base64url, + EncryptJWT, + jwtDecrypt, + JWTPayload, + compactDecrypt, + CompactEncrypt, + type JWEHeaderParameters, + type JWTDecryptOptions, + type DecryptOptions, +} from "jose" import { createSecret } from "@/secret.ts" -import { getRandomBytes } from "@/crypto.ts" +import { decoder, encoder, getRandomBytes } from "@/crypto.ts" import { isAuraJoseError, isFalsy } from "@/assert.ts" import { InvalidPayloadError, JWEDecryptionError, JWEEncryptionError } from "@/errors.ts" -import type { SecretInput } from "@/index.ts" +import type { SecretInput, TypedJWTPayload } from "@/index.ts" -export type { JWTDecryptOptions } from "jose" - -export interface EncryptedPayload { - payload: string -} - -export interface EncryptOptions { - nbf?: string | number | Date - exp?: string | number | Date -} +export type { JWTDecryptOptions, JWEHeaderParameters, DecryptOptions } from "jose" /** * Encrypt a standard JWT token with the following claims: @@ -25,9 +26,14 @@ export interface EncryptOptions { * * @param payload - Payload data information to encrypt the JWT * @param secret - Secret key to encrypt the JWT (CryptoKey, KeyObject, string or Uint8Array) + * @param options - Optional encryption options (e.g. algorithm, encryption method) * @returns Encrypted JWT string */ -export const encryptJWE = async (payload: string, secret: SecretInput, options?: EncryptOptions) => { +export const encryptJWE = async ( + payload: TypedJWTPayload>, + secret: SecretInput, + options?: JWEHeaderParameters +): Promise => { try { if (isFalsy(payload)) { throw new InvalidPayloadError("The payload must be a non-empty string") @@ -35,11 +41,11 @@ export const encryptJWE = async (payload: string, secret: SecretInput, options?: const secretKey = createSecret(secret) const jti = base64url.encode(getRandomBytes(32)) - return await new EncryptJWT({ payload }) - .setProtectedHeader({ alg: "dir", enc: "A256GCM", typ: "JWT", cty: "JWT" }) + return await new EncryptJWT(payload) + .setProtectedHeader({ alg: "dir", enc: "A256GCM", cty: "JWT", typ: "JWT", ...options }) .setIssuedAt() - .setNotBefore(options?.nbf ?? "0s") - .setExpirationTime(options?.exp ?? "15d") + .setNotBefore(payload?.nbf ?? "0s") + .setExpirationTime(payload?.exp ?? "15d") .setJti(jti) .encrypt(secretKey) } catch (error) { @@ -50,21 +56,83 @@ export const encryptJWE = async (payload: string, secret: SecretInput, options?: } } +/** + * Encrypt a standard JWT token using compact serialization. + * @param payload - Payload data information to encrypt the JWT + * @param secret - Secret key to encrypt the JWT (CryptoKey, KeyObject, string or Uint8Array) + * @param options - Optional encryption options (e.g. algorithm, encryption method) + * @returns Encrypted JWT string in compact serialization format + */ +export const compactEncryptJWE = async (payload: string, secret: SecretInput, options?: JWEHeaderParameters) => { + try { + if (isFalsy(payload)) { + throw new InvalidPayloadError("The payload must be a non-empty string") + } + const secretKey = createSecret(secret) + return await new CompactEncrypt(encoder.encode(payload)) + .setProtectedHeader({ alg: "dir", enc: "A256GCM", cty: "JWT", typ: "JWT", ...options }) + .encrypt(secretKey) + } catch (error) { + if (isAuraJoseError(error)) { + throw error + } + throw new JWEEncryptionError("JWE encryption failed", { cause: error }) + } +} + /** * Decrypt a JWE token and return the payload if valid. * * @param token - Encrypted JWT string to decrypt * @param secret - Secret key to decrypt the JWT (CryptoKey, KeyObject, string or Uint8Array) + * @param options - Additional JWT decryption options * @returns Decrypted JWT payload string */ -export const decryptJWE = async (token: string, secret: SecretInput, options?: JWTDecryptOptions) => { +export const decryptJWE = async ( + token: string, + secret: SecretInput, + options?: JWTDecryptOptions +): Promise> => { try { if (isFalsy(token)) { throw new InvalidPayloadError("The token must be a non-empty string") } const secretKey = createSecret(secret) - const { payload } = await jwtDecrypt(token, secretKey, options) - return payload.payload + const { payload } = await jwtDecrypt(token, secretKey, { + keyManagementAlgorithms: ["dir"], + contentEncryptionAlgorithms: ["A256GCM"], + ...options, + }) + return payload as TypedJWTPayload + } catch (error) { + if (isAuraJoseError(error)) { + throw error + } + throw new JWEDecryptionError("JWE decryption verification failed", { cause: error }) + } +} + +/** + * Decrypt a JWE token in compact serialization format and return the payload if valid. + * + * @param token - Encrypted JWT string in compact serialization format to decrypt + * @param secret - Secret key to decrypt the JWT (CryptoKey, KeyObject, string or Uint8Array) + * @param options - Additional JWT decryption options + * @returns Decrypted JWT payload string + */ +export const decryptCompactJWE = async (token: string, secret: SecretInput, options?: DecryptOptions) => { + try { + if (isFalsy(token)) { + throw new InvalidPayloadError("The token must be a non-empty string") + } + const secretKey = createSecret(secret) + + const { plaintext } = await compactDecrypt(token, secretKey, { + keyManagementAlgorithms: ["dir"], + contentEncryptionAlgorithms: ["A256GCM"], + ...options, + }) + return decoder.decode(plaintext) } catch (error) { if (isAuraJoseError(error)) { throw error @@ -80,9 +148,26 @@ export const decryptJWE = async (token: string, secret: SecretInput, options?: J * @param secret - Secret key used for encrypting and decrypting the JWE * @returns encryptJWE and decryptJWE functions */ -export const createJWE = (secret: SecretInput) => { +export const createJWE = (secret: SecretInput) => { + return { + encryptJWE: ( + payload: TypedJWTPayload>, + options?: JWEHeaderParameters + ) => encryptJWE(payload, secret, options), + decryptJWE: (payload: string, options?: JWTDecryptOptions) => + decryptJWE(payload, secret, options), + } +} + +/** + * Creates a `Compact JWE (JSON Web Encryption)` encrypter and decrypter using compact serialization. It implements the + * `compactEncryptJWE` and `decryptCompactJWE` functions. + * @param secret - Secret key used for encrypting and decrypting the JWE + * @returns compactEncryptJWE and decryptCompactJWE functions + */ +export const createCompactJWE = (secret: SecretInput) => { return { - encryptJWE: (payload: string, options?: EncryptOptions) => encryptJWE(payload, secret, options), - decryptJWE: (payload: string, options?: JWTDecryptOptions) => decryptJWE(payload, secret, options), + compactEncryptJWE: (payload: string, options?: JWEHeaderParameters) => compactEncryptJWE(payload, secret, options), + decryptCompactJWE: (payload: string, options?: DecryptOptions) => decryptCompactJWE(payload, secret, options), } } diff --git a/packages/jose/src/index.ts b/packages/jose/src/index.ts index 6baa4663..673b47ab 100644 --- a/packages/jose/src/index.ts +++ b/packages/jose/src/index.ts @@ -1,18 +1,23 @@ /** * @module @aura-stack/jose */ -import type { JWTDecryptOptions, JWTPayload, JWTVerifyOptions } from "jose" -import { createJWS } from "@/sign.ts" +import type { DecryptOptions, JWEHeaderParameters, JWTHeaderParameters, JWTPayload, JWTVerifyOptions } from "jose" import { getSecrets } from "@/secret.ts" -import { createJWE } from "@/encrypt.ts" +import { signJWS, verifyJWS } from "@/sign.ts" import { isAuraJoseError } from "@/assert.ts" import { JWTDecodingError, JWTEncodingError } from "@/errors.ts" +import { compactEncryptJWE, decryptCompactJWE } from "@/encrypt.ts" export * from "@/sign.ts" +export type * from "@/sign.ts" export * from "@/encrypt.ts" +export type * from "@/encrypt.ts" export * from "@/deriveKey.ts" +export type * from "@/deriveKey.ts" export * from "@/secret.ts" +export type * from "@/secret.ts" export * from "@/crypto.ts" +export type * from "@/crypto.ts" /** * Secret input can be: @@ -21,11 +26,31 @@ export * from "@/crypto.ts" * - string: String that will be encoded to UTF-8 */ export type SecretInput = Uint8Array | string | CryptoKey -export type DerivedKeyInput = { jws: SecretInput; jwe: SecretInput } -export type DecodedJWTPayloadOptions = { jws: JWTVerifyOptions; jwt: JWTDecryptOptions } +export type DerivedKeyInput = { sign: SecretInput; encrypt: SecretInput } export type Prettify = { [K in keyof T]: T[K] } & {} export type TypedJWTPayload = JWTPayload & Payload +/** + * JWT options for signin and encryption. + */ +export interface EncodeJWTOptions { + sign?: JWTHeaderParameters + encrypt?: JWEHeaderParameters +} + +/** + * Decoded JWT payload options for verification and decryption. + */ +export interface DecodeJWTOptions { + verify: JWTVerifyOptions + decrypt: DecryptOptions +} + +export interface CreateJWTOptions { + encode: EncodeJWTOptions + decode: DecodeJWTOptions +} + /** * Encode a JWT signed and encrypted token. The token first signed using JWS * and then encrypted using JWE to ensure both integrity and confidentiality. @@ -38,18 +63,18 @@ export type TypedJWTPayload = JWTPayload & Payload * * @param token - Payload data to encode in the JWT * @param secret - Secret key used for both signing and encrypting the JWT + * @param options - Optional algorithm configuration for signing and encryption * @returns Promise resolving to the signed and encrypted JWT string */ export const encodeJWT = async ( token: TypedJWTPayload>, - secret: SecretInput | DerivedKeyInput + secret: SecretInput | DerivedKeyInput, + options?: EncodeJWTOptions ) => { try { const { jweSecret, jwsSecret } = getSecrets(secret) - const { signJWS } = createJWS(jwsSecret) - const { encryptJWE } = createJWE(jweSecret) - const signed = await signJWS(token) - return await encryptJWE(signed) + const signed = await signJWS(token, jwsSecret, options?.sign) + return await compactEncryptJWE(signed, jweSecret, options?.encrypt) } catch (error) { if (isAuraJoseError(error)) { throw error @@ -66,21 +91,19 @@ export const encodeJWT = async ( * Based on the RFC 7519 standard * - Official RFC: https://datatracker.ietf.org/doc/html/rfc7519 * - Validating a JWT: https://datatracker.ietf.org/doc/html/rfc7519#section-7.2 - * @param token - * @param secret - * @returns + * @param token - JWT string to decode + * @param secret - Secret key used for both decrypting and verifying the JWT (CryptoKey, KeyObject, string or Uint8Array) + * @param options - Optional algorithm configuration for decryption and verification */ export const decodeJWT = async ( token: string, secret: SecretInput | DerivedKeyInput, - options?: DecodedJWTPayloadOptions + options?: DecodeJWTOptions ): Promise> => { try { const { jweSecret, jwsSecret } = getSecrets(secret) - const { verifyJWS } = createJWS(jwsSecret) - const { decryptJWE } = createJWE(jweSecret) - const decrypted = await decryptJWE(token, options?.jwt) - return await verifyJWS(decrypted, options?.jws) + const decrypted = await decryptCompactJWE(token, jweSecret, options?.decrypt) + return await verifyJWS(decrypted, jwsSecret, options?.verify) } catch (error) { if (isAuraJoseError(error)) { throw error @@ -95,13 +118,16 @@ export const decodeJWT = async ( * implements the `signJWS`, `verifyJWS`, `encryptJWE` and `decryptJWE` functions of the module. * * @param secret - Secret key used for signing, verifying, encrypting and decrypting the JWT + * @param options - Optional algorithm configuration for signing and encryption * @returns JWT handler object with `signJWS/encryptJWE` and `verifyJWS/decryptJWE` methods */ export const createJWT = (secret: SecretInput | DerivedKeyInput) => { return { - encodeJWT: async (payload: TypedJWTPayload>) => - await encodeJWT(payload, secret), - decodeJWT: async (token: string, options?: DecodedJWTPayloadOptions) => + encodeJWT: async ( + payload: TypedJWTPayload>, + options?: EncodeJWTOptions + ) => await encodeJWT(payload, secret, options), + decodeJWT: async (token: string, options?: DecodeJWTOptions) => await decodeJWT(token, secret, options), } } diff --git a/packages/jose/src/secret.ts b/packages/jose/src/secret.ts index 42e0667b..3ce94dcc 100644 --- a/packages/jose/src/secret.ts +++ b/packages/jose/src/secret.ts @@ -55,8 +55,8 @@ export const createSecret = (secret: SecretInput, length: number = 32) => { } 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 + const jwsSecret = isObject(secret) && "sign" in secret ? secret.sign : secret + const jweSecret = isObject(secret) && "encrypt" in secret ? secret.encrypt : secret return { jwsSecret, jweSecret, diff --git a/packages/jose/src/sign.ts b/packages/jose/src/sign.ts index ad468a7f..09579d2d 100644 --- a/packages/jose/src/sign.ts +++ b/packages/jose/src/sign.ts @@ -1,11 +1,11 @@ -import { base64url, jwtVerify, SignJWT, type JWTPayload, type JWTVerifyOptions } from "jose" +import { base64url, jwtVerify, SignJWT, type JWTPayload, type JWTVerifyOptions, type JWTHeaderParameters } from "jose" import { createSecret } from "@/secret.ts" import { getRandomBytes } from "@/crypto.ts" import { isAuraJoseError, isFalsy, isInvalidPayload } from "@/assert.ts" import { JWSSigningError, JWSVerificationError, InvalidPayloadError } from "@/errors.ts" import type { SecretInput, TypedJWTPayload } from "@/index.ts" -export type { JWTVerifyOptions } from "jose" +export type { JWTVerifyOptions, JWTHeaderParameters } from "jose" /** * Sign a standard JWT token with the following claims: @@ -18,11 +18,13 @@ export type { JWTVerifyOptions } from "jose" * * @param payload - Payload data information to sign the JWT * @param secret - Secret key to sign the JWT (CryptoKey, KeyObject, string or Uint8Array) + * @param options - Optional signing options (e.g. algorithm) * @returns Signed JWT string */ export const signJWS = async ( payload: TypedJWTPayload>, - secret: SecretInput + secret: SecretInput, + options?: JWTHeaderParameters ): Promise => { try { if (isInvalidPayload(payload)) { @@ -32,11 +34,11 @@ export const signJWS = async ( const jti = base64url.encode(getRandomBytes(32)) return await new SignJWT(payload) - .setProtectedHeader({ alg: "HS256", typ: "JWT" }) - .setIssuedAt() + .setProtectedHeader({ alg: "HS256", typ: "JWT", ...options }) + .setIssuedAt(payload.iat ?? "0s") .setNotBefore(payload.nbf ?? "0s") .setExpirationTime(payload.exp ?? "15d") - .setJti(jti) + .setJti(payload.jti ?? jti) .sign(secretKey) } catch (error) { if (isAuraJoseError(error)) { @@ -66,7 +68,11 @@ export const verifyJWS = async ( throw new InvalidPayloadError("The token must be a non-empty string") } const secretKey = createSecret(secret) - const { payload } = await jwtVerify(token, secretKey, options) + const { payload } = await jwtVerify(token, secretKey, { + algorithms: ["HS256"], + typ: "JWT", + ...options, + }) return payload as TypedJWTPayload } catch (error) { if (isAuraJoseError(error)) { @@ -76,18 +82,21 @@ export const verifyJWS = async ( } } -/** +/* * Create a JWS (JSON Web Signature) signer and verifier. It implements the `signJWS` * and `verifyJWS` functions of the module. * * @param secret - Secret key used for signing and verifying the JWS + * @param options - Optional signing options (e.g. algorithm) * @returns signJWS and verifyJWS functions */ export const createJWS = (secret: SecretInput) => { return { - signJWS: (payload: TypedJWTPayload>) => - signJWS(payload, secret), - verifyJWS: (payload: string, options?: JWTVerifyOptions) => - verifyJWS(payload, secret, options), + signJWS: ( + payload: TypedJWTPayload>, + options?: JWTHeaderParameters + ) => signJWS(payload, secret, options), + verifyJWS: (payload: string, verifyOptions?: JWTVerifyOptions) => + verifyJWS(payload, secret, verifyOptions), } } diff --git a/packages/jose/test/index.test.ts b/packages/jose/test/index.test.ts index 9290916a..ffbe42d3 100644 --- a/packages/jose/test/index.test.ts +++ b/packages/jose/test/index.test.ts @@ -3,7 +3,7 @@ import { createSecret } from "@/secret.ts" import { encoder, getRandomBytes } from "@/crypto.ts" import { createJWS, signJWS, verifyJWS } from "@/sign.ts" import { deriveKey, createDeriveKey } from "@/deriveKey.ts" -import { createJWE, encryptJWE, decryptJWE } from "@/encrypt.ts" +import { createCompactJWE, createJWE, decryptCompactJWE, decryptJWE, encryptJWE, compactEncryptJWE } from "@/encrypt.ts" import { createJWT, MIN_SECRET_ENTROPY_BITS, type SecretInput } from "@/index.ts" import type { JWTPayload } from "jose" @@ -85,6 +85,67 @@ describe("JWSs", () => { const jws = await signJWS({ exp, name: "John Doe" }, secretKey) expect(await verifyJWS(jws, secretKey)).toMatchObject({ name: "John Doe", exp }) }) + + test("set not before time in the payload of a JWS and verify it", async () => { + const secretKey = getRandomBytes(32) + const now = Math.floor(Date.now() / 1000) + const nbf = now + 60 + const jws = await signJWS({ nbf, name: "John Doe" }, secretKey) + await expect(verifyJWS(jws, secretKey)).rejects.toThrow("JWS signature verification failed") + }) + + test("set issued at time in the payload of a JWS and verify it", async () => { + const secretKey = getRandomBytes(32) + const iat = Math.floor(Date.now() / 1000) + const jws = await signJWS({ iat, name: "John Doe" }, secretKey) + expect(await verifyJWS(jws, secretKey)).toMatchObject({ name: "John Doe", iat }) + }) + + test("set JWT ID in the payload of a JWS and verify it", async () => { + const secretKey = getRandomBytes(32) + const jti = "unique-jwt-id-123" + const jws = await signJWS({ jti, name: "John Doe" }, secretKey) + expect(await verifyJWS(jws, secretKey)).toMatchObject({ name: "John Doe", jti }) + }) + + test("set protected header parameters in a JWS and verify it", async () => { + const secretKey = getRandomBytes(32) + const jws = await signJWS({ name: "John Doe" }, secretKey, { alg: "HS256", typ: "JWT" }) + expect(await verifyJWS(jws, secretKey)).toMatchObject({ name: "John Doe" }) + }) + + test("fail JWT to sign a JWS with invalid protected header parameters", async () => { + const secretKey = getRandomBytes(32) + await expect(signJWS({ name: "John Doe" }, secretKey, { alg: "invalid-algorithm" })).rejects.toThrow("JWS signing failed") + }) + + test("set none algorithm in the protected header of a JWS and fail to verify it", async () => { + const secretKey = getRandomBytes(32) + await expect(signJWS({ name: "John Doe" }, secretKey, { alg: "none" })).rejects.toThrow("JWS signing failed") + }) + + test("set custom protected header parameters in a JWS and verify it", async () => { + const secretKey = getRandomBytes(32) + const jws = await signJWS({ name: "John Doe" }, secretKey, { alg: "HS256", typ: "JWT", kid: "key-id-123" }) + expect(await verifyJWS(jws, secretKey)).toMatchObject({ name: "John Doe" }) + }) + + test("verify JWT with audience claim", async () => { + const secretKey = getRandomBytes(32) + const jws = await signJWS({ name: "John Doe", aud: "https://example.com" }, secretKey) + expect(await verifyJWS(jws, secretKey, { audience: "https://example.com" })).toMatchObject({ + name: "John Doe", + aud: "https://example.com", + }) + }) + + test("fail JWT to verify a JWS with incorrect audience claim", async () => { + const secretKey = getRandomBytes(32) + const jws = await signJWS({ name: "John Doe", aud: "https://example.com" }, secretKey) + await expect(verifyJWS(jws, secretKey, { audience: "https://wrong-audience.com" })).rejects.toThrow( + "JWS signature verification failed" + ) + }) }) describe("JWEs", () => { @@ -92,15 +153,11 @@ describe("JWEs", () => { const secretKey = getRandomBytes(32) const derivedKey = await createDeriveKey(secretKey) - const jwe = await encryptJWE(JSON.stringify(payload), derivedKey) + const jwe = await encryptJWE({ payload }, derivedKey) expect(jwe).toBeDefined() - const decryptedPayload = await decryptJWE(jwe, derivedKey) - const decodedPayload = JSON.parse(decryptedPayload) as JWTPayload - - expect(decodedPayload.sub).toBe(payload.sub) - expect(decodedPayload.name).toBe(payload.name) - expect(decodedPayload.email).toBe(payload.email) + const decryptedPayload = await decryptJWE<{ payload: string }>(jwe, derivedKey) + expect(decryptedPayload.payload).toMatchObject(payload) }) test("encrypt and decrypt a JWE using createJWE", async () => { @@ -111,37 +168,54 @@ describe("JWEs", () => { const { encryptJWE, decryptJWE } = createJWE(derivedKey) const jws = await signJWS(payload) - const jwe = await encryptJWE(jws) + const jwe = await encryptJWE({ payload: jws }) expect(jwe).toBeDefined() - const decryptedJWS = await decryptJWE(jwe) - expect(decryptedJWS).toBe(jws) + const decryptedJWS = await decryptJWE<{ payload: string }>(jwe) + expect(decryptedJWS.payload).toBe(jws) }) test("fail JWT to try to decrypt an invalid JWE", async () => { const secretKey = getRandomBytes(32) - const derivedKey = await createDeriveKey(secretKey) - - const { decryptJWE } = createJWE(derivedKey) - await expect(decryptJWE("header.payload.signature")).rejects.toThrow() + await expect(decryptJWE("header.payload.signature", secretKey)).rejects.toThrow() }) test("set audience in a JWE and decrypt it", async () => { const secretKey = getRandomBytes(32) - const jwe = await encryptJWE(JSON.stringify({ aud: "client_id_123", name: "John Doe" }), secretKey) + const jwe = await encryptJWE({ aud: "client_id_123", name: "John Doe" }, secretKey) const decrypted = await decryptJWE(jwe, secretKey) - const payload = JSON.parse(decrypted) as JWTPayload - expect(payload).toMatchObject({ aud: "client_id_123", name: "John Doe" }) + expect(decrypted).toMatchObject({ aud: "client_id_123", name: "John Doe" }) }) test("fail JWT to verify a JWE with incorrect audience", async () => { const secretKey = getRandomBytes(32) const jws = await signJWS({ aud: "client_id_123", name: "John Doe" }, secretKey) - const jwe = await encryptJWE(jws, secretKey) + const jwe = await encryptJWE({ payload: jws }, secretKey) await expect(decryptJWE(jwe, secretKey, { audience: "wrong_audience" })).rejects.toThrow( "JWE decryption verification failed" ) }) + + test("encrypt and decrypt compact JWE payload", async () => { + const secretKey = getRandomBytes(32) + const jws = await signJWS(payload, secretKey) + + const compactJWE = await compactEncryptJWE(jws, secretKey) + expect(compactJWE).toBeDefined() + + const decryptedJWS = await decryptCompactJWE(compactJWE, secretKey) + expect(decryptedJWS).toBe(jws) + }) + + test("encrypt and decrypt compact JWE payload using createCompactJWE", async () => { + const secretKey = getRandomBytes(32) + const jws = await signJWS(payload, secretKey) + const { compactEncryptJWE, decryptCompactJWE } = createCompactJWE(secretKey) + + const compactJWE = await compactEncryptJWE(jws) + const decryptedJWS = await decryptCompactJWE(compactJWE) + expect(decryptedJWS).toBe(jws) + }) }) describe("JWTs", () => { @@ -153,13 +227,13 @@ describe("JWTs", () => { const { encryptJWE, decryptJWE } = createJWE(derivedKey) const jws = await signJWS(payload) - const jwe = await encryptJWE(jws) + const jwe = await encryptJWE({ payload: jws }) expect(jwe).toBeDefined() - const decryptedJWS = await decryptJWE(jwe) - expect(decryptedJWS).toBe(jws) + const decryptedJWS = await decryptJWE<{ payload: string }>(jwe) + expect(decryptedJWS.payload).toBe(jws) - const decodedPayload = await verifyJWS(decryptedJWS) + const decodedPayload = await verifyJWS(decryptedJWS.payload) expect(decodedPayload.sub).toBe(payload.sub) expect(decodedPayload.name).toBe(payload.name) expect(decodedPayload.email).toBe(payload.email) @@ -193,7 +267,7 @@ describe("JWTs", () => { const derivedSigningKey = await createDeriveKey(secret, "salt", "signing") const derivedEncryptionKey = await createDeriveKey(secret, "salt", "encryption") - const { encodeJWT, decodeJWT } = createJWT({ jws: derivedSigningKey, jwe: derivedEncryptionKey }) + const { encodeJWT, decodeJWT } = createJWT({ sign: derivedSigningKey, encrypt: derivedEncryptionKey }) const jwt = await encodeJWT(payload) expect(jwt).toBeDefined() @@ -292,3 +366,25 @@ describe("deriveKey", () => { expect(derivedKey2).toEqual(derivedKey3) }) }) + +describe("createJWT", () => { + test("createJWT with separate JWS and JWE secrets", async () => { + const secret = getRandomBytes(32) + const derivedSigningKey = await createDeriveKey(secret, "salt", "signing") + const derivedEncryptionKey = await createDeriveKey(secret, "salt", "encryption") + + const { encodeJWT, decodeJWT } = createJWT({ sign: derivedSigningKey, encrypt: derivedEncryptionKey }) + + 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) + }) + + test("createJWT with invalid secret", async () => { + const { encodeJWT } = createJWT("short") + await expect(encodeJWT(payload)).rejects.toThrow("Secret string must be at least 32 bytes long") + }) +}) diff --git a/packages/jose/test/types.test-d.ts b/packages/jose/test/types.test-d.ts index a76d4c67..12440ad7 100644 --- a/packages/jose/test/types.test-d.ts +++ b/packages/jose/test/types.test-d.ts @@ -1,5 +1,5 @@ import { describe, expectTypeOf, test } from "vitest" -import type { JWTPayload, JWTVerifyOptions } from "jose" +import type { JWTHeaderParameters, JWTPayload, JWTVerifyOptions } from "jose" import { createJWS, createJWT, @@ -8,9 +8,10 @@ import { signJWS, verifyJWS, type SecretInput, - type DecodedJWTPayloadOptions, + type DecodeJWTOptions, type TypedJWTPayload, type DerivedKeyInput, + type EncodeJWTOptions, } from "@/index.ts" interface User extends Record { @@ -29,12 +30,15 @@ describe("type-safe payload", () => { test("createJWT", async () => { const jwt = createJWT("secret") expectTypeOf(jwt.encodeJWT).toEqualTypeOf< - (payload: TypedJWTPayload>) => Promise + ( + payload: TypedJWTPayload>, + options?: EncodeJWTOptions + ) => Promise >() expectTypeOf(jwt.decodeJWT).toEqualTypeOf< ( token: string, - options?: DecodedJWTPayloadOptions + options?: DecodeJWTOptions ) => Promise> >() @@ -67,13 +71,16 @@ describe("type-safe payload", () => { .toEqualTypeOf() expectTypeOf(decodeJWT) .parameter(2) - .toEqualTypeOf() + .toEqualTypeOf() }) test("createJWS", async () => { const jws = createJWS("secret") expectTypeOf(jws.signJWS).toEqualTypeOf< - (payload: TypedJWTPayload>) => Promise + ( + payload: TypedJWTPayload>, + options?: JWTHeaderParameters + ) => Promise >() expectTypeOf(jws.verifyJWS).toEqualTypeOf< ( @@ -97,6 +104,9 @@ describe("type-safe payload", () => { expectTypeOf(signJWS) .parameter(1) .toEqualTypeOf() + expectTypeOf(signJWS) + .parameter(2) + .toEqualTypeOf() }) test("verifyJWS", async () => {