diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index 2b9206b2..9b12b494 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -15,11 +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" @@ -536,6 +539,35 @@ export type InternalContext, "sessionConfig"> & { + sessionConfig: StatefulStrategyConfig +} + +/** Inputs for constructing a session strategy implementation for a given identity schema. */ +export interface CreateSessionStrategyOptions { + ctx: InternalContext + cookies: () => InternalCookieStoreConfig +} + +export interface InternalSessionContext { + ctx: Ctx + 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 = { + createToken(user: TypedJWTPayload>): Promise + verifyToken(token: string): Promise> +} + +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 b83bbd62..794fff73 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -1,20 +1,9 @@ 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, -} 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). */ export type User = Infer @@ -301,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. @@ -349,7 +300,7 @@ export interface OAuthTokenPayload { * @deprecated */ expiresAt: number - accessTokenExpiresAt: number + accessTokenExpiresAt?: number /** * The raw refresh token string issued by the OAuth provider, if applicable. */ @@ -379,3 +330,5 @@ export interface OAuthTokenPayload { */ issuedAt: number } + +export type CookieManager = ReturnType 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.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..5815247a --- /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 "@/shared/utils/session-strategy.ts" +import type { TypedJWTPayload } from "@aura-stack/jose" +import type { InternalStatefulContext, User } from "@/@types/index.ts" + +export const createSession = ({ ctx, cookies, cookieManager }: InternalStatefulContext) => { + const { logger, sessionConfig } = ctx + const createDevice = __createDevice({ ctx, cookies, cookieManager }) + + 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, + has_email: Boolean(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, + has_email: Boolean(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..1e948586 --- /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/index.ts" + +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", { + structuredData: { + strategy: "stateful", + operation: "destroySession", + }, + }) + + await verifyCSRFToken({ + headers, + cookies: cookies(), + logger, + jose: jose, + skipCSRFCheck, + }) + + try { + const { sessionToken } = cookieManager.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 = cookieManager.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..ae1a437d --- /dev/null +++ b/packages/core/src/session/stateful/getProviderTokens.ts @@ -0,0 +1,211 @@ +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/index.ts" + +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", + operation: "getProviderTokens", + oauth_id: oauthId, + }, + }) + + try { + const { sessionToken } = cookieManager.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: cookieManager.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: cookieManager.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: cookieManager.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: "OAUTH_UNLINKED_ACCOUNT_ERROR" }), + "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 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(getAccount?.id as string, { + 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..da3db719 --- /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, cookieManager }: InternalStatefulContext) => { + const { logger, sessionConfig } = ctx + + return async (headers: Headers): Promise> => { + logger?.log("STATEFUL_GET_SESSION_START", { + structuredData: { + strategy: "stateful", + operation: "getSession", + }, + }) + + try { + const { sessionToken } = cookieManager.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: cookieManager.clear(), + } + } + + 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: cookieManager.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: cookieManager.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: cookieManager.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..35e51cd8 --- /dev/null +++ b/packages/core/src/session/stateful/index.ts @@ -0,0 +1,30 @@ +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), + 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 new file mode 100644 index 00000000..b926f23e --- /dev/null +++ b/packages/core/src/session/stateful/isProviderConnected.ts @@ -0,0 +1,92 @@ +import { getErrorName } from "@/shared/utils.ts" +import type { InternalStatefulContext } from "@/@types/index.ts" + +export const isProviderConnected = ({ ctx, cookieManager }: 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 } = cookieManager.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, "account_suspended") + } + 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) { + /** + * @todo returns the error to the user in a structured way. + */ + 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..6e17f338 --- /dev/null +++ b/packages/core/src/session/stateful/oauthCallback.ts @@ -0,0 +1,247 @@ +import { HeadersBuilder } from "@aura-stack/router" +import { AuraAuthError } from "@/shared/errors.ts" +import { validateIDToken } from "@/shared/oidc/id-token.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/index.ts" + +export const oauthCallback = ({ ctx, cookies, cookieManager }: InternalStatefulContext) => { + const { logger, jose, oauth, sessionConfig } = ctx + const createDevice = __createDevice({ ctx, cookies, cookieManager }) + + 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 { sub: _sub, 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) { + 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, + 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..2645e7cb --- /dev/null +++ b/packages/core/src/session/stateful/refreshSession.ts @@ -0,0 +1,240 @@ +import { getErrorName, verifyCSRFToken } from "@/shared/utils.ts" +import type { DeepPartial } from "@/@types/utility.ts" +import type { InternalStatefulContext, Session, User } from "@/@types/index.ts" + +export const refreshSession = ({ ctx, cookies, cookieManager }: InternalStatefulContext) => { + const { logger, sessionConfig, jose } = 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 } = cookieManager.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: cookieManager.clear() } + } + + logger?.log("STATEFUL_CSRF_VERIFICATION_START", { + structuredData: { + skip_csrf_check: skipCSRFCheck, + }, + }) + + const isValidToken = await verifyCSRFToken({ + headers, + skipCSRFCheck, + jose, + cookies: 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: cookieManager.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: cookieManager.clear() } + } + + if (sessionByToken.status !== "active") { + return { session: null, headers: cookieManager.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: cookieManager.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: cookieManager.setCookie({ sessionToken }) } + } 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: cookieManager.clear() } + } + } +} diff --git a/packages/core/src/session/stateful/refreshUserInfo.ts b/packages/core/src/session/stateful/refreshUserInfo.ts new file mode 100644 index 00000000..f15c48ee --- /dev/null +++ b/packages/core/src/session/stateful/refreshUserInfo.ts @@ -0,0 +1,11 @@ +import { refreshSession as __refreshSession } from "@/session/stateful/refreshSession.ts" +import type { InternalStatefulContext, User } from "@/@types/index.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/revokeSession.ts b/packages/core/src/session/stateful/revokeSession.ts new file mode 100644 index 00000000..0ed4a80a --- /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/index.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..88cd2cf1 --- /dev/null +++ b/packages/core/src/session/stateful/revokeToken.ts @@ -0,0 +1,133 @@ +import { HeadersBuilder } from "@aura-stack/router" +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/index.ts" + +export const revokeToken = ({ ctx, cookieManager }: InternalStatefulContext) => { + const { oauth, logger, sessionConfig, cookies } = 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 } = cookieManager.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 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: { + 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..dd30d444 --- /dev/null +++ b/packages/core/src/session/stateful/signIn.ts @@ -0,0 +1,85 @@ +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 { 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/index.ts" + +export const signIn = ({ ctx }: 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 } = getDeviceInfo(request) + const fingerprint = await createFingerprint(request) + 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, + createdAt: new Date(), + expiresAt, + deviceId: null, + metadata: null, + }) + + const headers = new HeadersBuilder(secureApiHeaders).setHeader("Location", authorization).toHeaders() + + return { + success: true, + signInURL: authorization, + headers, + } + } +} 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..513cfa39 --- /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/index.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..1a267794 --- /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/index.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..9c8b4815 --- /dev/null +++ b/packages/core/src/session/stateless/getProviderTokens.ts @@ -0,0 +1,173 @@ +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 } from "@/shared/utils.ts" +import type { GetProviderTokensStatefulReturn, InternalStatelessContext } from "@/@types/index.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() + + logger?.log("STATELESS_GET_PROVIDER_TOKENS_COOKIE_UPDATED", { + structuredData: { + oauth_id: oauthId, + cookie_name: cookieName, + }, + }) + + return { + success: true, + tokens: refreshedTokens, + headers: builder, + } + } 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..c18633fb --- /dev/null +++ b/packages/core/src/session/stateless/getSession.ts @@ -0,0 +1,54 @@ +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/index.ts" + +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(secureApiHeaders) + 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 = identity.skipValidation ? defaultPayload : 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..996ed46d --- /dev/null +++ b/packages/core/src/session/stateless/index.ts @@ -0,0 +1,35 @@ +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: InternalStatelessContext +): SessionStrategy => { + 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), + createSession: createSession(ctx), + getProviderTokens: getProviderTokens(ctx), + refreshSession: refreshSession(ctx), + revokeSession, + 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 new file mode 100644 index 00000000..263f9533 --- /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/index.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..f3b0780e --- /dev/null +++ b/packages/core/src/session/stateless/oauthCallback.ts @@ -0,0 +1,118 @@ +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/index.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] + 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) + 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..d7675736 --- /dev/null +++ b/packages/core/src/session/stateless/refreshSession.ts @@ -0,0 +1,73 @@ +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/index.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, ...claimsWithoutJWTFields } = parsedClaims + const defaultPayload = identity.skipValidation + ? claimsWithoutJWTFields + : await identity.schemaRegistry.parse(claimsWithoutJWTFields) + 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..cefa8791 --- /dev/null +++ b/packages/core/src/session/stateless/refreshUserInfo.ts @@ -0,0 +1,10 @@ +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 }) + return async (userInfo: Partial, headers: Headers, skipCSRFCheck?: boolean) => { + const value = await refreshSession(headers, { user: userInfo as DefaultUser }, skipCSRFCheck) + return value + } +} diff --git a/packages/core/src/session/stateless/revokeToken.ts b/packages/core/src/session/stateless/revokeToken.ts new file mode 100644 index 00000000..16643c22 --- /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/index.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..360d2c46 --- /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/index.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 c5cea511..6f6ac7a6 100644 --- a/packages/core/src/session/strategy.ts +++ b/packages/core/src/session/strategy.ts @@ -1,42 +1,35 @@ import { AuraAuthError } from "@/shared/errors.ts" -import { createStatefulStrategy } from "@/session/stateful.ts" -import { createStatelessStrategy } from "@/session/stateless.ts" -import type { Identities } from "@/identity/index.ts" -import type { FromShapeToObject } from "@/@types/utility.ts" -import type { CreateSessionStrategyOptions, SessionStrategy, User } from "@/@types/session.ts" +import { createStatefulStrategy } from "@/session/stateful/index.ts" +import { createStatelessStrategy } from "@/session/stateless/index.ts" +import type { + SessionStrategy, + User, + CreateSessionStrategyOptions, + FromShapeToObject, + Identities, + InternalStatefulContext, + InternalStatelessContext, +} from "@/@types/index.ts" +import { createCookieManager } from "./cookie-manager.ts" +import { isStatelessStrategy } from "@/shared/assert.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 } + + 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, - 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/errors.ts b/packages/core/src/shared/errors.ts index ed6a2925..38cfe38f 100644 --- a/packages/core/src/shared/errors.ts +++ b/packages/core/src/shared/errors.ts @@ -129,8 +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] @@ -909,6 +910,23 @@ 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.", + }, + 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 19be4549..da367444 100644 --- a/packages/core/src/shared/logger.ts +++ b/packages/core/src/shared/logger.ts @@ -819,6 +819,18 @@ 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", + }, + 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/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 new file mode 100644 index 00000000..428f6baf --- /dev/null +++ b/packages/core/src/shared/utils/session-strategy.ts @@ -0,0 +1,58 @@ +import { createFingerprint, getDeviceInfo } from "@/shared/utils.ts" +import type { InternalStatefulContext } from "@/@types/index.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, + }) + } +} + +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 + } +} 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..e3f7a07a 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 = { @@ -209,7 +210,7 @@ export const authInstance = (adapter?: Partial {