generated from thegrind/laravel-dockerized
402 lines
13 KiB
PHP
402 lines
13 KiB
PHP
<?php
|
|
|
|
use App\Models\Application;
|
|
use App\Models\AuthenticationToken;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Str;
|
|
|
|
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 = [
|
|
'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);
|
|
}
|
|
}
|
|
|
|
describe('OIDC Authorization Endpoint', function () {
|
|
it('redirects to consent screen with valid parameters', function () {
|
|
$this->actingAs($this->user);
|
|
|
|
$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',
|
|
]));
|
|
|
|
$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');
|
|
});
|
|
|
|
it('fails with invalid client_id', function () {
|
|
$this->actingAs($this->user);
|
|
|
|
$response = $this->get('/auth/authorize?' . http_build_query([
|
|
'client_id' => 'invalid-client-id',
|
|
'redirect_uri' => $this->application->redirect_uri,
|
|
'response_type' => 'code',
|
|
]));
|
|
|
|
$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->assertRedirect(route('auth.confirm'));
|
|
});
|
|
});
|
|
|
|
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));
|
|
});
|
|
|
|
it('exchanges authorization code for tokens with 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' => $this->application->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
|
|
$this->assertDatabaseHas('authentication_tokens', [
|
|
'user_id' => $this->user->id,
|
|
'application_id' => $this->application->id,
|
|
'token' => $data['access_token'],
|
|
]);
|
|
|
|
// Verify auth code was consumed
|
|
expect(Cache::get("auth_code:{$this->authCode}"))->toBeNull();
|
|
});
|
|
|
|
it('exchanges authorization code for tokens with PKCE', function () {
|
|
$codeVerifier = Str::random(128);
|
|
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
|
|
|
|
// 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));
|
|
|
|
$response = $this->post('/auth/token', [
|
|
'grant_type' => 'authorization_code',
|
|
'code' => $this->authCode,
|
|
'redirect_uri' => $this->application->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', [
|
|
'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,
|
|
]);
|
|
|
|
$response->assertStatus(403);
|
|
});
|
|
|
|
it('fails with expired authorization code', function () {
|
|
// Set expired code
|
|
Cache::put("auth_code:expired", $this->payload, now()->subMinute());
|
|
|
|
$response = $this->post('/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_verifier' => 'wrong-verifier',
|
|
]);
|
|
|
|
$response->assertStatus(403);
|
|
});
|
|
|
|
it('fails with redirect_uri mismatch', function () {
|
|
$response = $this->post('/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,
|
|
]);
|
|
|
|
$response->assertStatus(403);
|
|
});
|
|
|
|
it('fails with missing authentication', function () {
|
|
$response = $this->post('/auth/token', [
|
|
'grant_type' => 'authorization_code',
|
|
'code' => $this->authCode,
|
|
'redirect_uri' => $this->application->redirect_uri,
|
|
]);
|
|
|
|
$response->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',
|
|
]);
|
|
});
|
|
|
|
it('returns user information with valid token', function () {
|
|
$response = $this->get('/auth/userinfo', [
|
|
'Authorization' => 'Bearer ' . $this->token->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
|
|
]);
|
|
});
|
|
|
|
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');
|
|
|
|
$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');
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|