This commit is contained in:
Javier Feliz 2025-07-31 04:23:02 -04:00
parent 038ee47fa3
commit 8e0abedbbb
3 changed files with 713 additions and 1 deletions

View File

@ -0,0 +1,401 @@
<?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');
});
});

View File

@ -0,0 +1,310 @@
<?php
use App\Models\Application;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
beforeEach(function () {
$this->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());
});
});

View File

@ -2,9 +2,10 @@
namespace Tests;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
use RefreshDatabase;
}