Files
breadcrumb-the-shire/lib/Service/Auth/MicrosoftOidcService.php

525 lines
20 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Service\Auth;
use MintyPHP\Http\Request;
use MintyPHP\I18n;
class MicrosoftOidcService
{
2026-03-06 00:44:52 +01:00
// 120s tolerance on exp/nbf checks to handle minor clock drift between systems.
private const CLOCK_SKEW = 120;
2026-02-23 12:58:19 +01:00
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'];
}
2026-02-23 12:58:19 +01:00
$oidc = $this->fetchOpenIdConfiguration((string) ($providerConfig['authority'] ?? ''));
if (!($oidc['ok'] ?? false)) {
return ['ok' => false, 'error' => 'oidc_discovery_failed'];
}
$metadata = $oidc['config'] ?? [];
2026-03-06 00:44:52 +01:00
// 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));
2026-02-23 12:58:19 +01:00
$this->stateStore->prune();
$this->stateStore->store($state, [
'tenant_id' => $tenantId,
'nonce' => $nonce,
'code_verifier' => $codeVerifier,
'locale' => $locale,
'redirect_uri' => $redirectUri,
2026-02-23 12:58:19 +01:00
]);
$params = [
'client_id' => (string) ($providerConfig['client_id'] ?? ''),
'response_type' => 'code',
'redirect_uri' => $redirectUri,
'response_mode' => 'query',
2026-02-23 12:58:19 +01:00
'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),
];
}
2026-02-23 12:58:19 +01:00
public function handleCallback(string $state, string $code): array
{
$state = trim($state);
$code = trim($code);
if ($state === '' || $code === '') {
return ['ok' => false, 'error' => 'callback_invalid'];
}
2026-02-23 12:58:19 +01:00
$consumeResult = $this->stateStore->consume($state);
if (!$consumeResult['ok']) {
return ['ok' => false, 'error' => (string) ($consumeResult['error'] ?? 'state_invalid')];
}
2026-02-23 12:58:19 +01:00
$entry = is_array($consumeResult['entry'] ?? null) ? $consumeResult['entry'] : [];
$tenantId = (int) ($entry['tenant_id'] ?? 0);
2026-02-23 12:58:19 +01:00
$tenant = $this->tenantGateway->findById($tenantId);
if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') {
return ['ok' => false, 'error' => 'tenant_not_found'];
}
2026-02-23 12:58:19 +01:00
$configResult = $this->tenantSsoService->getEffectiveMicrosoftProviderConfig($tenant);
if (!($configResult['ok'] ?? false)) {
return ['ok' => false, 'error' => (string) ($configResult['error'] ?? 'sso_config_invalid')];
}
$providerConfig = $configResult['config'] ?? [];
2026-02-23 12:58:19 +01:00
$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'];
}
2026-02-23 12:58:19 +01:00
$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'] ?? ''));
2026-02-23 12:58:19 +01:00
$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))));
2026-02-23 12:58:19 +01:00
if (!$this->tenantSsoService->isEmailDomainAllowed($email, $allowedDomains)) {
return ['ok' => false, 'error' => 'email_domain_not_allowed'];
}
$syncEnabled = !empty($providerConfig['sync_profile_on_login']);
2026-02-23 12:58:19 +01:00
$syncFields = $this->tenantSsoService->normalizeProfileSyncFields($providerConfig['sync_profile_fields'] ?? []);
if ($syncEnabled && !$syncFields) {
2026-02-23 12:58:19 +01:00
$syncFields = $this->tenantSsoService->defaultProfileSyncFields();
}
$graphProfile = [];
2026-02-23 12:58:19 +01:00
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 !== '') {
2026-02-23 12:58:19 +01:00
$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_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,
],
];
}
2026-02-23 12:58:19 +01:00
private function buildAuthorizationScope(array $providerConfig): string
{
$scopes = ['openid', 'profile', 'email'];
$syncEnabled = !empty($providerConfig['sync_profile_on_login']);
2026-02-23 12:58:19 +01:00
$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);
}
2026-02-23 12:58:19 +01:00
private function fetchOpenIdConfiguration(string $authority): array
{
$authority = trim($authority);
if ($authority === '') {
return ['ok' => false];
}
$url = rtrim($authority, '/') . '/.well-known/openid-configuration';
2026-02-23 12:58:19 +01:00
$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];
}
2026-02-23 12:58:19 +01:00
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'];
}
2026-02-23 12:58:19 +01:00
$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 '';
}
2026-02-23 12:58:19 +01:00
private function fetchMicrosoftGraphProfile(string $accessToken): array
{
$url = 'https://graph.microsoft.com/v1.0/me?$select=givenName,surname,mobilePhone,businessPhones';
2026-02-23 12:58:19 +01:00
$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'] ?? '')),
'mobile' => trim((string) ($data['mobilePhone'] ?? '')),
'phone' => $phone,
],
];
}
2026-02-23 12:58:19 +01:00
private function fetchMicrosoftGraphAvatar(string $accessToken): array
{
$url = 'https://graph.microsoft.com/v1.0/me/photo/$value';
2026-02-23 12:58:19 +01:00
$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 : '';
}
2026-03-06 00:44:52 +01:00
// 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;
}
}