securityCode = $this->tenant->configuration()->security()->code(); } // ========================================================================= // Main Entry Point // ========================================================================= /** * Handle an authentication request */ public function handle(AuthenticationRequest $request): AuthenticationResponse { return match ($request->action) { AuthenticationRequest::ACTION_START => $this->handleStart(), AuthenticationRequest::ACTION_IDENTIFY => $this->handleIdentify($request), AuthenticationRequest::ACTION_VERIFY => $this->handleVerify($request), AuthenticationRequest::ACTION_CHALLENGE => $this->handleChallenge($request), AuthenticationRequest::ACTION_REDIRECT => $this->handleRedirect($request), AuthenticationRequest::ACTION_CALLBACK => $this->handleCallback($request), AuthenticationRequest::ACTION_STATUS => $this->handleStatus($request), AuthenticationRequest::ACTION_CANCEL => $this->handleCancel($request), AuthenticationRequest::ACTION_REFRESH => $this->handleRefresh($request), AuthenticationRequest::ACTION_LOGOUT => $this->handleLogout($request), default => AuthenticationResponse::failed( AuthenticationResponse::ERROR_INVALID_REQUEST, 'Unknown action', 400 ), }; } // ========================================================================= // Action Handlers // ========================================================================= /** * Start a new authentication session */ private function handleStart(): AuthenticationResponse { $methods = $this->methodsConfigured(); $session = AuthenticationSession::create( $this->tenant->identifier(), AuthenticationSession::STATE_FRESH ); $this->saveSession($session); return AuthenticationResponse::started($session->id, $methods); } /** * Identify user (identity-first flow) */ private function handleIdentify(AuthenticationRequest $request): AuthenticationResponse { $session = $this->retrieveSession($request->sessionId); if ($session === null) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_SESSION_EXPIRED, 'Invalid or expired session', 401 ); } // Return all tenant methods to prevent enumeration // Filter to non-redirect methods since redirects don't need identity first $methods = $this->methodsConfigured(); $methods = array_values(array_filter($methods, fn($m) => $m['method'] !== 'redirect')); $require = $this->tenant->configuration()->authentication()->methodsMinimal(); // Store identity in session without validating to prevent enumeration $session->setMethods(array_column($methods, 'id'), $require); $session->setIdentity($request->identity); $this->saveSession($session); return AuthenticationResponse::identified($session->id, $session->state(), $methods); } /** * Verify credentials or challenge response */ private function handleVerify(AuthenticationRequest $request): AuthenticationResponse { $session = $this->retrieveSession($request->sessionId); if ($session === null) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_SESSION_EXPIRED, 'Invalid or expired session', 401 ); } if (empty($session->userIdentity)) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INVALID_SESSION, 'Identity is required', 400 ); } $method = $request->method; if (!$session->methodEligible($method)) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INVALID_REQUEST, 'Method not available', 400 ); } $provider = $this->providerManager->resolve('authentication', $method); if (!$provider instanceof AuthenticationProviderInterface) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INVALID_PROVIDER, 'Provider not available', 400 ); } // Build provider context $context = $this->buildProviderContext($session, $method); // Call appropriate provider method based on provider type $providerMethod = $provider->method(); if ($providerMethod === AuthenticationProviderInterface::METHOD_CREDENTIAL) { $result = $provider->verify($context, $request->secret); } elseif ($providerMethod === AuthenticationProviderInterface::METHOD_CHALLENGE) { $result = $provider->verifyChallenge($context, $request->secret); } else { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INVALID_PROVIDER, 'Provider cannot be used for direct verification', 400 ); } // Store any session data from provider if (!empty($result->sessionData)) { $session->setMeta("provider:{$method}", $result->sessionData); } if (!$result->isSuccess()) { $this->saveSession($session); return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INVALID_CREDENTIALS, 'Authentication failed. If you haven\'t set up this method, try another option.', 401 ); } // Resolve user if not yet set if ($session->userIdentifier === null) { $user = $this->userService->fetchByIdentity($session->userIdentity); if ($user === null) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_USER_NOT_FOUND, 'User not found', 401 ); } $session->userIdentifier = $user->getId(); } // Mark method complete $session->methodCompleted($method); $this->saveSession($session); // Check if all required factors are complete if ($session->state() !== AuthenticationSession::STATE_COMPLETE) { $remainingMethods = $this->methodsConfigured($session->methodsCompleted); // Filter out redirect methods - they can't be used as secondary factors $remainingMethods = array_values(array_filter( $remainingMethods, fn($m) => $m['method'] !== 'redirect' )); return AuthenticationResponse::pending($session->id, $remainingMethods); } // Authentication complete - issue tokens return $this->completeAuthentication($session); } /** * Begin a challenge (SMS, email, TOTP preparation) */ private function handleChallenge(AuthenticationRequest $request): AuthenticationResponse { $session = $this->retrieveSession($request->sessionId); if ($session === null) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_SESSION_EXPIRED, 'Invalid or expired session', 401 ); } $method = $request->method; // Resolve user identifier if needed if ($session->userIdentifier === null && $session->userIdentity) { $user = $this->userService->fetchByIdentity($session->userIdentity); if ($user) { $session->userIdentifier = $user->getId(); $this->saveSession($session); } } $provider = $this->providerManager->resolve('authentication', $method); if (!$provider instanceof AuthenticationProviderInterface) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INVALID_PROVIDER, 'Provider not available', 400 ); } $context = $this->buildProviderContext($session, $method); $result = $provider->beginChallenge($context); // Store any session data from provider if (!empty($result->sessionData)) { $session->setMeta("provider:{$method}", $result->sessionData); $this->saveSession($session); } if ($result->isChallenge()) { return AuthenticationResponse::challenge( $session->id, $result->getClientData('challenge', []) ); } if ($result->isFailed()) { // Generic error to prevent enumeration return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INVALID_CREDENTIALS, 'Authentication failed. If you haven\'t set up this method, try another option.', 401 ); } // Unexpected result return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INTERNAL, 'Unexpected provider response', 500 ); } /** * Begin redirect-based authentication (OIDC/SAML) */ private function handleRedirect(AuthenticationRequest $request): AuthenticationResponse { $session = $this->retrieveSession($request->sessionId); if ($session === null) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_SESSION_EXPIRED, 'Invalid or expired session', 401 ); } $method = $request->method; $provider = $this->providerManager->resolve('authentication', $method); if (!$provider instanceof AuthenticationProviderInterface) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INVALID_PROVIDER, 'Provider not available', 400 ); } if ($provider->method() !== AuthenticationProviderInterface::METHOD_REDIRECT) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INVALID_PROVIDER, 'Provider does not support redirect authentication', 400 ); } $context = $this->buildProviderContext($session, $method); $result = $provider->beginRedirect($context, $request->callbackUrl, $request->returnUrl); if ($result->isFailed()) { return AuthenticationResponse::failed( $result->errorCode ?? AuthenticationResponse::ERROR_INTERNAL, $result->errorMessage ?? 'Failed to initiate redirect authentication', 500 ); } // Store provider session data (state, nonce, etc.) $session->setMeta("provider:{$method}", $result->sessionData); $session->setMeta('redirect_method', $method); $this->saveSession($session); return AuthenticationResponse::redirect( $session->id, $result->getClientData('redirect_url') ); } /** * Complete redirect-based authentication (callback from IdP) */ private function handleCallback(AuthenticationRequest $request): AuthenticationResponse { $session = $this->retrieveSession($request->sessionId); if ($session === null) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_SESSION_EXPIRED, 'Invalid or expired session', 401 ); } $method = $request->method; $expectedMethod = $session->getMeta('redirect_method'); if ($expectedMethod !== $method) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INVALID_SESSION, 'Provider mismatch', 400 ); } $provider = $this->providerManager->resolve('authentication', $method); if (!$provider instanceof AuthenticationProviderInterface) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INVALID_PROVIDER, 'Provider not available', 400 ); } $context = $this->buildProviderContext($session, $method); $result = $provider->completeRedirect($context, $request->params); if ($result->isFailed()) { $this->deleteSession($session->id); return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INVALID_CREDENTIALS, $result->errorMessage ?? 'Authentication failed', 401 ); } // Provider has already provisioned the user - just get user identifier $userIdentifier = $result->identity['user_identifier'] ?? null; if (!$userIdentifier) { $this->deleteSession($session->id); return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INTERNAL, 'User provisioning failed', 500 ); } // Load user $userData = $this->userService->fetchByIdentifier($userIdentifier); if (!$userData) { $this->deleteSession($session->id); return AuthenticationResponse::failed( AuthenticationResponse::ERROR_USER_NOT_FOUND, 'User not found after provisioning', 401 ); } $user = new User(); $user->populate($userData, 'users'); // Set user in session $session->userIdentifier = $user->getId(); $session->userIdentity = $user->getIdentity(); $session->methodCompleted($method); // Check if MFA is required $require = $this->tenant->configuration()->authentication()->methodsMinimal(); if ($require > 1) { $remainingMethods = $this->methodsConfigured([$method]); // Filter out redirect methods - they can't be used as secondary factors $remainingMethods = array_values(array_filter( $remainingMethods, fn($m) => $m['method'] !== 'redirect' )); $session->setMethods(array_column($remainingMethods, 'id'), $require); $this->saveSession($session); return AuthenticationResponse::pending($session->id, $remainingMethods); } // Authentication complete return $this->completeAuthentication($session); } /** * Get session status */ private function handleStatus(AuthenticationRequest $request): AuthenticationResponse { $session = $this->retrieveSession($request->sessionId); if ($session === null) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_SESSION_EXPIRED, 'Session not found or expired', 404 ); } $methods = $this->methodsConfigured($session->methodsCompleted); return AuthenticationResponse::status( $session->id, $session->state(), $methods, $session->userIdentity ); } /** * Cancel session */ private function handleCancel(AuthenticationRequest $request): AuthenticationResponse { if ($request->sessionId) { $this->deleteSession($request->sessionId); } return AuthenticationResponse::cancelled(); } /** * Refresh access token */ private function handleRefresh(AuthenticationRequest $request): AuthenticationResponse { $payload = $this->tokenService->validateToken($request->token, $this->securityCode); if (!$payload) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INVALID_CREDENTIALS, 'Invalid or expired refresh token', 401 ); } if (($payload['type'] ?? null) !== 'refresh') { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INVALID_CREDENTIALS, 'Invalid token type', 401 ); } $identifier = $payload['identifier'] ?? null; $userData = $this->userService->fetchByIdentifier($identifier); if ($userData === null) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_USER_NOT_FOUND, 'User not found', 401 ); } $user = new User(); $user->populate($userData, 'users'); $accessToken = $this->tokenService->createToken( [ 'tenant' => $this->tenant->identifier(), 'identifier' => $user->getId(), 'identity' => $user->getIdentity(), 'label' => $user->getLabel(), 'permissions' => $user->getPermissions(), 'mfa_verified' => true, ], $this->securityCode, 900 ); return AuthenticationResponse::success( $this->buildUserData($user), ['access' => $accessToken] ); } /** * Logout */ private function handleLogout(AuthenticationRequest $request): AuthenticationResponse { $allDevices = $request->params['all_devices'] ?? false; if ($request->token) { $payload = $this->tokenService->validateToken($request->token, $this->securityCode); if ($payload) { if ($allDevices && isset($payload['identity'])) { $this->tokenService->blacklistUserTokensBefore($payload['identity'], time()); } elseif (isset($payload['jti'], $payload['exp'])) { $this->tokenService->blacklist($payload['jti'], $payload['exp']); } } } return AuthenticationResponse::cancelled(); } // ========================================================================= // Helper Methods // ========================================================================= /** * Build provider context from session */ private function buildProviderContext(AuthenticationSession $session, string $method): ProviderContext { return new ProviderContext( tenantId: $session->tenantIdentifier, userIdentifier: $session->userIdentifier, userIdentity: $session->userIdentity, metadata: $session->getMeta("provider:{$method}") ?? [], config: $this->getProviderConfig($method), ); } /** * Get provider configuration */ private function getProviderConfig(string $method): array { $providers = $this->tenant->configuration()->authentication()->providers(); return $providers[$method]['config'] ?? []; } /** * Complete authentication and issue tokens */ private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse { $userData = $this->userService->fetchByIdentifier($session->userIdentifier); if ($userData === null) { return AuthenticationResponse::failed( AuthenticationResponse::ERROR_USER_NOT_FOUND, 'User not found', 401 ); } $user = new User(); $user->populate($userData, 'users'); $tokens = $this->createTokens($user, count($session->methodsCompleted) > 1); $this->deleteSession($session->id); return AuthenticationResponse::success( $this->buildUserData($user), $tokens ); } /** * Build user data for response */ private function buildUserData(User $user): array { return [ 'identifier' => $user->getId(), 'identity' => $user->getIdentity(), 'label' => $user->getLabel(), 'permissions' => $user->getPermissions(), ]; } /** * Get configured authentication methods */ private function methodsConfigured(array $methodsCompleted = []): array { $tenantProviders = $this->tenant->configuration()->authentication()->providers(); $methods = []; foreach ($tenantProviders as $providerId => $providerConfiguration) { if (!($providerConfiguration['enabled'] ?? false)) { continue; } if (in_array($providerId, $methodsCompleted, true)) { continue; } $provider = $this->providerManager->resolve('authentication', $providerId); if (!$provider instanceof AuthenticationProviderInterface) { continue; } $methods[] = [ 'id' => $providerId, 'method' => $provider->method(), 'label' => $providerConfiguration['label'] ?? $provider->label(), 'icon' => $providerConfiguration['icon'] ?? $provider->icon() ?? null, ]; } return $methods; } /** * Create JWT tokens */ private function createTokens(User $user, bool $mfaVerified = false): array { $payload = [ 'tenant' => $this->tenant->identifier(), 'identifier' => $user->getId(), 'identity' => $user->getIdentity(), 'label' => $user->getLabel(), 'permissions' => $user->getPermissions(), 'mfa_verified' => $mfaVerified, ]; return [ 'access' => $this->tokenService->createToken($payload, $this->securityCode, 900), 'refresh' => $this->tokenService->createToken( [ 'tenant' => $payload['tenant'], 'identifier' => $payload['identifier'], 'identity' => $payload['identity'], 'type' => 'refresh', ], $this->securityCode, 604800 ), ]; } /** * Find or provision user from external identity */ private function findOrProvisionUser( string $providerId, array $identity, array $providerConfig ): ?User { $userIdentity = $identity['email'] ?? $identity['identity'] ?? null; $externalSubject = $identity['subject'] ?? $identity['sub'] ?? null; $attributes = $identity['attributes'] ?? []; $attributes['identity'] = $userIdentity; $attributes['external_subject'] = $externalSubject; /* // Try to find by external subject first if ($externalSubject) { $user = $this->provisioningService->findByExternalIdentity($providerId, $externalSubject); if ($user) { $this->provisioningService->syncProfile( $user, $attributes, $providerConfig['attribute_map'] ?? [] ); return $user; } } // Try to find by identity if ($userIdentity) { $existingUser = $this->userService->fetchByIdentity($userIdentity); if ($existingUser) { if ($existingUser->getProvider() === $providerId) { if ($externalSubject) { $this->provisioningService->linkExternalIdentity( $existingUser, $providerId, $externalSubject, $attributes ); } $this->provisioningService->syncProfile( $existingUser, $attributes, $providerConfig['attribute_map'] ?? [] ); return $existingUser; } return null; } } // Auto-provision if enabled if ($this->provisioningService->isAutoProvisioningEnabled($providerId)) { return $this->provisioningService->provisionUser( $providerId, $attributes, $providerConfig ); } */ return null; } // ========================================================================= // Session Cache Helpers // ========================================================================= /** * Retrieve authentication session from cache */ private function retrieveSession(?string $sessionId): ?AuthenticationSession { if (empty($sessionId)) { return null; } $data = $this->cache->get($sessionId, CacheScope::Tenant, self::CACHE_USAGE); if ($data === null) { return null; } if ($data instanceof AuthenticationSession) { if ($data->isExpired()) { $this->deleteSession($sessionId); return null; } return $data; } return null; } /** * Save authentication session to cache */ private function saveSession(AuthenticationSession $session): bool { $ttl = $session->expiresAt > 0 ? $session->expiresAt - time() : AuthenticationSession::DEFAULT_TTL; return $this->cache->set( $session->id, $session, CacheScope::Tenant, self::CACHE_USAGE, max($ttl, 60) ); } /** * Delete authentication session from cache */ private function deleteSession(string $sessionId): bool { return $this->cache->delete($sessionId, CacheScope::Tenant, self::CACHE_USAGE); } }