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:
= 2026-06-17 07:16:30 +00:00
parent 9c44d65f94
commit 02455fc71c
5 changed files with 383 additions and 1 deletions

View 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)
);
}
}
}

View 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;
}
}

View File

@ -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.

226
config/oidc-server.php Normal file
View 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'],
],
];

View 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');
});