*/ class MicrosoftJwtTokenValidator implements AuthorizationTokenValidatorInterface { /** * @param JwtTokenValidator $jwtTokenValidator Base JWT validator used for non-Graph tokens * @param string $scopeClaim Claim name for scopes in Graph tokens * @param list $trustedGraphIssuers Allowed Graph issuer host markers * @param int $notBeforeLeewaySeconds Allowed clock skew for "nbf" claim */ public function __construct( private readonly JwtTokenValidator $jwtTokenValidator, private readonly string $scopeClaim = 'scp', private readonly array $trustedGraphIssuers = ['sts.windows.net', 'login.microsoftonline.com'], private readonly int $notBeforeLeewaySeconds = 60, ) { } public function validate(string $accessToken): AuthorizationResult { $parts = explode('.', $accessToken); if (!$this->isGraphToken($parts)) { return $this->jwtTokenValidator->validate($accessToken); } return $this->validateGraphToken($parts); } /** * Validates a token has the required scopes. * * Use this after validation to check specific scope requirements. * * @param AuthorizationResult $result The result from validate() * @param list $requiredScopes Scopes required for this operation * * @return AuthorizationResult The original result if scopes are sufficient, forbidden otherwise */ public function requireScopes(AuthorizationResult $result, array $requiredScopes): AuthorizationResult { return $this->jwtTokenValidator->requireScopes($result, $requiredScopes); } /** * @param array $parts */ private function isGraphToken(array $parts): bool { if ([] === $parts) { return false; } $header = $this->decodePartToArray($parts[0]); if (null === $header) { return false; } return isset($header['nonce']); } /** * @param array $parts */ private function validateGraphToken(array $parts): AuthorizationResult { // Intentionally claim-based only for example Graph token compatibility. if (\count($parts) < 2) { return AuthorizationResult::unauthorized('invalid_token', 'Invalid token format.'); } $payload = $this->decodePartToArray($parts[1]); if (null === $payload) { return AuthorizationResult::unauthorized('invalid_token', 'Invalid token payload.'); } if (isset($payload['exp']) && is_numeric($payload['exp']) && (int) $payload['exp'] < time()) { return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.'); } if (isset($payload['nbf']) && is_numeric($payload['nbf']) && (int) $payload['nbf'] > time() + $this->notBeforeLeewaySeconds) { return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.'); } $issuer = $payload['iss'] ?? ''; if (!\is_string($issuer) || !$this->isTrustedGraphIssuer($issuer)) { return AuthorizationResult::unauthorized('invalid_token', 'Invalid token issuer for Graph token.'); } $scopes = $this->extractScopes($payload); $attributes = [ 'oauth.claims' => $payload, 'oauth.scopes' => $scopes, 'oauth.graph_token' => true, ]; if (isset($payload['sub'])) { $attributes['oauth.subject'] = $payload['sub']; } if (isset($payload['oid'])) { $attributes['oauth.object_id'] = $payload['oid']; } if (isset($payload['name'])) { $attributes['oauth.name'] = $payload['name']; } return AuthorizationResult::allow($attributes); } private function isTrustedGraphIssuer(string $issuer): bool { $host = parse_url($issuer, \PHP_URL_HOST); return \in_array($host, $this->trustedGraphIssuers, true); } /** * @param array $claims * * @return list */ private function extractScopes(array $claims): array { if (!isset($claims[$this->scopeClaim])) { return []; } $scopeValue = $claims[$this->scopeClaim]; if (\is_array($scopeValue)) { return array_values(array_filter($scopeValue, 'is_string')); } if (\is_string($scopeValue)) { return array_values(array_filter(explode(' ', $scopeValue))); } return []; } /** * @return array|null */ private function decodePartToArray(string $part): ?array { $decoded = base64_decode(strtr($part, '-_', '+/')); if (false === $decoded) { return null; } $data = json_decode($decoded, true); return \is_array($data) ? $data : null; } }