authentikate/tests/Feature/OIDCControllerTest.php
Javier Feliz 6a3971257a
Some checks failed
linter / quality (push) Successful in 6m36s
tests / ci (push) Has been cancelled
Update dockerfile
2025-08-02 19:28:34 -04:00

500 lines
17 KiB
PHP

<?php
use App\Models\Application;
use App\Models\AuthenticationToken;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Lcobucci\JWT\Encoding\JoseEncoder;
use Lcobucci\JWT\Token\Parser;
use Tests\Support\ManagesTestKeys;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class, ManagesTestKeys::class);
beforeEach(function () {
// Ensure test keys exist for each test
$this->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), '+/', '-_'), '=');
}