From 5c52939ff527ebe1aace296af68e639fbe847167 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Fri, 31 Jul 2026 16:35:49 -0500 Subject: [PATCH 1/7] refactor(core): split session strategies into function modules --- packages/core/src/@types/config.ts | 5 + packages/core/src/@types/session.ts | 9 + packages/core/src/session/stateful.ts | 1405 ----------------- .../src/session/stateful/createSession.ts | 147 ++ .../src/session/stateful/destroySession.ts | 82 + .../src/session/stateful/getProviderTokens.ts | 217 +++ .../core/src/session/stateful/getSession.ts | 149 ++ packages/core/src/session/stateful/index.ts | 10 + .../session/stateful/isProviderConnected.ts | 89 ++ .../src/session/stateful/oauthCallback.ts | 242 +++ .../src/session/stateful/refreshSession.ts | 241 +++ .../src/session/stateful/revokeSession.ts | 35 + .../core/src/session/stateful/revokeToken.ts | 121 ++ packages/core/src/session/stateful/signIn.ts | 85 + .../core/src/session/stateful/stateful.ts | 281 ++++ packages/core/src/session/stateful/utils.ts | 29 + packages/core/src/session/strategy.ts | 2 +- 17 files changed, 1743 insertions(+), 1406 deletions(-) delete mode 100644 packages/core/src/session/stateful.ts create mode 100644 packages/core/src/session/stateful/createSession.ts create mode 100644 packages/core/src/session/stateful/destroySession.ts create mode 100644 packages/core/src/session/stateful/getProviderTokens.ts create mode 100644 packages/core/src/session/stateful/getSession.ts create mode 100644 packages/core/src/session/stateful/index.ts create mode 100644 packages/core/src/session/stateful/isProviderConnected.ts create mode 100644 packages/core/src/session/stateful/oauthCallback.ts create mode 100644 packages/core/src/session/stateful/refreshSession.ts create mode 100644 packages/core/src/session/stateful/revokeSession.ts create mode 100644 packages/core/src/session/stateful/revokeToken.ts create mode 100644 packages/core/src/session/stateful/signIn.ts create mode 100644 packages/core/src/session/stateful/stateful.ts create mode 100644 packages/core/src/session/stateful/utils.ts diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index 2b9206b2..1846cc3d 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -20,6 +20,7 @@ import type { SessionStrategy, User, Awaitable, + StatefulStrategyConfig, } from "@/@types/index.ts" import type { ZodObject } from "zod" import type { SerializeOptions } from "@aura-stack/router/cookie" @@ -536,6 +537,10 @@ export type InternalContext, "sessionConfig"> & { + sessionConfig: StatefulStrategyConfig +} + export interface OnCreateUserContext { payload: InferSchema } diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index b83bbd62..1b0da88e 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -13,8 +13,10 @@ import type { Identities, OAuthProviderRecord, InternalContext, + InternalContextForStateful, } from "@/@types/index.ts" import type { DatabaseAdapter } from "@/@types/adapter.ts" +import type { createCookieManager } from "@/session/cookie-manager.ts" /** Application user type, inferred from the configured identity schema (defaults to the built-in user shape). */ export type User = Infer @@ -379,3 +381,10 @@ export interface OAuthTokenPayload { */ issuedAt: number } + +export type CookieManager = ReturnType + +export interface InternalStatefulContext { + ctx: InternalContextForStateful + cookieConfig: CookieManager +} diff --git a/packages/core/src/session/stateful.ts b/packages/core/src/session/stateful.ts deleted file mode 100644 index 42c89a55..00000000 --- a/packages/core/src/session/stateful.ts +++ /dev/null @@ -1,1405 +0,0 @@ -import { AuraAuthError } from "@/shared/errors.ts" -import { secureApiHeaders } from "@/shared/headers.ts" -import { verifyCSRFToken, getErrorName, shouldRefresh, toUnionHeaders, getDeviceInfo, createFingerprint } from "@/shared/utils.ts" -import { handleApiError } from "@/shared/utils/api.ts" -import { refreshProviderToken } from "@/shared/utils/refresh-tokens.ts" -import { revokeProviderToken } from "@/shared/utils/revoke-token.ts" -import { createCookieManager } from "@/session/cookie-manager.ts" -import { createHash, createSecretValue, createCSRF } 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, - GetProviderTokensStatefulReturn, - Session, - SessionStrategy, - User, -} from "@/@types/session.ts" -import { HeadersBuilder } from "@aura-stack/router" -import { getExpiredCookie } from "@/cookie.ts" -import { - createAuthorizationURL, - createRedirectTo, - createRedirectURI, - getOriginURL, - getTrustedOrigins, -} from "@/shared/utils/authorization.ts" -import { isOIDCProvider, resolveOpenIDProvider } from "@/shared/oidc/resolve-provider.ts" -import { createOIDCAuthorizationURL } from "@/shared/oidc/authorization-url.ts" -import { createAccessToken, getUserInfo } from "@/shared/utils/oauth.ts" -import { validateIDToken } from "@/shared/oidc/id-token.ts" -import { isRelativeURL, isSameOrigin, isTrustedOrigin } from "@/shared/assert.ts" - -export const createStatefulStrategy = ({ - config, - cookies, - ctx, - logger, - jose, - oauth, -}: DatabaseStrategyOptions): SessionStrategy => { - const cookieConfig = createCookieManager(cookies) - - const createDevice = async (userId: string, request: Request) => { - const { userAgent, browser, platform, deviceType, ip, name } = getDeviceInfo(request) - const fingerprint = await createFingerprint(request) - const device = await config.adapter.getDeviceByFingerprint(userId, fingerprint) - if (device) { - await config.adapter.updateDevice(device.id, { lastSeenAt: new Date() }) - return device - } - - return await config.adapter.createDevice({ - userId, - userAgent, - browser, - platform, - type: deviceType, - name, - lastIp: ip, - fingerprint, - firstSeenAt: new Date(), - lastSeenAt: new Date(), - trusted: false, - metadata: null, - }) - } - - 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: cookieConfig.clear(), - } - } - - const { attributes, ...userPayload } = session.user - const user = { ...userPayload, ...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 = ctx.identity.skipValidation ? user : await ctx.identity.schemaRegistry.parse(user) - logger?.log("STATEFUL_USER_VALIDATION", { - structuredData: { - validation_skipped: ctx.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: cookieConfig.setCookie({ sessionToken }), - } - } 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: cookieConfig.clear(), - } - } - } - - const createSession = async (session: TypedJWTPayload, request: Request) => { - logger?.log("STATEFUL_CREATE_SESSION_START", { - structuredData: { - strategy: "stateful", - operation: "createSession", - user_id: session.sub, - }, - }) - - if (ctx.identity.skipValidation) { - logger?.log("IDENTITY_VALIDATION_DISABLED", { - structuredData: { - identity_validation_disabled: true, - }, - }) - } - - const payload = ctx.identity.skipValidation ? session : await ctx.identity.schemaRegistry.parse(session) - logger?.log("STATEFUL_PAYLOAD_VALIDATION", { - structuredData: { - validation_skipped: ctx.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 cryptoId = createSecretValue(32) - const { sub: userId, email, image, name, ...attributes } = payload - - let user = await config.adapter.getUserById(userId as string) - if (!user) { - logger?.log("STATEFUL_USER_NOT_FOUND_CREATING", { - structuredData: { - user_id: userId, - reason: "user_not_found_creating_new", - }, - }) - user = await config.adapter.createUser({ - id: userId as string, - name, - email, - image, - attributes, - }) - logger?.log("STATEFUL_USER_CREATED", { - structuredData: { - user_id: user.id, - email: user.email || "", - }, - }) - } else { - logger?.log("STATEFUL_USER_FOUND_UPDATING", { - structuredData: { - user_id: userId, - reason: "user_exists_updating", - }, - }) - user = await config.adapter.updateUser(userId as string, { - name, - email, - image, - attributes, - }) - logger?.log("STATEFUL_USER_UPDATED", { - structuredData: { - user_id: user.id, - email: user.email || "", - }, - }) - } - - const device = await createDevice(userId as string, request) - - const dbSession = await config.adapter.createSession({ - id: cryptoId, - userId: userId as string, - deviceId: device.id, - 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 { attributes, ...spreadUser } = sessionByToken.user - const currentUser = { ...spreadUser, ...attributes, sub: sessionByToken.user.id } - logger?.log("STATEFUL_USER_DATA_MERGED", { - structuredData: { - user_id: currentUser.id, - has_attributes: Boolean(attributes), - }, - }) - - const parsedCurrentUser = ctx.identity.skipValidation - ? currentUser - : await ctx.identity.schemaRegistry.parse(currentUser) - logger?.log("STATEFUL_USER_VALIDATION", { - structuredData: { - validation_skipped: ctx.identity.skipValidation || false, - user_id: currentUser.id, - }, - }) - - const sessionPayload = ctx.identity.skipValidation - ? session.user - : await ctx.identity.schemaRegistry.parseAsPartial(session.user) - - logger?.log("STATEFUL_SESSION_UPDATE_PAYLOAD", { - structuredData: { - has_update_payload: Boolean(sessionPayload), - user_id: currentUser.id, - }, - }) - - const updatedUser = { - ...parsedCurrentUser, - ...sessionPayload, - sub: parsedCurrentUser.sub, - } - - logger?.log("STATEFUL_USER_FIELDS_MERGED", { - structuredData: { - user_id: updatedUser.id, - fields_updated: Object.keys(sessionPayload || {}).join(","), - }, - }) - - const validatedUser = ctx.identity.skipValidation ? updatedUser : await ctx.identity.schemaRegistry.parse(updatedUser) - logger?.log("STATEFUL_UPDATED_USER_VALIDATED", { - structuredData: { - user_id: validatedUser.id, - validation_skipped: ctx.identity.skipValidation || false, - }, - }) - - if (sessionPayload && Object.keys(sessionPayload).length > 0) { - const { sub: _sub, ...userUpdateFields } = validatedUser - await config.adapter.updateUser(sessionByToken.userId, userUpdateFields as any) - logger?.log("STATEFUL_USER_UPDATED_IN_DB", { - structuredData: { - user_id: sessionByToken.userId, - fields_updated: Object.keys(userUpdateFields).join(","), - }, - }) - } - - 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: validatedUser 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 - } - - const getProviderTokens = async (oauthId: string, request: Request): Promise => { - logger?.log("STATEFUL_GET_PROVIDER_TOKENS_START", { - structuredData: { - strategy: "stateful", - operation: "getProviderTokens", - oauth_id: oauthId, - }, - }) - - try { - const { sessionToken } = cookieConfig.getCookie(new Headers(request.headers)) - if (!sessionToken) { - logger?.log("STATEFUL_GET_PROVIDER_TOKENS_NO_SESSION", { - structuredData: { - reason: "no_session_token", - }, - }) - const { code, message, statusCode } = handleApiError( - new AuraAuthError({ code: "SESSION_NOT_FOUND" }), - "PROVIDER_TOKENS_ERROR", - "Failed to get provider tokens" - ) - return { success: false, error: { code, message }, tokens: null, headers: cookieConfig.clear(), statusCode } - } - - const sessionByToken = await config.adapter.getSessionByToken(sessionToken) - if (!sessionByToken || !sessionByToken.user) { - logger?.log("STATEFUL_GET_PROVIDER_TOKENS_SESSION_INVALID", { - structuredData: { - reason: "session_not_found_or_no_user", - }, - }) - const { code, message, statusCode } = handleApiError( - new AuraAuthError({ code: "SESSION_NOT_FOUND" }), - "PROVIDER_TOKENS_ERROR", - "Failed to get provider tokens" - ) - return { success: false, error: { code, message }, tokens: null, headers: cookieConfig.clear(), statusCode } - } - - const isExpired = Date.now() > sessionByToken.expiresAt.getTime() - if (sessionByToken.status !== "active" || isExpired) { - if (isExpired) { - await config.adapter.revokeSession(sessionByToken.id, "user_logout") - } - const { code, message, statusCode } = handleApiError( - new AuraAuthError({ code: "SESSION_NOT_FOUND" }), - "PROVIDER_TOKENS_ERROR", - "Failed to get provider tokens" - ) - return { success: false, error: { code, message }, tokens: null, headers: cookieConfig.clear(), statusCode } - } - - logger?.log("STATEFUL_GET_PROVIDER_TOKENS_SESSION_FOUND", { - structuredData: { - user_id: sessionByToken.userId, - session_id: sessionByToken.id, - }, - }) - - const accounts = await config.adapter.getAccountsByUserId(sessionByToken.userId) - const getAccount = accounts.find((account) => account.provider === oauthId && account.status === "active") - const oauthAccount = getAccount ? await config.adapter.getOAuthAccount(getAccount?.id ?? "") : null - if (!oauthAccount) { - logger?.log("STATEFUL_GET_PROVIDER_TOKENS_OAUTH_ACCOUNT_NOT_FOUND", { - structuredData: { - oauth_id: oauthId, - user_id: sessionByToken.userId, - reason: "oauth_account_not_found", - }, - }) - const { code, message, statusCode } = handleApiError( - new AuraAuthError({ code: "COOKIE_INVALID_VALUE" }), - "PROVIDER_TOKENS_ERROR", - "Failed to get provider tokens" - ) - return { success: false, error: { code, message }, tokens: null, headers: request.headers, statusCode } - } - - logger?.log("STATEFUL_GET_PROVIDER_TOKENS_OAUTH_ACCOUNT_FOUND", { - structuredData: { - oauth_id: oauthId, - account_id: oauthAccount.accountId, - }, - }) - - const tokens = { - accessToken: oauthAccount.accessToken, - refreshToken: oauthAccount.refreshToken || undefined, - idToken: oauthAccount.idToken || undefined, - tokenType: (oauthAccount.tokenType || "Bearer") as "Bearer", - scopes: oauthAccount.scopes ? oauthAccount.scopes.split(" ") : [], - expiresAt: oauthAccount.accessTokenExpiresAt ? Math.floor(oauthAccount.accessTokenExpiresAt.getTime() / 1000) : 0, - refreshTokenExpiresAt: oauthAccount.refreshTokenExpiresAt - ? Math.floor(oauthAccount.refreshTokenExpiresAt.getTime() / 1000) - : undefined, - issuedAt: Math.floor(oauthAccount.updatedAt.getTime() / 1000), - } - - logger?.log("STATEFUL_GET_PROVIDER_TOKENS_TOKENS_EXTRACTED", { - structuredData: { - has_access_token: Boolean(tokens.accessToken), - has_refresh_token: Boolean(tokens.refreshToken), - expires_at: tokens.expiresAt, - }, - }) - - const provider = oauth?.[oauthId] - if (!provider) { - logger?.log("STATEFUL_GET_PROVIDER_TOKENS_PROVIDER_NOT_FOUND", { - structuredData: { - oauth_id: oauthId, - reason: "provider_not_configured", - }, - }) - return { success: true, tokens: tokens as any, headers: request.headers } - } - - const refreshWindow = provider.refreshWindow ?? 300 - const needsRefresh = shouldRefresh(tokens as any, refreshWindow) - - logger?.log("STATEFUL_GET_PROVIDER_TOKENS_REFRESH_CHECK", { - structuredData: { - needs_refresh: needsRefresh, - refresh_window: refreshWindow, - expires_at: tokens.expiresAt, - }, - }) - - if (needsRefresh) { - logger?.log("STATEFUL_GET_PROVIDER_TOKENS_REFRESHING", { - structuredData: { - oauth_id: oauthId, - reason: "token_expired_or_in_refresh_window", - }, - }) - - try { - const refreshedTokens = await refreshProviderToken(tokens as any, provider) - - logger?.log("STATEFUL_GET_PROVIDER_TOKENS_REFRESH_SUCCESS", { - structuredData: { - oauth_id: oauthId, - new_expires_at: refreshedTokens.expiresAt, - }, - }) - - await config.adapter.updateOAuthTokens(oauthId, { - accountId: oauthAccount.accountId, - accessToken: refreshedTokens.accessToken, - refreshToken: refreshedTokens.refreshToken, - idToken: refreshedTokens.idToken, - tokenType: refreshedTokens.tokenType, - scopes: refreshedTokens.scopes ? refreshedTokens.scopes.join(" ") : null, - accessTokenExpiresAt: refreshedTokens.expiresAt ? new Date(refreshedTokens.expiresAt * 1000) : null, - refreshTokenExpiresAt: refreshedTokens.refreshTokenExpiresAt - ? new Date(refreshedTokens.refreshTokenExpiresAt * 1000) - : null, - }) - - logger?.log("STATEFUL_GET_PROVIDER_TOKENS_OAUTH_ACCOUNT_UPDATED", { - structuredData: { - oauth_id: oauthId, - account_id: oauthAccount.accountId, - }, - }) - - return { success: true, tokens: refreshedTokens, headers: request.headers } - } catch (refreshError) { - logger?.log("STATEFUL_GET_PROVIDER_TOKENS_REFRESH_ERROR", { - structuredData: { - oauth_id: oauthId, - error_type: getErrorName(refreshError), - error_message: refreshError instanceof Error ? refreshError.message : String(refreshError), - }, - }) - - const { code, message, statusCode } = handleApiError( - refreshError, - "PROVIDER_TOKENS_ERROR", - "Failed to get provider tokens" - ) - return { success: false, error: { code, message }, tokens: null, headers: request.headers, statusCode } - } - } - - logger?.log("STATEFUL_GET_PROVIDER_TOKENS_SUCCESS", { - structuredData: { - oauth_id: oauthId, - tokens_returned: true, - }, - }) - - return { success: true, tokens: tokens as any, headers: request.headers } - } catch (error) { - logger?.log("STATEFUL_GET_PROVIDER_TOKENS_ERROR", { - structuredData: { - oauth_id: oauthId, - error_type: getErrorName(error), - error_message: error instanceof Error ? error.message : String(error), - }, - }) - - const { code, message, statusCode } = handleApiError(error, "PROVIDER_TOKENS_ERROR", "Failed to get provider tokens") - return { success: false, error: { code, message }, tokens: null, headers: request.headers, statusCode } - } - } - - const revokeToken = async (oauthId: string, headers: Headers, disconnect: boolean): Promise => { - logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { - structuredData: { - provider: oauthId, - operation: "revokeToken", - disconnect, - }, - }) - - try { - const { sessionToken } = cookieConfig.getCookie(headers) - if (!sessionToken) { - logger?.log("SESSION_TOKEN_MISSING", { - structuredData: { - reason: "no_session_token", - }, - }) - throw new AuraAuthError({ code: "SESSION_NOT_FOUND" }) - } - - const sessionByToken = await config.adapter.getSessionByToken(sessionToken) - if (!sessionByToken || !sessionByToken.user) { - logger?.log("AUTH_SESSION_INVALID", { - structuredData: { - reason: "session_not_found_or_no_user", - }, - }) - throw new AuraAuthError({ code: "SESSION_NOT_FOUND" }) - } - - const isExpired = Date.now() > sessionByToken.expiresAt.getTime() - if (sessionByToken.status !== "active" || isExpired) { - if (isExpired) { - await config.adapter.revokeSession(sessionByToken.id, "user_logout") - } - logger?.log("AUTH_SESSION_INVALID", { - structuredData: { - reason: "session_expired_or_inactive", - }, - }) - throw new AuraAuthError({ code: "SESSION_NOT_FOUND" }) - } - - logger?.log("AUTH_SESSION_VALID", { - structuredData: { - user_id: sessionByToken.userId, - session_id: sessionByToken.id, - }, - }) - - const oauthAccount = await config.adapter.getOAuthAccount(oauthId) - if (!oauthAccount) { - logger?.log("OAUTH_UNLINKED_ACCOUNT_ERROR", { - structuredData: { - provider: oauthId, - reason: "oauth_account_not_found", - }, - }) - throw new AuraAuthError({ code: "OAUTH_UNLINKED_ACCOUNT_ERROR" }) - } - - const provider = oauth?.[oauthId] - if (!provider) { - logger?.log("INVALID_OAUTH_CONFIGURATION", { - structuredData: { - provider: oauthId, - reason: "provider_not_configured", - }, - }) - throw new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" }) - } - - if (!disconnect) { - logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { - structuredData: { provider: oauthId, hasAccessToken: !!oauthAccount.accessToken }, - }) - - await revokeProviderToken(provider, oauthAccount.accessToken) - - logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", { - structuredData: { provider: oauthId }, - }) - } - - await config.adapter.updateAccountStatus(oauthAccount.accountId, "unlinked") - - logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", { - structuredData: { - provider: oauthId, - account_unlinked: true, - }, - }) - - const cookieName = `${cookies().accessToken.name}.${oauthId}` - const builder = new HeadersBuilder(secureApiHeaders) - .setCookie(cookieName, "", getExpiredCookie(cookies().accessToken.attributes)) - .toHeaders() - return toUnionHeaders(builder, headers) - } catch (error) { - logger?.log("OAUTH_ACCESS_TOKEN_ERROR", { - structuredData: { - provider: oauthId, - error_type: getErrorName(error), - error_message: error instanceof Error ? error.message : String(error), - }, - }) - throw error - } - } - - const isProviderConnected = async (oauthId: string, headers: Headers): Promise => { - logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { - structuredData: { - provider: oauthId, - operation: "isProviderConnected", - }, - }) - - try { - const { sessionToken } = cookieConfig.getCookie(headers) - if (!sessionToken) { - logger?.log("SESSION_TOKEN_MISSING", { - structuredData: { - reason: "no_session_token", - }, - }) - return false - } - - const sessionByToken = await config.adapter.getSessionByToken(sessionToken) - if (!sessionByToken || !sessionByToken.user) { - logger?.log("AUTH_SESSION_INVALID", { - structuredData: { - reason: "session_not_found_or_no_user", - }, - }) - return false - } - - const isExpired = Date.now() > sessionByToken.expiresAt.getTime() - if (sessionByToken.status !== "active" || isExpired) { - if (isExpired) { - await config.adapter.revokeSession(sessionByToken.id, "user_logout") - } - logger?.log("AUTH_SESSION_INVALID", { - structuredData: { - reason: "session_expired_or_inactive", - }, - }) - return false - } - - logger?.log("AUTH_SESSION_VALID", { - structuredData: { - user_id: sessionByToken.userId, - session_id: sessionByToken.id, - }, - }) - - const accounts = await config.adapter.getAccountsByUserId(sessionByToken.userId) - const account = accounts.find((acc) => acc.provider === oauthId) - - if (!account) { - logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { - structuredData: { - provider: oauthId, - reason: "account_not_found_for_user", - }, - }) - return false - } - - const isConnected = account.status === "active" - logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", { - structuredData: { - provider: oauthId, - connected: isConnected, - }, - }) - - return isConnected - } catch (error) { - logger?.log("OAUTH_ACCESS_TOKEN_ERROR", { - structuredData: { - provider: oauthId, - error_type: getErrorName(error), - error_message: error instanceof Error ? error.message : String(error), - }, - }) - return false - } - } - - const refreshUserInfo = async (userInfo: TypedJWTPayload, headers: Headers, skipCSRFCheck: boolean) => { - return await refreshSession(headers, { user: userInfo }, skipCSRFCheck) - } - - const signIn = async (oauthId: string, request: Request, redirectTo?: string) => { - const provider = oauth[oauthId] - if (!provider) { - throw new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" }) - } - - const redirectURI = await createRedirectURI(request, oauthId, ctx) - const redirectToValue = await createRedirectTo(request, redirectTo, ctx) - - const isOIDC = isOIDCProvider(provider) - logger?.log("SIGN_IN_PROVIDER_TYPE_DETECTED", { - structuredData: { oauth_provider: oauthId, oidc: isOIDC }, - }) - - const resolvedProvider = isOIDC ? await resolveOpenIDProvider(provider!) : provider! - - if (isOIDC) { - logger?.log("OIDC_PROVIDER_RESOLVED", { - structuredData: { oauth_provider: oauthId, oidc: isOIDC }, - }) - } - - let authorization: string - let state: string - let codeVerifier: string - let nonce: string | undefined - - if (isOIDC) { - const result = await createOIDCAuthorizationURL(resolvedProvider, redirectURI, ctx) - authorization = result.authorization - state = result.state - codeVerifier = result.codeVerifier - nonce = result.nonce - } else { - const result = await createAuthorizationURL(resolvedProvider, redirectURI, ctx) - authorization = result.authorization - state = result.state - codeVerifier = result.codeVerifier - } - - logger?.log("SIGN_IN_INITIATED", { - structuredData: { oauth_provider: oauthId, oidc: isOIDC }, - }) - - const userAgent = request.headers.get("user-agent") || null - const fingerprint = request.headers.get("x-device-fingerprint") || null - const deviceId = request.headers.get("x-device-id") || null - const expiresAt = new Date(Date.now() + 10 * 60 * 1000) - - await config.adapter.createOAuthTransaction({ - id: crypto.randomUUID(), - provider: oauthId, - state, - nonce: nonce || null, - codeVerifier, - redirectURI: redirectURI, - redirectTo: redirectToValue, - userAgent, - fingerprint, - deviceId, - createdAt: new Date(), - expiresAt, - metadata: null, - }) - - const headers = new HeadersBuilder(secureApiHeaders).setHeader("Location", authorization).toHeaders() - - return { - success: true, - signInURL: authorization, - headers, - } - } - - const oauthCallback = async (oauthId: string, request: Request, { code, state }: { code: string; state: string }) => { - const oauthConfig = oauth[oauthId] - if (!oauthConfig) { - throw new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" }) - } - - const isOIDC = isOIDCProvider(oauthConfig) - const transaction = await config.adapter.getOAuthTransactionByState(state) - - if (!transaction) { - logger?.log("OAUTH_PROTOCOL_ERROR", { - structuredData: { - oauth_provider: oauthId, - state, - }, - }) - return Response.json( - { - type: "PROTOCOL", - code: "AUTH_MISMATCHING_STATE", - message: "The provided state passed in the OAuth response does not match the stored token state.", - }, - { status: 400 } - ) - } - - if (new Date() > transaction.expiresAt) { - logger?.log("OAUTH_PROTOCOL_ERROR", { - structuredData: { - oauth_provider: oauthId, - state, - expires_at: transaction.expiresAt.toISOString(), - }, - }) - await config.adapter.deleteExpiredOAuthTransactions() - return Response.json( - { - type: "PROTOCOL", - code: "AUTH_TRANSACTION_EXPIRED", - message: "The OAuth transaction has expired. Please try signing in again.", - }, - { status: 400 } - ) - } - - if (transaction.provider !== oauthId) { - logger?.log("OAUTH_PROTOCOL_ERROR", { - structuredData: { - expected_provider: transaction.provider, - provided_provider: oauthId, - }, - }) - return Response.json( - { - type: "PROTOCOL", - code: "AUTH_PROVIDER_MISMATCH", - message: "The OAuth provider does not match the stored transaction.", - }, - { status: 400 } - ) - } - - await config.adapter.consumeOAuthTransaction(state) - - const resolvedConfig = isOIDC ? await resolveOpenIDProvider(oauthConfig) : oauthConfig - - if (!transaction.codeVerifier) { - throw new AuraAuthError({ code: "DATABASE_TOKEN_HASH_NOT_FOUND" as any }) - } - - const accessToken = await createAccessToken( - resolvedConfig, - transaction.redirectURI, - code, - transaction.codeVerifier, - logger - ) - - if (isOIDC) { - if (!accessToken.id_token) { - throw new AuraAuthError({ code: "OIDC_ID_TOKEN_INVALID" }) - } - const { issuer, jwks_uri } = resolvedConfig.oidc! - if (!jwks_uri || !transaction.nonce || !resolvedConfig.clientId) { - throw new AuraAuthError({ code: "OIDC_ID_TOKEN_INVALID" }) - } - await validateIDToken(accessToken.id_token as string, { - issuer, - clientId: resolvedConfig.clientId, - nonce: transaction.nonce, - jwks_uri, - }) - } - - if (transaction.redirectTo && !isRelativeURL(transaction.redirectTo)) { - const origins = await getTrustedOrigins(request, ctx.trustedOrigins) - const requestOrigin = await getOriginURL(request, ctx) - let isValid = false - try { - isValid = - origins.length > 0 - ? isTrustedOrigin(transaction.redirectTo, origins) - : isSameOrigin(transaction.redirectTo, requestOrigin) - } catch { - isValid = false - } - if (!isValid) { - logger?.log("POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", { - structuredData: { - redirect_path: transaction.redirectTo, - provider: oauthId, - has_trusted_origins: origins.length > 0, - request_origin: requestOrigin, - }, - }) - throw new AuraAuthError({ code: "POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED" }) - } - } - - const userInfo = await getUserInfo(resolvedConfig, accessToken, logger) - - if (!userInfo.email) { - throw new AuraAuthError({ code: "INVALID_USER_INFO" }) - } - - let userId: string - const user = await config.adapter.getUserByEmail(userInfo.email) - - if (user) { - userId = user.id - const { sub: _, name, email, image, ...attributes } = userInfo - await config.adapter.updateUser(userId, { - name, - email, - image, - attributes, - }) - } else { - const { email, image, name, ...attributes } = userInfo - const newUser = await config.adapter.createUser({ - id: crypto.randomUUID(), - email: email, - name: name, - image: image, - emailVerifiedAt: new Date(), - status: "active", - mfaEnabled: false, - mfaPreferredMethod: null, - attributes: attributes, - }) - userId = newUser.id - } - - let accountId: string - const account = await config.adapter.getAccountByProvider(oauthId, userInfo.sub) - - if (account) { - accountId = account.id - await config.adapter.updateOAuthTokens(account.id, { - accessToken: accessToken.access_token, - refreshToken: accessToken.refresh_token || null, - idToken: accessToken.id_token || null, - tokenType: accessToken.token_type, - scopes: Array.isArray(accessToken.scope) ? accessToken.scope.join(" ") : accessToken.scope || null, - accessTokenExpiresAt: accessToken.expires_in ? new Date(Date.now() + accessToken.expires_in * 1000) : null, - refreshTokenExpiresAt: accessToken.refresh_token_expires_in - ? new Date(Date.now() + accessToken.refresh_token_expires_in * 1000) - : null, - }) - } else { - const newAccount = await config.adapter.createAccount({ - id: crypto.randomUUID(), - userId, - provider: oauthId, - providerUserId: userInfo.sub, - type: "oauth", - status: "active", - }) - accountId = newAccount.id - - await config.adapter.createOAuthAccount({ - accountId, - accessToken: accessToken.access_token, - refreshToken: accessToken.refresh_token || null, - idToken: accessToken.id_token || null, - tokenType: accessToken.token_type, - scopes: Array.isArray(accessToken.scope) ? accessToken.scope.join(" ") : accessToken.scope || null, - accessTokenExpiresAt: accessToken.expires_in ? new Date(Date.now() + accessToken.expires_in * 1000) : null, - refreshTokenExpiresAt: accessToken.refresh_token_expires_in - ? new Date(Date.now() + accessToken.refresh_token_expires_in * 1000) - : null, - }) - } - - const device = await createDevice(userId, request) - const sessionToken = createSecretValue(64) - const tokenHash = await createHash(sessionToken) - - await config.adapter.createSession({ - id: crypto.randomUUID(), - userId, - deviceId: device.id, - authenticatedWith: "oauth", - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - mfaState: "none", - status: "active", - tokenHash, - metadata: null, - }) - - const csrfToken = await createCSRF(jose as any) - - logger?.log("OAUTH_CALLBACK_SUCCESS", { - structuredData: { - provider: oauthId, - user_id: userId, - }, - }) - - const headersBuilder = new HeadersBuilder() - .setHeader("Location", transaction.redirectTo || "/") - .setCookie(cookies().sessionToken.name, tokenHash, cookies().sessionToken.attributes) - .setCookie(cookies().csrfToken.name, csrfToken, cookies().csrfToken.attributes) - - return Response.json({ oauth: oauthId }, { status: 302, headers: headersBuilder.toHeaders() }) - } - - return { - getSession, - createSession, - refreshSession, - revokeSession, - revokeToken, - destroySession, - getProviderTokens, - isProviderConnected, - refreshUserInfo, - signIn, - oauthCallback, - } -} diff --git a/packages/core/src/session/stateful/createSession.ts b/packages/core/src/session/stateful/createSession.ts new file mode 100644 index 00000000..3f9c02d2 --- /dev/null +++ b/packages/core/src/session/stateful/createSession.ts @@ -0,0 +1,147 @@ +import { AuraAuthError } from "@/shared/errors.ts" +import { createHash, createSecretValue } from "@/shared/crypto.ts" +import { createDevice as __createDevice } from "./utils.ts" +import type { TypedJWTPayload } from "@aura-stack/jose" +import type { InternalStatefulContext, User } from "@/@types/index.ts" + +export const __createSession = ({ ctx, cookieConfig }: InternalStatefulContext) => { + const { logger, sessionConfig } = ctx + const createDevice = __createDevice({ ctx, cookieConfig }) + + return async (session: TypedJWTPayload, request: Request) => { + logger?.log("STATEFUL_CREATE_SESSION_START", { + structuredData: { + strategy: "stateful", + operation: "createSession", + user_id: session.sub, + }, + }) + + if (ctx.identity.skipValidation) { + logger?.log("IDENTITY_VALIDATION_DISABLED", { + structuredData: { + identity_validation_disabled: true, + }, + }) + } + + const payload = ctx.identity.skipValidation ? session : await ctx.identity.schemaRegistry.parse(session) + logger?.log("STATEFUL_PAYLOAD_VALIDATION", { + structuredData: { + validation_skipped: ctx.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 cryptoId = createSecretValue(32) + const { sub: userId, email, image, name, ...attributes } = payload + + let user = await sessionConfig.adapter.getUserById(userId as string) + if (!user) { + logger?.log("STATEFUL_USER_NOT_FOUND_CREATING", { + structuredData: { + user_id: userId, + reason: "user_not_found_creating_new", + }, + }) + user = await sessionConfig.adapter.createUser({ + id: userId as string, + name, + email, + image, + attributes, + }) + logger?.log("STATEFUL_USER_CREATED", { + structuredData: { + user_id: user.id, + email: user.email || "", + }, + }) + } else { + logger?.log("STATEFUL_USER_FOUND_UPDATING", { + structuredData: { + user_id: userId, + reason: "user_exists_updating", + }, + }) + user = await sessionConfig.adapter.updateUser(userId as string, { + name, + email, + image, + attributes, + }) + logger?.log("STATEFUL_USER_UPDATED", { + structuredData: { + user_id: user.id, + email: user.email || "", + }, + }) + } + + const device = await createDevice(userId as string, request) + + const dbSession = await sessionConfig.adapter.createSession({ + id: cryptoId, + userId: userId as string, + deviceId: device.id, + 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 + } +} diff --git a/packages/core/src/session/stateful/destroySession.ts b/packages/core/src/session/stateful/destroySession.ts new file mode 100644 index 00000000..f595f774 --- /dev/null +++ b/packages/core/src/session/stateful/destroySession.ts @@ -0,0 +1,82 @@ +import { getErrorName, verifyCSRFToken } from "@/shared/utils.ts" +import type { InternalStatefulContext } from "@/@types/session.ts" + +export const __destroySession = ({ ctx, cookieConfig }: InternalStatefulContext) => { + const { logger, sessionConfig, cookies, jose } = ctx + + return 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, + 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 sessionConfig.adapter.getSessionByToken(sessionToken) + logger?.log("STATEFUL_SESSION_DB_LOOKUP", { + structuredData: { + session_found: Boolean(sessionByToken), + session_id: sessionByToken?.id || "", + }, + }) + + if (sessionByToken) { + await sessionConfig.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 + } +} diff --git a/packages/core/src/session/stateful/getProviderTokens.ts b/packages/core/src/session/stateful/getProviderTokens.ts new file mode 100644 index 00000000..1fc63ccd --- /dev/null +++ b/packages/core/src/session/stateful/getProviderTokens.ts @@ -0,0 +1,217 @@ +import type { GetProviderTokensStatefulReturn, InternalStatefulContext } from "@/@types/session.ts" +import { AuraAuthError } from "@/shared/errors.ts" +import { getErrorName, shouldRefresh } from "@/shared/utils.ts" +import { handleApiError } from "@/shared/utils/api.ts" +import { refreshProviderToken } from "@/shared/utils/refresh-tokens.ts" + +export const __getProviderTokens = ({ ctx, cookieConfig }: InternalStatefulContext) => { + const { oauth, logger, sessionConfig } = ctx + + return async (oauthId: string, request: Request): Promise => { + logger?.log("STATEFUL_GET_PROVIDER_TOKENS_START", { + structuredData: { + strategy: "stateful", + operation: "getProviderTokens", + oauth_id: oauthId, + }, + }) + + try { + const { sessionToken } = cookieConfig.getCookie(new Headers(request.headers)) + if (!sessionToken) { + logger?.log("STATEFUL_GET_PROVIDER_TOKENS_NO_SESSION", { + structuredData: { + reason: "no_session_token", + }, + }) + const { code, message, statusCode } = handleApiError( + new AuraAuthError({ code: "SESSION_NOT_FOUND" }), + "PROVIDER_TOKENS_ERROR", + "Failed to get provider tokens" + ) + return { success: false, error: { code, message }, tokens: null, headers: cookieConfig.clear(), statusCode } + } + + const sessionByToken = await sessionConfig.adapter.getSessionByToken(sessionToken) + if (!sessionByToken || !sessionByToken.user) { + logger?.log("STATEFUL_GET_PROVIDER_TOKENS_SESSION_INVALID", { + structuredData: { + reason: "session_not_found_or_no_user", + }, + }) + const { code, message, statusCode } = handleApiError( + new AuraAuthError({ code: "SESSION_NOT_FOUND" }), + "PROVIDER_TOKENS_ERROR", + "Failed to get provider tokens" + ) + return { success: false, error: { code, message }, tokens: null, headers: cookieConfig.clear(), statusCode } + } + + const isExpired = Date.now() > sessionByToken.expiresAt.getTime() + if (sessionByToken.status !== "active" || isExpired) { + if (isExpired) { + await sessionConfig.adapter.revokeSession(sessionByToken.id, "user_logout") + } + const { code, message, statusCode } = handleApiError( + new AuraAuthError({ code: "SESSION_NOT_FOUND" }), + "PROVIDER_TOKENS_ERROR", + "Failed to get provider tokens" + ) + return { success: false, error: { code, message }, tokens: null, headers: cookieConfig.clear(), statusCode } + } + + logger?.log("STATEFUL_GET_PROVIDER_TOKENS_SESSION_FOUND", { + structuredData: { + user_id: sessionByToken.userId, + session_id: sessionByToken.id, + }, + }) + + const accounts = await sessionConfig.adapter.getAccountsByUserId(sessionByToken.userId) + const getAccount = accounts.find((account) => account.provider === oauthId && account.status === "active") + const oauthAccount = getAccount ? await sessionConfig.adapter.getOAuthAccount(getAccount?.id ?? "") : null + if (!oauthAccount) { + logger?.log("STATEFUL_GET_PROVIDER_TOKENS_OAUTH_ACCOUNT_NOT_FOUND", { + structuredData: { + oauth_id: oauthId, + user_id: sessionByToken.userId, + reason: "oauth_account_not_found", + }, + }) + const { code, message, statusCode } = handleApiError( + new AuraAuthError({ code: "COOKIE_INVALID_VALUE" }), + "PROVIDER_TOKENS_ERROR", + "Failed to get provider tokens" + ) + return { success: false, error: { code, message }, tokens: null, headers: request.headers, statusCode } + } + + logger?.log("STATEFUL_GET_PROVIDER_TOKENS_OAUTH_ACCOUNT_FOUND", { + structuredData: { + oauth_id: oauthId, + account_id: oauthAccount.accountId, + }, + }) + + const tokens = { + accessToken: oauthAccount.accessToken, + refreshToken: oauthAccount.refreshToken || undefined, + idToken: oauthAccount.idToken || undefined, + tokenType: (oauthAccount.tokenType || "Bearer") as "Bearer", + scopes: oauthAccount.scopes ? oauthAccount.scopes.split(" ") : [], + expiresAt: oauthAccount.accessTokenExpiresAt ? Math.floor(oauthAccount.accessTokenExpiresAt.getTime() / 1000) : 0, + refreshTokenExpiresAt: oauthAccount.refreshTokenExpiresAt + ? Math.floor(oauthAccount.refreshTokenExpiresAt.getTime() / 1000) + : undefined, + issuedAt: Math.floor(oauthAccount.updatedAt.getTime() / 1000), + } + + logger?.log("STATEFUL_GET_PROVIDER_TOKENS_TOKENS_EXTRACTED", { + structuredData: { + has_access_token: Boolean(tokens.accessToken), + has_refresh_token: Boolean(tokens.refreshToken), + expires_at: tokens.expiresAt, + }, + }) + + const provider = oauth?.[oauthId] + if (!provider) { + logger?.log("STATEFUL_GET_PROVIDER_TOKENS_PROVIDER_NOT_FOUND", { + structuredData: { + oauth_id: oauthId, + reason: "provider_not_configured", + }, + }) + return { success: true, tokens: tokens as any, headers: request.headers } + } + + const refreshWindow = provider.refreshWindow ?? 300 + const needsRefresh = shouldRefresh(tokens as any, refreshWindow) + + logger?.log("STATEFUL_GET_PROVIDER_TOKENS_REFRESH_CHECK", { + structuredData: { + needs_refresh: needsRefresh, + refresh_window: refreshWindow, + expires_at: tokens.expiresAt, + }, + }) + + if (needsRefresh) { + logger?.log("STATEFUL_GET_PROVIDER_TOKENS_REFRESHING", { + structuredData: { + oauth_id: oauthId, + reason: "token_expired_or_in_refresh_window", + }, + }) + + try { + const refreshedTokens = await refreshProviderToken(tokens as any, provider) + + logger?.log("STATEFUL_GET_PROVIDER_TOKENS_REFRESH_SUCCESS", { + structuredData: { + oauth_id: oauthId, + new_expires_at: refreshedTokens.expiresAt, + }, + }) + + await sessionConfig.adapter.updateOAuthTokens(oauthId, { + accountId: oauthAccount.accountId, + accessToken: refreshedTokens.accessToken, + refreshToken: refreshedTokens.refreshToken, + idToken: refreshedTokens.idToken, + tokenType: refreshedTokens.tokenType, + scopes: refreshedTokens.scopes ? refreshedTokens.scopes.join(" ") : null, + accessTokenExpiresAt: refreshedTokens.expiresAt ? new Date(refreshedTokens.expiresAt * 1000) : null, + refreshTokenExpiresAt: refreshedTokens.refreshTokenExpiresAt + ? new Date(refreshedTokens.refreshTokenExpiresAt * 1000) + : null, + }) + + logger?.log("STATEFUL_GET_PROVIDER_TOKENS_OAUTH_ACCOUNT_UPDATED", { + structuredData: { + oauth_id: oauthId, + account_id: oauthAccount.accountId, + }, + }) + + return { success: true, tokens: refreshedTokens, headers: request.headers } + } catch (refreshError) { + logger?.log("STATEFUL_GET_PROVIDER_TOKENS_REFRESH_ERROR", { + structuredData: { + oauth_id: oauthId, + error_type: getErrorName(refreshError), + error_message: refreshError instanceof Error ? refreshError.message : String(refreshError), + }, + }) + + const { code, message, statusCode } = handleApiError( + refreshError, + "PROVIDER_TOKENS_ERROR", + "Failed to get provider tokens" + ) + return { success: false, error: { code, message }, tokens: null, headers: request.headers, statusCode } + } + } + + logger?.log("STATEFUL_GET_PROVIDER_TOKENS_SUCCESS", { + structuredData: { + oauth_id: oauthId, + tokens_returned: true, + }, + }) + + return { success: true, tokens: tokens as any, headers: request.headers } + } catch (error) { + logger?.log("STATEFUL_GET_PROVIDER_TOKENS_ERROR", { + structuredData: { + oauth_id: oauthId, + error_type: getErrorName(error), + error_message: error instanceof Error ? error.message : String(error), + }, + }) + + const { code, message, statusCode } = handleApiError(error, "PROVIDER_TOKENS_ERROR", "Failed to get provider tokens") + return { success: false, error: { code, message }, tokens: null, headers: request.headers, statusCode } + } + } +} diff --git a/packages/core/src/session/stateful/getSession.ts b/packages/core/src/session/stateful/getSession.ts new file mode 100644 index 00000000..a2936def --- /dev/null +++ b/packages/core/src/session/stateful/getSession.ts @@ -0,0 +1,149 @@ +import { getErrorName } from "@/shared/utils.ts" +import { AuraAuthError } from "@/shared/errors.ts" +import { secureApiHeaders } from "@/shared/headers.ts" +import type { GetStatefulSessionReturn, User, InternalStatefulContext } from "@/@types/index.ts" + +export const __getSession = ({ ctx, cookieConfig }: InternalStatefulContext) => { + const { logger, sessionConfig } = ctx + + return 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 sessionConfig.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 sessionConfig.adapter.revokeSession(session.id, "user_logout") + + return { + session: null, + headers: cookieConfig.clear(), + } + } + + const { attributes, ...userPayload } = session.user + const user = { ...userPayload, ...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 = ctx.identity.skipValidation ? user : await ctx.identity.schemaRegistry.parse(user) + logger?.log("STATEFUL_USER_VALIDATION", { + structuredData: { + validation_skipped: ctx.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: cookieConfig.setCookie({ sessionToken }), + } + } 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: cookieConfig.clear(), + } + } + } +} diff --git a/packages/core/src/session/stateful/index.ts b/packages/core/src/session/stateful/index.ts new file mode 100644 index 00000000..d72271ef --- /dev/null +++ b/packages/core/src/session/stateful/index.ts @@ -0,0 +1,10 @@ +export * from "@/session/stateful/createSession.ts" +export * from "@/session/stateful/destroySession.ts" +export * from "@/session/stateful/getProviderTokens.ts" +export * from "@/session/stateful/getSession.ts" +export * from "@/session/stateful/isProviderConnected.ts" +export * from "@/session/stateful/oauthCallback.ts" +export * from "@/session/stateful/refreshSession.ts" +export * from "@/session/stateful/revokeSession.ts" +export * from "@/session/stateful/revokeToken.ts" +export * from "@/session/stateful/signIn.ts" diff --git a/packages/core/src/session/stateful/isProviderConnected.ts b/packages/core/src/session/stateful/isProviderConnected.ts new file mode 100644 index 00000000..09017247 --- /dev/null +++ b/packages/core/src/session/stateful/isProviderConnected.ts @@ -0,0 +1,89 @@ +import type { InternalStatefulContext } from "@/@types/session.ts" +import { getErrorName } from "@/shared/utils.ts" + +export const __isProviderConnected = ({ ctx, cookieConfig }: InternalStatefulContext) => { + const { logger, sessionConfig } = ctx + + return async (oauthId: string, headers: Headers): Promise => { + logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { + structuredData: { + provider: oauthId, + operation: "isProviderConnected", + }, + }) + + try { + const { sessionToken } = cookieConfig.getCookie(headers) + if (!sessionToken) { + logger?.log("SESSION_TOKEN_MISSING", { + structuredData: { + reason: "no_session_token", + }, + }) + return false + } + + const sessionByToken = await sessionConfig.adapter.getSessionByToken(sessionToken) + if (!sessionByToken || !sessionByToken.user) { + logger?.log("AUTH_SESSION_INVALID", { + structuredData: { + reason: "session_not_found_or_no_user", + }, + }) + return false + } + + const isExpired = Date.now() > sessionByToken.expiresAt.getTime() + if (sessionByToken.status !== "active" || isExpired) { + if (isExpired) { + await sessionConfig.adapter.revokeSession(sessionByToken.id, "user_logout") + } + logger?.log("AUTH_SESSION_INVALID", { + structuredData: { + reason: "session_expired_or_inactive", + }, + }) + return false + } + + logger?.log("AUTH_SESSION_VALID", { + structuredData: { + user_id: sessionByToken.userId, + session_id: sessionByToken.id, + }, + }) + + const accounts = await sessionConfig.adapter.getAccountsByUserId(sessionByToken.userId) + const account = accounts.find((acc) => acc.provider === oauthId) + + if (!account) { + logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { + structuredData: { + provider: oauthId, + reason: "account_not_found_for_user", + }, + }) + return false + } + + const isConnected = account.status === "active" + logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", { + structuredData: { + provider: oauthId, + connected: isConnected, + }, + }) + + return isConnected + } catch (error) { + logger?.log("OAUTH_ACCESS_TOKEN_ERROR", { + structuredData: { + provider: oauthId, + error_type: getErrorName(error), + error_message: error instanceof Error ? error.message : String(error), + }, + }) + return false + } + } +} diff --git a/packages/core/src/session/stateful/oauthCallback.ts b/packages/core/src/session/stateful/oauthCallback.ts new file mode 100644 index 00000000..d9bfdcda --- /dev/null +++ b/packages/core/src/session/stateful/oauthCallback.ts @@ -0,0 +1,242 @@ +import { isRelativeURL, isSameOrigin, isTrustedOrigin } from "@/shared/assert.ts" +import { createCSRF, createHash, createSecretValue } from "@/shared/crypto.ts" +import { AuraAuthError } from "@/shared/errors.ts" +import { validateIDToken } from "@/shared/oidc/id-token.ts" +import { isOIDCProvider, resolveOpenIDProvider } from "@/shared/oidc/resolve-provider.ts" +import { getOriginURL, getTrustedOrigins } from "@/shared/utils/authorization.ts" +import { createAccessToken, getUserInfo } from "@/shared/utils/oauth.ts" +import type { InternalStatefulContext } from "@/@types/session.ts" +import { createDevice as __createDevice } from "./utils.ts" +import { HeadersBuilder } from "@aura-stack/router" + +export const __oauthCallback = ({ ctx, cookieConfig }: InternalStatefulContext) => { + const { logger, jose, oauth, cookies, sessionConfig } = ctx + const createDevice = __createDevice({ ctx, cookieConfig }) + + return async (oauthId: string, request: Request, { code, state }: { code: string; state: string }) => { + const oauthConfig = oauth[oauthId] + if (!oauthConfig) { + throw new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" }) + } + + const isOIDC = isOIDCProvider(oauthConfig) + const transaction = await sessionConfig.adapter.getOAuthTransactionByState(state) + + if (!transaction) { + logger?.log("OAUTH_PROTOCOL_ERROR", { + structuredData: { + oauth_provider: oauthId, + state, + }, + }) + return Response.json( + { + type: "PROTOCOL", + code: "AUTH_MISMATCHING_STATE", + message: "The provided state passed in the OAuth response does not match the stored token state.", + }, + { status: 400 } + ) + } + + if (new Date() > transaction.expiresAt) { + logger?.log("OAUTH_PROTOCOL_ERROR", { + structuredData: { + oauth_provider: oauthId, + state, + expires_at: transaction.expiresAt.toISOString(), + }, + }) + await sessionConfig.adapter.deleteExpiredOAuthTransactions() + return Response.json( + { + type: "PROTOCOL", + code: "AUTH_TRANSACTION_EXPIRED", + message: "The OAuth transaction has expired. Please try signing in again.", + }, + { status: 400 } + ) + } + + if (transaction.provider !== oauthId) { + logger?.log("OAUTH_PROTOCOL_ERROR", { + structuredData: { + expected_provider: transaction.provider, + provided_provider: oauthId, + }, + }) + return Response.json( + { + type: "PROTOCOL", + code: "AUTH_PROVIDER_MISMATCH", + message: "The OAuth provider does not match the stored transaction.", + }, + { status: 400 } + ) + } + + await sessionConfig.adapter.consumeOAuthTransaction(state) + + const resolvedConfig = isOIDC ? await resolveOpenIDProvider(oauthConfig) : oauthConfig + + if (!transaction.codeVerifier) { + throw new AuraAuthError({ code: "DATABASE_TOKEN_HASH_NOT_FOUND" as any }) + } + + const accessToken = await createAccessToken( + resolvedConfig, + transaction.redirectURI, + code, + transaction.codeVerifier, + logger + ) + + if (isOIDC) { + if (!accessToken.id_token) { + throw new AuraAuthError({ code: "OIDC_ID_TOKEN_INVALID" }) + } + const { issuer, jwks_uri } = resolvedConfig.oidc! + if (!jwks_uri || !transaction.nonce || !resolvedConfig.clientId) { + throw new AuraAuthError({ code: "OIDC_ID_TOKEN_INVALID" }) + } + await validateIDToken(accessToken.id_token as string, { + issuer, + clientId: resolvedConfig.clientId, + nonce: transaction.nonce, + jwks_uri, + }) + } + + if (transaction.redirectTo && !isRelativeURL(transaction.redirectTo)) { + const origins = await getTrustedOrigins(request, ctx.trustedOrigins) + const requestOrigin = await getOriginURL(request, ctx) + let isValid = false + try { + isValid = + origins.length > 0 + ? isTrustedOrigin(transaction.redirectTo, origins) + : isSameOrigin(transaction.redirectTo, requestOrigin) + } catch { + isValid = false + } + if (!isValid) { + logger?.log("POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", { + structuredData: { + redirect_path: transaction.redirectTo, + provider: oauthId, + has_trusted_origins: origins.length > 0, + request_origin: requestOrigin, + }, + }) + throw new AuraAuthError({ code: "POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED" }) + } + } + + const userInfo = await getUserInfo(resolvedConfig, accessToken, logger) + + if (!userInfo.email) { + throw new AuraAuthError({ code: "INVALID_USER_INFO" }) + } + + let userId: string + const user = await sessionConfig.adapter.getUserByEmail(userInfo.email) + + if (user) { + userId = user.id + const { sub: _, name, email, image, ...attributes } = userInfo + await sessionConfig.adapter.updateUser(userId, { + name, + email, + image, + attributes, + }) + } else { + const { email, image, name, ...attributes } = userInfo + const newUser = await sessionConfig.adapter.createUser({ + id: crypto.randomUUID(), + email: email, + name: name, + image: image, + emailVerifiedAt: new Date(), + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + attributes: attributes, + }) + userId = newUser.id + } + + let accountId: string + const account = await sessionConfig.adapter.getAccountByProvider(oauthId, userInfo.sub) + + if (account) { + accountId = account.id + await sessionConfig.adapter.updateOAuthTokens(account.id, { + accessToken: accessToken.access_token, + refreshToken: accessToken.refresh_token || null, + idToken: accessToken.id_token || null, + tokenType: accessToken.token_type, + scopes: Array.isArray(accessToken.scope) ? accessToken.scope.join(" ") : accessToken.scope || null, + accessTokenExpiresAt: accessToken.expires_in ? new Date(Date.now() + accessToken.expires_in * 1000) : null, + refreshTokenExpiresAt: accessToken.refresh_token_expires_in + ? new Date(Date.now() + accessToken.refresh_token_expires_in * 1000) + : null, + }) + } else { + const newAccount = await sessionConfig.adapter.createAccount({ + id: crypto.randomUUID(), + userId, + provider: oauthId, + providerUserId: userInfo.sub, + type: "oauth", + status: "active", + }) + accountId = newAccount.id + + await sessionConfig.adapter.createOAuthAccount({ + accountId, + accessToken: accessToken.access_token, + refreshToken: accessToken.refresh_token || null, + idToken: accessToken.id_token || null, + tokenType: accessToken.token_type, + scopes: Array.isArray(accessToken.scope) ? accessToken.scope.join(" ") : accessToken.scope || null, + accessTokenExpiresAt: accessToken.expires_in ? new Date(Date.now() + accessToken.expires_in * 1000) : null, + refreshTokenExpiresAt: accessToken.refresh_token_expires_in + ? new Date(Date.now() + accessToken.refresh_token_expires_in * 1000) + : null, + }) + } + + const device = await createDevice(userId, request) + const sessionToken = createSecretValue(64) + const tokenHash = await createHash(sessionToken) + + await sessionConfig.adapter.createSession({ + id: crypto.randomUUID(), + userId, + deviceId: device.id, + authenticatedWith: "oauth", + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + mfaState: "none", + status: "active", + tokenHash, + metadata: null, + }) + + const csrfToken = await createCSRF(jose) + + logger?.log("OAUTH_CALLBACK_SUCCESS", { + structuredData: { + provider: oauthId, + user_id: userId, + }, + }) + + const headersBuilder = new HeadersBuilder() + .setHeader("Location", transaction.redirectTo || "/") + .setCookie(cookies.sessionToken.name, tokenHash, cookies.sessionToken.attributes) + .setCookie(cookies.csrfToken.name, csrfToken, cookies.csrfToken.attributes) + + return Response.json({ oauth: oauthId }, { status: 302, headers: headersBuilder.toHeaders() }) + } +} diff --git a/packages/core/src/session/stateful/refreshSession.ts b/packages/core/src/session/stateful/refreshSession.ts new file mode 100644 index 00000000..ec5c1141 --- /dev/null +++ b/packages/core/src/session/stateful/refreshSession.ts @@ -0,0 +1,241 @@ +import type { InternalStatefulContext, Session, User } from "@/@types/session.ts" +import type { DeepPartial } from "@/@types/utility.ts" +import { secureApiHeaders } from "@/shared/headers.ts" +import { getErrorName, verifyCSRFToken } from "@/shared/utils.ts" + +export const __refreshSession = ({ ctx, cookieConfig }: InternalStatefulContext) => { + const { logger, sessionConfig, jose, cookies } = ctx + + return 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, + jose, + cookies, + logger, + }) + + 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 sessionConfig.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 sessionConfig.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 { attributes, ...spreadUser } = sessionByToken.user + const currentUser = { ...spreadUser, ...attributes, sub: sessionByToken.user.id } + logger?.log("STATEFUL_USER_DATA_MERGED", { + structuredData: { + user_id: currentUser.id, + has_attributes: Boolean(attributes), + }, + }) + + const parsedCurrentUser = ctx.identity.skipValidation + ? currentUser + : await ctx.identity.schemaRegistry.parse(currentUser) + logger?.log("STATEFUL_USER_VALIDATION", { + structuredData: { + validation_skipped: ctx.identity.skipValidation || false, + user_id: currentUser.id, + }, + }) + + const sessionPayload = ctx.identity.skipValidation + ? session.user + : await ctx.identity.schemaRegistry.parseAsPartial(session.user) + + logger?.log("STATEFUL_SESSION_UPDATE_PAYLOAD", { + structuredData: { + has_update_payload: Boolean(sessionPayload), + user_id: currentUser.id, + }, + }) + + const updatedUser = { + ...parsedCurrentUser, + ...sessionPayload, + sub: parsedCurrentUser.sub, + } + + logger?.log("STATEFUL_USER_FIELDS_MERGED", { + structuredData: { + user_id: updatedUser.id, + fields_updated: Object.keys(sessionPayload || {}).join(","), + }, + }) + + const validatedUser = ctx.identity.skipValidation ? updatedUser : await ctx.identity.schemaRegistry.parse(updatedUser) + logger?.log("STATEFUL_UPDATED_USER_VALIDATED", { + structuredData: { + user_id: validatedUser.id, + validation_skipped: ctx.identity.skipValidation || false, + }, + }) + + if (sessionPayload && Object.keys(sessionPayload).length > 0) { + const { sub: _sub, ...userUpdateFields } = validatedUser + await sessionConfig.adapter.updateUser(sessionByToken.userId, userUpdateFields as any) + logger?.log("STATEFUL_USER_UPDATED_IN_DB", { + structuredData: { + user_id: sessionByToken.userId, + fields_updated: Object.keys(userUpdateFields).join(","), + }, + }) + } + + 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 sessionConfig.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 sessionConfig.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: validatedUser 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() } + } + } +} diff --git a/packages/core/src/session/stateful/revokeSession.ts b/packages/core/src/session/stateful/revokeSession.ts new file mode 100644 index 00000000..4b42fe79 --- /dev/null +++ b/packages/core/src/session/stateful/revokeSession.ts @@ -0,0 +1,35 @@ +import { AuraAuthError } from "@/shared/errors.ts" +import type { InternalStatefulContext } from "@/@types/session.ts" + +export const __revokeSession = ({ ctx }: InternalStatefulContext) => { + const { logger, sessionConfig } = ctx + + return 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 sessionConfig.adapter.revokeSession(sessionId, "user_logout") + + logger?.log("STATEFUL_REVOKE_SESSION_SUCCESS", { + structuredData: { + session_id: sessionId, + reason: "user_logout", + }, + }) + } +} diff --git a/packages/core/src/session/stateful/revokeToken.ts b/packages/core/src/session/stateful/revokeToken.ts new file mode 100644 index 00000000..42b43298 --- /dev/null +++ b/packages/core/src/session/stateful/revokeToken.ts @@ -0,0 +1,121 @@ +import { AuraAuthError } from "@/shared/errors.ts" +import { revokeProviderToken } from "@/shared/utils/revoke-token.ts" +import { HeadersBuilder } from "@aura-stack/router" +import { secureApiHeaders } from "@/shared/headers.ts" +import { getExpiredCookie } from "@/cookie.ts" +import { getErrorName, toUnionHeaders } from "@/shared/utils.ts" +import type { InternalStatefulContext } from "@/@types/session.ts" + +export const __revokeToken = ({ ctx, cookieConfig }: InternalStatefulContext) => { + const { oauth, logger, sessionConfig, cookies, jose } = ctx + + return async (oauthId: string, headers: Headers, disconnect: boolean): Promise => { + logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { + structuredData: { + provider: oauthId, + operation: "revokeToken", + disconnect, + }, + }) + + try { + const { sessionToken } = cookieConfig.getCookie(headers) + if (!sessionToken) { + logger?.log("SESSION_TOKEN_MISSING", { + structuredData: { + reason: "no_session_token", + }, + }) + throw new AuraAuthError({ code: "SESSION_NOT_FOUND" }) + } + + const sessionByToken = await sessionConfig.adapter.getSessionByToken(sessionToken) + if (!sessionByToken || !sessionByToken.user) { + logger?.log("AUTH_SESSION_INVALID", { + structuredData: { + reason: "session_not_found_or_no_user", + }, + }) + throw new AuraAuthError({ code: "SESSION_NOT_FOUND" }) + } + + const isExpired = Date.now() > sessionByToken.expiresAt.getTime() + if (sessionByToken.status !== "active" || isExpired) { + if (isExpired) { + await sessionConfig.adapter.revokeSession(sessionByToken.id, "user_logout") + } + logger?.log("AUTH_SESSION_INVALID", { + structuredData: { + reason: "session_expired_or_inactive", + }, + }) + throw new AuraAuthError({ code: "SESSION_NOT_FOUND" }) + } + + logger?.log("AUTH_SESSION_VALID", { + structuredData: { + user_id: sessionByToken.userId, + session_id: sessionByToken.id, + }, + }) + + const oauthAccount = await sessionConfig.adapter.getOAuthAccount(oauthId) + if (!oauthAccount) { + logger?.log("OAUTH_UNLINKED_ACCOUNT_ERROR", { + structuredData: { + provider: oauthId, + reason: "oauth_account_not_found", + }, + }) + throw new AuraAuthError({ code: "OAUTH_UNLINKED_ACCOUNT_ERROR" }) + } + + const provider = oauth?.[oauthId] + if (!provider) { + logger?.log("INVALID_OAUTH_CONFIGURATION", { + structuredData: { + provider: oauthId, + reason: "provider_not_configured", + }, + }) + throw new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" }) + } + + if (!disconnect) { + logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { + structuredData: { provider: oauthId, hasAccessToken: !!oauthAccount.accessToken }, + }) + + await revokeProviderToken(provider, oauthAccount.accessToken) + + logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", { + structuredData: { provider: oauthId }, + }) + } + + await sessionConfig.adapter.updateAccountStatus(oauthAccount.accountId, "unlinked") + + logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", { + structuredData: { + provider: oauthId, + account_unlinked: true, + }, + }) + + const cookieName = `${cookies.accessToken.name}.${oauthId}` + const builder = new HeadersBuilder(secureApiHeaders) + .setCookie(cookieName, "", getExpiredCookie(cookies.accessToken.attributes)) + .toHeaders() + return toUnionHeaders(builder, headers) + } catch (error) { + logger?.log("OAUTH_ACCESS_TOKEN_ERROR", { + structuredData: { + provider: oauthId, + error_type: getErrorName(error), + error_message: error instanceof Error ? error.message : String(error), + }, + }) + throw error + } + } +} diff --git a/packages/core/src/session/stateful/signIn.ts b/packages/core/src/session/stateful/signIn.ts new file mode 100644 index 00000000..d4560b59 --- /dev/null +++ b/packages/core/src/session/stateful/signIn.ts @@ -0,0 +1,85 @@ +import { AuraAuthError } from "@/shared/errors.ts" +import { secureApiHeaders } from "@/shared/headers.ts" +import { createOIDCAuthorizationURL } from "@/shared/oidc/authorization-url.ts" +import { isOIDCProvider, resolveOpenIDProvider } from "@/shared/oidc/resolve-provider.ts" +import { createAuthorizationURL, createRedirectTo, createRedirectURI } from "@/shared/utils/authorization.ts" +import { HeadersBuilder } from "@aura-stack/router" +import type { InternalStatefulContext } from "@/@types/session.ts" + +export const __signIn = ({ ctx, cookieConfig }: InternalStatefulContext) => { + const { logger, oauth, sessionConfig } = ctx + + return async (oauthId: string, request: Request, redirectTo?: string) => { + const provider = oauth[oauthId] + if (!provider) { + throw new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" }) + } + + const redirectURI = await createRedirectURI(request, oauthId, ctx) + const redirectToValue = await createRedirectTo(request, redirectTo, ctx) + + const isOIDC = isOIDCProvider(provider) + logger?.log("SIGN_IN_PROVIDER_TYPE_DETECTED", { + structuredData: { oauth_provider: oauthId, oidc: isOIDC }, + }) + + const resolvedProvider = isOIDC ? await resolveOpenIDProvider(provider!) : provider! + + if (isOIDC) { + logger?.log("OIDC_PROVIDER_RESOLVED", { + structuredData: { oauth_provider: oauthId, oidc: isOIDC }, + }) + } + + let authorization: string + let state: string + let codeVerifier: string + let nonce: string | undefined + + if (isOIDC) { + const result = await createOIDCAuthorizationURL(resolvedProvider, redirectURI, ctx) + authorization = result.authorization + state = result.state + codeVerifier = result.codeVerifier + nonce = result.nonce + } else { + const result = await createAuthorizationURL(resolvedProvider, redirectURI, ctx) + authorization = result.authorization + state = result.state + codeVerifier = result.codeVerifier + } + + logger?.log("SIGN_IN_INITIATED", { + structuredData: { oauth_provider: oauthId, oidc: isOIDC }, + }) + + const userAgent = request.headers.get("user-agent") || null + const fingerprint = request.headers.get("x-device-fingerprint") || null + const deviceId = request.headers.get("x-device-id") || null + const expiresAt = new Date(Date.now() + 10 * 60 * 1000) + + await sessionConfig.adapter.createOAuthTransaction({ + id: crypto.randomUUID(), + provider: oauthId, + state, + nonce: nonce || null, + codeVerifier, + redirectURI: redirectURI, + redirectTo: redirectToValue, + userAgent, + fingerprint, + deviceId, + createdAt: new Date(), + expiresAt, + metadata: null, + }) + + const headers = new HeadersBuilder(secureApiHeaders).setHeader("Location", authorization).toHeaders() + + return { + success: true, + signInURL: authorization, + headers, + } + } +} diff --git a/packages/core/src/session/stateful/stateful.ts b/packages/core/src/session/stateful/stateful.ts new file mode 100644 index 00000000..ad651eec --- /dev/null +++ b/packages/core/src/session/stateful/stateful.ts @@ -0,0 +1,281 @@ +import { secureApiHeaders } from "@/shared/headers.ts" +import { verifyCSRFToken, getErrorName } from "@/shared/utils.ts" +import { createCookieManager } from "@/session/cookie-manager.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, Session, SessionStrategy, User } from "@/@types/session.ts" +import { + __getSession, + __createSession, + __oauthCallback, + __signIn, + __revokeSession, + __isProviderConnected, + __getProviderTokens, + __destroySession, + __refreshSession, + __revokeToken, + __refreshUserInfo, +} from "@/session/stateful/index.ts" + +export const createStatefulStrategy = ({ + config, + cookies, + ctx, + logger, + jose, +}: DatabaseStrategyOptions): SessionStrategy => { + const cookieConfig = createCookieManager(cookies) + + 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 { attributes, ...spreadUser } = sessionByToken.user + const currentUser = { ...spreadUser, ...attributes, sub: sessionByToken.user.id } + logger?.log("STATEFUL_USER_DATA_MERGED", { + structuredData: { + user_id: currentUser.id, + has_attributes: Boolean(attributes), + }, + }) + + const parsedCurrentUser = ctx.identity.skipValidation + ? currentUser + : await ctx.identity.schemaRegistry.parse(currentUser) + logger?.log("STATEFUL_USER_VALIDATION", { + structuredData: { + validation_skipped: ctx.identity.skipValidation || false, + user_id: currentUser.id, + }, + }) + + const sessionPayload = ctx.identity.skipValidation + ? session.user + : await ctx.identity.schemaRegistry.parseAsPartial(session.user) + + logger?.log("STATEFUL_SESSION_UPDATE_PAYLOAD", { + structuredData: { + has_update_payload: Boolean(sessionPayload), + user_id: currentUser.id, + }, + }) + + const updatedUser = { + ...parsedCurrentUser, + ...sessionPayload, + sub: parsedCurrentUser.sub, + } + + logger?.log("STATEFUL_USER_FIELDS_MERGED", { + structuredData: { + user_id: updatedUser.id, + fields_updated: Object.keys(sessionPayload || {}).join(","), + }, + }) + + const validatedUser = ctx.identity.skipValidation ? updatedUser : await ctx.identity.schemaRegistry.parse(updatedUser) + logger?.log("STATEFUL_UPDATED_USER_VALIDATED", { + structuredData: { + user_id: validatedUser.id, + validation_skipped: ctx.identity.skipValidation || false, + }, + }) + + if (sessionPayload && Object.keys(sessionPayload).length > 0) { + const { sub: _sub, ...userUpdateFields } = validatedUser + await config.adapter.updateUser(sessionByToken.userId, userUpdateFields as any) + logger?.log("STATEFUL_USER_UPDATED_IN_DB", { + structuredData: { + user_id: sessionByToken.userId, + fields_updated: Object.keys(userUpdateFields).join(","), + }, + }) + } + + 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: validatedUser 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 refreshUserInfo = async (userInfo: TypedJWTPayload, headers: Headers, skipCSRFCheck: boolean) => { + return await refreshSession(headers, { user: userInfo }, skipCSRFCheck) + } + + return { + refreshUserInfo, + getSession: __getSession({ ctx: ctx as any, cookieConfig }), + createSession: __createSession({ ctx: ctx as any, cookieConfig }), + refreshSession: __refreshSession({ ctx: ctx as any, cookieConfig }), + revokeSession: __revokeSession({ ctx: ctx as any, cookieConfig }), + revokeToken: __revokeToken({ ctx: ctx as any, cookieConfig }), + destroySession: __destroySession({ ctx: ctx as any, cookieConfig }), + getProviderTokens: __getProviderTokens({ ctx: ctx as any, cookieConfig }), + isProviderConnected: __isProviderConnected({ ctx: ctx as any, cookieConfig }), + signIn: __signIn({ ctx: ctx as any, cookieConfig }), + oauthCallback: __oauthCallback({ ctx: ctx as any, cookieConfig }), + } +} diff --git a/packages/core/src/session/stateful/utils.ts b/packages/core/src/session/stateful/utils.ts new file mode 100644 index 00000000..72b228de --- /dev/null +++ b/packages/core/src/session/stateful/utils.ts @@ -0,0 +1,29 @@ +import { createFingerprint, getDeviceInfo } from "@/shared/utils.ts" +import type { InternalStatefulContext } from "@/@types/session.ts" + +export const createDevice = ({ ctx: { sessionConfig } }: InternalStatefulContext) => { + return async (userId: string, request: Request) => { + const { userAgent, browser, platform, deviceType, ip, name } = getDeviceInfo(request) + const fingerprint = await createFingerprint(request) + const device = await sessionConfig.adapter.getDeviceByFingerprint(userId, fingerprint) + if (device) { + await sessionConfig.adapter.updateDevice(device.id, { lastSeenAt: new Date() }) + return device + } + + return await sessionConfig.adapter.createDevice({ + userId, + userAgent, + browser, + platform, + type: deviceType, + name, + lastIp: ip, + fingerprint, + firstSeenAt: new Date(), + lastSeenAt: new Date(), + trusted: false, + metadata: null, + }) + } +} diff --git a/packages/core/src/session/strategy.ts b/packages/core/src/session/strategy.ts index c5cea511..ecbd060f 100644 --- a/packages/core/src/session/strategy.ts +++ b/packages/core/src/session/strategy.ts @@ -1,5 +1,5 @@ import { AuraAuthError } from "@/shared/errors.ts" -import { createStatefulStrategy } from "@/session/stateful.ts" +import { createStatefulStrategy } from "@/session/stateful/stateful.ts" import { createStatelessStrategy } from "@/session/stateless.ts" import type { Identities } from "@/identity/index.ts" import type { FromShapeToObject } from "@/@types/utility.ts" From c066b2c8a8721f6dfca7a563f05aafc7c3fd5637 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Fri, 31 Jul 2026 17:39:25 -0500 Subject: [PATCH 2/7] chore: clean up refreshUserInfo function --- packages/core/src/@types/session.ts | 6 + .../src/session/stateful/createSession.ts | 4 +- packages/core/src/session/stateful/index.ts | 44 ++- .../src/session/stateful/oauthCallback.ts | 10 +- .../src/session/stateful/refreshSession.ts | 7 +- .../src/session/stateful/refreshUserInfo.ts | 11 + .../core/src/session/stateful/revokeToken.ts | 2 +- packages/core/src/session/stateful/signIn.ts | 2 +- .../core/src/session/stateful/stateful.ts | 281 ------------------ packages/core/src/session/strategy.ts | 2 +- packages/core/src/shared/utils.ts | 1 + .../core/src/shared/utils/refresh-tokens.ts | 1 + 12 files changed, 67 insertions(+), 304 deletions(-) create mode 100644 packages/core/src/session/stateful/refreshUserInfo.ts delete mode 100644 packages/core/src/session/stateful/stateful.ts diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index 1b0da88e..4d7b4ecb 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -386,5 +386,11 @@ export type CookieManager = ReturnType export interface InternalStatefulContext { ctx: InternalContextForStateful + cookies: () => InternalCookieStoreConfig + cookieConfig: CookieManager +} + +export interface InternalStatelessContext { + ctx: InternalContext cookieConfig: CookieManager } diff --git a/packages/core/src/session/stateful/createSession.ts b/packages/core/src/session/stateful/createSession.ts index 3f9c02d2..e0701ae5 100644 --- a/packages/core/src/session/stateful/createSession.ts +++ b/packages/core/src/session/stateful/createSession.ts @@ -4,9 +4,9 @@ import { createDevice as __createDevice } from "./utils.ts" import type { TypedJWTPayload } from "@aura-stack/jose" import type { InternalStatefulContext, User } from "@/@types/index.ts" -export const __createSession = ({ ctx, cookieConfig }: InternalStatefulContext) => { +export const __createSession = ({ ctx, cookies, cookieConfig }: InternalStatefulContext) => { const { logger, sessionConfig } = ctx - const createDevice = __createDevice({ ctx, cookieConfig }) + const createDevice = __createDevice({ ctx, cookies, cookieConfig }) return async (session: TypedJWTPayload, request: Request) => { logger?.log("STATEFUL_CREATE_SESSION_START", { diff --git a/packages/core/src/session/stateful/index.ts b/packages/core/src/session/stateful/index.ts index d72271ef..e40591d8 100644 --- a/packages/core/src/session/stateful/index.ts +++ b/packages/core/src/session/stateful/index.ts @@ -1,10 +1,34 @@ -export * from "@/session/stateful/createSession.ts" -export * from "@/session/stateful/destroySession.ts" -export * from "@/session/stateful/getProviderTokens.ts" -export * from "@/session/stateful/getSession.ts" -export * from "@/session/stateful/isProviderConnected.ts" -export * from "@/session/stateful/oauthCallback.ts" -export * from "@/session/stateful/refreshSession.ts" -export * from "@/session/stateful/revokeSession.ts" -export * from "@/session/stateful/revokeToken.ts" -export * from "@/session/stateful/signIn.ts" +import { __createSession } from "@/session/stateful/createSession.ts" +import { __destroySession } from "@/session/stateful/destroySession.ts" +import { __getProviderTokens } from "@/session/stateful/getProviderTokens.ts" +import { __getSession } from "@/session/stateful/getSession.ts" +import { __isProviderConnected } from "@/session/stateful/isProviderConnected.ts" +import { __oauthCallback } from "@/session/stateful/oauthCallback.ts" +import { __refreshSession } from "@/session/stateful/refreshSession.ts" +import { __refreshUserInfo } from "@/session/stateful/refreshUserInfo.ts" +import { __revokeSession } from "@/session/stateful/revokeSession.ts" +import { __revokeToken } from "@/session/stateful/revokeToken.ts" +import { __signIn } from "@/session/stateful/signIn.ts" +import { createCookieManager } from "@/session/cookie-manager.ts" +import type { DatabaseStrategyOptions, SessionStrategy, User } from "@/@types/session.ts" + +export const createStatefulStrategy = ({ + cookies, + ctx, +}: DatabaseStrategyOptions): SessionStrategy => { + const cookieConfig = createCookieManager(cookies) + + return { + refreshUserInfo: __refreshUserInfo({ ctx: ctx as any, cookies, cookieConfig }), + getSession: __getSession({ ctx: ctx as any, cookies, cookieConfig }), + createSession: __createSession({ ctx: ctx as any, cookies, cookieConfig }), + refreshSession: __refreshSession({ ctx: ctx as any, cookies, cookieConfig }), + revokeSession: __revokeSession({ ctx: ctx as any, cookies, cookieConfig }), + revokeToken: __revokeToken({ ctx: ctx as any, cookies, cookieConfig }), + destroySession: __destroySession({ ctx: ctx as any, cookies, cookieConfig }), + getProviderTokens: __getProviderTokens({ ctx: ctx as any, cookies, cookieConfig }), + isProviderConnected: __isProviderConnected({ ctx: ctx as any, cookies, cookieConfig }), + signIn: __signIn({ ctx: ctx as any, cookies, cookieConfig }), + oauthCallback: __oauthCallback({ ctx: ctx as any, cookies, cookieConfig }), + } +} diff --git a/packages/core/src/session/stateful/oauthCallback.ts b/packages/core/src/session/stateful/oauthCallback.ts index d9bfdcda..956ebc66 100644 --- a/packages/core/src/session/stateful/oauthCallback.ts +++ b/packages/core/src/session/stateful/oauthCallback.ts @@ -9,9 +9,9 @@ import type { InternalStatefulContext } from "@/@types/session.ts" import { createDevice as __createDevice } from "./utils.ts" import { HeadersBuilder } from "@aura-stack/router" -export const __oauthCallback = ({ ctx, cookieConfig }: InternalStatefulContext) => { - const { logger, jose, oauth, cookies, sessionConfig } = ctx - const createDevice = __createDevice({ ctx, cookieConfig }) +export const __oauthCallback = ({ ctx, cookies, cookieConfig }: InternalStatefulContext) => { + const { logger, jose, oauth, sessionConfig } = ctx + const createDevice = __createDevice({ ctx, cookies, cookieConfig }) return async (oauthId: string, request: Request, { code, state }: { code: string; state: string }) => { const oauthConfig = oauth[oauthId] @@ -234,8 +234,8 @@ export const __oauthCallback = ({ ctx, cookieConfig }: InternalStatefulContext) const headersBuilder = new HeadersBuilder() .setHeader("Location", transaction.redirectTo || "/") - .setCookie(cookies.sessionToken.name, tokenHash, cookies.sessionToken.attributes) - .setCookie(cookies.csrfToken.name, csrfToken, cookies.csrfToken.attributes) + .setCookie(cookies().sessionToken.name, tokenHash, cookies().sessionToken.attributes) + .setCookie(cookies().csrfToken.name, csrfToken, cookies().csrfToken.attributes) return Response.json({ oauth: oauthId }, { status: 302, headers: headersBuilder.toHeaders() }) } diff --git a/packages/core/src/session/stateful/refreshSession.ts b/packages/core/src/session/stateful/refreshSession.ts index ec5c1141..c1bfd8f7 100644 --- a/packages/core/src/session/stateful/refreshSession.ts +++ b/packages/core/src/session/stateful/refreshSession.ts @@ -3,8 +3,8 @@ import type { DeepPartial } from "@/@types/utility.ts" import { secureApiHeaders } from "@/shared/headers.ts" import { getErrorName, verifyCSRFToken } from "@/shared/utils.ts" -export const __refreshSession = ({ ctx, cookieConfig }: InternalStatefulContext) => { - const { logger, sessionConfig, jose, cookies } = ctx +export const __refreshSession = ({ ctx, cookies, cookieConfig }: InternalStatefulContext) => { + const { logger, sessionConfig, jose } = ctx return async ( headers: Headers, @@ -50,7 +50,7 @@ export const __refreshSession = ({ ctx, cookieConfig } headers, skipCSRFCheck, jose, - cookies, + cookies: cookies(), logger, }) @@ -229,6 +229,7 @@ export const __refreshSession = ({ ctx, cookieConfig } return { session: updatedSession, headers: new Headers(secureApiHeaders) } } catch (error) { + console.error("Error refreshing session:", error) logger?.log("STATEFUL_REFRESH_SESSION_ERROR", { structuredData: { error_type: getErrorName(error), diff --git a/packages/core/src/session/stateful/refreshUserInfo.ts b/packages/core/src/session/stateful/refreshUserInfo.ts new file mode 100644 index 00000000..24c6799e --- /dev/null +++ b/packages/core/src/session/stateful/refreshUserInfo.ts @@ -0,0 +1,11 @@ +import { __refreshSession } from "./refreshSession.ts" +import type { InternalStatefulContext, User } from "@/@types/session.ts" + +export const __refreshUserInfo = (ctx: InternalStatefulContext) => { + const refreshSession = __refreshSession(ctx) + + return async (userInfo: Partial, headers: Headers, skipCSRFCheck?: boolean) => { + const value = await refreshSession(headers, { user: userInfo }, skipCSRFCheck) + return value as any + } +} diff --git a/packages/core/src/session/stateful/revokeToken.ts b/packages/core/src/session/stateful/revokeToken.ts index 42b43298..47e25a31 100644 --- a/packages/core/src/session/stateful/revokeToken.ts +++ b/packages/core/src/session/stateful/revokeToken.ts @@ -7,7 +7,7 @@ import { getErrorName, toUnionHeaders } from "@/shared/utils.ts" import type { InternalStatefulContext } from "@/@types/session.ts" export const __revokeToken = ({ ctx, cookieConfig }: InternalStatefulContext) => { - const { oauth, logger, sessionConfig, cookies, jose } = ctx + const { oauth, logger, sessionConfig, cookies } = ctx return async (oauthId: string, headers: Headers, disconnect: boolean): Promise => { logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { diff --git a/packages/core/src/session/stateful/signIn.ts b/packages/core/src/session/stateful/signIn.ts index d4560b59..65698d93 100644 --- a/packages/core/src/session/stateful/signIn.ts +++ b/packages/core/src/session/stateful/signIn.ts @@ -6,7 +6,7 @@ import { createAuthorizationURL, createRedirectTo, createRedirectURI } from "@/s import { HeadersBuilder } from "@aura-stack/router" import type { InternalStatefulContext } from "@/@types/session.ts" -export const __signIn = ({ ctx, cookieConfig }: InternalStatefulContext) => { +export const __signIn = ({ ctx }: InternalStatefulContext) => { const { logger, oauth, sessionConfig } = ctx return async (oauthId: string, request: Request, redirectTo?: string) => { diff --git a/packages/core/src/session/stateful/stateful.ts b/packages/core/src/session/stateful/stateful.ts deleted file mode 100644 index ad651eec..00000000 --- a/packages/core/src/session/stateful/stateful.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { secureApiHeaders } from "@/shared/headers.ts" -import { verifyCSRFToken, getErrorName } from "@/shared/utils.ts" -import { createCookieManager } from "@/session/cookie-manager.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, Session, SessionStrategy, User } from "@/@types/session.ts" -import { - __getSession, - __createSession, - __oauthCallback, - __signIn, - __revokeSession, - __isProviderConnected, - __getProviderTokens, - __destroySession, - __refreshSession, - __revokeToken, - __refreshUserInfo, -} from "@/session/stateful/index.ts" - -export const createStatefulStrategy = ({ - config, - cookies, - ctx, - logger, - jose, -}: DatabaseStrategyOptions): SessionStrategy => { - const cookieConfig = createCookieManager(cookies) - - 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 { attributes, ...spreadUser } = sessionByToken.user - const currentUser = { ...spreadUser, ...attributes, sub: sessionByToken.user.id } - logger?.log("STATEFUL_USER_DATA_MERGED", { - structuredData: { - user_id: currentUser.id, - has_attributes: Boolean(attributes), - }, - }) - - const parsedCurrentUser = ctx.identity.skipValidation - ? currentUser - : await ctx.identity.schemaRegistry.parse(currentUser) - logger?.log("STATEFUL_USER_VALIDATION", { - structuredData: { - validation_skipped: ctx.identity.skipValidation || false, - user_id: currentUser.id, - }, - }) - - const sessionPayload = ctx.identity.skipValidation - ? session.user - : await ctx.identity.schemaRegistry.parseAsPartial(session.user) - - logger?.log("STATEFUL_SESSION_UPDATE_PAYLOAD", { - structuredData: { - has_update_payload: Boolean(sessionPayload), - user_id: currentUser.id, - }, - }) - - const updatedUser = { - ...parsedCurrentUser, - ...sessionPayload, - sub: parsedCurrentUser.sub, - } - - logger?.log("STATEFUL_USER_FIELDS_MERGED", { - structuredData: { - user_id: updatedUser.id, - fields_updated: Object.keys(sessionPayload || {}).join(","), - }, - }) - - const validatedUser = ctx.identity.skipValidation ? updatedUser : await ctx.identity.schemaRegistry.parse(updatedUser) - logger?.log("STATEFUL_UPDATED_USER_VALIDATED", { - structuredData: { - user_id: validatedUser.id, - validation_skipped: ctx.identity.skipValidation || false, - }, - }) - - if (sessionPayload && Object.keys(sessionPayload).length > 0) { - const { sub: _sub, ...userUpdateFields } = validatedUser - await config.adapter.updateUser(sessionByToken.userId, userUpdateFields as any) - logger?.log("STATEFUL_USER_UPDATED_IN_DB", { - structuredData: { - user_id: sessionByToken.userId, - fields_updated: Object.keys(userUpdateFields).join(","), - }, - }) - } - - 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: validatedUser 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 refreshUserInfo = async (userInfo: TypedJWTPayload, headers: Headers, skipCSRFCheck: boolean) => { - return await refreshSession(headers, { user: userInfo }, skipCSRFCheck) - } - - return { - refreshUserInfo, - getSession: __getSession({ ctx: ctx as any, cookieConfig }), - createSession: __createSession({ ctx: ctx as any, cookieConfig }), - refreshSession: __refreshSession({ ctx: ctx as any, cookieConfig }), - revokeSession: __revokeSession({ ctx: ctx as any, cookieConfig }), - revokeToken: __revokeToken({ ctx: ctx as any, cookieConfig }), - destroySession: __destroySession({ ctx: ctx as any, cookieConfig }), - getProviderTokens: __getProviderTokens({ ctx: ctx as any, cookieConfig }), - isProviderConnected: __isProviderConnected({ ctx: ctx as any, cookieConfig }), - signIn: __signIn({ ctx: ctx as any, cookieConfig }), - oauthCallback: __oauthCallback({ ctx: ctx as any, cookieConfig }), - } -} diff --git a/packages/core/src/session/strategy.ts b/packages/core/src/session/strategy.ts index ecbd060f..e9127cf8 100644 --- a/packages/core/src/session/strategy.ts +++ b/packages/core/src/session/strategy.ts @@ -1,5 +1,5 @@ import { AuraAuthError } from "@/shared/errors.ts" -import { createStatefulStrategy } from "@/session/stateful/stateful.ts" +import { createStatefulStrategy } from "@/session/stateful/index.ts" import { createStatelessStrategy } from "@/session/stateless.ts" import type { Identities } from "@/identity/index.ts" import type { FromShapeToObject } from "@/@types/utility.ts" diff --git a/packages/core/src/shared/utils.ts b/packages/core/src/shared/utils.ts index f4c9a908..b2242594 100644 --- a/packages/core/src/shared/utils.ts +++ b/packages/core/src/shared/utils.ts @@ -170,6 +170,7 @@ export const verifyCSRFToken = async ({ csrfToken = getCookie(headers, cookies.csrfToken.name) } catch (cause) { logger?.log("CSRF_TOKEN_MISSING") + console.error("CSRF token missing:", headers, cookies) throw new AuraAuthError({ code: "CSRF_TOKEN_MISSING", cause }) } logger?.log("CSRF_TOKEN_REQUESTED", { diff --git a/packages/core/src/shared/utils/refresh-tokens.ts b/packages/core/src/shared/utils/refresh-tokens.ts index 86d546cb..51ce59af 100644 --- a/packages/core/src/shared/utils/refresh-tokens.ts +++ b/packages/core/src/shared/utils/refresh-tokens.ts @@ -52,6 +52,7 @@ export const refreshProviderToken = async ( return { accessToken: data.access_token ?? payload.accessToken, expiresAt: now + (data.expires_in ?? 3600), + accessTokenExpiresAt: now + (data.expires_in ?? 3600), refreshToken: data.refresh_token ?? payload.refreshToken, refreshTokenExpiresAt: data.refresh_token_expires_in ? now + data.refresh_token_expires_in From 6d7a6489f74e2d8c994f88d002f6b94453abed19 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Fri, 31 Jul 2026 20:02:20 -0500 Subject: [PATCH 3/7] chore: fix logic and clean up code --- packages/core/src/@types/session.ts | 6 +- .../src/session/stateful/createSession.ts | 10 +-- .../src/session/stateful/destroySession.ts | 6 +- .../src/session/stateful/getProviderTokens.ts | 30 +++---- .../core/src/session/stateful/getSession.ts | 12 +-- packages/core/src/session/stateful/index.ts | 24 +++--- .../session/stateful/isProviderConnected.ts | 9 ++- .../src/session/stateful/oauthCallback.ts | 11 ++- .../src/session/stateful/refreshSession.ts | 26 ++++--- .../core/src/session/stateful/revokeToken.ts | 18 ++++- packages/core/src/session/stateful/signIn.ts | 13 ++-- packages/core/src/shared/errors.ts | 9 +++ packages/core/src/shared/logger.ts | 6 ++ .../core/src/shared/utils/refresh-tokens.ts | 1 - .../providers/tokens/revoke/stateful.test.ts | 40 ++++++++-- .../providers/tokens/tokens/stateful.test.ts | 59 +++++++------- .../providers/user/refresh/stateful.test.ts | 2 +- .../test/api/stateful/getAccessToken.test.ts | 61 ++++++++------- .../api/stateful/getProviderTokens.test.ts | 61 ++++++++------- .../test/api/stateful/refreshUserInfo.test.ts | 2 +- .../test/api/stateful/revokeToken.test.ts | 78 ++++++++++++++++--- .../test/api/stateful/updateSession.test.ts | 2 +- packages/core/test/presets.ts | 1 + 23 files changed, 305 insertions(+), 182 deletions(-) diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index 4d7b4ecb..cd26c006 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -351,7 +351,7 @@ export interface OAuthTokenPayload { * @deprecated */ expiresAt: number - accessTokenExpiresAt: number + accessTokenExpiresAt?: number /** * The raw refresh token string issued by the OAuth provider, if applicable. */ @@ -387,10 +387,10 @@ export type CookieManager = ReturnType export interface InternalStatefulContext { ctx: InternalContextForStateful cookies: () => InternalCookieStoreConfig - cookieConfig: CookieManager + cookieManager: CookieManager } export interface InternalStatelessContext { ctx: InternalContext - cookieConfig: CookieManager + cookieManager: CookieManager } diff --git a/packages/core/src/session/stateful/createSession.ts b/packages/core/src/session/stateful/createSession.ts index e0701ae5..7e9cd2cb 100644 --- a/packages/core/src/session/stateful/createSession.ts +++ b/packages/core/src/session/stateful/createSession.ts @@ -1,12 +1,12 @@ import { AuraAuthError } from "@/shared/errors.ts" import { createHash, createSecretValue } from "@/shared/crypto.ts" -import { createDevice as __createDevice } from "./utils.ts" +import { createDevice as __createDevice } from "@/session/stateful/utils.ts" import type { TypedJWTPayload } from "@aura-stack/jose" import type { InternalStatefulContext, User } from "@/@types/index.ts" -export const __createSession = ({ ctx, cookies, cookieConfig }: InternalStatefulContext) => { +export const __createSession = ({ ctx, cookies, cookieManager }: InternalStatefulContext) => { const { logger, sessionConfig } = ctx - const createDevice = __createDevice({ ctx, cookies, cookieConfig }) + const createDevice = __createDevice({ ctx, cookies, cookieManager }) return async (session: TypedJWTPayload, request: Request) => { logger?.log("STATEFUL_CREATE_SESSION_START", { @@ -87,7 +87,7 @@ export const __createSession = ({ ctx, cookies, cookie logger?.log("STATEFUL_USER_CREATED", { structuredData: { user_id: user.id, - email: user.email || "", + has_email: Boolean(user.email), }, }) } else { @@ -106,7 +106,7 @@ export const __createSession = ({ ctx, cookies, cookie logger?.log("STATEFUL_USER_UPDATED", { structuredData: { user_id: user.id, - email: user.email || "", + has_email: Boolean(user.email), }, }) } diff --git a/packages/core/src/session/stateful/destroySession.ts b/packages/core/src/session/stateful/destroySession.ts index f595f774..f8f7e6ba 100644 --- a/packages/core/src/session/stateful/destroySession.ts +++ b/packages/core/src/session/stateful/destroySession.ts @@ -1,7 +1,7 @@ import { getErrorName, verifyCSRFToken } from "@/shared/utils.ts" import type { InternalStatefulContext } from "@/@types/session.ts" -export const __destroySession = ({ ctx, cookieConfig }: InternalStatefulContext) => { +export const __destroySession = ({ ctx, cookieManager }: InternalStatefulContext) => { const { logger, sessionConfig, cookies, jose } = ctx return async (headers: Headers, skipCSRFCheck: boolean = false) => { @@ -21,7 +21,7 @@ export const __destroySession = ({ ctx, cookieConfig }: InternalStatefulContext) }) try { - const { sessionToken } = cookieConfig.getCookie(headers) + const { sessionToken } = cookieManager.getCookie(headers) logger?.log("STATEFUL_SESSION_TOKEN_EXTRACTED", { structuredData: { has_token: Boolean(sessionToken), @@ -70,7 +70,7 @@ export const __destroySession = ({ ctx, cookieConfig }: InternalStatefulContext) throw error } - const clearedHeaders = cookieConfig.clear() + const clearedHeaders = cookieManager.clear() logger?.log("STATEFUL_DESTROY_SESSION_SUCCESS", { structuredData: { cookies_cleared: true, diff --git a/packages/core/src/session/stateful/getProviderTokens.ts b/packages/core/src/session/stateful/getProviderTokens.ts index 1fc63ccd..eb58d1e7 100644 --- a/packages/core/src/session/stateful/getProviderTokens.ts +++ b/packages/core/src/session/stateful/getProviderTokens.ts @@ -4,10 +4,15 @@ import { getErrorName, shouldRefresh } from "@/shared/utils.ts" import { handleApiError } from "@/shared/utils/api.ts" import { refreshProviderToken } from "@/shared/utils/refresh-tokens.ts" -export const __getProviderTokens = ({ ctx, cookieConfig }: InternalStatefulContext) => { +export const __getProviderTokens = ({ ctx, cookieManager }: InternalStatefulContext) => { const { oauth, logger, sessionConfig } = ctx return async (oauthId: string, request: Request): Promise => { + const provider = oauth[oauthId] + if (!provider) { + throw new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" }) + } + logger?.log("STATEFUL_GET_PROVIDER_TOKENS_START", { structuredData: { strategy: "stateful", @@ -17,7 +22,7 @@ export const __getProviderTokens = ({ ctx, cookieConfig }: InternalStatefulConte }) try { - const { sessionToken } = cookieConfig.getCookie(new Headers(request.headers)) + const { sessionToken } = cookieManager.getCookie(new Headers(request.headers)) if (!sessionToken) { logger?.log("STATEFUL_GET_PROVIDER_TOKENS_NO_SESSION", { structuredData: { @@ -29,7 +34,7 @@ export const __getProviderTokens = ({ ctx, cookieConfig }: InternalStatefulConte "PROVIDER_TOKENS_ERROR", "Failed to get provider tokens" ) - return { success: false, error: { code, message }, tokens: null, headers: cookieConfig.clear(), statusCode } + return { success: false, error: { code, message }, tokens: null, headers: cookieManager.clear(), statusCode } } const sessionByToken = await sessionConfig.adapter.getSessionByToken(sessionToken) @@ -44,7 +49,7 @@ export const __getProviderTokens = ({ ctx, cookieConfig }: InternalStatefulConte "PROVIDER_TOKENS_ERROR", "Failed to get provider tokens" ) - return { success: false, error: { code, message }, tokens: null, headers: cookieConfig.clear(), statusCode } + return { success: false, error: { code, message }, tokens: null, headers: cookieManager.clear(), statusCode } } const isExpired = Date.now() > sessionByToken.expiresAt.getTime() @@ -57,7 +62,7 @@ export const __getProviderTokens = ({ ctx, cookieConfig }: InternalStatefulConte "PROVIDER_TOKENS_ERROR", "Failed to get provider tokens" ) - return { success: false, error: { code, message }, tokens: null, headers: cookieConfig.clear(), statusCode } + return { success: false, error: { code, message }, tokens: null, headers: cookieManager.clear(), statusCode } } logger?.log("STATEFUL_GET_PROVIDER_TOKENS_SESSION_FOUND", { @@ -79,7 +84,7 @@ export const __getProviderTokens = ({ ctx, cookieConfig }: InternalStatefulConte }, }) const { code, message, statusCode } = handleApiError( - new AuraAuthError({ code: "COOKIE_INVALID_VALUE" }), + new AuraAuthError({ code: "OAUTH_UNLINKED_ACCOUNT_ERROR" }), "PROVIDER_TOKENS_ERROR", "Failed to get provider tokens" ) @@ -114,17 +119,6 @@ export const __getProviderTokens = ({ ctx, cookieConfig }: InternalStatefulConte }, }) - const provider = oauth?.[oauthId] - if (!provider) { - logger?.log("STATEFUL_GET_PROVIDER_TOKENS_PROVIDER_NOT_FOUND", { - structuredData: { - oauth_id: oauthId, - reason: "provider_not_configured", - }, - }) - return { success: true, tokens: tokens as any, headers: request.headers } - } - const refreshWindow = provider.refreshWindow ?? 300 const needsRefresh = shouldRefresh(tokens as any, refreshWindow) @@ -154,7 +148,7 @@ export const __getProviderTokens = ({ ctx, cookieConfig }: InternalStatefulConte }, }) - await sessionConfig.adapter.updateOAuthTokens(oauthId, { + await sessionConfig.adapter.updateOAuthTokens(getAccount?.id as string, { accountId: oauthAccount.accountId, accessToken: refreshedTokens.accessToken, refreshToken: refreshedTokens.refreshToken, diff --git a/packages/core/src/session/stateful/getSession.ts b/packages/core/src/session/stateful/getSession.ts index a2936def..f6246cd8 100644 --- a/packages/core/src/session/stateful/getSession.ts +++ b/packages/core/src/session/stateful/getSession.ts @@ -3,7 +3,7 @@ import { AuraAuthError } from "@/shared/errors.ts" import { secureApiHeaders } from "@/shared/headers.ts" import type { GetStatefulSessionReturn, User, InternalStatefulContext } from "@/@types/index.ts" -export const __getSession = ({ ctx, cookieConfig }: InternalStatefulContext) => { +export const __getSession = ({ ctx, cookieManager }: InternalStatefulContext) => { const { logger, sessionConfig } = ctx return async (headers: Headers): Promise> => { @@ -15,7 +15,7 @@ export const __getSession = ({ ctx, cookieConfig }: In }) try { - const { sessionToken } = cookieConfig.getCookie(headers) + const { sessionToken } = cookieManager.getCookie(headers) logger?.log("STATEFUL_SESSION_TOKEN_EXTRACTED", { structuredData: { @@ -82,7 +82,7 @@ export const __getSession = ({ ctx, cookieConfig }: In }) return { session: null, - headers: new Headers(secureApiHeaders), + headers: cookieManager.clear(), } } @@ -97,7 +97,7 @@ export const __getSession = ({ ctx, cookieConfig }: In return { session: null, - headers: cookieConfig.clear(), + headers: cookieManager.clear(), } } @@ -131,7 +131,7 @@ export const __getSession = ({ ctx, cookieConfig }: In user: parsedUser as DefaultUser, expires: session.expiresAt.toISOString(), }, - headers: cookieConfig.setCookie({ sessionToken }), + headers: cookieManager.setCookie({ sessionToken }), } } catch (error) { logger?.log("STATEFUL_GET_SESSION_ERROR", { @@ -142,7 +142,7 @@ export const __getSession = ({ ctx, cookieConfig }: In }) return { session: null, - headers: cookieConfig.clear(), + headers: cookieManager.clear(), } } } diff --git a/packages/core/src/session/stateful/index.ts b/packages/core/src/session/stateful/index.ts index e40591d8..f94c4349 100644 --- a/packages/core/src/session/stateful/index.ts +++ b/packages/core/src/session/stateful/index.ts @@ -16,19 +16,19 @@ export const createStatefulStrategy = ({ cookies, ctx, }: DatabaseStrategyOptions): SessionStrategy => { - const cookieConfig = createCookieManager(cookies) + const cookieManager = createCookieManager(cookies) return { - refreshUserInfo: __refreshUserInfo({ ctx: ctx as any, cookies, cookieConfig }), - getSession: __getSession({ ctx: ctx as any, cookies, cookieConfig }), - createSession: __createSession({ ctx: ctx as any, cookies, cookieConfig }), - refreshSession: __refreshSession({ ctx: ctx as any, cookies, cookieConfig }), - revokeSession: __revokeSession({ ctx: ctx as any, cookies, cookieConfig }), - revokeToken: __revokeToken({ ctx: ctx as any, cookies, cookieConfig }), - destroySession: __destroySession({ ctx: ctx as any, cookies, cookieConfig }), - getProviderTokens: __getProviderTokens({ ctx: ctx as any, cookies, cookieConfig }), - isProviderConnected: __isProviderConnected({ ctx: ctx as any, cookies, cookieConfig }), - signIn: __signIn({ ctx: ctx as any, cookies, cookieConfig }), - oauthCallback: __oauthCallback({ ctx: ctx as any, cookies, cookieConfig }), + refreshUserInfo: __refreshUserInfo({ ctx: ctx as any, cookies, cookieManager }), + getSession: __getSession({ ctx: ctx as any, cookies, cookieManager }), + createSession: __createSession({ ctx: ctx as any, cookies, cookieManager }), + refreshSession: __refreshSession({ ctx: ctx as any, cookies, cookieManager }), + revokeSession: __revokeSession({ ctx: ctx as any, cookies, cookieManager }), + revokeToken: __revokeToken({ ctx: ctx as any, cookies, cookieManager }), + destroySession: __destroySession({ ctx: ctx as any, cookies, cookieManager }), + getProviderTokens: __getProviderTokens({ ctx: ctx as any, cookies, cookieManager }), + isProviderConnected: __isProviderConnected({ ctx: ctx as any, cookies, cookieManager }), + signIn: __signIn({ ctx: ctx as any, cookies, cookieManager }), + oauthCallback: __oauthCallback({ ctx: ctx as any, cookies, cookieManager }), } } diff --git a/packages/core/src/session/stateful/isProviderConnected.ts b/packages/core/src/session/stateful/isProviderConnected.ts index 09017247..afcca246 100644 --- a/packages/core/src/session/stateful/isProviderConnected.ts +++ b/packages/core/src/session/stateful/isProviderConnected.ts @@ -1,7 +1,7 @@ import type { InternalStatefulContext } from "@/@types/session.ts" import { getErrorName } from "@/shared/utils.ts" -export const __isProviderConnected = ({ ctx, cookieConfig }: InternalStatefulContext) => { +export const __isProviderConnected = ({ ctx, cookieManager }: InternalStatefulContext) => { const { logger, sessionConfig } = ctx return async (oauthId: string, headers: Headers): Promise => { @@ -13,7 +13,7 @@ export const __isProviderConnected = ({ ctx, cookieConfig }: InternalStatefulCon }) try { - const { sessionToken } = cookieConfig.getCookie(headers) + const { sessionToken } = cookieManager.getCookie(headers) if (!sessionToken) { logger?.log("SESSION_TOKEN_MISSING", { structuredData: { @@ -36,7 +36,7 @@ export const __isProviderConnected = ({ ctx, cookieConfig }: InternalStatefulCon const isExpired = Date.now() > sessionByToken.expiresAt.getTime() if (sessionByToken.status !== "active" || isExpired) { if (isExpired) { - await sessionConfig.adapter.revokeSession(sessionByToken.id, "user_logout") + await sessionConfig.adapter.revokeSession(sessionByToken.id, "account_suspended") } logger?.log("AUTH_SESSION_INVALID", { structuredData: { @@ -76,6 +76,9 @@ export const __isProviderConnected = ({ ctx, cookieConfig }: InternalStatefulCon return isConnected } catch (error) { + /** + * @todo returns the error to the user in a structured way. + */ logger?.log("OAUTH_ACCESS_TOKEN_ERROR", { structuredData: { provider: oauthId, diff --git a/packages/core/src/session/stateful/oauthCallback.ts b/packages/core/src/session/stateful/oauthCallback.ts index 956ebc66..84763e53 100644 --- a/packages/core/src/session/stateful/oauthCallback.ts +++ b/packages/core/src/session/stateful/oauthCallback.ts @@ -9,9 +9,9 @@ import type { InternalStatefulContext } from "@/@types/session.ts" import { createDevice as __createDevice } from "./utils.ts" import { HeadersBuilder } from "@aura-stack/router" -export const __oauthCallback = ({ ctx, cookies, cookieConfig }: InternalStatefulContext) => { +export const __oauthCallback = ({ ctx, cookies, cookieManager }: InternalStatefulContext) => { const { logger, jose, oauth, sessionConfig } = ctx - const createDevice = __createDevice({ ctx, cookies, cookieConfig }) + const createDevice = __createDevice({ ctx, cookies, cookieManager }) return async (oauthId: string, request: Request, { code, state }: { code: string; state: string }) => { const oauthConfig = oauth[oauthId] @@ -151,7 +151,7 @@ export const __oauthCallback = ({ ctx, cookies, cookieConfig }: InternalStateful attributes, }) } else { - const { email, image, name, ...attributes } = userInfo + const { sub: _sub, email, image, name, ...attributes } = userInfo const newUser = await sessionConfig.adapter.createUser({ id: crypto.randomUUID(), email: email, @@ -170,6 +170,11 @@ export const __oauthCallback = ({ ctx, cookies, cookieConfig }: InternalStateful const account = await sessionConfig.adapter.getAccountByProvider(oauthId, userInfo.sub) if (account) { + if (account.userId !== userId) { + logger?.log("OAUTH_ACCOUNT_USER_MISMATCH") + throw new AuraAuthError({ code: "OAUTH_ACCOUNT_USER_MISMATCH" }) + } + accountId = account.id await sessionConfig.adapter.updateOAuthTokens(account.id, { accessToken: accessToken.access_token, diff --git a/packages/core/src/session/stateful/refreshSession.ts b/packages/core/src/session/stateful/refreshSession.ts index c1bfd8f7..f6c5c696 100644 --- a/packages/core/src/session/stateful/refreshSession.ts +++ b/packages/core/src/session/stateful/refreshSession.ts @@ -1,9 +1,9 @@ -import type { InternalStatefulContext, Session, User } from "@/@types/session.ts" -import type { DeepPartial } from "@/@types/utility.ts" -import { secureApiHeaders } from "@/shared/headers.ts" import { getErrorName, verifyCSRFToken } from "@/shared/utils.ts" +import { createHash, createSecretValue } from "@/shared/crypto.ts" +import type { DeepPartial } from "@/@types/utility.ts" +import type { InternalStatefulContext, Session, User } from "@/@types/session.ts" -export const __refreshSession = ({ ctx, cookies, cookieConfig }: InternalStatefulContext) => { +export const __refreshSession = ({ ctx, cookies, cookieManager }: InternalStatefulContext) => { const { logger, sessionConfig, jose } = ctx return async ( @@ -23,7 +23,7 @@ export const __refreshSession = ({ ctx, cookies, cooki }) try { - const { sessionToken } = cookieConfig.getCookie(headers) + const { sessionToken } = cookieManager.getCookie(headers) logger?.log("STATEFUL_SESSION_TOKEN_EXTRACTED", { structuredData: { has_token: Boolean(sessionToken), @@ -37,7 +37,7 @@ export const __refreshSession = ({ ctx, cookies, cooki reason: "no_session_token_in_cookie", }, }) - return { session: null, headers: cookieConfig.clear() } + return { session: null, headers: cookieManager.clear() } } logger?.log("STATEFUL_CSRF_VERIFICATION_START", { @@ -66,7 +66,7 @@ export const __refreshSession = ({ ctx, cookies, cooki reason: "csrf_token_invalid", }, }) - return { session: null, headers: cookieConfig.clear() } + return { session: null, headers: cookieManager.clear() } } const sessionByToken = await sessionConfig.adapter.getSessionByToken(sessionToken) @@ -84,11 +84,11 @@ export const __refreshSession = ({ ctx, cookies, cooki reason: "session_not_found_or_no_user", }, }) - return { session: null, headers: cookieConfig.clear() } + return { session: null, headers: cookieManager.clear() } } if (sessionByToken.status !== "active") { - return { session: null, headers: cookieConfig.clear() } + return { session: null, headers: cookieManager.clear() } } logger?.log("STATEFUL_SESSION_EXPIRATION_CHECK", { @@ -113,7 +113,7 @@ export const __refreshSession = ({ ctx, cookies, cooki reason: "session_expired", }, }) - return { session: null, headers: cookieConfig.clear() } + return { session: null, headers: cookieManager.clear() } } const { attributes, ...spreadUser } = sessionByToken.user @@ -227,7 +227,9 @@ export const __refreshSession = ({ ctx, cookies, cooki }, }) - return { session: updatedSession, headers: new Headers(secureApiHeaders) } + const secretValue = createSecretValue(64) + const tokenHash = await createHash(secretValue) + return { session: updatedSession, headers: cookieManager.setCookie({ sessionToken: tokenHash }) } } catch (error) { console.error("Error refreshing session:", error) logger?.log("STATEFUL_REFRESH_SESSION_ERROR", { @@ -236,7 +238,7 @@ export const __refreshSession = ({ ctx, cookies, cooki error_message: error instanceof Error ? error.message : String(error), }, }) - return { session: null, headers: cookieConfig.clear() } + return { session: null, headers: cookieManager.clear() } } } } diff --git a/packages/core/src/session/stateful/revokeToken.ts b/packages/core/src/session/stateful/revokeToken.ts index 47e25a31..9122269f 100644 --- a/packages/core/src/session/stateful/revokeToken.ts +++ b/packages/core/src/session/stateful/revokeToken.ts @@ -6,7 +6,7 @@ import { getExpiredCookie } from "@/cookie.ts" import { getErrorName, toUnionHeaders } from "@/shared/utils.ts" import type { InternalStatefulContext } from "@/@types/session.ts" -export const __revokeToken = ({ ctx, cookieConfig }: InternalStatefulContext) => { +export const __revokeToken = ({ ctx, cookieManager }: InternalStatefulContext) => { const { oauth, logger, sessionConfig, cookies } = ctx return async (oauthId: string, headers: Headers, disconnect: boolean): Promise => { @@ -19,7 +19,7 @@ export const __revokeToken = ({ ctx, cookieConfig }: InternalStatefulContext) => }) try { - const { sessionToken } = cookieConfig.getCookie(headers) + const { sessionToken } = cookieManager.getCookie(headers) if (!sessionToken) { logger?.log("SESSION_TOKEN_MISSING", { structuredData: { @@ -59,7 +59,19 @@ export const __revokeToken = ({ ctx, cookieConfig }: InternalStatefulContext) => }, }) - const oauthAccount = await sessionConfig.adapter.getOAuthAccount(oauthId) + const accounts = await sessionConfig.adapter.getAccountsByUserId(sessionByToken.userId) + const account = accounts.find((account) => account.provider === oauthId) + if (!account) { + logger?.log("OAUTH_UNLINKED_ACCOUNT_ERROR", { + structuredData: { + provider: oauthId, + reason: "oauth_account_not_found", + }, + }) + throw new AuraAuthError({ code: "OAUTH_UNLINKED_ACCOUNT_ERROR" }) + } + + const oauthAccount = await sessionConfig.adapter.getOAuthAccount(account?.id) if (!oauthAccount) { logger?.log("OAUTH_UNLINKED_ACCOUNT_ERROR", { structuredData: { diff --git a/packages/core/src/session/stateful/signIn.ts b/packages/core/src/session/stateful/signIn.ts index 65698d93..92125414 100644 --- a/packages/core/src/session/stateful/signIn.ts +++ b/packages/core/src/session/stateful/signIn.ts @@ -1,9 +1,11 @@ +import { HeadersBuilder } from "@aura-stack/router" import { AuraAuthError } from "@/shared/errors.ts" import { secureApiHeaders } from "@/shared/headers.ts" +import { createFingerprint, getDeviceInfo } from "@/shared/utils.ts" +import { createDevice as __createDevice } from "@/session/stateful/utils.ts" import { createOIDCAuthorizationURL } from "@/shared/oidc/authorization-url.ts" import { isOIDCProvider, resolveOpenIDProvider } from "@/shared/oidc/resolve-provider.ts" import { createAuthorizationURL, createRedirectTo, createRedirectURI } from "@/shared/utils/authorization.ts" -import { HeadersBuilder } from "@aura-stack/router" import type { InternalStatefulContext } from "@/@types/session.ts" export const __signIn = ({ ctx }: InternalStatefulContext) => { @@ -23,7 +25,7 @@ export const __signIn = ({ ctx }: InternalStatefulContext) => { structuredData: { oauth_provider: oauthId, oidc: isOIDC }, }) - const resolvedProvider = isOIDC ? await resolveOpenIDProvider(provider!) : provider! + const resolvedProvider = isOIDC ? await resolveOpenIDProvider(provider) : provider if (isOIDC) { logger?.log("OIDC_PROVIDER_RESOLVED", { @@ -53,9 +55,8 @@ export const __signIn = ({ ctx }: InternalStatefulContext) => { structuredData: { oauth_provider: oauthId, oidc: isOIDC }, }) - const userAgent = request.headers.get("user-agent") || null - const fingerprint = request.headers.get("x-device-fingerprint") || null - const deviceId = request.headers.get("x-device-id") || null + const { userAgent } = getDeviceInfo(request) + const fingerprint = await createFingerprint(request) const expiresAt = new Date(Date.now() + 10 * 60 * 1000) await sessionConfig.adapter.createOAuthTransaction({ @@ -68,9 +69,9 @@ export const __signIn = ({ ctx }: InternalStatefulContext) => { redirectTo: redirectToValue, userAgent, fingerprint, - deviceId, createdAt: new Date(), expiresAt, + deviceId: null, metadata: null, }) diff --git a/packages/core/src/shared/errors.ts b/packages/core/src/shared/errors.ts index ed6a2925..eeb840c2 100644 --- a/packages/core/src/shared/errors.ts +++ b/packages/core/src/shared/errors.ts @@ -131,6 +131,7 @@ export const AuraErrorCode = { DATABASE_TOKEN_HASH_NOT_FOUND: "DATABASE_TOKEN_HASH_NOT_FOUND", OAUTH_UNLINKED_ACCOUNT_ERROR: "OAUTH_UNLINKED_ACCOUNT_ERROR", + OAUTH_ACCOUNT_USER_MISMATCH: "OAUTH_ACCOUNT_USER_MISMATCH", } as const export type AuraErrorCode = (typeof AuraErrorCode)[keyof typeof AuraErrorCode] @@ -909,6 +910,14 @@ export const ERROR_CATALOG: Record = { "No linked connection exists between the selected OAuth provider and the active user session. The revocation or unlinking operation was aborted.", userMessage: "The specified identity provider is not connected to your account.", }, + OAUTH_ACCOUNT_USER_MISMATCH: { + type: "AUTH_FLOW", + statusCode: 409, + name: "OAuthError", + message: + "Identity linkage assertion failed. The target OAuth account record is bound to a different user identifier than the currently authenticated session user.", + userMessage: "This identity provider account is already linked to a different user.", + }, } export interface AuraErrorOptions extends ErrorOptions { diff --git a/packages/core/src/shared/logger.ts b/packages/core/src/shared/logger.ts index 19be4549..14ce3829 100644 --- a/packages/core/src/shared/logger.ts +++ b/packages/core/src/shared/logger.ts @@ -819,6 +819,12 @@ export const logMessages = { msgId: "OAUTH_UNLINKED_ACCOUNT_ERROR", message: "Error occurred while unlinking OAuth account from user", }, + OAUTH_ACCOUNT_USER_MISMATCH: { + facility: 4, + severity: "error", + msgId: "OAUTH_ACCOUNT_USER_MISMATCH", + message: "OAuth account does not belong to the authenticated user", + }, } as const export const createLogEntry = (key: T, overrides?: Partial): SyslogOptions => { diff --git a/packages/core/src/shared/utils/refresh-tokens.ts b/packages/core/src/shared/utils/refresh-tokens.ts index 51ce59af..86d546cb 100644 --- a/packages/core/src/shared/utils/refresh-tokens.ts +++ b/packages/core/src/shared/utils/refresh-tokens.ts @@ -52,7 +52,6 @@ export const refreshProviderToken = async ( return { accessToken: data.access_token ?? payload.accessToken, expiresAt: now + (data.expires_in ?? 3600), - accessTokenExpiresAt: now + (data.expires_in ?? 3600), refreshToken: data.refresh_token ?? payload.refreshToken, refreshTokenExpiresAt: data.refresh_token_expires_in ? now + data.refresh_token_expires_in diff --git a/packages/core/test/actions/providers/tokens/revoke/stateful.test.ts b/packages/core/test/actions/providers/tokens/revoke/stateful.test.ts index bcc95029..e230f38a 100644 --- a/packages/core/test/actions/providers/tokens/revoke/stateful.test.ts +++ b/packages/core/test/actions/providers/tokens/revoke/stateful.test.ts @@ -206,6 +206,7 @@ describe("Revoke Action", () => { test("successfully revokes token", async () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) const updateAccountStatusMock = vi.fn().mockResolvedValue(accountEntity) @@ -213,6 +214,7 @@ describe("Revoke Action", () => { handlers: { POST }, } = authInstance({ getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }) @@ -254,12 +256,14 @@ describe("Revoke Action", () => { signal: expect.any(AbortSignal), }) expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") - expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") }) test("successfully revokes token with 204 status", async () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) const updateAccountStatusMock = vi.fn().mockResolvedValue(accountEntity) @@ -267,6 +271,7 @@ describe("Revoke Action", () => { handlers: { POST }, } = authInstance({ getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }) @@ -293,12 +298,14 @@ describe("Revoke Action", () => { expect(response.status).toBe(200) expect(await response.json()).toEqual({ success: true }) expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") - expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") }) test("handles network error during revocation", async () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) const updateAccountStatusMock = vi.fn().mockResolvedValue(accountEntity) @@ -306,6 +313,7 @@ describe("Revoke Action", () => { handlers: { POST }, } = authInstance({ getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }) @@ -329,12 +337,14 @@ describe("Revoke Action", () => { expect(await response.json()).toEqual({ success: false }) expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") - expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") expect(updateAccountStatusMock).not.toHaveBeenCalled() }) test("handles provider returning error response", async () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) const updateAccountStatusMock = vi.fn().mockResolvedValue(accountEntity) @@ -342,6 +352,7 @@ describe("Revoke Action", () => { handlers: { POST }, } = authInstance({ getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }) @@ -367,18 +378,21 @@ describe("Revoke Action", () => { expect(await response.json()).toEqual({ success: false }) expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") - expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") expect(updateAccountStatusMock).not.toHaveBeenCalled() }) test("handles provider returning unexpected status code", async () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) const updateAccountStatusMock = vi.fn().mockResolvedValue(accountEntity) const { handlers: { POST }, } = authInstance({ getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }) @@ -403,18 +417,21 @@ describe("Revoke Action", () => { expect(await response.json()).toEqual({ success: false }) expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") - expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") expect(updateAccountStatusMock).not.toHaveBeenCalled() }) test("handles malformed provider token cookie", async () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) const updateAccountStatusMock = vi.fn().mockResolvedValue(accountEntity) const { handlers: { POST }, } = authInstance({ getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }) @@ -434,7 +451,8 @@ describe("Revoke Action", () => { expect(await response.json()).toEqual({ success: false }) expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") - expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") expect(updateAccountStatusMock).not.toHaveBeenCalled() }) @@ -475,6 +493,7 @@ describe("Revoke Action", () => { test("handles provider with custom revoke token URL", async () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) const updateAccountStatusMock = vi.fn().mockResolvedValue(accountEntity) @@ -488,6 +507,7 @@ describe("Revoke Action", () => { } = authInstance( { getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }, @@ -517,12 +537,14 @@ describe("Revoke Action", () => { expect(mockFetch).toHaveBeenCalledWith("https://custom.example.com/revoke", expect.any(Object)) expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") - expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") }) test("handles provider with custom revoke token config object", async () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) const updateAccountStatusMock = vi.fn().mockResolvedValue(accountEntity) @@ -544,6 +566,7 @@ describe("Revoke Action", () => { } = authInstance( { getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }, @@ -574,7 +597,8 @@ describe("Revoke Action", () => { success: true, }) expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") - expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") expect(mockFetch).toHaveBeenCalled() diff --git a/packages/core/test/actions/providers/tokens/tokens/stateful.test.ts b/packages/core/test/actions/providers/tokens/tokens/stateful.test.ts index ca27f0ed..99fcb4e3 100644 --- a/packages/core/test/actions/providers/tokens/tokens/stateful.test.ts +++ b/packages/core/test/actions/providers/tokens/tokens/stateful.test.ts @@ -126,7 +126,7 @@ describe("tokensAction (Stateful)", async () => { }, }) ) - expect(response.status).toBe(401) + expect(response.status).toBe(400) expect(await response.json()).toEqual({ success: false, tokens: null, @@ -339,15 +339,16 @@ describe("tokensAction (Stateful)", async () => { }) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") - expect(updateOAuthTokensMock).toHaveBeenCalledWith( - "oauth-provider", - expect.objectContaining({ - accountId: "account-123", - accessToken: "new-access-token", - refreshToken: "new-refresh-token", - idToken: "new-id-token", - }) - ) + expect(updateOAuthTokensMock).toHaveBeenCalledWith("account-123", { + accountId: "account-123", + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + scopes: "scope1 scope2", + tokenType: "Bearer", + accessTokenExpiresAt: expect.any(Date), + refreshTokenExpiresAt: expect.any(Date), + }) }) test("refreshToken successfully refreshes tokens with credentials auth", async () => { @@ -453,15 +454,16 @@ describe("tokensAction (Stateful)", async () => { }) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") - expect(updateOAuthTokensMock).toHaveBeenCalledWith( - "oauth-provider", - expect.objectContaining({ - accountId: "account-123", - accessToken: "new-access-token", - refreshToken: "new-refresh-token", - idToken: "new-id-token", - }) - ) + expect(updateOAuthTokensMock).toHaveBeenCalledWith("account-123", { + accountId: "account-123", + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + scopes: "scope1 scope2", + tokenType: "Bearer", + accessTokenExpiresAt: expect.any(Date), + refreshTokenExpiresAt: expect.any(Date), + }) }) test("refreshToken fails when OAuth provider returns an error", async () => { @@ -705,14 +707,15 @@ describe("tokensAction (Stateful)", async () => { expect(mockFetch).toHaveBeenCalledTimes(1) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") - expect(updateOAuthTokensMock).toHaveBeenCalledWith( - "oauth-provider", - expect.objectContaining({ - accountId: "account-123", - accessToken: "brand-new-refreshed-token", - refreshToken: "new-refresh-token", - idToken: "new-id-token", - }) - ) + expect(updateOAuthTokensMock).toHaveBeenCalledWith("account-123", { + accountId: "account-123", + accessToken: "brand-new-refreshed-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + scopes: "scope1 scope2", + tokenType: "Bearer", + accessTokenExpiresAt: expect.any(Date), + refreshTokenExpiresAt: expect.any(Date), + }) }) }) diff --git a/packages/core/test/actions/providers/user/refresh/stateful.test.ts b/packages/core/test/actions/providers/user/refresh/stateful.test.ts index ec4c78a5..422ee1d6 100644 --- a/packages/core/test/actions/providers/user/refresh/stateful.test.ts +++ b/packages/core/test/actions/providers/user/refresh/stateful.test.ts @@ -704,7 +704,7 @@ describe("refreshUserInfo action", () => { expect(revokeSessionMock).not.toHaveBeenCalled() expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") - expect(updateOAuthTokensMock).toHaveBeenCalledWith("oauth-provider", { + expect(updateOAuthTokensMock).toHaveBeenCalledWith("account-123", { accountId: "account-123", accessToken: "access-token", refreshToken: "refresh-token", diff --git a/packages/core/test/api/stateful/getAccessToken.test.ts b/packages/core/test/api/stateful/getAccessToken.test.ts index 188632d4..f0ad9dc6 100644 --- a/packages/core/test/api/stateful/getAccessToken.test.ts +++ b/packages/core/test/api/stateful/getAccessToken.test.ts @@ -137,8 +137,8 @@ describe("getAccessToken API (Stateful)", () => { success: false, accessToken: null, error: { - code: "COOKIE_INVALID_VALUE", - message: "Expected configuration cookie not found or contains an empty value.", + code: "OAUTH_UNLINKED_ACCOUNT_ERROR", + message: "The specified identity provider is not connected to your account.", }, headers: expect.any(Headers), toResponse: expect.any(Function), @@ -304,15 +304,16 @@ describe("getAccessToken API (Stateful)", () => { }) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") - expect(updateOAuthTokensMock).toHaveBeenCalledWith( - "oauth-provider", - expect.objectContaining({ - accountId: "account-123", - accessToken: "new-access-token", - refreshToken: "new-refresh-token", - idToken: "new-id-token", - }) - ) + expect(updateOAuthTokensMock).toHaveBeenCalledWith("account-123", { + accountId: "account-123", + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + scopes: "scope1 scope2", + tokenType: "Bearer", + accessTokenExpiresAt: expect.any(Date), + refreshTokenExpiresAt: expect.any(Date), + }) }) test("refreshToken successfully refreshes tokens with credentials auth in refreshToken config", async () => { @@ -398,15 +399,16 @@ describe("getAccessToken API (Stateful)", () => { }) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") - expect(updateOAuthTokensMock).toHaveBeenCalledWith( - "oauth-provider", - expect.objectContaining({ - accountId: "account-123", - accessToken: "new-access-token", - refreshToken: "new-refresh-token", - idToken: "new-id-token", - }) - ) + expect(updateOAuthTokensMock).toHaveBeenCalledWith("account-123", { + accountId: "account-123", + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + scopes: "scope1 scope2", + tokenType: "Bearer", + accessTokenExpiresAt: expect.any(Date), + refreshTokenExpiresAt: expect.any(Date), + }) }) test("refreshToken fails when OAuth provider returns an error", async () => { @@ -602,14 +604,15 @@ describe("getAccessToken API (Stateful)", () => { expect(mockFetch).toHaveBeenCalledTimes(1) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") - expect(updateOAuthTokensMock).toHaveBeenCalledWith( - "oauth-provider", - expect.objectContaining({ - accountId: "account-123", - accessToken: "brand-new-refreshed-token", - refreshToken: "new-refresh-token", - idToken: "new-id-token", - }) - ) + expect(updateOAuthTokensMock).toHaveBeenCalledWith("account-123", { + accountId: "account-123", + accessToken: "brand-new-refreshed-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + scopes: "scope1 scope2", + tokenType: "Bearer", + accessTokenExpiresAt: expect.any(Date), + refreshTokenExpiresAt: expect.any(Date), + }) }) }) diff --git a/packages/core/test/api/stateful/getProviderTokens.test.ts b/packages/core/test/api/stateful/getProviderTokens.test.ts index 2b70c9fa..4601efed 100644 --- a/packages/core/test/api/stateful/getProviderTokens.test.ts +++ b/packages/core/test/api/stateful/getProviderTokens.test.ts @@ -137,8 +137,8 @@ describe("getProviderTokens API (Stateful)", () => { success: false, tokens: null, error: { - code: "COOKIE_INVALID_VALUE", - message: "Expected configuration cookie not found or contains an empty value.", + code: "OAUTH_UNLINKED_ACCOUNT_ERROR", + message: "The specified identity provider is not connected to your account.", }, headers: expect.any(Headers), toResponse: expect.any(Function), @@ -317,15 +317,16 @@ describe("getProviderTokens API (Stateful)", () => { }) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") - expect(updateOAuthTokensMock).toHaveBeenCalledWith( - "oauth-provider", - expect.objectContaining({ - accountId: "account-123", - accessToken: "new-access-token", - refreshToken: "new-refresh-token", - idToken: "new-id-token", - }) - ) + expect(updateOAuthTokensMock).toHaveBeenCalledWith("account-123", { + accountId: "account-123", + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + scopes: "scope1 scope2", + tokenType: "Bearer", + accessTokenExpiresAt: expect.any(Date), + refreshTokenExpiresAt: expect.any(Date), + }) }) test("refreshToken successfully refreshes tokens with credentials auth in refreshToken config", async () => { @@ -417,15 +418,16 @@ describe("getProviderTokens API (Stateful)", () => { }) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") - expect(updateOAuthTokensMock).toHaveBeenCalledWith( - "oauth-provider", - expect.objectContaining({ - accountId: "account-123", - accessToken: "new-access-token", - refreshToken: "new-refresh-token", - idToken: "new-id-token", - }) - ) + expect(updateOAuthTokensMock).toHaveBeenCalledWith("account-123", { + accountId: "account-123", + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + scopes: "scope1 scope2", + tokenType: "Bearer", + accessTokenExpiresAt: expect.any(Date), + refreshTokenExpiresAt: expect.any(Date), + }) }) test("refreshToken fails when OAuth provider returns an error", async () => { @@ -628,14 +630,15 @@ describe("getProviderTokens API (Stateful)", () => { expect(mockFetch).toHaveBeenCalledTimes(1) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") - expect(updateOAuthTokensMock).toHaveBeenCalledWith( - "oauth-provider", - expect.objectContaining({ - accountId: "account-123", - accessToken: "brand-new-refreshed-token", - refreshToken: "new-refresh-token", - idToken: "new-id-token", - }) - ) + expect(updateOAuthTokensMock).toHaveBeenCalledWith("account-123", { + accountId: "account-123", + accessToken: "brand-new-refreshed-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + scopes: "scope1 scope2", + tokenType: "Bearer", + accessTokenExpiresAt: expect.any(Date), + refreshTokenExpiresAt: expect.any(Date), + }) }) }) diff --git a/packages/core/test/api/stateful/refreshUserInfo.test.ts b/packages/core/test/api/stateful/refreshUserInfo.test.ts index 094ed747..a6d76827 100644 --- a/packages/core/test/api/stateful/refreshUserInfo.test.ts +++ b/packages/core/test/api/stateful/refreshUserInfo.test.ts @@ -666,7 +666,7 @@ describe("refreshUserInfo API (Stateful)", () => { expect(revokeSessionMock).not.toHaveBeenCalled() expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") - expect(updateOAuthTokensMock).toHaveBeenCalledWith("oauth-provider", { + expect(updateOAuthTokensMock).toHaveBeenCalledWith("account-123", { accountId: "account-123", accessToken: "new-access-token", refreshToken: "new-refresh-token", diff --git a/packages/core/test/api/stateful/revokeToken.test.ts b/packages/core/test/api/stateful/revokeToken.test.ts index 60560dbf..032bfae8 100644 --- a/packages/core/test/api/stateful/revokeToken.test.ts +++ b/packages/core/test/api/stateful/revokeToken.test.ts @@ -1,6 +1,13 @@ import { describe, test, expect, vi } from "vitest" import { createCSRF } from "@/shared/crypto.ts" -import { authInstance, jose, oauthAccountEntity, oauthCustomService, sessionEntityWithUser } from "@test/presets.ts" +import { + accountEntity, + authInstance, + jose, + oauthAccountEntity, + oauthCustomService, + sessionEntityWithUser, +} from "@test/presets.ts" describe("revokeToken (Stateful)", () => { test("throws error when provider is not configured", async () => { @@ -82,11 +89,13 @@ describe("revokeToken (Stateful)", () => { vi.stubEnv("BASE_URL", "https://example.com") const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([]) const getOAuthAccountMock = vi.fn() const updateAccountStatusMock = vi.fn() const { api } = authInstance({ getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }) @@ -109,7 +118,8 @@ describe("revokeToken (Stateful)", () => { toResponse: expect.any(Function), }) expect(getSessionByTokenMock).toHaveBeenCalledTimes(2) - expect(getOAuthAccountMock).toHaveBeenCalled() + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).not.toHaveBeenCalled() expect(updateAccountStatusMock).not.toHaveBeenCalled() }) @@ -153,11 +163,13 @@ describe("revokeToken (Stateful)", () => { vi.stubEnv("BASE_URL", "https://example.com") const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([]) const getOAuthAccountMock = vi.fn().mockResolvedValue(null) const updateAccountStatusMock = vi.fn() const { api } = authInstance({ getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }) @@ -181,7 +193,8 @@ describe("revokeToken (Stateful)", () => { toResponse: expect.any(Function), }) expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) - expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).not.toHaveBeenCalled() expect(updateAccountStatusMock).not.toHaveBeenCalled() }) @@ -189,6 +202,7 @@ describe("revokeToken (Stateful)", () => { vi.stubEnv("BASE_URL", "https://example.com") const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) const updateAccountStatusMock = vi.fn().mockResolvedValue({ accountId: "account-123", @@ -197,6 +211,7 @@ describe("revokeToken (Stateful)", () => { const { api } = authInstance({ getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }) @@ -225,7 +240,8 @@ describe("revokeToken (Stateful)", () => { }) expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) - expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/revoke_token", { method: "POST", @@ -242,6 +258,7 @@ describe("revokeToken (Stateful)", () => { vi.stubEnv("BASE_URL", "https://example.com") const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue({ accountId: "account-123", accessToken: "access-token", @@ -261,6 +278,7 @@ describe("revokeToken (Stateful)", () => { const { api } = authInstance({ getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }) @@ -289,7 +307,8 @@ describe("revokeToken (Stateful)", () => { }) expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) - expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") expect(mockFetch).not.toHaveBeenCalled() }) @@ -298,6 +317,7 @@ describe("revokeToken (Stateful)", () => { vi.stubEnv("BASE_URL", "https://example.com") const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue({ accountId: "account-123", accessToken: "access-token", @@ -314,6 +334,7 @@ describe("revokeToken (Stateful)", () => { const { api } = authInstance({ getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }) @@ -341,7 +362,8 @@ describe("revokeToken (Stateful)", () => { toResponse: expect.any(Function), }) expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) - expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") expect(updateAccountStatusMock).not.toHaveBeenCalled() }) @@ -349,6 +371,7 @@ describe("revokeToken (Stateful)", () => { vi.stubEnv("BASE_URL", "https://example.com") const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue({ accountId: "account-123", accessToken: "access-token", @@ -365,6 +388,7 @@ describe("revokeToken (Stateful)", () => { const { api } = authInstance({ getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }) @@ -395,7 +419,8 @@ describe("revokeToken (Stateful)", () => { toResponse: expect.any(Function), }) expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) - expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") expect(updateAccountStatusMock).not.toHaveBeenCalled() }) @@ -403,6 +428,7 @@ describe("revokeToken (Stateful)", () => { vi.stubEnv("BASE_URL", "https://example.com") const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue({ accountId: "account-123", accessToken: "access-token", @@ -419,6 +445,7 @@ describe("revokeToken (Stateful)", () => { const { api } = authInstance({ getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }) @@ -449,7 +476,8 @@ describe("revokeToken (Stateful)", () => { toResponse: expect.any(Function), }) expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) - expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") expect(updateAccountStatusMock).not.toHaveBeenCalled() }) @@ -457,6 +485,7 @@ describe("revokeToken (Stateful)", () => { vi.stubEnv("BASE_URL", "https://example.com") const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue({ accountId: "account-123", accessToken: "access-token", @@ -476,6 +505,7 @@ describe("revokeToken (Stateful)", () => { const { api } = authInstance({ getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }) @@ -504,6 +534,20 @@ describe("revokeToken (Stateful)", () => { expect(responseBody).toEqual({ success: true, }) + + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") + expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") + expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/revoke_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: expect.stringContaining("Basic"), + }, + body: expect.any(URLSearchParams), + signal: expect.any(AbortSignal), + }) }) test("toResponse returns correct response on failure", async () => { @@ -616,6 +660,7 @@ describe("revokeToken (Stateful)", () => { getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue({ accountId: "account-123", accessToken: "access-token", @@ -640,6 +685,7 @@ describe("revokeToken (Stateful)", () => { const { api } = authInstance( { getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }, @@ -665,6 +711,8 @@ describe("revokeToken (Stateful)", () => { expect(output.success).toBe(true) expect(mockFetch).toHaveBeenCalledWith("https://custom.example.com/revoke", expect.any(Object)) + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") }) @@ -676,6 +724,7 @@ describe("revokeToken (Stateful)", () => { getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue({ accountId: "account-123", accessToken: "access-token", @@ -709,6 +758,7 @@ describe("revokeToken (Stateful)", () => { const { api } = authInstance( { getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }, @@ -742,6 +792,8 @@ describe("revokeToken (Stateful)", () => { }), }) ) + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") }) @@ -753,6 +805,7 @@ describe("revokeToken (Stateful)", () => { getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue({ accountId: "account-123", accessToken: "access-token", @@ -772,6 +825,7 @@ describe("revokeToken (Stateful)", () => { const { api } = authInstance({ getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }) @@ -800,7 +854,8 @@ describe("revokeToken (Stateful)", () => { }) expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) - expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/revoke_token", { method: "POST", @@ -863,6 +918,7 @@ describe("revokeToken (Stateful)", () => { getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([accountEntity]) const getOAuthAccountMock = vi.fn().mockResolvedValue({ accountId: "account-123", accessToken: "access-token", @@ -882,6 +938,7 @@ describe("revokeToken (Stateful)", () => { const { api } = authInstance({ getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, getOAuthAccount: getOAuthAccountMock, updateAccountStatus: updateAccountStatusMock, }) @@ -911,7 +968,8 @@ describe("revokeToken (Stateful)", () => { }) expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) - expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getOAuthAccountMock).toHaveBeenCalledWith("account-123") expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/revoke_token", { method: "POST", diff --git a/packages/core/test/api/stateful/updateSession.test.ts b/packages/core/test/api/stateful/updateSession.test.ts index 7ae8c9c7..d1dd0eae 100644 --- a/packages/core/test/api/stateful/updateSession.test.ts +++ b/packages/core/test/api/stateful/updateSession.test.ts @@ -665,7 +665,7 @@ describe("updateSession API", () => { image: "https://example.com/image.jpg", }, }) - expect(updated.headers.get("Set-Cookie")).toBeNull() + expect(updated.headers.get("Set-Cookie")).toContain("aura-auth.session_token=") expect(sessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") expect(revokeSessionMock).not.toHaveBeenCalled() diff --git a/packages/core/test/presets.ts b/packages/core/test/presets.ts index 602b6ee8..efd1b4e8 100644 --- a/packages/core/test/presets.ts +++ b/packages/core/test/presets.ts @@ -135,6 +135,7 @@ export const accountEntity: Partial = { id: "account-123", status: "active", provider: "oauth-provider", + userId: "user-123", } export const deviceEntity: DeviceEntity = { From d0a3d315c7ca652af187c29d218d1c0e95b1da4c Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Fri, 31 Jul 2026 20:39:17 -0500 Subject: [PATCH 4/7] refactor(core): split stateless strategy into function modules --- packages/core/src/@types/config.ts | 5 + packages/core/src/@types/session.ts | 4 +- .../src/session/stateful/createSession.ts | 2 +- .../src/session/stateful/getProviderTokens.ts | 4 +- .../session/stateful/isProviderConnected.ts | 2 +- .../src/session/stateful/oauthCallback.ts | 12 +- .../core/src/session/stateful/revokeToken.ts | 6 +- packages/core/src/session/stateful/signIn.ts | 1 - packages/core/src/session/stateless.ts | 607 ------------------ .../src/session/stateless/createSession.ts | 17 + .../src/session/stateless/destroySession.ts | 11 + .../session/stateless/getProviderTokens.ts | 174 +++++ .../core/src/session/stateless/getSession.ts | 73 +++ packages/core/src/session/stateless/index.ts | 36 ++ .../session/stateless/isProviderConnected.ts | 28 + .../src/session/stateless/oauthCallback.ts | 114 ++++ .../src/session/stateless/refreshSession.ts | 71 ++ .../src/session/stateless/refreshUserInfo.ts | 27 + .../core/src/session/stateless/revokeToken.ts | 40 ++ packages/core/src/session/stateless/signIn.ts | 72 +++ packages/core/src/session/strategy.ts | 2 +- .../utils/session-strategy.ts} | 29 + 22 files changed, 714 insertions(+), 623 deletions(-) delete mode 100644 packages/core/src/session/stateless.ts create mode 100644 packages/core/src/session/stateless/createSession.ts create mode 100644 packages/core/src/session/stateless/destroySession.ts create mode 100644 packages/core/src/session/stateless/getProviderTokens.ts create mode 100644 packages/core/src/session/stateless/getSession.ts create mode 100644 packages/core/src/session/stateless/index.ts create mode 100644 packages/core/src/session/stateless/isProviderConnected.ts create mode 100644 packages/core/src/session/stateless/oauthCallback.ts create mode 100644 packages/core/src/session/stateless/refreshSession.ts create mode 100644 packages/core/src/session/stateless/refreshUserInfo.ts create mode 100644 packages/core/src/session/stateless/revokeToken.ts create mode 100644 packages/core/src/session/stateless/signIn.ts rename packages/core/src/{session/stateful/utils.ts => shared/utils/session-strategy.ts} (61%) diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index 1846cc3d..5fbf4ca3 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -21,6 +21,7 @@ import type { User, Awaitable, StatefulStrategyConfig, + StatelessStrategyConfig, } from "@/@types/index.ts" import type { ZodObject } from "zod" import type { SerializeOptions } from "@aura-stack/router/cookie" @@ -541,6 +542,10 @@ export type InternalContextForStateful = Omit, "sessio sessionConfig: StatefulStrategyConfig } +export type InternalContextForStateless = Omit, "sessionConfig"> & { + sessionConfig: StatelessStrategyConfig +} + export interface OnCreateUserContext { payload: InferSchema } diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index cd26c006..0f34ec58 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -14,6 +14,7 @@ import type { OAuthProviderRecord, InternalContext, InternalContextForStateful, + InternalContextForStateless, } from "@/@types/index.ts" import type { DatabaseAdapter } from "@/@types/adapter.ts" import type { createCookieManager } from "@/session/cookie-manager.ts" @@ -391,6 +392,7 @@ export interface InternalStatefulContext { } export interface InternalStatelessContext { - ctx: InternalContext + ctx: InternalContextForStateless + cookies: () => InternalCookieStoreConfig cookieManager: CookieManager } diff --git a/packages/core/src/session/stateful/createSession.ts b/packages/core/src/session/stateful/createSession.ts index 7e9cd2cb..aa4b2a4f 100644 --- a/packages/core/src/session/stateful/createSession.ts +++ b/packages/core/src/session/stateful/createSession.ts @@ -1,6 +1,6 @@ import { AuraAuthError } from "@/shared/errors.ts" import { createHash, createSecretValue } from "@/shared/crypto.ts" -import { createDevice as __createDevice } from "@/session/stateful/utils.ts" +import { createDevice as __createDevice } from "@/shared/utils/session-strategy.ts" import type { TypedJWTPayload } from "@aura-stack/jose" import type { InternalStatefulContext, User } from "@/@types/index.ts" diff --git a/packages/core/src/session/stateful/getProviderTokens.ts b/packages/core/src/session/stateful/getProviderTokens.ts index eb58d1e7..13eca53f 100644 --- a/packages/core/src/session/stateful/getProviderTokens.ts +++ b/packages/core/src/session/stateful/getProviderTokens.ts @@ -1,8 +1,8 @@ -import type { GetProviderTokensStatefulReturn, InternalStatefulContext } from "@/@types/session.ts" import { AuraAuthError } from "@/shared/errors.ts" -import { getErrorName, shouldRefresh } from "@/shared/utils.ts" import { handleApiError } from "@/shared/utils/api.ts" +import { getErrorName, shouldRefresh } from "@/shared/utils.ts" import { refreshProviderToken } from "@/shared/utils/refresh-tokens.ts" +import type { GetProviderTokensStatefulReturn, InternalStatefulContext } from "@/@types/session.ts" export const __getProviderTokens = ({ ctx, cookieManager }: InternalStatefulContext) => { const { oauth, logger, sessionConfig } = ctx diff --git a/packages/core/src/session/stateful/isProviderConnected.ts b/packages/core/src/session/stateful/isProviderConnected.ts index afcca246..875fb517 100644 --- a/packages/core/src/session/stateful/isProviderConnected.ts +++ b/packages/core/src/session/stateful/isProviderConnected.ts @@ -1,5 +1,5 @@ -import type { InternalStatefulContext } from "@/@types/session.ts" import { getErrorName } from "@/shared/utils.ts" +import type { InternalStatefulContext } from "@/@types/session.ts" export const __isProviderConnected = ({ ctx, cookieManager }: InternalStatefulContext) => { const { logger, sessionConfig } = ctx diff --git a/packages/core/src/session/stateful/oauthCallback.ts b/packages/core/src/session/stateful/oauthCallback.ts index 84763e53..c2e63c11 100644 --- a/packages/core/src/session/stateful/oauthCallback.ts +++ b/packages/core/src/session/stateful/oauthCallback.ts @@ -1,13 +1,13 @@ -import { isRelativeURL, isSameOrigin, isTrustedOrigin } from "@/shared/assert.ts" -import { createCSRF, createHash, createSecretValue } from "@/shared/crypto.ts" +import { HeadersBuilder } from "@aura-stack/router" import { AuraAuthError } from "@/shared/errors.ts" import { validateIDToken } from "@/shared/oidc/id-token.ts" -import { isOIDCProvider, resolveOpenIDProvider } from "@/shared/oidc/resolve-provider.ts" -import { getOriginURL, getTrustedOrigins } from "@/shared/utils/authorization.ts" import { createAccessToken, getUserInfo } from "@/shared/utils/oauth.ts" +import { createCSRF, createHash, createSecretValue } from "@/shared/crypto.ts" +import { getOriginURL, getTrustedOrigins } from "@/shared/utils/authorization.ts" +import { isRelativeURL, isSameOrigin, isTrustedOrigin } from "@/shared/assert.ts" +import { createDevice as __createDevice } from "@/shared/utils/session-strategy.ts" +import { isOIDCProvider, resolveOpenIDProvider } from "@/shared/oidc/resolve-provider.ts" import type { InternalStatefulContext } from "@/@types/session.ts" -import { createDevice as __createDevice } from "./utils.ts" -import { HeadersBuilder } from "@aura-stack/router" export const __oauthCallback = ({ ctx, cookies, cookieManager }: InternalStatefulContext) => { const { logger, jose, oauth, sessionConfig } = ctx diff --git a/packages/core/src/session/stateful/revokeToken.ts b/packages/core/src/session/stateful/revokeToken.ts index 9122269f..8a827a43 100644 --- a/packages/core/src/session/stateful/revokeToken.ts +++ b/packages/core/src/session/stateful/revokeToken.ts @@ -1,8 +1,8 @@ -import { AuraAuthError } from "@/shared/errors.ts" -import { revokeProviderToken } from "@/shared/utils/revoke-token.ts" import { HeadersBuilder } from "@aura-stack/router" -import { secureApiHeaders } from "@/shared/headers.ts" import { getExpiredCookie } from "@/cookie.ts" +import { AuraAuthError } from "@/shared/errors.ts" +import { secureApiHeaders } from "@/shared/headers.ts" +import { revokeProviderToken } from "@/shared/utils/revoke-token.ts" import { getErrorName, toUnionHeaders } from "@/shared/utils.ts" import type { InternalStatefulContext } from "@/@types/session.ts" diff --git a/packages/core/src/session/stateful/signIn.ts b/packages/core/src/session/stateful/signIn.ts index 92125414..d6120031 100644 --- a/packages/core/src/session/stateful/signIn.ts +++ b/packages/core/src/session/stateful/signIn.ts @@ -2,7 +2,6 @@ import { HeadersBuilder } from "@aura-stack/router" import { AuraAuthError } from "@/shared/errors.ts" import { secureApiHeaders } from "@/shared/headers.ts" import { createFingerprint, getDeviceInfo } from "@/shared/utils.ts" -import { createDevice as __createDevice } from "@/session/stateful/utils.ts" import { createOIDCAuthorizationURL } from "@/shared/oidc/authorization-url.ts" import { isOIDCProvider, resolveOpenIDProvider } from "@/shared/oidc/resolve-provider.ts" import { createAuthorizationURL, createRedirectTo, createRedirectURI } from "@/shared/utils/authorization.ts" diff --git a/packages/core/src/session/stateless.ts b/packages/core/src/session/stateless.ts deleted file mode 100644 index d415788c..00000000 --- a/packages/core/src/session/stateless.ts +++ /dev/null @@ -1,607 +0,0 @@ -import { getCookie, getExpiredCookie, getOptionalCookie } from "@/cookie.ts" -import { AuraAuthError } from "@/shared/errors.ts" -import { HeadersBuilder } from "@aura-stack/router" -import { cacheControl, secureApiHeaders } from "@/shared/headers.ts" -import { handleApiError } from "@/shared/utils/api.ts" -import { createJoseManager } from "@/session/jose-manager.ts" -import { createCookieManager } from "@/session/cookie-manager.ts" -import { refreshProviderToken } from "@/shared/utils/refresh-tokens.ts" -import { - verifyCSRFToken, - getErrorName, - verifySessionToken, - shouldRefresh, - toUnionHeaders, - getStandardSession, - timingSafeEqual, - transformToTokenPayload, -} from "@/shared/utils.ts" -import type { - Session, - SessionStrategy, - User, - TypedJWTPayload, - JWTStrategyOptions, - GetStatelessSessionReturn, - DeepPartial, - JoseInstance, - GetProviderTokensStatefulReturn, -} from "@/@types/index.ts" -import { revokeProviderToken } from "@/shared/utils/revoke-token.ts" -import { - createAuthorizationURL, - createRedirectTo, - createRedirectURI, - getOriginURL, - getTrustedOrigins, -} from "@/shared/utils/authorization.ts" -import { isOIDCProvider, resolveOpenIDProvider } from "@/shared/oidc/resolve-provider.ts" -import { createOIDCAuthorizationURL } from "@/shared/oidc/authorization-url.ts" -import { createAccessToken, getUserInfo } from "@/shared/utils/oauth.ts" -import { validateIDToken } from "@/shared/oidc/id-token.ts" -import { isRelativeURL, isSameOrigin, isTrustedOrigin } from "@/shared/assert.ts" -import { createCSRF } from "@/shared/crypto.ts" - -export const createStatelessStrategy = ({ - ctx, - config, - jose, - logger, - cookies, - identity, - oauth, -}: JWTStrategyOptions): SessionStrategy => { - const jwt = createJoseManager(config?.jwt, jose) - const cookieConfig = createCookieManager(cookies) - const maxAge = config?.jwt?.maxAge ?? 60 * 60 * 24 * 15 - const strategy = config?.jwt?.expirationStrategy ?? "absolute" - - const updateExpires = ({ exp }: { exp: number | undefined }): Date | null => { - if (!exp) return null - const now = Math.floor(Date.now() / 1000) - switch (strategy) { - case "fixed": - case "absolute": - return null - case "rolling": - return new Date((now + maxAge) * 1000) - case "sliding": { - const threshold = maxAge * 0.25 - if (exp - now < threshold) { - return new Date((now + maxAge) * 1000) - } - return null - } - default: - return null - } - } - - const getSession = async (headers: Headers): Promise> => { - const newHeaders = new Headers() - try { - const { sessionToken } = cookieConfig.getCookie(headers) - if (!sessionToken) return { session: null, headers: newHeaders } - - const claims = await jwt.verifyToken(sessionToken) - const parsedClaims = identity.skipValidation ? claims : await identity.schemaRegistry.parseWithJWT(claims) - const { exp, iat: _iat, mexp: _mexp, ...defaultPayload } = parsedClaims - const userClaims = await identity.schemaRegistry.parse(defaultPayload) - if (!userClaims.sub) return { session: null, headers: newHeaders } - - const session: Session = { - user: userClaims as DefaultUser, - expires: parsedClaims.exp ? new Date(exp * 1000).toISOString() : "", - } - - const expiresAt = updateExpires({ exp }) - if (!expiresAt) { - return { session: { expires: session.expires, user: userClaims }, headers } - } - - const issuedAt = strategy === "absolute" ? parsedClaims.iat : Math.floor(Date.now() / 1000) - const newSessionToken = await jwt.createToken({ - ...userClaims, - exp: Math.floor(expiresAt.getTime() / 1000), - iat: issuedAt, - mexp: parsedClaims.mexp, - }) - logger?.log("SESSION_REFRESHED", { structuredData: { strategy: "stateless", expiresAt: expiresAt.toISOString() } }) - return { - session: { - user: userClaims, - expires: expiresAt.toISOString(), - } as unknown as Session, - headers: cookieConfig.setCookie({ sessionToken: newSessionToken }), - } - } catch (error) { - logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } }) - return { session: null, headers: newHeaders } - } - } - - const createSession = async (session: TypedJWTPayload) => { - if (identity.skipValidation) { - logger?.log("IDENTITY_VALIDATION_DISABLED", { - structuredData: { - identity_validation_disabled: true, - }, - }) - } - const payload = identity.skipValidation ? session : await identity.schemaRegistry.parse(session) - return jwt.createToken(payload as unknown as DefaultUser) - } - - const getProviderTokens = async (oauthId: string, request: Request): Promise => { - logger?.log("STATELESS_GET_PROVIDER_TOKENS_START", { - structuredData: { - strategy: "stateless", - operation: "getProviderTokens", - oauth_id: oauthId, - }, - }) - - try { - const provider = oauth[oauthId] - if (!provider) { - logger?.log("STATELESS_GET_PROVIDER_TOKENS_PROVIDER_NOT_FOUND", { - structuredData: { - oauth_id: oauthId, - reason: "provider_not_configured", - }, - }) - const { code, message, statusCode } = handleApiError( - new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" }), - "PROVIDER_TOKENS_ERROR", - "Failed to get provider tokens" - ) - return { success: false, error: { code, message }, tokens: null, headers: request.headers, statusCode } - } - - logger?.log("STATELESS_GET_PROVIDER_TOKENS_PROVIDER_FOUND", { - structuredData: { - oauth_id: oauthId, - }, - }) - - const cookieName = `${cookies().accessToken.name}.${oauthId}` - const cookie = getCookie(request, cookieName) - - if (!cookie) { - logger?.log("STATELESS_GET_PROVIDER_TOKENS_NO_COOKIE", { - structuredData: { - oauth_id: oauthId, - cookie_name: cookieName, - reason: "no_access_token_cookie", - }, - }) - const { code, message, statusCode } = handleApiError( - new AuraAuthError({ code: "COOKIE_INVALID_VALUE" }), - "PROVIDER_TOKENS_ERROR", - "Failed to get provider tokens" - ) - return { success: false, error: { code, message }, tokens: null, headers: request.headers, statusCode } - } - - logger?.log("STATELESS_GET_PROVIDER_TOKENS_COOKIE_FOUND", { - structuredData: { - oauth_id: oauthId, - cookie_name: cookieName, - }, - }) - - const decodedToken = await jwt.verifyToken(cookie) - const tokens = await identity.schemaRegistry.parseOAuthTokens(decodedToken) - - logger?.log("STATELESS_GET_PROVIDER_TOKENS_TOKENS_DECODED", { - structuredData: { - oauth_id: oauthId, - has_access_token: Boolean(tokens.accessToken), - has_refresh_token: Boolean(tokens.refreshToken), - expires_at: tokens.expiresAt, - }, - }) - - const refreshWindow = provider.refreshWindow ?? 300 - const needsRefresh = shouldRefresh(tokens, refreshWindow) - - logger?.log("STATELESS_GET_PROVIDER_TOKENS_REFRESH_CHECK", { - structuredData: { - oauth_id: oauthId, - needs_refresh: needsRefresh, - refresh_window: refreshWindow, - expires_at: tokens.expiresAt, - }, - }) - - if (needsRefresh) { - logger?.log("STATELESS_GET_PROVIDER_TOKENS_REFRESHING", { - structuredData: { - oauth_id: oauthId, - reason: "token_expired_or_in_refresh_window", - }, - }) - - try { - const refreshedTokens = await refreshProviderToken(tokens, provider!) - - logger?.log("STATELESS_GET_PROVIDER_TOKENS_REFRESH_SUCCESS", { - structuredData: { - oauth_id: oauthId, - new_expires_at: refreshedTokens.expiresAt, - }, - }) - - const encodedTokens = await jwt.createToken(refreshedTokens as any) - const builder = new HeadersBuilder(secureApiHeaders) - .setCookie(cookieName, encodedTokens, cookies().accessToken.attributes) - .toHeaders() - const newHeaders = toUnionHeaders(builder, request.headers) - - logger?.log("STATELESS_GET_PROVIDER_TOKENS_COOKIE_UPDATED", { - structuredData: { - oauth_id: oauthId, - cookie_name: cookieName, - }, - }) - - return { - success: true, - tokens: refreshedTokens, - headers: newHeaders, - } - } catch (refreshError) { - logger?.log("STATELESS_GET_PROVIDER_TOKENS_REFRESH_ERROR", { - structuredData: { - oauth_id: oauthId, - error_type: getErrorName(refreshError), - error_message: refreshError instanceof Error ? refreshError.message : String(refreshError), - }, - }) - - const { code, message, statusCode } = handleApiError( - refreshError, - "PROVIDER_TOKENS_ERROR", - "Failed to get provider tokens" - ) - return { success: false, error: { code, message }, tokens: null, headers: request.headers, statusCode } - } - } - - logger?.log("STATELESS_GET_PROVIDER_TOKENS_SUCCESS", { - structuredData: { - oauth_id: oauthId, - tokens_returned: true, - }, - }) - - return { - success: true, - tokens, - headers: request.headers, - } - } catch (error) { - logger?.log("STATELESS_GET_PROVIDER_TOKENS_ERROR", { - structuredData: { - oauth_id: oauthId, - error_type: getErrorName(error), - error_message: error instanceof Error ? error.message : String(error), - }, - }) - - const { code, message, statusCode } = handleApiError(error, "PROVIDER_TOKENS_ERROR", "Failed to get provider tokens") - return { success: false, error: { code, message }, tokens: null, headers: request.headers, statusCode } - } - } - - const refreshSession = async ( - headers: Headers, - session: DeepPartial>, - skipCSRFCheck: boolean = false - ): Promise<{ - session: Session | null - headers: Headers - }> => { - try { - const { sessionToken } = cookieConfig.getCookie(headers) - if (!sessionToken) { - return { session: null, headers: cookieConfig.clear() } - } - const isValidToken = await verifyCSRFToken({ - headers, - skipCSRFCheck, - cookies: cookies(), - logger, - jose: jose as JoseInstance, - }) - if (!isValidToken) { - return { session: null, headers: cookieConfig.clear() } - } - const claims = await jwt.verifyToken(sessionToken) - const parsedClaims = identity.skipValidation ? claims : await identity.schemaRegistry.parseWithJWT(claims) - - const { exp, mexp, iat } = parsedClaims - const defaultPayload = identity.skipValidation ? parsedClaims : await identity.schemaRegistry.parse(parsedClaims) - const { sub } = defaultPayload - const sessionPayload = identity.skipValidation - ? session.user - : await identity.schemaRegistry.parseAsPartial(session.user) - - const expiresAt = session.expires - ? new Date(Math.min(Date.now() + maxAge * 1000, new Date(session.expires).getTime())) - : (updateExpires({ exp }) ?? new Date(Date.now() + maxAge * 1000)) - const updatedSession: Session = { - user: { - ...defaultPayload, - ...sessionPayload, - sub, - } as DefaultUser, - expires: expiresAt.toISOString(), - } - const verifiedPayload = await identity.schemaRegistry.parse(updatedSession.user) - const issuedAt = strategy === "absolute" ? iat : Math.floor(Date.now() / 1000) - const newToken = await jwt.createToken({ - ...verifiedPayload, - exp: Math.floor(expiresAt.getTime() / 1000), - iat: issuedAt, - mexp, - }) - updatedSession.expires = new Date(updatedSession.expires).toISOString() - return { session: updatedSession, headers: cookieConfig.setCookie({ sessionToken: newToken }) } - } catch (error) { - logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } }) - return { session: null, headers: cookieConfig.clear() } - } - } - - const revokeToken = async (oauthId: string, headers: Headers, disconnect: boolean): Promise => { - const cookieName = `${cookies().accessToken.name}.${oauthId}` - const cookie = getCookie(headers, cookieName) - const provider = oauth[oauthId] - - if (!provider) { - throw new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" }) - } - - if (!disconnect) { - const decodedToken = await jwt.verifyToken(cookie) - const tokens = await identity.schemaRegistry.parseOAuthTokens(decodedToken) - - logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { - structuredData: { provider: oauthId, hasAccessToken: !!tokens.accessToken }, - }) - - await revokeProviderToken(provider, tokens.accessToken) - - logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", { - structuredData: { provider: oauthId }, - }) - } - const builder = new HeadersBuilder(secureApiHeaders) - .setCookie(cookieName, "", getExpiredCookie(cookies().accessToken.attributes)) - .toHeaders() - return toUnionHeaders(builder, headers) - } - - const isProviderConnected = async (oauthId: string, headers: Headers): Promise => { - const cookieName = `${cookies().accessToken.name}.${oauthId}` - let cookieValue: string - try { - cookieValue = getCookie(headers, cookieName) - } catch { - logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { - structuredData: { provider: oauthId, hasCookie: false }, - }) - return false - } - - try { - const decodedToken = await jwt.verifyToken(cookieValue) - return !!decodedToken - } catch (error) { - logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } }) - return false - } - } - - const refreshUserInfo = async (userInfo: TypedJWTPayload, headers: Headers) => { - const sessionToken = await createSession(userInfo) - - const newHeaders = new HeadersBuilder(headers) - .setCookie(cookies().sessionToken.name, sessionToken, cookies().sessionToken.attributes) - .toHeaders() - - const session = await getStandardSession({ - jwt, - identity, - sessionToken, - }) - const mergedHeaders = toUnionHeaders(newHeaders, secureApiHeaders) - return { session, headers: mergedHeaders } - } - - const signIn = async (oauthId: string, request: Request, redirectTo?: string) => { - const provider = oauth[oauthId] - if (!provider) { - throw new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" }) - } - - const redirectURI = await createRedirectURI(request, oauthId, ctx) - const redirectToValue = await createRedirectTo(request, redirectTo, ctx) - - const isOIDC = isOIDCProvider(provider) - logger?.log("SIGN_IN_PROVIDER_TYPE_DETECTED", { - structuredData: { oauth_provider: oauthId, oidc: isOIDC }, - }) - - const resolvedProvider = isOIDC ? await resolveOpenIDProvider(provider!) : provider! - - if (isOIDC) { - logger?.log("OIDC_PROVIDER_RESOLVED", { - structuredData: { oauth_provider: oauthId, oidc: isOIDC }, - }) - } - - let authorization: string - let state: string - let codeVerifier: string - let nonce: string | undefined - - if (isOIDC) { - const result = await createOIDCAuthorizationURL(resolvedProvider, redirectURI, ctx) - authorization = result.authorization - state = result.state - codeVerifier = result.codeVerifier - nonce = result.nonce - } else { - const result = await createAuthorizationURL(resolvedProvider, redirectURI, ctx) - authorization = result.authorization - state = result.state - codeVerifier = result.codeVerifier - } - - logger?.log("SIGN_IN_INITIATED", { - structuredData: { oauth_provider: oauthId, oidc: isOIDC }, - }) - - const headersBuilder = new HeadersBuilder(cacheControl) - .setHeader("Location", authorization) - .setCookie(cookies().state.name, state, cookies().state.attributes) - .setCookie(cookies().redirectURI.name, redirectURI, cookies().redirectURI.attributes) - .setCookie(cookies().redirectTo.name, redirectToValue, cookies().redirectTo.attributes) - .setCookie(cookies().codeVerifier.name, codeVerifier, cookies().codeVerifier.attributes) - - if (nonce) { - headersBuilder.setCookie(cookies().nonce.name, nonce, cookies().nonce.attributes) - } - return { - success: true, - signInURL: authorization, - headers: headersBuilder.toHeaders(), - } - } - - const oauthCallback = async (oauthId: string, request: Request, { code, state }: { code: string; state: string }) => { - const { oauth: providers, cookies, jose, logger, trustedOrigins } = ctx - - const oauthConfig = providers[oauthId] - const isOIDC = isOIDCProvider(oauthConfig) - const cookieState = getCookie(request, cookies.state.name) - const codeVerifier = getCookie(request, cookies.codeVerifier.name) - const cookieNonce = isOIDC ? getOptionalCookie(request, cookies.nonce.name) : undefined - 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)) - .setCookie(cookies.nonce.name, "", getExpiredCookie(cookies.nonce.attributes)) - - if (!timingSafeEqual(cookieState, state)) { - logger?.log("MISMATCHING_STATE", { - structuredData: { - oauth_provider: oauthId, - }, - }) - return Response.json( - { - type: "PROTOCOL", - code: "AUTH_MISMATCHING_STATE", - message: "The provided state passed in the OAuth response does not match the stored token state.", - }, - { headers: clearCookieHeaders.toHeaders(), status: 400 } - ) - } - - const resolvedConfig = isOIDC ? await resolveOpenIDProvider(oauthConfig) : oauthConfig - - const accessToken = await createAccessToken(resolvedConfig, cookieRedirectURI, code, codeVerifier, logger) - - if (isOIDC) { - if (!accessToken.id_token) { - throw new AuraAuthError({ code: "OIDC_ID_TOKEN_INVALID" }) - } - const { issuer, jwks_uri } = resolvedConfig.oidc! - if (!jwks_uri || !cookieNonce || !resolvedConfig.clientId) { - throw new AuraAuthError({ code: "OIDC_ID_TOKEN_INVALID" }) - } - await validateIDToken(accessToken.id_token, { - issuer, - clientId: resolvedConfig.clientId, - nonce: cookieNonce, - jwks_uri, - }) - } - - if (!isRelativeURL(cookieRedirectTo)) { - const origins = await getTrustedOrigins(request, trustedOrigins) - const requestOrigin = await getOriginURL(request, ctx) - let isValid = false - try { - isValid = - origins.length > 0 - ? isTrustedOrigin(cookieRedirectTo, origins) - : isSameOrigin(cookieRedirectTo, requestOrigin) - } catch { - isValid = false - } - if (!isValid) { - logger?.log("POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", { - structuredData: { - redirect_path: cookieRedirectTo, - provider: oauthId, - has_trusted_origins: origins.length > 0, - request_origin: requestOrigin, - }, - }) - throw new AuraAuthError({ code: "POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED" }) - } - } - - const userInfo = await getUserInfo(resolvedConfig, accessToken, logger) - const session = await ctx.sessionStrategy.createSession(userInfo, request) - const csrfToken = await createCSRF(jose) - const tokenPayload = transformToTokenPayload(accessToken) - const providerToken = await ctx.jwtManager.createToken(tokenPayload) - - logger?.log("OAUTH_CALLBACK_SUCCESS", { - structuredData: { - provider: oauthId, - }, - }) - - const headers = clearCookieHeaders - .setHeader("Location", cookieRedirectTo) - .setCookie(cookies.sessionToken.name, session, cookies.sessionToken.attributes) - .setCookie(cookies.csrfToken.name, csrfToken, cookies.csrfToken.attributes) - .setCookie(`${cookies.accessToken.name}.${oauthId}`, providerToken, cookies.accessToken.attributes) - .toHeaders() - - return Response.json({ oauth }, { status: 302, headers: headers }) - } - - // JWT strategy: stateless tokens cannot be revoked server-side - const revokeSession = async (_sessionId: string): Promise => {} - - const destroySession = async (headers: Headers, skipCSRFCheck: boolean = false) => { - await verifyCSRFToken({ headers, skipCSRFCheck, cookies: cookies(), logger, jose: jose as JoseInstance }) - await verifySessionToken({ headers, cookies: cookies(), jwt, logger }) - return cookieConfig.clear() - } - - return { - getSession, - createSession, - getProviderTokens, - refreshSession, - revokeSession, - revokeToken, - isProviderConnected, - refreshUserInfo, - destroySession, - signIn, - oauthCallback, - } -} diff --git a/packages/core/src/session/stateless/createSession.ts b/packages/core/src/session/stateless/createSession.ts new file mode 100644 index 00000000..89696292 --- /dev/null +++ b/packages/core/src/session/stateless/createSession.ts @@ -0,0 +1,17 @@ +import type { TypedJWTPayload } from "@aura-stack/jose" +import type { InternalStatelessContext, User } from "@/@types/session.ts" + +export const __createSession = ({ ctx }: InternalStatelessContext) => { + const { logger, identity, jwtManager } = ctx + return async (session: TypedJWTPayload) => { + if (identity.skipValidation) { + logger?.log("IDENTITY_VALIDATION_DISABLED", { + structuredData: { + identity_validation_disabled: true, + }, + }) + } + const payload = identity.skipValidation ? session : await identity.schemaRegistry.parse(session) + return jwtManager.createToken(payload as unknown as DefaultUser) + } +} diff --git a/packages/core/src/session/stateless/destroySession.ts b/packages/core/src/session/stateless/destroySession.ts new file mode 100644 index 00000000..079d154e --- /dev/null +++ b/packages/core/src/session/stateless/destroySession.ts @@ -0,0 +1,11 @@ +import { verifyCSRFToken, verifySessionToken } from "@/shared/utils.ts" +import type { InternalStatelessContext } from "@/@types/session.ts" + +export const __destroySession = ({ ctx, cookies, cookieManager }: InternalStatelessContext) => { + const { logger, jose, jwtManager } = ctx + return async (headers: Headers, skipCSRFCheck: boolean = false) => { + await verifyCSRFToken({ headers, skipCSRFCheck, cookies: cookies(), logger, jose }) + await verifySessionToken({ headers, cookies: cookies(), jwt: jwtManager, logger }) + return cookieManager.clear() + } +} diff --git a/packages/core/src/session/stateless/getProviderTokens.ts b/packages/core/src/session/stateless/getProviderTokens.ts new file mode 100644 index 00000000..a1eca4f7 --- /dev/null +++ b/packages/core/src/session/stateless/getProviderTokens.ts @@ -0,0 +1,174 @@ +import { HeadersBuilder } from "@aura-stack/router" +import { getCookie } from "@/cookie.ts" +import { AuraAuthError } from "@/shared/errors.ts" +import { secureApiHeaders } from "@/shared/headers.ts" +import { handleApiError } from "@/shared/utils/api.ts" +import { refreshProviderToken } from "@/shared/utils/refresh-tokens.ts" +import { getErrorName, shouldRefresh, toUnionHeaders } from "@/shared/utils.ts" +import type { GetProviderTokensStatefulReturn, InternalStatelessContext } from "@/@types/session.ts" + +export const __getProviderTokens = ({ ctx, cookies }: InternalStatelessContext) => { + const { oauth, logger, identity, jwtManager: jwt } = ctx + + return async (oauthId: string, request: Request): Promise => { + logger?.log("STATELESS_GET_PROVIDER_TOKENS_START", { + structuredData: { + strategy: "stateless", + operation: "getProviderTokens", + oauth_id: oauthId, + }, + }) + + try { + const provider = oauth[oauthId] + if (!provider) { + logger?.log("STATELESS_GET_PROVIDER_TOKENS_PROVIDER_NOT_FOUND", { + structuredData: { + oauth_id: oauthId, + reason: "provider_not_configured", + }, + }) + const { code, message, statusCode } = handleApiError( + new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" }), + "PROVIDER_TOKENS_ERROR", + "Failed to get provider tokens" + ) + return { success: false, error: { code, message }, tokens: null, headers: request.headers, statusCode } + } + + logger?.log("STATELESS_GET_PROVIDER_TOKENS_PROVIDER_FOUND", { + structuredData: { + oauth_id: oauthId, + }, + }) + + const cookieName = `${cookies().accessToken.name}.${oauthId}` + const cookie = getCookie(request, cookieName) + + if (!cookie) { + logger?.log("STATELESS_GET_PROVIDER_TOKENS_NO_COOKIE", { + structuredData: { + oauth_id: oauthId, + cookie_name: cookieName, + reason: "no_access_token_cookie", + }, + }) + const { code, message, statusCode } = handleApiError( + new AuraAuthError({ code: "COOKIE_INVALID_VALUE" }), + "PROVIDER_TOKENS_ERROR", + "Failed to get provider tokens" + ) + return { success: false, error: { code, message }, tokens: null, headers: request.headers, statusCode } + } + + logger?.log("STATELESS_GET_PROVIDER_TOKENS_COOKIE_FOUND", { + structuredData: { + oauth_id: oauthId, + cookie_name: cookieName, + }, + }) + + const decodedToken = await jwt.verifyToken(cookie) + const tokens = await identity.schemaRegistry.parseOAuthTokens(decodedToken) + + logger?.log("STATELESS_GET_PROVIDER_TOKENS_TOKENS_DECODED", { + structuredData: { + oauth_id: oauthId, + has_access_token: Boolean(tokens.accessToken), + has_refresh_token: Boolean(tokens.refreshToken), + expires_at: tokens.expiresAt, + }, + }) + + const refreshWindow = provider.refreshWindow ?? 300 + const needsRefresh = shouldRefresh(tokens, refreshWindow) + + logger?.log("STATELESS_GET_PROVIDER_TOKENS_REFRESH_CHECK", { + structuredData: { + oauth_id: oauthId, + needs_refresh: needsRefresh, + refresh_window: refreshWindow, + expires_at: tokens.expiresAt, + }, + }) + + if (needsRefresh) { + logger?.log("STATELESS_GET_PROVIDER_TOKENS_REFRESHING", { + structuredData: { + oauth_id: oauthId, + reason: "token_expired_or_in_refresh_window", + }, + }) + + try { + const refreshedTokens = await refreshProviderToken(tokens, provider!) + + logger?.log("STATELESS_GET_PROVIDER_TOKENS_REFRESH_SUCCESS", { + structuredData: { + oauth_id: oauthId, + new_expires_at: refreshedTokens.expiresAt, + }, + }) + + const encodedTokens = await jwt.createToken(refreshedTokens as any) + const builder = new HeadersBuilder(secureApiHeaders) + .setCookie(cookieName, encodedTokens, cookies().accessToken.attributes) + .toHeaders() + const newHeaders = toUnionHeaders(builder, request.headers) + + logger?.log("STATELESS_GET_PROVIDER_TOKENS_COOKIE_UPDATED", { + structuredData: { + oauth_id: oauthId, + cookie_name: cookieName, + }, + }) + + return { + success: true, + tokens: refreshedTokens, + headers: newHeaders, + } + } catch (refreshError) { + logger?.log("STATELESS_GET_PROVIDER_TOKENS_REFRESH_ERROR", { + structuredData: { + oauth_id: oauthId, + error_type: getErrorName(refreshError), + error_message: refreshError instanceof Error ? refreshError.message : String(refreshError), + }, + }) + + const { code, message, statusCode } = handleApiError( + refreshError, + "PROVIDER_TOKENS_ERROR", + "Failed to get provider tokens" + ) + return { success: false, error: { code, message }, tokens: null, headers: request.headers, statusCode } + } + } + + logger?.log("STATELESS_GET_PROVIDER_TOKENS_SUCCESS", { + structuredData: { + oauth_id: oauthId, + tokens_returned: true, + }, + }) + + return { + success: true, + tokens, + headers: request.headers, + } + } catch (error) { + logger?.log("STATELESS_GET_PROVIDER_TOKENS_ERROR", { + structuredData: { + oauth_id: oauthId, + error_type: getErrorName(error), + error_message: error instanceof Error ? error.message : String(error), + }, + }) + + const { code, message, statusCode } = handleApiError(error, "PROVIDER_TOKENS_ERROR", "Failed to get provider tokens") + return { success: false, error: { code, message }, tokens: null, headers: request.headers, statusCode } + } + } +} diff --git a/packages/core/src/session/stateless/getSession.ts b/packages/core/src/session/stateless/getSession.ts new file mode 100644 index 00000000..e04ce5c7 --- /dev/null +++ b/packages/core/src/session/stateless/getSession.ts @@ -0,0 +1,73 @@ +import { getErrorName } from "@/shared/utils.ts" +import type { GetStatelessSessionReturn, InternalStatelessContext, Session, User } from "@/@types/session.ts" + +const updateExpires = ({ exp, maxAge, strategy }: { exp: number | undefined; maxAge: number; strategy: string }): Date | null => { + if (!exp) return null + const now = Math.floor(Date.now() / 1000) + switch (strategy) { + case "fixed": + case "absolute": + return null + case "rolling": + return new Date((now + maxAge) * 1000) + case "sliding": { + const threshold = maxAge * 0.25 + if (exp - now < threshold) { + return new Date((now + maxAge) * 1000) + } + return null + } + default: + return null + } +} + +export const __getSession = ({ ctx, cookieManager }: InternalStatelessContext) => { + const { logger, identity, jwtManager, sessionConfig } = ctx + + const maxAge = sessionConfig?.jwt?.maxAge ?? 60 * 60 * 24 * 15 + const strategy = sessionConfig?.jwt?.expirationStrategy ?? "absolute" + + return async (headers: Headers): Promise> => { + const newHeaders = new Headers() + try { + const { sessionToken } = cookieManager.getCookie(headers) + if (!sessionToken) return { session: null, headers: newHeaders } + + const claims = await jwtManager.verifyToken(sessionToken) + const parsedClaims = identity.skipValidation ? claims : await identity.schemaRegistry.parseWithJWT(claims) + const { exp, iat: _iat, mexp: _mexp, ...defaultPayload } = parsedClaims + const userClaims = await identity.schemaRegistry.parse(defaultPayload) + if (!userClaims.sub) return { session: null, headers: newHeaders } + + const session: Session = { + user: userClaims as DefaultUser, + expires: parsedClaims.exp ? new Date(exp * 1000).toISOString() : "", + } + + const expiresAt = updateExpires({ exp, maxAge, strategy }) + if (!expiresAt) { + return { session: { expires: session.expires, user: userClaims }, headers } + } + + const issuedAt = strategy === "absolute" ? parsedClaims.iat : Math.floor(Date.now() / 1000) + const newSessionToken = await jwtManager.createToken({ + ...userClaims, + exp: Math.floor(expiresAt.getTime() / 1000), + iat: issuedAt, + mexp: parsedClaims.mexp, + }) + logger?.log("SESSION_REFRESHED", { structuredData: { strategy: "stateless", expiresAt: expiresAt.toISOString() } }) + return { + session: { + user: userClaims, + expires: expiresAt.toISOString(), + } as unknown as Session, + headers: cookieManager.setCookie({ sessionToken: newSessionToken }), + } + } catch (error) { + logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } }) + return { session: null, headers: newHeaders } + } + } +} diff --git a/packages/core/src/session/stateless/index.ts b/packages/core/src/session/stateless/index.ts new file mode 100644 index 00000000..95849d6c --- /dev/null +++ b/packages/core/src/session/stateless/index.ts @@ -0,0 +1,36 @@ +import { createCookieManager } from "@/session/cookie-manager.ts" +import { __createSession } from "@/session/stateless/createSession.ts" +import { __getSession } from "@/session/stateless/getSession.ts" +import { __destroySession } from "@/session/stateless/destroySession.ts" +import { __oauthCallback } from "@/session/stateless/oauthCallback.ts" +import { __signIn } from "@/session/stateless/signIn.ts" +import { __refreshUserInfo } from "@/session/stateless/refreshUserInfo.ts" +import { __isProviderConnected } from "@/session/stateless/isProviderConnected.ts" +import { __revokeToken } from "@/session/stateless/revokeToken.ts" +import { __refreshSession } from "@/session/stateless/refreshSession.ts" +import { __getProviderTokens } from "@/session/stateless/getProviderTokens.ts" +import type { SessionStrategy, User, JWTStrategyOptions } from "@/@types/index.ts" + +export const createStatelessStrategy = ({ + ctx, + cookies, +}: JWTStrategyOptions): SessionStrategy => { + const cookieConfig = createCookieManager(cookies) + + // JWT strategy: stateless tokens cannot be revoked server-side + const revokeSession = async (_sessionId: string): Promise => {} + + return { + getSession: __getSession({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), + createSession: __createSession({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), + getProviderTokens: __getProviderTokens({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), + refreshSession: __refreshSession({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), + revokeSession, + revokeToken: __revokeToken({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), + isProviderConnected: __isProviderConnected({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), + refreshUserInfo: __refreshUserInfo({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), + destroySession: __destroySession({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), + signIn: __signIn({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), + oauthCallback: __oauthCallback({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), + } +} diff --git a/packages/core/src/session/stateless/isProviderConnected.ts b/packages/core/src/session/stateless/isProviderConnected.ts new file mode 100644 index 00000000..7062528f --- /dev/null +++ b/packages/core/src/session/stateless/isProviderConnected.ts @@ -0,0 +1,28 @@ +import { getCookie } from "@/cookie.ts" +import { getErrorName } from "@/shared/utils.ts" +import type { InternalStatelessContext } from "@/@types/session.ts" + +export const __isProviderConnected = ({ ctx, cookies }: InternalStatelessContext) => { + const { logger, jwtManager: jwt } = ctx + + return async (oauthId: string, headers: Headers): Promise => { + const cookieName = `${cookies().accessToken.name}.${oauthId}` + let cookieValue: string + try { + cookieValue = getCookie(headers, cookieName) + } catch { + logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { + structuredData: { provider: oauthId, hasCookie: false }, + }) + return false + } + + try { + const decodedToken = await jwt.verifyToken(cookieValue) + return !!decodedToken + } catch (error) { + logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } }) + return false + } + } +} diff --git a/packages/core/src/session/stateless/oauthCallback.ts b/packages/core/src/session/stateless/oauthCallback.ts new file mode 100644 index 00000000..996d9c70 --- /dev/null +++ b/packages/core/src/session/stateless/oauthCallback.ts @@ -0,0 +1,114 @@ +import { HeadersBuilder } from "@aura-stack/router" +import { createCSRF } from "@/shared/crypto.ts" +import { AuraAuthError } from "@/shared/errors.ts" +import { cacheControl } from "@/shared/headers.ts" +import { validateIDToken } from "@/shared/oidc/id-token.ts" +import { createAccessToken, getUserInfo } from "@/shared/utils/oauth.ts" +import { timingSafeEqual, transformToTokenPayload } from "@/shared/utils.ts" +import { getCookie, getExpiredCookie, getOptionalCookie } from "@/cookie.ts" +import { getOriginURL, getTrustedOrigins } from "@/shared/utils/authorization.ts" +import { isRelativeURL, isSameOrigin, isTrustedOrigin } from "@/shared/assert.ts" +import { isOIDCProvider, resolveOpenIDProvider } from "@/shared/oidc/resolve-provider.ts" +import type { InternalStatelessContext } from "@/@types/session.ts" + +export const __oauthCallback = ({ ctx }: InternalStatelessContext) => { + return async (oauthId: string, request: Request, { code, state }: { code: string; state: string }) => { + const { oauth: providers, cookies, jose, logger, trustedOrigins } = ctx + + const oauthConfig = providers[oauthId] + const isOIDC = isOIDCProvider(oauthConfig) + const cookieState = getCookie(request, cookies.state.name) + const codeVerifier = getCookie(request, cookies.codeVerifier.name) + const cookieNonce = isOIDC ? getOptionalCookie(request, cookies.nonce.name) : undefined + 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)) + .setCookie(cookies.nonce.name, "", getExpiredCookie(cookies.nonce.attributes)) + + if (!timingSafeEqual(cookieState, state)) { + logger?.log("MISMATCHING_STATE", { + structuredData: { + oauth_provider: oauthId, + }, + }) + return Response.json( + { + type: "PROTOCOL", + code: "AUTH_MISMATCHING_STATE", + message: "The provided state passed in the OAuth response does not match the stored token state.", + }, + { headers: clearCookieHeaders.toHeaders(), status: 400 } + ) + } + + const resolvedConfig = isOIDC ? await resolveOpenIDProvider(oauthConfig) : oauthConfig + const accessToken = await createAccessToken(resolvedConfig, cookieRedirectURI, code, codeVerifier, logger) + + if (isOIDC) { + if (!accessToken.id_token) { + throw new AuraAuthError({ code: "OIDC_ID_TOKEN_INVALID" }) + } + const { issuer, jwks_uri } = resolvedConfig.oidc! + if (!jwks_uri || !cookieNonce || !resolvedConfig.clientId) { + throw new AuraAuthError({ code: "OIDC_ID_TOKEN_INVALID" }) + } + await validateIDToken(accessToken.id_token, { + issuer, + clientId: resolvedConfig.clientId, + nonce: cookieNonce, + jwks_uri, + }) + } + + if (!isRelativeURL(cookieRedirectTo)) { + const origins = await getTrustedOrigins(request, trustedOrigins) + const requestOrigin = await getOriginURL(request, ctx) + let isValid = false + try { + isValid = + origins.length > 0 + ? isTrustedOrigin(cookieRedirectTo, origins) + : isSameOrigin(cookieRedirectTo, requestOrigin) + } catch { + isValid = false + } + if (!isValid) { + logger?.log("POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", { + structuredData: { + redirect_path: cookieRedirectTo, + provider: oauthId, + has_trusted_origins: origins.length > 0, + request_origin: requestOrigin, + }, + }) + throw new AuraAuthError({ code: "POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED" }) + } + } + + const userInfo = await getUserInfo(resolvedConfig, accessToken, logger) + const session = await ctx.sessionStrategy.createSession(userInfo, request) + const csrfToken = await createCSRF(jose) + const tokenPayload = transformToTokenPayload(accessToken) + const providerToken = await ctx.jwtManager.createToken(tokenPayload) + + logger?.log("OAUTH_CALLBACK_SUCCESS", { + structuredData: { + provider: oauthId, + }, + }) + + const headers = clearCookieHeaders + .setHeader("Location", cookieRedirectTo) + .setCookie(cookies.sessionToken.name, session, cookies.sessionToken.attributes) + .setCookie(cookies.csrfToken.name, csrfToken, cookies.csrfToken.attributes) + .setCookie(`${cookies.accessToken.name}.${oauthId}`, providerToken, cookies.accessToken.attributes) + .toHeaders() + + return Response.json({ oauth: oauthId }, { status: 302, headers: headers }) + } +} diff --git a/packages/core/src/session/stateless/refreshSession.ts b/packages/core/src/session/stateless/refreshSession.ts new file mode 100644 index 00000000..0cc88599 --- /dev/null +++ b/packages/core/src/session/stateless/refreshSession.ts @@ -0,0 +1,71 @@ +import { getErrorName, verifyCSRFToken } from "@/shared/utils.ts" +import { updateExpires } from "@/shared/utils/session-strategy.ts" +import type { DeepPartial } from "@/@types/utility.ts" +import type { InternalStatelessContext, Session, User } from "@/@types/session.ts" + +export const __refreshSession = ({ ctx, cookies, cookieManager }: InternalStatelessContext) => { + const { logger, jose, jwtManager: jwt, identity, sessionConfig } = ctx + + const maxAge = sessionConfig?.jwt?.maxAge ?? 60 * 60 * 24 * 15 + const strategy = sessionConfig?.jwt?.expirationStrategy ?? "absolute" + + return async ( + headers: Headers, + session: DeepPartial>, + skipCSRFCheck: boolean = false + ): Promise<{ + session: Session | null + headers: Headers + }> => { + try { + const { sessionToken } = cookieManager.getCookie(headers) + if (!sessionToken) { + return { session: null, headers: cookieManager.clear() } + } + const isValidToken = await verifyCSRFToken({ + headers, + skipCSRFCheck, + cookies: cookies(), + jose, + logger, + }) + if (!isValidToken) { + return { session: null, headers: cookieManager.clear() } + } + const claims = await jwt.verifyToken(sessionToken) + const parsedClaims = identity.skipValidation ? claims : await identity.schemaRegistry.parseWithJWT(claims) + + const { exp, mexp, iat } = parsedClaims + const defaultPayload = identity.skipValidation ? parsedClaims : await identity.schemaRegistry.parse(parsedClaims) + const { sub } = defaultPayload + const sessionPayload = identity.skipValidation + ? session.user + : await identity.schemaRegistry.parseAsPartial(session.user) + + const expiresAt = session.expires + ? new Date(Math.min(Date.now() + maxAge * 1000, new Date(session.expires).getTime())) + : (updateExpires({ exp, maxAge, strategy }) ?? new Date(Date.now() + maxAge * 1000)) + const updatedSession: Session = { + user: { + ...defaultPayload, + ...sessionPayload, + sub, + } as DefaultUser, + expires: expiresAt.toISOString(), + } + const verifiedPayload = await identity.schemaRegistry.parse(updatedSession.user) + const issuedAt = strategy === "absolute" ? iat : Math.floor(Date.now() / 1000) + const newToken = await jwt.createToken({ + ...verifiedPayload, + exp: Math.floor(expiresAt.getTime() / 1000), + iat: issuedAt, + mexp, + }) + updatedSession.expires = new Date(updatedSession.expires).toISOString() + return { session: updatedSession, headers: cookieManager.setCookie({ sessionToken: newToken }) } + } catch (error) { + logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } }) + return { session: null, headers: cookieManager.clear() } + } + } +} diff --git a/packages/core/src/session/stateless/refreshUserInfo.ts b/packages/core/src/session/stateless/refreshUserInfo.ts new file mode 100644 index 00000000..6acc2f58 --- /dev/null +++ b/packages/core/src/session/stateless/refreshUserInfo.ts @@ -0,0 +1,27 @@ +import { HeadersBuilder } from "@aura-stack/router" +import { __createSession } from "./createSession.ts" +import { secureApiHeaders } from "@/shared/headers.ts" +import { getStandardSession, toUnionHeaders } from "@/shared/utils.ts" +import type { TypedJWTPayload } from "@aura-stack/jose" +import type { InternalStatelessContext, User } from "@/@types/session.ts" + +export const __refreshUserInfo = ({ ctx, cookies, cookieManager }: InternalStatelessContext) => { + const { identity, jwtManager: jwt } = ctx + const createSession = __createSession({ ctx, cookies, cookieManager }) + + return async (userInfo: Partial, headers: Headers) => { + const sessionToken = await createSession(userInfo as Partial as TypedJWTPayload) + + const newHeaders = new HeadersBuilder(headers) + .setCookie(cookies().sessionToken.name, sessionToken, cookies().sessionToken.attributes) + .toHeaders() + + const session = await getStandardSession({ + jwt, + identity, + sessionToken, + }) + const mergedHeaders = toUnionHeaders(newHeaders, secureApiHeaders) + return { session, headers: mergedHeaders } + } +} diff --git a/packages/core/src/session/stateless/revokeToken.ts b/packages/core/src/session/stateless/revokeToken.ts new file mode 100644 index 00000000..8ebe63e9 --- /dev/null +++ b/packages/core/src/session/stateless/revokeToken.ts @@ -0,0 +1,40 @@ +import { HeadersBuilder } from "@aura-stack/router" +import { AuraAuthError } from "@/shared/errors.ts" +import { toUnionHeaders } from "@/shared/utils.ts" +import { secureApiHeaders } from "@/shared/headers.ts" +import { getCookie, getExpiredCookie } from "@/cookie.ts" +import { revokeProviderToken } from "@/shared/utils/revoke-token.ts" +import type { InternalStatelessContext } from "@/@types/session.ts" + +export const __revokeToken = ({ ctx, cookies }: InternalStatelessContext) => { + const { logger, identity, jwtManager: jwt, oauth } = ctx + + return async (oauthId: string, headers: Headers, disconnect: boolean): Promise => { + const cookieName = `${cookies().accessToken.name}.${oauthId}` + const cookie = getCookie(headers, cookieName) + const provider = oauth[oauthId] + + if (!provider) { + throw new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" }) + } + + if (!disconnect) { + const decodedToken = await jwt.verifyToken(cookie) + const tokens = await identity.schemaRegistry.parseOAuthTokens(decodedToken) + + logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { + structuredData: { provider: oauthId, hasAccessToken: !!tokens.accessToken }, + }) + + await revokeProviderToken(provider, tokens.accessToken) + + logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", { + structuredData: { provider: oauthId }, + }) + } + const builder = new HeadersBuilder(secureApiHeaders) + .setCookie(cookieName, "", getExpiredCookie(cookies().accessToken.attributes)) + .toHeaders() + return toUnionHeaders(builder, headers) + } +} diff --git a/packages/core/src/session/stateless/signIn.ts b/packages/core/src/session/stateless/signIn.ts new file mode 100644 index 00000000..f2490707 --- /dev/null +++ b/packages/core/src/session/stateless/signIn.ts @@ -0,0 +1,72 @@ +import { HeadersBuilder } from "@aura-stack/router" +import { AuraAuthError } from "@/shared/errors.ts" +import { cacheControl } from "@/shared/headers.ts" +import { createOIDCAuthorizationURL } from "@/shared/oidc/authorization-url.ts" +import { isOIDCProvider, resolveOpenIDProvider } from "@/shared/oidc/resolve-provider.ts" +import { createAuthorizationURL, createRedirectTo, createRedirectURI } from "@/shared/utils/authorization.ts" +import type { InternalStatelessContext } from "@/@types/session.ts" + +export const __signIn = ({ ctx, cookies }: InternalStatelessContext) => { + const { oauth, logger } = ctx + + return async (oauthId: string, request: Request, redirectTo?: string) => { + const provider = oauth[oauthId] + if (!provider) { + throw new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" }) + } + + const redirectURI = await createRedirectURI(request, oauthId, ctx) + const redirectToValue = await createRedirectTo(request, redirectTo, ctx) + + const isOIDC = isOIDCProvider(provider) + logger?.log("SIGN_IN_PROVIDER_TYPE_DETECTED", { + structuredData: { oauth_provider: oauthId, oidc: isOIDC }, + }) + + const resolvedProvider = isOIDC ? await resolveOpenIDProvider(provider!) : provider! + + if (isOIDC) { + logger?.log("OIDC_PROVIDER_RESOLVED", { + structuredData: { oauth_provider: oauthId, oidc: isOIDC }, + }) + } + + let authorization: string + let state: string + let codeVerifier: string + let nonce: string | undefined + + if (isOIDC) { + const result = await createOIDCAuthorizationURL(resolvedProvider, redirectURI, ctx) + authorization = result.authorization + state = result.state + codeVerifier = result.codeVerifier + nonce = result.nonce + } else { + const result = await createAuthorizationURL(resolvedProvider, redirectURI, ctx) + authorization = result.authorization + state = result.state + codeVerifier = result.codeVerifier + } + + logger?.log("SIGN_IN_INITIATED", { + structuredData: { oauth_provider: oauthId, oidc: isOIDC }, + }) + + const headersBuilder = new HeadersBuilder(cacheControl) + .setHeader("Location", authorization) + .setCookie(cookies().state.name, state, cookies().state.attributes) + .setCookie(cookies().redirectURI.name, redirectURI, cookies().redirectURI.attributes) + .setCookie(cookies().redirectTo.name, redirectToValue, cookies().redirectTo.attributes) + .setCookie(cookies().codeVerifier.name, codeVerifier, cookies().codeVerifier.attributes) + + if (nonce) { + headersBuilder.setCookie(cookies().nonce.name, nonce, cookies().nonce.attributes) + } + return { + success: true, + signInURL: authorization, + headers: headersBuilder.toHeaders(), + } + } +} diff --git a/packages/core/src/session/strategy.ts b/packages/core/src/session/strategy.ts index e9127cf8..388d40c0 100644 --- a/packages/core/src/session/strategy.ts +++ b/packages/core/src/session/strategy.ts @@ -1,6 +1,6 @@ import { AuraAuthError } from "@/shared/errors.ts" import { createStatefulStrategy } from "@/session/stateful/index.ts" -import { createStatelessStrategy } from "@/session/stateless.ts" +import { createStatelessStrategy } from "@/session/stateless/index.ts" import type { Identities } from "@/identity/index.ts" import type { FromShapeToObject } from "@/@types/utility.ts" import type { CreateSessionStrategyOptions, SessionStrategy, User } from "@/@types/session.ts" diff --git a/packages/core/src/session/stateful/utils.ts b/packages/core/src/shared/utils/session-strategy.ts similarity index 61% rename from packages/core/src/session/stateful/utils.ts rename to packages/core/src/shared/utils/session-strategy.ts index 72b228de..acb25ede 100644 --- a/packages/core/src/session/stateful/utils.ts +++ b/packages/core/src/shared/utils/session-strategy.ts @@ -27,3 +27,32 @@ export const createDevice = ({ ctx: { sessionConfig } }: InternalStatefulContext }) } } + +export const updateExpires = ({ + exp, + maxAge, + strategy, +}: { + exp: number | undefined + maxAge: number + strategy: string +}): Date | null => { + if (!exp) return null + const now = Math.floor(Date.now() / 1000) + switch (strategy) { + case "fixed": + case "absolute": + return null + case "rolling": + return new Date((now + maxAge) * 1000) + case "sliding": { + const threshold = maxAge * 0.25 + if (exp - now < threshold) { + return new Date((now + maxAge) * 1000) + } + return null + } + default: + return null + } +} From 7605c3c5321ad52f60ee19da5b3e3b4f8c993de4 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Fri, 31 Jul 2026 21:43:14 -0500 Subject: [PATCH 5/7] chore: apply coderabbit --- .../src/session/stateful/destroySession.ts | 6 ++-- .../src/session/stateful/refreshSession.ts | 6 +--- .../session/stateless/getProviderTokens.ts | 5 ++-- .../core/src/session/stateless/getSession.ts | 27 +++--------------- .../src/session/stateless/oauthCallback.ts | 4 +++ .../src/session/stateless/refreshSession.ts | 6 ++-- .../src/session/stateless/refreshUserInfo.ts | 28 ++++--------------- packages/core/src/shared/utils.ts | 1 - 8 files changed, 24 insertions(+), 59 deletions(-) diff --git a/packages/core/src/session/stateful/destroySession.ts b/packages/core/src/session/stateful/destroySession.ts index f8f7e6ba..09c24033 100644 --- a/packages/core/src/session/stateful/destroySession.ts +++ b/packages/core/src/session/stateful/destroySession.ts @@ -1,8 +1,8 @@ import { getErrorName, verifyCSRFToken } from "@/shared/utils.ts" import type { InternalStatefulContext } from "@/@types/session.ts" -export const __destroySession = ({ ctx, cookieManager }: InternalStatefulContext) => { - const { logger, sessionConfig, cookies, jose } = ctx +export const __destroySession = ({ ctx, cookies, cookieManager }: InternalStatefulContext) => { + const { logger, sessionConfig, jose } = ctx return async (headers: Headers, skipCSRFCheck: boolean = false) => { logger?.log("STATEFUL_DESTROY_SESSION_START", { @@ -14,7 +14,7 @@ export const __destroySession = ({ ctx, cookieManager }: InternalStatefulContext await verifyCSRFToken({ headers, - cookies: cookies, + cookies: cookies(), logger, jose: jose, skipCSRFCheck, diff --git a/packages/core/src/session/stateful/refreshSession.ts b/packages/core/src/session/stateful/refreshSession.ts index f6c5c696..d316f5ca 100644 --- a/packages/core/src/session/stateful/refreshSession.ts +++ b/packages/core/src/session/stateful/refreshSession.ts @@ -1,5 +1,4 @@ import { getErrorName, verifyCSRFToken } from "@/shared/utils.ts" -import { createHash, createSecretValue } from "@/shared/crypto.ts" import type { DeepPartial } from "@/@types/utility.ts" import type { InternalStatefulContext, Session, User } from "@/@types/session.ts" @@ -227,11 +226,8 @@ export const __refreshSession = ({ ctx, cookies, cooki }, }) - const secretValue = createSecretValue(64) - const tokenHash = await createHash(secretValue) - return { session: updatedSession, headers: cookieManager.setCookie({ sessionToken: tokenHash }) } + return { session: updatedSession, headers: cookieManager.setCookie({ sessionToken }) } } catch (error) { - console.error("Error refreshing session:", error) logger?.log("STATEFUL_REFRESH_SESSION_ERROR", { structuredData: { error_type: getErrorName(error), diff --git a/packages/core/src/session/stateless/getProviderTokens.ts b/packages/core/src/session/stateless/getProviderTokens.ts index a1eca4f7..7bc1aa40 100644 --- a/packages/core/src/session/stateless/getProviderTokens.ts +++ b/packages/core/src/session/stateless/getProviderTokens.ts @@ -4,7 +4,7 @@ import { AuraAuthError } from "@/shared/errors.ts" import { secureApiHeaders } from "@/shared/headers.ts" import { handleApiError } from "@/shared/utils/api.ts" import { refreshProviderToken } from "@/shared/utils/refresh-tokens.ts" -import { getErrorName, shouldRefresh, toUnionHeaders } from "@/shared/utils.ts" +import { getErrorName, shouldRefresh } from "@/shared/utils.ts" import type { GetProviderTokensStatefulReturn, InternalStatelessContext } from "@/@types/session.ts" export const __getProviderTokens = ({ ctx, cookies }: InternalStatelessContext) => { @@ -114,7 +114,6 @@ export const __getProviderTokens = ({ ctx, cookies }: InternalStatelessContext) const builder = new HeadersBuilder(secureApiHeaders) .setCookie(cookieName, encodedTokens, cookies().accessToken.attributes) .toHeaders() - const newHeaders = toUnionHeaders(builder, request.headers) logger?.log("STATELESS_GET_PROVIDER_TOKENS_COOKIE_UPDATED", { structuredData: { @@ -126,7 +125,7 @@ export const __getProviderTokens = ({ ctx, cookies }: InternalStatelessContext) return { success: true, tokens: refreshedTokens, - headers: newHeaders, + headers: builder, } } catch (refreshError) { logger?.log("STATELESS_GET_PROVIDER_TOKENS_REFRESH_ERROR", { diff --git a/packages/core/src/session/stateless/getSession.ts b/packages/core/src/session/stateless/getSession.ts index e04ce5c7..f3af79f1 100644 --- a/packages/core/src/session/stateless/getSession.ts +++ b/packages/core/src/session/stateless/getSession.ts @@ -1,27 +1,8 @@ import { getErrorName } from "@/shared/utils.ts" +import { secureApiHeaders } from "@/shared/headers.ts" +import { updateExpires } from "@/shared/utils/session-strategy.ts" import type { GetStatelessSessionReturn, InternalStatelessContext, Session, User } from "@/@types/session.ts" -const updateExpires = ({ exp, maxAge, strategy }: { exp: number | undefined; maxAge: number; strategy: string }): Date | null => { - if (!exp) return null - const now = Math.floor(Date.now() / 1000) - switch (strategy) { - case "fixed": - case "absolute": - return null - case "rolling": - return new Date((now + maxAge) * 1000) - case "sliding": { - const threshold = maxAge * 0.25 - if (exp - now < threshold) { - return new Date((now + maxAge) * 1000) - } - return null - } - default: - return null - } -} - export const __getSession = ({ ctx, cookieManager }: InternalStatelessContext) => { const { logger, identity, jwtManager, sessionConfig } = ctx @@ -29,7 +10,7 @@ export const __getSession = ({ ctx, cookieManager }: I const strategy = sessionConfig?.jwt?.expirationStrategy ?? "absolute" return async (headers: Headers): Promise> => { - const newHeaders = new Headers() + const newHeaders = new Headers(secureApiHeaders) try { const { sessionToken } = cookieManager.getCookie(headers) if (!sessionToken) return { session: null, headers: newHeaders } @@ -37,7 +18,7 @@ export const __getSession = ({ ctx, cookieManager }: I const claims = await jwtManager.verifyToken(sessionToken) const parsedClaims = identity.skipValidation ? claims : await identity.schemaRegistry.parseWithJWT(claims) const { exp, iat: _iat, mexp: _mexp, ...defaultPayload } = parsedClaims - const userClaims = await identity.schemaRegistry.parse(defaultPayload) + const userClaims = identity.skipValidation ? defaultPayload : await identity.schemaRegistry.parse(defaultPayload) if (!userClaims.sub) return { session: null, headers: newHeaders } const session: Session = { diff --git a/packages/core/src/session/stateless/oauthCallback.ts b/packages/core/src/session/stateless/oauthCallback.ts index 996d9c70..1dadaa55 100644 --- a/packages/core/src/session/stateless/oauthCallback.ts +++ b/packages/core/src/session/stateless/oauthCallback.ts @@ -16,6 +16,10 @@ export const __oauthCallback = ({ ctx }: InternalStatelessContext) => { const { oauth: providers, cookies, jose, logger, trustedOrigins } = ctx const oauthConfig = providers[oauthId] + if (!oauthConfig) { + throw new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" }) + } + const isOIDC = isOIDCProvider(oauthConfig) const cookieState = getCookie(request, cookies.state.name) const codeVerifier = getCookie(request, cookies.codeVerifier.name) diff --git a/packages/core/src/session/stateless/refreshSession.ts b/packages/core/src/session/stateless/refreshSession.ts index 0cc88599..891bba9f 100644 --- a/packages/core/src/session/stateless/refreshSession.ts +++ b/packages/core/src/session/stateless/refreshSession.ts @@ -35,8 +35,10 @@ export const __refreshSession = ({ ctx, cookies, cooki const claims = await jwt.verifyToken(sessionToken) const parsedClaims = identity.skipValidation ? claims : await identity.schemaRegistry.parseWithJWT(claims) - const { exp, mexp, iat } = parsedClaims - const defaultPayload = identity.skipValidation ? parsedClaims : await identity.schemaRegistry.parse(parsedClaims) + const { exp, mexp, iat, ...claimsWithoutJWTFields } = parsedClaims + const defaultPayload = identity.skipValidation + ? claimsWithoutJWTFields + : await identity.schemaRegistry.parse(claimsWithoutJWTFields) const { sub } = defaultPayload const sessionPayload = identity.skipValidation ? session.user diff --git a/packages/core/src/session/stateless/refreshUserInfo.ts b/packages/core/src/session/stateless/refreshUserInfo.ts index 6acc2f58..9308bb13 100644 --- a/packages/core/src/session/stateless/refreshUserInfo.ts +++ b/packages/core/src/session/stateless/refreshUserInfo.ts @@ -1,27 +1,11 @@ -import { HeadersBuilder } from "@aura-stack/router" -import { __createSession } from "./createSession.ts" -import { secureApiHeaders } from "@/shared/headers.ts" -import { getStandardSession, toUnionHeaders } from "@/shared/utils.ts" -import type { TypedJWTPayload } from "@aura-stack/jose" +import { __createSession } from "@/session/stateless/createSession.ts" +import { __refreshSession } from "@/session/stateless/refreshSession.ts" import type { InternalStatelessContext, User } from "@/@types/session.ts" export const __refreshUserInfo = ({ ctx, cookies, cookieManager }: InternalStatelessContext) => { - const { identity, jwtManager: jwt } = ctx - const createSession = __createSession({ ctx, cookies, cookieManager }) - - return async (userInfo: Partial, headers: Headers) => { - const sessionToken = await createSession(userInfo as Partial as TypedJWTPayload) - - const newHeaders = new HeadersBuilder(headers) - .setCookie(cookies().sessionToken.name, sessionToken, cookies().sessionToken.attributes) - .toHeaders() - - const session = await getStandardSession({ - jwt, - identity, - sessionToken, - }) - const mergedHeaders = toUnionHeaders(newHeaders, secureApiHeaders) - return { session, headers: mergedHeaders } + const refreshSession = __refreshSession({ ctx, cookies, cookieManager }) + return async (userInfo: Partial, headers: Headers, skipCSRFCheck?: boolean) => { + const value = await refreshSession(headers, { user: userInfo }, skipCSRFCheck) + return value as any } } diff --git a/packages/core/src/shared/utils.ts b/packages/core/src/shared/utils.ts index b2242594..f4c9a908 100644 --- a/packages/core/src/shared/utils.ts +++ b/packages/core/src/shared/utils.ts @@ -170,7 +170,6 @@ export const verifyCSRFToken = async ({ csrfToken = getCookie(headers, cookies.csrfToken.name) } catch (cause) { logger?.log("CSRF_TOKEN_MISSING") - console.error("CSRF token missing:", headers, cookies) throw new AuraAuthError({ code: "CSRF_TOKEN_MISSING", cause }) } logger?.log("CSRF_TOKEN_REQUESTED", { From f35559d9fd91e5131eb95493f3f237fe6e5c9b9a Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Fri, 31 Jul 2026 22:17:05 -0500 Subject: [PATCH 6/7] refactor: clean up code --- packages/core/src/@types/config.ts | 27 +++++++- packages/core/src/@types/session.ts | 66 +------------------ packages/core/src/router/context.ts | 7 +- .../src/session/stateful/createSession.ts | 2 +- .../src/session/stateful/destroySession.ts | 4 +- .../src/session/stateful/getProviderTokens.ts | 4 +- .../core/src/session/stateful/getSession.ts | 2 +- packages/core/src/session/stateful/index.ts | 56 ++++++++-------- .../session/stateful/isProviderConnected.ts | 4 +- .../src/session/stateful/oauthCallback.ts | 4 +- .../src/session/stateful/refreshSession.ts | 4 +- .../src/session/stateful/refreshUserInfo.ts | 6 +- .../src/session/stateful/revokeSession.ts | 4 +- .../core/src/session/stateful/revokeToken.ts | 4 +- packages/core/src/session/stateful/signIn.ts | 4 +- .../src/session/stateless/createSession.ts | 4 +- .../src/session/stateless/destroySession.ts | 4 +- .../session/stateless/getProviderTokens.ts | 4 +- .../core/src/session/stateless/getSession.ts | 4 +- packages/core/src/session/stateless/index.ts | 53 +++++++-------- .../session/stateless/isProviderConnected.ts | 4 +- .../src/session/stateless/oauthCallback.ts | 4 +- .../src/session/stateless/refreshSession.ts | 4 +- .../src/session/stateless/refreshUserInfo.ts | 8 +-- .../core/src/session/stateless/revokeToken.ts | 4 +- packages/core/src/session/stateless/signIn.ts | 4 +- packages/core/src/session/strategy.ts | 49 +++++--------- packages/core/src/shared/utils.ts | 12 +++- .../core/src/shared/utils/session-strategy.ts | 2 +- 29 files changed, 149 insertions(+), 209 deletions(-) diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index 5fbf4ca3..d88b1d8a 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -15,13 +15,14 @@ import type { OAuthProviderCredentials, OAuthProviderRecord, JWTKey, - JWTManager, SessionConfig, SessionStrategy, User, Awaitable, StatefulStrategyConfig, StatelessStrategyConfig, + TypedJWTPayload, + CookieManager, } from "@/@types/index.ts" import type { ZodObject } from "zod" import type { SerializeOptions } from "@aura-stack/router/cookie" @@ -542,6 +543,30 @@ export type InternalContextForStateful = Omit, "sessio sessionConfig: StatefulStrategyConfig } +/** Inputs for constructing a session strategy implementation for a given identity schema. */ +export interface CreateSessionStrategyOptions { + ctx: InternalContext + cookies: () => InternalCookieStoreConfig +} + +export interface InternalStatefulContext { + ctx: InternalContextForStateful + cookies: () => InternalCookieStoreConfig + cookieManager: CookieManager +} + +export interface InternalStatelessContext { + ctx: InternalContextForStateless + cookies: () => InternalCookieStoreConfig + cookieManager: CookieManager +} + +/** Minimal token issue/verify surface used by session code paths. */ +export type JWTManager = { + createToken(user: TypedJWTPayload>): Promise + verifyToken(token: string): Promise> +} + export type InternalContextForStateless = Omit, "sessionConfig"> & { sessionConfig: StatelessStrategyConfig } diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index 0f34ec58..794fff73 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -1,22 +1,8 @@ import { identitySchema } from "@/identity/zod.ts" import type { JWK } from "@aura-stack/jose/jose" import type { infer as Infer } from "zod/v4/core" -import type { TypedJWTPayload } from "@aura-stack/jose" -import type { - InternalCookieStoreConfig, - InternalLogger, - JoseInstance, - SchemaRegistryContext, - DeepPartial, - FromShapeToObject, - Prettify, - Identities, - OAuthProviderRecord, - InternalContext, - InternalContextForStateful, - InternalContextForStateless, -} from "@/@types/index.ts" import type { DatabaseAdapter } from "@/@types/adapter.ts" +import type { DeepPartial, Prettify } from "@/@types/index.ts" import type { createCookieManager } from "@/session/cookie-manager.ts" /** Application user type, inferred from the configured identity schema (defaults to the built-in user shape). */ @@ -304,44 +290,6 @@ export interface SessionStrategy { oauthCallback(oauth: string, request: Request, { code, state }: { code: string; state: string }): Promise } -/** Inputs for constructing a session strategy implementation for a given identity schema. */ -export interface CreateSessionStrategyOptions { - config?: SessionConfig - jose: JoseInstance & User> - cookies: () => InternalCookieStoreConfig - logger?: InternalLogger - identity: SchemaRegistryContext - oauth: OAuthProviderRecord - ctx: InternalContext -} - -/** Options specialized for the JWT-backed session strategy. */ -export interface JWTStrategyOptions { - ctx: InternalContext - config?: StatelessStrategyConfig - jose: JoseInstance - logger?: InternalLogger - cookies: () => InternalCookieStoreConfig - identity: SchemaRegistryContext - oauth: OAuthProviderRecord -} - -export interface DatabaseStrategyOptions { - ctx: InternalContext - config: StatefulStrategyConfig - jose: JoseInstance - logger?: InternalLogger - cookies: () => InternalCookieStoreConfig - identity: SchemaRegistryContext - oauth: OAuthProviderRecord -} - -/** Minimal token issue/verify surface used by session code paths. */ -export type JWTManager = { - createToken(user: TypedJWTPayload>): Promise - verifyToken(token: string): Promise> -} - export interface OAuthTokenPayload { /** * The raw access token string issued by the OAuth provider. @@ -384,15 +332,3 @@ export interface OAuthTokenPayload { } export type CookieManager = ReturnType - -export interface InternalStatefulContext { - ctx: InternalContextForStateful - cookies: () => InternalCookieStoreConfig - cookieManager: CookieManager -} - -export interface InternalStatelessContext { - ctx: InternalContextForStateless - cookies: () => InternalCookieStoreConfig - cookieManager: CookieManager -} diff --git a/packages/core/src/router/context.ts b/packages/core/src/router/context.ts index 68a9ac78..59b3efba 100644 --- a/packages/core/src/router/context.ts +++ b/packages/core/src/router/context.ts @@ -66,13 +66,8 @@ export const createContext = ctx.sessionStrategy = createSessionStrategy({ - cookies: () => ctx.cookies, ctx, - jose: ctx.jose, - config: config?.session, - logger: ctx.logger, - identity: ctx.identity, - oauth: ctx.oauth, + cookies: () => ctx.cookies, }) return ctx } diff --git a/packages/core/src/session/stateful/createSession.ts b/packages/core/src/session/stateful/createSession.ts index aa4b2a4f..5815247a 100644 --- a/packages/core/src/session/stateful/createSession.ts +++ b/packages/core/src/session/stateful/createSession.ts @@ -4,7 +4,7 @@ import { createDevice as __createDevice } from "@/shared/utils/session-strategy. import type { TypedJWTPayload } from "@aura-stack/jose" import type { InternalStatefulContext, User } from "@/@types/index.ts" -export const __createSession = ({ ctx, cookies, cookieManager }: InternalStatefulContext) => { +export const createSession = ({ ctx, cookies, cookieManager }: InternalStatefulContext) => { const { logger, sessionConfig } = ctx const createDevice = __createDevice({ ctx, cookies, cookieManager }) diff --git a/packages/core/src/session/stateful/destroySession.ts b/packages/core/src/session/stateful/destroySession.ts index 09c24033..1e948586 100644 --- a/packages/core/src/session/stateful/destroySession.ts +++ b/packages/core/src/session/stateful/destroySession.ts @@ -1,7 +1,7 @@ import { getErrorName, verifyCSRFToken } from "@/shared/utils.ts" -import type { InternalStatefulContext } from "@/@types/session.ts" +import type { InternalStatefulContext } from "@/@types/index.ts" -export const __destroySession = ({ ctx, cookies, cookieManager }: InternalStatefulContext) => { +export const destroySession = ({ ctx, cookies, cookieManager }: InternalStatefulContext) => { const { logger, sessionConfig, jose } = ctx return async (headers: Headers, skipCSRFCheck: boolean = false) => { diff --git a/packages/core/src/session/stateful/getProviderTokens.ts b/packages/core/src/session/stateful/getProviderTokens.ts index 13eca53f..ae1a437d 100644 --- a/packages/core/src/session/stateful/getProviderTokens.ts +++ b/packages/core/src/session/stateful/getProviderTokens.ts @@ -2,9 +2,9 @@ import { AuraAuthError } from "@/shared/errors.ts" import { handleApiError } from "@/shared/utils/api.ts" import { getErrorName, shouldRefresh } from "@/shared/utils.ts" import { refreshProviderToken } from "@/shared/utils/refresh-tokens.ts" -import type { GetProviderTokensStatefulReturn, InternalStatefulContext } from "@/@types/session.ts" +import type { GetProviderTokensStatefulReturn, InternalStatefulContext } from "@/@types/index.ts" -export const __getProviderTokens = ({ ctx, cookieManager }: InternalStatefulContext) => { +export const getProviderTokens = ({ ctx, cookieManager }: InternalStatefulContext) => { const { oauth, logger, sessionConfig } = ctx return async (oauthId: string, request: Request): Promise => { diff --git a/packages/core/src/session/stateful/getSession.ts b/packages/core/src/session/stateful/getSession.ts index f6246cd8..da3db719 100644 --- a/packages/core/src/session/stateful/getSession.ts +++ b/packages/core/src/session/stateful/getSession.ts @@ -3,7 +3,7 @@ import { AuraAuthError } from "@/shared/errors.ts" import { secureApiHeaders } from "@/shared/headers.ts" import type { GetStatefulSessionReturn, User, InternalStatefulContext } from "@/@types/index.ts" -export const __getSession = ({ ctx, cookieManager }: InternalStatefulContext) => { +export const getSession = ({ ctx, cookieManager }: InternalStatefulContext) => { const { logger, sessionConfig } = ctx return async (headers: Headers): Promise> => { diff --git a/packages/core/src/session/stateful/index.ts b/packages/core/src/session/stateful/index.ts index f94c4349..35e51cd8 100644 --- a/packages/core/src/session/stateful/index.ts +++ b/packages/core/src/session/stateful/index.ts @@ -1,34 +1,30 @@ -import { __createSession } from "@/session/stateful/createSession.ts" -import { __destroySession } from "@/session/stateful/destroySession.ts" -import { __getProviderTokens } from "@/session/stateful/getProviderTokens.ts" -import { __getSession } from "@/session/stateful/getSession.ts" -import { __isProviderConnected } from "@/session/stateful/isProviderConnected.ts" -import { __oauthCallback } from "@/session/stateful/oauthCallback.ts" -import { __refreshSession } from "@/session/stateful/refreshSession.ts" -import { __refreshUserInfo } from "@/session/stateful/refreshUserInfo.ts" -import { __revokeSession } from "@/session/stateful/revokeSession.ts" -import { __revokeToken } from "@/session/stateful/revokeToken.ts" -import { __signIn } from "@/session/stateful/signIn.ts" -import { createCookieManager } from "@/session/cookie-manager.ts" -import type { DatabaseStrategyOptions, SessionStrategy, User } from "@/@types/session.ts" - -export const createStatefulStrategy = ({ - cookies, - ctx, -}: DatabaseStrategyOptions): SessionStrategy => { - const cookieManager = createCookieManager(cookies) +import { signIn } from "@/session/stateful/signIn.ts" +import { getSession } from "@/session/stateful/getSession.ts" +import { revokeToken } from "@/session/stateful/revokeToken.ts" +import { createSession } from "@/session/stateful/createSession.ts" +import { oauthCallback } from "@/session/stateful/oauthCallback.ts" +import { revokeSession } from "@/session/stateful/revokeSession.ts" +import { destroySession } from "@/session/stateful/destroySession.ts" +import { refreshSession } from "@/session/stateful/refreshSession.ts" +import { refreshUserInfo } from "@/session/stateful/refreshUserInfo.ts" +import { getProviderTokens } from "@/session/stateful/getProviderTokens.ts" +import { isProviderConnected } from "@/session/stateful/isProviderConnected.ts" +import type { SessionStrategy, User, InternalStatefulContext } from "@/@types/index.ts" +export const createStatefulStrategy = ( + ctx: InternalStatefulContext +): SessionStrategy => { return { - refreshUserInfo: __refreshUserInfo({ ctx: ctx as any, cookies, cookieManager }), - getSession: __getSession({ ctx: ctx as any, cookies, cookieManager }), - createSession: __createSession({ ctx: ctx as any, cookies, cookieManager }), - refreshSession: __refreshSession({ ctx: ctx as any, cookies, cookieManager }), - revokeSession: __revokeSession({ ctx: ctx as any, cookies, cookieManager }), - revokeToken: __revokeToken({ ctx: ctx as any, cookies, cookieManager }), - destroySession: __destroySession({ ctx: ctx as any, cookies, cookieManager }), - getProviderTokens: __getProviderTokens({ ctx: ctx as any, cookies, cookieManager }), - isProviderConnected: __isProviderConnected({ ctx: ctx as any, cookies, cookieManager }), - signIn: __signIn({ ctx: ctx as any, cookies, cookieManager }), - oauthCallback: __oauthCallback({ ctx: ctx as any, cookies, cookieManager }), + refreshUserInfo: refreshUserInfo(ctx), + getSession: getSession(ctx), + createSession: createSession(ctx), + refreshSession: refreshSession(ctx), + revokeSession: revokeSession(ctx), + revokeToken: revokeToken(ctx), + destroySession: destroySession(ctx), + getProviderTokens: getProviderTokens(ctx), + isProviderConnected: isProviderConnected(ctx), + signIn: signIn(ctx), + oauthCallback: oauthCallback(ctx), } } diff --git a/packages/core/src/session/stateful/isProviderConnected.ts b/packages/core/src/session/stateful/isProviderConnected.ts index 875fb517..b926f23e 100644 --- a/packages/core/src/session/stateful/isProviderConnected.ts +++ b/packages/core/src/session/stateful/isProviderConnected.ts @@ -1,7 +1,7 @@ import { getErrorName } from "@/shared/utils.ts" -import type { InternalStatefulContext } from "@/@types/session.ts" +import type { InternalStatefulContext } from "@/@types/index.ts" -export const __isProviderConnected = ({ ctx, cookieManager }: InternalStatefulContext) => { +export const isProviderConnected = ({ ctx, cookieManager }: InternalStatefulContext) => { const { logger, sessionConfig } = ctx return async (oauthId: string, headers: Headers): Promise => { diff --git a/packages/core/src/session/stateful/oauthCallback.ts b/packages/core/src/session/stateful/oauthCallback.ts index c2e63c11..6e17f338 100644 --- a/packages/core/src/session/stateful/oauthCallback.ts +++ b/packages/core/src/session/stateful/oauthCallback.ts @@ -7,9 +7,9 @@ import { getOriginURL, getTrustedOrigins } from "@/shared/utils/authorization.ts import { isRelativeURL, isSameOrigin, isTrustedOrigin } from "@/shared/assert.ts" import { createDevice as __createDevice } from "@/shared/utils/session-strategy.ts" import { isOIDCProvider, resolveOpenIDProvider } from "@/shared/oidc/resolve-provider.ts" -import type { InternalStatefulContext } from "@/@types/session.ts" +import type { InternalStatefulContext } from "@/@types/index.ts" -export const __oauthCallback = ({ ctx, cookies, cookieManager }: InternalStatefulContext) => { +export const oauthCallback = ({ ctx, cookies, cookieManager }: InternalStatefulContext) => { const { logger, jose, oauth, sessionConfig } = ctx const createDevice = __createDevice({ ctx, cookies, cookieManager }) diff --git a/packages/core/src/session/stateful/refreshSession.ts b/packages/core/src/session/stateful/refreshSession.ts index d316f5ca..2645e7cb 100644 --- a/packages/core/src/session/stateful/refreshSession.ts +++ b/packages/core/src/session/stateful/refreshSession.ts @@ -1,8 +1,8 @@ import { getErrorName, verifyCSRFToken } from "@/shared/utils.ts" import type { DeepPartial } from "@/@types/utility.ts" -import type { InternalStatefulContext, Session, User } from "@/@types/session.ts" +import type { InternalStatefulContext, Session, User } from "@/@types/index.ts" -export const __refreshSession = ({ ctx, cookies, cookieManager }: InternalStatefulContext) => { +export const refreshSession = ({ ctx, cookies, cookieManager }: InternalStatefulContext) => { const { logger, sessionConfig, jose } = ctx return async ( diff --git a/packages/core/src/session/stateful/refreshUserInfo.ts b/packages/core/src/session/stateful/refreshUserInfo.ts index 24c6799e..f15c48ee 100644 --- a/packages/core/src/session/stateful/refreshUserInfo.ts +++ b/packages/core/src/session/stateful/refreshUserInfo.ts @@ -1,7 +1,7 @@ -import { __refreshSession } from "./refreshSession.ts" -import type { InternalStatefulContext, User } from "@/@types/session.ts" +import { refreshSession as __refreshSession } from "@/session/stateful/refreshSession.ts" +import type { InternalStatefulContext, User } from "@/@types/index.ts" -export const __refreshUserInfo = (ctx: InternalStatefulContext) => { +export const refreshUserInfo = (ctx: InternalStatefulContext) => { const refreshSession = __refreshSession(ctx) return async (userInfo: Partial, headers: Headers, skipCSRFCheck?: boolean) => { diff --git a/packages/core/src/session/stateful/revokeSession.ts b/packages/core/src/session/stateful/revokeSession.ts index 4b42fe79..0ed4a80a 100644 --- a/packages/core/src/session/stateful/revokeSession.ts +++ b/packages/core/src/session/stateful/revokeSession.ts @@ -1,7 +1,7 @@ import { AuraAuthError } from "@/shared/errors.ts" -import type { InternalStatefulContext } from "@/@types/session.ts" +import type { InternalStatefulContext } from "@/@types/index.ts" -export const __revokeSession = ({ ctx }: InternalStatefulContext) => { +export const revokeSession = ({ ctx }: InternalStatefulContext) => { const { logger, sessionConfig } = ctx return async (sessionId: string): Promise => { diff --git a/packages/core/src/session/stateful/revokeToken.ts b/packages/core/src/session/stateful/revokeToken.ts index 8a827a43..88cd2cf1 100644 --- a/packages/core/src/session/stateful/revokeToken.ts +++ b/packages/core/src/session/stateful/revokeToken.ts @@ -4,9 +4,9 @@ import { AuraAuthError } from "@/shared/errors.ts" import { secureApiHeaders } from "@/shared/headers.ts" import { revokeProviderToken } from "@/shared/utils/revoke-token.ts" import { getErrorName, toUnionHeaders } from "@/shared/utils.ts" -import type { InternalStatefulContext } from "@/@types/session.ts" +import type { InternalStatefulContext } from "@/@types/index.ts" -export const __revokeToken = ({ ctx, cookieManager }: InternalStatefulContext) => { +export const revokeToken = ({ ctx, cookieManager }: InternalStatefulContext) => { const { oauth, logger, sessionConfig, cookies } = ctx return async (oauthId: string, headers: Headers, disconnect: boolean): Promise => { diff --git a/packages/core/src/session/stateful/signIn.ts b/packages/core/src/session/stateful/signIn.ts index d6120031..dd30d444 100644 --- a/packages/core/src/session/stateful/signIn.ts +++ b/packages/core/src/session/stateful/signIn.ts @@ -5,9 +5,9 @@ import { createFingerprint, getDeviceInfo } from "@/shared/utils.ts" import { createOIDCAuthorizationURL } from "@/shared/oidc/authorization-url.ts" import { isOIDCProvider, resolveOpenIDProvider } from "@/shared/oidc/resolve-provider.ts" import { createAuthorizationURL, createRedirectTo, createRedirectURI } from "@/shared/utils/authorization.ts" -import type { InternalStatefulContext } from "@/@types/session.ts" +import type { InternalStatefulContext } from "@/@types/index.ts" -export const __signIn = ({ ctx }: InternalStatefulContext) => { +export const signIn = ({ ctx }: InternalStatefulContext) => { const { logger, oauth, sessionConfig } = ctx return async (oauthId: string, request: Request, redirectTo?: string) => { diff --git a/packages/core/src/session/stateless/createSession.ts b/packages/core/src/session/stateless/createSession.ts index 89696292..513cfa39 100644 --- a/packages/core/src/session/stateless/createSession.ts +++ b/packages/core/src/session/stateless/createSession.ts @@ -1,7 +1,7 @@ import type { TypedJWTPayload } from "@aura-stack/jose" -import type { InternalStatelessContext, User } from "@/@types/session.ts" +import type { InternalStatelessContext, User } from "@/@types/index.ts" -export const __createSession = ({ ctx }: InternalStatelessContext) => { +export const createSession = ({ ctx }: InternalStatelessContext) => { const { logger, identity, jwtManager } = ctx return async (session: TypedJWTPayload) => { if (identity.skipValidation) { diff --git a/packages/core/src/session/stateless/destroySession.ts b/packages/core/src/session/stateless/destroySession.ts index 079d154e..1a267794 100644 --- a/packages/core/src/session/stateless/destroySession.ts +++ b/packages/core/src/session/stateless/destroySession.ts @@ -1,7 +1,7 @@ import { verifyCSRFToken, verifySessionToken } from "@/shared/utils.ts" -import type { InternalStatelessContext } from "@/@types/session.ts" +import type { InternalStatelessContext } from "@/@types/index.ts" -export const __destroySession = ({ ctx, cookies, cookieManager }: InternalStatelessContext) => { +export const destroySession = ({ ctx, cookies, cookieManager }: InternalStatelessContext) => { const { logger, jose, jwtManager } = ctx return async (headers: Headers, skipCSRFCheck: boolean = false) => { await verifyCSRFToken({ headers, skipCSRFCheck, cookies: cookies(), logger, jose }) diff --git a/packages/core/src/session/stateless/getProviderTokens.ts b/packages/core/src/session/stateless/getProviderTokens.ts index 7bc1aa40..9c8b4815 100644 --- a/packages/core/src/session/stateless/getProviderTokens.ts +++ b/packages/core/src/session/stateless/getProviderTokens.ts @@ -5,9 +5,9 @@ import { secureApiHeaders } from "@/shared/headers.ts" import { handleApiError } from "@/shared/utils/api.ts" import { refreshProviderToken } from "@/shared/utils/refresh-tokens.ts" import { getErrorName, shouldRefresh } from "@/shared/utils.ts" -import type { GetProviderTokensStatefulReturn, InternalStatelessContext } from "@/@types/session.ts" +import type { GetProviderTokensStatefulReturn, InternalStatelessContext } from "@/@types/index.ts" -export const __getProviderTokens = ({ ctx, cookies }: InternalStatelessContext) => { +export const getProviderTokens = ({ ctx, cookies }: InternalStatelessContext) => { const { oauth, logger, identity, jwtManager: jwt } = ctx return async (oauthId: string, request: Request): Promise => { diff --git a/packages/core/src/session/stateless/getSession.ts b/packages/core/src/session/stateless/getSession.ts index f3af79f1..c18633fb 100644 --- a/packages/core/src/session/stateless/getSession.ts +++ b/packages/core/src/session/stateless/getSession.ts @@ -1,9 +1,9 @@ import { getErrorName } from "@/shared/utils.ts" import { secureApiHeaders } from "@/shared/headers.ts" import { updateExpires } from "@/shared/utils/session-strategy.ts" -import type { GetStatelessSessionReturn, InternalStatelessContext, Session, User } from "@/@types/session.ts" +import type { GetStatelessSessionReturn, InternalStatelessContext, Session, User } from "@/@types/index.ts" -export const __getSession = ({ ctx, cookieManager }: InternalStatelessContext) => { +export const getSession = ({ ctx, cookieManager }: InternalStatelessContext) => { const { logger, identity, jwtManager, sessionConfig } = ctx const maxAge = sessionConfig?.jwt?.maxAge ?? 60 * 60 * 24 * 15 diff --git a/packages/core/src/session/stateless/index.ts b/packages/core/src/session/stateless/index.ts index 95849d6c..6381e352 100644 --- a/packages/core/src/session/stateless/index.ts +++ b/packages/core/src/session/stateless/index.ts @@ -1,36 +1,31 @@ -import { createCookieManager } from "@/session/cookie-manager.ts" -import { __createSession } from "@/session/stateless/createSession.ts" -import { __getSession } from "@/session/stateless/getSession.ts" -import { __destroySession } from "@/session/stateless/destroySession.ts" -import { __oauthCallback } from "@/session/stateless/oauthCallback.ts" -import { __signIn } from "@/session/stateless/signIn.ts" -import { __refreshUserInfo } from "@/session/stateless/refreshUserInfo.ts" -import { __isProviderConnected } from "@/session/stateless/isProviderConnected.ts" -import { __revokeToken } from "@/session/stateless/revokeToken.ts" -import { __refreshSession } from "@/session/stateless/refreshSession.ts" -import { __getProviderTokens } from "@/session/stateless/getProviderTokens.ts" -import type { SessionStrategy, User, JWTStrategyOptions } from "@/@types/index.ts" +import { signIn } from "@/session/stateless/signIn.ts" +import { getSession } from "@/session/stateless/getSession.ts" +import { revokeToken } from "@/session/stateless/revokeToken.ts" +import { oauthCallback } from "@/session/stateless/oauthCallback.ts" +import { createSession } from "@/session/stateless/createSession.ts" +import { destroySession } from "@/session/stateless/destroySession.ts" +import { refreshSession } from "@/session/stateless/refreshSession.ts" +import { refreshUserInfo } from "@/session/stateless/refreshUserInfo.ts" +import { getProviderTokens } from "@/session/stateless/getProviderTokens.ts" +import { isProviderConnected } from "@/session/stateless/isProviderConnected.ts" +import type { SessionStrategy, User, InternalStatelessContext } from "@/@types/index.ts" -export const createStatelessStrategy = ({ - ctx, - cookies, -}: JWTStrategyOptions): SessionStrategy => { - const cookieConfig = createCookieManager(cookies) - - // JWT strategy: stateless tokens cannot be revoked server-side +export const createStatelessStrategy = ( + ctx: InternalStatelessContext +): SessionStrategy => { const revokeSession = async (_sessionId: string): Promise => {} return { - getSession: __getSession({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), - createSession: __createSession({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), - getProviderTokens: __getProviderTokens({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), - refreshSession: __refreshSession({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), + getSession: getSession(ctx), + createSession: createSession(ctx), + getProviderTokens: getProviderTokens(ctx), + refreshSession: refreshSession(ctx), revokeSession, - revokeToken: __revokeToken({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), - isProviderConnected: __isProviderConnected({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), - refreshUserInfo: __refreshUserInfo({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), - destroySession: __destroySession({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), - signIn: __signIn({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), - oauthCallback: __oauthCallback({ ctx: ctx as any, cookies, cookieManager: cookieConfig }), + revokeToken: revokeToken(ctx), + isProviderConnected: isProviderConnected(ctx), + refreshUserInfo: refreshUserInfo(ctx), + destroySession: destroySession(ctx), + signIn: signIn(ctx), + oauthCallback: oauthCallback(ctx), } } diff --git a/packages/core/src/session/stateless/isProviderConnected.ts b/packages/core/src/session/stateless/isProviderConnected.ts index 7062528f..263f9533 100644 --- a/packages/core/src/session/stateless/isProviderConnected.ts +++ b/packages/core/src/session/stateless/isProviderConnected.ts @@ -1,8 +1,8 @@ import { getCookie } from "@/cookie.ts" import { getErrorName } from "@/shared/utils.ts" -import type { InternalStatelessContext } from "@/@types/session.ts" +import type { InternalStatelessContext } from "@/@types/index.ts" -export const __isProviderConnected = ({ ctx, cookies }: InternalStatelessContext) => { +export const isProviderConnected = ({ ctx, cookies }: InternalStatelessContext) => { const { logger, jwtManager: jwt } = ctx return async (oauthId: string, headers: Headers): Promise => { diff --git a/packages/core/src/session/stateless/oauthCallback.ts b/packages/core/src/session/stateless/oauthCallback.ts index 1dadaa55..f3b0780e 100644 --- a/packages/core/src/session/stateless/oauthCallback.ts +++ b/packages/core/src/session/stateless/oauthCallback.ts @@ -9,9 +9,9 @@ import { getCookie, getExpiredCookie, getOptionalCookie } from "@/cookie.ts" import { getOriginURL, getTrustedOrigins } from "@/shared/utils/authorization.ts" import { isRelativeURL, isSameOrigin, isTrustedOrigin } from "@/shared/assert.ts" import { isOIDCProvider, resolveOpenIDProvider } from "@/shared/oidc/resolve-provider.ts" -import type { InternalStatelessContext } from "@/@types/session.ts" +import type { InternalStatelessContext } from "@/@types/index.ts" -export const __oauthCallback = ({ ctx }: InternalStatelessContext) => { +export const oauthCallback = ({ ctx }: InternalStatelessContext) => { return async (oauthId: string, request: Request, { code, state }: { code: string; state: string }) => { const { oauth: providers, cookies, jose, logger, trustedOrigins } = ctx diff --git a/packages/core/src/session/stateless/refreshSession.ts b/packages/core/src/session/stateless/refreshSession.ts index 891bba9f..6784b254 100644 --- a/packages/core/src/session/stateless/refreshSession.ts +++ b/packages/core/src/session/stateless/refreshSession.ts @@ -1,9 +1,9 @@ import { getErrorName, verifyCSRFToken } from "@/shared/utils.ts" import { updateExpires } from "@/shared/utils/session-strategy.ts" import type { DeepPartial } from "@/@types/utility.ts" -import type { InternalStatelessContext, Session, User } from "@/@types/session.ts" +import type { InternalStatelessContext, Session, User } from "@/@types/index.ts" -export const __refreshSession = ({ ctx, cookies, cookieManager }: InternalStatelessContext) => { +export const refreshSession = ({ ctx, cookies, cookieManager }: InternalStatelessContext) => { const { logger, jose, jwtManager: jwt, identity, sessionConfig } = ctx const maxAge = sessionConfig?.jwt?.maxAge ?? 60 * 60 * 24 * 15 diff --git a/packages/core/src/session/stateless/refreshUserInfo.ts b/packages/core/src/session/stateless/refreshUserInfo.ts index 9308bb13..e2d03a84 100644 --- a/packages/core/src/session/stateless/refreshUserInfo.ts +++ b/packages/core/src/session/stateless/refreshUserInfo.ts @@ -1,8 +1,8 @@ -import { __createSession } from "@/session/stateless/createSession.ts" -import { __refreshSession } from "@/session/stateless/refreshSession.ts" -import type { InternalStatelessContext, User } from "@/@types/session.ts" +import { createSession as __createSession } from "@/session/stateless/createSession.ts" +import { refreshSession as __refreshSession } from "@/session/stateless/refreshSession.ts" +import type { InternalStatelessContext, User } from "@/@types/index.ts" -export const __refreshUserInfo = ({ ctx, cookies, cookieManager }: InternalStatelessContext) => { +export const refreshUserInfo = ({ ctx, cookies, cookieManager }: InternalStatelessContext) => { const refreshSession = __refreshSession({ ctx, cookies, cookieManager }) return async (userInfo: Partial, headers: Headers, skipCSRFCheck?: boolean) => { const value = await refreshSession(headers, { user: userInfo }, skipCSRFCheck) diff --git a/packages/core/src/session/stateless/revokeToken.ts b/packages/core/src/session/stateless/revokeToken.ts index 8ebe63e9..16643c22 100644 --- a/packages/core/src/session/stateless/revokeToken.ts +++ b/packages/core/src/session/stateless/revokeToken.ts @@ -4,9 +4,9 @@ import { toUnionHeaders } from "@/shared/utils.ts" import { secureApiHeaders } from "@/shared/headers.ts" import { getCookie, getExpiredCookie } from "@/cookie.ts" import { revokeProviderToken } from "@/shared/utils/revoke-token.ts" -import type { InternalStatelessContext } from "@/@types/session.ts" +import type { InternalStatelessContext } from "@/@types/index.ts" -export const __revokeToken = ({ ctx, cookies }: InternalStatelessContext) => { +export const revokeToken = ({ ctx, cookies }: InternalStatelessContext) => { const { logger, identity, jwtManager: jwt, oauth } = ctx return async (oauthId: string, headers: Headers, disconnect: boolean): Promise => { diff --git a/packages/core/src/session/stateless/signIn.ts b/packages/core/src/session/stateless/signIn.ts index f2490707..360d2c46 100644 --- a/packages/core/src/session/stateless/signIn.ts +++ b/packages/core/src/session/stateless/signIn.ts @@ -4,9 +4,9 @@ import { cacheControl } from "@/shared/headers.ts" import { createOIDCAuthorizationURL } from "@/shared/oidc/authorization-url.ts" import { isOIDCProvider, resolveOpenIDProvider } from "@/shared/oidc/resolve-provider.ts" import { createAuthorizationURL, createRedirectTo, createRedirectURI } from "@/shared/utils/authorization.ts" -import type { InternalStatelessContext } from "@/@types/session.ts" +import type { InternalStatelessContext } from "@/@types/index.ts" -export const __signIn = ({ ctx, cookies }: InternalStatelessContext) => { +export const signIn = ({ ctx, cookies }: InternalStatelessContext) => { const { oauth, logger } = ctx return async (oauthId: string, request: Request, redirectTo?: string) => { diff --git a/packages/core/src/session/strategy.ts b/packages/core/src/session/strategy.ts index 388d40c0..368c69df 100644 --- a/packages/core/src/session/strategy.ts +++ b/packages/core/src/session/strategy.ts @@ -1,42 +1,29 @@ import { AuraAuthError } from "@/shared/errors.ts" import { createStatefulStrategy } from "@/session/stateful/index.ts" import { createStatelessStrategy } from "@/session/stateless/index.ts" -import type { Identities } from "@/identity/index.ts" -import type { FromShapeToObject } from "@/@types/utility.ts" -import type { CreateSessionStrategyOptions, SessionStrategy, User } from "@/@types/session.ts" +import type { + SessionStrategy, + User, + CreateSessionStrategyOptions, + FromShapeToObject, + Identities, + InternalStatefulContext, + InternalStatelessContext, +} from "@/@types/index.ts" +import { createCookieManager } from "./cookie-manager.ts" -export const createSessionStrategy = ({ - ctx, - config, - jose, - cookies, - logger, - identity, - oauth, -}: CreateSessionStrategyOptions): SessionStrategy & User> => { - const strategy = config?.strategy ?? "jwt" +export const createSessionStrategy = ( + config: CreateSessionStrategyOptions +): SessionStrategy & User> => { + const strategy = config?.ctx?.sessionConfig?.strategy ?? "jwt" + const cookieManager = createCookieManager(config.cookies) + const ctx = { ...config, cookieManager } switch (strategy) { case "jwt": - return createStatelessStrategy({ - ctx, - jose, - config: config as any, - cookies, - logger, - identity, - oauth, - }) + return createStatelessStrategy(ctx as InternalStatelessContext) case "database": - return createStatefulStrategy({ - ctx, - jose, - config: config as any, - cookies, - logger, - identity, - oauth, - }) + return createStatefulStrategy(ctx as InternalStatefulContext) default: throw new AuraAuthError({ code: "INVALID_SESSION_STRATEGY" }) } diff --git a/packages/core/src/shared/utils.ts b/packages/core/src/shared/utils.ts index f4c9a908..5d5c522c 100644 --- a/packages/core/src/shared/utils.ts +++ b/packages/core/src/shared/utils.ts @@ -4,9 +4,15 @@ import { createHash, verifyCSRF } from "@/shared/crypto.ts" import { encoder } from "@aura-stack/jose/crypto" import { AuraAuthError } from "@/shared/errors.ts" import { isRelativeURL, isString, isValidURL } from "@/shared/assert.ts" -import type { JWTManager, OAuthTokenPayload } from "@/@types/session.ts" -import type { InternalCookieStoreConfig, InternalLogger, JoseInstance, SchemaRegistryContext } from "@/@types/config.ts" -import type { OAuthAccessTokenResponseType } from "@/@types/oauth.ts" +import type { + InternalCookieStoreConfig, + InternalLogger, + JoseInstance, + SchemaRegistryContext, + JWTManager, + OAuthTokenPayload, + OAuthAccessTokenResponseType, +} from "@/@types/index.ts" import type { DeviceType } from "@/@types/entities.ts" export const AURA_AUTH_VERSION = "0.8.1" diff --git a/packages/core/src/shared/utils/session-strategy.ts b/packages/core/src/shared/utils/session-strategy.ts index acb25ede..428f6baf 100644 --- a/packages/core/src/shared/utils/session-strategy.ts +++ b/packages/core/src/shared/utils/session-strategy.ts @@ -1,5 +1,5 @@ import { createFingerprint, getDeviceInfo } from "@/shared/utils.ts" -import type { InternalStatefulContext } from "@/@types/session.ts" +import type { InternalStatefulContext } from "@/@types/index.ts" export const createDevice = ({ ctx: { sessionConfig } }: InternalStatefulContext) => { return async (userId: string, request: Request) => { From da68fc9f932b37f924674c89d01bdd25cc02740b Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sat, 1 Aug 2026 10:08:30 -0500 Subject: [PATCH 7/7] chore: apply coderabbit --- packages/core/src/@types/config.ts | 11 ++++------- packages/core/src/session/stateless/index.ts | 6 +++++- packages/core/src/session/stateless/refreshSession.ts | 2 +- .../core/src/session/stateless/refreshUserInfo.ts | 9 ++++----- packages/core/src/session/strategy.ts | 6 ++++++ packages/core/src/shared/errors.ts | 11 ++++++++++- packages/core/src/shared/logger.ts | 6 ++++++ packages/core/test/presets.ts | 2 +- 8 files changed, 37 insertions(+), 16 deletions(-) diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index d88b1d8a..9b12b494 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -549,17 +549,14 @@ export interface CreateSessionStrategyOptions { cookies: () => InternalCookieStoreConfig } -export interface InternalStatefulContext { - ctx: InternalContextForStateful +export interface InternalSessionContext { + ctx: Ctx cookies: () => InternalCookieStoreConfig cookieManager: CookieManager } -export interface InternalStatelessContext { - ctx: InternalContextForStateless - cookies: () => InternalCookieStoreConfig - cookieManager: CookieManager -} +export type InternalStatefulContext = InternalSessionContext +export type InternalStatelessContext = InternalSessionContext /** Minimal token issue/verify surface used by session code paths. */ export type JWTManager = { diff --git a/packages/core/src/session/stateless/index.ts b/packages/core/src/session/stateless/index.ts index 6381e352..996ed46d 100644 --- a/packages/core/src/session/stateless/index.ts +++ b/packages/core/src/session/stateless/index.ts @@ -13,7 +13,11 @@ import type { SessionStrategy, User, InternalStatelessContext } from "@/@types/i export const createStatelessStrategy = ( ctx: InternalStatelessContext ): SessionStrategy => { - const revokeSession = async (_sessionId: string): Promise => {} + const revokeSession = async (_sessionId: string): Promise => { + ctx.ctx.logger?.log("STATELESS_REVOKE_SESSION_NOOP", { + structuredData: { strategy: "stateless", reason: "no_server_side_session_record" }, + }) + } return { getSession: getSession(ctx), diff --git a/packages/core/src/session/stateless/refreshSession.ts b/packages/core/src/session/stateless/refreshSession.ts index 6784b254..d7675736 100644 --- a/packages/core/src/session/stateless/refreshSession.ts +++ b/packages/core/src/session/stateless/refreshSession.ts @@ -3,7 +3,7 @@ import { updateExpires } from "@/shared/utils/session-strategy.ts" import type { DeepPartial } from "@/@types/utility.ts" import type { InternalStatelessContext, Session, User } from "@/@types/index.ts" -export const refreshSession = ({ ctx, cookies, cookieManager }: InternalStatelessContext) => { +export const refreshSession = ({ ctx, cookies, cookieManager }: InternalStatelessContext) => { const { logger, jose, jwtManager: jwt, identity, sessionConfig } = ctx const maxAge = sessionConfig?.jwt?.maxAge ?? 60 * 60 * 24 * 15 diff --git a/packages/core/src/session/stateless/refreshUserInfo.ts b/packages/core/src/session/stateless/refreshUserInfo.ts index e2d03a84..cefa8791 100644 --- a/packages/core/src/session/stateless/refreshUserInfo.ts +++ b/packages/core/src/session/stateless/refreshUserInfo.ts @@ -1,11 +1,10 @@ -import { createSession as __createSession } from "@/session/stateless/createSession.ts" import { refreshSession as __refreshSession } from "@/session/stateless/refreshSession.ts" import type { InternalStatelessContext, User } from "@/@types/index.ts" -export const refreshUserInfo = ({ ctx, cookies, cookieManager }: InternalStatelessContext) => { - const refreshSession = __refreshSession({ ctx, cookies, cookieManager }) +export const refreshUserInfo = ({ ctx, cookies, cookieManager }: InternalStatelessContext) => { + const refreshSession = __refreshSession({ ctx, cookies, cookieManager }) return async (userInfo: Partial, headers: Headers, skipCSRFCheck?: boolean) => { - const value = await refreshSession(headers, { user: userInfo }, skipCSRFCheck) - return value as any + const value = await refreshSession(headers, { user: userInfo as DefaultUser }, skipCSRFCheck) + return value } } diff --git a/packages/core/src/session/strategy.ts b/packages/core/src/session/strategy.ts index 368c69df..6f6ac7a6 100644 --- a/packages/core/src/session/strategy.ts +++ b/packages/core/src/session/strategy.ts @@ -11,6 +11,7 @@ import type { InternalStatelessContext, } from "@/@types/index.ts" import { createCookieManager } from "./cookie-manager.ts" +import { isStatelessStrategy } from "@/shared/assert.ts" export const createSessionStrategy = ( config: CreateSessionStrategyOptions @@ -19,6 +20,11 @@ export const createSessionStrategy = ( const cookieManager = createCookieManager(config.cookies) const ctx = { ...config, cookieManager } + console.log("isStateles: ", isStatelessStrategy(config?.ctx?.sessionConfig)) + if (!isStatelessStrategy(config?.ctx?.sessionConfig) && !config?.ctx?.sessionConfig?.adapter) { + throw new AuraAuthError({ code: "MISSING_ADAPTER_IN_STATEFUL_STRATEGY" }) + } + switch (strategy) { case "jwt": return createStatelessStrategy(ctx as InternalStatelessContext) diff --git a/packages/core/src/shared/errors.ts b/packages/core/src/shared/errors.ts index eeb840c2..38cfe38f 100644 --- a/packages/core/src/shared/errors.ts +++ b/packages/core/src/shared/errors.ts @@ -129,9 +129,9 @@ export const AuraErrorCode = { * Database Errors */ DATABASE_TOKEN_HASH_NOT_FOUND: "DATABASE_TOKEN_HASH_NOT_FOUND", - OAUTH_UNLINKED_ACCOUNT_ERROR: "OAUTH_UNLINKED_ACCOUNT_ERROR", OAUTH_ACCOUNT_USER_MISMATCH: "OAUTH_ACCOUNT_USER_MISMATCH", + MISSING_ADAPTER_IN_STATEFUL_STRATEGY: "MISSING_ADAPTER_IN_STATEFUL_STRATEGY", } as const export type AuraErrorCode = (typeof AuraErrorCode)[keyof typeof AuraErrorCode] @@ -918,6 +918,15 @@ export const ERROR_CATALOG: Record = { "Identity linkage assertion failed. The target OAuth account record is bound to a different user identifier than the currently authenticated session user.", userMessage: "This identity provider account is already linked to a different user.", }, + MISSING_ADAPTER_IN_STATEFUL_STRATEGY: { + type: "INTERNAL", + statusCode: 500, + name: "ConfigError", + message: + "Initialization aborted: The 'database' session strategy was selected, but no database adapter was provided in the configuration settings. Stateful session management requires an adapter instance to execute database operations.", + userMessage: + "Internal library configuration error. Database session strategy requires an adapter instance to be configured.", + }, } export interface AuraErrorOptions extends ErrorOptions { diff --git a/packages/core/src/shared/logger.ts b/packages/core/src/shared/logger.ts index 14ce3829..da367444 100644 --- a/packages/core/src/shared/logger.ts +++ b/packages/core/src/shared/logger.ts @@ -825,6 +825,12 @@ export const logMessages = { msgId: "OAUTH_ACCOUNT_USER_MISMATCH", message: "OAuth account does not belong to the authenticated user", }, + STATELESS_REVOKE_SESSION_NOOP: { + facility: 4, + severity: "info", + msgId: "STATELESS_REVOKE_SESSION_NOOP", + message: "Stateless session revocation is a no-op (no server-side state to revoke)", + }, } as const export const createLogEntry = (key: T, overrides?: Partial): SyslogOptions => { diff --git a/packages/core/test/presets.ts b/packages/core/test/presets.ts index efd1b4e8..e3f7a07a 100644 --- a/packages/core/test/presets.ts +++ b/packages/core/test/presets.ts @@ -210,7 +210,7 @@ export const authInstance = (adapter?: Partial {