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.
This commit is contained in:
parent
9c44d65f94
commit
02455fc71c
28
app/OAuth/CustomAuthCodeRepository.php
Normal file
28
app/OAuth/CustomAuthCodeRepository.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\OAuth;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Laravel\Passport\Bridge\AuthCodeRepository as PassportAuthCodeRepository;
|
||||||
|
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
|
||||||
|
|
||||||
|
class CustomAuthCodeRepository extends PassportAuthCodeRepository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Persist a new authorization code and cache its nonce if present.
|
||||||
|
*/
|
||||||
|
public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity): void
|
||||||
|
{
|
||||||
|
parent::persistNewAuthCode($authCodeEntity);
|
||||||
|
|
||||||
|
if (request()->has('nonce')) {
|
||||||
|
Cache::put(
|
||||||
|
'oidc_nonce_'.$authCodeEntity->getIdentifier(),
|
||||||
|
request()->input('nonce'),
|
||||||
|
now()->addMinutes(10)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/OAuth/CustomTokenResponseType.php
Normal file
49
app/OAuth/CustomTokenResponseType.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\OAuth;
|
||||||
|
|
||||||
|
use Admin9\OidcServer\Services\TokenResponseType as BaseTokenResponseType;
|
||||||
|
use Defuse\Crypto\Crypto;
|
||||||
|
use Illuminate\Support\Facades\{Cache, Log};
|
||||||
|
use Laravel\Passport\Passport;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class CustomTokenResponseType extends BaseTokenResponseType
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Resolve the nonce from the current request or cached authorization code.
|
||||||
|
*/
|
||||||
|
protected function resolveNonce(): ?string
|
||||||
|
{
|
||||||
|
// Check if the nonce is directly in the request (e.g. client sent it)
|
||||||
|
if ($nonce = request()->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,17 +4,31 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Admin9\OidcServer\Services\TokenResponseType;
|
||||||
|
use App\OAuth\{CustomAuthCodeRepository, CustomTokenResponseType};
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Support\Facades\{DB, Date};
|
use Illuminate\Support\Facades\{DB, Date};
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
use Laravel\Passport\Bridge\AuthCodeRepository;
|
||||||
|
|
||||||
final class AppServiceProvider extends ServiceProvider
|
final class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Register any application services.
|
* 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.
|
* Bootstrap any application services.
|
||||||
|
|||||||
226
config/oidc-server.php
Normal file
226
config/oidc-server.php
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| OIDC Issuer
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'issuer' => 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'],
|
||||||
|
],
|
||||||
|
];
|
||||||
65
tests/Feature/OidcNonceTest.php
Normal file
65
tests/Feature/OidcNonceTest.php
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\OAuth\{CustomAuthCodeRepository, CustomTokenResponseType};
|
||||||
|
use Defuse\Crypto\Crypto;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Laravel\Passport\Passport;
|
||||||
|
use League\OAuth2\Server\Entities\{AuthCodeEntityInterface, ClientEntityInterface};
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('caches the nonce from the request during authorization and resolves it during token request', function (): void {
|
||||||
|
// --- Phase 1: Authorization ---
|
||||||
|
// 1. Create a request to authorize endpoint with a nonce
|
||||||
|
$authorizeRequest = Request::create('/oauth/authorize', 'GET', ['nonce' => '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');
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user