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\Client\Auth;
15:
16: use Nexus\Assert\Assert;
17: use Nexus\Mcp\Core\Exception\RuntimeException;
18: use Nexus\Mcp\Core\Validation\SuggestedDependencyGuard;
19:
20: /**
21: * Token store that persists its tokens to one file, encrypted with XChaCha20-Poly1305.
22: */
23: final readonly class EncryptedFileTokenStore implements TokenStoreInterface
24: {
25: /**
26: * @param non-empty-string $path File the encrypted token map is kept in
27: * @param non-empty-string $key 32-byte secret, e.g. from `random_bytes(32)`
28: */
29: public function __construct(
30: private string $path,
31:
32: #[\SensitiveParameter]
33: private string $key,
34: ) {
35: SuggestedDependencyGuard::verifyExtension(self::class, 'sodium');
36:
37: Assert::that($path)->isNonEmptyString('Encrypted token store path must be a non-empty string.');
38: Assert::that(\strlen($key))->isIdentical(
39: \SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES,
40: 'Encrypted token store key must be exactly 32 bytes long, {value} given.',
41: );
42: }
43:
44: #[\Override]
45: public function read(string $resource): ?AccessToken
46: {
47: $entry = $this->readMap()[$resource] ?? null;
48:
49: return null === $entry ? null : $this->parseToken($entry);
50: }
51:
52: #[\Override]
53: public function write(string $resource, AccessToken $token): void
54: {
55: $map = $this->readMap();
56: $map[$resource] = [
57: 'value' => $token->value,
58: 'issuer' => $token->issuer,
59: 'expiresAt' => $token->expiresAt,
60: 'refreshToken' => $token->refreshToken,
61: 'scopes' => $token->scopes,
62: ];
63:
64: $this->saveMap($map);
65: }
66:
67: #[\Override]
68: public function forget(string $resource): void
69: {
70: if (! is_file($this->path)) {
71: return;
72: }
73:
74: $map = $this->readMap();
75: unset($map[$resource]);
76:
77: if ([] === $map) {
78: if (! @unlink($this->path)) {
79: throw $this->refuseWrite();
80: }
81:
82: return;
83: }
84:
85: $this->saveMap($map);
86: }
87:
88: /**
89: * @return array<array-key, mixed>
90: */
91: private function readMap(): array
92: {
93: if (! is_file($this->path)) {
94: return [];
95: }
96:
97: $raw = @file_get_contents($this->path);
98:
99: if (false === $raw) {
100: throw new RuntimeException(\sprintf('Encrypted token store could not read "%s".', $this->path));
101: }
102:
103: try {
104: $plain = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
105: substr($raw, \SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES),
106: '',
107: substr($raw, 0, \SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES),
108: $this->key,
109: );
110: } catch (\SodiumException) {
111: throw $this->refuseUnreadable();
112: }
113:
114: if (false === $plain) {
115: throw $this->refuseUnreadable();
116: }
117:
118: $map = json_decode($plain, associative: true);
119:
120: if (! \is_array($map)) {
121: throw $this->refuseUnreadable();
122: }
123:
124: return $map;
125: }
126:
127: private function parseToken(mixed $entry): AccessToken
128: {
129: if (! \is_array($entry)) {
130: throw $this->refuseUnreadable();
131: }
132:
133: $value = $entry['value'] ?? null;
134: $issuer = $entry['issuer'] ?? null;
135: $expiresAt = $entry['expiresAt'] ?? null;
136: $refreshToken = $entry['refreshToken'] ?? null;
137: $scopes = $entry['scopes'] ?? [];
138:
139: if (
140: ! \is_string($value)
141: || ! \is_string($issuer)
142: || (null !== $expiresAt && ! \is_int($expiresAt))
143: || (null !== $refreshToken && ! \is_string($refreshToken))
144: || ! \is_array($scopes)
145: ) {
146: throw $this->refuseUnreadable();
147: }
148:
149: $scopeList = [];
150:
151: foreach ($scopes as $scope) {
152: if (
153: ! \is_string($scope)
154: || '' === $scope
155: ) {
156: throw $this->refuseUnreadable();
157: }
158:
159: $scopeList[] = $scope;
160: }
161:
162: return new AccessToken($value, $issuer, $expiresAt, $refreshToken, $scopeList);
163: }
164:
165: /**
166: * @param array<array-key, mixed> $map
167: */
168: private function saveMap(array $map): void
169: {
170: try {
171: $payload = json_encode($map, \JSON_THROW_ON_ERROR);
172: } catch (\JsonException $e) {
173: throw $this->refuseWrite($e);
174: }
175:
176: $nonce = random_bytes(\SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
177: $cipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($payload, '', $nonce, $this->key);
178:
179: $temp = \sprintf('%s.%s.tmp', $this->path, bin2hex(random_bytes(8)));
180:
181: if (
182: false === @file_put_contents($temp, '')
183: || ! @chmod($temp, 0o600)
184: || false === @file_put_contents($temp, $nonce.$cipher)
185: || ! @rename($temp, $this->path)
186: ) {
187: @unlink($temp);
188:
189: throw $this->refuseWrite();
190: }
191: }
192:
193: private function refuseUnreadable(): RuntimeException
194: {
195: return new RuntimeException(\sprintf(
196: 'Encrypted token store file "%s" is not a token map written with the configured key.',
197: $this->path,
198: ));
199: }
200:
201: private function refuseWrite(?\JsonException $cause = null): RuntimeException
202: {
203: return new RuntimeException(\sprintf('Encrypted token store could not write "%s".', $this->path), previous: $cause);
204: }
205: }
206: