From bff633f41fcedfa02767e38d0de7f87da37fc73f Mon Sep 17 00:00:00 2001 From: dogukanoksuz Date: Tue, 28 Jul 2026 12:22:51 +0300 Subject: [PATCH 1/3] feat: authentication handoff --- .env.example | 22 ++- app/Classes/Authentication/Authenticator.php | 148 ++++++++------ .../Handoff/AuthenticationHandoffService.php | 183 ++++++++++++++++++ .../Handoff/TrustedClientRegistry.php | 143 ++++++++++++++ .../Authentication/OIDC/OIDCFlowService.php | 182 ++++++++++++++--- .../OIDC/OpenIDConnectClient.php | 11 ++ app/Http/Controllers/API/AuthController.php | 18 ++ .../Controllers/API/AuthHandoffController.php | 56 ++++++ app/Providers/OIDCServiceProvider.php | 5 + app/Providers/RouteServiceProvider.php | 11 ++ config/auth_handoff.php | 34 ++++ docs/authentication-handoff.md | 71 +++++++ routes/api.php | 3 + 13 files changed, 802 insertions(+), 85 deletions(-) create mode 100644 app/Classes/Authentication/Handoff/AuthenticationHandoffService.php create mode 100644 app/Classes/Authentication/Handoff/TrustedClientRegistry.php create mode 100644 app/Http/Controllers/API/AuthHandoffController.php create mode 100644 config/auth_handoff.php create mode 100644 docs/authentication-handoff.md diff --git a/.env.example b/.env.example index 9eb044f8..8f2fe024 100644 --- a/.env.example +++ b/.env.example @@ -206,6 +206,26 @@ OIDC_CLIENT_SECRET= # This is the URL where your application will receive the authentication response from the OIDC provider. OIDC_REDIRECT_URI=https:///api/auth/oidc/callback +# OIDC SSL CERTIFICATE VERIFICATION +# Keep enabled in production. Set to false only when the OIDC provider uses a +# self-signed certificate that is not installed in Liman's trusted CA store. +OIDC_SSL_VERIFY=true + +# TRUSTED APPLICATION AUTHENTICATION HANDOFF +# Optional. Lets confidential applications complete login through Liman OIDC +# without exposing the Liman JWT to the browser. Generate every client secret +# with at least 32 cryptographically random characters. Redirect URIs are +# exact-match and must use HTTPS outside explicit loopback development. +# Example: +# AUTH_HANDOFF_CLIENTS='{"netex":{"secret":"","redirect_uris":["https://netex.example/auth/liman/callback"]}}' +AUTH_HANDOFF_CLIENTS={} + +# One-time handoff authorization code lifetime in seconds (clamped to 30-300). +AUTH_HANDOFF_CODE_TTL=60 + +# Development only: allow http://localhost, http://127.0.0.1, and http://[::1]. +AUTH_HANDOFF_ALLOW_INSECURE_LOOPBACK=false + # OIDC AUTHORIZATION ENDPOINT # The endpoint used for user authentication. # This is typically the authorization endpoint of your OIDC provider. @@ -341,4 +361,4 @@ REDIS_SSL_VERIFY_PEER=true # REDIS SSL VERIFY PEER NAME # Verify the peer's certificate name # Values: true, false -REDIS_SSL_VERIFY_PEER_NAME=false \ No newline at end of file +REDIS_SSL_VERIFY_PEER_NAME=false diff --git a/app/Classes/Authentication/Authenticator.php b/app/Classes/Authentication/Authenticator.php index 7d5ef153..4fd674dd 100644 --- a/app/Classes/Authentication/Authenticator.php +++ b/app/Classes/Authentication/Authenticator.php @@ -20,6 +20,99 @@ class Authenticator */ public static function createNewToken($token, ?Request $request = null) { + $request ??= request(); + [$user, $return, $tokenTimeout] = self::recordLogin($request); + + // OIDC kullanıcıları için callback URL'den ana sayfaya redirect + if ($user->auth_type === 'oidc' && $request && $request->has('callback_url')) { + $callbackUrl = $request->input('callback_url'); + + // Callback URL'yi parse et ve ana sayfaya redirect URL'i oluştur + $parsedUrl = parse_url($callbackUrl); + $baseUrl = $parsedUrl['scheme'] . '://' . $parsedUrl['host'] . + (isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : ''); + + $redirectResponse = redirect($baseUrl . '/'); + + // Cookie'leri redirect response'a ekle + return $redirectResponse + ->withCookie(cookie( + 'token', + $token, + $tokenTimeout, + null, + $request->getHost(), + true, + true, + false + )) + ->withCookie(cookie( + 'currentUser', + json_encode($return), + $tokenTimeout, + null, + $request->getHost(), + true, + false, + false + )); + } + + return response()->json($return)->withCookie(cookie( + 'token', + $token, + $tokenTimeout, + null, + $request->getHost(), + true, + true, + false + ))->withCookie(cookie( + 'currentUser', + json_encode($return), + $tokenTimeout, + null, + $request->getHost(), + true, + false, + false + )); + } + + /** + * Build the confidential response stored behind a one-time handoff code. + * The JWT is returned only by the authenticated server-to-server exchange. + * + * @param string $token + * @return array + */ + public static function createHandoffToken($token, ?Request $request = null): array + { + $request ??= request(); + [$user, $return] = self::recordLogin($request); + + return [ + 'access_token' => $token, + 'token_type' => 'Bearer', + 'expired_at' => $return['expired_at'], + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'status' => $user->status, + 'auth_type' => $user->auth_type, + ], + ]; + } + + /** + * Persist login audit data and return the shared token response metadata. + * + * @return array{0: User, 1: array, 2: int} + */ + private static function recordLogin(?Request $request): array + { + $request ??= request(); $id = auth('api')->user()->id; $user = User::find($id); @@ -123,60 +216,7 @@ public static function createNewToken($token, ?Request $request = null) $tokenTimeout = auth('api')->factory()->getTTL() * 60; } - // OIDC kullanıcıları için callback URL'den ana sayfaya redirect - if ($user->auth_type === 'oidc' && $request && $request->has('callback_url')) { - $callbackUrl = $request->input('callback_url'); - - // Callback URL'yi parse et ve ana sayfaya redirect URL'i oluştur - $parsedUrl = parse_url($callbackUrl); - $baseUrl = $parsedUrl['scheme'] . '://' . $parsedUrl['host'] . - (isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : ''); - - $redirectResponse = redirect($baseUrl . '/'); - - // Cookie'leri redirect response'a ekle - return $redirectResponse - ->withCookie(cookie( - 'token', - $token, - $tokenTimeout, - null, - $request->getHost(), - true, - true, - false - )) - ->withCookie(cookie( - 'currentUser', - json_encode($return), - $tokenTimeout, - null, - $request->getHost(), - true, - false, - false - )); - } - - return response()->json($return)->withCookie(cookie( - 'token', - $token, - $tokenTimeout, - null, - $request->getHost(), - true, - true, - false - ))->withCookie(cookie( - 'currentUser', - json_encode($return), - $tokenTimeout, - null, - $request->getHost(), - true, - false, - false - )); + return [$user, $return, $tokenTimeout]; } /** diff --git a/app/Classes/Authentication/Handoff/AuthenticationHandoffService.php b/app/Classes/Authentication/Handoff/AuthenticationHandoffService.php new file mode 100644 index 00000000..6dc9049c --- /dev/null +++ b/app/Classes/Authentication/Handoff/AuthenticationHandoffService.php @@ -0,0 +1,183 @@ +clients ??= new TrustedClientRegistry; + } + + /** + * @param array $handoff + * @return array|null + */ + public function authorizeInitiation(array $handoff): ?array + { + return $this->clients->authorizeInitiation($handoff); + } + + /** + * @param array $handoff + * @param array $tokenPayload + */ + public function issue(array $handoff, array $tokenPayload): string + { + $code = $this->randomCode(); + $digest = hash('sha256', $code); + $ttl = (int) config('auth_handoff.code_ttl', 60); + $expiresAt = time() + $ttl; + + $payload = [ + 'version' => 1, + 'client_id' => $handoff['client_id'], + 'redirect_uri' => $handoff['redirect_uri'], + 'code_challenge' => $handoff['code_challenge'], + 'code_challenge_method' => 'S256', + 'token' => $tokenPayload, + 'expires_at' => $expiresAt, + ]; + + Cache::put( + self::CODE_CACHE_PREFIX.$digest, + Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR)), + $ttl, + ); + + Log::info('Authentication handoff issued', [ + 'client_id' => $handoff['client_id'], + 'expires_at' => $expiresAt, + ]); + + return $code; + } + + /** + * @return array|null + */ + public function exchange( + string $clientId, + string $clientSecret, + string $code, + string $codeVerifier, + string $redirectUri, + ): ?array { + if (! $this->clients->authenticate($clientId, $clientSecret) + || ! $this->clients->redirectRegistered($clientId, $redirectUri) + || ! preg_match('/\A[A-Za-z0-9_-]{43}\z/', $code) + || ! preg_match('/\A[A-Za-z0-9\-._~]{43,128}\z/', $codeVerifier)) { + return null; + } + + $digest = hash('sha256', $code); + $lock = Cache::lock(self::LOCK_CACHE_PREFIX.$digest, 5); + if (! $lock->get()) { + return null; + } + + try { + $encrypted = Cache::get(self::CODE_CACHE_PREFIX.$digest); + if (! is_string($encrypted)) { + return null; + } + + try { + $payload = json_decode(Crypt::decryptString($encrypted), true, 32, JSON_THROW_ON_ERROR); + } catch (\Throwable $e) { + Cache::forget(self::CODE_CACHE_PREFIX.$digest); + Log::warning('Discarded invalid authentication handoff record', [ + 'client_id' => $clientId, + ]); + + return null; + } + + $expectedChallenge = $this->codeChallenge($codeVerifier); + if (! is_array($payload) + || ($payload['version'] ?? null) !== 1 + || ($payload['client_id'] ?? null) !== $clientId + || ($payload['redirect_uri'] ?? null) !== $redirectUri + || ($payload['code_challenge_method'] ?? null) !== 'S256' + || ! is_string($payload['code_challenge'] ?? null) + || ! hash_equals($payload['code_challenge'], $expectedChallenge) + || ! is_int($payload['expires_at'] ?? null) + || $payload['expires_at'] < time() + || ! is_array($payload['token'] ?? null)) { + return null; + } + + // Forget while holding the distributed lock: a successful code can + // be redeemed exactly once, even across multiple Liman replicas. + Cache::forget(self::CODE_CACHE_PREFIX.$digest); + + Log::info('Authentication handoff redeemed', [ + 'client_id' => $clientId, + ]); + + return $payload['token']; + } finally { + $this->release($lock); + } + } + + /** + * @param array $handoff + */ + public function successRedirect(array $handoff, string $code): string + { + return $handoff['redirect_uri'].'?'.http_build_query([ + 'code' => $code, + 'state' => $handoff['state'], + ], '', '&', PHP_QUERY_RFC3986); + } + + /** + * @param array $handoff + */ + public function errorRedirect(array $handoff, string $error): string + { + if (! preg_match('/\A[a-z][a-z0-9_]{0,63}\z/', $error)) { + $error = 'server_error'; + } + + return $handoff['redirect_uri'].'?'.http_build_query([ + 'error' => $error, + 'state' => $handoff['state'], + ], '', '&', PHP_QUERY_RFC3986); + } + + private function randomCode(): string + { + return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + } + + private function codeChallenge(string $verifier): string + { + return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + } + + private function release(Lock $lock): void + { + try { + $lock->release(); + } catch (\Throwable $e) { + Log::warning('Authentication handoff lock release failed'); + } + } +} diff --git a/app/Classes/Authentication/Handoff/TrustedClientRegistry.php b/app/Classes/Authentication/Handoff/TrustedClientRegistry.php new file mode 100644 index 00000000..7e46bf79 --- /dev/null +++ b/app/Classes/Authentication/Handoff/TrustedClientRegistry.php @@ -0,0 +1,143 @@ + */ + private array $clients; + + private bool $allowInsecureLoopback; + + /** + * @param array|null $clients + */ + public function __construct(?array $clients = null, ?bool $allowInsecureLoopback = null) + { + $this->clients = $clients ?? (array) config('auth_handoff.clients', []); + $this->allowInsecureLoopback = $allowInsecureLoopback + ?? (bool) config('auth_handoff.allow_insecure_loopback', false); + } + + /** + * Return the canonical handoff fields safe to persist with OIDC state. + * + * @param array $handoff + * @return array|null + */ + public function authorizeInitiation(array $handoff): ?array + { + $clientId = $handoff['client_id'] ?? null; + $redirectUri = $handoff['redirect_uri'] ?? null; + $state = $handoff['state'] ?? null; + $codeChallenge = $handoff['code_challenge'] ?? null; + $challengeMethod = $handoff['code_challenge_method'] ?? null; + + if (! is_string($clientId) + || ! preg_match('/\A[A-Za-z0-9_-]{1,64}\z/', $clientId) + || ! is_string($redirectUri) + || ! is_string($state) + || ! preg_match('/\A[A-Za-z0-9_-]{32,128}\z/', $state) + || ! is_string($codeChallenge) + || ! preg_match('/\A[A-Za-z0-9_-]{43}\z/', $codeChallenge) + || $challengeMethod !== 'S256') { + return null; + } + + $client = $this->client($clientId); + if ($client === null || ! $this->redirectAllowed($client, $redirectUri)) { + return null; + } + + return [ + 'client_id' => $clientId, + 'redirect_uri' => $redirectUri, + 'state' => $state, + 'code_challenge' => $codeChallenge, + 'code_challenge_method' => 'S256', + ]; + } + + public function authenticate(string $clientId, string $secret): bool + { + $client = $this->client($clientId); + $expected = $client['secret'] ?? null; + + if (! is_string($expected) || strlen($expected) < 32 || strlen($secret) < 32) { + return false; + } + + return hash_equals($expected, $secret); + } + + public function redirectRegistered(string $clientId, string $redirectUri): bool + { + $client = $this->client($clientId); + + return $client !== null && $this->redirectAllowed($client, $redirectUri); + } + + /** + * @return array|null + */ + private function client(string $clientId): ?array + { + if (! preg_match('/\A[A-Za-z0-9_-]{1,64}\z/', $clientId)) { + return null; + } + + $client = $this->clients[$clientId] ?? null; + if (! is_array($client) || ($client['enabled'] ?? true) !== true) { + return null; + } + + return $client; + } + + /** + * @param array $client + */ + private function redirectAllowed(array $client, string $redirectUri): bool + { + $registered = $client['redirect_uris'] ?? null; + if (! is_array($registered) + || ! in_array($redirectUri, $registered, true) + || ! $this->validRedirectUri($redirectUri)) { + return false; + } + + return true; + } + + private function validRedirectUri(string $redirectUri): bool + { + if (strlen($redirectUri) > 2048 || filter_var($redirectUri, FILTER_VALIDATE_URL) === false) { + return false; + } + + $parts = parse_url($redirectUri); + if (! is_array($parts) + || isset($parts['user']) + || isset($parts['pass']) + || isset($parts['query']) + || isset($parts['fragment']) + || empty($parts['host']) + || empty($parts['scheme'])) { + return false; + } + + if (strtolower($parts['scheme']) === 'https') { + return true; + } + + if (! $this->allowInsecureLoopback || strtolower($parts['scheme']) !== 'http') { + return false; + } + + return in_array(strtolower($parts['host']), ['127.0.0.1', '::1', 'localhost'], true); + } +} diff --git a/app/Classes/Authentication/OIDC/OIDCFlowService.php b/app/Classes/Authentication/OIDC/OIDCFlowService.php index be0e1728..e553c251 100644 --- a/app/Classes/Authentication/OIDC/OIDCFlowService.php +++ b/app/Classes/Authentication/OIDC/OIDCFlowService.php @@ -3,6 +3,7 @@ namespace App\Classes\Authentication\OIDC; use App\Classes\Authentication\Authenticator; +use App\Classes\Authentication\Handoff\AuthenticationHandoffService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -29,6 +30,8 @@ class OIDCFlowService { private const STATE_CACHE_PREFIX = 'oidc_state:'; + private const STATE_LOCK_PREFIX = 'oidc_state_lock:'; + private const STATE_TTL = 1800; // 30 dakika public function __construct( @@ -36,11 +39,13 @@ public function __construct( ?OIDCUserProvisioner $userProvisioner = null, ?OIDCRoleMapper $roleMapper = null, ?OIDCTokenStore $tokenStore = null, + ?AuthenticationHandoffService $handoffService = null, ) { $this->client = $client ?? new OpenIDConnectClient; $this->userProvisioner = $userProvisioner ?? new OIDCUserProvisioner; $this->roleMapper = $roleMapper ?? new OIDCRoleMapper; $this->tokenStore = $tokenStore ?? new OIDCTokenStore; + $this->handoffService = $handoffService ?? new AuthenticationHandoffService; } /** @var OpenIDConnectClient */ @@ -55,6 +60,8 @@ public function __construct( /** @var OIDCTokenStore */ private $tokenStore; + private AuthenticationHandoffService $handoffService; + /** * OIDC flow'unu başlat - frontend'e redirect URL'i döndür. */ @@ -63,6 +70,17 @@ public function initiate(Request $request): JsonResponse $state = Str::random(40); $nonce = Str::random(32); + $handoff = null; + if ($request->has('handoff')) { + $input = $request->input('handoff'); + $handoff = is_array($input) + ? $this->handoffService->authorizeInitiation($input) + : null; + if ($handoff === null) { + return $this->error('Invalid authentication handoff request', 400); + } + } + $redirectPath = null; if ($request->has('redirect_path')) { $redirectPath = $this->validateRedirectPath($request->input('redirect_path')); @@ -73,21 +91,26 @@ public function initiate(Request $request): JsonResponse 'ip' => $request->ip(), 'user_agent' => $request->userAgent(), 'redirect_path' => $redirectPath, + 'handoff' => $handoff, 'created_at' => now()->toDateTimeString(), ], self::STATE_TTL); $authUrl = $this->client->buildAuthorizationUrl($state, $nonce); Log::info('OIDC flow initiated', [ - 'state' => $state, 'ip' => $request->ip(), 'user_agent' => $request->userAgent(), + 'handoff_client_id' => $handoff['client_id'] ?? null, ]); return response()->json([ 'message' => 'OIDC provider\'a yönlendiriliyor...', 'redirect_required' => true, 'redirect_url' => $authUrl, + ])->withHeaders([ + 'Cache-Control' => 'no-store', + 'Pragma' => 'no-cache', + 'Referrer-Policy' => 'no-referrer', ]); } @@ -96,42 +119,55 @@ public function initiate(Request $request): JsonResponse */ public function handleCallback(Request $request): JsonResponse|RedirectResponse { + $stateData = null; + try { Log::info('OIDC callback received', [ - 'state' => $request->state, + 'has_state' => $request->has('state'), 'has_code' => $request->has('code'), 'has_error' => $request->has('error'), 'ip' => $request->ip(), ]); - if ($request->has('error')) { - Log::error('OIDC authentication error: '.$request->error.' - '.$request->error_description); - - return $this->error('OIDC authentication failed: '.($request->error_description ?? $request->error), 401); - } - - if (! $request->has('code')) { - Log::error('OIDC callback received without authorization code'); - - return $this->error('Authorization code not received', 400); - } - if (! $request->has('state')) { Log::error('OIDC callback received without state parameter'); return $this->error('State parameter not received', 400); } - $stateData = Cache::get(self::STATE_CACHE_PREFIX.$request->state); + $stateData = $this->consumeState((string) $request->state); if (! $stateData) { Log::error('OIDC state not found in cache', [ - 'state' => $request->state, 'ip' => $request->ip(), ]); return $this->error('Invalid or expired state parameter', 400); } + if ($request->has('error')) { + Log::error('OIDC authentication error', [ + 'error' => (string) $request->error, + ]); + + return $this->callbackError( + $stateData, + 'access_denied', + 'OIDC authentication failed', + 401, + ); + } + + if (! $request->has('code')) { + Log::error('OIDC callback received without authorization code'); + + return $this->callbackError( + $stateData, + 'invalid_request', + 'Authorization code not received', + 400, + ); + } + $result = $this->client->completeAuthorizationCodeFlow( $request->code, $stateData['nonce'], @@ -143,14 +179,24 @@ public function handleCallback(Request $request): JsonResponse|RedirectResponse // kontrolleri burada (spec: iat gelecekte olmamalı, azp multi-aud'de // client_id'ye eşit olmalı). if (! $this->validateExtraClaims($claims)) { - return $this->error('ID token claim validation failed', 400); + return $this->callbackError( + $stateData, + 'invalid_token', + 'ID token claim validation failed', + 400, + ); } $user = $this->userProvisioner->findOrCreate($claims); if (! $user) { Log::error('OIDC user creation/update failed.'); - return $this->error('User creation failed', 500); + return $this->callbackError( + $stateData, + 'server_error', + 'User creation failed', + 500, + ); } auth('api')->factory()->setTTL($user->session_time); @@ -158,8 +204,10 @@ public function handleCallback(Request $request): JsonResponse|RedirectResponse $request->merge([ 'ip' => $stateData['ip'], 'user_agent' => $stateData['user_agent'], - 'callback_url' => $request->fullUrl(), ]); + if (empty($stateData['handoff'])) { + $request->merge(['callback_url' => $request->fullUrl()]); + } $permissions = $this->extractPermissions($tokenResponse, $claims); if (! empty($permissions)) { @@ -174,28 +222,102 @@ public function handleCallback(Request $request): JsonResponse|RedirectResponse 'email' => $user->email, ]); - Cache::forget(self::STATE_CACHE_PREFIX.$request->state); + $limanToken = auth('api')->login($user); + if (! empty($stateData['handoff']) && is_array($stateData['handoff'])) { + $tokenPayload = Authenticator::createHandoffToken($limanToken, $request); + $code = $this->handoffService->issue($stateData['handoff'], $tokenPayload); + + return redirect()->away( + $this->handoffService->successRedirect($stateData['handoff'], $code), + )->withHeaders([ + 'Cache-Control' => 'no-store', + 'Pragma' => 'no-cache', + 'Referrer-Policy' => 'no-referrer', + ]); + } - $limanTokenResponse = Authenticator::createNewToken( - auth('api')->login($user), - $request, - ); + $limanTokenResponse = Authenticator::createNewToken($limanToken, $request); return redirect($stateData['redirect_path'] ?? '/') ->withCookies($limanTokenResponse->headers->getCookies()); } catch (OpenIDConnectClientException $e) { - Log::error('OIDC authentication failed: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), + Log::error('OIDC authentication failed', [ + 'exception' => get_class($e), ]); - return $this->error('Authentication failed: '.$e->getMessage(), 400); + return $this->callbackError( + $stateData, + 'invalid_grant', + 'Authentication failed', + 400, + ); } catch (\Exception $e) { - Log::error('OIDC authentication exception: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), + Log::error('OIDC authentication exception', [ + 'exception' => get_class($e), ]); - return $this->error('Authentication failed', 500); + return $this->callbackError( + $stateData, + 'server_error', + 'Authentication failed', + 500, + ); + } + } + + /** + * Consume OIDC state once while holding a distributed cache lock. + * + * @return array|null + */ + private function consumeState(string $state): ?array + { + if (! preg_match('/\A[A-Za-z0-9]{40}\z/', $state)) { + return null; + } + + $lock = Cache::lock(self::STATE_LOCK_PREFIX.hash('sha256', $state), 5); + if (! $lock->get()) { + return null; } + + try { + $value = Cache::pull(self::STATE_CACHE_PREFIX.$state); + + return is_array($value) ? $value : null; + } finally { + try { + $lock->release(); + } catch (\Throwable $e) { + Log::warning('OIDC state lock release failed'); + } + } + } + + /** + * Return browser handoff errors to the registered application without + * exposing provider details. Native Liman OIDC retains its JSON behavior. + * + * @param array|null $stateData + */ + private function callbackError( + ?array $stateData, + string $error, + string $message, + int $status, + ): JsonResponse|RedirectResponse { + $handoff = $stateData['handoff'] ?? null; + if (is_array($handoff)) { + return redirect()->away( + $this->handoffService->errorRedirect($handoff, $error), + )->withHeaders([ + 'Cache-Control' => 'no-store', + 'Pragma' => 'no-cache', + 'Referrer-Policy' => 'no-referrer', + ]); + } + + return $this->error($message, $status); } /** diff --git a/app/Classes/Authentication/OIDC/OpenIDConnectClient.php b/app/Classes/Authentication/OIDC/OpenIDConnectClient.php index 8a6a00d0..dd81d57d 100644 --- a/app/Classes/Authentication/OIDC/OpenIDConnectClient.php +++ b/app/Classes/Authentication/OIDC/OpenIDConnectClient.php @@ -39,6 +39,17 @@ public function __construct() $issuer ?: null, ); + $verifySsl = filter_var( + env('OIDC_SSL_VERIFY', true), + FILTER_VALIDATE_BOOLEAN, + ); + if (! $verifySsl) { + // Jumbojett performs its requests with cURL rather than Laravel's + // HTTP client. These are its equivalent of withoutVerifying(). + $this->setVerifyPeer(false); + $this->setVerifyHost(false); + } + $redirectUri = env('OIDC_REDIRECT_URI'); if ($redirectUri) { $this->setRedirectURL($redirectUri); diff --git a/app/Http/Controllers/API/AuthController.php b/app/Http/Controllers/API/AuthController.php index 802db9ff..207a4640 100644 --- a/app/Http/Controllers/API/AuthController.php +++ b/app/Http/Controllers/API/AuthController.php @@ -99,6 +99,24 @@ public function login(Request $request) $validator = Validator::make($request->all(), [ 'type' => 'required|string', 'redirect_path' => 'nullable|string', + 'handoff' => 'nullable|array', + 'handoff.client_id' => [ + 'required_with:handoff', + 'string', + 'regex:/\A[A-Za-z0-9_-]{1,64}\z/', + ], + 'handoff.redirect_uri' => 'required_with:handoff|string|url|max:2048', + 'handoff.state' => [ + 'required_with:handoff', + 'string', + 'regex:/\A[A-Za-z0-9_-]{32,128}\z/', + ], + 'handoff.code_challenge' => [ + 'required_with:handoff', + 'string', + 'regex:/\A[A-Za-z0-9_-]{43}\z/', + ], + 'handoff.code_challenge_method' => 'required_with:handoff|in:S256', ]); } else { $validator = Validator::make($request->all(), [ diff --git a/app/Http/Controllers/API/AuthHandoffController.php b/app/Http/Controllers/API/AuthHandoffController.php new file mode 100644 index 00000000..59d6456a --- /dev/null +++ b/app/Http/Controllers/API/AuthHandoffController.php @@ -0,0 +1,56 @@ +getUser(); + $clientSecret = (string) $request->getPassword(); + if ($clientId === '' || $clientSecret === '') { + return $this->error('invalid_client', 401); + } + + $validator = Validator::make($request->all(), [ + 'grant_type' => 'required|in:authorization_code', + 'code' => ['required', 'string', 'regex:/\A[A-Za-z0-9_-]{43}\z/'], + 'code_verifier' => ['required', 'string', 'regex:/\A[A-Za-z0-9\-._~]{43,128}\z/'], + 'redirect_uri' => 'required|string|url|max:2048', + ]); + if ($validator->fails()) { + return $this->error('invalid_request', 400); + } + + $input = $validator->validated(); + $token = $handoff->exchange( + $clientId, + $clientSecret, + $input['code'], + $input['code_verifier'], + $input['redirect_uri'], + ); + if ($token === null) { + // Do not reveal whether the client, code, redirect, or verifier was + // wrong. Every failure is safe to retry only by restarting login. + return $this->error('invalid_grant', 400); + } + + return response()->json($token) + ->header('Cache-Control', 'no-store') + ->header('Pragma', 'no-cache'); + } + + private function error(string $error, int $status): JsonResponse + { + return response()->json(['error' => $error], $status) + ->header('Cache-Control', 'no-store') + ->header('Pragma', 'no-cache'); + } +} diff --git a/app/Providers/OIDCServiceProvider.php b/app/Providers/OIDCServiceProvider.php index 6ee57ea6..ac85b74e 100644 --- a/app/Providers/OIDCServiceProvider.php +++ b/app/Providers/OIDCServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Classes\Authentication\Handoff\AuthenticationHandoffService; +use App\Classes\Authentication\Handoff\TrustedClientRegistry; use App\Classes\Authentication\OIDC\OIDCFlowService; use App\Classes\Authentication\OIDC\OIDCRoleMapper; use App\Classes\Authentication\OIDC\OIDCTokenStore; @@ -28,6 +30,8 @@ public function register(): void $this->app->singleton(OIDCUserProvisioner::class); $this->app->singleton(OIDCRoleMapper::class); $this->app->singleton(OIDCTokenStore::class); + $this->app->singleton(TrustedClientRegistry::class); + $this->app->singleton(AuthenticationHandoffService::class); $this->app->singleton(OIDCFlowService::class, function ($app) { return new OIDCFlowService( @@ -35,6 +39,7 @@ public function register(): void $app->make(OIDCUserProvisioner::class), $app->make(OIDCRoleMapper::class), $app->make(OIDCTokenStore::class), + $app->make(AuthenticationHandoffService::class), ); }); } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 8fd1b5b3..2beb0698 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -31,6 +31,13 @@ class RouteServiceProvider extends ServiceProvider public function boot() { RateLimiter::for('login', function ($request) { + if ($request->type === 'oidc' && $request->has('handoff')) { + // The BFF is the network caller, so all of its users share one + // source IP. Do not include the untrusted client_id in the key: + // attackers could rotate it to create unlimited rate buckets. + return Limit::perMinute(300)->by('handoff|'.$request->ip()); + } + return Limit::perMinute(3)->by($request->email.$request->ip()); }); @@ -46,6 +53,10 @@ public function boot() return Limit::perMinute(10)->by($request->ip()); }); + RateLimiter::for('auth-handoff-exchange', function ($request) { + return Limit::perMinute(300)->by($request->ip()); + }); + parent::boot(); Route::middleware([]) diff --git a/config/auth_handoff.php b/config/auth_handoff.php new file mode 100644 index 00000000..a437edb4 --- /dev/null +++ b/config/auth_handoff.php @@ -0,0 +1,34 @@ +", + | "redirect_uris": ["https://netex.example/auth/liman/callback"] + | } + | } + | + | Redirect URIs are matched exactly. JWTs are never sent through the + | browser; the registered backend redeems a short-lived, single-use code. + | + */ + 'clients' => is_array($clients) ? $clients : [], + + // Keep browser-visible authorization codes short-lived. + 'code_ttl' => max(30, min(300, (int) env('AUTH_HANDOFF_CODE_TTL', 60))), + + // HTTP callback URIs are only useful for explicit local development. + 'allow_insecure_loopback' => filter_var( + env('AUTH_HANDOFF_ALLOW_INSECURE_LOOPBACK', false), + FILTER_VALIDATE_BOOLEAN, + ), +]; diff --git a/docs/authentication-handoff.md b/docs/authentication-handoff.md new file mode 100644 index 00000000..a3bc911a --- /dev/null +++ b/docs/authentication-handoff.md @@ -0,0 +1,71 @@ +# Trusted application authentication handoff + +Liman can complete its existing OIDC flow for a confidential application on a +different browser origin without placing the Liman JWT in a redirect URL. + +## Client registration + +Configure an exact callback URI and a cryptographically random secret of at +least 32 characters: + +```env +AUTH_HANDOFF_CLIENTS='{"netex":{"secret":"","redirect_uris":["https://netex.example/auth/liman/callback"]}}' +AUTH_HANDOFF_CODE_TTL=60 +``` + +HTTP callbacks are rejected. For local loopback development only, set +`AUTH_HANDOFF_ALLOW_INSECURE_LOOPBACK=true`. + +## Flow + +1. The confidential application generates `state`, an RFC 7636 verifier, and + its S256 challenge. +2. Its backend calls `POST /api/auth/login` with: + + ```json + { + "type": "oidc", + "handoff": { + "client_id": "netex", + "redirect_uri": "https://netex.example/auth/liman/callback", + "state": "", + "code_challenge": "", + "code_challenge_method": "S256" + } + } + ``` + +3. The browser follows Liman's `redirect_url`. Liman owns the provider + callback, validates the OIDC response, provisions the user, maps roles, and + creates its JWT. +4. Liman stores the JWT payload behind an encrypted, short-lived, single-use + code and redirects to the registered application callback with `code` and + the original application `state`. +5. The application backend exchanges the code using HTTP Basic client + authentication: + + ```http + POST /api/auth/handoff/exchange + Authorization: Basic base64(client_id:client_secret) + Content-Type: application/json + + { + "grant_type": "authorization_code", + "code": "", + "code_verifier": "", + "redirect_uri": "https://netex.example/auth/liman/callback" + } + ``` + +The exchange response contains the Liman access token, expiry, and user +identity. It has `Cache-Control: no-store`. Codes are encrypted in Liman's +cache, keyed by a hash, protected by a distributed lock, bound to the client, +redirect URI, and PKCE challenge, and deleted after the first successful +exchange. + +Multi-replica Liman deployments must use a shared cache backend that supports +atomic locks (for example Redis) so OIDC state and one-time codes are available +and consumed consistently across replicas. + +Never put the Liman JWT in a query string, fragment, browser storage, or +cross-origin message. diff --git a/routes/api.php b/routes/api.php index 2633181f..b8bb5ac7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ name('oidcCallback') ->middleware('throttle:30,1'); + Route::post('/handoff/exchange', [AuthHandoffController::class, 'exchange']) + ->middleware('throttle:auth-handoff-exchange'); }); Route::post('/notifications/send', [ExternalNotificationController::class, 'accept']) From 459db522758aa4e28f2701ff04127037151fb845 Mon Sep 17 00:00:00 2001 From: dogukanoksuz Date: Thu, 30 Jul 2026 17:25:17 +0300 Subject: [PATCH 2/3] feat: user details for external services and oidc mapper fixes --- .../Authentication/OIDC/OIDCFlowService.php | 4 +- .../Authentication/OIDC/OIDCRoleMapper.php | 9 +- .../API/CurrentUserDetailsController.php | 150 ++++++++++++++++++ ...00000_label_oidc_auto_role_assignments.php | 26 +++ routes/api.php | 3 + 5 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 app/Http/Controllers/API/CurrentUserDetailsController.php create mode 100644 database/migrations/2026_07_30_000000_label_oidc_auto_role_assignments.php diff --git a/app/Classes/Authentication/OIDC/OIDCFlowService.php b/app/Classes/Authentication/OIDC/OIDCFlowService.php index e553c251..985a6085 100644 --- a/app/Classes/Authentication/OIDC/OIDCFlowService.php +++ b/app/Classes/Authentication/OIDC/OIDCFlowService.php @@ -210,9 +210,7 @@ public function handleCallback(Request $request): JsonResponse|RedirectResponse } $permissions = $this->extractPermissions($tokenResponse, $claims); - if (! empty($permissions)) { - $this->roleMapper->assignByPermissions($user, $permissions); - } + $this->roleMapper->assignByPermissions($user, $permissions); $externalToken = $this->extractExternalToken($claims); $this->tokenStore->persist($user, $tokenResponse, $externalToken, $permissions); diff --git a/app/Classes/Authentication/OIDC/OIDCRoleMapper.php b/app/Classes/Authentication/OIDC/OIDCRoleMapper.php index 03f48c1d..66f74b84 100644 --- a/app/Classes/Authentication/OIDC/OIDCRoleMapper.php +++ b/app/Classes/Authentication/OIDC/OIDCRoleMapper.php @@ -20,13 +20,13 @@ class OIDCRoleMapper { public function assignByPermissions(User $user, array $permissions): void { - if (empty($permissions)) { - return; - } - try { $this->removeAutoRoles($user); + if (empty($permissions)) { + return; + } + $matchingRoles = Role::whereIn('name', $permissions)->get(); if ($matchingRoles->isEmpty()) { @@ -53,6 +53,7 @@ public function assignByPermissions(User $user, array $permissions): void RoleUser::create([ 'user_id' => $user->id, 'role_id' => $role->id, + 'type' => 'oidc', 'auto' => true, ]); diff --git a/app/Http/Controllers/API/CurrentUserDetailsController.php b/app/Http/Controllers/API/CurrentUserDetailsController.php new file mode 100644 index 00000000..563b124f --- /dev/null +++ b/app/Http/Controllers/API/CurrentUserDetailsController.php @@ -0,0 +1,150 @@ +user(); + + $roleAssignments = $user->roles() + ->withPivot(['type', 'auto']) + ->with('permissions') + ->orderBy('roles.name') + ->get(); + + $roles = $roleAssignments + ->groupBy('id') + ->map(function (Collection $assignments): array { + /** @var Role $role */ + $role = $assignments->first(); + + return [ + 'id' => $role->id, + 'name' => $role->name, + 'assignments' => $assignments + ->map(fn (Role $assignedRole): array => [ + 'type' => $assignedRole->pivot->type, + 'automatic' => (bool) $assignedRole->pivot->auto, + ]) + ->unique(fn (array $assignment): string => serialize($assignment)) + ->values(), + 'permissions' => $this->serializePermissions($role->permissions), + ]; + }) + ->values(); + + $directPermissions = $this->serializePermissions( + $user->permissions()->get() + ); + + $assignedPermissions = $roles + ->flatMap(fn (array $role) => $role['permissions']) + ->merge($directPermissions) + ->unique(fn (array $permission): string => $this->permissionKey($permission)) + ->sortBy(fn (array $permission): string => $this->permissionKey($permission)) + ->values(); + + $externalRoles = $this->externalRoles($user->id, $user->auth_type); + + return response() + ->json([ + 'id' => $user->id, + 'name' => $user->name, + 'username' => $user->username, + 'email' => $user->email, + 'auth_type' => $user->auth_type, + 'locale' => $user->locale, + 'access' => [ + 'is_admin' => $user->isAdmin(), + 'mode' => $user->isAdmin() ? 'unrestricted' : 'explicit', + 'role_count' => $roles->count(), + 'external_role_count' => $externalRoles->count(), + 'assigned_permission_count' => $assignedPermissions->count(), + ], + 'roles' => $roles, + 'external_roles' => $externalRoles, + 'direct_permissions' => $directPermissions, + 'assigned_permissions' => $assignedPermissions, + ]) + ->header('Cache-Control', 'private, no-store') + ->header('Pragma', 'no-cache'); + } + + /** + * Serialize only the permission attributes that are useful to the user. + * + * Internal ownership, audit and timestamp fields are intentionally omitted. + * + * @param Collection $permissions + * @return Collection + */ + private function serializePermissions(Collection $permissions): Collection + { + return $permissions + ->map(fn (Permission $permission): array => [ + 'type' => $permission->type, + 'key' => $permission->key, + 'value' => $permission->value, + 'extra' => $permission->extra, + ]) + ->sortBy(fn (array $permission): string => $this->permissionKey($permission)) + ->values(); + } + + /** + * Build a stable key for sorting and de-duplicating permission records. + * + * @param array{type: ?string, key: ?string, value: ?string, extra: ?string} $permission + */ + private function permissionKey(array $permission): string + { + return serialize([ + $permission['type'], + $permission['key'], + $permission['value'], + $permission['extra'], + ]); + } + + /** + * Return Keycloak realm roles used by Permission::can for extra matching. + * + * These are kept separate from persisted Liman roles because they have + * different authorization semantics. + * + * @return Collection + */ + private function externalRoles(string $userId, ?string $authType): Collection + { + if ($authType !== 'keycloak') { + return collect(); + } + + $cachedRoles = Cache::get(sprintf('kc_roles:%s', $userId)); + $roles = is_string($cachedRoles) + ? json_decode($cachedRoles, true) + : $cachedRoles; + + if (! is_array($roles)) { + return collect(); + } + + return collect($roles) + ->filter(fn ($role): bool => is_string($role) && $role !== '') + ->unique() + ->sort() + ->values(); + } +} diff --git a/database/migrations/2026_07_30_000000_label_oidc_auto_role_assignments.php b/database/migrations/2026_07_30_000000_label_oidc_auto_role_assignments.php new file mode 100644 index 00000000..53bcb3c3 --- /dev/null +++ b/database/migrations/2026_07_30_000000_label_oidc_auto_role_assignments.php @@ -0,0 +1,26 @@ +where('auto', true) + ->where('type', 'local') + ->update(['type' => 'oidc']); + } + + public function down(): void + { + DB::table('role_users') + ->where('auto', true) + ->where('type', 'oidc') + ->update(['type' => 'local']); + } +}; diff --git a/routes/api.php b/routes/api.php index b8bb5ac7..ee31b5dc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,7 @@ use App\Http\Controllers\API\AuthController; use App\Http\Controllers\API\AuthHandoffController; +use App\Http\Controllers\API\CurrentUserDetailsController; use App\Http\Controllers\API\DashboardController; use App\Http\Controllers\API\ExtensionController; use App\Http\Controllers\API\ExternalNotificationController; @@ -33,6 +34,8 @@ ->middleware('throttle:5,1'); Route::post('/logout', [AuthController::class, 'logout']); Route::get('/user', [AuthController::class, 'userProfile']); + Route::get('/user/details', CurrentUserDetailsController::class) + ->middleware('auth:api'); Route::post('/change_password', [AuthController::class, 'forceChangePassword']) ->middleware('throttle:5,1'); Route::post('/forgot_password', [AuthController::class, 'sendPasswordResetLink']) From 986cf59853f1869de55154e2d2211a1cbd797cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Do=C4=9Fukan=20=C3=96ks=C3=BCz?= Date: Tue, 4 Aug 2026 09:20:13 +0300 Subject: [PATCH 3/3] chore: bump version --- storage/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/storage/VERSION b/storage/VERSION index 2bf1c1cc..f90b1afc 100644 --- a/storage/VERSION +++ b/storage/VERSION @@ -1 +1 @@ -2.3.1 +2.3.2