mailerbot-mcp 0.1.0__tar.gz
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.
- mailerbot_mcp-0.1.0/.gitignore +7 -0
- mailerbot_mcp-0.1.0/Dockerfile +24 -0
- mailerbot_mcp-0.1.0/PKG-INFO +99 -0
- mailerbot_mcp-0.1.0/README.md +74 -0
- mailerbot_mcp-0.1.0/pyproject.toml +50 -0
- mailerbot_mcp-0.1.0/src/mailerbot_mcp/__init__.py +83 -0
- mailerbot_mcp-0.1.0/src/mailerbot_mcp/__main__.py +5 -0
- mailerbot_mcp-0.1.0/src/mailerbot_mcp/auth.py +123 -0
- mailerbot_mcp-0.1.0/src/mailerbot_mcp/server.py +1342 -0
- mailerbot_mcp-0.1.0/tests/test_import.py +9 -0
- mailerbot_mcp-0.1.0/uv.lock +988 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
FROM python:3.13-slim
|
|
2
|
+
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
|
|
5
|
+
# Install uv for fast dependency resolution
|
|
6
|
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
|
7
|
+
|
|
8
|
+
# Copy SDK and MCP server source
|
|
9
|
+
COPY sdk/python /app/sdk/python
|
|
10
|
+
COPY mcp /app/mcp
|
|
11
|
+
|
|
12
|
+
# Install the MCP server (which pulls in the local SDK)
|
|
13
|
+
RUN uv pip install --system /app/sdk/python /app/mcp
|
|
14
|
+
|
|
15
|
+
EXPOSE 8080
|
|
16
|
+
|
|
17
|
+
# Default to streamable-http (current MCP standard) for remote deployment
|
|
18
|
+
ENV MAILERBOT_MCP_TRANSPORT=streamable-http
|
|
19
|
+
ENV MAILERBOT_MCP_HOST=0.0.0.0
|
|
20
|
+
ENV MAILERBOT_MCP_PORT=8080
|
|
21
|
+
# In Docker Compose, MAILERBOT_API_URL and MAILERBOT_OAUTH_ISSUER are set
|
|
22
|
+
# via environment or env_file to point at the backend service.
|
|
23
|
+
|
|
24
|
+
CMD ["mailerbot-mcp"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mailerbot-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for the MailerBot direct mail API
|
|
5
|
+
Project-URL: Homepage, https://mailerbot.com
|
|
6
|
+
Project-URL: Documentation, https://mailerbot.com/docs/mcp
|
|
7
|
+
Project-URL: Repository, https://github.com/mailerbot-hq/MailerBot/tree/dev/mcp
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/mailerbot-hq/MailerBot/issues
|
|
9
|
+
Author-email: MailerBot <dev@mailerbot.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
Keywords: ai,direct-mail,mailerbot,mcp,model-context-protocol
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: mailerbot>=0.1.0
|
|
23
|
+
Requires-Dist: mcp[cli]>=1.0
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# MailerBot MCP Server
|
|
27
|
+
|
|
28
|
+
MCP (Model Context Protocol) server for the MailerBot direct mail API. Enables AI tools like Claude, Cursor, and others to manage contacts, mailings, campaigns, and more.
|
|
29
|
+
|
|
30
|
+
## Remote server (recommended)
|
|
31
|
+
|
|
32
|
+
URL: `https://mcp.mailerbot.com/mcp`. OAuth is handled automatically — no API key to manage.
|
|
33
|
+
|
|
34
|
+
In Claude: **Settings > Connectors > Add custom connector**, then paste the URL above.
|
|
35
|
+
|
|
36
|
+
## Quick Start (Local / STDIO)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Set your API key
|
|
40
|
+
export MAILERBOT_API_KEY="mb_live_..."
|
|
41
|
+
|
|
42
|
+
# Run via uvx
|
|
43
|
+
uvx mailerbot-mcp
|
|
44
|
+
|
|
45
|
+
# Or run from source
|
|
46
|
+
cd mcp
|
|
47
|
+
uv run mailerbot-mcp
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Claude Desktop Configuration
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"mcpServers": {
|
|
55
|
+
"mailerbot": {
|
|
56
|
+
"command": "uvx",
|
|
57
|
+
"args": ["mailerbot-mcp"],
|
|
58
|
+
"env": {
|
|
59
|
+
"MAILERBOT_API_KEY": "mb_live_..."
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Self-Hosted (SSE)
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
docker build -t mailerbot-mcp -f mcp/Dockerfile .
|
|
70
|
+
docker run -p 8080:8080 \
|
|
71
|
+
-e MAILERBOT_MCP_TRANSPORT=sse \
|
|
72
|
+
-e MAILERBOT_BEARER_TOKEN="..." \
|
|
73
|
+
mailerbot-mcp
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Environment Variables
|
|
77
|
+
|
|
78
|
+
| Variable | Description | Default |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `MAILERBOT_API_KEY` | API key for authentication | — |
|
|
81
|
+
| `MAILERBOT_BEARER_TOKEN` | OAuth bearer token (alternative to API key) | — |
|
|
82
|
+
| `MAILERBOT_API_URL` | API base URL | `https://api.mailerbot.com/api/v1` |
|
|
83
|
+
| `MAILERBOT_MCP_TRANSPORT` | Transport mode: `stdio` or `sse` | `stdio` |
|
|
84
|
+
| `MAILERBOT_MCP_HOST` | Host to bind (SSE mode) | `0.0.0.0` |
|
|
85
|
+
| `MAILERBOT_MCP_PORT` | Port to bind (SSE mode) | `8080` |
|
|
86
|
+
|
|
87
|
+
## Available Tools
|
|
88
|
+
|
|
89
|
+
**Contacts** — list, get, create, update, delete, validate addresses
|
|
90
|
+
**Contact Lists** — list, get, create, update, delete, add/remove contacts
|
|
91
|
+
**Documents** — list, get, create, update, delete
|
|
92
|
+
**Postcards** — list templates, list, get, create, delete
|
|
93
|
+
**Mailings** — list, get, create, update, delete, validate addresses, estimate cost, calculate cost, send
|
|
94
|
+
**Campaigns** — list, get, create, update, delete, add/remove mailings
|
|
95
|
+
**Dashboard** — stats, reporting
|
|
96
|
+
**Pricing** — catalog, countries, postage zones, postage rates
|
|
97
|
+
**Coupons** — list, get, create, update, delete, list/import codes, check availability
|
|
98
|
+
**QR Tracking** — list, create, delete links, analytics
|
|
99
|
+
**Merge Tags** — list available tags
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# MailerBot MCP Server
|
|
2
|
+
|
|
3
|
+
MCP (Model Context Protocol) server for the MailerBot direct mail API. Enables AI tools like Claude, Cursor, and others to manage contacts, mailings, campaigns, and more.
|
|
4
|
+
|
|
5
|
+
## Remote server (recommended)
|
|
6
|
+
|
|
7
|
+
URL: `https://mcp.mailerbot.com/mcp`. OAuth is handled automatically — no API key to manage.
|
|
8
|
+
|
|
9
|
+
In Claude: **Settings > Connectors > Add custom connector**, then paste the URL above.
|
|
10
|
+
|
|
11
|
+
## Quick Start (Local / STDIO)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Set your API key
|
|
15
|
+
export MAILERBOT_API_KEY="mb_live_..."
|
|
16
|
+
|
|
17
|
+
# Run via uvx
|
|
18
|
+
uvx mailerbot-mcp
|
|
19
|
+
|
|
20
|
+
# Or run from source
|
|
21
|
+
cd mcp
|
|
22
|
+
uv run mailerbot-mcp
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Claude Desktop Configuration
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"mcpServers": {
|
|
30
|
+
"mailerbot": {
|
|
31
|
+
"command": "uvx",
|
|
32
|
+
"args": ["mailerbot-mcp"],
|
|
33
|
+
"env": {
|
|
34
|
+
"MAILERBOT_API_KEY": "mb_live_..."
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Self-Hosted (SSE)
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
docker build -t mailerbot-mcp -f mcp/Dockerfile .
|
|
45
|
+
docker run -p 8080:8080 \
|
|
46
|
+
-e MAILERBOT_MCP_TRANSPORT=sse \
|
|
47
|
+
-e MAILERBOT_BEARER_TOKEN="..." \
|
|
48
|
+
mailerbot-mcp
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Environment Variables
|
|
52
|
+
|
|
53
|
+
| Variable | Description | Default |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `MAILERBOT_API_KEY` | API key for authentication | — |
|
|
56
|
+
| `MAILERBOT_BEARER_TOKEN` | OAuth bearer token (alternative to API key) | — |
|
|
57
|
+
| `MAILERBOT_API_URL` | API base URL | `https://api.mailerbot.com/api/v1` |
|
|
58
|
+
| `MAILERBOT_MCP_TRANSPORT` | Transport mode: `stdio` or `sse` | `stdio` |
|
|
59
|
+
| `MAILERBOT_MCP_HOST` | Host to bind (SSE mode) | `0.0.0.0` |
|
|
60
|
+
| `MAILERBOT_MCP_PORT` | Port to bind (SSE mode) | `8080` |
|
|
61
|
+
|
|
62
|
+
## Available Tools
|
|
63
|
+
|
|
64
|
+
**Contacts** — list, get, create, update, delete, validate addresses
|
|
65
|
+
**Contact Lists** — list, get, create, update, delete, add/remove contacts
|
|
66
|
+
**Documents** — list, get, create, update, delete
|
|
67
|
+
**Postcards** — list templates, list, get, create, delete
|
|
68
|
+
**Mailings** — list, get, create, update, delete, validate addresses, estimate cost, calculate cost, send
|
|
69
|
+
**Campaigns** — list, get, create, update, delete, add/remove mailings
|
|
70
|
+
**Dashboard** — stats, reporting
|
|
71
|
+
**Pricing** — catalog, countries, postage zones, postage rates
|
|
72
|
+
**Coupons** — list, get, create, update, delete, list/import codes, check availability
|
|
73
|
+
**QR Tracking** — list, create, delete links, analytics
|
|
74
|
+
**Merge Tags** — list available tags
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mailerbot-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for the MailerBot direct mail API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "MailerBot", email = "dev@mailerbot.com" }]
|
|
13
|
+
keywords = ["mailerbot", "mcp", "model-context-protocol", "direct-mail", "ai"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"mcp[cli]>=1.0",
|
|
27
|
+
"mailerbot>=0.1.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
mailerbot-mcp = "mailerbot_mcp:main"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://mailerbot.com"
|
|
35
|
+
Documentation = "https://mailerbot.com/docs/mcp"
|
|
36
|
+
Repository = "https://github.com/mailerbot-hq/MailerBot/tree/dev/mcp"
|
|
37
|
+
"Bug Tracker" = "https://github.com/mailerbot-hq/MailerBot/issues"
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
dev = ["pytest"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/mailerbot_mcp"]
|
|
47
|
+
|
|
48
|
+
# For local development, use the SDK from the monorepo
|
|
49
|
+
[tool.uv.sources]
|
|
50
|
+
mailerbot = { path = "../sdk/python", editable = true }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from .server import mcp
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _configure_auth() -> None:
|
|
7
|
+
"""Configure OAuth authentication for remote (SSE/streamable-http) mode.
|
|
8
|
+
|
|
9
|
+
Sets up the MCP server as an OAuth Resource Server that:
|
|
10
|
+
- Points MCP clients to the MailerBot backend's OAuth endpoints
|
|
11
|
+
- Validates bearer tokens by calling the backend's /auth/me endpoint
|
|
12
|
+
- Passes valid tokens through to the SDK for API calls
|
|
13
|
+
"""
|
|
14
|
+
from pydantic import AnyHttpUrl
|
|
15
|
+
from mcp.server.auth.settings import AuthSettings
|
|
16
|
+
from .auth import MailerBotTokenVerifier
|
|
17
|
+
|
|
18
|
+
api_url = os.environ.get(
|
|
19
|
+
"MAILERBOT_API_URL", "https://api.mailerbot.com/api/v1"
|
|
20
|
+
)
|
|
21
|
+
# The OAuth issuer is the backend root (without /api/v1)
|
|
22
|
+
issuer_url = os.environ.get(
|
|
23
|
+
"MAILERBOT_OAUTH_ISSUER",
|
|
24
|
+
api_url.replace("/api/v1", ""),
|
|
25
|
+
)
|
|
26
|
+
# The MCP server's own public URL (for resource server metadata)
|
|
27
|
+
mcp_url = os.environ.get("MAILERBOT_MCP_URL", "http://localhost:8080")
|
|
28
|
+
|
|
29
|
+
mcp.settings.auth = AuthSettings(
|
|
30
|
+
issuer_url=AnyHttpUrl(issuer_url),
|
|
31
|
+
resource_server_url=AnyHttpUrl(mcp_url),
|
|
32
|
+
)
|
|
33
|
+
mcp._token_verifier = MailerBotTokenVerifier(api_base_url=api_url)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main():
|
|
37
|
+
transport = os.environ.get("MAILERBOT_MCP_TRANSPORT", "stdio")
|
|
38
|
+
|
|
39
|
+
if transport in ("sse", "streamable-http"):
|
|
40
|
+
import uvicorn
|
|
41
|
+
from urllib.parse import urlparse
|
|
42
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
43
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
44
|
+
|
|
45
|
+
_configure_auth()
|
|
46
|
+
host = os.environ.get("MAILERBOT_MCP_HOST", "0.0.0.0")
|
|
47
|
+
port = int(os.environ.get("MAILERBOT_MCP_PORT", "8080"))
|
|
48
|
+
|
|
49
|
+
# Configure transport security to allow the public-facing hostname.
|
|
50
|
+
# Behind an ALB the Host header is the public domain, not localhost.
|
|
51
|
+
mcp_url = os.environ.get("MAILERBOT_MCP_URL", "http://localhost:8080")
|
|
52
|
+
parsed = urlparse(mcp_url)
|
|
53
|
+
mcp.settings.transport_security = TransportSecuritySettings(
|
|
54
|
+
enable_dns_rebinding_protection=True,
|
|
55
|
+
allowed_hosts=[
|
|
56
|
+
parsed.netloc, # e.g. "mcp.mailerbot.com" or "localhost:8080"
|
|
57
|
+
"127.0.0.1:*",
|
|
58
|
+
"localhost:*",
|
|
59
|
+
],
|
|
60
|
+
allowed_origins=["*"],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Get the ASGI app from FastMCP and wrap with CORS middleware.
|
|
64
|
+
# This ensures OPTIONS preflight requests are handled before the
|
|
65
|
+
# auth middleware rejects them with 401.
|
|
66
|
+
if transport == "streamable-http":
|
|
67
|
+
app = mcp.streamable_http_app()
|
|
68
|
+
else:
|
|
69
|
+
app = mcp.sse_app()
|
|
70
|
+
|
|
71
|
+
app.add_middleware(
|
|
72
|
+
CORSMiddleware,
|
|
73
|
+
allow_origins=["*"],
|
|
74
|
+
allow_methods=["*"],
|
|
75
|
+
allow_headers=["*"],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
uvicorn.run(app, host=host, port=port)
|
|
79
|
+
else:
|
|
80
|
+
mcp.run(transport="stdio")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
__all__ = ["main", "mcp"]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Token verification for the MailerBot MCP server.
|
|
2
|
+
|
|
3
|
+
In SSE/streamable-http mode, MCP clients authenticate via OAuth against the
|
|
4
|
+
MailerBot backend. The MCP server acts as a Resource Server (RS) — it
|
|
5
|
+
validates incoming bearer tokens by calling the backend's ``/api/v1/auth/me``
|
|
6
|
+
endpoint. If the backend returns the user profile, the token is valid.
|
|
7
|
+
|
|
8
|
+
Verified tokens are cached in memory (keyed by token hash) to avoid a
|
|
9
|
+
backend round-trip on every MCP tool invocation.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Cache verified tokens for up to 5 minutes to reduce backend round-trips.
|
|
28
|
+
_TOKEN_CACHE_TTL = 300
|
|
29
|
+
_token_cache: dict[str, tuple[AccessToken, float]] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MailerBotTokenVerifier(TokenVerifier):
|
|
33
|
+
"""Verify bearer tokens by calling the MailerBot backend."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, api_base_url: str | None = None):
|
|
36
|
+
self.api_base_url = (api_base_url or os.environ.get(
|
|
37
|
+
"MAILERBOT_API_URL", "https://api.mailerbot.com/api/v1"
|
|
38
|
+
)).rstrip("/")
|
|
39
|
+
|
|
40
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
41
|
+
"""Verify a bearer token against the MailerBot backend.
|
|
42
|
+
|
|
43
|
+
Checks the local cache first; if not cached, calls GET
|
|
44
|
+
/api/v1/auth/me with the token. Valid tokens are cached to avoid
|
|
45
|
+
repeated round-trips for the same session.
|
|
46
|
+
"""
|
|
47
|
+
# Check cache first
|
|
48
|
+
cache_key = hashlib.sha256(token.encode()).hexdigest()[:16]
|
|
49
|
+
cached = _token_cache.get(cache_key)
|
|
50
|
+
if cached is not None:
|
|
51
|
+
access_token, cached_at = cached
|
|
52
|
+
if time.monotonic() - cached_at < _TOKEN_CACHE_TTL:
|
|
53
|
+
return access_token
|
|
54
|
+
else:
|
|
55
|
+
del _token_cache[cache_key]
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
59
|
+
resp = await client.get(
|
|
60
|
+
f"{self.api_base_url}/auth/me",
|
|
61
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if resp.status_code != 200:
|
|
65
|
+
logger.debug("Token verification failed: %s", resp.status_code)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
user_data = resp.json()
|
|
69
|
+
|
|
70
|
+
scopes = _extract_scopes_from_jwt(token)
|
|
71
|
+
expires_at = _extract_exp_from_jwt(token)
|
|
72
|
+
|
|
73
|
+
access_token = AccessToken(
|
|
74
|
+
token=token,
|
|
75
|
+
client_id=user_data.get("id", "unknown"),
|
|
76
|
+
scopes=scopes,
|
|
77
|
+
expires_at=expires_at,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Cache the verified token
|
|
81
|
+
_token_cache[cache_key] = (access_token, time.monotonic())
|
|
82
|
+
|
|
83
|
+
return access_token
|
|
84
|
+
|
|
85
|
+
except httpx.HTTPError as exc:
|
|
86
|
+
logger.error("Token verification HTTP error: %s", exc)
|
|
87
|
+
return None
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
logger.error("Token verification error: %s", exc)
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _decode_jwt_payload(token: str) -> dict | None:
|
|
94
|
+
"""Decode JWT payload without signature verification.
|
|
95
|
+
|
|
96
|
+
The backend already validated the token — we just need the claims.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
payload_b64 = token.split(".")[1]
|
|
100
|
+
# Add padding
|
|
101
|
+
padding = 4 - len(payload_b64) % 4
|
|
102
|
+
if padding != 4:
|
|
103
|
+
payload_b64 += "=" * padding
|
|
104
|
+
return json.loads(base64.urlsafe_b64decode(payload_b64))
|
|
105
|
+
except Exception:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _extract_scopes_from_jwt(token: str) -> list[str]:
|
|
110
|
+
"""Extract scopes from a JWT."""
|
|
111
|
+
payload = _decode_jwt_payload(token)
|
|
112
|
+
return payload.get("scopes", []) if payload else []
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _extract_exp_from_jwt(token: str) -> int | None:
|
|
116
|
+
"""Extract expiration from a JWT."""
|
|
117
|
+
payload = _decode_jwt_payload(token)
|
|
118
|
+
if not payload:
|
|
119
|
+
return None
|
|
120
|
+
exp = payload.get("exp")
|
|
121
|
+
if exp is None:
|
|
122
|
+
logger.warning("JWT has no exp claim — token refresh may not work correctly")
|
|
123
|
+
return exp
|