generated from thegrind/laravel-dockerized
Invites and some tests
This commit is contained in:
parent
1fd6f03a81
commit
93c6baa16b
81
CLAUDE.md
Normal file
81
CLAUDE.md
Normal file
@ -0,0 +1,81 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
AuthentiKate is a lightweight SSO/OIDC solution built with Laravel and Livewire, designed as a simpler alternative to Authentik for homelabbers. It provides OpenID Connect authentication services with JWT token generation and user management.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Laravel/PHP Commands
|
||||
- `composer run dev` - Start development environment (combines server, queue, logs, and vite)
|
||||
- `composer run test` - Run test suite with config clearing
|
||||
- `php artisan serve` - Start Laravel development server
|
||||
- `php artisan queue:listen --tries=1` - Start queue worker
|
||||
- `php artisan pail --timeout=0` - Start log monitoring
|
||||
- `php artisan migrate` - Run database migrations
|
||||
- `php artisan key:generate` - Generate application key
|
||||
|
||||
### Frontend Commands
|
||||
- `npm run dev` - Start Vite development server
|
||||
- `npm run build` - Build assets for production
|
||||
|
||||
### Docker Commands
|
||||
- `make build` - Build Docker image (runs npm build first)
|
||||
- `make run` - Run container on port 8889
|
||||
- `make rebuild` - Force rebuild without cache
|
||||
- `make setup` - Install Laravel Octane with FrankenPHP
|
||||
|
||||
### Testing
|
||||
- Uses Pest PHP testing framework
|
||||
- Test files located in `tests/Feature/` and `tests/Unit/`
|
||||
- Run with `composer run test` or `php artisan test`
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
**OIDC Implementation** (`app/Http/Controllers/OIDCController.php`):
|
||||
- Authorization endpoint with PKCE support
|
||||
- JWT token generation using RSA256 signing
|
||||
- User info endpoint for profile data
|
||||
- JWKS and OpenID configuration endpoints
|
||||
- Uses Laravel Cache for authorization codes
|
||||
|
||||
**User Management**:
|
||||
- `User` model with avatar support and authentication tokens
|
||||
- `Application` model for OAuth clients
|
||||
- `AuthenticationToken` model for access token tracking
|
||||
- `Invitation` system for user onboarding
|
||||
|
||||
**Frontend**:
|
||||
- Livewire components for reactive UI
|
||||
- Flux UI components for consistent design
|
||||
- Tailwind CSS for styling
|
||||
- Vite for asset building
|
||||
|
||||
### Key Files
|
||||
- `routes/web.php` - Main application routes including OIDC endpoints
|
||||
- `app/Livewire/ConsentScreen.php` - OAuth consent flow
|
||||
- `database/migrations/` - Database schema definitions
|
||||
- `storage/oauth/` - RSA key pair for JWT signing
|
||||
|
||||
### Security Features
|
||||
- PKCE (Proof Key for Code Exchange) support
|
||||
- JWT token validation with RSA signatures
|
||||
- Client secret verification
|
||||
- Redirect URI validation
|
||||
- CSRF protection (disabled for token endpoint)
|
||||
|
||||
## Database
|
||||
- Uses SQLite by default
|
||||
- Migrations handle users, applications, authentication tokens, and invitations
|
||||
- Seeders available for development data
|
||||
|
||||
## Configuration
|
||||
- Standard Laravel `.env` configuration
|
||||
- OAuth keys stored in `storage/oauth/`
|
||||
- Uses Laravel's built-in authentication system
|
||||
- Email verification and password reset supported
|
||||
- Uses the free version of FluxUI. A livewire component library.
|
@ -167,8 +167,13 @@ class OIDCController extends Controller
|
||||
// if (!$token || $token->expires_at->isPast()) {
|
||||
// return response()->json(['error' => 'invalid_token'], 401);
|
||||
// }
|
||||
|
||||
if (empty($token)) {
|
||||
return response()->json(['error' => 'invalid_token'], 401);
|
||||
}
|
||||
|
||||
$user = $token->user;
|
||||
if (empty($token) || empty($user)) {
|
||||
if (empty($user)) {
|
||||
return response()->json(['error' => 'invalid_token'], 401);
|
||||
}
|
||||
|
||||
@ -208,7 +213,7 @@ class OIDCController extends Controller
|
||||
'issuer' => config('app.url'),
|
||||
'authorization_endpoint' => route('auth.authorize'),
|
||||
'token_endpoint' => route('auth.token'),
|
||||
'useringo_endpoint' => route('auth.userinfo'),
|
||||
'userinfo_endpoint' => route('auth.userinfo'),
|
||||
'scopes_supported' => ["openid", "profile", "email"],
|
||||
'response_types_supported' => ["code"],
|
||||
"jwks_uri" => route('auth.keys'),
|
||||
|
@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use App\Models\Invitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('components.layouts.auth')]
|
||||
@ -21,11 +23,27 @@ class Register extends Component
|
||||
|
||||
public string $password_confirmation = '';
|
||||
|
||||
#[Url]
|
||||
public string $code = '';
|
||||
|
||||
public ?Invitation $invitation = null;
|
||||
|
||||
public bool $invitationAccepted = false;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
dd($this->code);
|
||||
if ($this->code) {
|
||||
$this->invitation = Invitation::where('code', $this->code)->first();
|
||||
|
||||
if ($this->invitation) {
|
||||
if (!$this->invitation->isPending()) {
|
||||
$this->invitationAccepted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->email = $this->invitation->email;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -33,16 +51,36 @@ class Register extends Component
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$validated = $this->validate([
|
||||
if ($this->invitation && !$this->invitation->isPending()) {
|
||||
$this->addError('general', 'This invitation has already been accepted.');
|
||||
return;
|
||||
}
|
||||
|
||||
$validationRules = [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:' . User::class],
|
||||
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
];
|
||||
|
||||
if ($this->invitation) {
|
||||
$validationRules['email'] = ['required', 'string', 'lowercase', 'email', 'max:255', 'in:' . $this->invitation->email];
|
||||
} else {
|
||||
$validationRules['email'] = ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:' . User::class];
|
||||
}
|
||||
|
||||
$validated = $this->validate($validationRules);
|
||||
|
||||
$validated['password'] = Hash::make($validated['password']);
|
||||
|
||||
if ($this->invitation) {
|
||||
$validated['email'] = $this->invitation->email;
|
||||
}
|
||||
|
||||
event(new Registered(($user = User::create($validated))));
|
||||
|
||||
if ($this->invitation) {
|
||||
$this->invitation->accept();
|
||||
}
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
$this->redirect(route('dashboard', absolute: false), navigate: true);
|
||||
|
@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AuthenticationToken extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
|
@ -21,4 +21,10 @@ class Invitation extends Model
|
||||
{
|
||||
return empty($this->accepted_at);
|
||||
}
|
||||
|
||||
public function accept(): void
|
||||
{
|
||||
$this->accepted_at = now();
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
33
database/factories/AuthenticationTokenFactory.php
Normal file
33
database/factories/AuthenticationTokenFactory.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\AuthenticationToken;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\AuthenticationToken>
|
||||
*/
|
||||
class AuthenticationTokenFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'application_id' => Application::factory(),
|
||||
'token' => Str::random(64),
|
||||
'issued_at' => now(),
|
||||
'expires_at' => now()->addMonth(),
|
||||
'ip' => fake()->ipv4(),
|
||||
'user_agent' => fake()->userAgent(),
|
||||
];
|
||||
}
|
||||
}
|
@ -1,10 +1,22 @@
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-auth-header :title="__('Create an account')" :description="__('Enter your details below to create your account')" />
|
||||
@if($invitationAccepted)
|
||||
<x-auth-header :title="__('Invitation Already Used')" :description="__('This invitation has already been accepted')" />
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-4">{{ __('This invitation code has already been used to create an account.') }}</p>
|
||||
<flux:link :href="route('login')" wire:navigate class="text-sm">{{ __('Return to login') }}</flux:link>
|
||||
</div>
|
||||
@else
|
||||
<x-auth-header :title="__('Create an account')" :description="__('Enter your details below to create your account')" />
|
||||
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
||||
|
||||
<form wire:submit="register" class="flex flex-col gap-6">
|
||||
@error('general')
|
||||
<div class="text-red-600 text-sm text-center">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
<form wire:submit="register" class="flex flex-col gap-6">
|
||||
<!-- Name -->
|
||||
<flux:input
|
||||
wire:model="name"
|
||||
@ -24,6 +36,7 @@
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="email@example.com"
|
||||
:disabled="$invitation !== null"
|
||||
/>
|
||||
|
||||
<!-- Password -->
|
||||
@ -55,8 +68,9 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="space-x-1 rtl:space-x-reverse text-center text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<span>{{ __('Already have an account?') }}</span>
|
||||
<flux:link :href="route('login')" wire:navigate>{{ __('Log in') }}</flux:link>
|
||||
</div>
|
||||
<div class="space-x-1 rtl:space-x-reverse text-center text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<span>{{ __('Already have an account?') }}</span>
|
||||
<flux:link :href="route('login')" wire:navigate>{{ __('Log in') }}</flux:link>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
@ -1,7 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('example', function () {
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
@ -4,357 +4,496 @@ use App\Models\Application;
|
||||
use App\Models\AuthenticationToken;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Lcobucci\JWT\Encoding\JoseEncoder;
|
||||
use Lcobucci\JWT\Token\Parser;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'preferred_username' => 'testuser',
|
||||
]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'name' => 'Test App',
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'redirect_uri' => 'https://example.com/callback',
|
||||
]);
|
||||
|
||||
// Create RSA key pair for testing
|
||||
$this->createTestKeys();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
// Clean up test keys
|
||||
$this->cleanupTestKeys();
|
||||
});
|
||||
|
||||
// Helper function to create test RSA keys
|
||||
function createTestKeys()
|
||||
{
|
||||
$keyDir = storage_path('oauth');
|
||||
if (!file_exists($keyDir)) {
|
||||
mkdir($keyDir, 0755, true);
|
||||
}
|
||||
|
||||
$config = [
|
||||
// Create OAuth keys for testing
|
||||
Storage::disk('local')->makeDirectory('oauth');
|
||||
$keyPair = openssl_pkey_new([
|
||||
'digest_alg' => 'sha256',
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
];
|
||||
|
||||
$privateKey = openssl_pkey_new($config);
|
||||
openssl_pkey_export($privateKey, $privateKeyPem);
|
||||
file_put_contents(storage_path('oauth/private.pem'), $privateKeyPem);
|
||||
|
||||
$publicKey = openssl_pkey_get_details($privateKey);
|
||||
file_put_contents(storage_path('oauth/public.pem'), $publicKey['key']);
|
||||
}
|
||||
|
||||
// Helper function to clean up test keys
|
||||
function cleanupTestKeys()
|
||||
{
|
||||
$privateKeyPath = storage_path('oauth/private.pem');
|
||||
$publicKeyPath = storage_path('oauth/public.pem');
|
||||
|
||||
if (file_exists($privateKeyPath)) {
|
||||
unlink($privateKeyPath);
|
||||
}
|
||||
if (file_exists($publicKeyPath)) {
|
||||
unlink($publicKeyPath);
|
||||
}
|
||||
}
|
||||
]);
|
||||
openssl_pkey_export($keyPair, $privateKey);
|
||||
$publicKey = openssl_pkey_get_details($keyPair)['key'];
|
||||
|
||||
Storage::disk('local')->put('oauth/private.pem', $privateKey);
|
||||
Storage::disk('local')->put('oauth/public.pem', $publicKey);
|
||||
});
|
||||
|
||||
describe('OIDC Authorization Endpoint', function () {
|
||||
it('redirects to consent screen with valid parameters', function () {
|
||||
$this->actingAs($this->user);
|
||||
test('requires authentication', function () {
|
||||
$this->get(route('auth.authorize'))
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
$response = $this->get('/auth/authorize?' . http_build_query([
|
||||
'client_id' => $this->application->client_id,
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'response_type' => 'code',
|
||||
'scope' => 'openid email profile',
|
||||
'state' => 'test-state',
|
||||
]));
|
||||
test('validates client_id parameter', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('auth.authorize') . '?response_type=code&redirect_uri=http://example.com')
|
||||
->assertStatus(404);
|
||||
});
|
||||
|
||||
test('validates redirect_uri matches client configuration', function () {
|
||||
$user = User::factory()->create();
|
||||
$app = Application::factory()->create([
|
||||
'redirect_uri' => 'http://example.com/callback'
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('auth.authorize') . '?' . http_build_query([
|
||||
'client_id' => $app->client_id,
|
||||
'response_type' => 'code',
|
||||
'redirect_uri' => 'http://malicious.com/callback',
|
||||
]))
|
||||
->assertStatus(403);
|
||||
});
|
||||
|
||||
test('successful authorization flow with required parameters', function () {
|
||||
$user = User::factory()->create();
|
||||
$app = Application::factory()->create([
|
||||
'redirect_uri' => 'http://example.com/callback'
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('auth.authorize') . '?' . http_build_query([
|
||||
'client_id' => $app->client_id,
|
||||
'response_type' => 'code',
|
||||
'redirect_uri' => $app->redirect_uri,
|
||||
'state' => 'test-state-123',
|
||||
'scope' => 'openid profile email',
|
||||
]));
|
||||
|
||||
$response->assertRedirect(route('auth.confirm'));
|
||||
|
||||
// Verify session data is set
|
||||
$this->assertEquals($this->application->id, session('app_id'));
|
||||
expect(session('redirect_on_confirm'))->toContain($this->application->redirect_uri);
|
||||
expect(session('redirect_on_confirm'))->toContain('code=');
|
||||
expect(session('redirect_on_confirm'))->toContain('state=test-state');
|
||||
expect(session('app_id'))->toBe($app->id);
|
||||
expect(session('redirect_on_confirm'))->toContain('http://example.com/callback');
|
||||
expect(session('redirect_on_confirm'))->toContain('state=test-state-123');
|
||||
});
|
||||
|
||||
it('fails with invalid client_id', function () {
|
||||
$this->actingAs($this->user);
|
||||
test('supports PKCE flow with S256 code challenge', function () {
|
||||
$user = User::factory()->create();
|
||||
$app = Application::factory()->create([
|
||||
'redirect_uri' => 'http://example.com/callback'
|
||||
]);
|
||||
|
||||
$response = $this->get('/auth/authorize?' . http_build_query([
|
||||
'client_id' => 'invalid-client-id',
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'response_type' => 'code',
|
||||
]));
|
||||
$codeVerifier = base64url_encode(random_bytes(32));
|
||||
$codeChallenge = base64url_encode(hash('sha256', $codeVerifier, true));
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
it('fails with redirect_uri mismatch', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$response = $this->get('/auth/authorize?' . http_build_query([
|
||||
'client_id' => $this->application->client_id,
|
||||
'redirect_uri' => 'https://evil.com/callback',
|
||||
'response_type' => 'code',
|
||||
]));
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
it('requires authentication', function () {
|
||||
$response = $this->get('/auth/authorize?' . http_build_query([
|
||||
'client_id' => $this->application->client_id,
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'response_type' => 'code',
|
||||
]));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
it('supports PKCE parameters', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$codeVerifier = Str::random(128);
|
||||
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$response = $this->get('/auth/authorize?' . http_build_query([
|
||||
'client_id' => $this->application->client_id,
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'response_type' => 'code',
|
||||
'code_challenge' => $codeChallenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
]));
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('auth.authorize') . '?' . http_build_query([
|
||||
'client_id' => $app->client_id,
|
||||
'response_type' => 'code',
|
||||
'redirect_uri' => $app->redirect_uri,
|
||||
'code_challenge' => $codeChallenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
'state' => 'test-state',
|
||||
]));
|
||||
|
||||
$response->assertRedirect(route('auth.confirm'));
|
||||
|
||||
// Verify PKCE parameters are cached
|
||||
$redirectUrl = session('redirect_on_confirm');
|
||||
preg_match('/code=([^&]+)/', $redirectUrl, $matches);
|
||||
$authCode = $matches[1];
|
||||
|
||||
$cachedData = Cache::get("auth_code:$authCode");
|
||||
expect($cachedData)->not->toBeNull();
|
||||
expect($cachedData['code_challenge'])->toBe($codeChallenge);
|
||||
expect($cachedData['code_challenge_method'])->toBe('S256');
|
||||
});
|
||||
|
||||
test('supports PKCE flow with plain code challenge', function () {
|
||||
$user = User::factory()->create();
|
||||
$app = Application::factory()->create([
|
||||
'redirect_uri' => 'http://example.com/callback'
|
||||
]);
|
||||
|
||||
$codeVerifier = 'test-code-verifier';
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('auth.authorize') . '?' . http_build_query([
|
||||
'client_id' => $app->client_id,
|
||||
'response_type' => 'code',
|
||||
'redirect_uri' => $app->redirect_uri,
|
||||
'code_challenge' => $codeVerifier,
|
||||
'code_challenge_method' => 'plain',
|
||||
]));
|
||||
|
||||
$response->assertRedirect(route('auth.confirm'));
|
||||
|
||||
$redirectUrl = session('redirect_on_confirm');
|
||||
preg_match('/code=([^&]+)/', $redirectUrl, $matches);
|
||||
$authCode = $matches[1];
|
||||
|
||||
$cachedData = Cache::get("auth_code:$authCode");
|
||||
expect($cachedData['code_challenge'])->toBe($codeVerifier);
|
||||
expect($cachedData['code_challenge_method'])->toBe('plain');
|
||||
});
|
||||
|
||||
test('preserves nonce parameter for ID token', function () {
|
||||
$user = User::factory()->create();
|
||||
$app = Application::factory()->create([
|
||||
'redirect_uri' => 'http://example.com/callback'
|
||||
]);
|
||||
|
||||
$nonce = 'test-nonce-123';
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('auth.authorize') . '?' . http_build_query([
|
||||
'client_id' => $app->client_id,
|
||||
'response_type' => 'code',
|
||||
'redirect_uri' => $app->redirect_uri,
|
||||
'nonce' => $nonce,
|
||||
]));
|
||||
|
||||
$redirectUrl = session('redirect_on_confirm');
|
||||
preg_match('/code=([^&]+)/', $redirectUrl, $matches);
|
||||
$authCode = $matches[1];
|
||||
|
||||
$cachedData = Cache::get("auth_code:$authCode");
|
||||
expect($cachedData['nonce'])->toBe($nonce);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC Token Endpoint', function () {
|
||||
beforeEach(function () {
|
||||
// Set up authorization code in cache
|
||||
$this->authCode = Str::random(40);
|
||||
$this->payload = [
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $this->application->id,
|
||||
'scope' => 'openid email profile',
|
||||
'nonce' => 'test-nonce',
|
||||
];
|
||||
Cache::put("auth_code:{$this->authCode}", $this->payload, now()->addMinutes(5));
|
||||
test('rejects invalid authorization codes', function () {
|
||||
$app = Application::factory()->create();
|
||||
|
||||
$this->post(route('auth.token'), [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => 'invalid-code',
|
||||
'redirect_uri' => $app->redirect_uri,
|
||||
'client_id' => $app->client_id,
|
||||
'client_secret' => $app->client_secret,
|
||||
])->assertStatus(403);
|
||||
});
|
||||
|
||||
it('exchanges authorization code for tokens with client credentials', function () {
|
||||
$response = $this->post('/auth/token', [
|
||||
test('rejects expired authorization codes', function () {
|
||||
$user = User::factory()->create();
|
||||
$app = Application::factory()->create();
|
||||
|
||||
// Create expired code
|
||||
$code = 'test-code';
|
||||
Cache::put("auth_code:$code", [
|
||||
'user_id' => $user->id,
|
||||
'client_id' => $app->id,
|
||||
], now()->subMinutes(10)); // Expired
|
||||
|
||||
$this->post(route('auth.token'), [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $this->authCode,
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'client_id' => $this->application->client_id,
|
||||
'client_secret' => $this->application->client_secret,
|
||||
'code' => $code,
|
||||
'redirect_uri' => $app->redirect_uri,
|
||||
'client_id' => $app->client_id,
|
||||
'client_secret' => $app->client_secret,
|
||||
])->assertStatus(403);
|
||||
});
|
||||
|
||||
test('validates client credentials', function () {
|
||||
$user = User::factory()->create();
|
||||
$app = Application::factory()->create();
|
||||
|
||||
$code = 'test-code';
|
||||
Cache::put("auth_code:$code", [
|
||||
'user_id' => $user->id,
|
||||
'client_id' => $app->id,
|
||||
], now()->addMinutes(5));
|
||||
|
||||
$this->post(route('auth.token'), [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $app->redirect_uri,
|
||||
'client_id' => $app->client_id,
|
||||
'client_secret' => 'wrong-secret',
|
||||
])->assertStatus(403);
|
||||
});
|
||||
|
||||
test('validates redirect_uri matches original request', function () {
|
||||
$user = User::factory()->create();
|
||||
$app = Application::factory()->create([
|
||||
'redirect_uri' => 'http://example.com/callback'
|
||||
]);
|
||||
|
||||
$code = 'test-code';
|
||||
Cache::put("auth_code:$code", [
|
||||
'user_id' => $user->id,
|
||||
'client_id' => $app->id,
|
||||
], now()->addMinutes(5));
|
||||
|
||||
$this->post(route('auth.token'), [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => 'http://malicious.com/callback',
|
||||
'client_id' => $app->client_id,
|
||||
'client_secret' => $app->client_secret,
|
||||
])->assertStatus(403);
|
||||
});
|
||||
|
||||
test('successful token exchange with client credentials', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'test@example.com',
|
||||
'name' => 'Test User',
|
||||
'preferred_username' => 'testuser',
|
||||
]);
|
||||
$app = Application::factory()->create();
|
||||
|
||||
$code = 'test-code';
|
||||
Cache::put("auth_code:$code", [
|
||||
'user_id' => $user->id,
|
||||
'client_id' => $app->id,
|
||||
'scope' => 'openid profile email',
|
||||
], now()->addMinutes(5));
|
||||
|
||||
$response = $this->post(route('auth.token'), [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $app->redirect_uri,
|
||||
'client_id' => $app->client_id,
|
||||
'client_secret' => $app->client_secret,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json();
|
||||
|
||||
|
||||
expect($data)->toHaveKeys(['access_token', 'token_type', 'expires_in', 'id_token']);
|
||||
expect($data['token_type'])->toBe('Bearer');
|
||||
expect($data['expires_in'])->toBe(3600);
|
||||
|
||||
// Verify authentication token was created
|
||||
|
||||
// Verify access token is stored
|
||||
$this->assertDatabaseHas('authentication_tokens', [
|
||||
'user_id' => $this->user->id,
|
||||
'application_id' => $this->application->id,
|
||||
'token' => $data['access_token'],
|
||||
'user_id' => $user->id,
|
||||
'application_id' => $app->id,
|
||||
]);
|
||||
|
||||
// Verify auth code was consumed
|
||||
expect(Cache::get("auth_code:{$this->authCode}"))->toBeNull();
|
||||
// Verify ID token is valid JWT
|
||||
$parser = new Parser(new JoseEncoder());
|
||||
$idToken = $parser->parse($data['id_token']);
|
||||
expect($idToken->claims()->get('sub'))->toBe((string) $user->id);
|
||||
expect($idToken->claims()->get('email'))->toBe($user->email);
|
||||
});
|
||||
|
||||
it('exchanges authorization code for tokens with PKCE', function () {
|
||||
$codeVerifier = Str::random(128);
|
||||
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
|
||||
test('successful PKCE token exchange with S256', function () {
|
||||
$user = User::factory()->create();
|
||||
$app = Application::factory()->create();
|
||||
|
||||
// Update cache with PKCE parameters
|
||||
$this->payload['code_challenge'] = $codeChallenge;
|
||||
$this->payload['code_challenge_method'] = 'S256';
|
||||
Cache::put("auth_code:{$this->authCode}", $this->payload, now()->addMinutes(5));
|
||||
$codeVerifier = base64url_encode(random_bytes(32));
|
||||
$codeChallenge = base64url_encode(hash('sha256', $codeVerifier, true));
|
||||
|
||||
$response = $this->post('/auth/token', [
|
||||
$code = 'test-code';
|
||||
Cache::put("auth_code:$code", [
|
||||
'user_id' => $user->id,
|
||||
'client_id' => $app->id,
|
||||
'code_challenge' => $codeChallenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
], now()->addMinutes(5));
|
||||
|
||||
$response = $this->post(route('auth.token'), [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $this->authCode,
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'code' => $code,
|
||||
'redirect_uri' => $app->redirect_uri,
|
||||
'code_verifier' => $codeVerifier,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json();
|
||||
|
||||
expect($data)->toHaveKeys(['access_token', 'token_type', 'expires_in', 'id_token']);
|
||||
});
|
||||
|
||||
it('fails with invalid authorization code', function () {
|
||||
$response = $this->post('/auth/token', [
|
||||
test('successful PKCE token exchange with plain method', function () {
|
||||
$user = User::factory()->create();
|
||||
$app = Application::factory()->create();
|
||||
|
||||
$codeVerifier = 'test-code-verifier';
|
||||
|
||||
$code = 'test-code';
|
||||
Cache::put("auth_code:$code", [
|
||||
'user_id' => $user->id,
|
||||
'client_id' => $app->id,
|
||||
'code_challenge' => $codeVerifier,
|
||||
'code_challenge_method' => 'plain',
|
||||
], now()->addMinutes(5));
|
||||
|
||||
$response = $this->post(route('auth.token'), [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => 'invalid-code',
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'client_id' => $this->application->client_id,
|
||||
'client_secret' => $this->application->client_secret,
|
||||
'code' => $code,
|
||||
'redirect_uri' => $app->redirect_uri,
|
||||
'code_verifier' => $codeVerifier,
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
it('fails with expired authorization code', function () {
|
||||
// Set expired code
|
||||
Cache::put("auth_code:expired", $this->payload, now()->subMinute());
|
||||
test('rejects invalid PKCE code verifier', function () {
|
||||
$user = User::factory()->create();
|
||||
$app = Application::factory()->create();
|
||||
|
||||
$response = $this->post('/auth/token', [
|
||||
$codeVerifier = base64url_encode(random_bytes(32));
|
||||
$codeChallenge = base64url_encode(hash('sha256', $codeVerifier, true));
|
||||
|
||||
$code = 'test-code';
|
||||
Cache::put("auth_code:$code", [
|
||||
'user_id' => $user->id,
|
||||
'client_id' => $app->id,
|
||||
'code_challenge' => $codeChallenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
], now()->addMinutes(5));
|
||||
|
||||
$this->post(route('auth.token'), [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => 'expired',
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'client_id' => $this->application->client_id,
|
||||
'client_secret' => $this->application->client_secret,
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
it('fails with invalid client credentials', function () {
|
||||
$response = $this->post('/auth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $this->authCode,
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'client_id' => $this->application->client_id,
|
||||
'client_secret' => 'wrong-secret',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
it('fails with invalid PKCE code_verifier', function () {
|
||||
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', 'correct-verifier', true)), '+/', '-_'), '=');
|
||||
|
||||
$this->payload['code_challenge'] = $codeChallenge;
|
||||
$this->payload['code_challenge_method'] = 'S256';
|
||||
Cache::put("auth_code:{$this->authCode}", $this->payload, now()->addMinutes(5));
|
||||
|
||||
$response = $this->post('/auth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $this->authCode,
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'code' => $code,
|
||||
'redirect_uri' => $app->redirect_uri,
|
||||
'code_verifier' => 'wrong-verifier',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
])->assertStatus(403);
|
||||
});
|
||||
|
||||
it('fails with redirect_uri mismatch', function () {
|
||||
$response = $this->post('/auth/token', [
|
||||
test('includes nonce in ID token when provided', function () {
|
||||
$user = User::factory()->create();
|
||||
$app = Application::factory()->create();
|
||||
|
||||
$nonce = 'test-nonce-123';
|
||||
$code = 'test-code';
|
||||
Cache::put("auth_code:$code", [
|
||||
'user_id' => $user->id,
|
||||
'client_id' => $app->id,
|
||||
'nonce' => $nonce,
|
||||
], now()->addMinutes(5));
|
||||
|
||||
$response = $this->post(route('auth.token'), [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $this->authCode,
|
||||
'redirect_uri' => 'https://evil.com/callback',
|
||||
'client_id' => $this->application->client_id,
|
||||
'client_secret' => $this->application->client_secret,
|
||||
'code' => $code,
|
||||
'redirect_uri' => $app->redirect_uri,
|
||||
'client_id' => $app->client_id,
|
||||
'client_secret' => $app->client_secret,
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$data = $response->json();
|
||||
$parser = new Parser(new JoseEncoder());
|
||||
$idToken = $parser->parse($data['id_token']);
|
||||
expect($idToken->claims()->get('nonce'))->toBe($nonce);
|
||||
});
|
||||
|
||||
it('fails with missing authentication', function () {
|
||||
$response = $this->post('/auth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $this->authCode,
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
]);
|
||||
test('requires authentication method when no PKCE', function () {
|
||||
$user = User::factory()->create();
|
||||
$app = Application::factory()->create();
|
||||
|
||||
$response->assertStatus(403);
|
||||
$code = 'test-code';
|
||||
Cache::put("auth_code:$code", [
|
||||
'user_id' => $user->id,
|
||||
'client_id' => $app->id,
|
||||
], now()->addMinutes(5));
|
||||
|
||||
$this->post(route('auth.token'), [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $app->redirect_uri,
|
||||
])->assertStatus(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC UserInfo Endpoint', function () {
|
||||
beforeEach(function () {
|
||||
$this->token = AuthenticationToken::create([
|
||||
'user_id' => $this->user->id,
|
||||
'application_id' => $this->application->id,
|
||||
'token' => Str::random(64),
|
||||
'issued_at' => now(),
|
||||
'expires_at' => now()->addMonth(),
|
||||
'ip' => '127.0.0.1',
|
||||
'user_agent' => 'Test Agent',
|
||||
]);
|
||||
test('requires authorization header', function () {
|
||||
$this->get(route('auth.userinfo'))
|
||||
->assertStatus(400)
|
||||
->assertJson(['error' => 'invalid_request']);
|
||||
});
|
||||
|
||||
it('returns user information with valid token', function () {
|
||||
$response = $this->get('/auth/userinfo', [
|
||||
'Authorization' => 'Bearer ' . $this->token->token,
|
||||
test('requires bearer token format', function () {
|
||||
$this->get(route('auth.userinfo'), [
|
||||
'Authorization' => 'Basic invalid'
|
||||
])->assertStatus(400)
|
||||
->assertJson(['error' => 'invalid_request']);
|
||||
});
|
||||
|
||||
test('rejects invalid access tokens', function () {
|
||||
$this->get(route('auth.userinfo'), [
|
||||
'Authorization' => 'Bearer invalid-token'
|
||||
])->assertStatus(401)
|
||||
->assertJson(['error' => 'invalid_token']);
|
||||
});
|
||||
|
||||
test('returns user info for valid access token', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'test@example.com',
|
||||
'name' => 'Test User',
|
||||
'preferred_username' => 'testuser',
|
||||
'avatar' => 'avatar.jpg',
|
||||
]);
|
||||
$app = Application::factory()->create();
|
||||
$token = AuthenticationToken::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'application_id' => $app->id,
|
||||
'token' => 'valid-access-token',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('auth.userinfo'), [
|
||||
'Authorization' => 'Bearer valid-access-token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'sub' => (string) $user->id,
|
||||
'email' => $user->email,
|
||||
'name' => $user->name,
|
||||
'preferred_username' => $user->preferred_username,
|
||||
'picture' => $user->avatarUrl(),
|
||||
]);
|
||||
});
|
||||
|
||||
test('handles user without avatar', function () {
|
||||
$user = User::factory()->create([
|
||||
'avatar' => null,
|
||||
]);
|
||||
$app = Application::factory()->create();
|
||||
$token = AuthenticationToken::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'application_id' => $app->id,
|
||||
'token' => 'valid-access-token',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('auth.userinfo'), [
|
||||
'Authorization' => 'Bearer valid-access-token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json();
|
||||
|
||||
expect($data)->toBe([
|
||||
'sub' => (string) $this->user->id,
|
||||
'email' => $this->user->email,
|
||||
'name' => $this->user->name,
|
||||
'preferred_username' => $this->user->preferred_username,
|
||||
'picture' => null, // No avatar set in test
|
||||
]);
|
||||
expect($data['picture'])->toBeNull();
|
||||
});
|
||||
|
||||
it('returns user information with avatar', function () {
|
||||
$this->user->update(['avatar' => 'test-avatar.jpg']);
|
||||
|
||||
$response = $this->get('/auth/userinfo', [
|
||||
'Authorization' => 'Bearer ' . $this->token->token,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json();
|
||||
|
||||
expect($data['picture'])->toContain('test-avatar.jpg');
|
||||
});
|
||||
|
||||
it('fails with missing authorization header', function () {
|
||||
$response = $this->get('/auth/userinfo');
|
||||
|
||||
$response->assertStatus(400);
|
||||
expect($response->json()['error'])->toBe('invalid_request');
|
||||
});
|
||||
|
||||
it('fails with invalid authorization header format', function () {
|
||||
$response = $this->get('/auth/userinfo', [
|
||||
'Authorization' => 'Basic invalid-format',
|
||||
]);
|
||||
|
||||
$response->assertStatus(400);
|
||||
expect($response->json()['error'])->toBe('invalid_request');
|
||||
});
|
||||
|
||||
it('fails with invalid token', function () {
|
||||
$response = $this->get('/auth/userinfo', [
|
||||
'Authorization' => 'Bearer invalid-token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401);
|
||||
expect($response->json()['error'])->toBe('invalid_token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC JWKS Endpoint', function () {
|
||||
it('returns public keys in JWKS format', function () {
|
||||
$response = $this->get('/auth/keys');
|
||||
describe('OIDC Discovery and JWKS', function () {
|
||||
test('openid configuration endpoint returns correct metadata', function () {
|
||||
$response = $this->get(route('auth.openid-configuration'));
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'issuer' => config('app.url'),
|
||||
'authorization_endpoint' => route('auth.authorize'),
|
||||
'token_endpoint' => route('auth.token'),
|
||||
'userinfo_endpoint' => route('auth.userinfo'),
|
||||
'jwks_uri' => route('auth.keys'),
|
||||
'scopes_supported' => ['openid', 'profile', 'email'],
|
||||
'response_types_supported' => ['code'],
|
||||
'id_token_signing_alg_values_supported' => ['RS256'],
|
||||
'claims_supported' => [
|
||||
'sub',
|
||||
'email',
|
||||
'name',
|
||||
'preferred_username',
|
||||
'picture'
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('jwks endpoint returns valid key set', function () {
|
||||
$response = $this->get(route('auth.keys'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json();
|
||||
|
||||
|
||||
expect($data)->toHaveKey('keys');
|
||||
expect($data['keys'])->toBeArray();
|
||||
expect($data['keys'][0])->toHaveKeys(['kty', 'use', 'alg', 'kid', 'n', 'e']);
|
||||
@ -364,38 +503,8 @@ describe('OIDC JWKS Endpoint', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC OpenID Configuration', function () {
|
||||
it('returns openid configuration', function () {
|
||||
$response = $this->get('/.well-known/openid-configuration');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json();
|
||||
|
||||
$expectedKeys = [
|
||||
'issuer',
|
||||
'authorization_endpoint',
|
||||
'token_endpoint',
|
||||
'useringo_endpoint',
|
||||
'scopes_supported',
|
||||
'response_types_supported',
|
||||
'jwks_uri',
|
||||
'id_token_signing_alg_values_supported',
|
||||
'claims_supported'
|
||||
];
|
||||
|
||||
expect($data)->toHaveKeys($expectedKeys);
|
||||
expect($data['issuer'])->toBe(config('app.url'));
|
||||
expect($data['scopes_supported'])->toContain('openid');
|
||||
expect($data['response_types_supported'])->toContain('code');
|
||||
expect($data['id_token_signing_alg_values_supported'])->toContain('RS256');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC Logout Endpoint', function () {
|
||||
it('returns logout view', function () {
|
||||
$response = $this->get('/auth/logout');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewIs('logged-out');
|
||||
});
|
||||
});
|
||||
// Helper function for base64url encoding
|
||||
function base64url_encode($data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
@ -1,310 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use Lcobucci\JWT\Signer\Key\InMemory;
|
||||
use Lcobucci\JWT\Signer\Rsa\Sha256;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create([
|
||||
'name' => 'JWT Test User',
|
||||
'email' => 'jwt@example.com',
|
||||
]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'name' => 'JWT Test App',
|
||||
'client_id' => 'jwt-test-client',
|
||||
'client_secret' => 'jwt-test-secret',
|
||||
'redirect_uri' => 'https://jwt.example.com/callback',
|
||||
]);
|
||||
|
||||
// Create test keys
|
||||
$this->createJWTTestKeys();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
$this->cleanupJWTTestKeys();
|
||||
});
|
||||
|
||||
function createJWTTestKeys()
|
||||
{
|
||||
$keyDir = storage_path('oauth');
|
||||
if (!file_exists($keyDir)) {
|
||||
mkdir($keyDir, 0755, true);
|
||||
}
|
||||
|
||||
$config = [
|
||||
'digest_alg' => 'sha256',
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
];
|
||||
|
||||
$privateKey = openssl_pkey_new($config);
|
||||
openssl_pkey_export($privateKey, $privateKeyPem);
|
||||
file_put_contents(storage_path('oauth/private.pem'), $privateKeyPem);
|
||||
|
||||
$publicKey = openssl_pkey_get_details($privateKey);
|
||||
file_put_contents(storage_path('oauth/public.pem'), $publicKey['key']);
|
||||
}
|
||||
|
||||
function cleanupJWTTestKeys()
|
||||
{
|
||||
$files = [
|
||||
storage_path('oauth/private.pem'),
|
||||
storage_path('oauth/public.pem')
|
||||
];
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (file_exists($file)) {
|
||||
unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('JWT ID Token Generation and Validation', function () {
|
||||
it('generates valid JWT ID token', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
// Set up authorization flow
|
||||
$authCode = Str::random(40);
|
||||
Cache::put("auth_code:$authCode", [
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $this->application->id,
|
||||
'scope' => 'openid email profile',
|
||||
'nonce' => 'test-nonce-123',
|
||||
], now()->addMinutes(5));
|
||||
|
||||
// Exchange code for tokens
|
||||
$response = $this->post('/auth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $authCode,
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'client_id' => $this->application->client_id,
|
||||
'client_secret' => $this->application->client_secret,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json();
|
||||
|
||||
// Validate ID token structure and basic properties
|
||||
$idToken = $data['id_token'];
|
||||
expect($idToken)->toBeString();
|
||||
expect(substr_count($idToken, '.'))->toBe(2); // JWT has 3 parts separated by dots
|
||||
|
||||
// Decode JWT payload for basic validation (without signature verification for simplicity)
|
||||
$parts = explode('.', $idToken);
|
||||
$payload = json_decode(base64_decode(str_pad(strtr($parts[1], '-_', '+/'), strlen($parts[1]) % 4, '=', STR_PAD_RIGHT)), true);
|
||||
|
||||
// Validate basic claims structure
|
||||
expect($payload)->toHaveKeys(['iss', 'aud', 'sub', 'email', 'nonce', 'iat', 'exp']);
|
||||
expect($payload['iss'])->toBe(config('app.url'));
|
||||
expect($payload['aud'])->toBe($this->application->client_id);
|
||||
expect($payload['sub'])->toBe((string) $this->user->id);
|
||||
expect($payload['email'])->toBe($this->user->email);
|
||||
expect($payload['nonce'])->toBe('test-nonce-123');
|
||||
|
||||
// Validate timing
|
||||
expect($payload['iat'])->toBeLessThanOrEqual(time());
|
||||
expect($payload['exp'])->toBeGreaterThan(time());
|
||||
});
|
||||
|
||||
it('generates JWT without nonce when not provided', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$authCode = Str::random(40);
|
||||
Cache::put("auth_code:$authCode", [
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $this->application->id,
|
||||
'scope' => 'openid email profile',
|
||||
// No nonce provided
|
||||
], now()->addMinutes(5));
|
||||
|
||||
$response = $this->post('/auth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $authCode,
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'client_id' => $this->application->client_id,
|
||||
'client_secret' => $this->application->client_secret,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json();
|
||||
|
||||
// Parse JWT payload for basic validation
|
||||
$parts = explode('.', $data['id_token']);
|
||||
$payload = json_decode(base64_decode(str_pad(strtr($parts[1], '-_', '+/'), strlen($parts[1]) % 4, '=', STR_PAD_RIGHT)), true);
|
||||
|
||||
// Verify nonce claim is not present
|
||||
expect($payload)->not->toHaveKey('nonce');
|
||||
});
|
||||
|
||||
it('JWT expires in 5 minutes', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$authCode = Str::random(40);
|
||||
Cache::put("auth_code:$authCode", [
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $this->application->id,
|
||||
'scope' => 'openid email profile',
|
||||
], now()->addMinutes(5));
|
||||
|
||||
$response = $this->post('/auth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $authCode,
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'client_id' => $this->application->client_id,
|
||||
'client_secret' => $this->application->client_secret,
|
||||
]);
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
// Parse JWT payload
|
||||
$parts = explode('.', $data['id_token']);
|
||||
$payload = json_decode(base64_decode(str_pad(strtr($parts[1], '-_', '+/'), strlen($parts[1]) % 4, '=', STR_PAD_RIGHT)), true);
|
||||
|
||||
// Should expire in 5 minutes (300 seconds)
|
||||
expect($payload['exp'] - $payload['iat'])->toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PKCE Code Challenge Validation', function () {
|
||||
it('validates S256 code challenge correctly', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$codeVerifier = Str::random(128);
|
||||
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$authCode = Str::random(40);
|
||||
Cache::put("auth_code:$authCode", [
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $this->application->id,
|
||||
'scope' => 'openid',
|
||||
'code_challenge' => $codeChallenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
], now()->addMinutes(5));
|
||||
|
||||
$response = $this->post('/auth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $authCode,
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'code_verifier' => $codeVerifier,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
it('validates plain code challenge correctly', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$codeVerifier = 'plain-text-verifier';
|
||||
|
||||
$authCode = Str::random(40);
|
||||
Cache::put("auth_code:$authCode", [
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $this->application->id,
|
||||
'scope' => 'openid',
|
||||
'code_challenge' => $codeVerifier,
|
||||
'code_challenge_method' => 'plain',
|
||||
], now()->addMinutes(5));
|
||||
|
||||
$response = $this->post('/auth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $authCode,
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'code_verifier' => $codeVerifier,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
it('rejects invalid code challenge method', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$authCode = Str::random(40);
|
||||
Cache::put("auth_code:$authCode", [
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $this->application->id,
|
||||
'scope' => 'openid',
|
||||
'code_challenge' => 'some-challenge',
|
||||
'code_challenge_method' => 'invalid-method',
|
||||
], now()->addMinutes(5));
|
||||
|
||||
$response = $this->post('/auth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $authCode,
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'code_verifier' => 'some-verifier',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Access Token Management', function () {
|
||||
it('creates authentication token record with correct metadata', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$authCode = Str::random(40);
|
||||
Cache::put("auth_code:$authCode", [
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $this->application->id,
|
||||
'scope' => 'openid email profile',
|
||||
], now()->addMinutes(5));
|
||||
|
||||
$response = $this->post('/auth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $authCode,
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'client_id' => $this->application->client_id,
|
||||
'client_secret' => $this->application->client_secret,
|
||||
], [
|
||||
'User-Agent' => 'Test Browser/1.0',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json();
|
||||
|
||||
// Verify token record was created with metadata
|
||||
$tokenRecord = $this->user->tokens()->where('token', $data['access_token'])->first();
|
||||
|
||||
expect($tokenRecord)->not->toBeNull();
|
||||
expect($tokenRecord->application_id)->toBe($this->application->id);
|
||||
expect($tokenRecord->ip)->toBe('127.0.0.1'); // Default test IP
|
||||
expect($tokenRecord->user_agent)->toBe('Test Browser/1.0');
|
||||
expect($tokenRecord->issued_at)->not->toBeNull();
|
||||
expect($tokenRecord->expires_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('sets access token expiration to 1 month', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$authCode = Str::random(40);
|
||||
Cache::put("auth_code:$authCode", [
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $this->application->id,
|
||||
'scope' => 'openid',
|
||||
], now()->addMinutes(5));
|
||||
|
||||
$before = now();
|
||||
|
||||
$response = $this->post('/auth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $authCode,
|
||||
'redirect_uri' => $this->application->redirect_uri,
|
||||
'client_id' => $this->application->client_id,
|
||||
'client_secret' => $this->application->client_secret,
|
||||
]);
|
||||
|
||||
$after = now();
|
||||
$data = $response->json();
|
||||
|
||||
$tokenRecord = $this->user->tokens()->where('token', $data['access_token'])->first();
|
||||
|
||||
// Token should expire approximately 1 month from now
|
||||
$expectedExpiry = $before->addMonth();
|
||||
expect($tokenRecord->expires_at)->toBeBetween($expectedExpiry, $after->addMonth());
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user