diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index f689609d..44b19bcd 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -486,6 +486,7 @@ export interface RouterGlobalContext jwtManager: JWTManager rateLimiters: InferRules> + sessionStrategyMode: "jwt" | "database" } export interface SchemaRegistryContext { diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index 11ac3c17..10447cbe 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -11,6 +11,7 @@ import type { FromShapeToObject, Prettify, Identities, + OAuthProviderRecord, } from "@/@types/index.ts" import type { DatabaseAdapter } from "@/@types/adapter.ts" @@ -213,6 +214,10 @@ export interface GetStatelessSessionReturn { export type GetStatefulSessionReturn = GetStatelessSessionReturn +export type GetProviderTokensStatefulReturn = + | { success: true; tokens: OAuthTokenPayload; headers: Headers } + | { success: false; tokens: null; error: { code: string; message: string }; headers: Headers; statusCode: number } + /** * Abstraction layer for session management. */ @@ -229,6 +234,8 @@ export interface SessionStrategy { */ createSession(session: User): Promise + getProviderTokens(oauth: string, request: Request): Promise + /** * Attempt to refresh using the refresh token cookie. * Returns null session + cookie-clearing response on any failure. @@ -236,7 +243,7 @@ export interface SessionStrategy { refreshSession( headers: Headers, session: DeepPartial>, - skipCSRFCheck?: boolean + skipCSRFCheck: boolean ): Promise<{ session: Session | null headers: Headers @@ -263,6 +270,7 @@ export interface CreateSessionStrategyOptions { cookies: () => InternalCookieStoreConfig logger?: InternalLogger identity: SchemaRegistryContext + oauth: OAuthProviderRecord } /** Options specialized for the JWT-backed session strategy. */ @@ -272,6 +280,7 @@ export interface JWTStrategyOptions { logger?: InternalLogger cookies: () => InternalCookieStoreConfig identity: SchemaRegistryContext + oauth: OAuthProviderRecord } export interface DatabaseStrategyOptions { @@ -280,6 +289,7 @@ export interface DatabaseStrategyOptions { logger?: InternalLogger cookies: () => InternalCookieStoreConfig identity: SchemaRegistryContext + oauth: OAuthProviderRecord } /** Minimal token issue/verify surface used by session code paths. */ diff --git a/packages/core/src/api/getProviderTokens.ts b/packages/core/src/api/getProviderTokens.ts index 50878809..937b3462 100644 --- a/packages/core/src/api/getProviderTokens.ts +++ b/packages/core/src/api/getProviderTokens.ts @@ -1,78 +1,21 @@ -import { getCookie } from "@/cookie.ts" -import { HeadersBuilder } from "@aura-stack/router" -import { fetchAsync } from "@/shared/fetch-async.ts" +import { toUnionHeaders } from "@/shared/utils.ts" import { secureApiHeaders } from "@/shared/headers.ts" -import { createBasicAuthHeader, shouldRefresh, toUnionHeaders } from "@/shared/utils.ts" -import { AuraAuthError } from "@/shared/errors.ts" import { createValidation, handleApiError } from "@/shared/utils/api.ts" import type { FunctionAPIContext, GetProviderTokensAPIOptions, GetProviderTokensAPIReturn, LiteralUnion, - OAuthTokenPayload, BuiltInOAuthProvider, - RuntimeOAuthProvider, } from "@/@types/index.ts" -import { isObject, isRefreshTokenObject } from "@/shared/assert.ts" - -export const refreshProviderToken = async ( - payload: OAuthTokenPayload, - provider: RuntimeOAuthProvider -): Promise => { - if (!provider.refreshToken || (isObject(provider.refreshToken) && !isRefreshTokenObject(provider.refreshToken))) { - throw new AuraAuthError({ code: "OAUTH_INVALID_REFRESH_TOKEN_CONFIG" }) - } - if (!payload.refreshToken) { - throw new AuraAuthError({ code: "OAUTH_INVALID_REFRESH_TOKEN_CONFIG" }) - } - const url = isRefreshTokenObject(provider.refreshToken) ? provider.refreshToken.url : provider.refreshToken - - const isCredentialsAuth = isRefreshTokenObject(provider.refreshToken) - ? provider.refreshToken.authorization?.type === "credentials" - : false - const response = await fetchAsync(url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - ...(isCredentialsAuth ? {} : { Authorization: createBasicAuthHeader(provider.clientId!, provider.clientSecret!) }), - ...(typeof provider.refreshToken === "object" && provider.refreshToken.headers ? provider.refreshToken.headers : {}), - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: payload.refreshToken!, - ...(isCredentialsAuth ? { client_id: provider.clientId!, client_secret: provider.clientSecret! } : {}), - ...(typeof provider.refreshToken === "object" && provider.refreshToken.params ? provider.refreshToken.params : {}), - }), - }) - if (!response.ok) { - throw new AuraAuthError({ code: "OAUTH_INVALID_REFRESH_TOKEN_RESPONSE" }) - } - const data = await response.json() - const now = Math.floor(Date.now() / 1000) - - return { - accessToken: data.access_token ?? payload.accessToken, - expiresAt: now + (data.expires_in ?? 3600), - refreshToken: data.refresh_token ?? payload.refreshToken, - refreshTokenExpiresAt: data.refresh_token_expires_in - ? now + data.refresh_token_expires_in - : payload.refreshTokenExpiresAt, - scopes: typeof data.scope === "string" ? data.scope.split(" ") : payload.scopes, - tokenType: data.token_type ?? payload.tokenType, - idToken: data.id_token ?? payload.idToken, - issuedAt: now, - } -} export const getProviderTokens = async ( oauth: LiteralUnion, { ctx, request: requestInit, headers: headersInit, skipCSRFCheck = false }: FunctionAPIContext ): Promise => { - const { cookies, identity, jwtManager } = ctx const initialHeaders = new Headers(headersInit ?? requestInit?.headers) try { - const { provider, headers, request, rateLimit } = await createValidation(ctx, initialHeaders) + const { request, rateLimit } = await createValidation(ctx, initialHeaders) .verifyOAuthProvider(oauth) .verifySession() .verifyCSRFToken(skipCSRFCheck) @@ -83,36 +26,29 @@ export const getProviderTokens = async ( if (rateLimit) { return rateLimit as unknown as GetProviderTokensAPIReturn } - - const cookieName = `${cookies.accessToken.name}.${oauth}` - const cookie = getCookie(request, cookieName) - - const decodedToken = await jwtManager.verifyToken(cookie) - const tokens = await identity.schemaRegistry.parseOAuthTokens(decodedToken) - - const refreshWindow = provider.refreshWindow ?? 300 - const refreshed = shouldRefresh(tokens, refreshWindow) - - if (refreshed) { - const refreshedTokens = await refreshProviderToken(tokens, provider!) - const encodedTokens = await jwtManager.createToken(refreshedTokens as unknown as Record) - const builder = new HeadersBuilder(secureApiHeaders) - .setCookie(cookieName, encodedTokens, cookies.accessToken.attributes) - .toHeaders() - const newHeaders = toUnionHeaders(builder, headers) + const getTokens = await ctx.sessionStrategy.getProviderTokens(oauth, request) + if (getTokens.success) { + const { success, tokens, headers } = getTokens return { - success: true, - tokens: refreshedTokens, - headers: newHeaders, - toResponse: () => Response.json({ success: true, tokens: refreshedTokens }, { status: 200, headers: newHeaders }), + success, + tokens, + headers, + toResponse: () => { + return Response.json({ success, tokens }, { status: success ? 200 : 400, headers }) + }, } } - return { - success: true, - tokens, - headers, - toResponse: () => Response.json({ success: true, tokens }, { status: 200, headers }), + success: false, + tokens: null, + headers: getTokens.headers, + error: getTokens.error, + toResponse: () => { + return Response.json( + { success: false, tokens: null }, + { status: getTokens.statusCode, headers: getTokens.headers } + ) + }, } } catch (error) { const { code, message, statusCode } = handleApiError(error, "PROVIDER_TOKENS_ERROR", "Failed to get provider tokens") diff --git a/packages/core/src/router/context.ts b/packages/core/src/router/context.ts index bbb4b1e6..422d14ee 100644 --- a/packages/core/src/router/context.ts +++ b/packages/core/src/router/context.ts @@ -63,6 +63,7 @@ export const createContext = ctx.sessionStrategy = createSessionStrategy({ cookies: () => ctx.cookies, @@ -70,6 +71,7 @@ export const createContext = ({ config, @@ -14,6 +23,7 @@ export const createStatefulStrategy = ({ identity, logger, jose, + oauth, }: DatabaseStrategyOptions): SessionStrategy => { const cookieConfig = createCookieManager(cookies) @@ -629,11 +639,218 @@ export const createStatefulStrategy = ({ 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 oauthAccount = await config.adapter.getOAuthAccount(oauthId) + 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 } + } + } + return { getSession, createSession, refreshSession, revokeSession, destroySession, + getProviderTokens, } } diff --git a/packages/core/src/session/stateless.ts b/packages/core/src/session/stateless.ts index f401a7c2..f8c5994c 100644 --- a/packages/core/src/session/stateless.ts +++ b/packages/core/src/session/stateless.ts @@ -1,6 +1,12 @@ -import { verifyCSRFToken, getErrorName, verifySessionToken } from "@/shared/utils.ts" +import { getCookie } from "@/cookie.ts" +import { AuraAuthError } from "@/shared/errors.ts" +import { HeadersBuilder } from "@aura-stack/router" +import { 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 } from "@/shared/utils.ts" import type { Session, SessionStrategy, @@ -10,6 +16,7 @@ import type { GetStatelessSessionReturn, DeepPartial, JoseInstance, + GetProviderTokensStatefulReturn, } from "@/@types/index.ts" export const createStatelessStrategy = ({ @@ -18,6 +25,7 @@ export const createStatelessStrategy = ({ logger, cookies, identity, + oauth, }: JWTStrategyOptions): SessionStrategy => { const jwt = createJoseManager(config?.jwt, jose) const cookieConfig = createCookieManager(cookies) @@ -100,6 +108,168 @@ export const createStatelessStrategy = ({ 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>, @@ -169,5 +339,5 @@ export const createStatelessStrategy = ({ return cookieConfig.clear() } - return { getSession, createSession, refreshSession, revokeSession, destroySession } + return { getSession, createSession, getProviderTokens, refreshSession, revokeSession, destroySession } } diff --git a/packages/core/src/session/strategy.ts b/packages/core/src/session/strategy.ts index d5e29d24..f8b992bd 100644 --- a/packages/core/src/session/strategy.ts +++ b/packages/core/src/session/strategy.ts @@ -11,6 +11,7 @@ export const createSessionStrategy = ({ cookies, logger, identity, + oauth, }: CreateSessionStrategyOptions): SessionStrategy & User> => { const strategy = config?.strategy ?? "jwt" @@ -22,6 +23,7 @@ export const createSessionStrategy = ({ cookies, logger, identity, + oauth, }) case "database": return createStatefulStrategy({ @@ -30,6 +32,7 @@ export const createSessionStrategy = ({ cookies, logger, identity, + oauth, }) default: throw new AuraAuthError({ code: "INVALID_SESSION_STRATEGY" }) diff --git a/packages/core/src/shared/logger.ts b/packages/core/src/shared/logger.ts index 31f4f45b..e2d05735 100644 --- a/packages/core/src/shared/logger.ts +++ b/packages/core/src/shared/logger.ts @@ -645,6 +645,174 @@ export const logMessages = { msgId: "STATEFUL_USER_UPDATED", message: "User updated in database", }, + STATEFUL_GET_PROVIDER_TOKENS_START: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_GET_PROVIDER_TOKENS_START", + message: "Starting stateful getProviderTokens process", + }, + STATEFUL_GET_PROVIDER_TOKENS_NO_SESSION: { + facility: 4, + severity: "warning", + msgId: "STATEFUL_GET_PROVIDER_TOKENS_NO_SESSION", + message: "No session token found during getProviderTokens", + }, + STATEFUL_GET_PROVIDER_TOKENS_SESSION_INVALID: { + facility: 4, + severity: "warning", + msgId: "STATEFUL_GET_PROVIDER_TOKENS_SESSION_INVALID", + message: "Session invalid or has no user during getProviderTokens", + }, + STATEFUL_GET_PROVIDER_TOKENS_SESSION_FOUND: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_GET_PROVIDER_TOKENS_SESSION_FOUND", + message: "Session found and validated during getProviderTokens", + }, + STATEFUL_GET_PROVIDER_TOKENS_OAUTH_ACCOUNT_NOT_FOUND: { + facility: 4, + severity: "warning", + msgId: "STATEFUL_GET_PROVIDER_TOKENS_OAUTH_ACCOUNT_NOT_FOUND", + message: "OAuth account not found during getProviderTokens", + }, + STATEFUL_GET_PROVIDER_TOKENS_OAUTH_ACCOUNT_FOUND: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_GET_PROVIDER_TOKENS_OAUTH_ACCOUNT_FOUND", + message: "OAuth account found during getProviderTokens", + }, + STATEFUL_GET_PROVIDER_TOKENS_TOKENS_EXTRACTED: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_GET_PROVIDER_TOKENS_TOKENS_EXTRACTED", + message: "OAuth tokens extracted from database", + }, + STATEFUL_GET_PROVIDER_TOKENS_PROVIDER_NOT_FOUND: { + facility: 4, + severity: "warning", + msgId: "STATEFUL_GET_PROVIDER_TOKENS_PROVIDER_NOT_FOUND", + message: "OAuth provider not configured during getProviderTokens", + }, + STATEFUL_GET_PROVIDER_TOKENS_REFRESH_CHECK: { + facility: 4, + severity: "debug", + msgId: "STATEFUL_GET_PROVIDER_TOKENS_REFRESH_CHECK", + message: "Checking if OAuth tokens need refresh", + }, + STATEFUL_GET_PROVIDER_TOKENS_REFRESHING: { + facility: 4, + severity: "info", + msgId: "STATEFUL_GET_PROVIDER_TOKENS_REFRESHING", + message: "Refreshing OAuth tokens", + }, + STATEFUL_GET_PROVIDER_TOKENS_REFRESH_SUCCESS: { + facility: 4, + severity: "info", + msgId: "STATEFUL_GET_PROVIDER_TOKENS_REFRESH_SUCCESS", + message: "OAuth tokens refreshed successfully", + }, + STATEFUL_GET_PROVIDER_TOKENS_OAUTH_ACCOUNT_UPDATED: { + facility: 4, + severity: "info", + msgId: "STATEFUL_GET_PROVIDER_TOKENS_OAUTH_ACCOUNT_UPDATED", + message: "OAuth account updated with refreshed tokens", + }, + STATEFUL_GET_PROVIDER_TOKENS_REFRESH_ERROR: { + facility: 4, + severity: "error", + msgId: "STATEFUL_GET_PROVIDER_TOKENS_REFRESH_ERROR", + message: "Error occurred during OAuth token refresh", + }, + STATEFUL_GET_PROVIDER_TOKENS_SUCCESS: { + facility: 4, + severity: "info", + msgId: "STATEFUL_GET_PROVIDER_TOKENS_SUCCESS", + message: "Stateful getProviderTokens completed successfully", + }, + STATEFUL_GET_PROVIDER_TOKENS_ERROR: { + facility: 4, + severity: "error", + msgId: "STATEFUL_GET_PROVIDER_TOKENS_ERROR", + message: "Error occurred during stateful getProviderTokens", + }, + STATELESS_GET_PROVIDER_TOKENS_START: { + facility: 4, + severity: "debug", + msgId: "STATELESS_GET_PROVIDER_TOKENS_START", + message: "Starting stateless getProviderTokens process", + }, + STATELESS_GET_PROVIDER_TOKENS_PROVIDER_NOT_FOUND: { + facility: 4, + severity: "warning", + msgId: "STATELESS_GET_PROVIDER_TOKENS_PROVIDER_NOT_FOUND", + message: "OAuth provider not configured during stateless getProviderTokens", + }, + STATELESS_GET_PROVIDER_TOKENS_PROVIDER_FOUND: { + facility: 4, + severity: "debug", + msgId: "STATELESS_GET_PROVIDER_TOKENS_PROVIDER_FOUND", + message: "OAuth provider found during stateless getProviderTokens", + }, + STATELESS_GET_PROVIDER_TOKENS_NO_COOKIE: { + facility: 4, + severity: "warning", + msgId: "STATELESS_GET_PROVIDER_TOKENS_NO_COOKIE", + message: "No access token cookie found during stateless getProviderTokens", + }, + STATELESS_GET_PROVIDER_TOKENS_COOKIE_FOUND: { + facility: 4, + severity: "debug", + msgId: "STATELESS_GET_PROVIDER_TOKENS_COOKIE_FOUND", + message: "Access token cookie found during stateless getProviderTokens", + }, + STATELESS_GET_PROVIDER_TOKENS_TOKENS_DECODED: { + facility: 4, + severity: "debug", + msgId: "STATELESS_GET_PROVIDER_TOKENS_TOKENS_DECODED", + message: "OAuth tokens decoded from cookie", + }, + STATELESS_GET_PROVIDER_TOKENS_REFRESH_CHECK: { + facility: 4, + severity: "debug", + msgId: "STATELESS_GET_PROVIDER_TOKENS_REFRESH_CHECK", + message: "Checking if OAuth tokens need refresh (stateless)", + }, + STATELESS_GET_PROVIDER_TOKENS_REFRESHING: { + facility: 4, + severity: "info", + msgId: "STATELESS_GET_PROVIDER_TOKENS_REFRESHING", + message: "Refreshing OAuth tokens (stateless)", + }, + STATELESS_GET_PROVIDER_TOKENS_REFRESH_SUCCESS: { + facility: 4, + severity: "info", + msgId: "STATELESS_GET_PROVIDER_TOKENS_REFRESH_SUCCESS", + message: "OAuth tokens refreshed successfully (stateless)", + }, + STATELESS_GET_PROVIDER_TOKENS_COOKIE_UPDATED: { + facility: 4, + severity: "info", + msgId: "STATELESS_GET_PROVIDER_TOKENS_COOKIE_UPDATED", + message: "Access token cookie updated with refreshed tokens", + }, + STATELESS_GET_PROVIDER_TOKENS_REFRESH_ERROR: { + facility: 4, + severity: "error", + msgId: "STATELESS_GET_PROVIDER_TOKENS_REFRESH_ERROR", + message: "Error occurred during OAuth token refresh (stateless)", + }, + STATELESS_GET_PROVIDER_TOKENS_SUCCESS: { + facility: 4, + severity: "info", + msgId: "STATELESS_GET_PROVIDER_TOKENS_SUCCESS", + message: "Stateless getProviderTokens completed successfully", + }, + STATELESS_GET_PROVIDER_TOKENS_ERROR: { + facility: 4, + severity: "error", + msgId: "STATELESS_GET_PROVIDER_TOKENS_ERROR", + message: "Error occurred during stateless getProviderTokens", + }, } 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 8d12ea3d..3de9a675 100644 --- a/packages/core/src/shared/utils.ts +++ b/packages/core/src/shared/utils.ts @@ -149,6 +149,23 @@ export const verifySessionToken = async ({ } } +export const verifyPresentSessionValue = async ({ + headers, + cookies, + logger, +}: { + headers: Headers + cookies: InternalCookieStoreConfig + logger: InternalLogger | undefined +}) => { + try { + return getCookie(headers, cookies.sessionToken.name) + } catch (cause) { + logger?.log("SESSION_NOT_FOUND") + throw new AuraAuthError({ code: "SESSION_NOT_FOUND", cause }) + } +} + export const verifyCSRFToken = async ({ headers, skipCSRFCheck, diff --git a/packages/core/src/shared/utils/api.ts b/packages/core/src/shared/utils/api.ts index 9a03cf53..09105c75 100644 --- a/packages/core/src/shared/utils/api.ts +++ b/packages/core/src/shared/utils/api.ts @@ -1,7 +1,7 @@ import { HeadersBuilder } from "@aura-stack/router" import { verifyRateLimit } from "@/router/rate-limiter.ts" import { AuraAuthError, isAuraAuthError } from "@/shared/errors.ts" -import { verifyCSRFToken, verifySessionToken } from "@/shared/utils.ts" +import { verifyCSRFToken, verifyPresentSessionValue, verifySessionToken } from "@/shared/utils.ts" import { getBaseURL, getOriginURL, createRedirectTo } from "@/shared/utils/authorization.ts" import type { BuiltInOAuthProvider, @@ -38,12 +38,20 @@ export const createValidation = (ctx: RouterGlobalContext, headersInit?: Headers }, verifySession: () => { steps.push(async () => { - await verifySessionToken({ - headers: output.headers, - cookies: ctx.cookies, - jwt: ctx.jwtManager, - logger: ctx.logger, - }) + if (ctx.sessionStrategyMode === "database") { + await verifyPresentSessionValue({ + headers: output.headers, + cookies: ctx.cookies, + logger: ctx.logger, + }) + } else { + await verifySessionToken({ + headers: output.headers, + cookies: ctx.cookies, + jwt: ctx.jwtManager, + logger: ctx.logger, + }) + } }) return builder }, diff --git a/packages/core/src/shared/utils/refresh-tokens.ts b/packages/core/src/shared/utils/refresh-tokens.ts new file mode 100644 index 00000000..86d546cb --- /dev/null +++ b/packages/core/src/shared/utils/refresh-tokens.ts @@ -0,0 +1,64 @@ +import { AuraAuthError } from "@/shared/errors.ts" +import { fetchAsync } from "@/shared/fetch-async.ts" +import { createBasicAuthHeader } from "@/shared/utils.ts" +import { isObject, isRefreshTokenObject } from "@/shared/assert.ts" +import type { OAuthTokenPayload } from "@/@types/session.ts" +import type { RuntimeOAuthProvider } from "@/@types/oauth.ts" + +export const refreshProviderToken = async ( + payload: OAuthTokenPayload, + provider: RuntimeOAuthProvider +): Promise => { + if (!provider.refreshToken || (isObject(provider.refreshToken) && !isRefreshTokenObject(provider.refreshToken))) { + throw new AuraAuthError({ code: "OAUTH_INVALID_REFRESH_TOKEN_CONFIG" }) + } + if (!payload.refreshToken) { + throw new AuraAuthError({ code: "OAUTH_INVALID_REFRESH_TOKEN_CONFIG" }) + } + const url = isRefreshTokenObject(provider.refreshToken) ? provider.refreshToken.url : provider.refreshToken + + const isCredentialsAuth = isRefreshTokenObject(provider.refreshToken) + ? provider.refreshToken.authorization?.type === "credentials" + : false + const response = await fetchAsync(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...(isCredentialsAuth || !provider.clientSecret + ? {} + : { Authorization: createBasicAuthHeader(provider.clientId!, provider.clientSecret!) }), + ...(isRefreshTokenObject(provider.refreshToken) && provider.refreshToken.headers + ? provider.refreshToken.headers + : {}), + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: payload.refreshToken!, + ...(isCredentialsAuth || !provider.clientSecret + ? { + client_id: provider.clientId!, + ...(provider.clientSecret ? { client_secret: provider.clientSecret } : {}), + } + : {}), + ...(isRefreshTokenObject(provider.refreshToken) && provider.refreshToken.params ? provider.refreshToken.params : {}), + }), + }) + if (!response.ok) { + throw new AuraAuthError({ code: "OAUTH_INVALID_REFRESH_TOKEN_RESPONSE" }) + } + const data = await response.json() + const now = Math.floor(Date.now() / 1000) + + return { + accessToken: data.access_token ?? payload.accessToken, + expiresAt: now + (data.expires_in ?? 3600), + refreshToken: data.refresh_token ?? payload.refreshToken, + refreshTokenExpiresAt: data.refresh_token_expires_in + ? now + data.refresh_token_expires_in + : payload.refreshTokenExpiresAt, + scopes: typeof data.scope === "string" ? data.scope.split(" ") : payload.scopes, + tokenType: data.token_type ?? payload.tokenType, + idToken: data.id_token ?? payload.idToken, + issuedAt: now, + } +} diff --git a/packages/core/test/actions/providers/tokens/tokens/stateful.test.ts b/packages/core/test/actions/providers/tokens/tokens/stateful.test.ts new file mode 100644 index 00000000..384f1696 --- /dev/null +++ b/packages/core/test/actions/providers/tokens/tokens/stateful.test.ts @@ -0,0 +1,757 @@ +import { describe, test, expect, vi, afterEach, beforeEach } from "vitest" +import { authInstance, jose, oauthCustomService, sessionEntityWithUser } from "@test/presets.ts" +import { createAuth } from "@/createAuth.ts" +import { createBasicAuthHeader } from "@/shared/utils.ts" +import type { OAuthProviderConfig } from "@/@types/oauth.ts" +import { createCSRF } from "@/shared/crypto.ts" + +beforeEach(() => { + vi.stubEnv("BASE_URL", undefined) +}) + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe("tokensAction (Stateful)", async () => { + test("should return 422 if the provider is not supported", async () => { + const getSessionByTokenMock = vi.fn() + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const response = await GET(new Request("https://example.com/auth/providers/unsupported/tokens")) + expect(response.status).toBe(422) + expect(await response.json()).toEqual({ + type: "VALIDATION", + code: "UNPROCESSABLE_ENTITY", + message: "The request body or parameter schema layout contains input format errors.", + details: { + oauth: { + code: "invalid_value", + message: "The OAuth provider is not supported or invalid.", + }, + }, + }) + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("should return 401 if session token is missing", async () => { + const getSessionByTokenMock = vi.fn() + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const response = await GET(new Request("https://example.com/auth/providers/oauth-provider/tokens")) + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + success: false, + tokens: null, + }) + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("should return 403 if CSRF token is missing", async () => { + const getSessionByTokenMock = vi.fn() + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const sessionToken = "valid-session-token" + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider/tokens", { + headers: { Cookie: `__Secure-aura-auth.session_token=${sessionToken}` }, + }) + ) + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + success: false, + tokens: null, + }) + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("should return 403 if CSRF token is invalid", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn() + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const sessionToken = "valid-session-token" + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider/tokens", { + headers: { + "x-csrf-token": "invalid-token", + Cookie: `__Secure-aura-auth.session_token=${sessionToken}`, + }, + }) + ) + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + success: false, + tokens: null, + }) + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("should return 401 if session is not found in database", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(null) + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider/tokens", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}`, + }, + }) + ) + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + success: false, + tokens: null, + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("should return 401 if OAuth account does not exist", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(null) + const updateOAuthTokensMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider/tokens", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}`, + }, + }) + ) + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + success: false, + tokens: null, + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("successfully get provider tokens from database", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + updatedAt: new Date(), + }) + const updateOAuthTokensMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider/tokens", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}`, + }, + }) + ) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + tokens: expect.objectContaining({ + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: ["scope1", "scope2"], + }), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("refreshToken config not provided", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + updatedAt: new Date(), + }) + const updateOAuthTokensMock = vi.fn() + + const { refreshToken: _, ...spread } = oauthCustomService + const { + handlers: { GET }, + } = createAuth({ + oauth: [spread], + session: { + strategy: "database", + adapter: { + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + } as any, + }, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider/tokens", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}`, + }, + }) + ) + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ + success: false, + tokens: null, + }) + }) + + test("refreshToken successfully refreshes tokens", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + updatedAt: new Date(), + }) + const updateOAuthTokensMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + updatedAt: new Date(), + }) + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + id_token: "new-id-token", + expires_in: 3600, + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider/tokens", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}`, + }, + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + tokens: expect.objectContaining({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + expiresAt: expect.any(Number), + issuedAt: expect.any(Number), + }), + }) + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/refresh_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: createBasicAuthHeader("oauth_client_id", "oauth_client_secret"), + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: "refresh-token", + }), + signal: expect.any(AbortSignal), + }) + expect(updateOAuthTokensMock).toHaveBeenCalledWith( + "oauth-provider", + expect.objectContaining({ + accountId: "account-123", + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + }) + ) + }) + + test("refreshToken successfully refreshes tokens with credentials auth", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + updatedAt: new Date(), + }) + const updateOAuthTokensMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + updatedAt: new Date(), + }) + + const provider: OAuthProviderConfig = { + ...oauthCustomService, + refreshToken: { + url: "https://example.com/oauth/refresh_token", + authorization: { type: "credentials" }, + }, + } + + const { + handlers: { GET }, + } = createAuth({ + oauth: [provider], + session: { + strategy: "database", + adapter: { + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + } as any, + }, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + id_token: "new-id-token", + expires_in: 3600, + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider/tokens", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}`, + }, + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + tokens: expect.objectContaining({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + expiresAt: expect.any(Number), + issuedAt: expect.any(Number), + }), + }) + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/refresh_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: "refresh-token", + client_id: "oauth_client_id", + client_secret: "oauth_client_secret", + }), + signal: expect.any(AbortSignal), + }) + expect(updateOAuthTokensMock).toHaveBeenCalledWith( + "oauth-provider", + expect.objectContaining({ + accountId: "account-123", + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + }) + ) + }) + + test("refreshToken fails when OAuth provider returns an error", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + updatedAt: new Date(), + }) + const updateOAuthTokensMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ error: "invalid_grant", error_description: "Refresh token revoked" }), + }) + vi.stubGlobal("fetch", mockFetch) + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider/tokens", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}`, + }, + }) + ) + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + success: false, + tokens: null, + }) + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("refreshToken handles unexpected network exceptions gracefully", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + updatedAt: new Date(), + }) + const updateOAuthTokensMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + const mockFetch = vi.fn().mockRejectedValueOnce(new Error("Network connection lost")) + vi.stubGlobal("fetch", mockFetch) + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider/tokens", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}`, + }, + }) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + success: false, + tokens: null, + }) + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("returns current tokens without refreshing when close to expiry but outside the refresh window", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const currentTime = Math.floor(Date.now() / 1000) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date((currentTime + 600) * 1000), + refreshTokenExpiresAt: new Date((currentTime + 7200) * 1000), + updatedAt: new Date(), + }) + const updateOAuthTokensMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + const mockFetch = vi.fn() + vi.stubGlobal("fetch", mockFetch) + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider/tokens", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}`, + }, + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + tokens: expect.objectContaining({ + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAt: currentTime + 600, + }), + }) + expect(mockFetch).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("automatically refreshes the token when its lifetime falls inside the refresh window", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const currentTime = Math.floor(Date.now() / 1000) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date((currentTime + 120) * 1000), + refreshTokenExpiresAt: new Date((currentTime + 7200) * 1000), + updatedAt: new Date(), + }) + const updateOAuthTokensMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "brand-new-refreshed-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date((currentTime + 3600) * 1000), + refreshTokenExpiresAt: new Date((currentTime + 7200) * 1000), + updatedAt: new Date(), + }) + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + access_token: "brand-new-refreshed-token", + refresh_token: "new-refresh-token", + id_token: "new-id-token", + expires_in: 3600, + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider/tokens", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}`, + }, + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + tokens: expect.objectContaining({ + accessToken: "brand-new-refreshed-token", + expiresAt: expect.any(Number), + issuedAt: expect.any(Number), + }), + }) + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(updateOAuthTokensMock).toHaveBeenCalledWith( + "oauth-provider", + expect.objectContaining({ + accountId: "account-123", + accessToken: "brand-new-refreshed-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + }) + ) + }) +}) diff --git a/packages/core/test/actions/signIn/signInCredentials/stateful.test.ts b/packages/core/test/actions/signIn/signInCredentials/stateful.test.ts index 886048e3..485d3990 100644 --- a/packages/core/test/actions/signIn/signInCredentials/stateful.test.ts +++ b/packages/core/test/actions/signIn/signInCredentials/stateful.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach, vi, afterEach } from "vitest" -import { authInstance, jose, userEntity } from "@test/presets.ts" +import { authInstance, jose, sessionEntityWithUser, userEntity } from "@test/presets.ts" import { createCSRF } from "@/shared/crypto.ts" import { createSchemaRegistry } from "@/validator/registry.ts" @@ -53,9 +53,9 @@ describe("signInCredentials action", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { handlers } = authInstance({ createUser: createUserMock, @@ -219,9 +219,9 @@ describe("signInCredentials action", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { handlers } = authInstance({ createUser: createUserMock, @@ -283,9 +283,9 @@ describe("signInCredentials action", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { handlers } = authInstance({ createUser: createUserMock, @@ -347,9 +347,9 @@ describe("signInCredentials action", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { handlers } = authInstance({ createUser: createUserMock, @@ -411,9 +411,9 @@ describe("signInCredentials action", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { handlers } = authInstance({ createUser: createUserMock, diff --git a/packages/core/test/actions/signUp/stateful.test.ts b/packages/core/test/actions/signUp/stateful.test.ts index 53a41aaa..c93672ab 100644 --- a/packages/core/test/actions/signUp/stateful.test.ts +++ b/packages/core/test/actions/signUp/stateful.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, beforeEach, vi, afterEach } from "vitest" import { z } from "zod/v4" import { createCSRF } from "@/shared/crypto.ts" import { identitySchema } from "@/identity/zod.ts" -import { authInstance, jose, sessionPayload, userEntity } from "@test/presets.ts" +import { authInstance, jose, sessionEntityWithUser, sessionPayload, userEntity } from "@test/presets.ts" import { createSchemaRegistry } from "@/validator/registry.ts" beforeEach(() => { @@ -55,9 +55,9 @@ describe("signUp API", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { handlers } = authInstance({ createSession: createSessionMock, @@ -116,7 +116,7 @@ describe("signUp API", async () => { const createUserMock = vi.fn() const updateUserMock = vi.fn().mockReturnValue(userEntity) const getUserByIdMock = vi.fn().mockReturnValue(userEntity) - const createSessionMock = vi.fn() + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { handlers } = authInstance({ createSession: createSessionMock, @@ -172,9 +172,9 @@ describe("signUp API", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { handlers } = authInstance( { @@ -218,9 +218,9 @@ describe("signUp API", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { handlers } = authInstance( { @@ -274,9 +274,9 @@ describe("signUp API", async () => { test("valid signUp.onCreateUser return with custom schema", async () => { const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { handlers } = authInstance( { @@ -346,9 +346,9 @@ describe("signUp API", async () => { test("valid signUp.onCreateUser return with custom schema and identity.schema", async () => { const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { handlers } = authInstance( { @@ -425,9 +425,9 @@ describe("signUp API", async () => { test("signUp with redirect: true and redirectTo", async () => { const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { handlers } = authInstance({ createUser: createUserMock, @@ -475,9 +475,9 @@ describe("signUp API", async () => { test("signUp with redirect: false", async () => { const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { handlers } = authInstance({ createUser: createUserMock, @@ -525,9 +525,9 @@ describe("signUp API", async () => { test("signUp with redirect: false and redirectTo", async () => { const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { handlers } = authInstance({ createUser: createUserMock, @@ -575,9 +575,9 @@ describe("signUp API", async () => { test("signUp with redirect: true and invalid redirectTo", async () => { const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { handlers } = authInstance({ createUser: createUserMock, @@ -625,9 +625,9 @@ describe("signUp API", async () => { test("signUp with redirect: false and invalid redirectTo", async () => { const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { handlers } = authInstance({ createUser: createUserMock, diff --git a/packages/core/test/api/getProviderTokens.test.ts b/packages/core/test/api/getProviderTokens.test.ts index a8a807ae..33c530e8 100644 --- a/packages/core/test/api/getProviderTokens.test.ts +++ b/packages/core/test/api/getProviderTokens.test.ts @@ -11,6 +11,8 @@ beforeEach(() => { afterEach(() => { vi.unstubAllEnvs() + vi.restoreAllMocks() + vi.unstubAllGlobals() }) describe("getProviderTokens API", () => { diff --git a/packages/core/test/api/stateful/getProviderTokens.test.ts b/packages/core/test/api/stateful/getProviderTokens.test.ts new file mode 100644 index 00000000..e1ab01cf --- /dev/null +++ b/packages/core/test/api/stateful/getProviderTokens.test.ts @@ -0,0 +1,777 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest" +import { authInstance, jose, oauthCustomService, sessionEntityWithUser } from "@test/presets.ts" +import { createCSRF } from "@/shared/crypto.ts" +import { createAuth } from "@/createAuth.ts" +import { createBasicAuthHeader } from "@/shared/utils.ts" +import type { OAuthProviderConfig } from "@/@types/oauth.ts" + +beforeEach(() => { + vi.stubEnv("BASE_URL", undefined) +}) + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +vi.mock("@aura-stack/rate-limiter", async () => { + const actual = await vi.importActual("@aura-stack/rate-limiter") + return { + ...actual, + createRateLimiter: (...args: Parameters) => { + const limiters = actual.createRateLimiter(...args) + + for (const limiter of Object.values(limiters)) { + limiter.check = vi.fn().mockResolvedValue({ + ok: true, + limit: Number.MAX_SAFE_INTEGER, + remaining: Number.MAX_SAFE_INTEGER, + resetAt: Date.now() + 60000, + retryAfter: 0, + toResponse: () => new Response(), + }) + } + + return limiters + }, + } +}) + +describe("getProviderTokens API (Stateful)", () => { + test("throws error when provider is missing", async () => { + const getSessionByTokenMock = vi.fn() + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const output = await api.getProviderTokens("unsuppported", { headers: new Headers() }) + expect(output).toEqual({ + success: false, + tokens: null, + error: { + code: "UNSUPPORTED_OAUTH_CONFIGURATION", + message: "The targeted OAuth provider has not been configured in the initialization parameters.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("throws error when session token is missing", async () => { + const getSessionByTokenMock = vi.fn() + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const output = await api.getProviderTokens("oauth-provider", { headers: new Headers() }) + expect(output).toEqual({ + success: false, + tokens: null, + error: { + code: "SESSION_NOT_FOUND", + message: "The session token is not found. There is no active session.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("throws error when CSRF token is missing", async () => { + const getSessionByTokenMock = vi.fn() + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const sessionToken = "valid-session-token" + + const output = await api.getProviderTokens("oauth-provider", { + headers: { + Cookie: `aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: false, + tokens: null, + error: { + code: "CSRF_TOKEN_MISSING", + message: "The CSRF token is missing. Please refresh and try again.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("throws error when CSRF token is invalid", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn() + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const sessionToken = "valid-session-token" + + const output = await api.getProviderTokens("oauth-provider", { + headers: new Headers({ + Cookie: `aura-auth.csrf_token=invalid-token; aura-auth.session_token=${sessionToken}`, + "X-CSRF-Token": "invalid-token", + }), + }) + expect(output).toEqual({ + success: false, + tokens: null, + error: { + code: "CSRF_TOKEN_MISMATCH", + message: "CSRF token verification failed. Please refresh and try again.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("throws error when session is not found in database", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(null) + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.getProviderTokens("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: false, + tokens: null, + error: { + code: "SESSION_NOT_FOUND", + message: "The session token is not found. There is no active session.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("throws error when OAuth account does not exist", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(null) + const updateOAuthTokensMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.getProviderTokens("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: false, + tokens: null, + error: { + code: "COOKIE_INVALID_VALUE", + message: "Expected configuration cookie not found or contains an empty value.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("successfully gets provider tokens from database", async () => { + vi.stubEnv("BASE_URL", "http://localhost:3000") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + updatedAt: new Date(), + }) + const updateOAuthTokensMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.getProviderTokens("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: true, + tokens: expect.objectContaining({ + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: ["scope1", "scope2"], + }), + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("refreshToken config not provided", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + updatedAt: new Date(), + }) + const updateOAuthTokensMock = vi.fn() + + const { refreshToken: _, ...spread } = oauthCustomService + const { api } = createAuth({ + oauth: [spread], + session: { + strategy: "database", + adapter: { + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + } as any, + }, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.getProviderTokens("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: false, + tokens: null, + error: { + code: "OAUTH_INVALID_REFRESH_TOKEN_CONFIG", + message: + "Internal library configuration error. Token refresh operations are not enabled or configured for this identity provider.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) + + test("refreshToken successfully refreshes tokens", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + updatedAt: new Date(), + }) + const updateOAuthTokensMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + updatedAt: new Date(), + }) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn() + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + id_token: "new-id-token", + expires_in: 3600, + }), + }) + + vi.stubGlobal("fetch", mockFetch) + + const output = await api.getProviderTokens("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: true, + tokens: expect.objectContaining({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + expiresAt: expect.any(Number), + issuedAt: expect.any(Number), + }), + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/refresh_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: createBasicAuthHeader("oauth_client_id", "oauth_client_secret"), + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: "refresh-token", + }), + signal: expect.any(AbortSignal), + }) + expect(updateOAuthTokensMock).toHaveBeenCalledWith( + "oauth-provider", + expect.objectContaining({ + accountId: "account-123", + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + }) + ) + }) + + test("refreshToken successfully refreshes tokens with credentials auth in refreshToken config", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + updatedAt: new Date(), + }) + const updateOAuthTokensMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + updatedAt: new Date(), + }) + + const provider: OAuthProviderConfig = { + ...oauthCustomService, + refreshToken: { + url: "https://example.com/oauth/refresh_token", + authorization: { type: "credentials" }, + }, + } + + const { api } = createAuth({ + oauth: [provider], + session: { + strategy: "database", + adapter: { + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + } as any, + }, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn() + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + id_token: "new-id-token", + expires_in: 3600, + }), + }) + + vi.stubGlobal("fetch", mockFetch) + + const output = await api.getProviderTokens("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: true, + tokens: expect.objectContaining({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + expiresAt: expect.any(Number), + issuedAt: expect.any(Number), + }), + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/refresh_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: "refresh-token", + client_id: "oauth_client_id", + client_secret: "oauth_client_secret", + }), + signal: expect.any(AbortSignal), + }) + expect(updateOAuthTokensMock).toHaveBeenCalledWith( + "oauth-provider", + expect.objectContaining({ + accountId: "account-123", + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + }) + ) + }) + + test("refreshToken fails when OAuth provider returns an error", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + updatedAt: new Date(), + }) + const updateOAuthTokensMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ error: "invalid_grant", error_description: "Refresh token revoked" }), + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.getProviderTokens("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output).toEqual({ + success: false, + tokens: null, + error: { + code: "OAUTH_INVALID_REFRESH_TOKEN_RESPONSE", + message: "Your secure session renewal failed. Please sign in again to continue.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("refreshToken handles unexpected network exceptions gracefully", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + updatedAt: new Date(), + }) + const updateOAuthTokensMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn().mockRejectedValueOnce(new Error("Network connection lost")) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.getProviderTokens("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output).toEqual({ + success: false, + tokens: null, + error: { + code: "PROVIDER_TOKENS_ERROR", + message: "Failed to get provider tokens", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("returns current tokens without refreshing when close to expiry but outside the refresh window", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const currentTime = Math.floor(Date.now() / 1000) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date((currentTime + 600) * 1000), + refreshTokenExpiresAt: new Date((currentTime + 7200) * 1000), + updatedAt: new Date(), + }) + const updateOAuthTokensMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + const mockFetch = vi.fn() + vi.stubGlobal("fetch", mockFetch) + + const output = await api.getProviderTokens("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output).toEqual({ + success: true, + tokens: expect.objectContaining({ + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAt: currentTime + 600, + }), + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(mockFetch).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("automatically refreshes the token when its lifetime falls inside the refresh window", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const currentTime = Math.floor(Date.now() / 1000) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date((currentTime + 120) * 1000), + refreshTokenExpiresAt: new Date((currentTime + 7200) * 1000), + updatedAt: new Date(), + }) + const updateOAuthTokensMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + accessToken: "brand-new-refreshed-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + issuer: "https://example.com", + accessTokenExpiresAt: new Date((currentTime + 3600) * 1000), + refreshTokenExpiresAt: new Date((currentTime + 7200) * 1000), + updatedAt: new Date(), + }) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: "brand-new-refreshed-token", + refresh_token: "new-refresh-token", + id_token: "new-id-token", + expires_in: 3600, + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.getProviderTokens("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=valid-token-hash`, + }, + }) + + expect(output).toEqual({ + success: true, + tokens: expect.objectContaining({ + accessToken: "brand-new-refreshed-token", + expiresAt: expect.any(Number), + issuedAt: expect.any(Number), + }), + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(updateOAuthTokensMock).toHaveBeenCalledWith( + "oauth-provider", + expect.objectContaining({ + accountId: "account-123", + accessToken: "brand-new-refreshed-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + }) + ) + }) +}) diff --git a/packages/core/test/api/stateful/signInCredentials.test.ts b/packages/core/test/api/stateful/signInCredentials.test.ts index 45769c76..00663f4d 100644 --- a/packages/core/test/api/stateful/signInCredentials.test.ts +++ b/packages/core/test/api/stateful/signInCredentials.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest" import { createCSRF } from "@/shared/crypto.ts" -import { authInstance, jose, userEntity } from "@test/presets.ts" +import { authInstance, jose, sessionEntityWithUser, userEntity } from "@test/presets.ts" import { createSchemaRegistry } from "@/validator/registry.ts" beforeEach(() => { @@ -53,9 +53,9 @@ describe("signInCredentials API", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { api } = authInstance({ createUser: createUserMock, @@ -267,9 +267,9 @@ describe("signInCredentials API", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { api } = authInstance({ createUser: createUserMock, @@ -333,9 +333,9 @@ describe("signInCredentials API", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { api } = authInstance({ createUser: createUserMock, @@ -399,9 +399,9 @@ describe("signInCredentials API", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { api } = authInstance({ createUser: createUserMock, @@ -465,9 +465,9 @@ describe("signInCredentials API", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { api } = authInstance({ createUser: createUserMock, diff --git a/packages/core/test/api/stateful/signUp.test.ts b/packages/core/test/api/stateful/signUp.test.ts index fbe2b30c..5d7ad871 100644 --- a/packages/core/test/api/stateful/signUp.test.ts +++ b/packages/core/test/api/stateful/signUp.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, beforeEach, vi, afterEach } from "vitest" import { createCSRF } from "@/shared/crypto.ts" import { createSchemaRegistry } from "@/validator/registry.ts" -import { authInstance, jose, sessionPayload, userEntity } from "@test/presets.ts" +import { authInstance, jose, sessionEntityWithUser, sessionPayload, userEntity } from "@test/presets.ts" import type { User } from "@/index.ts" beforeEach(() => { @@ -54,7 +54,7 @@ describe("signUp API", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) @@ -252,9 +252,9 @@ describe("signUp API", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { api } = authInstance({ createUser: createUserMock, @@ -313,9 +313,9 @@ describe("signUp API", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { api } = authInstance({ createUser: createUserMock, @@ -374,9 +374,9 @@ describe("signUp API", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { api } = authInstance({ createUser: createUserMock, @@ -435,9 +435,9 @@ describe("signUp API", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { api } = authInstance({ createUser: createUserMock, @@ -496,9 +496,9 @@ describe("signUp API", async () => { vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) const updateUserMock = vi.fn() - const createSessionMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const { api } = authInstance({ createUser: createUserMock, diff --git a/packages/core/test/presets.ts b/packages/core/test/presets.ts index 13813a86..830dc4c3 100644 --- a/packages/core/test/presets.ts +++ b/packages/core/test/presets.ts @@ -150,6 +150,7 @@ export const { export const authInstance = (adapter?: Partial>, override?: Partial>) => { const config: AuthConfig = { oauth: [oauthCustomService, oauthCustomServiceProfile, openIDCustomProvider], + logger: true, session: { strategy: "database", adapter: adapter as any,