BastionSSO/tests/Feature/OauthProtocolTest.php

231 lines
9.0 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\OauthClient;
use App\Models\OauthRefreshToken;
use App\Models\OauthScope;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
use Tymon\JWTAuth\Facades\JWTAuth;
class OauthProtocolTest extends TestCase
{
use RefreshDatabase;
public function test_authorization_code_flow_userinfo_remap_and_discovery(): void
{
config()->set('oauth.frontend_login_url', 'http://frontend.local/#/login');
config()->set('oauth.frontend_consent_url', 'http://frontend.local/#/oauth-consent');
config()->set('oauth.issuer', 'http://localhost');
$resourceOwner = User::factory()->create([
'nickname' => 'alice',
'email' => 'alice@example.com',
'phone' => '13800000000',
]);
$accessTokenForAuthorize = JWTAuth::fromUser($resourceOwner);
$plainSecret = 'secret_123456789';
$client = OauthClient::factory()->create([
'client_id' => 'cli_test_client',
'client_secret_hash' => Hash::make($plainSecret),
'redirect_uris' => ['https://client.example.com/callback'],
'allowed_userinfo_fields' => ['sub', 'nickname', 'email'],
'userinfo_claim_remap' => ['nickname' => 'username'],
]);
$scopeIds = OauthScope::query()
->whereIn('name', ['openid', 'profile', 'email'])
->pluck('id')
->all();
$client->scopes()->sync($scopeIds);
$authorizeResponse = $this->get('/oauth/authorize?'.http_build_query([
'response_type' => 'code',
'client_id' => $client->client_id,
'redirect_uri' => 'https://client.example.com/callback',
'scope' => 'openid profile email',
'state' => 'state_001',
'nonce' => 'nonce_001',
'access_token' => $accessTokenForAuthorize,
]));
$authorizeResponse->assertRedirectContains('/#/oauth-consent');
$decisionResponse = $this->post('/oauth/authorize/decision', [
'approve' => true,
'client_id' => $client->client_id,
'redirect_uri' => 'https://client.example.com/callback',
'scope' => 'openid profile email',
'state' => 'state_001',
'nonce' => 'nonce_001',
'access_token' => $accessTokenForAuthorize,
]);
$decisionResponse->assertStatus(302);
$callbackUrl = (string) $decisionResponse->headers->get('Location');
$queryValues = $this->queryValuesFromUrl($callbackUrl);
$authorizationCode = (string) ($queryValues['code'] ?? '');
$this->assertNotSame('', $authorizationCode);
$this->assertSame('state_001', (string) ($queryValues['state'] ?? ''));
$tokenResponse = $this->post('/oauth/token', [
'grant_type' => 'authorization_code',
'code' => $authorizationCode,
'redirect_uri' => 'https://client.example.com/callback',
], [
'Authorization' => 'Basic '.base64_encode($client->client_id.':'.$plainSecret),
'Accept' => 'application/json',
]);
$tokenResponse
->assertOk()
->assertJsonStructure([
'access_token',
'token_type',
'expires_in',
'refresh_token',
'scope',
'id_token',
]);
$userInfoResponse = $this->get('/oauth/userinfo?access_token='.(string) $tokenResponse->json('access_token'));
$userInfoResponse
->assertOk()
->assertJsonPath('sub', (string) $resourceOwner->id)
->assertJsonPath('username', 'alice')
->assertJsonPath('email', 'alice@example.com')
->assertJsonMissingPath('nickname');
$discoveryResponse = $this->get('/.well-known/openid-configuration');
$discoveryResponse
->assertOk()
->assertJsonPath('issuer', 'http://localhost')
->assertJsonPath('token_endpoint_auth_methods_supported.0', 'client_secret_basic');
$jwksResponse = $this->get('/oauth/jwks');
$jwksResponse
->assertOk()
->assertJsonPath('keys.0.kty', 'RSA')
->assertJsonPath('keys.0.alg', 'RS256');
}
public function test_requested_scope_is_ignored_and_authorization_continues_with_server_config(): void
{
$resourceOwner = User::factory()->create();
$accessTokenForAuthorize = JWTAuth::fromUser($resourceOwner);
$client = OauthClient::factory()->create([
'client_id' => 'cli_scope_subset',
'client_secret_hash' => Hash::make('secret'),
'redirect_uris' => ['https://subset.example.com/callback'],
'allowed_userinfo_fields' => ['sub'],
'userinfo_claim_remap' => [],
]);
$scopeIds = OauthScope::query()->whereIn('name', ['profile'])->pluck('id')->all();
$client->scopes()->sync($scopeIds);
$response = $this->get('/oauth/authorize?'.http_build_query([
'response_type' => 'code',
'client_id' => $client->client_id,
'redirect_uri' => 'https://subset.example.com/callback',
'scope' => 'profile openid',
'state' => 'scope_state',
'access_token' => $accessTokenForAuthorize,
]));
$response->assertStatus(302);
$location = (string) $response->headers->get('Location');
$this->assertStringContainsString('/#/oauth-consent', $location);
$this->assertStringContainsString('state=scope_state', $location);
}
public function test_refresh_rotation_and_replay_revokes_authorization_chain(): void
{
$resourceOwner = User::factory()->create();
$accessTokenForAuthorize = JWTAuth::fromUser($resourceOwner);
$plainSecret = 'secret_refresh_001';
$client = OauthClient::factory()->create([
'client_id' => 'cli_refresh_client',
'client_secret_hash' => Hash::make($plainSecret),
'redirect_uris' => ['https://refresh.example.com/callback'],
'allowed_userinfo_fields' => ['sub'],
'userinfo_claim_remap' => [],
]);
$scopeIds = OauthScope::query()->whereIn('name', ['openid'])->pluck('id')->all();
$client->scopes()->sync($scopeIds);
$decisionResponse = $this->post('/oauth/authorize/decision', [
'approve' => true,
'client_id' => $client->client_id,
'redirect_uri' => 'https://refresh.example.com/callback',
'scope' => 'openid',
'state' => 'refresh_state',
'nonce' => 'refresh_nonce',
'access_token' => $accessTokenForAuthorize,
]);
$authorizationCode = (string) ($this->queryValuesFromUrl((string) $decisionResponse->headers->get('Location'))['code'] ?? '');
$this->assertNotSame('', $authorizationCode);
$tokenResponse = $this->post('/oauth/token', [
'grant_type' => 'authorization_code',
'code' => $authorizationCode,
'redirect_uri' => 'https://refresh.example.com/callback',
], [
'Authorization' => 'Basic '.base64_encode($client->client_id.':'.$plainSecret),
'Accept' => 'application/json',
])->assertOk();
$firstRefreshToken = (string) $tokenResponse->json('refresh_token');
$this->assertNotSame('', $firstRefreshToken);
$refreshResponse = $this->post('/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $firstRefreshToken,
], [
'Authorization' => 'Basic '.base64_encode($client->client_id.':'.$plainSecret),
'Accept' => 'application/json',
]);
$refreshResponse->assertOk();
$secondRefreshToken = (string) $refreshResponse->json('refresh_token');
$this->assertNotSame('', $secondRefreshToken);
$this->assertNotSame($firstRefreshToken, $secondRefreshToken);
$replayResponse = $this->post('/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $firstRefreshToken,
], [
'Authorization' => 'Basic '.base64_encode($client->client_id.':'.$plainSecret),
'Accept' => 'application/json',
]);
$replayResponse
->assertStatus(400)
->assertJsonPath('error', 'invalid_grant');
$latestRefreshRecord = OauthRefreshToken::query()
->where('token_hash', hash('sha256', $secondRefreshToken))
->first();
$this->assertNotNull($latestRefreshRecord);
$this->assertNotNull($latestRefreshRecord->revoked_at);
}
/**
* @return array<string, string>
*/
private function queryValuesFromUrl(string $url): array
{
$query = parse_url($url, PHP_URL_QUERY);
if (! is_string($query)) {
return [];
}
parse_str($query, $values);
return collect($values)->map(fn ($value): string => (string) $value)->all();
}
}