generated from thegrind/laravel-dockerized
500 lines
17 KiB
PHP
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), '+/', '-_'), '=');
|
|
} |