diff --git a/packages/core/package.json b/packages/core/package.json index c81468f3..de88c2ef 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -7,6 +7,8 @@ "scripts": { "dev": "tsup --watch", "build": "tsup", + "lint": "oxlint", + "lint:fix": "oxlint --fix", "test": "vitest --run", "test:watch": "vitest", "test:coverage": "vitest --run --coverage", diff --git a/packages/core/src/@types/index.ts b/packages/core/src/@types/index.ts index 336d39e3..1507063d 100644 --- a/packages/core/src/@types/index.ts +++ b/packages/core/src/@types/index.ts @@ -2,15 +2,18 @@ import { z } from "zod" import { createLogEntry } from "@/logger.ts" import { OAuthAccessTokenErrorResponse, OAuthAuthorizationErrorResponse, OAuthEnvSchema } from "@/schemas.ts" import { createJoseInstance, type JWTPayload } from "@/jose.ts" -import type { SerializeOptions } from "@aura-stack/router/cookie" -import type { BuiltInOAuthProvider } from "@/oauth/index.ts" -import type { LiteralUnion, Prettify } from "@/@types/utility.ts" -import type { createAuthInstance } from "@/createAuth.ts" import type { createAuthAPI } from "@/api/createApi.ts" import type { ClientOptions } from "@aura-stack/router" +import type { createAuthInstance } from "@/createAuth.ts" +import type { BuiltInOAuthProvider } from "@/oauth/index.ts" +import type { LiteralUnion, Prettify } from "@/@types/utility.ts" +import type { SerializeOptions } from "@aura-stack/router/cookie" +import type { JWTKey, Session, SessionConfig, SessionStrategy, User } from "@/@types/session.ts" -export * from "./utility.ts" +export type * from "./utility.ts" export type { BuiltInOAuthProvider } from "@/oauth/index.ts" +export type * from "@/@types/session.ts" +export type { TypedJWTPayload } from "@aura-stack/jose" /** * Standard JWT claims that are managed internally by the token system. @@ -23,25 +26,6 @@ export type JWTStandardClaims = Pick { - sub: string - name?: string | null - email?: string | null - image?: string | null -} - -/** - * Session data returned by the session endpoint. - */ -export interface Session { - user: User - expires: string -} - export type AuthorizeParams = LiteralUnion< "clientId" | "prompt" | "scope" | "responseMode" | "audience" | "loginHint" | "nonce" | "display" > @@ -210,7 +194,7 @@ export interface AuthConfig { * If not provided, it will load from the environment variable `AURA_AUTH_SECRET` or `AUTH_SECRET`, but if it * doesn't exist, it will throw an error during the initialization of the Auth module. */ - secret?: string + secret?: JWTKey /** * Base URL of the application, used to construct the incoming request's origin. */ @@ -236,7 +220,10 @@ export interface AuthConfig { * @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. @@ -255,6 +242,10 @@ export interface AuthConfig { * } */ trustedOrigins?: TrustedOrigin[] | ((request: Request) => Promise | TrustedOrigin[]) + /** + * Defines the session management strategy for Aura Auth. It determines how sessions are created, stored, and validated. + */ + session?: SessionConfig } /** @@ -283,12 +274,13 @@ export interface RouterGlobalContext { oauth: OAuthProviderRecord cookies: CookieStoreConfig jose: JoseInstance - secret?: string + secret?: JWTKey baseURL?: string basePath: string trustedProxyHeaders: boolean trustedOrigins?: TrustedOrigin[] | ((request: Request) => Promise | TrustedOrigin[]) logger?: InternalLogger + session: SessionStrategy } /** diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts new file mode 100644 index 00000000..9ce7c881 --- /dev/null +++ b/packages/core/src/@types/session.ts @@ -0,0 +1,199 @@ +import type { JoseInstance, CookieStoreConfig, InternalLogger } from "@/@types/index.ts" + +/** + * Standardized user profile returned by OAuth providers after fetching user information + * and mapping the response to this format by default or via the `profile` custom function. + */ +export interface User extends Record { + sub: string + name?: string | null + email?: string | null + image?: string | null +} + +/** + * Session data returned by the session endpoint. + */ +export interface Session { + user: User + expires: string +} + +/** + * A symmetric secret or asymmetric key pair used for JWT operations. + * + * - string / Uint8Array: used as-is for HMAC (signed) or AES (encrypted) + * - CryptoKey: Web Crypto API key, for environments that support it + * - KeyPair: asymmetric signing (RS256, ES256, EdDSA, etc.) + */ +export type SecretKey = string | Uint8Array | CryptoKey + +export interface KeyPair { + privateKey: CryptoKey + publicKey: CryptoKey +} + +/** + * @todo: add key rotation support for "SecretKey | KeyPair | [SecretKey | KeyPair, ...(SecretKey | KeyPair)[]]" + */ +export type JWTKey = SecretKey + +/** + * - "signed" → standard JWS (e.g. HS256, RS256, ES256). + * - "encrypted" → JWE only. (e.g. A256GCM with RSA-OAEP key wrapping). + * - "sealed" → JWS nested inside JWE (signed then encrypted). + */ +export type JWTMode = "signed" | "encrypted" | "sealed" + +/** + * Signing algorithms for "signed" and "sealed" modes. + * Symmetric: HS256 | HS384 | HS512 + * Asymmetric: RS256 | RS384 | RS512 | ES256 | ES384 | ES512 | EdDSA | PS256 + */ +export type JWTSigningAlgorithm = + | "HS256" + | "HS384" + | "HS512" + | "RS256" + | "RS384" + | "RS512" + | "ES256" + | "ES384" + | "ES512" + | "EdDSA" + | "PS256" + +/** + * Key-wrapping algorithms for "encrypted" and "sealed" modes. + * Symmetric: A128KW | A192KW | A256KW | dir (direct) + * ECDH: ECDH-ES | ECDH-ES+A128KW | ECDH-ES+A256KW + * RSA: RSA-OAEP | RSA-OAEP-256 + */ +export type JWTKeyAlgorithm = + | "A128KW" + | "A192KW" + | "A256KW" + | "dir" + | "ECDH-ES" + | "ECDH-ES+A128KW" + | "ECDH-ES+A256KW" + | "RSA-OAEP" + | "RSA-OAEP-256" + +/** Content-encryption algorithms for JWE. */ +export type JWTEncryptionAlgorithm = "A128CBC-HS256" | "A192CBC-HS384" | "A256CBC-HS512" | "A128GCM" | "A192GCM" | "A256GCM" + +/** Signed JWT mode configuration. */ +export type JWTSignedMode = { + mode: "signed" + signingAlgorithm?: JWTSigningAlgorithm +} + +/** Encrypted JWT mode configuration. */ +export type JWTEncryptedMode = { + mode: "encrypted" + keyAlgorithm?: JWTKeyAlgorithm + encryptionAlgorithm?: JWTEncryptionAlgorithm +} + +/** Signed and Encrypted JWT mode configuration. */ +export type JWTSealedMode = { + mode?: "sealed" + signingAlgorithm?: JWTSigningAlgorithm + keyAlgorithm?: JWTKeyAlgorithm + encryptionAlgorithm?: JWTEncryptionAlgorithm +} + +export type JWTConfigBase = JWTSignedMode | JWTEncryptedMode | JWTSealedMode + +export type JWTConfig = { + /** + * Token lifetime. + */ + maxAge?: number + /** + * JWT `iss` (issuer) claim. Set this to your app's canonical URL. + * @example "https://auth.example.com" + */ + issuer?: string + /** + * JWT `aud` claim. Single value or array for multi-audience tokens. + * @example ["https://api.example.com", "https://app.example.com"] + */ + audience?: string | string[] +} & JWTConfigBase + +/** + * Stateless JWT strategy. + * No database required. Tokens are self-contained and cannot be revoked + * before they expire — keep `jwt.maxAge` short or enable refresh tokens. + * + * @example + * { + * strategy: "jwt", + * jwt: { mode: "sealed", maxAge: "15m", issuer: "https://auth.example.com" }, + * refreshToken: { enabled: true, maxAge: "7d" }, + * } + */ +export type StatelessStrategyConfig = { + strategy?: "jwt" + jwt?: JWTConfig +} + +/** + * The session strategy. Determines which fields below are required. + * + * - "jwt": stateless. No database needed. JWTs are self-contained. + * - "database": stateful. Every request hits the DB to validate the session. + * - "hybrid": JWT transport + DB revocation. Best of both for most apps. + * + * @default "jwt" + */ +export type SessionConfig = StatelessStrategyConfig + +export interface SessionStrategy { + /** + * Read and validate the session from an incoming request. + * Returns null if absent, invalid, or expired. Never throws on auth failure. + */ + getSession(request: Headers): Promise + + /** + * Create a session after successful authentication. + * Signs the JWT / writes the DB row / sets cookies. + */ + createSession(session: User): Promise + + /** + * Attempt to refresh using the refresh token cookie. + * Returns null session + cookie-clearing response on any failure. + */ + refreshSession(request: Headers): Promise + + /** + * Revoke a session by ID. + * JWT strategy: best-effort (clears cookies, no server state). + * Database / hybrid: marks row inactive. + */ + revokeSession(sessionId: string): Promise + + /** + * Destroy the session attached to this request (logout). + * Returns a response that clears cookies. + */ + destroySession(request: Headers, skipCSRFCheck?: boolean): Promise +} + +export interface CreateSessionStrategyOptions { + config?: SessionConfig + jose: JoseInstance + cookies: () => CookieStoreConfig + logger?: InternalLogger +} + +export interface JWTStrategyOptions { + config?: StatelessStrategyConfig + jose: JoseInstance + logger?: InternalLogger + cookies: () => CookieStoreConfig +} diff --git a/packages/core/src/actions/callback/callback.ts b/packages/core/src/actions/callback/callback.ts index 9278ba22..92c3a6d2 100644 --- a/packages/core/src/actions/callback/callback.ts +++ b/packages/core/src/actions/callback/callback.ts @@ -8,7 +8,7 @@ import { OAuthAuthorizationErrorResponse } from "@/schemas.ts" import { AuthSecurityError, OAuthProtocolError } from "@/errors.ts" import { getOriginURL, getTrustedOrigins } from "@/actions/signIn/authorization.ts" import { createAccessToken } from "@/actions/callback/access-token.ts" -import { createSessionCookie, getCookie, expiredCookieAttributes } from "@/cookie.ts" +import { getCookie, expiredCookieAttributes } from "@/cookie.ts" import type { OAuthProviderRecord } from "@/@types/index.ts" const callbackConfig = (oauth: OAuthProviderRecord) => { @@ -108,7 +108,7 @@ export const callbackAction = (oauth: OAuthProviderRecord) => { } const userInfo = await getUserInfo(oauthConfig, accessToken.access_token, logger) - const sessionCookie = await createSessionCookie(jose, userInfo) + const session = await context.session.createSession(userInfo) const csrfToken = await createCSRF(jose) logger?.log("OAUTH_CALLBACK_SUCCESS", { @@ -119,7 +119,7 @@ export const callbackAction = (oauth: OAuthProviderRecord) => { const headers = new HeadersBuilder(cacheControl) .setHeader("Location", cookieRedirectTo) - .setCookie(cookies.sessionToken.name, sessionCookie, cookies.sessionToken.attributes) + .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) diff --git a/packages/core/src/api/getSession.ts b/packages/core/src/api/getSession.ts index 94084bb6..2b95b7eb 100644 --- a/packages/core/src/api/getSession.ts +++ b/packages/core/src/api/getSession.ts @@ -1,18 +1,12 @@ -import { getCookie } from "@/cookie.ts" -import { getErrorName, toISOString } from "@/utils.ts" +import { getErrorName } from "@/utils.ts" import type { FunctionAPIContext, GetSessionAPIOptions, SessionResponse } from "@/@types/index.ts" export const getSession = async ({ ctx, headers }: FunctionAPIContext): Promise => { try { - const session = getCookie(new Headers(headers), ctx.cookies.sessionToken.name) - const decoded = await ctx.jose.decodeJWT(session) - ctx?.logger?.log("AUTH_SESSION_VALID") - const { exp, iat: _iat, jti: _jti, nbf: _nbf, aud: _aud, iss: _iss, ...user } = decoded + const session = await ctx.session.getSession(new Headers(headers)) + if (!session) return { session: null, authenticated: false } return { - session: { - user, - expires: toISOString(exp! * 1000), - }, + session, authenticated: true, } } catch (error) { diff --git a/packages/core/src/api/signOut.ts b/packages/core/src/api/signOut.ts index 5378fb23..841863ad 100644 --- a/packages/core/src/api/signOut.ts +++ b/packages/core/src/api/signOut.ts @@ -1,8 +1,3 @@ -import { expiredCookieAttributes, getCookie } from "@/cookie.ts" -import { verifyCSRF } from "@/secure.ts" -import { getErrorName } from "@/utils.ts" -import { AuthSecurityError } from "@/errors.ts" -import { secureApiHeaders } from "@/headers.ts" import { HeadersBuilder } from "@aura-stack/router" import type { FunctionAPIContext, SignOutAPIOptions } from "@/@types/index.ts" @@ -12,67 +7,9 @@ export const signOut = async ({ redirectTo = "/", skipCSRFCheck = false, }: FunctionAPIContext) => { - const headers = new Headers(headersInit) - const header = headers.get("X-CSRF-Token") - let session = null - let csrfToken = null - try { - session = getCookie(headers, ctx.cookies.sessionToken.name) - } catch { - throw new AuthSecurityError("SESSION_TOKEN_MISSING", "The sessionToken is missing.") - } - try { - csrfToken = getCookie(headers, ctx.cookies.csrfToken.name) - } catch { - throw new AuthSecurityError("CSRF_TOKEN_MISSING", "The CSRF token is missing.") - } - ctx?.logger?.log("SIGN_OUT_ATTEMPT", { - structuredData: { - has_session: Boolean(session), - has_csrf_token: Boolean(csrfToken), - has_csrf_header: Boolean(header), - skip_csrf_check: skipCSRFCheck, - }, - }) - if (!session) { - ctx?.logger?.log("SESSION_TOKEN_MISSING") - throw new AuthSecurityError("SESSION_TOKEN_MISSING", "The sessionToken is missing.") - } - if (!skipCSRFCheck) { - if (!csrfToken) { - ctx?.logger?.log("CSRF_TOKEN_MISSING") - throw new AuthSecurityError("CSRF_TOKEN_MISSING", "The CSRF token is missing.") - } - if (!header) { - ctx?.logger?.log("CSRF_HEADER_MISSING") - throw new AuthSecurityError("CSRF_HEADER_MISSING", "The CSRF header is missing.") - } - try { - await verifyCSRF(ctx.jose, csrfToken, header) - } catch (error) { - ctx?.logger?.log("CSRF_TOKEN_INVALID", { structuredData: { error_type: getErrorName(error) } }) - throw new AuthSecurityError("CSRF_TOKEN_INVALID", "CSRF token verification failed") - } - ctx?.logger?.log("SIGN_OUT_CSRF_VERIFIED") - } else { - try { - await ctx.jose.verifyJWS(csrfToken) - } catch (error) { - ctx?.logger?.log("CSRF_TOKEN_INVALID", { structuredData: { error_type: getErrorName(error) } }) - throw new AuthSecurityError("CSRF_TOKEN_INVALID", "CSRF token verification failed") - } - } - try { - await ctx.jose.decodeJWT(session) - ctx?.logger?.log("SIGN_OUT_SUCCESS") - } catch (error) { - ctx?.logger?.log("INVALID_JWT_TOKEN", { structuredData: { error_type: getErrorName(error) } }) - } - const headersList = new HeadersBuilder(secureApiHeaders) - .setHeader("Location", redirectTo) - .setCookie(ctx.cookies.csrfToken.name, "", expiredCookieAttributes) - .setCookie(ctx.cookies.sessionToken.name, "", expiredCookieAttributes) - .toHeaders() + const headers = await ctx.session.destroySession(new Headers(headersInit), skipCSRFCheck) + + const headersList = new HeadersBuilder(headers).setHeader("Location", redirectTo).toHeaders() return Response.json( { redirect: Boolean(redirectTo), url: redirectTo }, { diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index 78c9c9ec..6c268235 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -1,6 +1,7 @@ import { createJoseInstance } from "@/jose.ts" import { createProxyLogger } from "@/logger.ts" import { createCookieStore } from "@/cookie.ts" +import { createSessionStrategy } from "@/session/index.ts" import { getEnv, getEnvArray, getEnvBoolean } from "@/env.ts" import { createBuiltInOAuthProviders } from "@/oauth/index.ts" import type { AuthConfig, InternalContext } from "@/@types/index.ts" @@ -14,11 +15,12 @@ export const createContext = (config?: AuthConfig): InternalContext => { const cookieOverrides = config?.cookies?.overrides ?? {} const secureCookieStore = createCookieStore(true, cookiePrefix, cookieOverrides, logger) const standardCookieStore = createCookieStore(false, cookiePrefix, cookieOverrides, logger) + const jose = createJoseInstance(config?.secret, config?.session) - return { + const ctx = { oauth: createBuiltInOAuthProviders(config?.oauth), cookies: standardCookieStore, - jose: createJoseInstance(config?.secret), + jose, secret: config?.secret, basePath: config?.basePath ?? "/auth", trustedProxyHeaders: useProxyHeaders, @@ -26,5 +28,12 @@ export const createContext = (config?: AuthConfig): InternalContext => { logger, cookieConfig: { secure: secureCookieStore, standard: standardCookieStore }, baseURL: config?.baseURL, - } + } as InternalContext + ctx.session = createSessionStrategy({ + cookies: () => ctx.cookies, + jose, + config: config?.session, + logger: ctx.logger, + }) + return ctx } diff --git a/packages/core/src/cookie.ts b/packages/core/src/cookie.ts index a8c29f72..3d1b2f87 100644 --- a/packages/core/src/cookie.ts +++ b/packages/core/src/cookie.ts @@ -1,7 +1,7 @@ import { env } from "@/env.ts" -import { parse, parseSetCookie, serialize, type SerializeOptions } from "@aura-stack/router/cookie" import { AuthInternalError } from "@/errors.ts" -import type { AuthRuntimeConfig, CookieStoreConfig, CookieConfig, InternalLogger, User } from "@/@types/index.ts" +import { parse, parseSetCookie, serialize, type SerializeOptions } from "@aura-stack/router/cookie" +import type { CookieStoreConfig, CookieConfig, InternalLogger } from "@/@types/index.ts" /** * Prefix for all cookies set by Aura Auth. @@ -109,23 +109,6 @@ export const getSetCookie = (response: Response, cookieName: string) => { return parseSetCookie(strCookie).value } -/** - * Create a session cookie containing a signed and encrypted JWT, using the - * `@aura-stack/jose` package for the encoding. - * - * @param jose - Jose Instance - * @param session - The JWT payload to be encoded in the session cookie - * @returns The serialized session cookie string - */ -export const createSessionCookie = async (jose: AuthRuntimeConfig["jose"], session: User) => { - try { - const encoded = await jose.encodeJWT(session) - return encoded - } catch (error) { - throw new AuthInternalError("INVALID_JWT_TOKEN", "Failed to create session cookie", { cause: error }) - } -} - /** * Defines the cookie configuration based on the request security and cookie options passed * in the Aura Auth configuration (`createAuth` function). This function ensures the correct diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index c50428f8..2ef05684 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -69,6 +69,14 @@ export class AuthClientError extends Error { } } +export class AuthInvalidConfigurationError extends Error { + constructor(message?: string, options?: ErrorOptions) { + super(message, options) + this.name = new.target.name + Error?.captureStackTrace?.(this, new.target) + } +} + export const isNativeError = (error: unknown): error is Error => { return error instanceof Error } @@ -88,3 +96,7 @@ export const isAuthSecurityError = (error: unknown): error is AuthSecurityError export const isAuthClientError = (error: unknown): error is AuthClientError => { return error instanceof AuthClientError } + +export const isAuthInvalidConfigurationError = (error: unknown): error is AuthInvalidConfigurationError => { + return error instanceof AuthInvalidConfigurationError +} diff --git a/packages/core/src/jose.ts b/packages/core/src/jose.ts index 0b794525..3d06ed65 100644 --- a/packages/core/src/jose.ts +++ b/packages/core/src/jose.ts @@ -16,17 +16,112 @@ import { import { AuthInternalError } from "@/errors.ts" export { base64url, type JWTPayload } from "@aura-stack/jose/jose" export { encoder, getRandomBytes, getSubtleCrypto } from "@aura-stack/jose/crypto" -import type { User } from "@/@types/index.ts" +import type { User, SessionConfig, JWTMode, JWTConfig, JWTKey } from "@/@types/index.ts" + +export const isSignedMode = (config?: SessionConfig): config is { jwt: Extract } => + getJWTMode(config) === "signed" + +export const isEncryptedMode = (config?: SessionConfig): config is { jwt: Extract } => + getJWTMode(config) === "encrypted" + +export const isSealedMode = (config?: SessionConfig): config is { jwt: Extract } => + getJWTMode(config) === "sealed" + +/** + * Extracts the JWT mode from a SessionConfig. + * Defaults to "sealed" when no mode is specified. + */ +const getJWTMode = (config?: SessionConfig): JWTMode => { + return config?.jwt?.mode ?? "sealed" +} + +const getJWTConfig = (config?: SessionConfig) => { + return config?.jwt +} + +export const getJWTClaims = (config?: SessionConfig) => { + const jwt = getJWTConfig(config) + const claims: Record = {} + if (jwt?.audience) { + claims.aud = jwt.audience + } + if (jwt?.issuer) { + claims.iss = jwt.issuer + } + if (jwt?.maxAge) { + claims.exp = jwt.maxAge + } + return claims +} + +export const getPayloadClaims = (payload: TypedJWTPayload>, config?: SessionConfig) => { + const claims = getJWTClaims(config) + return { ...claims, ...payload } +} + +export const getSignOptions = (config?: SessionConfig, options?: JWTHeaderParameters) => { + const signOptions = {} as JWTHeaderParameters + if ((isSignedMode(config) || isSealedMode(config)) && config?.jwt?.signingAlgorithm) { + signOptions.alg = config.jwt.signingAlgorithm + } + return { ...signOptions, ...options } +} + +export const getEncryptOptions = (config?: SessionConfig, options?: JWEHeaderParameters) => { + const encryptOptions: JWEHeaderParameters = {} + if (isEncryptedMode(config) || isSealedMode(config)) { + if (config?.jwt?.keyAlgorithm) { + encryptOptions.alg = config.jwt.keyAlgorithm + } + if (config?.jwt?.encryptionAlgorithm) { + encryptOptions.enc = config.jwt.encryptionAlgorithm + } + } + return { ...encryptOptions, ...options } +} + +export const getVerifyOptions = (config?: SessionConfig, options?: JWTVerifyOptions) => { + const verifyOptions: JWTVerifyOptions = {} + if (isSignedMode(config) || isSealedMode(config)) { + if (config?.jwt?.signingAlgorithm) { + verifyOptions.algorithms = [config.jwt.signingAlgorithm] + } + verifyOptions.issuer = config?.jwt?.issuer + verifyOptions.audience = config?.jwt?.audience + } + return { ...verifyOptions, ...options } +} + +export const getDecryptOptions = (config?: SessionConfig, options?: JWTDecryptOptions) => { + const decryptOptions: JWTDecryptOptions = {} + if (isEncryptedMode(config) || isSealedMode(config)) { + if (config?.jwt?.keyAlgorithm) { + decryptOptions.keyManagementAlgorithms = [config.jwt.keyAlgorithm] + } + if (config?.jwt?.encryptionAlgorithm) { + decryptOptions.contentEncryptionAlgorithms = [config.jwt.encryptionAlgorithm] + } + decryptOptions.issuer = config?.jwt?.issuer + decryptOptions.audience = config?.jwt?.audience + } + return { ...decryptOptions, ...options } +} /** * Creates the JOSE instance used for signing and verifying tokens. It derives keys * for session tokens and CSRF tokens. For security and determinism, it's required * to set a salt value in `AURA_AUTH_SALT` or `AUTH_SALT` env. * + * The instance respects the `SessionConfig` to determine: + * - **mode**: `signed` (JWS only), `encrypted` (JWE only), or `sealed` (JWS + JWE) + * - **algorithms**: signing, key-wrapping, and content-encryption algorithms + * - **claims**: audience, issuer, maxAge + * * @param secret the base secret for key derivation + * @param session the session configuration that drives algorithm and mode selection * @returns jose instance with methods for encoding/decoding JWTs and signing/verifying JWSs */ -export const createJoseInstance = (secret?: string) => { +export const createJoseInstance = (secret?: JWTKey, session?: SessionConfig) => { secret ??= getEnv("SECRET") if (!secret) { throw new AuthInternalError( @@ -68,29 +163,35 @@ export const createJoseInstance = (secret?: string) => { jose.catch(() => {}) return { - encodeJWT: async (payload: TypedJWTPayload>, options?: EncodeJWTOptions) => { - const { jwt } = await jose - return jwt.encodeJWT(payload, options) - }, - decodeJWT: async (token: string, options?: DecodeJWTOptions) => { - const { jwt } = await jose - return jwt.decodeJWT(token, options) - }, signJWS: async (payload: TypedJWTPayload>, options?: JWTHeaderParameters) => { const { jws } = await jose - return jws.signJWS(payload, options) + return jws.signJWS(getPayloadClaims(payload, session), getSignOptions(session, options)) }, verifyJWS: async (token: string, options?: JWTVerifyOptions) => { const { jws } = await jose - return jws.verifyJWS(token, options) + return jws.verifyJWS(token, getVerifyOptions(session, options)) }, encryptJWE: async (payload: TypedJWTPayload>, options?: JWEHeaderParameters) => { const { jwe } = await jose - return jwe.encryptJWE(payload, options) + return jwe.encryptJWE(getPayloadClaims(payload, session), getEncryptOptions(session, options)) }, decryptJWE: async (token: string, options?: JWTDecryptOptions) => { const { jwe } = await jose - return jwe.decryptJWE(token, options) + return jwe.decryptJWE(token, getDecryptOptions(session, options)) + }, + encodeJWT: async (payload: TypedJWTPayload>, options?: EncodeJWTOptions) => { + const { jwt } = await jose + return jwt.encodeJWT(getPayloadClaims(payload, session), { + sign: getSignOptions(session, options?.sign), + encrypt: getEncryptOptions(session, options?.encrypt), + }) + }, + decodeJWT: async (token: string, options?: DecodeJWTOptions) => { + const { jwt } = await jose + return jwt.decodeJWT(token, { + verify: getVerifyOptions(session, options?.verify), + decrypt: getDecryptOptions(session, options?.decrypt), + }) }, } } diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts new file mode 100644 index 00000000..15de030d --- /dev/null +++ b/packages/core/src/session/index.ts @@ -0,0 +1,19 @@ +import { AuthInvalidConfigurationError } from "@/errors.ts" +import { createStatelessStrategy } from "@/session/strategies/stateless.ts" +import type { CreateSessionStrategyOptions, SessionStrategy } from "@/@types/session.ts" + +export const createSessionStrategy = ({ config, jose, cookies, logger }: CreateSessionStrategyOptions): SessionStrategy => { + const strategy = config?.strategy ?? "jwt" + + switch (strategy) { + case "jwt": + return createStatelessStrategy({ + jose, + config, + cookies, + logger, + }) + default: + throw new AuthInvalidConfigurationError(`[auth] unknown session strategy "${strategy}". Valid options are: "jwt".`) + } +} diff --git a/packages/core/src/session/manager/cookie.ts b/packages/core/src/session/manager/cookie.ts new file mode 100644 index 00000000..4d39d62a --- /dev/null +++ b/packages/core/src/session/manager/cookie.ts @@ -0,0 +1,27 @@ +import { HeadersBuilder } from "@aura-stack/router" +import { secureApiHeaders } from "@/headers.ts" +import { expiredCookieAttributes, getCookie as getCookieByName } from "@/cookie.ts" +import type { CookieStoreConfig } from "@/@types/index.ts" + +export const createCookieManager = (store: () => CookieStoreConfig) => { + const getCookie = (request: Request | Headers) => { + const sessionToken = getCookieByName(request, store().sessionToken.name) + return { + sessionToken, + } + } + + const setCookie = ({ sessionToken }: { sessionToken: string }) => { + return new HeadersBuilder(secureApiHeaders) + .setCookie(store().sessionToken.name, sessionToken, store().sessionToken.attributes) + .toHeaders() + } + + const clear = () => { + return new HeadersBuilder(secureApiHeaders) + .setCookie(store().csrfToken.name, "", { ...expiredCookieAttributes, ...store().csrfToken.attributes }) + .setCookie(store().sessionToken.name, "", { ...expiredCookieAttributes, ...store().sessionToken.attributes }) + .toHeaders() + } + return { getCookie, setCookie, clear } +} diff --git a/packages/core/src/session/manager/jose.ts b/packages/core/src/session/manager/jose.ts new file mode 100644 index 00000000..725075b1 --- /dev/null +++ b/packages/core/src/session/manager/jose.ts @@ -0,0 +1,23 @@ +import { AuthInvalidConfigurationError } from "@/errors.ts" +import type { TypedJWTPayload } from "@aura-stack/jose" +import type { JoseInstance, User, JWTConfig } from "@/@types/index.ts" + +export type JWTManager = { + createToken(user: TypedJWTPayload>): Promise + verifyToken(token: string): Promise> +} + +export const createJoseManager = (config: JWTConfig | undefined, jose: JoseInstance): JWTManager => { + const mode = config?.mode ?? "sealed" + + if (!["sealed", "signed", "encrypted"].includes(mode)) { + throw new AuthInvalidConfigurationError( + `[auth] invalid JWT mode "${mode}". Valid options are: "sealed", "signed", "encrypted".` + ) + } + + return { + createToken: mode === "sealed" ? jose.encodeJWT : mode === "signed" ? jose.signJWS : jose.encryptJWE, + verifyToken: mode === "sealed" ? jose.decodeJWT : mode === "signed" ? jose.verifyJWS : jose.decryptJWE, + } +} diff --git a/packages/core/src/session/strategies/stateless.ts b/packages/core/src/session/strategies/stateless.ts new file mode 100644 index 00000000..aa993848 --- /dev/null +++ b/packages/core/src/session/strategies/stateless.ts @@ -0,0 +1,103 @@ +import { getCookie } from "@/cookie.ts" +import { verifyCSRF } from "@/secure.ts" +import { getErrorName } from "@/utils.ts" +import { AuthSecurityError } from "@/errors.ts" +import { createJoseManager } from "@/session/manager/jose.ts" +import { createCookieManager } from "@/session/manager/cookie.ts" +import type { Session, SessionStrategy, User, TypedJWTPayload, JWTStrategyOptions } from "@/@types/index.ts" + +export const createStatelessStrategy = ({ config, jose, logger, cookies }: JWTStrategyOptions): SessionStrategy => { + const jwt = createJoseManager(config?.jwt, jose) + const cookieConfig = createCookieManager(cookies) + + const getSession = async (headers: Headers): Promise => { + try { + const { sessionToken } = cookieConfig.getCookie(headers) + if (!sessionToken) return null + + const decoded = await jwt.verifyToken(sessionToken) + const { exp, iat: _iat, jti: _jti, nbf: _nbf, aud: _aud, iss: _iss, ...user } = decoded + + if (!user.sub) return null + + return { + user, + expires: exp ? new Date(exp * 1000).toISOString() : "", + } + } catch { + return null + } + } + + const createSession = async (session: TypedJWTPayload) => jwt.createToken(session) + + /** @todo: implement refresh session logic */ + const refreshSession = async (_headers: Headers): Promise => { + // JWT strategy: refresh not implemented; return null per interface contract + return null + } + + // JWT strategy: stateless tokens cannot be revoked server-side + const revokeSession = async (_sessionId: string): Promise => {} + + const destroySession = async (headers: Headers, skipCSRFCheck: boolean = false) => { + let session = null + let csrfToken = null + const header = headers.get("X-CSRF-Token") + try { + session = getCookie(headers, cookies().sessionToken.name) + } catch { + throw new AuthSecurityError("SESSION_TOKEN_MISSING", "The sessionToken is missing.") + } + try { + csrfToken = getCookie(headers, cookies().csrfToken.name) + } catch { + throw new AuthSecurityError("CSRF_TOKEN_MISSING", "The CSRF token is missing.") + } + logger?.log("SIGN_OUT_ATTEMPT", { + structuredData: { + has_session: Boolean(session), + has_csrf_token: Boolean(csrfToken), + has_csrf_header: Boolean(header), + skip_csrf_check: skipCSRFCheck, + }, + }) + if (!session) { + logger?.log("SESSION_TOKEN_MISSING") + throw new AuthSecurityError("SESSION_TOKEN_MISSING", "The sessionToken is missing.") + } + if (!skipCSRFCheck) { + if (!csrfToken) { + logger?.log("CSRF_TOKEN_MISSING") + throw new AuthSecurityError("CSRF_TOKEN_MISSING", "The CSRF token is missing.") + } + if (!header) { + logger?.log("CSRF_HEADER_MISSING") + throw new AuthSecurityError("CSRF_HEADER_MISSING", "The CSRF header is missing.") + } + try { + await verifyCSRF(jose, csrfToken, header) + } catch (error) { + logger?.log("CSRF_TOKEN_INVALID", { structuredData: { error_type: getErrorName(error) } }) + throw new AuthSecurityError("CSRF_TOKEN_INVALID", "CSRF token verification failed") + } + logger?.log("SIGN_OUT_CSRF_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) + logger?.log("SIGN_OUT_SUCCESS") + } catch (error) { + logger?.log("INVALID_JWT_TOKEN", { structuredData: { error_type: getErrorName(error) } }) + } + return cookieConfig.clear() + } + + return { getSession, createSession, refreshSession, revokeSession, destroySession } +} diff --git a/packages/core/test/actions/session/session.test.ts b/packages/core/test/actions/session/session.test.ts index 0ab9caba..7cf183d9 100644 --- a/packages/core/test/actions/session/session.test.ts +++ b/packages/core/test/actions/session/session.test.ts @@ -70,11 +70,7 @@ describe("sessionAction", () => { }) test("expired sessionToken cookie", async () => { - const decodeJWTMock = vi.spyOn(jose, "decodeJWT").mockImplementation(async () => { - throw new Error("Token expired") - }) - - const sessionToken = await encodeJWT(sessionPayload) + const sessionToken = await encodeJWT({ exp: Math.floor(Date.now() / 1000) - 3600, ...sessionPayload }) // expired 1 hour ago const request = await GET( new Request("https://example.com/auth/session", { headers: { @@ -84,7 +80,6 @@ describe("sessionAction", () => { ) expect(request.status).toBe(401) expect(await request.json()).toEqual({ authenticated: false, session: null }) - decodeJWTMock.mockRestore() }) test("verify cache control headers are set", async () => { diff --git a/packages/core/test/jose.test.ts b/packages/core/test/jose.test.ts new file mode 100644 index 00000000..6c5d8e37 --- /dev/null +++ b/packages/core/test/jose.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { createJoseInstance } from "@/jose.ts" +import { createSecretValue } from "@/secure.ts" + +const payload = { + sub: "1234567890", + name: "Alice", + email: "alice@example.com", + image: "alice.jpg", +} + +beforeEach(() => { + vi.stubEnv("SALT", createSecretValue()) + vi.stubEnv("SECRET", createSecretValue()) +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe("createJoseInstance", () => { + test("createJoseInstance with default options", async () => { + const jose = createJoseInstance() + + const signed = await jose.signJWS(payload) + const verified = await jose.verifyJWS(signed) + expect(verified).toMatchObject(payload) + + const encoded = await jose.encodeJWT(payload) + const decoded = await jose.decodeJWT(encoded) + expect(decoded).toMatchObject(payload) + + const encrypted = await jose.encryptJWE(payload) + const decrypted = await jose.decryptJWE(encrypted) + expect(decrypted).toMatchObject(payload) + }) + + test("set issuer, audience and signing algorithm", async () => { + const secret = createSecretValue() + const jose = createJoseInstance(secret, { + jwt: { + mode: "sealed", + issuer: "test-issuer", + audience: "test-audience", + signingAlgorithm: "HS384", + }, + }) + + const signed = await jose.signJWS(payload) + const verified = await jose.verifyJWS(signed) + expect(verified).toMatchObject(payload) + + const encoded = await jose.encodeJWT(payload) + const decoded = await jose.decodeJWT(encoded) + expect(decoded).toMatchObject(payload) + }) + + test("overrides signing algorithm", async () => { + const secret = createSecretValue() + const jose = createJoseInstance(secret, { + jwt: { + mode: "signed", + issuer: "test-issuer", + audience: "test-audience", + signingAlgorithm: "HS384", + }, + }) + + const signed = await jose.signJWS(payload, { alg: "HS256" }) + const verified = await jose.verifyJWS(signed, { algorithms: ["HS256"] }) + expect(verified).toMatchObject(payload) + await expect(jose.verifyJWS(signed)).rejects.toThrow() + }) + + test("overrides issuer and audience", async () => { + const secret = createSecretValue() + const jose = createJoseInstance(secret, { + jwt: { + mode: "sealed", + issuer: "test-issuer", + audience: "test-audience", + signingAlgorithm: "HS384", + encryptionAlgorithm: "A256GCM", + keyAlgorithm: "dir", + }, + }) + + const payloadWithCustomClaims = { ...payload, iss: "custom-issuer", aud: "custom-audience" } + + const signed = await jose.signJWS(payloadWithCustomClaims) + await expect(jose.verifyJWS(signed)).rejects.toThrow() + const verified = await jose.verifyJWS(signed, { issuer: "custom-issuer", audience: "custom-audience" }) + expect(verified).toMatchObject(payloadWithCustomClaims) + + const encrypted = await jose.encryptJWE(payloadWithCustomClaims) + await expect(jose.decryptJWE(encrypted)).rejects.toThrow() + const decrypted = await jose.decryptJWE(encrypted, { issuer: "custom-issuer", audience: "custom-audience" }) + expect(decrypted).toMatchObject(payloadWithCustomClaims) + + const encoded = await jose.encodeJWT(payloadWithCustomClaims) + await expect(jose.decodeJWT(encoded)).rejects.toThrow() + const decoded = await jose.decodeJWT(encoded, { + verify: { issuer: "custom-issuer", audience: "custom-audience" }, + }) + expect(decoded).toMatchObject(payloadWithCustomClaims) + }) + + test("merge claims", async () => { + const secret = createSecretValue() + const jose = createJoseInstance(secret, { + jwt: { + audience: "test-audience", + }, + }) + const signed = await jose.signJWS(payload, { alg: "HS512" }) + const verified = await jose.verifyJWS(signed, { algorithms: ["HS512"], audience: "test-audience" }) + expect(verified).toMatchObject({ ...payload, aud: "test-audience" }) + + const encrypted = await jose.encryptJWE(payload, { alg: "dir", enc: "A256GCM" }) + const decrypted = await jose.decryptJWE(encrypted, { audience: "test-audience" }) + expect(decrypted).toMatchObject({ ...payload, aud: "test-audience" }) + + const encoded = await jose.encodeJWT(payload, { sign: { alg: "HS512" }, encrypt: { alg: "dir", enc: "A128CBC-HS256" } }) + const decoded = await jose.decodeJWT(encoded, { + verify: { algorithms: ["HS512"], audience: "test-audience" }, + decrypt: { keyManagementAlgorithms: ["dir"], contentEncryptionAlgorithms: ["A128CBC-HS256"] }, + }) + expect(decoded).toMatchObject({ ...payload, aud: "test-audience" }) + }) + + test("invalid token", async () => { + const jose = createJoseInstance() + await expect(jose.verifyJWS("invalid-token")).rejects.toThrow() + await expect(jose.decryptJWE("invalid-token")).rejects.toThrow() + await expect(jose.decodeJWT("invalid-token")).rejects.toThrow() + }) +}) diff --git a/packages/jose/package.json b/packages/jose/package.json index b1ca1b69..5e9595d1 100644 --- a/packages/jose/package.json +++ b/packages/jose/package.json @@ -7,6 +7,8 @@ "scripts": { "dev": "tsup --watch", "build": "tsup", + "lint": "oxlint", + "lint:fix": "oxlint --fix", "test": "vitest --run", "test:watch": "vitest", "test:coverage": "vitest --run --coverage", diff --git a/packages/jose/src/index.ts b/packages/jose/src/index.ts index 673b47ab..1efa29e0 100644 --- a/packages/jose/src/index.ts +++ b/packages/jose/src/index.ts @@ -42,8 +42,8 @@ export interface EncodeJWTOptions { * Decoded JWT payload options for verification and decryption. */ export interface DecodeJWTOptions { - verify: JWTVerifyOptions - decrypt: DecryptOptions + verify?: JWTVerifyOptions + decrypt?: DecryptOptions } export interface CreateJWTOptions {