1: <?php
2:
3: declare(strict_types=1);
4:
5: /**
6: * This file is part of the Nexus MCP SDK package.
7: *
8: * (c) 2026 John Paul E. Balandan, CPA <paulbalandan@gmail.com>
9: *
10: * For the full copyright and license information, please view
11: * the LICENSE file that was distributed with this source code.
12: */
13:
14: namespace Nexus\Mcp\Core\Auth;
15:
16: use Nexus\Assert\Assert;
17:
18: /**
19: * An ordered, duplicate-free set of OAuth scope values.
20: *
21: * @see https://datatracker.ietf.org/doc/html/rfc6749#section-3.3
22: */
23: final readonly class ScopeSet
24: {
25: /**
26: * The scope that asks an authorization server for a refresh token.
27: *
28: * @see https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
29: */
30: public const string OFFLINE_ACCESS = 'offline_access';
31:
32: /**
33: * RFC 6749 section 3.3 `scope-token` syntax (`1*( %x21 / %x23-5B / %x5D-7E )`).
34: */
35: private const string SCOPE_TOKEN_PATTERN = '/\A[\x21\x23-\x5B\x5D-\x7E]+\z/';
36:
37: /**
38: * @var list<non-empty-string>
39: */
40: public array $values;
41:
42: /**
43: * @param list<non-empty-string> $values
44: */
45: public function __construct(array $values = [])
46: {
47: Assert::that($values)->values()->isNonEmptyString('Each scope must be a non-empty string, {type} given.');
48:
49: $this->values = array_values(array_unique($values));
50: }
51:
52: /**
53: * Parses a space-delimited `scope` parameter, treating an absent or blank one as the empty set.
54: *
55: * A segment that is not an RFC 6749 `scope-token` is dropped, so never build the *required* side of a
56: * `containsAll()` grant decision from this.
57: */
58: public static function parse(?string $scope): self
59: {
60: return null === $scope ? new self() : self::fromList(explode(' ', $scope));
61: }
62:
63: /**
64: * Builds a set from peer-supplied values, dropping each that is not an RFC 6749 `scope-token`.
65: *
66: * @param list<string> $values
67: */
68: public static function fromList(array $values): self
69: {
70: $kept = [];
71:
72: foreach ($values as $value) {
73: if (preg_match(self::SCOPE_TOKEN_PATTERN, $value) === 1) {
74: $kept[] = $value;
75: }
76: }
77:
78: return new self($kept);
79: }
80:
81: /**
82: * Accumulates another set onto this one, keeping this set's values first.
83: */
84: public function mergeWith(self $other): self
85: {
86: return new self([...$this->values, ...$other->values]);
87: }
88:
89: public function contains(string $scope): bool
90: {
91: return \in_array($scope, $this->values, true);
92: }
93:
94: /**
95: * This set with one value removed, whether or not it was present.
96: */
97: public function without(string $scope): self
98: {
99: return new self(array_values(array_filter($this->values, static fn(string $value): bool => $value !== $scope)));
100: }
101:
102: public function containsAll(self $other): bool
103: {
104: foreach ($other->values as $value) {
105: if (! $this->contains($value)) {
106: return false;
107: }
108: }
109:
110: return true;
111: }
112:
113: /**
114: * Renders the set as a `scope` parameter, or `null` when the set is empty and the parameter is omitted.
115: */
116: public function toParameter(): ?string
117: {
118: return [] === $this->values ? null : implode(' ', $this->values);
119: }
120: }
121: