fastmcp 2.10.6__py3-none-any.whl → 2.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. fastmcp/cli/cli.py +128 -33
  2. fastmcp/cli/install/claude_code.py +42 -1
  3. fastmcp/cli/install/claude_desktop.py +42 -1
  4. fastmcp/cli/install/cursor.py +42 -1
  5. fastmcp/cli/install/mcp_json.py +41 -0
  6. fastmcp/cli/run.py +127 -1
  7. fastmcp/client/__init__.py +2 -0
  8. fastmcp/client/auth/oauth.py +68 -99
  9. fastmcp/client/oauth_callback.py +18 -0
  10. fastmcp/client/transports.py +69 -15
  11. fastmcp/contrib/component_manager/example.py +2 -2
  12. fastmcp/experimental/server/openapi/README.md +266 -0
  13. fastmcp/experimental/server/openapi/__init__.py +38 -0
  14. fastmcp/experimental/server/openapi/components.py +348 -0
  15. fastmcp/experimental/server/openapi/routing.py +132 -0
  16. fastmcp/experimental/server/openapi/server.py +466 -0
  17. fastmcp/experimental/utilities/openapi/README.md +239 -0
  18. fastmcp/experimental/utilities/openapi/__init__.py +68 -0
  19. fastmcp/experimental/utilities/openapi/director.py +208 -0
  20. fastmcp/experimental/utilities/openapi/formatters.py +355 -0
  21. fastmcp/experimental/utilities/openapi/json_schema_converter.py +340 -0
  22. fastmcp/experimental/utilities/openapi/models.py +85 -0
  23. fastmcp/experimental/utilities/openapi/parser.py +618 -0
  24. fastmcp/experimental/utilities/openapi/schemas.py +538 -0
  25. fastmcp/mcp_config.py +125 -88
  26. fastmcp/prompts/prompt.py +11 -1
  27. fastmcp/resources/resource.py +21 -1
  28. fastmcp/resources/template.py +20 -1
  29. fastmcp/server/auth/__init__.py +17 -2
  30. fastmcp/server/auth/auth.py +144 -7
  31. fastmcp/server/auth/providers/bearer.py +25 -473
  32. fastmcp/server/auth/providers/in_memory.py +4 -2
  33. fastmcp/server/auth/providers/jwt.py +538 -0
  34. fastmcp/server/auth/providers/workos.py +170 -0
  35. fastmcp/server/auth/registry.py +52 -0
  36. fastmcp/server/context.py +107 -26
  37. fastmcp/server/dependencies.py +9 -2
  38. fastmcp/server/http.py +62 -30
  39. fastmcp/server/middleware/middleware.py +3 -23
  40. fastmcp/server/openapi.py +1 -1
  41. fastmcp/server/proxy.py +50 -11
  42. fastmcp/server/server.py +168 -59
  43. fastmcp/settings.py +73 -6
  44. fastmcp/tools/tool.py +36 -3
  45. fastmcp/tools/tool_manager.py +38 -2
  46. fastmcp/tools/tool_transform.py +112 -3
  47. fastmcp/utilities/components.py +35 -2
  48. fastmcp/utilities/json_schema.py +136 -98
  49. fastmcp/utilities/json_schema_type.py +1 -3
  50. fastmcp/utilities/mcp_config.py +28 -0
  51. fastmcp/utilities/openapi.py +240 -50
  52. fastmcp/utilities/tests.py +54 -6
  53. fastmcp/utilities/types.py +89 -11
  54. {fastmcp-2.10.6.dist-info → fastmcp-2.11.0.dist-info}/METADATA +4 -3
  55. fastmcp-2.11.0.dist-info/RECORD +108 -0
  56. fastmcp/server/auth/providers/bearer_env.py +0 -63
  57. fastmcp/utilities/cache.py +0 -26
  58. fastmcp-2.10.6.dist-info/RECORD +0 -93
  59. {fastmcp-2.10.6.dist-info → fastmcp-2.11.0.dist-info}/WHEEL +0 -0
  60. {fastmcp-2.10.6.dist-info → fastmcp-2.11.0.dist-info}/entry_points.txt +0 -0
  61. {fastmcp-2.10.6.dist-info → fastmcp-2.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -36,18 +36,20 @@ class InMemoryOAuthProvider(OAuthProvider):
36
36
 
37
37
  def __init__(
38
38
  self,
39
- issuer_url: AnyHttpUrl | str | None = None,
39
+ base_url: AnyHttpUrl | str | None = None,
40
40
  service_documentation_url: AnyHttpUrl | str | None = None,
41
41
  client_registration_options: ClientRegistrationOptions | None = None,
42
42
  revocation_options: RevocationOptions | None = None,
43
43
  required_scopes: list[str] | None = None,
44
+ resource_server_url: AnyHttpUrl | str | None = None,
44
45
  ):
45
46
  super().__init__(
46
- issuer_url=issuer_url or "http://fastmcp.example.com",
47
+ base_url=base_url or "http://fastmcp.example.com",
47
48
  service_documentation_url=service_documentation_url,
48
49
  client_registration_options=client_registration_options,
49
50
  revocation_options=revocation_options,
50
51
  required_scopes=required_scopes,
52
+ resource_server_url=resource_server_url,
51
53
  )
52
54
  self.clients: dict[str, OAuthClientInformationFull] = {}
53
55
  self.auth_codes: dict[str, AuthorizationCode] = {}
@@ -0,0 +1,538 @@
1
+ """TokenVerifier implementations for FastMCP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Any, cast
8
+
9
+ import httpx
10
+ from authlib.jose import JsonWebKey, JsonWebToken
11
+ from authlib.jose.errors import JoseError
12
+ from cryptography.hazmat.primitives import serialization
13
+ from cryptography.hazmat.primitives.asymmetric import rsa
14
+ from mcp.server.auth.provider import AccessToken
15
+ from pydantic import AnyHttpUrl, SecretStr
16
+ from pydantic_settings import BaseSettings, SettingsConfigDict
17
+ from typing_extensions import TypedDict
18
+
19
+ from fastmcp.server.auth.auth import TokenVerifier
20
+ from fastmcp.server.auth.registry import register_provider
21
+ from fastmcp.utilities.logging import get_logger
22
+ from fastmcp.utilities.types import NotSet, NotSetT
23
+
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ class JWKData(TypedDict, total=False):
28
+ """JSON Web Key data structure."""
29
+
30
+ kty: str # Key type (e.g., "RSA") - required
31
+ kid: str # Key ID (optional but recommended)
32
+ use: str # Usage (e.g., "sig")
33
+ alg: str # Algorithm (e.g., "RS256")
34
+ n: str # Modulus (for RSA keys)
35
+ e: str # Exponent (for RSA keys)
36
+ x5c: list[str] # X.509 certificate chain (for JWKs)
37
+ x5t: str # X.509 certificate thumbprint (for JWKs)
38
+
39
+
40
+ class JWKSData(TypedDict):
41
+ """JSON Web Key Set data structure."""
42
+
43
+ keys: list[JWKData]
44
+
45
+
46
+ @dataclass(frozen=True, kw_only=True, repr=False)
47
+ class RSAKeyPair:
48
+ """RSA key pair for JWT testing."""
49
+
50
+ private_key: SecretStr
51
+ public_key: str
52
+
53
+ @classmethod
54
+ def generate(cls) -> RSAKeyPair:
55
+ """
56
+ Generate an RSA key pair for testing.
57
+
58
+ Returns:
59
+ RSAKeyPair: Generated key pair
60
+ """
61
+ # Generate private key
62
+ private_key = rsa.generate_private_key(
63
+ public_exponent=65537,
64
+ key_size=2048,
65
+ )
66
+
67
+ # Serialize private key to PEM format
68
+ private_pem = private_key.private_bytes(
69
+ encoding=serialization.Encoding.PEM,
70
+ format=serialization.PrivateFormat.PKCS8,
71
+ encryption_algorithm=serialization.NoEncryption(),
72
+ ).decode("utf-8")
73
+
74
+ # Serialize public key to PEM format
75
+ public_pem = (
76
+ private_key.public_key()
77
+ .public_bytes(
78
+ encoding=serialization.Encoding.PEM,
79
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
80
+ )
81
+ .decode("utf-8")
82
+ )
83
+
84
+ return cls(
85
+ private_key=SecretStr(private_pem),
86
+ public_key=public_pem,
87
+ )
88
+
89
+ def create_token(
90
+ self,
91
+ subject: str = "fastmcp-user",
92
+ issuer: str = "https://fastmcp.example.com",
93
+ audience: str | list[str] | None = None,
94
+ scopes: list[str] | None = None,
95
+ expires_in_seconds: int = 3600,
96
+ additional_claims: dict[str, Any] | None = None,
97
+ kid: str | None = None,
98
+ ) -> str:
99
+ """
100
+ Generate a test JWT token for testing purposes.
101
+
102
+ Args:
103
+ subject: Subject claim (usually user ID)
104
+ issuer: Issuer claim
105
+ audience: Audience claim - can be a string or list of strings (optional)
106
+ scopes: List of scopes to include
107
+ expires_in_seconds: Token expiration time in seconds
108
+ additional_claims: Any additional claims to include
109
+ kid: Key ID to include in header
110
+ """
111
+ import time
112
+
113
+ # Create header
114
+ header = {"alg": "RS256"}
115
+ if kid:
116
+ header["kid"] = kid
117
+
118
+ # Create payload
119
+ payload = {
120
+ "sub": subject,
121
+ "iss": issuer,
122
+ "iat": int(time.time()),
123
+ "exp": int(time.time()) + expires_in_seconds,
124
+ }
125
+
126
+ if audience:
127
+ payload["aud"] = audience
128
+
129
+ if scopes:
130
+ payload["scope"] = " ".join(scopes)
131
+
132
+ if additional_claims:
133
+ payload.update(additional_claims)
134
+
135
+ # Create JWT
136
+ jwt_lib = JsonWebToken(["RS256"])
137
+ token_bytes = jwt_lib.encode(
138
+ header, payload, self.private_key.get_secret_value()
139
+ )
140
+
141
+ return token_bytes.decode("utf-8")
142
+
143
+
144
+ class JWTVerifierSettings(BaseSettings):
145
+ """Settings for JWT token verification."""
146
+
147
+ model_config = SettingsConfigDict(
148
+ env_prefix="FASTMCP_SERVER_AUTH_JWT_",
149
+ env_file=".env",
150
+ extra="ignore",
151
+ )
152
+
153
+ public_key: str | None = None
154
+ jwks_uri: str | None = None
155
+ issuer: str | None = None
156
+ algorithm: str | None = None
157
+ audience: str | list[str] | None = None
158
+ required_scopes: list[str] | None = None
159
+ resource_server_url: AnyHttpUrl | str | None = None
160
+
161
+
162
+ @register_provider("JWT")
163
+ class JWTVerifier(TokenVerifier):
164
+ """
165
+ JWT token verifier using public key or JWKS.
166
+
167
+ This verifier validates JWT tokens signed by an external issuer. It's ideal for
168
+ scenarios where you have a centralized identity provider (like Auth0, Okta, or
169
+ your own OAuth server) that issues JWTs, and your FastMCP server acts as a
170
+ resource server validating those tokens.
171
+
172
+ Use this when:
173
+ - You have JWT tokens issued by an external service
174
+ - You want asymmetric key verification (public/private key pairs)
175
+ - You need JWKS support for automatic key rotation
176
+ - Your tokens contain standard OAuth scopes and claims
177
+ """
178
+
179
+ def __init__(
180
+ self,
181
+ *,
182
+ public_key: str | None | NotSetT = NotSet,
183
+ jwks_uri: str | None | NotSetT = NotSet,
184
+ issuer: str | None | NotSetT = NotSet,
185
+ audience: str | list[str] | None | NotSetT = NotSet,
186
+ algorithm: str | None | NotSetT = NotSet,
187
+ required_scopes: list[str] | None | NotSetT = NotSet,
188
+ resource_server_url: AnyHttpUrl | str | None | NotSetT = NotSet,
189
+ ):
190
+ """
191
+ Initialize the JWT token verifier.
192
+
193
+ Args:
194
+ public_key: PEM-encoded public key for verification
195
+ jwks_uri: URI to fetch JSON Web Key Set
196
+ issuer: Expected issuer claim
197
+ audience: Expected audience claim(s)
198
+ algorithm: JWT signing algorithm (default: RS256)
199
+ required_scopes: Required scopes for all tokens
200
+ resource_server_url: Resource server URL for TokenVerifier protocol
201
+ """
202
+ settings = JWTVerifierSettings.model_validate(
203
+ {
204
+ k: v
205
+ for k, v in {
206
+ "public_key": public_key,
207
+ "jwks_uri": jwks_uri,
208
+ "issuer": issuer,
209
+ "audience": audience,
210
+ "algorithm": algorithm,
211
+ "required_scopes": required_scopes,
212
+ "resource_server_url": resource_server_url,
213
+ }.items()
214
+ if v is not NotSet
215
+ }
216
+ )
217
+
218
+ if not settings.public_key and not settings.jwks_uri:
219
+ raise ValueError("Either public_key or jwks_uri must be provided")
220
+
221
+ if settings.public_key and settings.jwks_uri:
222
+ raise ValueError("Provide either public_key or jwks_uri, not both")
223
+
224
+ algorithm = settings.algorithm or "RS256"
225
+ if algorithm not in {
226
+ "HS256",
227
+ "HS384",
228
+ "HS512",
229
+ "RS256",
230
+ "RS384",
231
+ "RS512",
232
+ "ES256",
233
+ "ES384",
234
+ "ES512",
235
+ "PS256",
236
+ "PS384",
237
+ "PS512",
238
+ }:
239
+ raise ValueError(f"Unsupported algorithm: {algorithm}.")
240
+
241
+ # Initialize parent TokenVerifier
242
+ super().__init__(
243
+ resource_server_url=settings.resource_server_url,
244
+ required_scopes=settings.required_scopes,
245
+ )
246
+
247
+ self.algorithm = algorithm
248
+ self.issuer = settings.issuer
249
+ self.audience = settings.audience
250
+ self.public_key = settings.public_key
251
+ self.jwks_uri = settings.jwks_uri
252
+ self.jwt = JsonWebToken([self.algorithm])
253
+ self.logger = get_logger(__name__)
254
+
255
+ # Simple JWKS cache
256
+ self._jwks_cache: dict[str, str] = {}
257
+ self._jwks_cache_time: float = 0
258
+ self._cache_ttl = 3600 # 1 hour
259
+
260
+ async def _get_verification_key(self, token: str) -> str:
261
+ """Get the verification key for the token."""
262
+ if self.public_key:
263
+ return self.public_key
264
+
265
+ # Extract kid from token header for JWKS lookup
266
+ try:
267
+ import base64
268
+ import json
269
+
270
+ header_b64 = token.split(".")[0]
271
+ header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding
272
+ header = json.loads(base64.urlsafe_b64decode(header_b64))
273
+ kid = header.get("kid")
274
+
275
+ return await self._get_jwks_key(kid)
276
+
277
+ except Exception as e:
278
+ raise ValueError(f"Failed to extract key ID from token: {e}")
279
+
280
+ async def _get_jwks_key(self, kid: str | None) -> str:
281
+ """Fetch key from JWKS with simple caching."""
282
+ if not self.jwks_uri:
283
+ raise ValueError("JWKS URI not configured")
284
+
285
+ current_time = time.time()
286
+
287
+ # Check cache first
288
+ if current_time - self._jwks_cache_time < self._cache_ttl:
289
+ if kid and kid in self._jwks_cache:
290
+ return self._jwks_cache[kid]
291
+ elif not kid and len(self._jwks_cache) == 1:
292
+ # If no kid but only one key cached, use it
293
+ return next(iter(self._jwks_cache.values()))
294
+
295
+ # Fetch JWKS
296
+ try:
297
+ async with httpx.AsyncClient() as client:
298
+ response = await client.get(self.jwks_uri)
299
+ response.raise_for_status()
300
+ jwks_data = response.json()
301
+
302
+ # Cache all keys
303
+ self._jwks_cache = {}
304
+ for key_data in jwks_data.get("keys", []):
305
+ key_kid = key_data.get("kid")
306
+ jwk = JsonWebKey.import_key(key_data)
307
+ public_key = jwk.get_public_key() # type: ignore
308
+
309
+ if key_kid:
310
+ self._jwks_cache[key_kid] = public_key
311
+ else:
312
+ # Key without kid - use a default identifier
313
+ self._jwks_cache["_default"] = public_key
314
+
315
+ self._jwks_cache_time = current_time
316
+
317
+ # Select the appropriate key
318
+ if kid:
319
+ if kid not in self._jwks_cache:
320
+ self.logger.debug(
321
+ "JWKS key lookup failed: key ID '%s' not found", kid
322
+ )
323
+ raise ValueError(f"Key ID '{kid}' not found in JWKS")
324
+ return self._jwks_cache[kid]
325
+ else:
326
+ # No kid in token - only allow if there's exactly one key
327
+ if len(self._jwks_cache) == 1:
328
+ return next(iter(self._jwks_cache.values()))
329
+ elif len(self._jwks_cache) > 1:
330
+ raise ValueError(
331
+ "Multiple keys in JWKS but no key ID (kid) in token"
332
+ )
333
+ else:
334
+ raise ValueError("No keys found in JWKS")
335
+
336
+ except httpx.HTTPError as e:
337
+ raise ValueError(f"Failed to fetch JWKS: {e}")
338
+ except Exception as e:
339
+ self.logger.debug(f"JWKS fetch failed: {e}")
340
+ raise ValueError(f"Failed to fetch JWKS: {e}")
341
+
342
+ def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
343
+ """
344
+ Extract scopes from JWT claims. Supports both 'scope' and 'scp'
345
+ claims.
346
+
347
+ Checks the `scope` claim first (standard OAuth2 claim), then the `scp`
348
+ claim (used by some Identity Providers).
349
+ """
350
+ for claim in ["scope", "scp"]:
351
+ if claim in claims:
352
+ if isinstance(claims[claim], str):
353
+ return claims[claim].split()
354
+ elif isinstance(claims[claim], list):
355
+ return claims[claim]
356
+
357
+ return []
358
+
359
+ async def load_access_token(self, token: str) -> AccessToken | None:
360
+ """
361
+ Validates the provided JWT bearer token.
362
+
363
+ Args:
364
+ token: The JWT token string to validate
365
+
366
+ Returns:
367
+ AccessToken object if valid, None if invalid or expired
368
+ """
369
+ try:
370
+ # Get verification key (static or from JWKS)
371
+ verification_key = await self._get_verification_key(token)
372
+
373
+ # Decode and verify the JWT token
374
+ claims = self.jwt.decode(token, verification_key)
375
+
376
+ # Extract client ID early for logging
377
+ client_id = claims.get("client_id") or claims.get("sub") or "unknown"
378
+
379
+ # Validate expiration
380
+ exp = claims.get("exp")
381
+ if exp and exp < time.time():
382
+ self.logger.debug(
383
+ "Token validation failed: expired token for client %s", client_id
384
+ )
385
+ self.logger.info("Bearer token rejected for client %s", client_id)
386
+ return None
387
+
388
+ # Validate issuer - note we use issuer instead of issuer_url here because
389
+ # issuer is optional, allowing users to make this check optional
390
+ if self.issuer:
391
+ if claims.get("iss") != self.issuer:
392
+ self.logger.debug(
393
+ "Token validation failed: issuer mismatch for client %s",
394
+ client_id,
395
+ )
396
+ self.logger.info("Bearer token rejected for client %s", client_id)
397
+ return None
398
+
399
+ # Validate audience if configured
400
+ if self.audience:
401
+ aud = claims.get("aud")
402
+
403
+ # Handle different combinations of audience types
404
+ audience_valid = False
405
+ if isinstance(self.audience, list):
406
+ # self.audience is a list - check if any expected audience is present
407
+ if isinstance(aud, list):
408
+ # Both are lists - check for intersection
409
+ audience_valid = any(
410
+ expected in aud for expected in self.audience
411
+ )
412
+ else:
413
+ # aud is a string - check if it's in our expected list
414
+ audience_valid = aud in cast(list, self.audience)
415
+ else:
416
+ # self.audience is a string - use original logic
417
+ if isinstance(aud, list):
418
+ audience_valid = self.audience in aud
419
+ else:
420
+ audience_valid = aud == self.audience
421
+
422
+ if not audience_valid:
423
+ self.logger.debug(
424
+ "Token validation failed: audience mismatch for client %s",
425
+ client_id,
426
+ )
427
+ self.logger.info("Bearer token rejected for client %s", client_id)
428
+ return None
429
+
430
+ # Extract scopes
431
+ scopes = self._extract_scopes(claims)
432
+
433
+ # Check required scopes
434
+ if self.required_scopes:
435
+ token_scopes = set(scopes)
436
+ required_scopes = set(self.required_scopes)
437
+ if not required_scopes.issubset(token_scopes):
438
+ self.logger.debug(
439
+ "Token missing required scopes. Has: %s, Required: %s",
440
+ token_scopes,
441
+ required_scopes,
442
+ )
443
+ self.logger.info("Bearer token rejected for client %s", client_id)
444
+ return None
445
+
446
+ return AccessToken(
447
+ token=token,
448
+ client_id=str(client_id),
449
+ scopes=scopes,
450
+ expires_at=int(exp) if exp else None,
451
+ )
452
+
453
+ except JoseError:
454
+ self.logger.debug("Token validation failed: JWT signature/format invalid")
455
+ return None
456
+ except Exception as e:
457
+ self.logger.debug("Token validation failed: %s", str(e))
458
+ return None
459
+
460
+ async def verify_token(self, token: str) -> AccessToken | None:
461
+ """
462
+ Verify a bearer token and return access info if valid.
463
+
464
+ This method implements the TokenVerifier protocol by delegating
465
+ to our existing load_access_token method.
466
+
467
+ Args:
468
+ token: The JWT token string to validate
469
+
470
+ Returns:
471
+ AccessToken object if valid, None if invalid or expired
472
+ """
473
+ return await self.load_access_token(token)
474
+
475
+
476
+ class StaticTokenVerifier(TokenVerifier):
477
+ """
478
+ Simple static token verifier for testing and development.
479
+
480
+ This verifier validates tokens against a predefined dictionary of valid token
481
+ strings and their associated claims. When a token string matches a key in the
482
+ dictionary, the verifier returns the corresponding claims as if the token was
483
+ validated by a real authorization server.
484
+
485
+ Use this when:
486
+ - You're developing or testing locally without a real OAuth server
487
+ - You need predictable tokens for automated testing
488
+ - You want to simulate different users/scopes without complex setup
489
+ - You're prototyping and need simple API key-style authentication
490
+
491
+ WARNING: Never use this in production - tokens are stored in plain text!
492
+ """
493
+
494
+ def __init__(
495
+ self,
496
+ tokens: dict[str, dict[str, Any]],
497
+ required_scopes: list[str] | None = None,
498
+ ):
499
+ """
500
+ Initialize the static token verifier.
501
+
502
+ Args:
503
+ tokens: Dict mapping token strings to token metadata
504
+ Each token should have: client_id, scopes, expires_at (optional)
505
+ required_scopes: Required scopes for all tokens
506
+ """
507
+ super().__init__(required_scopes=required_scopes)
508
+ self.tokens = tokens
509
+
510
+ async def verify_token(self, token: str) -> AccessToken | None:
511
+ """Verify token against static token dictionary."""
512
+ token_data = self.tokens.get(token)
513
+ if not token_data:
514
+ return None
515
+
516
+ # Check expiration if present
517
+ expires_at = token_data.get("expires_at")
518
+ if expires_at is not None and expires_at < time.time():
519
+ return None
520
+
521
+ scopes = token_data.get("scopes", [])
522
+
523
+ # Check required scopes
524
+ if self.required_scopes:
525
+ token_scopes = set(scopes)
526
+ required_scopes = set(self.required_scopes)
527
+ if not required_scopes.issubset(token_scopes):
528
+ logger.debug(
529
+ f"Token missing required scopes. Has: {token_scopes}, Required: {required_scopes}"
530
+ )
531
+ return None
532
+
533
+ return AccessToken(
534
+ token=token,
535
+ client_id=token_data["client_id"],
536
+ scopes=scopes,
537
+ expires_at=expires_at,
538
+ )