Files
breadcrumb-the-shire/lib/Service/Auth/MicrosoftOidcService.php
fs 4e359fe659 feat(auth): sync display name and job title from Microsoft Graph, fix tenant SSO form handling
Fetches displayName/jobTitle from Graph API and syncs them via SSO profile sync. Also fixes LDAP enabled state in tenant form, adds LDAP save on tenant create, and hardens LDAP port validation precedence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 14:13:21 +02:00

529 lines
20 KiB
PHP

<?php
namespace MintyPHP\Service\Auth;
use MintyPHP\Http\Request;
use MintyPHP\I18n;
class MicrosoftOidcService
{
// 120s tolerance on exp/nbf checks to handle minor clock drift between systems.
private const CLOCK_SKEW = 120;
public function __construct(
private readonly TenantSsoService $tenantSsoService,
private readonly AuthTenantGateway $tenantGateway,
private readonly OidcHttpGateway $oidcHttpGateway,
private readonly MicrosoftOidcStateStoreService $stateStore
) {
}
public function startAuthorization(array $tenant, array $providerConfig): array
{
$tenantId = (int) ($tenant['id'] ?? 0);
if ($tenantId <= 0) {
return ['ok' => false, 'error' => 'tenant_not_found'];
}
$oidc = $this->fetchOpenIdConfiguration((string) ($providerConfig['authority'] ?? ''));
if (!($oidc['ok'] ?? false)) {
return ['ok' => false, 'error' => 'oidc_discovery_failed'];
}
$metadata = $oidc['config'] ?? [];
// PKCE flow: store code_verifier in session, send only the SHA-256 challenge to the AS.
$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));
$this->stateStore->prune();
$this->stateStore->store($state, [
'tenant_id' => $tenantId,
'nonce' => $nonce,
'code_verifier' => $codeVerifier,
'locale' => $locale,
'redirect_uri' => $redirectUri,
]);
$params = [
'client_id' => (string) ($providerConfig['client_id'] ?? ''),
'response_type' => 'code',
'redirect_uri' => $redirectUri,
'response_mode' => 'query',
'scope' => $this->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 function handleCallback(string $state, string $code): array
{
$state = trim($state);
$code = trim($code);
if ($state === '' || $code === '') {
return ['ok' => false, 'error' => 'callback_invalid'];
}
$consumeResult = $this->stateStore->consume($state);
if (!$consumeResult['ok']) {
return ['ok' => false, 'error' => (string) ($consumeResult['error'] ?? 'state_invalid')];
}
$entry = is_array($consumeResult['entry'] ?? null) ? $consumeResult['entry'] : [];
$tenantId = (int) ($entry['tenant_id'] ?? 0);
$tenant = $this->tenantGateway->findById($tenantId);
if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') {
return ['ok' => false, 'error' => 'tenant_not_found'];
}
$configResult = $this->tenantSsoService->getEffectiveMicrosoftProviderConfig($tenant);
if (!($configResult['ok'] ?? false)) {
return ['ok' => false, 'error' => (string) ($configResult['error'] ?? 'sso_config_invalid')];
}
$providerConfig = $configResult['config'] ?? [];
$oidc = $this->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 = $this->oidcHttpGateway->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 = $this->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 (!$this->tenantSsoService->isEmailDomainAllowed($email, $allowedDomains)) {
return ['ok' => false, 'error' => 'email_domain_not_allowed'];
}
$syncEnabled = !empty($providerConfig['sync_profile_on_login']);
$syncFields = $this->tenantSsoService->normalizeProfileSyncFields($providerConfig['sync_profile_fields'] ?? []);
if ($syncEnabled && !$syncFields) {
$syncFields = $this->tenantSsoService->defaultProfileSyncFields();
}
$graphProfile = [];
if ($syncEnabled && $this->tenantSsoService->profileSyncFieldsRequireGraph($syncFields) && $accessToken !== '') {
$graph = $this->fetchMicrosoftGraphProfile($accessToken);
if (!empty($graph['ok'])) {
$graphProfile = is_array($graph['profile'] ?? null) ? $graph['profile'] : [];
}
}
$graphAvatar = [];
if ($syncEnabled && in_array('avatar', $syncFields, true) && $accessToken !== '') {
$avatar = $this->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_display_name' => trim((string) ($graphProfile['display_name'] ?? '')),
'graph_job_title' => trim((string) ($graphProfile['job_title'] ?? '')),
'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 function buildAuthorizationScope(array $providerConfig): string
{
$scopes = ['openid', 'profile', 'email'];
$syncEnabled = !empty($providerConfig['sync_profile_on_login']);
$syncFields = $this->tenantSsoService->normalizeProfileSyncFields($providerConfig['sync_profile_fields'] ?? []);
if ($syncEnabled && $this->tenantSsoService->profileSyncFieldsRequireGraph($syncFields)) {
$scopes[] = 'User.Read';
}
$scopes = array_values(array_unique($scopes));
return implode(' ', $scopes);
}
private function fetchOpenIdConfiguration(string $authority): array
{
$authority = trim($authority);
if ($authority === '') {
return ['ok' => false];
}
$url = rtrim($authority, '/') . '/.well-known/openid-configuration';
$response = $this->oidcHttpGateway->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 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 = $this->oidcHttpGateway->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 function fetchMicrosoftGraphProfile(string $accessToken): array
{
$url = 'https://graph.microsoft.com/v1.0/me?$select=givenName,surname,displayName,jobTitle,mobilePhone,businessPhones';
$response = $this->oidcHttpGateway->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'] ?? '')),
'display_name' => trim((string) ($data['displayName'] ?? '')),
'job_title' => trim((string) ($data['jobTitle'] ?? '')),
'mobile' => trim((string) ($data['mobilePhone'] ?? '')),
'phone' => $phone,
],
];
}
private function fetchMicrosoftGraphAvatar(string $accessToken): array
{
$url = 'https://graph.microsoft.com/v1.0/me/photo/$value';
$response = $this->oidcHttpGateway->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 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 : '';
}
// Builds a PEM public key from a JWK RSA key (n, e) by constructing the ASN.1 DER structure manually.
// PHP's openssl extension doesn't support importing JWK directly, so we encode it ourselves.
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;
}
}