diff --git a/.env.example b/.env.example index 42bf5bc..fb54d82 100644 --- a/.env.example +++ b/.env.example @@ -76,6 +76,7 @@ BASTION_TOKEN_VERIFY_SSL=false OAUTH_ISSUER=${APP_URL} OAUTH_FRONTEND_LOGIN_URL="http://localhost:5173/#/login" OAUTH_FRONTEND_CONSENT_URL="http://localhost:5173/#/oauth-consent" +OAUTH_FRONTEND_AUTHORIZE_PROXY_BASE=/api OAUTH_KEY_ID= OAUTH_PRIVATE_KEY_PATH=storage/oauth/private.pem OAUTH_PUBLIC_KEY_PATH=storage/oauth/public.pem diff --git a/app/Http/Controllers/Api/OauthAuthorizationController.php b/app/Http/Controllers/Api/OauthAuthorizationController.php index c7d191b..a8a88c5 100644 --- a/app/Http/Controllers/Api/OauthAuthorizationController.php +++ b/app/Http/Controllers/Api/OauthAuthorizationController.php @@ -3,9 +3,9 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\AccessLog; use App\Models\OauthClient; use App\Models\OauthConsent; -use App\Models\OauthScope; use App\Models\User; use App\Services\OAuth\OAuthClientAuthService; use App\Services\OAuth\OAuthTokenService; @@ -31,7 +31,6 @@ class OauthAuthorizationController extends Controller 'response_type' => ['required', 'in:code'], 'client_id' => ['required', 'string', 'max:80'], 'redirect_uri' => ['required', 'url:http,https'], - 'scope' => ['nullable', 'string', 'max:1000'], 'state' => ['nullable', 'string', 'max:1024'], 'nonce' => ['nullable', 'string', 'max:255'], 'prompt' => ['nullable', 'string', 'max:100'], @@ -61,21 +60,14 @@ class OauthAuthorizationController extends Controller return $this->authorizationError($redirectUri, (string) $request->query('state', ''), 'invalid_request', 'Invalid redirect_uri.'); } - $allowedScopes = $this->clientAuthService->allowedScopeNames($client); - $requestedScopeRaw = (string) $request->query('scope', ''); - $requestedScopes = $requestedScopeRaw === '' - ? $allowedScopes - : $this->clientAuthService->parseScopes($requestedScopeRaw); - if (! $this->clientAuthService->isScopeSubset($requestedScopes, $allowedScopes)) { - return $this->authorizationError($redirectUri, (string) $request->query('state', ''), 'invalid_scope', 'Requested scope is not allowed.'); - } + $serverScopes = $this->clientAuthService->allowedScopeNames($client); $resourceOwner = $this->resolveResourceOwner($request); if (! $resourceOwner) { - return $this->redirectToFrontendLogin($request); + return $this->redirectToFrontendConsent($request, $client); } - $scopeFingerprint = $this->tokenService->scopeFingerprint($requestedScopes); + $scopeFingerprint = $this->tokenService->scopeFingerprint($serverScopes); $consent = OauthConsent::query() ->where('user_id', $resourceOwner->id) ->where('client_id', $client->id) @@ -85,17 +77,19 @@ class OauthAuthorizationController extends Controller $consentRequired = $consent === null || $prompt === 'consent'; if ($consentRequired) { - return $this->redirectToFrontendConsent($request, $client, $requestedScopes); + return $this->redirectToFrontendConsent($request, $client); } $code = $this->tokenService->issueAuthorizationCode( user: $resourceOwner, client: $client, - scopes: $requestedScopes, + scopes: $serverScopes, redirectUri: $redirectUri, nonce: $request->query('nonce') ? (string) $request->query('nonce') : null ); + $this->writeOAuthAuthorizationLog($resourceOwner, $request, $client, true); + return $this->successRedirect($redirectUri, $code, (string) $request->query('state', '')); } @@ -106,7 +100,6 @@ class OauthAuthorizationController extends Controller 'approve' => ['required', 'boolean'], 'client_id' => ['required', 'string', 'max:80'], 'redirect_uri' => ['required', 'url:http,https'], - 'scope' => ['nullable', 'string', 'max:1000'], 'state' => ['nullable', 'string', 'max:1024'], 'nonce' => ['nullable', 'string', 'max:255'], ]); @@ -142,18 +135,11 @@ class OauthAuthorizationController extends Controller ], 401); } - $allowedScopes = $this->clientAuthService->allowedScopeNames($client); - $requestedScopes = $request->filled('scope') - ? $this->clientAuthService->parseScopes((string) $request->input('scope')) - : $allowedScopes; - if (! $this->clientAuthService->isScopeSubset($requestedScopes, $allowedScopes)) { - return response()->json([ - 'error' => 'invalid_scope', - 'error_description' => 'Requested scope is not allowed.', - ], 400); - } + $serverScopes = $this->clientAuthService->allowedScopeNames($client); if (! $request->boolean('approve')) { + $this->writeOAuthAuthorizationLog($resourceOwner, $request, $client, false); + return $this->authorizationError( $redirectUri, (string) $request->input('state', ''), @@ -162,7 +148,7 @@ class OauthAuthorizationController extends Controller ); } - $scopeFingerprint = $this->tokenService->scopeFingerprint($requestedScopes); + $scopeFingerprint = $this->tokenService->scopeFingerprint($serverScopes); OauthConsent::query()->updateOrCreate( [ 'user_id' => $resourceOwner->id, @@ -170,7 +156,7 @@ class OauthAuthorizationController extends Controller 'scope_fingerprint' => $scopeFingerprint, ], [ - 'scopes' => $requestedScopes, + 'scopes' => $serverScopes, 'granted_at' => now(), ] ); @@ -178,52 +164,62 @@ class OauthAuthorizationController extends Controller $code = $this->tokenService->issueAuthorizationCode( user: $resourceOwner, client: $client, - scopes: $requestedScopes, + scopes: $serverScopes, redirectUri: $redirectUri, nonce: $request->input('nonce') ? (string) $request->input('nonce') : null ); + $this->writeOAuthAuthorizationLog($resourceOwner, $request, $client, false); + return $this->successRedirect($redirectUri, $code, (string) $request->input('state', '')); } - /** - * @param array $scopes - */ - private function redirectToFrontendConsent(Request $request, OauthClient $client, array $scopes): RedirectResponse + private function redirectToFrontendConsent(Request $request, OauthClient $client): RedirectResponse { $frontendConsentUrl = (string) config('oauth.frontend_consent_url'); + $userinfoFields = collect($client->allowed_userinfo_fields ?? config('oauth.userinfo_fields', [])) + ->map(fn ($field): string => trim((string) $field)) + ->filter() + ->unique() + ->values() + ->all(); + + $remapRules = collect($client->userinfo_claim_remap ?? []) + ->mapWithKeys(function ($target, $source): array { + $from = trim((string) $source); + $to = trim((string) $target); + if ($from === '' || $to === '') { + return []; + } + + return [$from => $to]; + }) + ->all(); + $query = [ - 'return_to' => $request->fullUrl(), + 'return_to' => $this->buildFrontendAuthorizeReturnTo($request), 'client_id' => $client->client_id, 'client_name' => $client->name, 'client_logo' => $client->logo_url, - 'scope' => $this->clientAuthService->buildScopeString($scopes), 'state' => (string) $request->query('state', ''), 'nonce' => (string) $request->query('nonce', ''), 'redirect_uri' => (string) $request->query('redirect_uri', ''), + 'userinfo_fields' => base64_encode((string) json_encode($userinfoFields, JSON_UNESCAPED_UNICODE)), + 'userinfo_remap' => base64_encode((string) json_encode($remapRules, JSON_UNESCAPED_UNICODE)), ]; - $scopesMap = OauthScope::query() - ->whereIn('name', $scopes) - ->get(['name', 'display_name', 'description']) - ->map(fn (OauthScope $scope): array => [ - 'name' => $scope->name, - 'display_name' => $scope->display_name, - 'description' => $scope->description, - ]) - ->values() - ->all(); - $query['scope_meta'] = base64_encode((string) json_encode($scopesMap, JSON_UNESCAPED_UNICODE)); - return redirect()->away($this->appendQueryToFrontendUrl($frontendConsentUrl, $query)); } - private function redirectToFrontendLogin(Request $request): RedirectResponse + private function buildFrontendAuthorizeReturnTo(Request $request): string { - $frontendLoginUrl = (string) config('oauth.frontend_login_url'); - $query = ['return_to' => $request->fullUrl()]; + $base = trim((string) config('oauth.frontend_authorize_proxy_base', '/api')); + $base = $base === '' ? '/api' : $base; + $base = rtrim($base, '/'); + $queryString = (string) ($request->getQueryString() ?? ''); + $target = $base.'/oauth/authorize'; - return redirect()->away($this->appendQueryToFrontendUrl($frontendLoginUrl, $query)); + return $queryString === '' ? $target : $target.'?'.$queryString; } private function successRedirect(string $redirectUri, string $code, string $state): RedirectResponse @@ -301,4 +297,26 @@ class OauthAuthorizationController extends Controller return $user instanceof User ? $user : null; } + + private function writeOAuthAuthorizationLog(User $user, Request $request, OauthClient $client, bool $autoApproved): void + { + AccessLog::query()->create([ + 'user_id' => $user->id, + 'server_resource_id' => null, + 'bastion_account_id' => null, + 'protocol' => 'oauth2', + 'action' => 'oauth_authorization', + 'requested_at' => now(), + 'metadata' => [ + 'path' => $request->path(), + 'method' => $request->method(), + 'client_ip' => $request->ip(), + 'oauth_client_id' => $client->id, + 'oauth_client_name' => $client->name, + 'oauth_client_identifier' => $client->client_id, + 'redirect_uri' => (string) $request->input('redirect_uri', $request->query('redirect_uri', '')), + 'auto_approved' => $autoApproved, + ], + ]); + } } diff --git a/app/Http/Controllers/Api/OauthUserInfoController.php b/app/Http/Controllers/Api/OauthUserInfoController.php index b9c0721..f184e1d 100644 --- a/app/Http/Controllers/Api/OauthUserInfoController.php +++ b/app/Http/Controllers/Api/OauthUserInfoController.php @@ -3,8 +3,6 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; -use App\Models\OauthScope; -use App\Services\OAuth\OAuthClientAuthService; use App\Services\OAuth\OAuthTokenService; use hg\apidoc\annotation as Apidoc; use Illuminate\Http\JsonResponse; @@ -15,8 +13,7 @@ use Illuminate\Support\Collection; class OauthUserInfoController extends Controller { public function __construct( - private readonly OAuthTokenService $tokenService, - private readonly OAuthClientAuthService $clientAuthService + private readonly OAuthTokenService $tokenService ) {} #[Apidoc\Title('UserInfo'), Apidoc\Method('GET'), Apidoc\Url('/oauth/userinfo')] @@ -32,36 +29,12 @@ class OauthUserInfoController extends Controller return $this->invalidTokenResponse('Invalid access token.'); } - $scopeNames = $this->clientAuthService->parseScopes((string) $accessToken->scope); - $scopes = OauthScope::query() - ->whereIn('name', $scopeNames) - ->where('is_active', true) - ->get(); - - $scopeFields = $scopes - ->flatMap(function (OauthScope $scope): array { - $claims = $scope->claims; - if (! is_array($claims)) { - return []; - } - - return array_values($claims); - }) - ->map(fn ($field): string => trim((string) $field)) - ->filter() - ->unique() - ->values(); - $clientFields = collect($accessToken->client->allowed_userinfo_fields ?? config('oauth.userinfo_fields', [])) ->map(fn ($field): string => trim((string) $field)) ->filter() ->unique() ->values(); - $finalFields = $scopeFields - ->intersect($clientFields) - ->values(); - $user = $accessToken->user; $claims = [ 'sub' => (string) $user->id, @@ -71,7 +44,7 @@ class OauthUserInfoController extends Controller ]; $payload = ['sub' => $claims['sub']]; - foreach ($finalFields as $fieldName) { + foreach ($clientFields as $fieldName) { $field = (string) $fieldName; if ($field === 'sub') { continue; diff --git a/config/oauth.php b/config/oauth.php index d4271d8..012f0a7 100644 --- a/config/oauth.php +++ b/config/oauth.php @@ -4,6 +4,7 @@ return [ 'issuer' => env('OAUTH_ISSUER', rtrim((string) env('APP_URL', 'http://localhost'), '/')), 'frontend_login_url' => env('OAUTH_FRONTEND_LOGIN_URL', rtrim((string) env('APP_URL', 'http://localhost'), '/').'/#/login'), 'frontend_consent_url' => env('OAUTH_FRONTEND_CONSENT_URL', rtrim((string) env('APP_URL', 'http://localhost'), '/').'/#/oauth-consent'), + 'frontend_authorize_proxy_base' => env('OAUTH_FRONTEND_AUTHORIZE_PROXY_BASE', '/api'), 'key_id' => env('OAUTH_KEY_ID', ''), 'private_key_path' => env('OAUTH_PRIVATE_KEY_PATH', storage_path('oauth/private.pem')), diff --git a/tests/Feature/OauthProtocolTest.php b/tests/Feature/OauthProtocolTest.php index 69c7f06..30cd4c8 100644 --- a/tests/Feature/OauthProtocolTest.php +++ b/tests/Feature/OauthProtocolTest.php @@ -111,7 +111,7 @@ class OauthProtocolTest extends TestCase ->assertJsonPath('keys.0.alg', 'RS256'); } - public function test_invalid_scope_is_returned_as_standard_oauth_redirect_error(): void + public function test_requested_scope_is_ignored_and_authorization_continues_with_server_config(): void { $resourceOwner = User::factory()->create(); $accessTokenForAuthorize = JWTAuth::fromUser($resourceOwner); @@ -137,9 +137,8 @@ class OauthProtocolTest extends TestCase $response->assertStatus(302); $location = (string) $response->headers->get('Location'); - $queryValues = $this->queryValuesFromUrl($location); - $this->assertSame('invalid_scope', (string) ($queryValues['error'] ?? '')); - $this->assertSame('scope_state', (string) ($queryValues['state'] ?? '')); + $this->assertStringContainsString('/#/oauth-consent', $location); + $this->assertStringContainsString('state=scope_state', $location); } public function test_refresh_rotation_and_replay_revokes_authorization_chain(): void