From 02455fc71cc5273bd01248bf7f4dc70c41febe5d Mon Sep 17 00:00:00 2001 From: = Date: Wed, 17 Jun 2026 07:16:30 +0000 Subject: [PATCH] Feat: Implement OpenID Connect nonce handling - Added `CustomAuthCodeRepository` to cache and store nonces during authorization. - Introduced `CustomTokenResponseType` to resolve cached nonces during token exchange. - Created `oidc-server.php` configuration for OpenID Connect support, including scopes, claims, and token lifetimes. - Registered custom implementations in `AppServiceProvider` with bindings for `AuthCodeRepository` and `TokenResponseType`. - Added feature tests to validate nonce caching and resolution logic. --- app/OAuth/CustomAuthCodeRepository.php | 28 +++ app/OAuth/CustomTokenResponseType.php | 49 ++++++ app/Providers/AppServiceProvider.php | 16 +- config/oidc-server.php | 226 +++++++++++++++++++++++++ tests/Feature/OidcNonceTest.php | 65 +++++++ 5 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 app/OAuth/CustomAuthCodeRepository.php create mode 100644 app/OAuth/CustomTokenResponseType.php create mode 100644 config/oidc-server.php create mode 100644 tests/Feature/OidcNonceTest.php diff --git a/app/OAuth/CustomAuthCodeRepository.php b/app/OAuth/CustomAuthCodeRepository.php new file mode 100644 index 0000000..8c04c84 --- /dev/null +++ b/app/OAuth/CustomAuthCodeRepository.php @@ -0,0 +1,28 @@ +has('nonce')) { + Cache::put( + 'oidc_nonce_'.$authCodeEntity->getIdentifier(), + request()->input('nonce'), + now()->addMinutes(10) + ); + } + } +} diff --git a/app/OAuth/CustomTokenResponseType.php b/app/OAuth/CustomTokenResponseType.php new file mode 100644 index 0000000..7194bca --- /dev/null +++ b/app/OAuth/CustomTokenResponseType.php @@ -0,0 +1,49 @@ +input('nonce')) { + return $nonce; + } + + // Otherwise, extract the nonce associated with the authorization code + $encryptedAuthCode = request()->input('code'); + if (! $encryptedAuthCode) { + return null; + } + + try { + $key = Passport::tokenEncryptionKey(app('encrypter')); + $decrypted = Crypto::decryptWithPassword($encryptedAuthCode, $key); + $payload = json_decode($decrypted, true); + + if (isset($payload['auth_code_id'])) { + $cacheKey = 'oidc_nonce_'.$payload['auth_code_id']; + $nonce = Cache::get($cacheKey); + Cache::forget($cacheKey); + + return $nonce; + } + } catch (Throwable $e) { + Log::error($e->getMessage()); + } + + return null; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 03d09ba..ae2b9c3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,17 +4,31 @@ namespace App\Providers; +use Admin9\OidcServer\Services\TokenResponseType; +use App\OAuth\{CustomAuthCodeRepository, CustomTokenResponseType}; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\{DB, Date}; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Laravel\Passport\Bridge\AuthCodeRepository; final class AppServiceProvider extends ServiceProvider { /** * Register any application services. */ - public function register(): void {} + public function register(): void + { + $this->app->bind( + AuthCodeRepository::class, + CustomAuthCodeRepository::class + ); + + $this->app->singleton( + TokenResponseType::class, + CustomTokenResponseType::class + ); + } /** * Bootstrap any application services. diff --git a/config/oidc-server.php b/config/oidc-server.php new file mode 100644 index 0000000..686a919 --- /dev/null +++ b/config/oidc-server.php @@ -0,0 +1,226 @@ + env('OIDC_ISSUER', env('APP_URL')), + + /* + |-------------------------------------------------------------------------- + | User Model + |-------------------------------------------------------------------------- + | + | The Eloquent model class used to look up users when generating ID tokens. + | Falls back to the default auth provider model if not set. + | + */ + 'user_model' => App\Models\User::class, + + /* + |-------------------------------------------------------------------------- + | Passport Auto-Configuration + |-------------------------------------------------------------------------- + | + | When true, the package will automatically configure Passport scopes, + | token TTLs, response type, client model, and authorization view. + | Set to false if you want to configure Passport yourself. + | + */ + 'configure_passport' => true, + + /* + |-------------------------------------------------------------------------- + | Ignore Passport Routes + |-------------------------------------------------------------------------- + | + | When true, the package will call Passport::ignoreRoutes() to prevent + | Passport from registering its own routes. Set to false if you need + | Passport's default routes alongside the OIDC routes. + | + */ + 'ignore_passport_routes' => true, + + /* + |-------------------------------------------------------------------------- + | Authorization View + |-------------------------------------------------------------------------- + | + | The Blade view used for the OAuth authorization prompt. + | Publish and customize, or point to your own view. + | + */ + 'authorization_view' => 'oidc-server::authorize', + + /* + |-------------------------------------------------------------------------- + | Client Model + |-------------------------------------------------------------------------- + | + | The Passport Client model class. The default OidcClient skips the + | authorization prompt for first-party clients. + | + */ + 'client_model' => Admin9\OidcServer\Models\OidcClient::class, + + /* + |-------------------------------------------------------------------------- + | Supported Scopes + |-------------------------------------------------------------------------- + */ + 'scopes' => [ + 'openid' => [ + 'description' => 'OpenID Connect authentication', + 'claims' => ['sub'], + ], + 'profile' => [ + 'description' => 'Access user profile information', + 'claims' => ['name', 'nickname', 'picture', 'updated_at'], + ], + 'email' => [ + 'description' => 'Access user email address', + 'claims' => ['email', 'email_verified'], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Default Scopes + |-------------------------------------------------------------------------- + */ + 'default_scopes' => ['openid'], + + /* + |-------------------------------------------------------------------------- + | Claims Resolver + |-------------------------------------------------------------------------- + | + | Map claim names to model attributes or callables. + | Example: 'nickname' => 'public_name' + | Example: 'picture' => fn($user) => $user->avatar_url + | + */ + 'claims_resolver' => [], + + /* + |-------------------------------------------------------------------------- + | Default Claims Map + |-------------------------------------------------------------------------- + | + | Map claim names to model attributes or callables for the default + | resolution in HasOidcClaims. These are used when no claims_resolver + | entry exists for a claim. Override to match your User model's schema. + | + | String values are treated as model attribute names (e.g., 'name' => 'name'). + | Callables receive the user model as the first argument: + | 'email_verified' => fn($user) => $user->email_verified_at !== null + | + */ + 'default_claims_map' => [ + 'name' => 'name', + 'email' => 'email', + 'email_verified' => fn ($user) => null !== $user->email_verified_at, + 'updated_at' => fn ($user) => $user->updated_at?->timestamp, + ], + + /* + |-------------------------------------------------------------------------- + | Token Configuration + |-------------------------------------------------------------------------- + */ + 'tokens' => [ + 'access_token_ttl' => (int) env('OIDC_ACCESS_TOKEN_TTL', 900), + 'refresh_token_ttl' => (int) env('OIDC_REFRESH_TOKEN_TTL', 604800), + 'id_token_ttl' => (int) env('OIDC_ID_TOKEN_TTL', 900), + ], + + /* + |-------------------------------------------------------------------------- + | Supported Response Types + |-------------------------------------------------------------------------- + */ + 'response_types_supported' => [ + 'code', + 'token', + ], + + /* + |-------------------------------------------------------------------------- + | Supported Grant Types + |-------------------------------------------------------------------------- + */ + 'grant_types_supported' => [ + 'authorization_code', + 'refresh_token', + 'client_credentials', + 'urn:ietf:params:oauth:grant-type:device_code', + ], + + /* + |-------------------------------------------------------------------------- + | Token Endpoint Auth Methods + |-------------------------------------------------------------------------- + */ + 'token_endpoint_auth_methods_supported' => [ + 'client_secret_basic', + 'client_secret_post', + ], + + /* + |-------------------------------------------------------------------------- + | ID Token Signing Algorithms + |-------------------------------------------------------------------------- + */ + 'id_token_signing_alg_values_supported' => [ + 'RS256', + ], + + /* + |-------------------------------------------------------------------------- + | Subject Types + |-------------------------------------------------------------------------- + */ + 'subject_types_supported' => [ + 'public', + ], + + /* + |-------------------------------------------------------------------------- + | PKCE Code Challenge Methods + |-------------------------------------------------------------------------- + */ + 'code_challenge_methods_supported' => [ + 'S256', + 'plain', + ], + + /* + |-------------------------------------------------------------------------- + | Post Logout Redirect URIs + |-------------------------------------------------------------------------- + */ + 'post_logout_redirect_uris_supported' => [], + + /* + |-------------------------------------------------------------------------- + | Routes Configuration + |-------------------------------------------------------------------------- + | + | Control route registration and per-group middleware. + | - discovery_middleware: Applied to /.well-known/* endpoints + | - token_middleware: Applied to /oauth/token, introspect, revoke, logout + | - userinfo_middleware: Applied to /oauth/userinfo (default: auth:api, + | which requires a valid Passport access token) + | + */ + 'routes' => [ + 'enabled' => true, + 'discovery_middleware' => [], + 'token_middleware' => [], + 'userinfo_middleware' => ['auth:api'], + ], +]; diff --git a/tests/Feature/OidcNonceTest.php b/tests/Feature/OidcNonceTest.php new file mode 100644 index 0000000..d6ee781 --- /dev/null +++ b/tests/Feature/OidcNonceTest.php @@ -0,0 +1,65 @@ + 'secure-nonce-value-xyz']); + app()->instance('request', $authorizeRequest); + + // 2. Create the mock AuthCodeEntity + $client = Mockery::mock(ClientEntityInterface::class); + $client->shouldReceive('getIdentifier')->andReturn('client-123'); + + $authCodeEntity = Mockery::mock(AuthCodeEntityInterface::class); + $authCodeEntity->shouldReceive('getIdentifier')->andReturn('code-id-abc'); + $authCodeEntity->shouldReceive('getUserIdentifier')->andReturn('user-1'); + $authCodeEntity->shouldReceive('getClient')->andReturn($client); + $authCodeEntity->shouldReceive('getScopes')->andReturn([]); + $authCodeEntity->shouldReceive('getExpiryDateTime')->andReturn(new DateTimeImmutable('+10 minutes')); + + // 4. Run the repository persist method + $repository = new CustomAuthCodeRepository(); + $repository->persistNewAuthCode($authCodeEntity); + + // 5. Assert the nonce is cached + expect(Cache::get('oidc_nonce_code-id-abc'))->toBe('secure-nonce-value-xyz'); + + // --- Phase 2: Token Exchange --- + // 1. Prepare mock encrypted auth code payload + $key = Passport::tokenEncryptionKey(app('encrypter')); + $payload = json_encode([ + 'client_id' => 'client-123', + 'auth_code_id' => 'code-id-abc', + 'user_id' => 'user-1', + 'scopes' => [], + 'expire_time' => time() + 600, + ]); + $encryptedAuthCode = Crypto::encryptWithPassword($payload, $key); + + // 2. Set up the request to token endpoint containing the encrypted code + $tokenRequest = Request::create('/oauth/token', 'POST', ['code' => $encryptedAuthCode]); + app()->instance('request', $tokenRequest); + + // 3. Resolve CustomTokenResponseType and call the protected resolveNonce method using reflection + $responseType = app(CustomTokenResponseType::class); + $reflection = new ReflectionClass(CustomTokenResponseType::class); + $method = $reflection->getMethod('resolveNonce'); + $method->setAccessible(true); + + $resolvedNonce = $method->invoke($responseType); + + // 4. Assert the resolved nonce is correct + expect($resolvedNonce)->toBe('secure-nonce-value-xyz'); +});