diff --git a/packages/core/src/@types/entities.ts b/packages/core/src/@types/entities.ts index 38d373e0..446265c3 100644 --- a/packages/core/src/@types/entities.ts +++ b/packages/core/src/@types/entities.ts @@ -87,7 +87,7 @@ export interface SessionEntity { authenticatedWith: string status: SessionStatus mfaState: MFAState - sessionToken: string + tokenHash: string expiresAt: Date metadata: Record | null lastActivityAt: Date diff --git a/packages/core/src/@types/index.ts b/packages/core/src/@types/index.ts index 41f79416..f1649efe 100644 --- a/packages/core/src/@types/index.ts +++ b/packages/core/src/@types/index.ts @@ -13,6 +13,8 @@ export type * from "@/@types/session.ts" export type * from "@/@types/utility.ts" export type * from "@/@types/api.ts" export type * from "@/identity/index.ts" +export type * from "@/@types/entities.ts" +export type * from "@/@types/adapter.ts" export type { Awaitable } from "@aura-stack/router/types" export type { TypedJWTPayload } from "@aura-stack/jose" diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index 060b2655..11ac3c17 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -211,6 +211,8 @@ export interface GetStatelessSessionReturn { headers: Headers } +export type GetStatefulSessionReturn = GetStatelessSessionReturn + /** * Abstraction layer for session management. */ @@ -272,6 +274,14 @@ export interface JWTStrategyOptions { identity: SchemaRegistryContext } +export interface DatabaseStrategyOptions { + config: StatefulStrategyConfig + jose: JoseInstance + logger?: InternalLogger + cookies: () => InternalCookieStoreConfig + identity: SchemaRegistryContext +} + /** Minimal token issue/verify surface used by session code paths. */ export type JWTManager = { createToken(user: TypedJWTPayload>): Promise diff --git a/packages/core/src/jose.ts b/packages/core/src/jose.ts index d7078e36..606eb8b2 100644 --- a/packages/core/src/jose.ts +++ b/packages/core/src/jose.ts @@ -24,14 +24,15 @@ import { isPEMFormattedKeyPairFromEnv, isSealedMode, isSignedMode, + isStatelessStrategy, } from "@/shared/assert.ts" +import { AuraAuthError } from "@/shared/errors.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 { AuraAuthError } from "./shared/errors.ts" const getJWTConfig = (config?: SessionConfig) => { - return config?.jwt + return isStatelessStrategy(config) ? config?.jwt : {} } export const getJWTClaims = (config?: SessionConfig) => { diff --git a/packages/core/src/router/context.ts b/packages/core/src/router/context.ts index 3e03081c..3dbca1af 100644 --- a/packages/core/src/router/context.ts +++ b/packages/core/src/router/context.ts @@ -10,6 +10,7 @@ import { getEnv, getEnvArray, getEnvBoolean } from "@/shared/env.ts" import { createRateLimiterInstance } from "@/router/rate-limiter.ts" import type { Identities, SchemaTypes } from "@/identity/index.ts" import type { AuthConfig, InternalContext, FromShapeToObject } from "@/@types/index.ts" +import { isStatelessStrategy } from "@/shared/assert.ts" export const createContext = ( config?: AuthConfig @@ -60,7 +61,7 @@ export const createContext = ctx.sessionStrategy = createSessionStrategy({ diff --git a/packages/core/src/session/stateful.ts b/packages/core/src/session/stateful.ts new file mode 100644 index 00000000..47c7cd08 --- /dev/null +++ b/packages/core/src/session/stateful.ts @@ -0,0 +1,548 @@ +import { AuraAuthError } from "@/shared/errors.ts" +import { secureApiHeaders } from "@/shared/headers.ts" +import { verifyCSRFToken, getErrorName } from "@/shared/utils.ts" +import { createCookieManager } from "@/session/cookie-manager.ts" +import { createHash, createSecretValue } from "@/shared/crypto.ts" +import type { JoseInstance } from "@/@types/index.ts" +import type { DeepPartial } from "@/@types/utility.ts" +import type { TypedJWTPayload } from "@aura-stack/jose" +import type { DatabaseStrategyOptions, GetStatefulSessionReturn, Session, SessionStrategy, User } from "@/@types/session.ts" + +export const createStatefulStrategy = ({ + config, + cookies, + identity, + logger, + jose, +}: DatabaseStrategyOptions): SessionStrategy => { + const cookieConfig = createCookieManager(cookies) + + const getSession = async (headers: Headers): Promise> => { + logger?.log("STATEFUL_GET_SESSION_START", { + structuredData: { + strategy: "stateful", + operation: "getSession", + }, + }) + + try { + const { sessionToken } = cookieConfig.getCookie(headers) + + logger?.log("STATEFUL_SESSION_TOKEN_EXTRACTED", { + structuredData: { + has_token: Boolean(sessionToken), + token_length: sessionToken?.length || 0, + }, + }) + + if (!sessionToken) { + logger?.log("STATEFUL_SESSION_TOKEN_MISSING", { + structuredData: { + reason: "no_session_token_in_cookie", + }, + }) + return { + session: null, + headers: new Headers(secureApiHeaders), + } + } + + const session = await config.adapter.getSessionByToken(sessionToken) + logger?.log("STATEFUL_SESSION_DB_LOOKUP", { + structuredData: { + session_found: Boolean(session), + session_id: session?.id || "", + user_id: session?.userId || "", + }, + }) + + if (!session) { + logger?.log("STATEFUL_SESSION_NOT_FOUND", { + structuredData: { + reason: "session_not_found_in_database", + }, + }) + throw new AuraAuthError({ code: "DATABASE_TOKEN_HASH_NOT_FOUND" }) + } + + if (!session.user) { + logger?.log("STATEFUL_SESSION_NO_USER", { + structuredData: { + reason: "session_has_no_associated_user", + session_id: session.id, + }, + }) + throw new AuraAuthError({ code: "DATABASE_TOKEN_HASH_NOT_FOUND" }) + } + + logger?.log("STATEFUL_SESSION_STATUS_CHECK", { + structuredData: { + session_id: session.id, + status: session.status, + expires_at: session.expiresAt.toISOString(), + is_expired: new Date() > session.expiresAt, + }, + }) + + if (session.status !== "active") { + logger?.log("STATEFUL_SESSION_INACTIVE", { + structuredData: { + session_id: session.id, + status: session.status, + }, + }) + return { + session: null, + headers: new Headers(secureApiHeaders), + } + } + + if (new Date() > session.expiresAt) { + logger?.log("STATEFUL_SESSION_EXPIRED", { + structuredData: { + session_id: session.id, + expires_at: session.expiresAt.toISOString(), + }, + }) + await config.adapter.revokeSession(session.id, "user_logout") + return { + session: null, + headers: new Headers(secureApiHeaders), + } + } + + const user = { ...session.user, ...session.user.attributes, sub: session.user.id } + logger?.log("STATEFUL_USER_DATA_MERGED", { + structuredData: { + user_id: user.id, + has_attributes: Boolean(session.user.attributes) || false, + }, + }) + + const parsedUser = identity.skipValidation ? user : await identity.schemaRegistry.parse(user) + logger?.log("STATEFUL_USER_VALIDATION", { + structuredData: { + validation_skipped: identity.skipValidation || false, + user_id: user.id, + }, + }) + + logger?.log("STATEFUL_GET_SESSION_SUCCESS", { + structuredData: { + session_id: session.id, + user_id: user.id, + expires_at: session.expiresAt.toISOString(), + }, + }) + + return { + session: { + user: parsedUser as DefaultUser, + expires: session.expiresAt.toISOString(), + }, + headers: new Headers(secureApiHeaders), + } + } catch (error) { + logger?.log("STATEFUL_GET_SESSION_ERROR", { + structuredData: { + error_type: getErrorName(error), + error_message: error instanceof Error ? error.message : String(error), + }, + }) + return { + session: null, + headers: new Headers(secureApiHeaders), + } + } + } + + const createSession = async (session: TypedJWTPayload) => { + logger?.log("STATEFUL_CREATE_SESSION_START", { + structuredData: { + strategy: "stateful", + operation: "createSession", + user_id: session.sub, + }, + }) + + if (identity.skipValidation) { + logger?.log("IDENTITY_VALIDATION_DISABLED", { + structuredData: { + identity_validation_disabled: true, + }, + }) + } + + const payload = identity.skipValidation ? session : await identity.schemaRegistry.parse(session) + logger?.log("STATEFUL_PAYLOAD_VALIDATION", { + structuredData: { + validation_skipped: identity.skipValidation || false, + user_id: payload.sub || "", + has_email: Boolean(payload.email) || false, + }, + }) + + if (!payload.sub) { + logger?.log("STATEFUL_CREATE_SESSION_ERROR", { + structuredData: { + error: "missing_user_id", + reason: "payload.sub is required", + }, + }) + throw new AuraAuthError({ code: "INVALID_USER_INFO" }) + } + + const secretValue = createSecretValue(64) + logger?.log("STATEFUL_TOKEN_GENERATED", { + structuredData: { + token_length: secretValue.length, + }, + }) + + const tokenHash = await createHash(secretValue) + logger?.log("STATEFUL_TOKEN_HASHED", { + structuredData: { + hash_length: tokenHash.length, + }, + }) + + const expiresAt = new Date(Date.now() + 60 * 60 * 24 * 15 * 1000) + logger?.log("STATEFUL_SESSION_EXPIRATION_SET", { + structuredData: { + expires_at: expiresAt?.toISOString(), + max_age_days: 15, + }, + }) + + const dbSession = await config.adapter.createSession({ + id: crypto.randomUUID(), + userId: payload.sub as string, + deviceId: null, + authenticatedWith: "credentials", + status: "active", + mfaState: "none", + tokenHash, + expiresAt, + metadata: null, + }) + + logger?.log("STATEFUL_SESSION_CREATED", { + structuredData: { + session_id: dbSession.id, + user_id: dbSession.userId, + status: dbSession.status, + expires_at: dbSession?.expiresAt?.toISOString(), + }, + }) + + logger?.log("STATEFUL_CREATE_SESSION_SUCCESS", { + structuredData: { + session_id: dbSession.id, + user_id: dbSession.userId, + token_returned: true, + }, + }) + + return secretValue + } + + const refreshSession = async ( + headers: Headers, + _session: DeepPartial>, + skipCSRFCheck: boolean = false + ): Promise<{ + session: Session | null + headers: Headers + }> => { + logger?.log("STATEFUL_REFRESH_SESSION_START", { + structuredData: { + strategy: "stateful", + operation: "refreshSession", + skip_csrf_check: skipCSRFCheck, + }, + }) + + try { + const { sessionToken } = cookieConfig.getCookie(headers) + logger?.log("STATEFUL_SESSION_TOKEN_EXTRACTED", { + structuredData: { + has_token: Boolean(sessionToken), + token_length: sessionToken?.length || 0, + }, + }) + + if (!sessionToken) { + logger?.log("STATEFUL_REFRESH_TOKEN_MISSING", { + structuredData: { + reason: "no_session_token_in_cookie", + }, + }) + return { session: null, headers: cookieConfig.clear() } + } + + logger?.log("STATEFUL_CSRF_VERIFICATION_START", { + structuredData: { + skip_csrf_check: skipCSRFCheck, + }, + }) + + const isValidToken = await verifyCSRFToken({ + headers, + skipCSRFCheck, + cookies: cookies(), + logger, + jose: jose as JoseInstance, + }) + + logger?.log("STATEFUL_CSRF_VERIFICATION_RESULT", { + structuredData: { + is_valid: isValidToken, + }, + }) + + if (!isValidToken) { + logger?.log("STATEFUL_CSRF_VERIFICATION_FAILED", { + structuredData: { + reason: "csrf_token_invalid", + }, + }) + return { session: null, headers: cookieConfig.clear() } + } + + const sessionByToken = await config.adapter.getSessionByToken(sessionToken) + logger?.log("STATEFUL_SESSION_DB_LOOKUP", { + structuredData: { + session_found: Boolean(sessionByToken), + session_id: sessionByToken?.id || "", + user_id: sessionByToken?.userId || "", + }, + }) + + if (!sessionByToken || !sessionByToken.user) { + logger?.log("STATEFUL_REFRESH_SESSION_NOT_FOUND", { + structuredData: { + reason: "session_not_found_or_no_user", + }, + }) + return { session: null, headers: cookieConfig.clear() } + } + + if (sessionByToken.status !== "active") { + return { session: null, headers: cookieConfig.clear() } + } + + logger?.log("STATEFUL_SESSION_EXPIRATION_CHECK", { + structuredData: { + session_id: sessionByToken.id, + expires_at: sessionByToken.expiresAt.toISOString(), + is_expired: new Date() > sessionByToken.expiresAt, + }, + }) + + if (new Date() > sessionByToken.expiresAt) { + logger?.log("STATEFUL_SESSION_EXPIRED", { + structuredData: { + session_id: sessionByToken.id, + expires_at: sessionByToken.expiresAt.toISOString(), + }, + }) + await config.adapter.revokeSession(sessionByToken.id, "user_logout") + logger?.log("STATEFUL_EXPIRED_SESSION_REVOKED", { + structuredData: { + session_id: sessionByToken.id, + reason: "session_expired", + }, + }) + return { session: null, headers: cookieConfig.clear() } + } + + const user = { sub: sessionByToken.user.id, ...sessionByToken.user, ...sessionByToken.user.attributes } + logger?.log("STATEFUL_USER_DATA_MERGED", { + structuredData: { + user_id: user.id, + has_attributes: Boolean(sessionByToken.user.attributes), + }, + }) + + const parsedUser = identity.skipValidation ? user : await identity.schemaRegistry.parse(user) + logger?.log("STATEFUL_USER_VALIDATION", { + structuredData: { + validation_skipped: identity.skipValidation || false, + user_id: user.id, + }, + }) + + const newExpiresAt = new Date(Date.now() + 60 * 60 * 24 * 15 * 1000) + logger?.log("STATEFUL_SESSION_EXPIRATION_UPDATE", { + structuredData: { + session_id: sessionByToken.id, + old_expires_at: sessionByToken.expiresAt.toISOString(), + new_expires_at: newExpiresAt.toISOString(), + }, + }) + + await config.adapter.updateSession(sessionByToken.id, { + id: sessionByToken.id, + userId: sessionByToken.userId, + deviceId: sessionByToken.deviceId, + authenticatedWith: sessionByToken.authenticatedWith, + status: sessionByToken.status, + mfaState: sessionByToken.mfaState, + tokenHash: sessionByToken.tokenHash, + expiresAt: newExpiresAt, + metadata: sessionByToken.metadata, + }) + + logger?.log("STATEFUL_SESSION_UPDATED", { + structuredData: { + session_id: sessionByToken.id, + new_expires_at: newExpiresAt.toISOString(), + }, + }) + + await config.adapter.touchSession(sessionByToken.id, new Date()) + logger?.log("STATEFUL_SESSION_TOUCHED", { + structuredData: { + session_id: sessionByToken.id, + last_activity: new Date().toISOString(), + }, + }) + + const updatedSession: Session = { + user: parsedUser as DefaultUser, + expires: newExpiresAt.toISOString(), + } + + logger?.log("STATEFUL_REFRESH_SESSION_SUCCESS", { + structuredData: { + session_id: sessionByToken.id, + user_id: sessionByToken.userId, + expires_at: newExpiresAt.toISOString(), + }, + }) + + return { session: updatedSession, headers: new Headers(secureApiHeaders) } + } catch (error) { + logger?.log("STATEFUL_REFRESH_SESSION_ERROR", { + structuredData: { + error_type: getErrorName(error), + error_message: error instanceof Error ? error.message : String(error), + }, + }) + return { session: null, headers: cookieConfig.clear() } + } + } + + const revokeSession = async (sessionId: string): Promise => { + logger?.log("STATEFUL_REVOKE_SESSION_START", { + structuredData: { + strategy: "stateful", + operation: "revokeSession", + session_id: sessionId, + }, + }) + + if (!sessionId) { + logger?.log("STATEFUL_REVOKE_SESSION_ERROR", { + structuredData: { + error: "missing_session_id", + reason: "session_id is required", + }, + }) + throw new AuraAuthError({ code: "INVALID_USER_INFO" }) + } + + await config.adapter.revokeSession(sessionId, "user_logout") + + logger?.log("STATEFUL_REVOKE_SESSION_SUCCESS", { + structuredData: { + session_id: sessionId, + reason: "user_logout", + }, + }) + } + + const destroySession = async (headers: Headers, skipCSRFCheck: boolean = false) => { + logger?.log("STATEFUL_DESTROY_SESSION_START", { + structuredData: { + strategy: "stateful", + operation: "destroySession", + }, + }) + + await verifyCSRFToken({ + headers, + cookies: cookies(), + logger, + jose: jose as JoseInstance, + skipCSRFCheck, + }) + + try { + const { sessionToken } = cookieConfig.getCookie(headers) + logger?.log("STATEFUL_SESSION_TOKEN_EXTRACTED", { + structuredData: { + has_token: Boolean(sessionToken), + token_length: sessionToken?.length || 0, + }, + }) + + if (sessionToken) { + const sessionByToken = await config.adapter.getSessionByToken(sessionToken) + logger?.log("STATEFUL_SESSION_DB_LOOKUP", { + structuredData: { + session_found: Boolean(sessionByToken), + session_id: sessionByToken?.id || "", + }, + }) + + if (sessionByToken) { + await config.adapter.revokeSession(sessionByToken.id, "user_logout") + logger?.log("STATEFUL_SESSION_REVOKED", { + structuredData: { + session_id: sessionByToken.id, + reason: "user_logout", + }, + }) + } else { + logger?.log("STATEFUL_SESSION_NOT_FOUND_FOR_DESTRUCTION", { + structuredData: { + reason: "session_not_found_in_database", + }, + }) + } + } else { + logger?.log("STATEFUL_NO_TOKEN_FOR_DESTRUCTION", { + structuredData: { + reason: "no_session_token_in_cookie", + }, + }) + } + } catch (error) { + logger?.log("STATEFUL_DESTROY_SESSION_ERROR", { + structuredData: { + error_type: getErrorName(error), + error_message: error instanceof Error ? error.message : String(error), + }, + }) + throw error + } + + const clearedHeaders = cookieConfig.clear() + logger?.log("STATEFUL_DESTROY_SESSION_SUCCESS", { + structuredData: { + cookies_cleared: true, + }, + }) + + return clearedHeaders + } + + return { + getSession, + createSession, + refreshSession, + revokeSession, + destroySession, + } +} diff --git a/packages/core/src/session/strategy.ts b/packages/core/src/session/strategy.ts index aa629753..e517e8e6 100644 --- a/packages/core/src/session/strategy.ts +++ b/packages/core/src/session/strategy.ts @@ -1,4 +1,5 @@ import { AuraAuthError } from "@/shared/errors.ts" +import { createStatefulStrategy } from "@/session/stateful.ts" import { createStatelessStrategy } from "@/session/stateless.ts" import type { Identities } from "@/identity/index.ts" import type { FromShapeToObject } from "@/@types/utility.ts" @@ -17,7 +18,15 @@ export const createSessionStrategy = ({ case "jwt": return createStatelessStrategy({ jose, - config, + config: config as any, + cookies, + logger, + identity, + }) + case "database": + return createStatefulStrategy({ + config: config as any, + jose, cookies, logger, identity, diff --git a/packages/core/src/shared/assert.ts b/packages/core/src/shared/assert.ts index 27cd2b14..25c8cea3 100644 --- a/packages/core/src/shared/assert.ts +++ b/packages/core/src/shared/assert.ts @@ -15,6 +15,8 @@ import type { JWTPayloadWithToken, OAuthProviderConfig, SessionConfig, + StatefulStrategyConfig, + StatelessStrategyConfig, } from "@/@types/index.ts" import type { JWK } from "@aura-stack/jose/jose" @@ -110,12 +112,20 @@ export const isTrustedOrigin = (url: string, trustedOrigins: string[]): boolean return false } +export const isStatelessStrategy = (config?: SessionConfig): config is StatelessStrategyConfig => { + return config?.strategy === "jwt" || config?.strategy === undefined +} + +export const isStatefulStrategy = (config?: SessionConfig): config is StatefulStrategyConfig => { + return config?.strategy === "database" +} + /** * 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" + return isStatelessStrategy(config) ? (config?.jwt?.mode ?? "sealed") : "sealed" } export const isSignedMode = (config?: SessionConfig): config is { jwt: Extract } => diff --git a/packages/core/src/shared/errors.ts b/packages/core/src/shared/errors.ts index d25c7bfb..0ce3b285 100644 --- a/packages/core/src/shared/errors.ts +++ b/packages/core/src/shared/errors.ts @@ -125,6 +125,10 @@ export const AuraErrorCode = { OAUTH_INVALID_REVOKE_TOKEN_CONFIG: "OAUTH_INVALID_REVOKE_TOKEN_CONFIG", OAUTH_INVALID_REVOKE_TOKEN_RESPONSE: "OAUTH_INVALID_REVOKE_TOKEN_RESPONSE", OAUTH_INVALID_REVOKE_TOKEN_PROCESS: "OAUTH_INVALID_REVOKE_TOKEN_PROCESS", + /** + * Database Errors + */ + DATABASE_TOKEN_HASH_NOT_FOUND: "DATABASE_TOKEN_HASH_NOT_FOUND", } as const export type AuraErrorCode = (typeof AuraErrorCode)[keyof typeof AuraErrorCode] @@ -887,6 +891,14 @@ export const ERROR_CATALOG: Record = { "The token revocation operation failed because the upstream server returned a non-200 status code. Per the RFC 7009 specification, successful token invalidation requests must respond with an HTTP status 200.", userMessage: "The identity provider encountered an error while processing the token revocation.", }, + DATABASE_TOKEN_HASH_NOT_FOUND: { + type: "AUTH_FLOW", + statusCode: 401, + name: "SessionError", + message: + "The session verification query completed successfully, but the database returned a nullish record matching the provided session token hash identifier reference.", + userMessage: "The session was not found in the database. Please sign in again.", + }, } export interface AuraErrorOptions extends ErrorOptions { diff --git a/packages/core/src/shared/logger.ts b/packages/core/src/shared/logger.ts index 2ff8f345..80516783 100644 --- a/packages/core/src/shared/logger.ts +++ b/packages/core/src/shared/logger.ts @@ -339,6 +339,264 @@ export const logMessages = { msgId: "OIDC_PROVIDER_RESOLVED", message: "OIDC provider configuration resolved successfully", }, + STATEFUL_GET_SESSION_START: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_GET_SESSION_START", + message: "Starting stateful session retrieval process", + }, + STATEFUL_SESSION_TOKEN_EXTRACTED: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_SESSION_TOKEN_EXTRACTED", + message: "Session token extracted from request cookies", + }, + STATEFUL_SESSION_TOKEN_MISSING: { + facility: 4, + severity: "warning", + msgId: "STATEFUL_SESSION_TOKEN_MISSING", + message: "Session token is missing from request cookies", + }, + STATEFUL_SESSION_DB_LOOKUP: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_SESSION_DB_LOOKUP", + message: "Looking up session in database by token hash", + }, + STATEFUL_SESSION_NOT_FOUND: { + facility: 4, + severity: "warning", + msgId: "STATEFUL_SESSION_NOT_FOUND", + message: "Session not found in database", + }, + STATEFUL_SESSION_NO_USER: { + facility: 4, + severity: "warning", + msgId: "STATEFUL_SESSION_NO_USER", + message: "Session exists but has no associated user", + }, + STATEFUL_SESSION_STATUS_CHECK: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_SESSION_STATUS_CHECK", + message: "Checking session status and expiration", + }, + STATEFUL_SESSION_INACTIVE: { + facility: 4, + severity: "warning", + msgId: "STATEFUL_SESSION_INACTIVE", + message: "Session is not active", + }, + STATEFUL_SESSION_EXPIRED: { + facility: 4, + severity: "warning", + msgId: "STATEFUL_SESSION_EXPIRED", + message: "Session has expired", + }, + STATEFUL_USER_DATA_MERGED: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_USER_DATA_MERGED", + message: "Merged user data with user attributes", + }, + STATEFUL_USER_VALIDATION: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_USER_VALIDATION", + message: "Validating user data against identity schema", + }, + STATEFUL_GET_SESSION_SUCCESS: { + facility: 4, + severity: "info", + msgId: "STATEFUL_GET_SESSION_SUCCESS", + message: "Stateful session retrieved successfully", + }, + STATEFUL_GET_SESSION_ERROR: { + facility: 4, + severity: "error", + msgId: "STATEFUL_GET_SESSION_ERROR", + message: "Error occurred during stateful session retrieval", + }, + STATEFUL_CREATE_SESSION_START: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_CREATE_SESSION_START", + message: "Starting stateful session creation process", + }, + STATEFUL_PAYLOAD_VALIDATION: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_PAYLOAD_VALIDATION", + message: "Validating session payload against identity schema", + }, + STATEFUL_CREATE_SESSION_ERROR: { + facility: 4, + severity: "error", + msgId: "STATEFUL_CREATE_SESSION_ERROR", + message: "Error occurred during stateful session creation", + }, + STATEFUL_TOKEN_GENERATED: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_TOKEN_GENERATED", + message: "Generated new session token", + }, + STATEFUL_TOKEN_HASHED: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_TOKEN_HASHED", + message: "Hashed session token for database storage", + }, + STATEFUL_SESSION_EXPIRATION_SET: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_SESSION_EXPIRATION_SET", + message: "Set session expiration time", + }, + STATEFUL_SESSION_CREATED: { + facility: 4, + severity: "info", + msgId: "STATEFUL_SESSION_CREATED", + message: "Session created in database", + }, + STATEFUL_CREATE_SESSION_SUCCESS: { + facility: 4, + severity: "info", + msgId: "STATEFUL_CREATE_SESSION_SUCCESS", + message: "Stateful session created successfully", + }, + STATEFUL_REFRESH_SESSION_START: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_REFRESH_SESSION_START", + message: "Starting stateful session refresh process", + }, + STATEFUL_REFRESH_TOKEN_MISSING: { + facility: 4, + severity: "warning", + msgId: "STATEFUL_REFRESH_TOKEN_MISSING", + message: "Session token missing during refresh", + }, + STATEFUL_CSRF_VERIFICATION_START: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_CSRF_VERIFICATION_START", + message: "Starting CSRF token verification", + }, + STATEFUL_CSRF_VERIFICATION_RESULT: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_CSRF_VERIFICATION_RESULT", + message: "CSRF token verification completed", + }, + STATEFUL_CSRF_VERIFICATION_FAILED: { + facility: 4, + severity: "error", + msgId: "STATEFUL_CSRF_VERIFICATION_FAILED", + message: "CSRF token verification failed", + }, + STATEFUL_REFRESH_SESSION_NOT_FOUND: { + facility: 4, + severity: "warning", + msgId: "STATEFUL_REFRESH_SESSION_NOT_FOUND", + message: "Session not found during refresh", + }, + STATEFUL_SESSION_EXPIRATION_CHECK: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_SESSION_EXPIRATION_CHECK", + message: "Checking session expiration during refresh", + }, + STATEFUL_EXPIRED_SESSION_REVOKED: { + facility: 4, + severity: "info", + msgId: "STATEFUL_EXPIRED_SESSION_REVOKED", + message: "Expired session was revoked", + }, + STATEFUL_SESSION_EXPIRATION_UPDATE: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_SESSION_EXPIRATION_UPDATE", + message: "Updating session expiration time", + }, + STATEFUL_SESSION_UPDATED: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_SESSION_UPDATED", + message: "Session updated in database", + }, + STATEFUL_SESSION_TOUCHED: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_SESSION_TOUCHED", + message: "Session last activity timestamp updated", + }, + STATEFUL_REFRESH_SESSION_SUCCESS: { + facility: 4, + severity: "info", + msgId: "STATEFUL_REFRESH_SESSION_SUCCESS", + message: "Stateful session refreshed successfully", + }, + STATEFUL_REFRESH_SESSION_ERROR: { + facility: 4, + severity: "error", + msgId: "STATEFUL_REFRESH_SESSION_ERROR", + message: "Error occurred during stateful session refresh", + }, + STATEFUL_REVOKE_SESSION_START: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_REVOKE_SESSION_START", + message: "Starting stateful session revocation", + }, + STATEFUL_REVOKE_SESSION_ERROR: { + facility: 4, + severity: "error", + msgId: "STATEFUL_REVOKE_SESSION_ERROR", + message: "Error occurred during stateful session revocation", + }, + STATEFUL_REVOKE_SESSION_SUCCESS: { + facility: 4, + severity: "info", + msgId: "STATEFUL_REVOKE_SESSION_SUCCESS", + message: "Stateful session revoked successfully", + }, + STATEFUL_DESTROY_SESSION_START: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_DESTROY_SESSION_START", + message: "Starting stateful session destruction", + }, + STATEFUL_SESSION_REVOKED: { + facility: 4, + severity: "info", + msgId: "STATEFUL_SESSION_REVOKED", + message: "Session revoked during destruction", + }, + STATEFUL_SESSION_NOT_FOUND_FOR_DESTRUCTION: { + facility: 4, + severity: "warning", + msgId: "STATEFUL_SESSION_NOT_FOUND_FOR_DESTRUCTION", + message: "Session not found during destruction", + }, + STATEFUL_NO_TOKEN_FOR_DESTRUCTION: { + facility: 4, + severity: "warning", + msgId: "STATEFUL_NO_TOKEN_FOR_DESTRUCTION", + message: "No session token found during destruction", + }, + STATEFUL_DESTROY_SESSION_ERROR: { + facility: 4, + severity: "error", + msgId: "STATEFUL_DESTROY_SESSION_ERROR", + message: "Error occurred during stateful session destruction", + }, + STATEFUL_DESTROY_SESSION_SUCCESS: { + facility: 4, + severity: "info", + msgId: "STATEFUL_DESTROY_SESSION_SUCCESS", + message: "Stateful session destroyed successfully", + }, } as const export const createLogEntry = (key: T, overrides?: Partial): SyslogOptions => { diff --git a/packages/core/test/session/stateful.test.ts b/packages/core/test/session/stateful.test.ts new file mode 100644 index 00000000..5731834b --- /dev/null +++ b/packages/core/test/session/stateful.test.ts @@ -0,0 +1,574 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { jose } from "@test/presets.ts" +import { createAuth } from "@/createAuth.ts" +import { createCSRF } from "@/shared/crypto.ts" +import { createSchemaRegistry } from "@/validator/registry.ts" +import type { DatabaseAdapter, IdentityConfig, SessionEntity, UserEntity } from "@/@types/index.ts" + +describe("Stateful Strategy", () => { + const mockUser: UserEntity = { + id: "user-123", + name: "John Doe", + email: "john@example.com", + image: "https://example.com/image.jpg", + emailVerifiedAt: new Date(), + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + createdAt: new Date(), + updatedAt: new Date(), + attributes: null, + } + + const mockSession: SessionEntity & { user: UserEntity } = { + id: "session-123", + userId: "user-123", + deviceId: null, + authenticatedWith: "credentials", + status: "active", + mfaState: "none", + tokenHash: "hashed-token", + expiresAt: new Date(Date.now() + 3600 * 1000), + metadata: null, + lastActivityAt: new Date(), + revokedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + user: mockUser, + } + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-03-24T00:00:00Z")) + }) + + afterEach(() => { + vi.useRealTimers() + vi.resetAllMocks() + vi.clearAllMocks() + }) + + const authInstance = ( + functions: Partial>, + identityConfig: Partial> = {} + ) => { + const { handlers } = createAuth({ + oauth: [], + session: { + strategy: "database", + adapter: functions as any, + }, + credentials: { + authorize: async ({ credentials }) => ({ + sub: "user-123", + name: credentials.username, + email: credentials.password, + image: "https://example.com/image.jpg", + }), + }, + logger: true, + identity: identityConfig, + }) + return handlers + } + + describe("getSession", () => { + test("should return session when valid token exists", async () => { + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spy = vi.spyOn(registry, "parse") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const mock = vi.fn().mockResolvedValue(mockSession) + const { GET } = authInstance({ + getSessionByToken: mock, + }) + const response = await GET( + new Request("https://example.com/auth/session", { + headers: { + Cookie: "__Secure-aura-auth.session_token=valid-token", + }, + }) + ) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + session: { + user: { + sub: "user-123", + name: "John Doe", + email: "john@example.com", + image: "https://example.com/image.jpg", + }, + expires: mockSession.expiresAt.toISOString(), + }, + success: true, + }) + expect(mock).toHaveBeenCalledWith("valid-token") + expect(spy).toHaveBeenCalledWith({ sub: mockUser.id, ...mockUser, ...mockUser.attributes }) + }) + + test("should return null session when token not found", async () => { + const mock = vi.fn().mockResolvedValue(null) + const { GET } = authInstance({ + getSessionByToken: mock, + }) + const response = await GET( + new Request("https://example.com/auth/session", { + headers: { + Cookie: "__Secure-aura-auth.session_token=invalid-token", + }, + }) + ) + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + session: null, + success: false, + }) + expect(mock).toHaveBeenCalledWith("invalid-token") + }) + + test("should return null session when session has no user", async () => { + const mock = vi.fn().mockResolvedValue({ + ...mockSession, + user: null as any, + }) + const { GET } = authInstance({ + getSessionByToken: mock, + }) + const response = await GET( + new Request("https://example.com/auth/session", { + headers: { + Cookie: "__Secure-aura-auth.session_token=token-without-user", + }, + }) + ) + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + session: null, + success: false, + }) + expect(mock).toHaveBeenCalledWith("token-without-user") + }) + + test("should return null session on adapter error", async () => { + const mock = vi.fn().mockRejectedValue(new Error("DB Error")) + const { GET } = authInstance({ + getSessionByToken: mock, + }) + const response = await GET( + new Request("https://example.com/auth/session", { + headers: { + Cookie: "__Secure-aura-auth.session_token=error-token", + }, + }) + ) + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + session: null, + success: false, + }) + expect(mock).toHaveBeenCalledWith("error-token") + }) + + test("should skip validation when identity.skipValidation is true", async () => { + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spy = vi.spyOn(registry, "parse") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const mock = vi.fn().mockResolvedValue(mockSession) + const { GET } = authInstance( + { + getSessionByToken: mock, + }, + { skipValidation: true } + ) + const response = await GET( + new Request("https://example.com/auth/session", { + headers: { + Cookie: "__Secure-aura-auth.session_token=valid-token", + }, + }) + ) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + session: { + user: { + ...mockUser, + sub: "user-123", + createdAt: mockUser.createdAt.toISOString(), + updatedAt: mockUser.updatedAt.toISOString(), + emailVerifiedAt: mockUser.emailVerifiedAt?.toISOString(), + }, + expires: mockSession.expiresAt.toISOString(), + }, + success: true, + }) + expect(mock).toHaveBeenCalledWith("valid-token") + expect(spy).not.toHaveBeenCalled() + }) + }) + + describe("createSession", () => { + test("should create a hashed session token", async () => { + const csrfToken = await createCSRF(jose) + + const mock = vi.fn().mockResolvedValue("hashed-token") + + const { POST } = authInstance({ + createSession: mock, + }) + const response = await POST( + new Request("https://example.com/auth/signIn/credentials", { + method: "POST", + body: JSON.stringify({ username: "John Doe", password: "john@example.com" }), + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}`, + }, + }) + ) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: null, + }) + expect(mock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: "user-123", + deviceId: null, + authenticatedWith: "credentials", + status: "active", + mfaState: "none", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + metadata: null, + }) + }) + + test("should log when identity validation is disabled", async () => { + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spy = vi.spyOn(registry, "parse") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const csrfToken = await createCSRF(jose) + const mock = vi.fn().mockResolvedValue("hashed-token") + + const { POST } = authInstance( + { + createSession: mock, + }, + { skipValidation: true } + ) + + const response = await POST( + new Request("https://example.com/auth/signIn/credentials", { + method: "POST", + body: JSON.stringify({ username: "John Doe", password: "john@example.com" }), + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}`, + }, + }) + ) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: null, + }) + expect(mock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: "user-123", + deviceId: null, + authenticatedWith: "credentials", + status: "active", + mfaState: "none", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + metadata: null, + }) + expect(spy).not.toHaveBeenCalled() + }) + }) + + describe("refreshSession", () => { + test("should refresh session with valid token using createAuth", async () => { + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spy = vi.spyOn(registry, "parse") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionMock = vi.fn().mockResolvedValue(mockSession) + const updateSessionMock = vi.fn().mockResolvedValue(mockSession) + const touchSessionMock = vi.fn().mockResolvedValue(undefined) + + const { PATCH } = authInstance({ + getSessionByToken: getSessionMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }) + + const csrfToken = await createCSRF(jose) + const response = await PATCH( + new Request("https://example.com/auth/session", { + method: "PATCH", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Secure-aura-auth.session_token=valid-token; __Host-aura-auth.csrf_token=${csrfToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + user: { name: "Updated Name" }, + }), + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + session: { + user: { + sub: "user-123", + name: "John Doe", + email: "john@example.com", + image: "https://example.com/image.jpg", + }, + expires: expect.any(String), + }, + success: true, + redirect: false, + redirectURL: null, + }) + expect(getSessionMock).toHaveBeenCalledWith("valid-token") + expect(updateSessionMock).toHaveBeenCalled() + expect(touchSessionMock).toHaveBeenCalled() + expect(spy).toHaveBeenCalledWith({ sub: mockUser.id, ...mockUser, ...mockUser.attributes }) + }) + + test("should return null session when token not found using createAuth", async () => { + const getSessionMock = vi.fn().mockResolvedValue(null) + const { PATCH } = authInstance({ + getSessionByToken: getSessionMock, + }) + + const csrfToken = await createCSRF(jose) + const response = await PATCH( + new Request("https://example.com/auth/session", { + method: "PATCH", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Secure-aura-auth.session_token=invalid-token; __Host-aura-auth.csrf_token=${csrfToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + user: { name: "Updated Name" }, + }), + }) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + session: null, + success: false, + redirect: false, + redirectURL: null, + }) + expect(getSessionMock).toHaveBeenCalledWith("invalid-token") + }) + + test("should revoke expired sessions using createAuth", async () => { + const expiredSession = { + ...mockSession, + expiresAt: new Date(Date.now() - 1000), + } + + const getSessionMock = vi.fn().mockResolvedValue(expiredSession) + const revokeSessionMock = vi.fn().mockResolvedValue(undefined) + + const { PATCH } = authInstance({ + getSessionByToken: getSessionMock, + revokeSession: revokeSessionMock, + }) + + const csrfToken = await createCSRF(jose) + const response = await PATCH( + new Request("https://example.com/auth/session", { + method: "PATCH", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Secure-aura-auth.session_token=expired-token; __Host-aura-auth.csrf_token=${csrfToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + user: { name: "Updated Name" }, + }), + }) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + session: null, + success: false, + redirect: false, + redirectURL: null, + }) + expect(revokeSessionMock).toHaveBeenCalledWith("session-123", "user_logout") + }) + + test("should skip validation when identity.skipValidation is true using createAuth", async () => { + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spy = vi.spyOn(registry, "parse") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionMock = vi.fn().mockResolvedValue(mockSession) + const updateSessionMock = vi.fn().mockResolvedValue(mockSession) + const touchSessionMock = vi.fn().mockResolvedValue(undefined) + + const { PATCH } = authInstance( + { + getSessionByToken: getSessionMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }, + { skipValidation: true } + ) + + const csrfToken = await createCSRF(jose) + const response = await PATCH( + new Request("https://example.com/auth/session", { + method: "PATCH", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Secure-aura-auth.session_token=valid-token; __Host-aura-auth.csrf_token=${csrfToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + user: { name: "Updated Name" }, + }), + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + session: { + user: { + ...mockUser, + sub: "user-123", + createdAt: mockUser.createdAt.toISOString(), + updatedAt: mockUser.updatedAt.toISOString(), + emailVerifiedAt: mockUser.emailVerifiedAt?.toISOString(), + }, + expires: expect.any(String), + }, + success: true, + redirect: false, + redirectURL: null, + }) + expect(getSessionMock).toHaveBeenCalledWith("valid-token") + expect(updateSessionMock).toHaveBeenCalled() + expect(touchSessionMock).toHaveBeenCalled() + expect(spy).not.toHaveBeenCalled() + }) + }) + + describe("revokeSession", () => { + test("should revoke session by ID using createAuth", async () => { + const revokeSessionMock = vi.fn().mockResolvedValue(undefined) + + const { POST } = authInstance({ + revokeSession: revokeSessionMock, + getSessionByToken: vi.fn().mockResolvedValue(mockSession), + }) + + const csrfToken = await createCSRF(jose) + const response = await POST( + new Request("https://example.com/auth/signOut?token_type_hint=session_token", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token`, + }, + }) + ) + + expect(response.status).toBe(202) + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: null, + }) + expect(revokeSessionMock).toHaveBeenCalledWith("session-123", "user_logout") + }) + }) + + describe("destroySession", () => { + test("should clear cookies and revoke session using createAuth", async () => { + const getSessionMock = vi.fn().mockResolvedValue(mockSession) + const revokeSessionMock = vi.fn().mockResolvedValue(undefined) + + const { POST } = authInstance({ + getSessionByToken: getSessionMock, + revokeSession: revokeSessionMock, + }) + + const csrfToken = await createCSRF(jose) + const response = await POST( + new Request("https://example.com/auth/signOut?token_type_hint=session_token", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Secure-aura-auth.session_token=valid-token; __Host-aura-auth.csrf_token=${csrfToken}`, + }, + }) + ) + + expect(response.status).toBe(202) + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: null, + }) + expect(getSessionMock).toHaveBeenCalledWith("valid-token") + expect(revokeSessionMock).toHaveBeenCalledWith("session-123", "user_logout") + }) + + test("should handle missing session token using createAuth", async () => { + const getSessionMock = vi.fn().mockResolvedValue(null) + const revokeSessionMock = vi.fn().mockResolvedValue(undefined) + + const { POST } = authInstance({ + getSessionByToken: getSessionMock, + revokeSession: revokeSessionMock, + }) + + const csrfToken = await createCSRF(jose) + const response = await POST( + new Request("https://example.com/auth/signOut?token_type_hint=session_token", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}`, + }, + }) + ) + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + success: false, + redirect: false, + redirectURL: null, + }) + expect(getSessionMock).not.toHaveBeenCalled() + expect(revokeSessionMock).not.toHaveBeenCalled() + }) + }) +})