false, 'error' => 'tenant_not_found']; } $oidc = self::fetchOpenIdConfiguration((string) ($providerConfig['authority'] ?? '')); if (!($oidc['ok'] ?? false)) { return ['ok' => false, 'error' => 'oidc_discovery_failed']; } $metadata = $oidc['config'] ?? []; $state = self::randomString(32); $nonce = self::randomString(32); $codeVerifier = self::randomString(64); $codeChallenge = self::base64UrlEncode(hash('sha256', $codeVerifier, true)); $locale = I18n::$locale ?? I18n::$defaultLocale ?? 'de'; $redirectUri = appUrl(Request::withLocale('auth/microsoft/callback', $locale)); self::pruneStates(); $_SESSION[self::SESSION_KEY][$state] = [ 'tenant_id' => $tenantId, 'nonce' => $nonce, 'code_verifier' => $codeVerifier, 'locale' => $locale, 'redirect_uri' => $redirectUri, 'created_at' => time(), ]; $params = [ 'client_id' => (string) ($providerConfig['client_id'] ?? ''), 'response_type' => 'code', 'redirect_uri' => $redirectUri, 'response_mode' => 'query', 'scope' => self::buildAuthorizationScope($providerConfig), 'state' => $state, 'nonce' => $nonce, 'code_challenge' => $codeChallenge, 'code_challenge_method' => 'S256', ]; $authorizeEndpoint = (string) ($metadata['authorization_endpoint'] ?? ''); if ($authorizeEndpoint === '') { return ['ok' => false, 'error' => 'authorize_endpoint_missing']; } return [ 'ok' => true, 'url' => $authorizeEndpoint . '?' . http_build_query($params), ]; } public static function handleCallback(string $state, string $code): array { $state = trim($state); $code = trim($code); if ($state === '' || $code === '') { return ['ok' => false, 'error' => 'callback_invalid']; } $states = $_SESSION[self::SESSION_KEY] ?? []; $entry = is_array($states[$state] ?? null) ? $states[$state] : null; if (!$entry) { return ['ok' => false, 'error' => 'state_invalid']; } unset($_SESSION[self::SESSION_KEY][$state]); $createdAt = (int) ($entry['created_at'] ?? 0); if ($createdAt <= 0 || (time() - $createdAt) > self::STATE_TTL) { return ['ok' => false, 'error' => 'state_expired']; } $tenantId = (int) ($entry['tenant_id'] ?? 0); $tenant = TenantRepository::find($tenantId); if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') { return ['ok' => false, 'error' => 'tenant_not_found']; } $configResult = TenantSsoService::getEffectiveMicrosoftProviderConfig($tenant); if (!($configResult['ok'] ?? false)) { return ['ok' => false, 'error' => (string) ($configResult['error'] ?? 'sso_config_invalid')]; } $providerConfig = $configResult['config'] ?? []; $oidc = self::fetchOpenIdConfiguration((string) ($providerConfig['authority'] ?? '')); if (!($oidc['ok'] ?? false)) { return ['ok' => false, 'error' => 'oidc_discovery_failed']; } $metadata = $oidc['config'] ?? []; $tokenEndpoint = (string) ($metadata['token_endpoint'] ?? ''); if ($tokenEndpoint === '') { return ['ok' => false, 'error' => 'token_endpoint_missing']; } $tokenResponse = Curl::call('POST', $tokenEndpoint, [ 'grant_type' => 'authorization_code', 'client_id' => (string) ($providerConfig['client_id'] ?? ''), 'client_secret' => (string) ($providerConfig['client_secret'] ?? ''), 'code' => $code, 'redirect_uri' => (string) ($entry['redirect_uri'] ?? ''), 'code_verifier' => (string) ($entry['code_verifier'] ?? ''), ], [ 'Accept' => 'application/json', ]); if ((int) ($tokenResponse['status'] ?? 0) < 200 || (int) ($tokenResponse['status'] ?? 0) >= 300) { return ['ok' => false, 'error' => 'token_exchange_failed']; } $tokenData = json_decode((string) ($tokenResponse['data'] ?? ''), true); if (!is_array($tokenData)) { return ['ok' => false, 'error' => 'token_response_invalid']; } $idToken = trim((string) ($tokenData['id_token'] ?? '')); if ($idToken === '') { return ['ok' => false, 'error' => 'id_token_missing']; } $accessToken = trim((string) ($tokenData['access_token'] ?? '')); $validated = self::validateIdToken( $idToken, $providerConfig, $metadata, (string) ($entry['nonce'] ?? '') ); if (!($validated['ok'] ?? false)) { return ['ok' => false, 'error' => (string) ($validated['error'] ?? 'id_token_invalid')]; } $claims = $validated['claims'] ?? []; $email = self::extractEmailFromClaims($claims); if ($email === '') { return ['ok' => false, 'error' => 'email_missing']; } $allowedDomainsCsv = (string) ($providerConfig['allowed_domains'] ?? ''); $allowedDomains = array_values(array_filter(array_map('trim', explode(',', $allowedDomainsCsv)))); if (!TenantSsoService::isEmailDomainAllowed($email, $allowedDomains)) { return ['ok' => false, 'error' => 'email_domain_not_allowed']; } $syncEnabled = !empty($providerConfig['sync_profile_on_login']); $syncFields = TenantSsoService::normalizeProfileSyncFields($providerConfig['sync_profile_fields'] ?? []); if ($syncEnabled && !$syncFields) { $syncFields = TenantSsoService::defaultProfileSyncFields(); } $graphProfile = []; if ($syncEnabled && TenantSsoService::profileSyncFieldsRequireGraph($syncFields) && $accessToken !== '') { $graph = self::fetchMicrosoftGraphProfile($accessToken); if (!empty($graph['ok'])) { $graphProfile = is_array($graph['profile'] ?? null) ? $graph['profile'] : []; } } $graphAvatar = []; if ($syncEnabled && in_array('avatar', $syncFields, true) && $accessToken !== '') { $avatar = self::fetchMicrosoftGraphAvatar($accessToken); if (!empty($avatar['ok'])) { $graphAvatar = is_array($avatar['avatar'] ?? null) ? $avatar['avatar'] : []; } } return [ 'ok' => true, 'tenant' => $tenant, 'locale' => (string) ($entry['locale'] ?? (I18n::$locale ?? I18n::$defaultLocale ?? 'de')), 'graph_profile' => $graphProfile, 'graph_avatar' => $graphAvatar, 'sync_config' => [ 'enabled' => $syncEnabled, 'fields' => $syncFields, ], 'claims' => [ 'tid' => (string) ($claims['tid'] ?? ''), 'oid' => (string) ($claims['oid'] ?? ''), 'issuer' => (string) ($claims['iss'] ?? ''), 'subject' => (string) ($claims['sub'] ?? ''), 'email' => $email, 'name' => trim((string) ($claims['name'] ?? '')), 'given_name' => trim((string) ($claims['given_name'] ?? '')), 'family_name' => trim((string) ($claims['family_name'] ?? '')), 'preferred_language' => trim((string) ($claims['preferred_language'] ?? '')), 'graph_given_name' => trim((string) ($graphProfile['given_name'] ?? '')), 'graph_family_name' => trim((string) ($graphProfile['family_name'] ?? '')), 'graph_phone' => trim((string) ($graphProfile['phone'] ?? '')), 'graph_mobile' => trim((string) ($graphProfile['mobile'] ?? '')), 'graph_avatar_mime' => trim((string) ($graphAvatar['mime'] ?? '')), 'graph_avatar_data_base64' => trim((string) ($graphAvatar['data_base64'] ?? '')), 'sync_profile_on_login' => $syncEnabled ? 1 : 0, 'sync_profile_fields' => $syncFields, ], ]; } private static function buildAuthorizationScope(array $providerConfig): string { $scopes = ['openid', 'profile', 'email']; $syncEnabled = !empty($providerConfig['sync_profile_on_login']); $syncFields = TenantSsoService::normalizeProfileSyncFields($providerConfig['sync_profile_fields'] ?? []); if ($syncEnabled && TenantSsoService::profileSyncFieldsRequireGraph($syncFields)) { $scopes[] = 'User.Read'; } $scopes = array_values(array_unique($scopes)); return implode(' ', $scopes); } private static function fetchOpenIdConfiguration(string $authority): array { $authority = trim($authority); if ($authority === '') { return ['ok' => false]; } $url = rtrim($authority, '/') . '/.well-known/openid-configuration'; $response = Curl::call('GET', $url, '', ['Accept' => 'application/json']); if ((int) ($response['status'] ?? 0) < 200 || (int) ($response['status'] ?? 0) >= 300) { return ['ok' => false]; } $config = json_decode((string) ($response['data'] ?? ''), true); if (!is_array($config)) { return ['ok' => false]; } return ['ok' => true, 'config' => $config]; } private static function validateIdToken( string $idToken, array $providerConfig, array $metadata, string $expectedNonce ): array { $parts = explode('.', $idToken); if (count($parts) !== 3) { return ['ok' => false, 'error' => 'id_token_format_invalid']; } $header = json_decode(self::base64UrlDecode($parts[0]), true); $claims = json_decode(self::base64UrlDecode($parts[1]), true); $signature = self::base64UrlDecode($parts[2]); if (!is_array($header) || !is_array($claims) || $signature === '') { return ['ok' => false, 'error' => 'id_token_parse_failed']; } $alg = strtoupper(trim((string) ($header['alg'] ?? ''))); if ($alg !== 'RS256') { return ['ok' => false, 'error' => 'id_token_alg_invalid']; } $jwksUri = trim((string) ($metadata['jwks_uri'] ?? '')); if ($jwksUri === '') { return ['ok' => false, 'error' => 'jwks_uri_missing']; } $jwksResponse = Curl::call('GET', $jwksUri, '', ['Accept' => 'application/json']); if ((int) ($jwksResponse['status'] ?? 0) < 200 || (int) ($jwksResponse['status'] ?? 0) >= 300) { return ['ok' => false, 'error' => 'jwks_fetch_failed']; } $jwks = json_decode((string) ($jwksResponse['data'] ?? ''), true); if (!is_array($jwks) || !is_array($jwks['keys'] ?? null)) { return ['ok' => false, 'error' => 'jwks_invalid']; } $kid = (string) ($header['kid'] ?? ''); $jwk = null; foreach ($jwks['keys'] as $key) { if (!is_array($key)) { continue; } if ((string) ($key['kid'] ?? '') === $kid) { $jwk = $key; break; } } if (!$jwk) { return ['ok' => false, 'error' => 'jwk_not_found']; } $pem = self::jwkToPem($jwk); if ($pem === null) { return ['ok' => false, 'error' => 'jwk_invalid']; } $verified = openssl_verify( $parts[0] . '.' . $parts[1], $signature, $pem, OPENSSL_ALGO_SHA256 ); if ($verified !== 1) { return ['ok' => false, 'error' => 'id_token_signature_invalid']; } $now = time(); $exp = (int) ($claims['exp'] ?? 0); if ($exp <= 0 || $exp < ($now - self::CLOCK_SKEW)) { return ['ok' => false, 'error' => 'id_token_expired']; } $nbf = (int) ($claims['nbf'] ?? 0); if ($nbf > 0 && $nbf > ($now + self::CLOCK_SKEW)) { return ['ok' => false, 'error' => 'id_token_not_yet_valid']; } $audience = $claims['aud'] ?? ''; $clientId = (string) ($providerConfig['client_id'] ?? ''); $audValid = false; if (is_array($audience)) { $audValid = in_array($clientId, $audience, true); } else { $audValid = (string) $audience === $clientId; } if (!$audValid) { return ['ok' => false, 'error' => 'id_token_aud_invalid']; } if ((string) ($claims['nonce'] ?? '') !== $expectedNonce) { return ['ok' => false, 'error' => 'id_token_nonce_invalid']; } $tid = strtolower((string) ($claims['tid'] ?? '')); $expectedTid = strtolower((string) ($providerConfig['tenant_id'] ?? '')); if ($expectedTid === '' || $tid !== $expectedTid) { return ['ok' => false, 'error' => 'id_token_tid_invalid']; } $iss = strtolower((string) ($claims['iss'] ?? '')); $issuerTemplate = strtolower((string) ($metadata['issuer'] ?? '')); if ($issuerTemplate !== '') { $expectedIssuer = str_replace('{tenantid}', $tid, $issuerTemplate); if ($iss !== $expectedIssuer) { return ['ok' => false, 'error' => 'id_token_iss_invalid']; } } if ((string) ($claims['oid'] ?? '') === '' || (string) ($claims['sub'] ?? '') === '') { return ['ok' => false, 'error' => 'id_token_identity_invalid']; } return ['ok' => true, 'claims' => $claims]; } private static function extractEmailFromClaims(array $claims): string { $email = trim((string) ($claims['email'] ?? '')); if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { return strtolower($email); } $upn = trim((string) ($claims['preferred_username'] ?? '')); if ($upn !== '' && filter_var($upn, FILTER_VALIDATE_EMAIL)) { return strtolower($upn); } return ''; } private static function fetchMicrosoftGraphProfile(string $accessToken): array { $url = 'https://graph.microsoft.com/v1.0/me?$select=givenName,surname,mobilePhone,businessPhones'; $response = Curl::call('GET', $url, '', [ 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $accessToken, ]); $status = (int) ($response['status'] ?? 0); if ($status < 200 || $status >= 300) { return ['ok' => false]; } $data = json_decode((string) ($response['data'] ?? ''), true); if (!is_array($data)) { return ['ok' => false]; } $phone = ''; $businessPhones = $data['businessPhones'] ?? []; if (is_array($businessPhones) && isset($businessPhones[0])) { $phone = trim((string) $businessPhones[0]); } return [ 'ok' => true, 'profile' => [ 'given_name' => trim((string) ($data['givenName'] ?? '')), 'family_name' => trim((string) ($data['surname'] ?? '')), 'mobile' => trim((string) ($data['mobilePhone'] ?? '')), 'phone' => $phone, ], ]; } private static function fetchMicrosoftGraphAvatar(string $accessToken): array { $url = 'https://graph.microsoft.com/v1.0/me/photo/$value'; $response = Curl::call('GET', $url, '', [ 'Accept' => 'image/*', 'Authorization' => 'Bearer ' . $accessToken, ]); $status = (int) ($response['status'] ?? 0); if ($status < 200 || $status >= 300) { return ['ok' => false]; } $data = $response['data'] ?? null; if (!is_string($data) || $data === '') { return ['ok' => false]; } $contentType = self::headerValue($response, 'Content-Type'); $contentType = strtolower(trim(explode(';', $contentType, 2)[0])); if (!in_array($contentType, ['image/jpeg', 'image/png', 'image/webp'], true)) { return ['ok' => false]; } return [ 'ok' => true, 'avatar' => [ 'mime' => $contentType, 'data_base64' => base64_encode($data), ], ]; } private static function headerValue(array $response, string $name): string { $headers = is_array($response['headers'] ?? null) ? $response['headers'] : []; foreach ($headers as $key => $value) { if (strcasecmp((string) $key, $name) === 0) { return is_string($value) ? $value : ''; } } return ''; } private static function pruneStates(): void { $states = $_SESSION[self::SESSION_KEY] ?? []; if (!is_array($states)) { $_SESSION[self::SESSION_KEY] = []; return; } $now = time(); foreach ($states as $state => $payload) { $createdAt = (int) (($payload['created_at'] ?? 0)); if ($createdAt <= 0 || ($now - $createdAt) > self::STATE_TTL) { unset($states[$state]); } } $_SESSION[self::SESSION_KEY] = $states; } private static function randomString(int $bytes): string { return self::base64UrlEncode(random_bytes($bytes)); } private static function base64UrlEncode(string $value): string { return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); } private static function base64UrlDecode(string $value): string { $value = strtr($value, '-_', '+/'); $pad = strlen($value) % 4; if ($pad > 0) { $value .= str_repeat('=', 4 - $pad); } $decoded = base64_decode($value, true); return is_string($decoded) ? $decoded : ''; } private static function jwkToPem(array $jwk): ?string { $n = self::base64UrlDecode((string) ($jwk['n'] ?? '')); $e = self::base64UrlDecode((string) ($jwk['e'] ?? '')); if ($n === '' || $e === '') { return null; } $modulus = self::asn1Integer($n); $publicExponent = self::asn1Integer($e); $rsaPublicKey = self::asn1Sequence($modulus . $publicExponent); $algo = self::asn1Sequence( self::asn1ObjectIdentifier("\x2A\x86\x48\x86\xF7\x0D\x01\x01\x01") . self::asn1Null() ); $bitString = "\x03" . self::asn1Length(strlen($rsaPublicKey) + 1) . "\x00" . $rsaPublicKey; $subjectPublicKeyInfo = self::asn1Sequence($algo . $bitString); $pem = "-----BEGIN PUBLIC KEY-----\n"; $pem .= chunk_split(base64_encode($subjectPublicKeyInfo), 64, "\n"); $pem .= "-----END PUBLIC KEY-----\n"; return $pem; } private static function asn1Integer(string $bytes): string { if (ord($bytes[0]) > 0x7F) { $bytes = "\x00" . $bytes; } return "\x02" . self::asn1Length(strlen($bytes)) . $bytes; } private static function asn1Sequence(string $bytes): string { return "\x30" . self::asn1Length(strlen($bytes)) . $bytes; } private static function asn1ObjectIdentifier(string $bytes): string { return "\x06" . self::asn1Length(strlen($bytes)) . $bytes; } private static function asn1Null(): string { return "\x05\x00"; } private static function asn1Length(int $length): string { if ($length < 128) { return chr($length); } $temp = ltrim(pack('N', $length), "\x00"); return chr(0x80 | strlen($temp)) . $temp; } }