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 */ 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(); } }