232 lines
9.1 KiB
PHP
232 lines
9.1 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_invalid_scope_is_returned_as_standard_oauth_redirect_error(): 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');
|
|
$queryValues = $this->queryValuesFromUrl($location);
|
|
$this->assertSame('invalid_scope', (string) ($queryValues['error'] ?? ''));
|
|
$this->assertSame('scope_state', (string) ($queryValues['state'] ?? ''));
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|