diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index c169f7f4..ea302854 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -13,7 +13,7 @@ import type { JWTKey, SessionConfig, SessionStrategy, User } from "@/@types/sess * Main configuration interface for Aura Auth. * This is the user-facing configuration object passed to `createAuth()`. */ -export interface AuthConfig { +export type AuthConfig = { /** * OAuth providers available in the authentication and authorization flows. It provides a type-inference * for the OAuth providers that are supported by Aura Stack Auth; alternatively, you can provide a custom @@ -100,45 +100,11 @@ export interface AuthConfig { * Base path for all authentication routes. Default is `/auth`. */ basePath?: `/${string}` - /** - * Enable trusted proxy headers for scenarios where the application is behind a reverse proxy or load balancer. - * This setting allows Aura Auth to correctly interpret headers like `X-Forwarded-For` and `X-Forwarded-Proto` - * to determine the original client IP address and protocol. - * - * Default is `false`. Enable this option only if you are certain that your application is behind a trusted proxy. - * Misconfiguration can lead to security vulnerabilities, such as incorrect handling of secure cookies or - * inaccurate client IP logging. - * - * This value can also be set via environment variable as `AURA_AUTH_TRUSTED_PROXY_HEADERS` - * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded - * @experimental - */ - trustedProxyHeaders?: boolean /** * Logger configuration for handling authentication-related logs and errors. It can be set to `true`, * `DEBUG=true`, `LOG_LEVEL=debug`, or a custom logger. It implements the syslog format. */ logger?: boolean | Logger - /** - * Defines trusted origins for your application to prevent open redirect attacks. - * URLs from the Referer header, Origin header, request URL, and redirectTo option - * are validated against this list before redirecting. - * - * - **Exact URL**: `https://example.com` matches only that origin. - * - **Subdomain wildcard**: `https://*.example.com` matches `https://app.example.com`, `https://api.example.com`, etc. - * @example - * trustedOrigins: ["https://example.com", "https://*.example.com", "http://localhost:3000"] - * - * - * trustedOrigins: async (request) => { - * const origin = new URL(request.url).origin - * return [origin, "https://admin.example.com"] - * } - */ - trustedOrigins?: TrustedOrigin[] | ((request: Request) => Promise | TrustedOrigin[]) /** * Defines the session management strategy for Aura Auth. It determines how sessions are created, stored, and validated. */ @@ -166,15 +132,115 @@ export interface AuthConfig { * } */ identity?: Partial<{ + /** + * Skip schema validation for session data, JWT payloads, and OAuth profiles. + * This can be useful for performance optimization if you are certain that the + * data is valid, but it can lead to security vulnerabilities if misused. + * > ⚠️ WARNING: Use this option with caution. + */ skipValidation: boolean + /** + * Custom schema validation for user identity data. It supports any Zod, Arktype, + * Valibot or Typebox schema. Use `createIdentity` helper function to create a schema + * with the correct shape and inference. + */ schema: ConfigSchema + /** + * Defines how unknown keys are handled during schema validation. It can be set to: + * - `passthrough`: Unknown keys are allowed and included in the validated data. + * - `strict`: Unknown keys will cause validation to fail with an error. + * - `strip`: Unknown keys are removed from the validated data. + */ unknownKeys: "passthrough" | "strict" | "strip" }> /** * Credentials provider for username/password or similar authentication. */ credentials?: CredentialsProvider -} +} & TrustedProxyHeadersConfig + +// @todo Should trustedOrigins support subdomain wildcards like `https://*.example.com`? +// This option could introduce security risks if misconfigured. +export type TrustedProxyHeadersConfig = + | { + /** + * Enable trusted proxy headers for scenarios where the application is behind a reverse proxy or load balancer. + * This setting allows Aura Auth to correctly interpret headers like `X-Forwarded-For` and `X-Forwarded-Proto` + * to determine the original client IP address and protocol. + * + * Default is `false`. Enable this option only if you are certain that your application is behind a trusted proxy. + * Misconfiguration can lead to security vulnerabilities, such as incorrect handling of secure cookies or + * inaccurate client IP logging. + * + * This value can also be set via environment variable as `AURA_AUTH_TRUSTED_PROXY_HEADERS` + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded + * @experimental + */ + trustedProxyHeaders: true + /** + * Defines trusted origins for your application to prevent open redirect attacks. + * URLs from the Referer header, Origin header, request URL, and redirectTo option + * are validated against this list before redirecting. + * + * - **Exact URL**: `https://example.com` matches only that origin. + * - **Subdomain wildcard**: `https://*.example.com` matches `https://app.example.com`, `https://api.example.com`, etc. + * + * > **⚠️ WARNING:** Ensure that the trusted origins are configured correctly to prevent open redirect vulnerabilities. + * Only include origins that you control and trust. + * + * @example + * trustedOrigins: ["https://example.com", "https://*.example.com", "http://localhost:3000"] + * + * trustedOrigins: async (request) => { + * const origin = new URL(request.url).origin + * return [origin, "https://admin.example.com"] + * } + */ + trustedOrigins: TrustedOrigin[] | ((request: Request) => Promise | TrustedOrigin[]) + } + | { + /** + * Enable trusted proxy headers for scenarios where the application is behind a reverse proxy or load balancer. + * This setting allows Aura Auth to correctly interpret headers like `X-Forwarded-For` and `X-Forwarded-Proto` + * to determine the original client IP address and protocol. + * + * Default is `false`. Enable this option only if you are certain that your application is behind a trusted proxy. + * Misconfiguration can lead to security vulnerabilities, such as incorrect handling of secure cookies or + * inaccurate client IP logging. + * + * This value can also be set via environment variable as `AURA_AUTH_TRUSTED_PROXY_HEADERS` + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded + * @experimental + */ + trustedProxyHeaders?: false + /** + * Defines trusted origins for your application to prevent open redirect attacks. + * URLs from the Referer header, Origin header, request URL, and redirectTo option + * are validated against this list before redirecting. + * + * - **Exact URL**: `https://example.com` matches only that origin. + * - **Subdomain wildcard**: `https://*.example.com` matches `https://app.example.com`, `https://api.example.com`, etc. + * + * > **⚠️ WARNING:** Ensure that the trusted origins are configured correctly to prevent open redirect vulnerabilities. + * Only include origins that you control and trust. + * + * @example + * trustedOrigins: ["https://example.com", "https://*.example.com", "http://localhost:3000"] + * + * trustedOrigins: async (request) => { + * const origin = new URL(request.url).origin + * return [origin, "https://admin.example.com"] + * } + * + */ + trustedOrigins?: TrustedOrigin[] | ((request: Request) => Promise | TrustedOrigin[]) + } /** * Cookie type with __Secure- prefix, must be Secure. @@ -207,10 +273,9 @@ export type CookieStrategyAttributes = StandardCookie | SecureCookie | HostCooki * - `sessionToken`: User session JWT * - `csrfToken`: CSRF protection token * - `state`: OAuth state parameter for CSRF protection - * - `code_verifier`: PKCE code verifier for authorization code flow - * - `redirect_uri`: OAuth callback URI - * - `redirect_to`: Post-authentication redirect path - * - `nonce`: OpenID Connect nonce parameter + * - `codeVerifier`: PKCE code verifier for authorization code flow + * - `redirectURI`: OAuth callback URI + * - `redirectTo`: Post-authentication redirect path */ export type CookieName = "sessionToken" | "csrfToken" | "state" | "codeVerifier" | "redirectTo" | "redirectURI" @@ -222,6 +287,10 @@ export interface CookieConfig { * Prefix to be added to all cookie names. By default "aura-stack". */ prefix?: string + /** + * Overrides for individual cookie configurations. + * @see {@link CookieStoreConfig} for the structure of each cookie configuration. + */ overrides?: Partial } @@ -365,8 +434,17 @@ export type AuthRuntimeConfig = RouterGlobalCon * Public auth instance: programmatic {@link AuthAPI}, {@link JoseInstance}, and HTTP {@link AuthClient} handlers. */ export interface AuthInstance { + /** + * Programmatic API for authentication actions (getSession, signIn, signOut, etc.) that can be used in server-side contexts or API routes. + */ api: AuthAPI + /** + * JOSE helper functions for signin, encryption and verification of JWTs. + */ jose: JoseInstance + /** + * HTTP handlers for mounting on a router or server. + */ handlers: { GET: (request: Request) => Response | Promise POST: (request: Request) => Response | Promise diff --git a/packages/core/src/actions/callback/access-token.ts b/packages/core/src/actions/callback/access-token.ts index 7c33ee15..7bbb1681 100644 --- a/packages/core/src/actions/callback/access-token.ts +++ b/packages/core/src/actions/callback/access-token.ts @@ -87,10 +87,10 @@ export const createAccessToken = async ( logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS") return token.data } catch (error) { - logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_FAILED") - if (error instanceof Error) { - throw new OAuthProtocolError("server_error", "Failed to communicate with OAuth provider", "", { cause: error }) + if (error instanceof OAuthProtocolError) { + throw error } - throw error + logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_FAILED") + throw new OAuthProtocolError("server_error", "Failed to communicate with OAuth provider", "", { cause: error }) } } diff --git a/packages/core/src/actions/callback/callback.ts b/packages/core/src/actions/callback/callback.ts index e8574af9..6ccb1cc0 100644 --- a/packages/core/src/actions/callback/callback.ts +++ b/packages/core/src/actions/callback/callback.ts @@ -5,7 +5,7 @@ import { cacheControl } from "@/shared/headers.ts" import { timingSafeEqual } from "@/shared/utils.ts" import { getUserInfo } from "@/actions/callback/userinfo.ts" import { OAuthAuthorizationErrorResponse } from "@/schemas.ts" -import { getCookie, expiredCookieAttributes } from "@/cookie.ts" +import { getCookie, getExpiredCookie } from "@/cookie.ts" import { createAccessToken } from "@/actions/callback/access-token.ts" import { AuthSecurityError, OAuthProtocolError } from "@/shared/errors.ts" import { isRelativeURL, isSameOrigin, isTrustedOrigin } from "@/shared/assert.ts" @@ -77,15 +77,25 @@ export const callbackAction = (oauth: OAuthProviderRecord) => { const cookieRedirectTo = getCookie(request, cookies.redirectTo.name) const cookieRedirectURI = getCookie(request, cookies.redirectURI.name) + const clearCookieHeaders = new HeadersBuilder(cacheControl) + .setCookie(cookies.state.name, "", getExpiredCookie(cookies.state.attributes)) + .setCookie(cookies.redirectURI.name, "", getExpiredCookie(cookies.redirectURI.attributes)) + .setCookie(cookies.redirectTo.name, "", getExpiredCookie(cookies.redirectTo.attributes)) + .setCookie(cookies.codeVerifier.name, "", getExpiredCookie(cookies.codeVerifier.attributes)) + if (!timingSafeEqual(cookieState, state)) { logger?.log("MISMATCHING_STATE", { structuredData: { oauth_provider: oauth, }, }) - throw new AuthSecurityError( - "MISMATCHING_STATE", - "The provided state passed in the OAuth response does not match the stored state." + return Response.json( + { + type: "AUTH_SECURITY_ERROR", + code: "MISMATCHING_STATE", + message: "The provided state passed in the OAuth response does not match the stored state.", + }, + { headers: clearCookieHeaders.toHeaders(), status: 400 } ) } @@ -124,14 +134,10 @@ export const callbackAction = (oauth: OAuthProviderRecord) => { }, }) - const headers = new HeadersBuilder(cacheControl) + const headers = clearCookieHeaders .setHeader("Location", cookieRedirectTo) .setCookie(cookies.sessionToken.name, session, cookies.sessionToken.attributes) .setCookie(cookies.csrfToken.name, csrfToken, cookies.csrfToken.attributes) - .setCookie(cookies.state.name, "", expiredCookieAttributes) - .setCookie(cookies.redirectURI.name, "", expiredCookieAttributes) - .setCookie(cookies.redirectTo.name, "", expiredCookieAttributes) - .setCookie(cookies.codeVerifier.name, "", expiredCookieAttributes) .toHeaders() return Response.json({ oauth }, { status: 302, headers: headers }) }, diff --git a/packages/core/src/actions/callback/userinfo.ts b/packages/core/src/actions/callback/userinfo.ts index f8982113..87ab8b88 100644 --- a/packages/core/src/actions/callback/userinfo.ts +++ b/packages/core/src/actions/callback/userinfo.ts @@ -1,5 +1,4 @@ import { fetchAsync } from "@/shared/fetch-async.ts" -import { createSecretValue } from "@/shared/crypto.ts" import { AURA_AUTH_VERSION } from "@/shared/utils.ts" import { OAuthErrorResponse } from "@/schemas.ts" import { isNativeError, isOAuthProtocolError, OAuthProtocolError } from "@/shared/errors.ts" @@ -12,10 +11,13 @@ import type { InternalLogger, OAuthProviderCredentials, User } from "@/@types/in * @returns The standardized OAuth user profile */ const getDefaultUserInfo = (profile: Record): User => { - const sub = createSecretValue(16) + const sub = profile?.id ?? profile?.sub ?? profile?.uid ?? profile?.user_id ?? profile?.account_id + if (!sub) { + throw new OAuthProtocolError("invalid_userinfo", "OAuth provider did not return a stable user identifier (id/sub/uid).") + } return { - sub: profile?.id ?? profile?.sub ?? sub, + sub, email: profile?.email, name: profile?.name ?? profile?.username ?? profile?.nickname, image: profile?.image ?? profile?.picture, diff --git a/packages/core/src/actions/signIn/authorization.ts b/packages/core/src/actions/signIn/authorization.ts index fb4fe922..862de7ea 100644 --- a/packages/core/src/actions/signIn/authorization.ts +++ b/packages/core/src/actions/signIn/authorization.ts @@ -1,10 +1,10 @@ import { getEnv } from "@/shared/env.ts" +import { Identities } from "@/shared/identity.ts" import { AuthInternalError } from "@/shared/errors.ts" import { equals, extractPath, patternToRegex } from "@/shared/utils.ts" import { isRelativeURL, isSameOrigin, isValidURL, isTrustedOrigin } from "@/shared/assert.ts" import type { AuthConfig } from "@/@types/index.ts" import type { GlobalContext } from "@aura-stack/router" -import { Identities } from "@/shared/identity.ts" /** * Resolves trusted origins from config (array or function). diff --git a/packages/core/src/api/getSession.ts b/packages/core/src/api/getSession.ts index 85549291..ff29f07b 100644 --- a/packages/core/src/api/getSession.ts +++ b/packages/core/src/api/getSession.ts @@ -1,7 +1,7 @@ import { getErrorName, toUnionHeaders } from "@/shared/utils.ts" import { HeadersBuilder } from "@aura-stack/router" import { secureApiHeaders } from "@/shared/headers.ts" -import { expiredCookieAttributes } from "@/cookie.ts" +import { getExpiredCookie } from "@/cookie.ts" import type { User } from "@/@types/session.ts" import type { FunctionAPIContext, GetSessionAPIOptions, GetSessionAPIReturn } from "@/@types/api.ts" @@ -10,8 +10,8 @@ export const getSession = async ({ headers: headersInit, }: FunctionAPIContext): Promise> => { const headers = new HeadersBuilder(secureApiHeaders) - .setCookie(ctx.cookies.sessionToken.name, "", { ...ctx.cookies.sessionToken.attributes, ...expiredCookieAttributes }) - .setCookie(ctx.cookies.csrfToken.name, "", { ...ctx.cookies.csrfToken.attributes, ...expiredCookieAttributes }) + .setCookie(ctx.cookies.sessionToken.name, "", getExpiredCookie(ctx.cookies.sessionToken.attributes)) + .setCookie(ctx.cookies.csrfToken.name, "", getExpiredCookie(ctx.cookies.csrfToken.attributes)) .toHeaders() const unauthorizedError = { code: "GET_SESSION_FAILED", @@ -32,7 +32,7 @@ export const getSession = async ({ session, headers: newHeaders, success: true, - toResponse: () => Response.json({ success: true, session, error: unauthorizedError }, { headers: newHeaders }), + toResponse: () => Response.json({ success: true, session }, { headers: newHeaders }), } as GetSessionAPIReturn } catch (error) { ctx?.logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } }) diff --git a/packages/core/src/cookie.ts b/packages/core/src/cookie.ts index 9ecdb326..a0d7be5f 100644 --- a/packages/core/src/cookie.ts +++ b/packages/core/src/cookie.ts @@ -64,11 +64,13 @@ export const setCookie = (cookieName: string, value: string, options?: Serialize return serialize(cookieName, value, options) } -export const expiredCookieAttributes: SerializeOptions = { - ...defaultCookieOptions, - expires: new Date(0), - maxAge: 0, - secure: true, +export const getExpiredCookie = (options?: SerializeOptions) => { + return { + ...options, + expires: new Date(0), + maxAge: 0, + secure: options?.secure ?? true, + } } /** diff --git a/packages/core/src/jose.ts b/packages/core/src/jose.ts index dfbeb12d..8bdb5161 100644 --- a/packages/core/src/jose.ts +++ b/packages/core/src/jose.ts @@ -26,9 +26,9 @@ import { isSealedMode, isSignedMode, } from "@/shared/assert.ts" +import { importPEMKeyPair } from "@/shared/crypto.ts" export { encoder, getRandomBytes, getSubtleCrypto } from "@aura-stack/jose/crypto" import type { User, SessionConfig, JWTKey, AsymmetricKeyPairFromEnv } from "@/@types/index.ts" -import { importPEMKeyPair } from "./shared/crypto.ts" const getJWTConfig = (config?: SessionConfig) => { return config?.jwt @@ -285,7 +285,6 @@ export const createJoseInstance = (secret?: JWT jwe: createJWE(jweSecret), } })() - jose.catch(() => {}) return { signJWS: async (payload: TypedJWTPayload>, options?: JWTHeaderParameters) => { diff --git a/packages/core/src/session/cookie-manager.ts b/packages/core/src/session/cookie-manager.ts index aee5c3dc..0fdba993 100644 --- a/packages/core/src/session/cookie-manager.ts +++ b/packages/core/src/session/cookie-manager.ts @@ -1,6 +1,6 @@ import { HeadersBuilder } from "@aura-stack/router" import { secureApiHeaders } from "@/shared/headers.ts" -import { expiredCookieAttributes, getCookie as getCookieByName } from "@/cookie.ts" +import { getExpiredCookie, getCookie as getCookieByName } from "@/cookie.ts" import type { CookieStoreConfig } from "@/@types/index.ts" export const createCookieManager = (store: () => CookieStoreConfig) => { @@ -19,8 +19,8 @@ export const createCookieManager = (store: () => CookieStoreConfig) => { const clear = () => { return new HeadersBuilder(secureApiHeaders) - .setCookie(store().csrfToken.name, "", { ...expiredCookieAttributes, ...store().csrfToken.attributes }) - .setCookie(store().sessionToken.name, "", { ...expiredCookieAttributes, ...store().sessionToken.attributes }) + .setCookie(store().csrfToken.name, "", getExpiredCookie(store().csrfToken.attributes)) + .setCookie(store().sessionToken.name, "", getExpiredCookie(store().sessionToken.attributes)) .toHeaders() } return { getCookie, setCookie, clear } diff --git a/packages/core/src/session/stateless.ts b/packages/core/src/session/stateless.ts index 2fa88bb7..27f96e17 100644 --- a/packages/core/src/session/stateless.ts +++ b/packages/core/src/session/stateless.ts @@ -89,13 +89,6 @@ export const createStatelessStrategy = ({ throw new AuthSecurityError("CSRF_TOKEN_INVALID", "CSRF token verification failed") } logger?.log("CSRF_TOKEN_VERIFIED") - } else { - try { - await jose.verifyJWS(csrfToken) - } catch (error) { - logger?.log("CSRF_TOKEN_INVALID", { structuredData: { error_type: getErrorName(error) } }) - throw new AuthSecurityError("CSRF_TOKEN_INVALID", "CSRF token verification failed") - } } try { await jose.decodeJWT(session) diff --git a/packages/core/src/shared/crypto.ts b/packages/core/src/shared/crypto.ts index c4dca6ed..036bfe61 100644 --- a/packages/core/src/shared/crypto.ts +++ b/packages/core/src/shared/crypto.ts @@ -96,8 +96,10 @@ export const verifyCSRF = async ( */ export const hashPassword = async (password: string, salt?: string, iterations = 100000) => { const subtle = getSubtleCrypto() - const saltBuffer = (salt ? base64url.decode(salt) : getRandomBytes(16)) as any - const baseKey = await subtle.importKey("raw", encoder.encode(password) as any, "PBKDF2", false, ["deriveBits"]) + const saltBuffer = (salt ? base64url.decode(salt) : getRandomBytes(16)) as Uint8Array + const baseKey = await subtle.importKey("raw", encoder.encode(password) as Uint8Array, "PBKDF2", false, [ + "deriveBits", + ]) const derivedKey = await subtle.deriveBits( { name: "PBKDF2", @@ -130,7 +132,10 @@ export const verifyPassword = async (password: string, hashedPassword: string) = const iterations = parseInt(iterationsStr, 10) if (isNaN(iterations)) return false const newHashed = await hashPassword(password, saltStr, iterations) - return timingSafeEqual(newHashed, hashedPassword) + const [, , , hashA] = newHashed.split(":") + const [, , , hashB] = hashedPassword.split(":") + if (!hashA || !hashB) return false + return timingSafeEqual(hashA, hashB) } catch { return false } diff --git a/packages/core/src/shared/env.ts b/packages/core/src/shared/env.ts index 19f37a0a..caa3723f 100644 --- a/packages/core/src/shared/env.ts +++ b/packages/core/src/shared/env.ts @@ -34,7 +34,8 @@ export const env = new Proxy({} as Record, { export const getEnv = (key: string): string | undefined => { const keys = [`AURA_AUTH_${key.toUpperCase()}`, `AURA_${key.toUpperCase()}`, `AUTH_${key.toUpperCase()}`, key.toUpperCase()] - return env[keys.find((k) => env[k]) ?? ""] + const found = keys.find((k) => env[k] !== undefined) + return found ? env[found] : undefined } export const getEnvBoolean = (key: string): boolean => { diff --git a/packages/core/src/shared/errors.ts b/packages/core/src/shared/errors.ts index b5f4f366..e3826f2a 100644 --- a/packages/core/src/shared/errors.ts +++ b/packages/core/src/shared/errors.ts @@ -1,5 +1,17 @@ import type { AuthInternalErrorCode, AuthSecurityErrorCode, ErrorType, LiteralUnion } from "@/@types/index.ts" +interface V8ErrorConstructor extends ErrorConstructor { + captureStackTrace(targetObject: object, constructorOpt?: Function): void +} + +/** + * Type guard to check if the current runtime environment + * supports Error.captureStackTrace. + */ +export const hasCaptureStackTrace = (errorConstructor: ErrorConstructor): errorConstructor is V8ErrorConstructor => { + return "captureStackTrace" in errorConstructor && typeof (errorConstructor as any).captureStackTrace === "function" +} + /** * The object returned by the class to users its: * - type: "OAUTH_PROTOCOL_ERROR" to identify the error type @@ -17,7 +29,9 @@ export class OAuthProtocolError extends Error { this.error = error this.errorURI = errorURI this.name = new.target.name - Error?.captureStackTrace(this, new.target) + if (hasCaptureStackTrace(Error)) { + Error.captureStackTrace(this, new.target) + } } } @@ -35,7 +49,9 @@ export class AuthInternalError extends Error { super(message, options) this.code = code this.name = new.target.name - Error?.captureStackTrace(this, new.target) + if (hasCaptureStackTrace(Error)) { + Error.captureStackTrace(this, new.target) + } } } @@ -53,7 +69,9 @@ export class AuthSecurityError extends Error { super(message, options) this.code = code this.name = new.target.name - Error?.captureStackTrace(this, new.target) + if (hasCaptureStackTrace(Error)) { + Error.captureStackTrace(this, new.target) + } } } @@ -65,7 +83,9 @@ export class AuthClientError extends Error { super(message, options) this.code = code this.name = new.target.name - Error?.captureStackTrace(this, new.target) + if (hasCaptureStackTrace(Error)) { + Error.captureStackTrace(this, new.target) + } } } @@ -75,7 +95,9 @@ export class AuthInvalidConfigurationError extends Error { constructor(message?: string, options?: ErrorOptions) { super(message, options) this.name = new.target.name - Error?.captureStackTrace?.(this, new.target) + if (hasCaptureStackTrace(Error)) { + Error.captureStackTrace(this, new.target) + } } } @@ -87,7 +109,9 @@ export class AuthValidationError extends Error { super(message, options) this.code = code this.name = new.target.name - Error?.captureStackTrace?.(this, new.target) + if (hasCaptureStackTrace(Error)) { + Error.captureStackTrace(this, new.target) + } } } @@ -99,7 +123,9 @@ export class AuthJoseInitializationError extends Error { super(message, options) this.code = code this.name = new.target.name - Error?.captureStackTrace?.(this, new.target) + if (hasCaptureStackTrace(Error)) { + Error.captureStackTrace(this, new.target) + } } } diff --git a/packages/core/src/shared/utils.ts b/packages/core/src/shared/utils.ts index ffe31144..a360b627 100644 --- a/packages/core/src/shared/utils.ts +++ b/packages/core/src/shared/utils.ts @@ -83,6 +83,7 @@ export const patternToRegex = (pattern: string): RegExp | null => { const [, protocol, host, port] = match const hasWildcard = host.includes("*") if (hasWildcard && !host.startsWith("*.")) return null + if (hasWildcard && !host.startsWith("*.")) return null if (hasWildcard && host.slice(2).includes("*")) return null const domain = hasWildcard ? host.slice(2) : host diff --git a/packages/core/src/validator/registry.ts b/packages/core/src/validator/registry.ts index 7145165a..fe9024c5 100644 --- a/packages/core/src/validator/registry.ts +++ b/packages/core/src/validator/registry.ts @@ -117,6 +117,7 @@ export const deriveSchemaWithJWT = (schema: Schema): mexp: z.number().optional(), }) } + throw new AuthValidationError("INVALID_IDENTITY_VALIDATION_FAILED", "Unsupported schema type for JWT extension.") } export const getFullSchema = (schema: Schema): any => { @@ -150,10 +151,13 @@ export const getFullSchema = (schema: Schema): any = expires: Typebox.Optional(Typebox.String()), }) } - return z.object({ - user: schema, - expires: z.coerce.date().optional(), - }) + if (isZodSchema(schema)) { + return z.object({ + user: schema, + expires: z.coerce.date().optional(), + }) + } + throw new AuthValidationError("INVALID_IDENTITY_VALIDATION_FAILED", "Unsupported schema type for schema extension.") } const throwValidationError = (activeSchema: SchemaTypes, error: unknown): never => { diff --git a/packages/core/test/actions/callback/access-token.test.ts b/packages/core/test/actions/callback/access-token.test.ts index 86fed620..338c1a5a 100644 --- a/packages/core/test/actions/callback/access-token.test.ts +++ b/packages/core/test/actions/callback/access-token.test.ts @@ -92,7 +92,7 @@ describe("createAccessToken", async () => { await expect( createAccessToken(oauthCustomService, "https://myapp.com/auth/callback/oauth-provider", "invalid_code", codeVerifier) - ).rejects.toThrow(/Failed to communicate with OAuth provider/) + ).rejects.toThrow(/Failed to retrieve access token/) expect(fetch).toHaveBeenCalledWith("https://example.com/oauth/access_token", { method: "POST", @@ -128,7 +128,7 @@ describe("createAccessToken", async () => { "authorization_code_123", codeVerifier ) - ).rejects.toThrow(/Failed to communicate with OAuth provider/) + ).rejects.toThrow(/Invalid access token response format/) expect(fetch).toHaveBeenCalledWith("https://example.com/oauth/access_token", { method: "POST", diff --git a/packages/core/test/actions/callback/callback.test.ts b/packages/core/test/actions/callback/callback.test.ts index 47d76ecf..4ccece22 100644 --- a/packages/core/test/actions/callback/callback.test.ts +++ b/packages/core/test/actions/callback/callback.test.ts @@ -234,7 +234,6 @@ describe("callbackAction", () => { const redirectTo = setCookie("__Secure-aura-auth.redirect_to", "/auth") const { codeVerifier } = await createPKCE() const codeVerifierCookie = setCookie("__Secure-aura-auth.code_verifier", codeVerifier) - // @todo: fix oauth types const GET = createAuth({ oauth: [ { @@ -336,7 +335,6 @@ describe("callbackAction", () => { const redirectTo = setCookie("__Secure-aura-auth.redirect_to", "/auth") const { codeVerifier } = await createPKCE() const codeVerifierCookie = setCookie("__Secure-aura-auth.code_verifier", codeVerifier) - // @todo: fix oauth types const GET = createAuth({ oauth: [ { diff --git a/packages/core/test/actions/session/session.test.ts b/packages/core/test/actions/session/session.test.ts index 88d2d3ed..ad3d4bef 100644 --- a/packages/core/test/actions/session/session.test.ts +++ b/packages/core/test/actions/session/session.test.ts @@ -49,10 +49,6 @@ describe("sessionAction", () => { expect(await request.json()).toMatchObject({ success: true, session: { user: sessionPayload, expires: expect.any(String) }, - error: { - code: "GET_SESSION_FAILED", - message: "Failed to retrieve session. The session token may be missing, expired, or invalid.", - }, }) }) @@ -70,10 +66,6 @@ describe("sessionAction", () => { expect(await request.json()).toMatchObject({ success: true, session: { user: sessionPayload, expires: expect.any(String) }, - error: { - code: "GET_SESSION_FAILED", - message: "Failed to retrieve session. The session token may be missing, expired, or invalid.", - }, }) }) @@ -228,10 +220,6 @@ describe("sessionAction", () => { user: { sub: id, name, image, email }, expires: expect.any(String), }, - error: { - code: "GET_SESSION_FAILED", - message: "Failed to retrieve session. The session token may be missing, expired, or invalid.", - }, }) }) }) diff --git a/packages/core/test/actions/signIn/authorization.test.ts b/packages/core/test/actions/signIn/authorization.test.ts index 564512ac..25a7f81c 100644 --- a/packages/core/test/actions/signIn/authorization.test.ts +++ b/packages/core/test/actions/signIn/authorization.test.ts @@ -14,63 +14,103 @@ afterEach(() => { }) describe("createRedirectURI", () => { - const testCases = [ - { - description: "creates redirect URI from standard URL", - requestURL: "https://example.com/signIn/github", - oauth: "github", - expected: "https://example.com/auth/callback/github", - }, - { - description: "creates redirect URI from URL with port", - requestURL: "http://localhost:3000/signIn/google", - oauth: "google", - expected: "http://localhost:3000/auth/callback/google", - }, - { - description: "creates redirect URI from URL with path and query", - requestURL: "https://example.com/signIn/github?query=123", - oauth: "facebook", - expected: "https://example.com/auth/callback/facebook", - }, - { - description: "creates redirect URI from URL with trailing slash", - requestURL: "https://example.com/signIn/github/", - oauth: "github", - expected: "https://example.com/auth/callback/github", - }, - { - description: "creates redirect URI from URL with undefined query", - requestURL: "https://example.com/signIn/github?", - oauth: "github", - expected: "https://example.com/auth/callback/github", - }, - { - description: "creates redirect URI from URL with hash", - requestURL: "https://example.com/signIn/twitter#section", - oauth: "twitter", - expected: "https://example.com/auth/callback/twitter", - }, - { - description: "creates redirect URI from URL with subdomain", - requestURL: "https://subdomain.example.com/signIn/linkedin", - oauth: "linkedin", - expected: "https://subdomain.example.com/auth/callback/linkedin", - }, - { - description: "creates redirect URI from URL with IP address", - requestURL: "http://192.168.1.1/signIn/github", - oauth: "github", - expected: "http://192.168.1.1/auth/callback/github", - }, - ] + describe("valid request", () => { + const testCases = [ + { + description: "creates redirect URI from standard URL", + request: new Request("https://example.com/signIn/github"), + oauth: "github", + expected: "https://example.com/auth/callback/github", + trustedProxyHeaders: false, + }, + { + description: "creates redirect URI from URL with port", + request: new Request("http://localhost:3000/signIn/google"), + oauth: "google", + expected: "http://localhost:3000/auth/callback/google", + trustedProxyHeaders: false, + }, + { + description: "creates redirect URI from URL with path and query", + request: new Request("https://example.com/signIn/github?query=123"), + oauth: "facebook", + expected: "https://example.com/auth/callback/facebook", + trustedProxyHeaders: false, + }, + { + description: "creates redirect URI from URL with trailing slash", + request: new Request("https://example.com/signIn/github/"), + oauth: "github", + expected: "https://example.com/auth/callback/github", + trustedProxyHeaders: false, + }, + { + description: "creates redirect URI from URL with undefined query", + request: new Request("https://example.com/signIn/github?"), + oauth: "github", + expected: "https://example.com/auth/callback/github", + trustedProxyHeaders: false, + }, + { + description: "creates redirect URI from URL with hash", + request: new Request("https://example.com/signIn/twitter#section"), + oauth: "twitter", + expected: "https://example.com/auth/callback/twitter", + trustedProxyHeaders: false, + }, + { + description: "creates redirect URI from URL with subdomain", + request: new Request("https://subdomain.example.com/signIn/linkedin"), + oauth: "linkedin", + expected: "https://subdomain.example.com/auth/callback/linkedin", + trustedProxyHeaders: false, + }, + { + description: "creates redirect URI from URL with IP address", + request: new Request("http://192.168.1.1/signIn/github"), + oauth: "github", + expected: "http://192.168.1.1/auth/callback/github", + trustedProxyHeaders: false, + }, + ] - for (const { description, requestURL, oauth, expected } of testCases) { - test(description, async () => { - const redirectURI = await createRedirectURI(new Request(requestURL), oauth, { basePath: "/auth" } as GlobalContext) - expect(redirectURI).toBe(expected) - }) - } + for (const { description, request, oauth, expected, trustedProxyHeaders } of testCases) { + test(description, async () => { + const redirectURI = await createRedirectURI(request, oauth, { + basePath: "/auth", + trustedProxyHeaders, + } as GlobalContext) + expect(redirectURI).toBe(expected) + }) + } + }) + + describe("invalid request", () => { + const testCases = [ + { + description: "creates redirect URI from URL with trusted proxy headers", + request: new Request("http://localhost:3000/signIn/google", { + headers: { + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "example.com", + }, + }), + oauth: "google", + trustedProxyHeaders: true, + }, + ] + + for (const { description, request, oauth, trustedProxyHeaders } of testCases) { + test(description, async () => { + await expect( + createRedirectURI(request, oauth, { + basePath: "/auth", + trustedProxyHeaders, + } as GlobalContext) + ).rejects.toThrow("The constructed origin URL is not trusted.") + }) + } + }) }) describe("createAuthorizationURL", () => { @@ -543,6 +583,89 @@ describe("createRedirectTo", () => { } as GlobalContext) expect(redirectTo).toBe("/") }) + + test("with wildcard pattern and subdomain and valid origin", async () => { + const request = new Request("https://app.example.com/auth/signIn/github") + const redirectTo = await createRedirectTo(request, "https://app.example.com/dashboard", { + trustedOrigins: ["https://*.example.com"], + trustedProxyHeaders: false, + } as GlobalContext) + expect(redirectTo).toBe("/dashboard") + }) + + test("with wildcard pattern and subdomain with invalid redirect value", async () => { + const request = new Request("https://app.example.com/auth/signIn/github") + const redirectTo = await createRedirectTo(request, "https://malicious.com/phishing", { + trustedOrigins: ["https://*.example.com"], + trustedProxyHeaders: false, + } as GlobalContext) + expect(redirectTo).toBe("/") + }) + + test("with wildcard pattern and subdomain with different subdomain", async () => { + const request = new Request("https://app.example.com/auth/signIn/github") + const redirectTo3 = await createRedirectTo(request, "https://sub.app.example.com/dashboard", { + trustedOrigins: ["https://*.example.com"], + trustedProxyHeaders: false, + } as GlobalContext) + expect(redirectTo3).toBe("/") + }) + + test("with wildcard pattern and subdomain with multiple subdomains", async () => { + const request = new Request("https://app.example.com/auth/signIn/github") + const redirectTo = await createRedirectTo(request, "https://evil.app.example.com/dashboard", { + trustedOrigins: ["https://*.example.com"], + trustedProxyHeaders: false, + } as GlobalContext) + expect(redirectTo).toBe("/") + }) + + test("with wildcard pattern with multiple subdomains", async () => { + const request = new Request("https://app.example.com/auth/signIn/github", { + headers: { + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "evil.app.example.com", + }, + }) + const redirectTo = await createRedirectTo(request, "https://evil.app.example.com/dashboard", { + trustedOrigins: ["https://*.example.com"], + trustedProxyHeaders: true, + } as GlobalContext) + expect(redirectTo).toBe("/") + }) + }) + + describe("SECURITY", () => { + // This test demonstrates a potential security risk if trustedProxyHeaders is enabled in an + // insecure environment. It shows that if an attacker can control the X-Forwarded-Host header, + // they could potentially bypass the trusted origins check and redirect users to a malicious site. + test("with wildcard pattern with dangerous origin from trustedProxyHeaders", async () => { + const request = new Request("https://app.example.com/auth/signIn/github", { + headers: { + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "evil.example.com", + }, + }) + const redirectTo = await createRedirectTo(request, "https://evil.example.com/dashboard", { + trustedOrigins: ["https://*.example.com"], + trustedProxyHeaders: true, + } as GlobalContext) + expect(redirectTo).toBe("https://evil.example.com/dashboard") + }) + + test("with wildcard pattern with trustedProxyHeaders", async () => { + const request = new Request("https://app.example.com/auth/signIn/github", { + headers: { + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "app.example.com", + }, + }) + const redirectTo = await createRedirectTo(request, "https://evil.example.com/dashboard", { + trustedOrigins: ["https://*.example.com"], + trustedProxyHeaders: true, + } as GlobalContext) + expect(redirectTo).toBe("https://evil.example.com/dashboard") + }) }) }) @@ -651,6 +774,17 @@ describe("getOriginURL", () => { trustedOrigins: [], trustedProxyHeaders: true, }, + { + description: "with trusted proxy headers and without trusted origins", + request: new Request("http://localhost:3000/auth/signIn/github", { + headers: { + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "malicious.com", + }, + }), + trustedOrigins: [], + trustedProxyHeaders: true, + }, ] for (const { description, request, trustedOrigins, trustedProxyHeaders } of testCases) { diff --git a/packages/jose/CHANGELOG.md b/packages/jose/CHANGELOG.md index f6dae0cd..b01d81ab 100644 --- a/packages/jose/CHANGELOG.md +++ b/packages/jose/CHANGELOG.md @@ -10,10 +10,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Introduced algorithm inference: signing and encryption algorithms are now inferred from the provided asymmetric keys to reduce configuration mismatches. Defaults: `RS256` (signing) and `RSA-OAEP-256` (encryption) for asymmetric keys; `HS256` (signing) and `dir` (encryption) for symmetric keys. [#165](https://github.com/aura-stack-ts/auth/pull/165) + - Extended asymmetric cryptography support to accept JWK (JSON Web Key) format keys in addition to `CryptoKeyPair` across JOSE functions, including the dedicated `signJWS`, `verifyJWS`, `encryptJWE`, `decryptJWE`, `encryptCompactJWE`, `decryptCompactJWE`, `encodeJWT`, and `decodeJWT` functions, as well as the factory functions `createJWS`, `createJWE`, and `createJWT`. [#159](https://github.com/aura-stack-ts/auth/pull/159) - Added support for asymmetric cryptography using `public/private` key pairs via `CryptoKeyPair` across JOSE functions, including the dedicated `signJWS`, `verifyJWS`, `encryptJWE`, `decryptJWE`, `encryptCompactJWE`, `decryptCompactJWE`, `encodeJWT`, and `decodeJWT` functions, as well as the factory functions `createJWS`, `createJWE`, and `createJWT`. [#157](https://github.com/aura-stack-ts/auth/pull/157) +### Changed + +- Added strict secret verification: require at least 4 bits of entropy per character and a minimum total entropy of 254 bits. [`#165`](https://github.com/aura-stack-ts/auth/pull/165) + +- Renamed compact JWE helpers from `compactEncryptJWE` to `encryptCompactJWE` to align naming with other JWE APIs. [#165](https://github.com/aura-stack-ts/auth/pull/165) + ## [0.5.0] - 2026-04-21 ### Added diff --git a/packages/jose/src/assert.ts b/packages/jose/src/assert.ts index 5c1282ed..047118cd 100644 --- a/packages/jose/src/assert.ts +++ b/packages/jose/src/assert.ts @@ -32,3 +32,11 @@ export const isAsymmetricKeyPair = (value: unknown): value is AsymmetricKeyPair export const isJWKKey = (value: unknown): value is JsonWebKey => { return typeof value === "object" && value !== null && "kty" in value && typeof (value as JsonWebKey).kty === "string" } + +export const isCryptoKey = (value: unknown): value is CryptoKey => { + return typeof value === "object" && value !== null && "type" in value && typeof (value as CryptoKey).type === "string" +} + +export const isRSAJwk = (key: unknown): boolean => { + return typeof key === "object" && key !== null && "kty" in key && (key as { kty?: unknown }).kty === "RSA" +} diff --git a/packages/jose/src/encrypt.ts b/packages/jose/src/encrypt.ts index b7c46507..71f2e7e8 100644 --- a/packages/jose/src/encrypt.ts +++ b/packages/jose/src/encrypt.ts @@ -11,7 +11,7 @@ import { } from "jose" import { createSecret } from "@/secret.ts" import { decoder, encoder, getRandomBytes } from "@/crypto.ts" -import { isAuraJoseError, isAsymmetricKeyPair, isFalsy } from "@/assert.ts" +import { isAuraJoseError, isAsymmetricKeyPair, isFalsy, isCryptoKey, isRSAJwk } from "@/assert.ts" import { InvalidPayloadError, JWEDecryptionError, JWEEncryptionError } from "@/errors.ts" import type { JWTSecretInput, SecretInput, TypedJWTPayload } from "@/index.ts" @@ -40,9 +40,16 @@ export const encryptJWE = async ( } const secretKey = createSecret(secret) const jti = base64url.encode(getRandomBytes(32)) + const isAsymmetricCryptoKey = (isCryptoKey(secret) && secret.type === "public") || isRSAJwk(secret) return await new EncryptJWT(payload) - .setProtectedHeader({ alg: "dir", enc: "A256GCM", cty: "JWT", typ: "JWT", ...options }) + .setProtectedHeader({ + alg: isAsymmetricCryptoKey ? "RSA-OAEP-256" : "dir", + enc: "A256GCM", + cty: "JWT", + typ: "JWT", + ...options, + }) .setIssuedAt() .setNotBefore(payload?.nbf ?? "0s") .setExpirationTime(payload?.exp ?? "15d") @@ -63,14 +70,22 @@ export const encryptJWE = async ( * @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) => { +export const encryptCompactJWE = 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) + const isAsymmetricCryptoKey = (isCryptoKey(secret) && secret.type === "public") || isRSAJwk(secret) + return await new CompactEncrypt(encoder.encode(payload)) - .setProtectedHeader({ alg: "dir", enc: "A256GCM", cty: "JWT", typ: "JWT", ...options }) + .setProtectedHeader({ + alg: isAsymmetricCryptoKey ? "RSA-OAEP-256" : "dir", + enc: "A256GCM", + cty: "JWT", + typ: "JWT", + ...options, + }) .encrypt(secretKey) } catch (error) { if (isAuraJoseError(error)) { @@ -98,8 +113,10 @@ export const decryptJWE = async ( throw new InvalidPayloadError("The token must be a non-empty string") } const secretKey = createSecret(secret) + const isAsymmetricCryptoKey = (isCryptoKey(secret) && secret.type === "private") || isRSAJwk(secret) + const { payload } = await jwtDecrypt(token, secretKey, { - keyManagementAlgorithms: ["dir"], + keyManagementAlgorithms: [isAsymmetricCryptoKey ? "RSA-OAEP-256" : "dir"], contentEncryptionAlgorithms: ["A256GCM"], ...options, }) @@ -126,9 +143,10 @@ export const decryptCompactJWE = async (token: string, secret: SecretInput, opti throw new InvalidPayloadError("The token must be a non-empty string") } const secretKey = createSecret(secret) + const isAsymmetricCryptoKey = (isCryptoKey(secret) && secret.type === "private") || isRSAJwk(secret) const { plaintext } = await compactDecrypt(token, secretKey, { - keyManagementAlgorithms: ["dir"], + keyManagementAlgorithms: [isAsymmetricCryptoKey ? "RSA-OAEP-256" : "dir"], contentEncryptionAlgorithms: ["A256GCM"], ...options, }) @@ -164,16 +182,16 @@ export const createJWE = (secret: JWTSecretInput) => /** * Creates a `Compact JWE (JSON Web Encryption)` encrypter and decrypter using compact serialization. It implements the - * `compactEncryptJWE` and `decryptCompactJWE` functions. + * `encryptCompactJWE` and `decryptCompactJWE` functions. * @param secret - Secret key used for encrypting and decrypting the JWE - * @returns compactEncryptJWE and decryptCompactJWE functions + * @returns encryptCompactJWE and decryptCompactJWE functions */ export const createCompactJWE = (secret: JWTSecretInput) => { const encryptSecret = isAsymmetricKeyPair(secret) ? secret.publicKey : secret const decryptSecret = isAsymmetricKeyPair(secret) ? secret.privateKey : secret return { - compactEncryptJWE: (payload: string, options?: JWEHeaderParameters) => compactEncryptJWE(payload, encryptSecret, options), + encryptCompactJWE: (payload: string, options?: JWEHeaderParameters) => encryptCompactJWE(payload, encryptSecret, options), decryptCompactJWE: (payload: string, options?: DecryptOptions) => decryptCompactJWE(payload, decryptSecret, options), } } diff --git a/packages/jose/src/errors.ts b/packages/jose/src/errors.ts index 2191b6f1..ec9206cb 100644 --- a/packages/jose/src/errors.ts +++ b/packages/jose/src/errors.ts @@ -1,3 +1,15 @@ +interface V8ErrorConstructor extends ErrorConstructor { + captureStackTrace(targetObject: object, constructorOpt?: Function): void +} + +/** + * Type guard to check if the current runtime environment + * supports Error.captureStackTrace. + */ +export const hasCaptureStackTrace = (errorConstructor: ErrorConstructor): errorConstructor is V8ErrorConstructor => { + return "captureStackTrace" in errorConstructor && typeof (errorConstructor as any).captureStackTrace === "function" +} + /** * @todo: add link attribute to docs when available */ @@ -9,7 +21,9 @@ export class AuraJoseError extends Error { super(message, options) this.name = new.target.name this.code = new.target.code - Error.captureStackTrace(this, new.target) + if (hasCaptureStackTrace(Error)) { + Error.captureStackTrace(this, new.target) + } } } diff --git a/packages/jose/src/index.ts b/packages/jose/src/index.ts index 0171bd27..11930872 100644 --- a/packages/jose/src/index.ts +++ b/packages/jose/src/index.ts @@ -6,7 +6,7 @@ import { getSecrets } from "@/secret.ts" import { signJWS, verifyJWS } from "@/sign.ts" import { isAuraJoseError } from "@/assert.ts" import { JWTDecodingError, JWTEncodingError } from "@/errors.ts" -import { compactEncryptJWE, decryptCompactJWE } from "@/encrypt.ts" +import { encryptCompactJWE, decryptCompactJWE } from "@/encrypt.ts" export * from "@/sign.ts" export type * from "@/sign.ts" @@ -81,7 +81,7 @@ export const encodeJWT = async ( const { encode } = getSecrets(secret) const { jweSecret, jwsSecret } = encode const signed = await signJWS(token, jwsSecret, options?.sign) - return await compactEncryptJWE(signed, jweSecret, options?.encrypt) + return await encryptCompactJWE(signed, jweSecret, options?.encrypt) } catch (error) { if (isAuraJoseError(error)) { throw error diff --git a/packages/jose/src/secret.ts b/packages/jose/src/secret.ts index 26fb6b87..c290910a 100644 --- a/packages/jose/src/secret.ts +++ b/packages/jose/src/secret.ts @@ -3,9 +3,10 @@ import { InvalidSecretError } from "@/errors.ts" import { encoder } from "@/crypto.ts" import type { DerivedKeyInput, JWTSecretInput, SecretInput } from "@/index.ts" -export const MIN_SECRET_ENTROPY_BITS = 4 +export const MIN_SECRET_ENTROPY_PER_CHAR = 4 +export const MIN_SECRET_ENTROPY_BITS = 254 -export const getEntropy = (secret: string): number => { +export const assertSecretEntropy = (secret: string) => { const charFreq = new Map() for (const char of secret) { if (!charFreq.has(char)) { @@ -13,13 +14,18 @@ export const getEntropy = (secret: string): number => { } charFreq.set(char, charFreq.get(char)! + 1) } - let entropy = 0 + let perCharEntropy = 0 const length = secret.length for (const freq of charFreq.values()) { const p = freq / length - entropy -= p * Math.log2(p) + perCharEntropy -= p * Math.log2(p) + } + const totalEntropy = perCharEntropy * length + if (perCharEntropy < MIN_SECRET_ENTROPY_PER_CHAR || totalEntropy < MIN_SECRET_ENTROPY_BITS) { + throw new InvalidSecretError( + `Secret must have an entropy of at least ${MIN_SECRET_ENTROPY_PER_CHAR} bits per character and a total entropy of at least ${MIN_SECRET_ENTROPY_BITS} bits` + ) } - return entropy } /** @@ -37,12 +43,7 @@ export const createSecret = (secret: SecretInput, length: number = 32) => { if (byteLength < length) { throw new InvalidSecretError(`Secret string must be at least ${length} bytes long`) } - const entropy = getEntropy(secret) - if (entropy < MIN_SECRET_ENTROPY_BITS) { - throw new InvalidSecretError( - `Secret string must have an entropy of at least ${MIN_SECRET_ENTROPY_BITS} bits per character` - ) - } + assertSecretEntropy(secret) return encoded } if (secret instanceof CryptoKey || secret instanceof Uint8Array || isJWKKey(secret)) { diff --git a/packages/jose/src/sign.ts b/packages/jose/src/sign.ts index 84ca28f6..189d1837 100644 --- a/packages/jose/src/sign.ts +++ b/packages/jose/src/sign.ts @@ -1,7 +1,7 @@ import { base64url, jwtVerify, SignJWT, type JWTPayload, type JWTVerifyOptions, type JWTHeaderParameters } from "jose" import { createSecret } from "@/secret.ts" import { getRandomBytes } from "@/crypto.ts" -import { isAsymmetricKeyPair, isAuraJoseError, isFalsy, isInvalidPayload } from "@/assert.ts" +import { isAsymmetricKeyPair, isAuraJoseError, isCryptoKey, isFalsy, isInvalidPayload } from "@/assert.ts" import { JWSSigningError, JWSVerificationError, InvalidPayloadError } from "@/errors.ts" import type { JWTSecretInput, SecretInput, TypedJWTPayload } from "@/index.ts" @@ -30,11 +30,12 @@ export const signJWS = async ( if (isInvalidPayload(payload)) { throw new InvalidPayloadError("The payload must be a non-empty object") } + const isAsymmetricCryptoKey = isCryptoKey(secret) && secret.type === "private" const secretKey = createSecret(secret) const jti = base64url.encode(getRandomBytes(32)) return await new SignJWT(payload) - .setProtectedHeader({ alg: "HS256", typ: "JWT", ...options }) + .setProtectedHeader({ alg: isAsymmetricCryptoKey ? "RS256" : "HS256", typ: "JWT", ...options }) .setIssuedAt(payload.iat ?? "0s") .setNotBefore(payload.nbf ?? "0s") .setExpirationTime(payload.exp ?? "15d") @@ -67,9 +68,10 @@ export const verifyJWS = async ( if (isFalsy(token)) { throw new InvalidPayloadError("The token must be a non-empty string") } + const isAsymmetricCryptoKey = isCryptoKey(secret) && secret.type === "public" const secretKey = createSecret(secret) const { payload } = await jwtVerify(token, secretKey, { - algorithms: ["HS256"], + algorithms: [isAsymmetricCryptoKey ? "RS256" : "HS256"], typ: "JWT", ...options, }) @@ -91,14 +93,19 @@ export const verifyJWS = async ( * @returns signJWS and verifyJWS functions */ export const createJWS = (secret: JWTSecretInput) => { - const signSecret = isAsymmetricKeyPair(secret) ? secret.privateKey : secret - const verifySecret = isAsymmetricKeyPair(secret) ? secret.publicKey : secret + const isAsymmetric = isAsymmetricKeyPair(secret) + const signSecret = isAsymmetric ? secret.privateKey : secret + const verifySecret = isAsymmetric ? secret.publicKey : secret return { signJWS: ( payload: TypedJWTPayload>, options?: JWTHeaderParameters - ) => signJWS(payload, signSecret, options), + ) => signJWS(payload, signSecret, isAsymmetric ? { alg: "RS256", ...options } : options), verifyJWS: (payload: string, verifyOptions?: JWTVerifyOptions) => - verifyJWS(payload, verifySecret, verifyOptions), + verifyJWS( + payload, + verifySecret, + isAsymmetric ? { algorithms: ["RS256"], ...verifyOptions } : verifyOptions + ), } } diff --git a/packages/jose/test/deriveKey.test.ts b/packages/jose/test/deriveKey.test.ts new file mode 100644 index 00000000..fb1717f0 --- /dev/null +++ b/packages/jose/test/deriveKey.test.ts @@ -0,0 +1,44 @@ +import { describe, test, expect } from "vitest" +import { encoder, getRandomBytes } from "@/crypto.ts" +import { deriveKey, createDeriveKey } from "@/deriveKey.ts" +import type { SecretInput } from "@/index.ts" + +describe("createDeriveKey", () => { + test("createDeriveKey", async () => { + await expect(createDeriveKey("asfts")).rejects.toThrow(/Secret string must be at least 32 bytes long/) + }) + + test("createDeriveKey with 32 bytes", async () => { + const secretKey = getRandomBytes(32) + const derivedKey = await createDeriveKey(secretKey) + expect(derivedKey).toBeDefined() + expect(derivedKey.byteLength).toBe(32) + }) + + test("createDeriveKey throws when given a CryptoKey", async () => { + const cryptoKey = await globalThis.crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256" }, false, ["sign"]) + await expect(createDeriveKey(cryptoKey as unknown as SecretInput)).rejects.toThrow("Cannot derive key from CryptoKey") + }) +}) + +describe("deriveKey", () => { + test("deriveKey", async () => { + const secret = "my-secret-password-123" + const derivedKey1 = await deriveKey(encoder.encode(secret), "salt-1", "info-1") + const derivedKey2 = await deriveKey(encoder.encode(secret), "salt-2", "info-2") + expect(derivedKey1).toBeDefined() + expect(derivedKey2).toBeDefined() + expect(derivedKey1).not.toEqual(derivedKey2) + }) + + test("create deterministic derived keys", async () => { + const salt = "deterministic-salt" + const info = "deterministic-info" + const secretKey = getRandomBytes(32) + const derivedKey1 = await deriveKey(secretKey, salt, info) + const derivedKey2 = await deriveKey(secretKey, salt, info) + const derivedKey3 = await deriveKey(secretKey, salt, info) + expect(derivedKey1).toEqual(derivedKey2) + expect(derivedKey2).toEqual(derivedKey3) + }) +}) diff --git a/packages/jose/test/encrypt.test.ts b/packages/jose/test/encrypt.test.ts new file mode 100644 index 00000000..c13765b0 --- /dev/null +++ b/packages/jose/test/encrypt.test.ts @@ -0,0 +1,184 @@ +import { describe, test, expect } from "vitest" +import { getRandomBytes } from "@/crypto.ts" +import { createJWS, signJWS } from "@/sign.ts" +import { createDeriveKey } from "@/deriveKey.ts" +import { generateKeyPair, type JWTPayload } from "jose" +import { createCompactJWE, createJWE, decryptCompactJWE, decryptJWE, encryptJWE, encryptCompactJWE } from "@/encrypt.ts" + +const payload: JWTPayload = { + sub: "user-123", + name: "John Doe", + email: "john.doe@example.com", +} + +describe("JWEs", () => { + test("encrypt and decrypt a JWE using encryptJWE and decryptJWE", async () => { + const secretKey = getRandomBytes(32) + const derivedKey = await createDeriveKey(secretKey) + + const jwe = await encryptJWE({ payload }, derivedKey) + expect(jwe).toBeDefined() + + const decryptedPayload = await decryptJWE<{ payload: string }>(jwe, derivedKey) + expect(decryptedPayload.payload).toMatchObject(payload) + }) + + test("encrypt and decrypt a JWE using createJWE", async () => { + const secretKey = getRandomBytes(32) + const derivedKey = await createDeriveKey(secretKey) + + const { signJWS } = createJWS(derivedKey) + const { encryptJWE, decryptJWE } = createJWE(derivedKey) + + const jws = await signJWS(payload) + const jwe = await encryptJWE({ payload: jws }) + expect(jwe).toBeDefined() + + 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) + await expect(decryptJWE("header.payload.signature", secretKey)).rejects.toThrow(/JWE decryption verification failed/) + }) + + test("set audience in a JWE and decrypt it", async () => { + const secretKey = getRandomBytes(32) + const jwe = await encryptJWE({ aud: "client_id_123", name: "John Doe" }, secretKey) + const decrypted = await decryptJWE(jwe, secretKey) + 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({ 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 encryptCompactJWE(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 { encryptCompactJWE, decryptCompactJWE } = createCompactJWE(secretKey) + + const compactJWE = await encryptCompactJWE(jws) + const decryptedJWS = await decryptCompactJWE(compactJWE) + expect(decryptedJWS).toBe(jws) + }) + + test("verify JWE with RSA algorithm", async () => { + const { publicKey, privateKey } = await generateKeyPair("RSA-OAEP-256") + const jwe = await encryptJWE({ payload }, publicKey, { alg: "RSA-OAEP-256", enc: "A256GCM" }) + const decryptedPayload = await decryptJWE<{ payload: string }>(jwe, privateKey, { + keyManagementAlgorithms: ["RSA-OAEP-256"], + contentEncryptionAlgorithms: ["A256GCM"], + }) + expect(decryptedPayload.payload).toMatchObject(payload) + }) + + test("verify createJWE with RSA algorithm", async () => { + const { publicKey, privateKey } = await generateKeyPair("RSA-OAEP-256") + const { encryptJWE, decryptJWE } = createJWE({ publicKey, privateKey }) + const jwe = await encryptJWE({ payload }, { alg: "RSA-OAEP-256", enc: "A256GCM" }) + const decryptedPayload = await decryptJWE<{ payload: string }>(jwe, { + keyManagementAlgorithms: ["RSA-OAEP-256"], + contentEncryptionAlgorithms: ["A256GCM"], + }) + expect(decryptedPayload.payload).toMatchObject(payload) + }) + + test("verify createCompactJWE with RSA algorithm", async () => { + const { publicKey, privateKey } = await generateKeyPair("RSA-OAEP-256") + const { encryptCompactJWE, decryptCompactJWE } = createCompactJWE({ publicKey, privateKey }) + const jwe = await encryptCompactJWE(JSON.stringify(payload), { alg: "RSA-OAEP-256", enc: "A256GCM" }) + const decryptedPayload = await decryptCompactJWE(jwe, { + keyManagementAlgorithms: ["RSA-OAEP-256"], + contentEncryptionAlgorithms: ["A256GCM"], + }) + expect(JSON.parse(decryptedPayload)).toMatchObject(payload) + }) + + test("verify JWK with RSA algorithm", async () => { + const { publicKey, privateKey } = await generateKeyPair("RSA-OAEP-256", { extractable: true }) + const publicJWK = await crypto.subtle.exportKey("jwk", publicKey) + const privateJWK = await crypto.subtle.exportKey("jwk", privateKey) + const { encryptJWE, decryptJWE } = createJWE({ + publicKey: publicJWK, + privateKey: privateJWK, + }) + const signed = await encryptJWE(payload, { alg: "RSA-OAEP-256" }) + const verified = await decryptJWE(signed, { keyManagementAlgorithms: ["RSA-OAEP-256"] }) + expect(verified).toMatchObject(payload) + }) + + test("encrypt and decrypt with dir algorithm using createJWE", async () => { + const secretKey = getRandomBytes(32) + const { encryptJWE, decryptJWE } = createJWE(secretKey) + const jwe = await encryptJWE(payload, { alg: "dir", enc: "A256GCM" }) + await expect( + decryptJWE(jwe, { + keyManagementAlgorithms: ["dir"], + contentEncryptionAlgorithms: ["A128GCM"], + }) + ).rejects.toThrow("JWE decryption verification failed") + }) + + test("encrypt and decrypt with dir algorithm using createCompactJWE", async () => { + const secretKey = getRandomBytes(32) + const { encryptCompactJWE, decryptCompactJWE } = createCompactJWE(secretKey) + const jwe = await encryptCompactJWE(JSON.stringify(payload), { alg: "dir", enc: "A256GCM" }) + await expect( + decryptCompactJWE(jwe, { + keyManagementAlgorithms: ["dir"], + contentEncryptionAlgorithms: ["RSA-OAP-256"], + }) + ).rejects.toThrow("JWE decryption verification failed") + }) + + test("infer algorithm from CryptoKey type", async () => { + const { publicKey, privateKey } = await generateKeyPair("RSA-OAEP-256") + const jwe = await encryptJWE({ payload }, publicKey) + const decryptedPayload = await decryptJWE<{ payload: string }>(jwe, privateKey) + expect(decryptedPayload.payload).toMatchObject(payload) + }) + + test("infer algorithm from CryptoKey type with compact serialization", async () => { + const { publicKey, privateKey } = await generateKeyPair("RSA-OAEP-256") + const jwe = await encryptCompactJWE(JSON.stringify(payload), publicKey) + const decryptedPayload = await decryptCompactJWE(jwe, privateKey) + expect(JSON.parse(decryptedPayload)).toMatchObject(payload) + }) + + test("infer algorithm from CryptoKey type using createJWE", async () => { + const { publicKey, privateKey } = await generateKeyPair("RSA-OAEP-256") + const { encryptJWE, decryptJWE } = createJWE({ publicKey, privateKey }) + const jwe = await encryptJWE({ payload }) + const decryptedPayload = await decryptJWE<{ payload: string }>(jwe) + expect(decryptedPayload.payload).toMatchObject(payload) + }) + + test("infer algorithm from CryptoKey type using createCompactJWE", async () => { + const { publicKey, privateKey } = await generateKeyPair("RSA-OAEP-256") + const { encryptCompactJWE, decryptCompactJWE } = createCompactJWE({ publicKey, privateKey }) + const jwe = await encryptCompactJWE(JSON.stringify(payload), { alg: "RSA-OAEP-256", enc: "A256GCM" }) + const decryptedPayload = await decryptCompactJWE(jwe, { + keyManagementAlgorithms: ["RSA-OAEP-256"], + contentEncryptionAlgorithms: ["A256GCM"], + }) + expect(JSON.parse(decryptedPayload)).toMatchObject(payload) + }) +}) diff --git a/packages/jose/test/index.test.ts b/packages/jose/test/index.test.ts index c5bdec49..ac8f0bcc 100644 --- a/packages/jose/test/index.test.ts +++ b/packages/jose/test/index.test.ts @@ -1,11 +1,10 @@ import { describe, test, expect } from "vitest" -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 { createCompactJWE, createJWE, decryptCompactJWE, decryptJWE, encryptJWE, compactEncryptJWE } from "@/encrypt.ts" -import { createJWT, decodeJWT, encodeJWT, MIN_SECRET_ENTROPY_BITS, type SecretInput } from "@/index.ts" +import { createJWS } from "@/sign.ts" +import { createJWE } from "@/encrypt.ts" +import { getRandomBytes } from "@/crypto.ts" +import { createDeriveKey } from "@/deriveKey.ts" import { generateKeyPair, type JWTPayload } from "jose" +import { createJWT, decodeJWT, encodeJWT } from "@/index.ts" const payload: JWTPayload = { sub: "user-123", @@ -13,332 +12,6 @@ const payload: JWTPayload = { email: "john.doe@example.com", } -describe("JWSs", () => { - test("sign and verify a JWS using signJWS and verifyJWS", async () => { - const secretKey = getRandomBytes(32) - const derivedKey = await createDeriveKey(secretKey) - - const jws = await signJWS(payload, derivedKey) - expect(jws).toBeDefined() - - const decodedPayload = await verifyJWS(jws, derivedKey) - expect(decodedPayload.sub).toBe(payload.sub) - expect(decodedPayload.name).toBe(payload.name) - expect(decodedPayload.email).toBe(payload.email) - }) - - test("sign and verify a JWS using createJWS", async () => { - const secretKey = getRandomBytes(32) - const derivedKey = await createDeriveKey(secretKey) - - const { signJWS, verifyJWS } = createJWS(derivedKey) - - const jws = await signJWS(payload) - expect(jws).toBeDefined() - - const decodedPayload = await verifyJWS(jws) - expect(decodedPayload.sub).toBe(payload.sub) - expect(decodedPayload.name).toBe(payload.name) - expect(decodedPayload.email).toBe(payload.email) - }) - - test("fail JWT to try to verify an invalid JWS", async () => { - const { verifyJWS } = createJWS("my-secret-key") - await expect(verifyJWS("invalid.jwt.token")).rejects.toThrow("Secret string must be at least 32 bytes long") - }) - - test("fail JWT to try to verify a JWS with invalid secret", async () => { - const secretKey = getRandomBytes(32) - const derivedKey = await createDeriveKey(secretKey) - - const jws = await signJWS(payload, derivedKey) - expect(jws).toBeDefined() - - const { verifyJWS } = createJWS("wrong-secret-key") - await expect(verifyJWS(jws)).rejects.toThrow("Secret string must be at least 32 bytes long") - }) - - test("fail JWT with invalid format JWS", async () => { - const secretKey = getRandomBytes(32) - const { signJWS } = createJWS(secretKey) - await expect(signJWS(undefined as unknown as JWTPayload)).rejects.toThrow("The payload must be a non-empty object") - }) - - test("set audience in a JWS and verify it", async () => { - const secretKey = getRandomBytes(32) - const jws = await signJWS({ aud: "client_id_123", name: "John Doe" }, secretKey) - expect(await verifyJWS(jws, secretKey, { audience: "client_id_123" })).toMatchObject({ name: "John Doe" }) - }) - - test("fail JWT to verify a JWS with incorrect audience", async () => { - const secretKey = getRandomBytes(32) - const jws = await signJWS({ aud: "client_id_123", name: "John Doe" }, secretKey) - await expect(verifyJWS(jws, secretKey, { audience: "wrong_audience" })).rejects.toThrow( - "JWS signature verification failed" - ) - }) - - test("set expiration time in the payload of a JWS and verify it", async () => { - const secretKey = getRandomBytes(32) - const now = Math.floor(Date.now() / 1000) - const exp = now + 60 - 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" - ) - }) - - test("verify JWT with RSA algorithm", async () => { - const { publicKey, privateKey } = await generateKeyPair("RS256") - const jws = await signJWS(payload, privateKey, { alg: "RS256" }) - const decodedPayload = await verifyJWS(jws, publicKey, { algorithms: ["RS256"] }) - expect(decodedPayload.sub).toBe(payload.sub) - expect(decodedPayload.name).toBe(payload.name) - expect(decodedPayload.email).toBe(payload.email) - }) - - test("fail JWT to verify a JWS with incorrect RSA public key", async () => { - const { privateKey } = await generateKeyPair("RS256") - const { publicKey: wrongPublicKey } = await generateKeyPair("RS256") - const jws = await signJWS(payload, privateKey, { alg: "RS256" }) - await expect(verifyJWS(jws, wrongPublicKey, { algorithms: ["RS256"] })).rejects.toThrow( - "JWS signature verification failed" - ) - }) - - test("verify createJWS with crypto.generateKey", async () => { - const secret = await crypto.subtle.generateKey( - { - name: "RSA-PSS", - modulusLength: 2048, - publicExponent: new Uint8Array([1, 0, 1]), - hash: "SHA-256", - }, - true, - ["sign", "verify"] - ) - const { signJWS, verifyJWS } = createJWS(secret) - const signed = await signJWS(payload, { alg: "PS256" }) - const verified = await verifyJWS(signed, { algorithms: ["PS256"] }) - expect(verified).toMatchObject(payload) - }) - - test("verify createJWS with crypto.importKey", async () => { - const secretValue = encoder.encode(getRandomBytes(32).toString()) - const secret = await crypto.subtle.importKey( - "raw", - secretValue, - { - name: "HMAC", - hash: "SHA-256", - }, - true, - ["sign", "verify"] - ) - const { signJWS, verifyJWS } = createJWS(secret) - const signed = await signJWS(payload, { alg: "HS256" }) - const verified = await verifyJWS(signed, { algorithms: ["HS256"] }) - expect(verified).toMatchObject(payload) - }) - - test("verify createJWS with RSA algorithm", async () => { - const entries = await generateKeyPair("RS256") - const { signJWS, verifyJWS } = createJWS(entries) - const jws = await signJWS(payload, { alg: "RS256" }) - const decoded = await verifyJWS(jws, { algorithms: ["RS256"] }) - expect(decoded.sub).toBe(payload.sub) - expect(decoded.name).toBe(payload.name) - expect(decoded.email).toBe(payload.email) - }) - - test("verify JWK with RSA algorithm", async () => { - const { publicKey, privateKey } = await generateKeyPair("RS256", { extractable: true }) - const publicJWK = await crypto.subtle.exportKey("jwk", publicKey) - const privateJWK = await crypto.subtle.exportKey("jwk", privateKey) - const { signJWS, verifyJWS } = createJWS({ - publicKey: publicJWK, - privateKey: privateJWK, - }) - const signed = await signJWS(payload, { alg: "RS256" }) - const verified = await verifyJWS(signed, { algorithms: ["RS256"] }) - expect(verified).toMatchObject(payload) - }) -}) - -describe("JWEs", () => { - test("encrypt and decrypt a JWE using encryptJWE and decryptJWE", async () => { - const secretKey = getRandomBytes(32) - const derivedKey = await createDeriveKey(secretKey) - - const jwe = await encryptJWE({ payload }, derivedKey) - expect(jwe).toBeDefined() - - const decryptedPayload = await decryptJWE<{ payload: string }>(jwe, derivedKey) - expect(decryptedPayload.payload).toMatchObject(payload) - }) - - test("encrypt and decrypt a JWE using createJWE", async () => { - const secretKey = getRandomBytes(32) - const derivedKey = await createDeriveKey(secretKey) - - const { signJWS } = createJWS(derivedKey) - const { encryptJWE, decryptJWE } = createJWE(derivedKey) - - const jws = await signJWS(payload) - const jwe = await encryptJWE({ payload: jws }) - expect(jwe).toBeDefined() - - 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) - await expect(decryptJWE("header.payload.signature", secretKey)).rejects.toThrow(/JWE decryption verification failed/) - }) - - test("set audience in a JWE and decrypt it", async () => { - const secretKey = getRandomBytes(32) - const jwe = await encryptJWE({ aud: "client_id_123", name: "John Doe" }, secretKey) - const decrypted = await decryptJWE(jwe, secretKey) - 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({ 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) - }) - - test("verify JWE with RSA algorithm", async () => { - const { publicKey, privateKey } = await generateKeyPair("RSA-OAEP-256") - const jwe = await encryptJWE({ payload }, publicKey, { alg: "RSA-OAEP-256", enc: "A256GCM" }) - const decryptedPayload = await decryptJWE<{ payload: string }>(jwe, privateKey, { - keyManagementAlgorithms: ["RSA-OAEP-256"], - contentEncryptionAlgorithms: ["A256GCM"], - }) - expect(decryptedPayload.payload).toMatchObject(payload) - }) - - test("verify createJWE with RSA algorithm", async () => { - const { publicKey, privateKey } = await generateKeyPair("RSA-OAEP-256") - const { encryptJWE, decryptJWE } = createJWE({ publicKey, privateKey }) - const jwe = await encryptJWE({ payload }, { alg: "RSA-OAEP-256", enc: "A256GCM" }) - const decryptedPayload = await decryptJWE<{ payload: string }>(jwe, { - keyManagementAlgorithms: ["RSA-OAEP-256"], - contentEncryptionAlgorithms: ["A256GCM"], - }) - expect(decryptedPayload.payload).toMatchObject(payload) - }) - - test("verify createCompactJWE with RSA algorithm", async () => { - const { publicKey, privateKey } = await generateKeyPair("RSA-OAEP-256") - const { compactEncryptJWE, decryptCompactJWE } = createCompactJWE({ publicKey, privateKey }) - const jwe = await compactEncryptJWE(JSON.stringify(payload), { alg: "RSA-OAEP-256", enc: "A256GCM" }) - const decryptedPayload = await decryptCompactJWE(jwe, { - keyManagementAlgorithms: ["RSA-OAEP-256"], - contentEncryptionAlgorithms: ["A256GCM"], - }) - expect(JSON.parse(decryptedPayload)).toMatchObject(payload) - }) - - test("verify JWK with RSA algorithm", async () => { - const { publicKey, privateKey } = await generateKeyPair("RSA-OAEP-256", { extractable: true }) - const publicJWK = await crypto.subtle.exportKey("jwk", publicKey) - const privateJWK = await crypto.subtle.exportKey("jwk", privateKey) - const { encryptJWE, decryptJWE } = createJWE({ - publicKey: publicJWK, - privateKey: privateJWK, - }) - const signed = await encryptJWE(payload, { alg: "RSA-OAEP-256" }) - const verified = await decryptJWE(signed, { keyManagementAlgorithms: ["RSA-OAEP-256"] }) - expect(verified).toMatchObject(payload) - }) -}) - describe("JWTs", () => { test("create a signed and encrypted JWT using createJWS and createJWE functions", async () => { const secretKey = getRandomBytes(32) @@ -480,95 +153,6 @@ describe("JWTs", () => { }) }) -describe("createSecret", () => { - test("createSecret without secret", () => { - const secret = undefined - expect(() => createSecret(secret as unknown as string)).toThrow("Secret is required") - }) - - test("createSecret with string secret with at least 32 bytes", () => { - const secretString = "this-is-a-very-secure-and-long-secret" - expect(() => createSecret(secretString)).toThrow( - `Secret string must have an entropy of at least ${MIN_SECRET_ENTROPY_BITS} bits per character` - ) - }) - - test("createSecret with string secret with less than 32 bytes", () => { - const secretString = "short-secret" - expect(() => createSecret(secretString)).toThrow("Secret string must be at least 32 bytes long") - }) - - test("createSecret returns the passed Uint8Array secret", () => { - const secretArray = new Uint8Array(32) - const secret = createSecret(secretArray) - expect(secret).toBe(secretArray) - }) - - test("createSecret with null secret", () => { - const secret = null - expect(() => createSecret(secret as unknown as string)).toThrow("Secret is required") - }) - - test("createSecret with undefined secret", () => { - const secret = undefined - expect(() => createSecret(secret as unknown as string)).toThrow("Secret is required") - }) - - test("createSecret with repeated words", () => { - const secret = "aaaabbbbccccddddeeeeffffgggghhhh" - expect(() => createSecret(secret)).toThrow( - `Secret string must have an entropy of at least ${MIN_SECRET_ENTROPY_BITS} bits per character` - ) - }) - - test("createSecret with high entropy string", () => { - const secret = "mysecretmysecretmysecretmysecret" - expect(() => createSecret(secret)).toThrow( - `Secret string must have an entropy of at least ${MIN_SECRET_ENTROPY_BITS} bits per character` - ) - }) -}) - -describe("createDeriveKey", () => { - test("createDeriveKey", async () => { - await expect(createDeriveKey("asfts")).rejects.toThrow(/Secret string must be at least 32 bytes long/) - }) - - test("createDeriveKey with 32 bytes", async () => { - const secretKey = getRandomBytes(32) - const derivedKey = await createDeriveKey(secretKey) - expect(derivedKey).toBeDefined() - expect(derivedKey.byteLength).toBe(32) - }) - - test("createDeriveKey throws when given a CryptoKey", async () => { - const cryptoKey = await globalThis.crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256" }, false, ["sign"]) - await expect(createDeriveKey(cryptoKey as unknown as SecretInput)).rejects.toThrow("Cannot derive key from CryptoKey") - }) -}) - -describe("deriveKey", () => { - test("deriveKey", async () => { - const secret = "my-secret-password-123" - const derivedKey1 = await deriveKey(encoder.encode(secret), "salt-1", "info-1") - const derivedKey2 = await deriveKey(encoder.encode(secret), "salt-2", "info-2") - expect(derivedKey1).toBeDefined() - expect(derivedKey2).toBeDefined() - expect(derivedKey1).not.toEqual(derivedKey2) - }) - - test("create deterministic derived keys", async () => { - const salt = "deterministic-salt" - const info = "deterministic-info" - const secretKey = getRandomBytes(32) - const derivedKey1 = await deriveKey(secretKey, salt, info) - const derivedKey2 = await deriveKey(secretKey, salt, info) - const derivedKey3 = await deriveKey(secretKey, salt, info) - expect(derivedKey1).toEqual(derivedKey2) - expect(derivedKey2).toEqual(derivedKey3) - }) -}) - describe("createJWT", () => { test("createJWT with separate JWS and JWE secrets", async () => { const secret = getRandomBytes(32) diff --git a/packages/jose/test/secret.test.ts b/packages/jose/test/secret.test.ts new file mode 100644 index 00000000..bc3a2c49 --- /dev/null +++ b/packages/jose/test/secret.test.ts @@ -0,0 +1,86 @@ +import { describe, test, expect } from "vitest" +import { createSecret, MIN_SECRET_ENTROPY_BITS, MIN_SECRET_ENTROPY_PER_CHAR } from "@/secret.ts" + +describe("createSecret", () => { + test("createSecret without secret", () => { + const secret = undefined + expect(() => createSecret(secret as unknown as string)).toThrow("Secret is required") + }) + + test("createSecret with string secret with at least 32 bytes", () => { + const secretString = "this-is-a-very-secure-and-long-secret" + expect(() => createSecret(secretString)).toThrow( + `Secret must have an entropy of at least ${MIN_SECRET_ENTROPY_PER_CHAR} bits per character and a total entropy of at least ${MIN_SECRET_ENTROPY_BITS} bits` + ) + }) + + test("createSecret with string secret with less than 32 bytes", () => { + const secretString = "short-secret" + expect(() => createSecret(secretString)).toThrow("Secret string must be at least 32 bytes long") + }) + + test("createSecret returns the passed Uint8Array secret", () => { + const secretArray = new Uint8Array(32) + const secret = createSecret(secretArray) + expect(secret).toBe(secretArray) + }) + + test("createSecret with null secret", () => { + const secret = null + expect(() => createSecret(secret as unknown as string)).toThrow("Secret is required") + }) + + test("createSecret with undefined secret", () => { + const secret = undefined + expect(() => createSecret(secret as unknown as string)).toThrow("Secret is required") + }) + + test("createSecret with repeated words", () => { + const secret = "aaaabbbbccccddddeeeeffffgggghhhh" + expect(() => createSecret(secret)).toThrow( + `Secret must have an entropy of at least ${MIN_SECRET_ENTROPY_PER_CHAR} bits per character and a total entropy of at least ${MIN_SECRET_ENTROPY_BITS} bits` + ) + }) + + test("createSecret with low entropy string - repeated characters", () => { + const secret = "mysecretmysecretmysecretmysecret" + expect(() => createSecret(secret)).toThrow( + `Secret must have an entropy of at least ${MIN_SECRET_ENTROPY_PER_CHAR} bits per character and a total entropy of at least ${MIN_SECRET_ENTROPY_BITS} bits` + ) + }) + + test("createSecret with low entropy string - 3.9015 bits per character", () => { + const secret = "7b3fa92e1c8d0e5b6a4f7c2d8e1b0a9f3e" + expect(() => createSecret(secret)).toThrow( + `Secret must have an entropy of at least ${MIN_SECRET_ENTROPY_PER_CHAR} bits per character and a total entropy of at least ${MIN_SECRET_ENTROPY_BITS} bits` + ) + }) + + test("createSecret with low entropy string - 3.9032 bits per character", () => { + const secret = "9a4b3d7e2f0c1b8d5e9a4f3c7b2e0d1f8a5b9c4e" + expect(() => createSecret(secret)).toThrow( + `Secret must have an entropy of at least ${MIN_SECRET_ENTROPY_PER_CHAR} bits per character and a total entropy of at least ${MIN_SECRET_ENTROPY_BITS} bits` + ) + }) + + test("createSecret with low entropy string - 3.9074 bits per character", () => { + const secret = "a8f2c7b1d0e93fa4b6c8d2e1f0a93b4c7d8e2f1a0b93c4d7e8f2b1c0a93b4c7d" + expect(() => createSecret(secret)).toThrow( + `Secret must have an entropy of at least ${MIN_SECRET_ENTROPY_PER_CHAR} bits per character and a total entropy of at least ${MIN_SECRET_ENTROPY_BITS} bits` + ) + }) + + test("createSecret with 50 characters", () => { + const secret = "8f2c7b3a9e1d0c4b8f2c7b3a9e1d0c4b8f2c7b3a9e1d0c4b8f" + expect(() => createSecret(secret)).toThrow( + `Secret must have an entropy of at least ${MIN_SECRET_ENTROPY_PER_CHAR} bits per character and a total entropy of at least ${MIN_SECRET_ENTROPY_BITS} bits` + ) + }) + + test("createSecret with 66 characters", () => { + const secret = "4b2d7e0f1a9c3b5d8e2f0a1c9b3d5e8f2a0c1b9d3e5f8a2c1b9d3e5f8a2c1b9d3e" + expect(() => createSecret(secret)).toThrow( + `Secret must have an entropy of at least ${MIN_SECRET_ENTROPY_PER_CHAR} bits per character and a total entropy of at least ${MIN_SECRET_ENTROPY_BITS} bits` + ) + }) +}) diff --git a/packages/jose/test/sign.test.ts b/packages/jose/test/sign.test.ts new file mode 100644 index 00000000..c534ae7d --- /dev/null +++ b/packages/jose/test/sign.test.ts @@ -0,0 +1,247 @@ +import { describe, test, expect } from "vitest" +import { createDeriveKey } from "@/deriveKey.ts" +import { encoder, getRandomBytes } from "@/crypto.ts" +import { generateKeyPair, type JWTPayload } from "jose" +import { createJWS, signJWS, verifyJWS } from "@/sign.ts" + +const payload: JWTPayload = { + sub: "user-123", + name: "John Doe", + email: "john.doe@example.com", +} + +describe("JWSs", () => { + test("sign and verify a JWS using signJWS and verifyJWS", async () => { + const secretKey = getRandomBytes(32) + const derivedKey = await createDeriveKey(secretKey) + + const jws = await signJWS(payload, derivedKey) + expect(jws).toBeDefined() + + const decodedPayload = await verifyJWS(jws, derivedKey) + expect(decodedPayload.sub).toBe(payload.sub) + expect(decodedPayload.name).toBe(payload.name) + expect(decodedPayload.email).toBe(payload.email) + }) + + test("sign and verify a JWS using createJWS", async () => { + const secretKey = getRandomBytes(32) + const derivedKey = await createDeriveKey(secretKey) + + const { signJWS, verifyJWS } = createJWS(derivedKey) + + const jws = await signJWS(payload) + expect(jws).toBeDefined() + + const decodedPayload = await verifyJWS(jws) + expect(decodedPayload.sub).toBe(payload.sub) + expect(decodedPayload.name).toBe(payload.name) + expect(decodedPayload.email).toBe(payload.email) + }) + + test("fail JWT to try to verify an invalid JWS", async () => { + const { verifyJWS } = createJWS("my-secret-key") + await expect(verifyJWS("invalid.jwt.token")).rejects.toThrow("Secret string must be at least 32 bytes long") + }) + + test("fail JWT to try to verify a JWS with invalid secret", async () => { + const secretKey = getRandomBytes(32) + const derivedKey = await createDeriveKey(secretKey) + + const jws = await signJWS(payload, derivedKey) + expect(jws).toBeDefined() + + const { verifyJWS } = createJWS("wrong-secret-key") + await expect(verifyJWS(jws)).rejects.toThrow("Secret string must be at least 32 bytes long") + }) + + test("fail JWT with invalid format JWS", async () => { + const secretKey = getRandomBytes(32) + const { signJWS } = createJWS(secretKey) + await expect(signJWS(undefined as unknown as JWTPayload)).rejects.toThrow("The payload must be a non-empty object") + }) + + test("set audience in a JWS and verify it", async () => { + const secretKey = getRandomBytes(32) + const jws = await signJWS({ aud: "client_id_123", name: "John Doe" }, secretKey) + expect(await verifyJWS(jws, secretKey, { audience: "client_id_123" })).toMatchObject({ name: "John Doe" }) + }) + + test("fail JWT to verify a JWS with incorrect audience", async () => { + const secretKey = getRandomBytes(32) + const jws = await signJWS({ aud: "client_id_123", name: "John Doe" }, secretKey) + await expect(verifyJWS(jws, secretKey, { audience: "wrong_audience" })).rejects.toThrow( + "JWS signature verification failed" + ) + }) + + test("set expiration time in the payload of a JWS and verify it", async () => { + const secretKey = getRandomBytes(32) + const now = Math.floor(Date.now() / 1000) + const exp = now + 60 + 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" + ) + }) + + test("verify JWT with RSA algorithm", async () => { + const { publicKey, privateKey } = await generateKeyPair("RS256") + const jws = await signJWS(payload, privateKey, { alg: "RS256" }) + const decodedPayload = await verifyJWS(jws, publicKey, { algorithms: ["RS256"] }) + expect(decodedPayload.sub).toBe(payload.sub) + expect(decodedPayload.name).toBe(payload.name) + expect(decodedPayload.email).toBe(payload.email) + }) + + test("fail JWT to verify a JWS with incorrect RSA public key", async () => { + const { privateKey } = await generateKeyPair("RS256") + const { publicKey: wrongPublicKey } = await generateKeyPair("RS256") + const jws = await signJWS(payload, privateKey, { alg: "RS256" }) + await expect(verifyJWS(jws, wrongPublicKey, { algorithms: ["RS256"] })).rejects.toThrow( + "JWS signature verification failed" + ) + }) + + test("verify createJWS with crypto.generateKey", async () => { + const secret = await crypto.subtle.generateKey( + { + name: "RSA-PSS", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"] + ) + const { signJWS, verifyJWS } = createJWS(secret) + const signed = await signJWS(payload, { alg: "PS256" }) + const verified = await verifyJWS(signed, { algorithms: ["PS256"] }) + expect(verified).toMatchObject(payload) + }) + + test("verify createJWS with crypto.importKey", async () => { + const secretValue = encoder.encode(getRandomBytes(32).toString()) + const secret = await crypto.subtle.importKey( + "raw", + secretValue, + { + name: "HMAC", + hash: "SHA-256", + }, + true, + ["sign", "verify"] + ) + const { signJWS, verifyJWS } = createJWS(secret) + const signed = await signJWS(payload, { alg: "HS256" }) + const verified = await verifyJWS(signed, { algorithms: ["HS256"] }) + expect(verified).toMatchObject(payload) + }) + + test("verify createJWS with RSA algorithm", async () => { + const entries = await generateKeyPair("RS256") + const { signJWS, verifyJWS } = createJWS(entries) + const jws = await signJWS(payload, { alg: "RS256" }) + const decoded = await verifyJWS(jws, { algorithms: ["RS256"] }) + expect(decoded.sub).toBe(payload.sub) + expect(decoded.name).toBe(payload.name) + expect(decoded.email).toBe(payload.email) + }) + + test("verify JWK with RSA algorithm", async () => { + const { publicKey, privateKey } = await generateKeyPair("RS256", { extractable: true }) + const publicJWK = await crypto.subtle.exportKey("jwk", publicKey) + const privateJWK = await crypto.subtle.exportKey("jwk", privateKey) + const { signJWS, verifyJWS } = createJWS({ + publicKey: publicJWK, + privateKey: privateJWK, + }) + const signed = await signJWS(payload, { alg: "RS256" }) + const verified = await verifyJWS(signed, { algorithms: ["RS256"] }) + expect(verified).toMatchObject(payload) + }) + + test("sign and verify with HMAC algorithm", async () => { + const secretKey = getRandomBytes(32) + const signed = await signJWS(payload, secretKey) + await expect( + verifyJWS(signed, secretKey, { + algorithms: ["RS256"], + }) + ).rejects.toThrow("JWS signature verification failed") + }) + + test("infer algorithm from key type", async () => { + const { publicKey, privateKey } = await generateKeyPair("RS256") + const signed = await signJWS(payload, privateKey) + const verified = await verifyJWS(signed, publicKey) + expect(verified).toMatchObject(payload) + }) + + test("infer asymmetric algorithm from the key type", async () => { + const entries = await generateKeyPair("RS256") + const { signJWS, verifyJWS } = createJWS(entries) + const signed = await signJWS(payload) + const verified = await verifyJWS(signed) + expect(verified).toMatchObject(payload) + }) +})