ensureTestKeysExist(); }); describe('OIDC Authorization Endpoint', function () { test('requires authentication', function () { $this->get(route('auth.authorize')) ->assertRedirect(route('login')); }); 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')); 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'); }); test('supports PKCE flow with S256 code challenge', function () { $user = User::factory()->create(); $app = Application::factory()->create([ 'redirect_uri' => 'http://example.com/callback' ]); $codeVerifier = base64url_encode(random_bytes(32)); $codeChallenge = base64url_encode(hash('sha256', $codeVerifier, true)); $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 () { 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); }); 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' => $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 access token is stored $this->assertDatabaseHas('authentication_tokens', [ 'token' => $data['access_token'], 'user_id' => $user->id, 'application_id' => $app->id, ]); // 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->uuid); expect($idToken->claims()->get('email'))->toBe($user->email); }); test('successful PKCE token exchange with S256', function () { $user = User::factory()->create(); $app = Application::factory()->create(); $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)); $response = $this->post(route('auth.token'), [ 'grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => $app->redirect_uri, 'code_verifier' => $codeVerifier, ]); $response->assertStatus(200); }); 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' => $code, 'redirect_uri' => $app->redirect_uri, 'code_verifier' => $codeVerifier, ]); $response->assertStatus(200); }); test('rejects invalid PKCE code verifier', function () { $user = User::factory()->create(); $app = Application::factory()->create(); $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' => $code, 'redirect_uri' => $app->redirect_uri, 'code_verifier' => 'wrong-verifier', ])->assertStatus(403); }); 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' => $code, 'redirect_uri' => $app->redirect_uri, 'client_id' => $app->client_id, 'client_secret' => $app->client_secret, ]); $data = $response->json(); $parser = new Parser(new JoseEncoder()); $idToken = $parser->parse($data['id_token']); expect($idToken->claims()->get('nonce'))->toBe($nonce); }); test('requires authentication method when no PKCE', 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, ])->assertStatus(403); }); }); describe('OIDC UserInfo Endpoint', function () { test('requires authorization header', function () { $this->get(route('auth.userinfo')) ->assertStatus(400) ->assertJson(['error' => 'invalid_request']); }); 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->uuid, '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['picture'])->toBeNull(); }); }); 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']); expect($data['keys'][0]['kty'])->toBe('RSA'); expect($data['keys'][0]['use'])->toBe('sig'); expect($data['keys'][0]['alg'])->toBe('RS256'); }); }); // Helper function for base64url encoding function base64url_encode($data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); }