Files
breadcrumb-the-shire/tests/Service/Auth/MicrosoftOidcServiceTest.php

645 lines
26 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'] ?? ''));
}
public function testHandleCallbackReturnsEmailDomainNotAllowedWhenTenantDomainPolicyRejectsUser(): 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' => 'allowed.example.com',
'sync_profile_on_login' => 0,
'sync_profile_fields' => [],
],
]);
$tenantSsoService->method('normalizeProfileSyncFields')->willReturn([]);
$tenantSsoService->expects($this->once())
->method('isEmailDomainAllowed')
->with('user@example.com', ['allowed.example.com'])
->willReturn(false);
$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->assertFalse($result['ok']);
$this->assertSame('email_domain_not_allowed', $result['error']);
}
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), '+/', '-_'), '=');
}
}