568 lines
22 KiB
PHP
568 lines
22 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Tests\Service\Auth;
|
|
|
|
use MintyPHP\Service\Auth\AuthTenantGateway;
|
|
use MintyPHP\Service\Auth\MicrosoftOidcService;
|
|
use MintyPHP\Service\Auth\MicrosoftOidcStateStoreService;
|
|
use MintyPHP\Service\Auth\OidcHttpGateway;
|
|
use MintyPHP\Service\Auth\TenantSsoService;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
class MicrosoftOidcServiceTest extends TestCase
|
|
{
|
|
public function testStartAuthorizationReturnsTenantNotFoundForInvalidTenant(): void
|
|
{
|
|
$service = $this->newService();
|
|
|
|
$result = $service->startAuthorization(['id' => 0], ['authority' => 'https://login.microsoftonline.com/common/v2.0']);
|
|
|
|
$this->assertFalse($result['ok']);
|
|
$this->assertSame('tenant_not_found', $result['error']);
|
|
}
|
|
|
|
public function testStartAuthorizationReturnsDiscoveryFailedWhenMetadataLookupFails(): void
|
|
{
|
|
$oidcGateway = $this->createMock(OidcHttpGateway::class);
|
|
$oidcGateway->expects($this->once())
|
|
->method('call')
|
|
->willReturn(['status' => 500, 'data' => '']);
|
|
|
|
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
|
|
$stateStore->expects($this->never())->method('prune');
|
|
$stateStore->expects($this->never())->method('store');
|
|
|
|
$service = $this->newService(null, null, $oidcGateway, $stateStore);
|
|
$result = $service->startAuthorization(
|
|
['id' => 7],
|
|
['authority' => 'https://login.microsoftonline.com/common/v2.0']
|
|
);
|
|
|
|
$this->assertFalse($result['ok']);
|
|
$this->assertSame('oidc_discovery_failed', $result['error']);
|
|
}
|
|
|
|
public function testStartAuthorizationReturnsAuthorizeEndpointMissingWhenMetadataIsIncomplete(): void
|
|
{
|
|
$oidcGateway = $this->createMock(OidcHttpGateway::class);
|
|
$oidcGateway->method('call')->willReturn([
|
|
'status' => 200,
|
|
'data' => json_encode(['issuer' => 'https://issuer.example']) ?: '{}',
|
|
]);
|
|
|
|
$storedState = '';
|
|
$storedPayload = [];
|
|
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
|
|
$stateStore->expects($this->once())->method('prune');
|
|
$stateStore->expects($this->once())
|
|
->method('store')
|
|
->willReturnCallback(function (string $state, array $payload) use (&$storedState, &$storedPayload): void {
|
|
$storedState = $state;
|
|
$storedPayload = $payload;
|
|
});
|
|
|
|
$service = $this->newService(null, null, $oidcGateway, $stateStore);
|
|
$result = $service->startAuthorization(
|
|
['id' => 7],
|
|
['authority' => 'https://login.microsoftonline.com/common/v2.0', 'client_id' => 'client-id']
|
|
);
|
|
|
|
$this->assertFalse($result['ok']);
|
|
$this->assertSame('authorize_endpoint_missing', $result['error']);
|
|
$this->assertNotSame('', $storedState);
|
|
$this->assertSame(7, (int) ($storedPayload['tenant_id'] ?? 0));
|
|
}
|
|
|
|
public function testStartAuthorizationBuildsPkceAuthorizationUrlAndStoresState(): void
|
|
{
|
|
$tenantSsoService = $this->createMock(TenantSsoService::class);
|
|
$tenantSsoService->method('normalizeProfileSyncFields')->willReturn(['avatar']);
|
|
$tenantSsoService->method('profileSyncFieldsRequireGraph')->willReturn(true);
|
|
|
|
$oidcGateway = $this->createMock(OidcHttpGateway::class);
|
|
$oidcGateway->expects($this->once())
|
|
->method('call')
|
|
->willReturn([
|
|
'status' => 200,
|
|
'data' => json_encode([
|
|
'authorization_endpoint' => 'https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize',
|
|
]) ?: '{}',
|
|
]);
|
|
|
|
$storedState = '';
|
|
$storedPayload = [];
|
|
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
|
|
$stateStore->expects($this->once())->method('prune');
|
|
$stateStore->expects($this->once())
|
|
->method('store')
|
|
->willReturnCallback(function (string $state, array $payload) use (&$storedState, &$storedPayload): void {
|
|
$storedState = $state;
|
|
$storedPayload = $payload;
|
|
});
|
|
|
|
$service = $this->newService($tenantSsoService, null, $oidcGateway, $stateStore);
|
|
$result = $service->startAuthorization(
|
|
['id' => 9],
|
|
[
|
|
'authority' => 'https://login.microsoftonline.com/tenant/v2.0',
|
|
'client_id' => 'client-id',
|
|
'sync_profile_on_login' => 1,
|
|
'sync_profile_fields' => ['avatar'],
|
|
]
|
|
);
|
|
|
|
$this->assertTrue($result['ok']);
|
|
$this->assertNotSame('', $storedState);
|
|
$this->assertSame(9, (int) ($storedPayload['tenant_id'] ?? 0));
|
|
$this->assertNotSame('', (string) ($storedPayload['redirect_uri'] ?? ''));
|
|
|
|
$query = $this->queryFromUrl((string) ($result['url'] ?? ''));
|
|
$this->assertSame('client-id', (string) ($query['client_id'] ?? ''));
|
|
$this->assertSame('code', (string) ($query['response_type'] ?? ''));
|
|
$this->assertSame('query', (string) ($query['response_mode'] ?? ''));
|
|
$this->assertSame('S256', (string) ($query['code_challenge_method'] ?? ''));
|
|
$this->assertSame($storedState, (string) ($query['state'] ?? ''));
|
|
$this->assertSame((string) ($storedPayload['nonce'] ?? ''), (string) ($query['nonce'] ?? ''));
|
|
$this->assertSame((string) ($storedPayload['redirect_uri'] ?? ''), (string) ($query['redirect_uri'] ?? ''));
|
|
$this->assertStringContainsString('openid', (string) ($query['scope'] ?? ''));
|
|
$this->assertStringContainsString('User.Read', (string) ($query['scope'] ?? ''));
|
|
}
|
|
|
|
public function testHandleCallbackReturnsCallbackInvalidWhenInputIsEmpty(): void
|
|
{
|
|
$service = $this->newService();
|
|
|
|
$result = $service->handleCallback(' ', '');
|
|
|
|
$this->assertFalse($result['ok']);
|
|
$this->assertSame('callback_invalid', $result['error']);
|
|
}
|
|
|
|
public function testHandleCallbackReturnsStateErrorWhenStateCannotBeConsumed(): void
|
|
{
|
|
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
|
|
$stateStore->expects($this->once())
|
|
->method('consume')
|
|
->with('state-x')
|
|
->willReturn(['ok' => false, 'error' => 'state_invalid']);
|
|
|
|
$tenantGateway = $this->createMock(AuthTenantGateway::class);
|
|
$tenantGateway->expects($this->never())->method('findById');
|
|
|
|
$service = $this->newService(null, $tenantGateway, null, $stateStore);
|
|
$result = $service->handleCallback('state-x', 'code-x');
|
|
|
|
$this->assertFalse($result['ok']);
|
|
$this->assertSame('state_invalid', $result['error']);
|
|
}
|
|
|
|
public function testHandleCallbackReturnsTenantNotFoundForInactiveTenant(): void
|
|
{
|
|
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
|
|
$stateStore->method('consume')->willReturn([
|
|
'ok' => true,
|
|
'entry' => [
|
|
'tenant_id' => 5,
|
|
'nonce' => 'nonce',
|
|
'code_verifier' => 'verifier',
|
|
'redirect_uri' => 'https://app.example/auth/microsoft/callback',
|
|
],
|
|
]);
|
|
|
|
$tenantGateway = $this->createMock(AuthTenantGateway::class);
|
|
$tenantGateway->expects($this->once())
|
|
->method('findById')
|
|
->with(5)
|
|
->willReturn(['id' => 5, 'status' => 'inactive']);
|
|
|
|
$service = $this->newService(null, $tenantGateway, null, $stateStore);
|
|
$result = $service->handleCallback('state-ok', 'code-ok');
|
|
|
|
$this->assertFalse($result['ok']);
|
|
$this->assertSame('tenant_not_found', $result['error']);
|
|
}
|
|
|
|
public function testHandleCallbackReturnsConfigErrorFromTenantSsoService(): void
|
|
{
|
|
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
|
|
$stateStore->method('consume')->willReturn([
|
|
'ok' => true,
|
|
'entry' => [
|
|
'tenant_id' => 8,
|
|
'nonce' => 'nonce',
|
|
'code_verifier' => 'verifier',
|
|
'redirect_uri' => 'https://app.example/auth/microsoft/callback',
|
|
],
|
|
]);
|
|
|
|
$tenantGateway = $this->createMock(AuthTenantGateway::class);
|
|
$tenantGateway->expects($this->once())
|
|
->method('findById')
|
|
->with(8)
|
|
->willReturn(['id' => 8, 'status' => 'active']);
|
|
|
|
$tenantSsoService = $this->createMock(TenantSsoService::class);
|
|
$tenantSsoService->method('getEffectiveMicrosoftProviderConfig')->willReturn([
|
|
'ok' => false,
|
|
'error' => 'sso_disabled',
|
|
]);
|
|
|
|
$service = $this->newService($tenantSsoService, $tenantGateway, null, $stateStore);
|
|
$result = $service->handleCallback('state-ok', 'code-ok');
|
|
|
|
$this->assertFalse($result['ok']);
|
|
$this->assertSame('sso_disabled', $result['error']);
|
|
}
|
|
|
|
public function testHandleCallbackReturnsTokenEndpointMissingWhenDiscoveryMetadataIsIncomplete(): void
|
|
{
|
|
$service = $this->newServiceForTokenFlow([
|
|
'status' => 200,
|
|
'data' => json_encode(['issuer' => 'https://issuer.example']) ?: '{}',
|
|
]);
|
|
|
|
$result = $service->handleCallback('state-ok', 'code-ok');
|
|
|
|
$this->assertFalse($result['ok']);
|
|
$this->assertSame('token_endpoint_missing', $result['error']);
|
|
}
|
|
|
|
public function testHandleCallbackReturnsTokenExchangeFailedWhenTokenCallIsNotSuccessful(): void
|
|
{
|
|
$service = $this->newServiceForTokenFlow(
|
|
[
|
|
'status' => 200,
|
|
'data' => json_encode([
|
|
'token_endpoint' => 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token',
|
|
'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
|
|
'jwks_uri' => 'https://login.microsoftonline.com/tenant/discovery/v2.0/keys',
|
|
]) ?: '{}',
|
|
],
|
|
[
|
|
'status' => 401,
|
|
'data' => json_encode(['error' => 'invalid_grant']) ?: '{}',
|
|
]
|
|
);
|
|
|
|
$result = $service->handleCallback('state-ok', 'code-ok');
|
|
|
|
$this->assertFalse($result['ok']);
|
|
$this->assertSame('token_exchange_failed', $result['error']);
|
|
}
|
|
|
|
public function testHandleCallbackReturnsTokenResponseInvalidWhenBodyIsNotJson(): void
|
|
{
|
|
$service = $this->newServiceForTokenFlow(
|
|
[
|
|
'status' => 200,
|
|
'data' => json_encode([
|
|
'token_endpoint' => 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token',
|
|
'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
|
|
'jwks_uri' => 'https://login.microsoftonline.com/tenant/discovery/v2.0/keys',
|
|
]) ?: '{}',
|
|
],
|
|
[
|
|
'status' => 200,
|
|
'data' => 'not-json',
|
|
]
|
|
);
|
|
|
|
$result = $service->handleCallback('state-ok', 'code-ok');
|
|
|
|
$this->assertFalse($result['ok']);
|
|
$this->assertSame('token_response_invalid', $result['error']);
|
|
}
|
|
|
|
public function testHandleCallbackReturnsIdTokenMissingWhenTokenPayloadHasNoIdToken(): void
|
|
{
|
|
$service = $this->newServiceForTokenFlow(
|
|
[
|
|
'status' => 200,
|
|
'data' => json_encode([
|
|
'token_endpoint' => 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token',
|
|
'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
|
|
'jwks_uri' => 'https://login.microsoftonline.com/tenant/discovery/v2.0/keys',
|
|
]) ?: '{}',
|
|
],
|
|
[
|
|
'status' => 200,
|
|
'data' => json_encode(['access_token' => 'access']) ?: '{}',
|
|
]
|
|
);
|
|
|
|
$result = $service->handleCallback('state-ok', 'code-ok');
|
|
|
|
$this->assertFalse($result['ok']);
|
|
$this->assertSame('id_token_missing', $result['error']);
|
|
}
|
|
|
|
public function testHandleCallbackReturnsValidationErrorWhenIdTokenIsMalformed(): void
|
|
{
|
|
$service = $this->newServiceForTokenFlow(
|
|
[
|
|
'status' => 200,
|
|
'data' => json_encode([
|
|
'token_endpoint' => 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token',
|
|
'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
|
|
'jwks_uri' => 'https://login.microsoftonline.com/tenant/discovery/v2.0/keys',
|
|
]) ?: '{}',
|
|
],
|
|
[
|
|
'status' => 200,
|
|
'data' => json_encode(['id_token' => 'not-a-jwt']) ?: '{}',
|
|
]
|
|
);
|
|
|
|
$result = $service->handleCallback('state-ok', 'code-ok');
|
|
|
|
$this->assertFalse($result['ok']);
|
|
$this->assertSame('id_token_format_invalid', $result['error']);
|
|
}
|
|
|
|
public function testHandleCallbackReturnsClaimsForValidToken(): void
|
|
{
|
|
[$idToken, $jwk] = $this->buildValidIdToken('client-id', 'tenant-id-123', 'nonce-123');
|
|
|
|
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
|
|
$stateStore->method('consume')->willReturn([
|
|
'ok' => true,
|
|
'entry' => [
|
|
'tenant_id' => 12,
|
|
'nonce' => 'nonce-123',
|
|
'code_verifier' => 'verifier-123',
|
|
'redirect_uri' => 'https://app.example/auth/microsoft/callback',
|
|
'locale' => 'de',
|
|
],
|
|
]);
|
|
|
|
$tenantGateway = $this->createMock(AuthTenantGateway::class);
|
|
$tenantGateway->expects($this->once())
|
|
->method('findById')
|
|
->with(12)
|
|
->willReturn([
|
|
'id' => 12,
|
|
'status' => 'active',
|
|
'uuid' => 'tenant-uuid',
|
|
]);
|
|
|
|
$tenantSsoService = $this->createMock(TenantSsoService::class);
|
|
$tenantSsoService->method('getEffectiveMicrosoftProviderConfig')->willReturn([
|
|
'ok' => true,
|
|
'config' => [
|
|
'authority' => 'https://login.microsoftonline.com/tenant-id-123/v2.0',
|
|
'client_id' => 'client-id',
|
|
'client_secret' => 'client-secret',
|
|
'tenant_id' => 'tenant-id-123',
|
|
'allowed_domains' => 'example.com',
|
|
'sync_profile_on_login' => 0,
|
|
'sync_profile_fields' => [],
|
|
],
|
|
]);
|
|
$tenantSsoService->method('isEmailDomainAllowed')->willReturn(true);
|
|
$tenantSsoService->method('normalizeProfileSyncFields')->willReturn([]);
|
|
|
|
$oidcGateway = $this->createMock(OidcHttpGateway::class);
|
|
$oidcGateway->expects($this->exactly(3))
|
|
->method('call')
|
|
->willReturnOnConsecutiveCalls(
|
|
[
|
|
'status' => 200,
|
|
'data' => json_encode([
|
|
'token_endpoint' => 'https://login.microsoftonline.com/tenant-id-123/oauth2/v2.0/token',
|
|
'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
|
|
'jwks_uri' => 'https://login.microsoftonline.com/tenant-id-123/discovery/v2.0/keys',
|
|
]) ?: '{}',
|
|
],
|
|
[
|
|
'status' => 200,
|
|
'data' => json_encode([
|
|
'id_token' => $idToken,
|
|
'access_token' => '',
|
|
]) ?: '{}',
|
|
],
|
|
[
|
|
'status' => 200,
|
|
'data' => json_encode(['keys' => [$jwk]]) ?: '{}',
|
|
],
|
|
);
|
|
|
|
$service = $this->newService($tenantSsoService, $tenantGateway, $oidcGateway, $stateStore);
|
|
$result = $service->handleCallback('state-ok', 'code-ok');
|
|
|
|
$this->assertTrue($result['ok']);
|
|
$this->assertSame('de', (string) ($result['locale'] ?? ''));
|
|
$this->assertSame('user@example.com', (string) (($result['claims'] ?? [])['email'] ?? ''));
|
|
$this->assertSame('oid-123', (string) (($result['claims'] ?? [])['oid'] ?? ''));
|
|
$this->assertSame('sub-123', (string) (($result['claims'] ?? [])['subject'] ?? ''));
|
|
$this->assertSame('tenant-id-123', (string) (($result['claims'] ?? [])['tid'] ?? ''));
|
|
}
|
|
|
|
private function newServiceForTokenFlow(array $discoveryResponse, ?array $tokenResponse = null): MicrosoftOidcService
|
|
{
|
|
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
|
|
$stateStore->method('consume')->willReturn([
|
|
'ok' => true,
|
|
'entry' => [
|
|
'tenant_id' => 8,
|
|
'nonce' => 'nonce-1',
|
|
'code_verifier' => 'verifier-1',
|
|
'redirect_uri' => 'https://app.example/auth/microsoft/callback',
|
|
'locale' => 'de',
|
|
],
|
|
]);
|
|
|
|
$tenantGateway = $this->createMock(AuthTenantGateway::class);
|
|
$tenantGateway->method('findById')->willReturn([
|
|
'id' => 8,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$tenantSsoService = $this->createMock(TenantSsoService::class);
|
|
$tenantSsoService->method('getEffectiveMicrosoftProviderConfig')->willReturn([
|
|
'ok' => true,
|
|
'config' => [
|
|
'authority' => 'https://login.microsoftonline.com/tenant/v2.0',
|
|
'client_id' => 'client-id',
|
|
'client_secret' => 'client-secret',
|
|
'tenant_id' => 'tenant-id-123',
|
|
'allowed_domains' => 'example.com',
|
|
'sync_profile_on_login' => 0,
|
|
'sync_profile_fields' => [],
|
|
],
|
|
]);
|
|
$tenantSsoService->method('normalizeProfileSyncFields')->willReturn([]);
|
|
|
|
$oidcGateway = $this->createMock(OidcHttpGateway::class);
|
|
if ($tokenResponse === null) {
|
|
$oidcGateway->method('call')->willReturn($discoveryResponse);
|
|
} else {
|
|
$oidcGateway->method('call')->willReturnOnConsecutiveCalls($discoveryResponse, $tokenResponse);
|
|
}
|
|
|
|
return $this->newService($tenantSsoService, $tenantGateway, $oidcGateway, $stateStore);
|
|
}
|
|
|
|
private function newService(
|
|
?TenantSsoService $tenantSsoService = null,
|
|
?AuthTenantGateway $tenantGateway = null,
|
|
?OidcHttpGateway $oidcGateway = null,
|
|
?MicrosoftOidcStateStoreService $stateStore = null
|
|
): MicrosoftOidcService {
|
|
if ($tenantSsoService === null) {
|
|
$tenantSsoService = $this->createMock(TenantSsoService::class);
|
|
$tenantSsoService->method('normalizeProfileSyncFields')->willReturn([]);
|
|
$tenantSsoService->method('profileSyncFieldsRequireGraph')->willReturn(false);
|
|
$tenantSsoService->method('defaultProfileSyncFields')->willReturn(['first_name', 'last_name']);
|
|
$tenantSsoService->method('isEmailDomainAllowed')->willReturn(true);
|
|
$tenantSsoService->method('getEffectiveMicrosoftProviderConfig')->willReturn([
|
|
'ok' => true,
|
|
'config' => [
|
|
'authority' => 'https://login.microsoftonline.com/common/v2.0',
|
|
'client_id' => 'client-id',
|
|
'client_secret' => 'client-secret',
|
|
'tenant_id' => 'tenant-id-123',
|
|
'allowed_domains' => 'example.com',
|
|
'sync_profile_on_login' => 0,
|
|
'sync_profile_fields' => [],
|
|
],
|
|
]);
|
|
}
|
|
|
|
$tenantGateway = $tenantGateway ?? $this->createMock(AuthTenantGateway::class);
|
|
$oidcGateway = $oidcGateway ?? $this->createMock(OidcHttpGateway::class);
|
|
$stateStore = $stateStore ?? $this->createMock(MicrosoftOidcStateStoreService::class);
|
|
|
|
return new MicrosoftOidcService(
|
|
$tenantSsoService,
|
|
$tenantGateway,
|
|
$oidcGateway,
|
|
$stateStore
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{0:string,1:array<string,string>}
|
|
*/
|
|
private function buildValidIdToken(string $clientId, string $tenantId, string $nonce): array
|
|
{
|
|
$key = openssl_pkey_new([
|
|
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
|
'private_key_bits' => 2048,
|
|
]);
|
|
if ($key === false) {
|
|
$this->fail('Unable to generate RSA key for OIDC token test');
|
|
}
|
|
|
|
$details = openssl_pkey_get_details($key);
|
|
if (!is_array($details) || !is_array($details['rsa'] ?? null)) {
|
|
$this->fail('Unable to read RSA key details');
|
|
}
|
|
|
|
$header = ['alg' => 'RS256', 'kid' => 'kid-123', 'typ' => 'JWT'];
|
|
$claims = [
|
|
'aud' => $clientId,
|
|
'nonce' => $nonce,
|
|
'tid' => $tenantId,
|
|
'iss' => 'https://login.microsoftonline.com/' . $tenantId . '/v2.0',
|
|
'exp' => time() + 300,
|
|
'nbf' => time() - 10,
|
|
'oid' => 'oid-123',
|
|
'sub' => 'sub-123',
|
|
'email' => 'user@example.com',
|
|
'name' => 'Example User',
|
|
];
|
|
|
|
$headerJson = json_encode($header);
|
|
$claimsJson = json_encode($claims);
|
|
if (!is_string($headerJson) || !is_string($claimsJson)) {
|
|
$this->fail('Unable to encode token payload');
|
|
}
|
|
|
|
$encodedHeader = self::base64UrlEncode($headerJson);
|
|
$encodedClaims = self::base64UrlEncode($claimsJson);
|
|
$signingInput = $encodedHeader . '.' . $encodedClaims;
|
|
|
|
$signature = '';
|
|
$signed = openssl_sign($signingInput, $signature, $key, OPENSSL_ALGO_SHA256);
|
|
if (!$signed) {
|
|
$this->fail('Unable to sign token payload');
|
|
}
|
|
|
|
$rsa = $details['rsa'];
|
|
$modulus = is_string($rsa['n'] ?? null) ? $rsa['n'] : '';
|
|
$exponent = is_string($rsa['e'] ?? null) ? $rsa['e'] : '';
|
|
if ($modulus === '' || $exponent === '') {
|
|
$this->fail('Missing RSA modulus/exponent');
|
|
}
|
|
|
|
return [
|
|
$signingInput . '.' . self::base64UrlEncode($signature),
|
|
[
|
|
'kid' => 'kid-123',
|
|
'n' => self::base64UrlEncode($modulus),
|
|
'e' => self::base64UrlEncode($exponent),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function queryFromUrl(string $url): array
|
|
{
|
|
$parts = parse_url($url);
|
|
if (!is_array($parts)) {
|
|
return [];
|
|
}
|
|
|
|
$query = [];
|
|
parse_str((string) ($parts['query'] ?? ''), $query);
|
|
return array_map(static fn ($value): string => (string) $value, $query);
|
|
}
|
|
|
|
private static function base64UrlEncode(string $value): string
|
|
{
|
|
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
|
}
|
|
}
|