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} */ 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 */ 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), '+/', '-_'), '='); } }