From 93c6baa16bfb845b98c22c7c1ec40ee3b3ad6e5f Mon Sep 17 00:00:00 2001 From: Javier Feliz Date: Fri, 1 Aug 2025 21:33:43 -0400 Subject: [PATCH] Invites and some tests --- CLAUDE.md | 81 ++ app/Http/Controllers/OIDCController.php | 9 +- app/Livewire/Auth/Register.php | 46 +- app/Models/AuthenticationToken.php | 2 + app/Models/Invitation.php | 6 + .../factories/AuthenticationTokenFactory.php | 33 + .../views/livewire/auth/register.blade.php | 30 +- tests/Feature/InvitationSystemTest.php | 7 - tests/Feature/OIDCControllerTest.php | 725 ++++++++++-------- tests/Feature/OIDCJWTTest.php | 310 -------- 10 files changed, 610 insertions(+), 639 deletions(-) create mode 100644 CLAUDE.md create mode 100644 database/factories/AuthenticationTokenFactory.php delete mode 100644 tests/Feature/InvitationSystemTest.php delete mode 100644 tests/Feature/OIDCJWTTest.php diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0b4a078 --- /dev/null +++ b/CLAUDE.md @@ -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. \ No newline at end of file diff --git a/app/Http/Controllers/OIDCController.php b/app/Http/Controllers/OIDCController.php index 7aa270f..9b7df60 100644 --- a/app/Http/Controllers/OIDCController.php +++ b/app/Http/Controllers/OIDCController.php @@ -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'), diff --git a/app/Livewire/Auth/Register.php b/app/Livewire/Auth/Register.php index fb459e5..6a5fe68 100644 --- a/app/Livewire/Auth/Register.php +++ b/app/Livewire/Auth/Register.php @@ -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); diff --git a/app/Models/AuthenticationToken.php b/app/Models/AuthenticationToken.php index 0cce833..892b976 100644 --- a/app/Models/AuthenticationToken.php +++ b/app/Models/AuthenticationToken.php @@ -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 diff --git a/app/Models/Invitation.php b/app/Models/Invitation.php index c810148..99efa5d 100644 --- a/app/Models/Invitation.php +++ b/app/Models/Invitation.php @@ -21,4 +21,10 @@ class Invitation extends Model { return empty($this->accepted_at); } + + public function accept(): void + { + $this->accepted_at = now(); + $this->save(); + } } diff --git a/database/factories/AuthenticationTokenFactory.php b/database/factories/AuthenticationTokenFactory.php new file mode 100644 index 0000000..3829091 --- /dev/null +++ b/database/factories/AuthenticationTokenFactory.php @@ -0,0 +1,33 @@ + + */ +class AuthenticationTokenFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + 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(), + ]; + } +} \ No newline at end of file diff --git a/resources/views/livewire/auth/register.blade.php b/resources/views/livewire/auth/register.blade.php index 7602701..862f6a5 100644 --- a/resources/views/livewire/auth/register.blade.php +++ b/resources/views/livewire/auth/register.blade.php @@ -1,10 +1,22 @@
- + @if($invitationAccepted) + + +
+

{{ __('This invitation code has already been used to create an account.') }}

+ {{ __('Return to login') }} +
+ @else + - - + + -
+ @error('general') +
{{ $message }}
+ @enderror + + @@ -55,8 +68,9 @@
-
- {{ __('Already have an account?') }} - {{ __('Log in') }} -
+
+ {{ __('Already have an account?') }} + {{ __('Log in') }} +
+ @endif diff --git a/tests/Feature/InvitationSystemTest.php b/tests/Feature/InvitationSystemTest.php deleted file mode 100644 index b46239f..0000000 --- a/tests/Feature/InvitationSystemTest.php +++ /dev/null @@ -1,7 +0,0 @@ -get('/'); - - $response->assertStatus(200); -}); diff --git a/tests/Feature/OIDCControllerTest.php b/tests/Feature/OIDCControllerTest.php index 443ae3a..10774a7 100644 --- a/tests/Feature/OIDCControllerTest.php +++ b/tests/Feature/OIDCControllerTest.php @@ -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), '+/', '-_'), '='); +} \ No newline at end of file diff --git a/tests/Feature/OIDCJWTTest.php b/tests/Feature/OIDCJWTTest.php deleted file mode 100644 index 1c74581..0000000 --- a/tests/Feature/OIDCJWTTest.php +++ /dev/null @@ -1,310 +0,0 @@ -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()); - }); -});