remem-auth 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.
- remem_auth-0.1.0/.gitignore +10 -0
- remem_auth-0.1.0/PKG-INFO +26 -0
- remem_auth-0.1.0/README.md +197 -0
- remem_auth-0.1.0/pyproject.toml +48 -0
- remem_auth-0.1.0/src/remem/auth/__init__.py +33 -0
- remem_auth-0.1.0/src/remem/auth/_config.py +86 -0
- remem_auth-0.1.0/src/remem/auth/_fastapi.py +37 -0
- remem_auth-0.1.0/src/remem/auth/_fastmcp.py +81 -0
- remem_auth-0.1.0/src/remem/auth/_models.py +36 -0
- remem_auth-0.1.0/src/remem/auth/_token_extraction.py +43 -0
- remem_auth-0.1.0/src/remem/auth/_verifier.py +131 -0
- remem_auth-0.1.0/src/remem/auth/py.typed +0 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: remem-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared authentication library for remem Python services
|
|
5
|
+
Requires-Python: <3.15,>=3.11
|
|
6
|
+
Requires-Dist: pydantic-settings<3,>=2
|
|
7
|
+
Requires-Dist: pydantic<3,>=2
|
|
8
|
+
Requires-Dist: pyjwt[crypto]<3,>=2.8
|
|
9
|
+
Provides-Extra: all
|
|
10
|
+
Requires-Dist: fastapi>=0.100; extra == 'all'
|
|
11
|
+
Requires-Dist: fastmcp>=2.14.0; extra == 'all'
|
|
12
|
+
Requires-Dist: starlette>=0.27; extra == 'all'
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: cryptography==46.0.5; extra == 'dev'
|
|
15
|
+
Requires-Dist: fastapi>=0.100; extra == 'dev'
|
|
16
|
+
Requires-Dist: fastmcp>=2.14.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: httpx==0.28.1; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-asyncio==1.3.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest==9.0.2; extra == 'dev'
|
|
20
|
+
Requires-Dist: ruff==0.15.4; extra == 'dev'
|
|
21
|
+
Requires-Dist: starlette>=0.27; extra == 'dev'
|
|
22
|
+
Provides-Extra: fastapi
|
|
23
|
+
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
|
|
24
|
+
Requires-Dist: starlette>=0.27; extra == 'fastapi'
|
|
25
|
+
Provides-Extra: fastmcp
|
|
26
|
+
Requires-Dist: fastmcp>=2.14.0; extra == 'fastmcp'
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# remem-auth
|
|
2
|
+
|
|
3
|
+
Shared authentication library for remem Python services. Supports JWT verification (Azure Entra ID / Google) and static bearer tokens, with out-of-the-box integrations for FastAPI and FastMCP.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Core library (JWT verification + static tokens)
|
|
9
|
+
pip install remem-auth
|
|
10
|
+
|
|
11
|
+
# With FastAPI integration
|
|
12
|
+
pip install remem-auth[fastapi]
|
|
13
|
+
|
|
14
|
+
# With FastMCP integration
|
|
15
|
+
pip install remem-auth[fastmcp]
|
|
16
|
+
|
|
17
|
+
# All optional dependencies
|
|
18
|
+
pip install remem-auth[all]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requires Python 3.11+.
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
### 1. Configure Environment Variables
|
|
26
|
+
|
|
27
|
+
All settings are read from environment variables with the `REMEM_AUTH_` prefix:
|
|
28
|
+
|
|
29
|
+
| Variable | Description | Default |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `REMEM_AUTH_AZURE_TENANT_ID` | Azure Entra ID tenant ID | `""` |
|
|
32
|
+
| `REMEM_AUTH_AZURE_CLIENT_ID` | Azure app registration client ID | `""` |
|
|
33
|
+
| `REMEM_AUTH_GOOGLE_CLIENT_ID` | Google OAuth client ID | `""` |
|
|
34
|
+
| `REMEM_AUTH_STATIC_TOKENS` | Comma-separated static bearer tokens | `""` |
|
|
35
|
+
| `REMEM_AUTH_IDPS_JSON` | Advanced: full JSON array of IdP configs | `""` |
|
|
36
|
+
| `REMEM_AUTH_VERIFY_EXP` | Whether to verify token expiration | `True` |
|
|
37
|
+
| `REMEM_AUTH_VERIFY_AUD` | Whether to verify audience | `True` |
|
|
38
|
+
|
|
39
|
+
**Example — Azure Entra ID:**
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
export REMEM_AUTH_AZURE_TENANT_ID="your-tenant-id"
|
|
43
|
+
export REMEM_AUTH_AZURE_CLIENT_ID="your-client-id"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Example — Static tokens (useful for dev/test):**
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
export REMEM_AUTH_STATIC_TOKENS="dev-token-1,dev-token-2"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Example — Custom IdP via JSON:**
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
export REMEM_AUTH_IDPS_JSON='[{"name":"my-idp","issuer":"https://idp.example.com","jwks_uri":"https://idp.example.com/.well-known/jwks.json","audience":"my-app"}]'
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 2. FastAPI Integration
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from fastapi import Depends, FastAPI
|
|
62
|
+
from remem.auth import AuthConfig, AuthenticatedUser, FastAPIAuth
|
|
63
|
+
|
|
64
|
+
config = AuthConfig() # reads from environment variables
|
|
65
|
+
auth = FastAPIAuth(config)
|
|
66
|
+
app = FastAPI()
|
|
67
|
+
|
|
68
|
+
@app.get("/protected")
|
|
69
|
+
def protected(user: AuthenticatedUser = Depends(auth)):
|
|
70
|
+
return {"subject": user.subject, "email": user.email}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Unauthenticated requests receive a `401 Unauthorized` response with a `WWW-Authenticate: Bearer` header.
|
|
74
|
+
|
|
75
|
+
### 3. FastMCP Integration
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from fastmcp import FastMCP
|
|
79
|
+
from remem.auth import AuthConfig, FastMCPAuthProvider
|
|
80
|
+
|
|
81
|
+
config = AuthConfig()
|
|
82
|
+
mcp = FastMCP("my-server", auth=FastMCPAuthProvider(config))
|
|
83
|
+
|
|
84
|
+
@mcp.tool()
|
|
85
|
+
def hello() -> str:
|
|
86
|
+
return "world"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
FastMCPAuthProvider implements FastMCP's `TokenVerifier` interface and uses `asyncio.to_thread()` internally so the synchronous verifier doesn't block the event loop.
|
|
90
|
+
|
|
91
|
+
### 4. Using the Core API Directly
|
|
92
|
+
|
|
93
|
+
If you're not using either framework, you can call the verification engine directly:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from remem.auth import AuthConfig, AuthVerifier, AuthenticationError
|
|
97
|
+
|
|
98
|
+
config = AuthConfig()
|
|
99
|
+
verifier = AuthVerifier(config)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
user = verifier.verify_token("eyJhbGciOi...")
|
|
103
|
+
print(user.subject, user.email, user.auth_method)
|
|
104
|
+
except AuthenticationError as e:
|
|
105
|
+
print(f"Authentication failed: {e}")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Extracting tokens from request headers:**
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from remem.auth import extract_token
|
|
112
|
+
|
|
113
|
+
# Works with any object that has a .headers attribute (Mapping[str, str])
|
|
114
|
+
token = extract_token(request)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`extract_token` checks `Authorization: Bearer <token>` first, then falls back to the `api-key` header.
|
|
118
|
+
|
|
119
|
+
## Core Concepts
|
|
120
|
+
|
|
121
|
+
### Verification Flow
|
|
122
|
+
|
|
123
|
+
`AuthVerifier.verify_token()` processes tokens in this order:
|
|
124
|
+
|
|
125
|
+
1. **Auth not enabled** — returns an anonymous user (graceful degradation)
|
|
126
|
+
2. **No token** — raises `AuthenticationError`
|
|
127
|
+
3. **Matches a static token** — returns a static-token user (O(1) set lookup)
|
|
128
|
+
4. **JWT** — peeks the issuer from an unverified decode, then routes to the matching IdP verifier
|
|
129
|
+
|
|
130
|
+
### AuthenticatedUser
|
|
131
|
+
|
|
132
|
+
The user object returned after successful authentication:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
class AuthenticatedUser(BaseModel):
|
|
136
|
+
subject: str # JWT "sub" claim
|
|
137
|
+
email: str | None # extracted from email / preferred_username / upn
|
|
138
|
+
name: str | None # JWT "name" claim
|
|
139
|
+
auth_method: AuthMethod # "jwt" | "static" | "none"
|
|
140
|
+
idp_name: str | None # IdP name (e.g. "azure", "google")
|
|
141
|
+
claims: dict[str, Any] # full JWT payload
|
|
142
|
+
token: str # raw token (excluded from serialization)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The `token` field is marked with `exclude=True` and `repr=False`, so it won't appear in `.model_dump()` output or `print()` — preventing accidental credential leaks.
|
|
146
|
+
|
|
147
|
+
### Graceful Degradation
|
|
148
|
+
|
|
149
|
+
When no IdPs or static tokens are configured, `auth_enabled` is `False` and all requests are allowed through with an anonymous user. This lets you omit auth configuration in development environments.
|
|
150
|
+
|
|
151
|
+
## API Reference
|
|
152
|
+
|
|
153
|
+
### Module Exports
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from remem.auth import (
|
|
157
|
+
AuthConfig, # pydantic-settings configuration
|
|
158
|
+
AuthVerifier, # core verification engine
|
|
159
|
+
AuthenticationError, # verification failure exception
|
|
160
|
+
AuthenticatedUser, # user model
|
|
161
|
+
AuthMethod, # authentication method enum
|
|
162
|
+
IdpConfig, # identity provider config model
|
|
163
|
+
extract_token, # extract token from request headers
|
|
164
|
+
create_auth_config_from_env, # AuthConfig factory function
|
|
165
|
+
FastAPIAuth, # FastAPI dependency (lazy import)
|
|
166
|
+
FastMCPAuthProvider, # FastMCP auth provider (lazy import)
|
|
167
|
+
)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`FastAPIAuth` and `FastMCPAuthProvider` are lazy-imported via `__getattr__` — their framework dependencies are only loaded when actually accessed.
|
|
171
|
+
|
|
172
|
+
### IdpConfig Fields
|
|
173
|
+
|
|
174
|
+
When using `REMEM_AUTH_IDPS_JSON` to define custom IdPs, each entry supports:
|
|
175
|
+
|
|
176
|
+
| Field | Type | Required | Default | Description |
|
|
177
|
+
|---|---|---|---|---|
|
|
178
|
+
| `name` | `str` | Yes | — | IdP identifier |
|
|
179
|
+
| `issuer` | `str` | Yes | — | JWT issuer (used for routing) |
|
|
180
|
+
| `jwks_uri` | `str` | Yes | — | JWKS endpoint URL |
|
|
181
|
+
| `audience` | `str \| None` | No | `None` | Expected audience |
|
|
182
|
+
| `algorithms` | `list[str]` | No | `["RS256"]` | Allowed signing algorithms |
|
|
183
|
+
|
|
184
|
+
## Development
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
# Create a virtualenv and install dev dependencies
|
|
188
|
+
python -m venv .venv
|
|
189
|
+
source .venv/bin/activate
|
|
190
|
+
pip install -e ".[dev]"
|
|
191
|
+
|
|
192
|
+
# Run tests
|
|
193
|
+
pytest tests/ -v
|
|
194
|
+
|
|
195
|
+
# Lint
|
|
196
|
+
ruff check src/ tests/
|
|
197
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "remem-auth"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Shared authentication library for remem Python services"
|
|
9
|
+
requires-python = ">=3.11,<3.15"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"PyJWT[crypto]>=2.8,<3",
|
|
12
|
+
"pydantic>=2,<3",
|
|
13
|
+
"pydantic-settings>=2,<3",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
fastapi = [
|
|
18
|
+
"fastapi>=0.100",
|
|
19
|
+
"starlette>=0.27",
|
|
20
|
+
]
|
|
21
|
+
fastmcp = [
|
|
22
|
+
"fastmcp>=2.14.0",
|
|
23
|
+
]
|
|
24
|
+
all = [
|
|
25
|
+
"remem-auth[fastapi,fastmcp]",
|
|
26
|
+
]
|
|
27
|
+
dev = [
|
|
28
|
+
"remem-auth[all]",
|
|
29
|
+
"pytest==9.0.2",
|
|
30
|
+
"pytest-asyncio==1.3.0",
|
|
31
|
+
"httpx==0.28.1",
|
|
32
|
+
"cryptography==46.0.5",
|
|
33
|
+
"ruff==0.15.4",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/remem"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
only-include = ["src/remem", "pyproject.toml", "README.md"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
pythonpath = ["src"]
|
|
44
|
+
asyncio_mode = "auto"
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
target-version = "py311"
|
|
48
|
+
line-length = 100
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""remem-auth — Shared authentication library for remem Python services."""
|
|
2
|
+
|
|
3
|
+
from ._config import AuthConfig, create_auth_config_from_env
|
|
4
|
+
from ._models import AuthenticatedUser, AuthMethod, IdpConfig
|
|
5
|
+
from ._token_extraction import extract_token
|
|
6
|
+
from ._verifier import AuthenticationError, AuthVerifier
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AuthConfig",
|
|
10
|
+
"AuthenticatedUser",
|
|
11
|
+
"AuthenticationError",
|
|
12
|
+
"AuthMethod",
|
|
13
|
+
"AuthVerifier",
|
|
14
|
+
"FastAPIAuth",
|
|
15
|
+
"FastMCPAuthProvider",
|
|
16
|
+
"IdpConfig",
|
|
17
|
+
"create_auth_config_from_env",
|
|
18
|
+
"extract_token",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def __getattr__(name: str):
|
|
23
|
+
if name == "FastAPIAuth":
|
|
24
|
+
from ._fastapi import FastAPIAuth
|
|
25
|
+
|
|
26
|
+
return FastAPIAuth
|
|
27
|
+
|
|
28
|
+
if name == "FastMCPAuthProvider":
|
|
29
|
+
from ._fastmcp import FastMCPAuthProvider
|
|
30
|
+
|
|
31
|
+
return FastMCPAuthProvider
|
|
32
|
+
|
|
33
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Configuration for remem-auth via environment variables."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
from pydantic_settings import BaseSettings
|
|
7
|
+
|
|
8
|
+
from ._models import IdpConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthConfig(BaseSettings):
|
|
12
|
+
"""Auth configuration loaded from environment variables.
|
|
13
|
+
|
|
14
|
+
All env vars are prefixed with ``REMEM_AUTH_``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
model_config = {"env_prefix": "REMEM_AUTH_"}
|
|
18
|
+
|
|
19
|
+
# Azure Entra ID convenience fields
|
|
20
|
+
azure_tenant_id: str = ""
|
|
21
|
+
azure_client_id: str = ""
|
|
22
|
+
|
|
23
|
+
# Google convenience field
|
|
24
|
+
google_client_id: str = ""
|
|
25
|
+
|
|
26
|
+
# Advanced: full JSON array of IdpConfig dicts
|
|
27
|
+
idps_json: str = ""
|
|
28
|
+
|
|
29
|
+
# Comma-separated static bearer tokens
|
|
30
|
+
static_tokens: str = ""
|
|
31
|
+
|
|
32
|
+
# Verification flags
|
|
33
|
+
verify_exp: bool = True
|
|
34
|
+
verify_aud: bool = True
|
|
35
|
+
|
|
36
|
+
def get_idp_configs(self) -> list[IdpConfig]:
|
|
37
|
+
"""Build the list of IdP configurations from settings."""
|
|
38
|
+
if self.idps_json:
|
|
39
|
+
try:
|
|
40
|
+
raw = json.loads(self.idps_json)
|
|
41
|
+
return [IdpConfig(**entry) for entry in raw]
|
|
42
|
+
except (json.JSONDecodeError, TypeError, ValidationError) as exc:
|
|
43
|
+
raise ValueError(f"Invalid REMEM_AUTH_IDPS_JSON: {exc}") from exc
|
|
44
|
+
|
|
45
|
+
configs: list[IdpConfig] = []
|
|
46
|
+
|
|
47
|
+
if self.azure_tenant_id and self.azure_client_id:
|
|
48
|
+
configs.append(
|
|
49
|
+
IdpConfig(
|
|
50
|
+
name="azure",
|
|
51
|
+
issuer=f"https://login.microsoftonline.com/{self.azure_tenant_id}/v2.0",
|
|
52
|
+
jwks_uri=(
|
|
53
|
+
f"https://login.microsoftonline.com/{self.azure_tenant_id}"
|
|
54
|
+
"/discovery/v2.0/keys"
|
|
55
|
+
),
|
|
56
|
+
audience=self.azure_client_id,
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if self.google_client_id:
|
|
61
|
+
configs.append(
|
|
62
|
+
IdpConfig(
|
|
63
|
+
name="google",
|
|
64
|
+
issuer="https://accounts.google.com",
|
|
65
|
+
jwks_uri="https://www.googleapis.com/oauth2/v3/certs",
|
|
66
|
+
audience=self.google_client_id,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return configs
|
|
71
|
+
|
|
72
|
+
def get_static_token_set(self) -> set[str]:
|
|
73
|
+
"""Parse comma-separated static tokens into a set."""
|
|
74
|
+
if not self.static_tokens:
|
|
75
|
+
return set()
|
|
76
|
+
return {t.strip() for t in self.static_tokens.split(",") if t.strip()}
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def auth_enabled(self) -> bool:
|
|
80
|
+
"""True if any IdP or static token is configured."""
|
|
81
|
+
return bool(self.get_idp_configs() or self.get_static_token_set())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def create_auth_config_from_env() -> AuthConfig:
|
|
85
|
+
"""Factory that instantiates AuthConfig from environment variables."""
|
|
86
|
+
return AuthConfig()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""FastAPI integration — Depends()-compatible auth callable."""
|
|
2
|
+
|
|
3
|
+
from fastapi import HTTPException, Request
|
|
4
|
+
from starlette.status import HTTP_401_UNAUTHORIZED
|
|
5
|
+
|
|
6
|
+
from ._config import AuthConfig
|
|
7
|
+
from ._models import AuthenticatedUser
|
|
8
|
+
from ._token_extraction import extract_token
|
|
9
|
+
from ._verifier import AuthenticationError, AuthVerifier
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FastAPIAuth:
|
|
13
|
+
"""Callable dependency for FastAPI that verifies bearer tokens.
|
|
14
|
+
|
|
15
|
+
Usage::
|
|
16
|
+
|
|
17
|
+
auth = FastAPIAuth(config)
|
|
18
|
+
app = FastAPI()
|
|
19
|
+
|
|
20
|
+
@app.get("/protected")
|
|
21
|
+
def protected(user: AuthenticatedUser = Depends(auth)):
|
|
22
|
+
return {"subject": user.subject}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, config: AuthConfig):
|
|
26
|
+
self._verifier = AuthVerifier(config)
|
|
27
|
+
|
|
28
|
+
def __call__(self, request: Request) -> AuthenticatedUser:
|
|
29
|
+
token = extract_token(request)
|
|
30
|
+
try:
|
|
31
|
+
return self._verifier.verify_token(token)
|
|
32
|
+
except AuthenticationError as exc:
|
|
33
|
+
raise HTTPException(
|
|
34
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
35
|
+
detail=str(exc),
|
|
36
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
37
|
+
) from exc
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""FastMCP integration — TokenVerifier-based auth provider."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from ._config import AuthConfig
|
|
6
|
+
from ._verifier import AuthenticationError, AuthVerifier
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from fastmcp.server.auth import AccessToken, TokenVerifier
|
|
10
|
+
|
|
11
|
+
_HAS_FASTMCP = True
|
|
12
|
+
except ImportError: # pragma: no cover
|
|
13
|
+
_HAS_FASTMCP = False
|
|
14
|
+
|
|
15
|
+
class TokenVerifier: # type: ignore[no-redef]
|
|
16
|
+
"""Stub so the module is importable without fastmcp."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, *args, **kwargs): ...
|
|
19
|
+
|
|
20
|
+
class AccessToken: # type: ignore[no-redef]
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FastMCPAuthProvider(TokenVerifier):
|
|
25
|
+
"""Auth provider for FastMCP servers.
|
|
26
|
+
|
|
27
|
+
Usage::
|
|
28
|
+
|
|
29
|
+
from remem.auth import AuthConfig, FastMCPAuthProvider
|
|
30
|
+
|
|
31
|
+
config = AuthConfig()
|
|
32
|
+
mcp = FastMCP("my-server", auth=FastMCPAuthProvider(config))
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, config: AuthConfig):
|
|
36
|
+
super().__init__()
|
|
37
|
+
self._verifier = AuthVerifier(config)
|
|
38
|
+
|
|
39
|
+
async def verify_token(self, token: str) -> AccessToken | None: # type: ignore[override]
|
|
40
|
+
if not self._verifier.auth_enabled:
|
|
41
|
+
return AccessToken(
|
|
42
|
+
token="",
|
|
43
|
+
client_id="anonymous",
|
|
44
|
+
scopes=[],
|
|
45
|
+
claims={},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if not token:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
user = await asyncio.to_thread(self._verifier.verify_token, token)
|
|
53
|
+
except AuthenticationError:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
scopes = _extract_scopes(user.claims)
|
|
57
|
+
expires_at = user.claims.get("exp")
|
|
58
|
+
|
|
59
|
+
return AccessToken(
|
|
60
|
+
token=token,
|
|
61
|
+
client_id=user.subject,
|
|
62
|
+
scopes=scopes,
|
|
63
|
+
expires_at=int(expires_at) if expires_at is not None else None,
|
|
64
|
+
claims=user.claims,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _extract_scopes(claims: dict) -> list[str]:
|
|
69
|
+
"""Extract scopes from JWT claims.
|
|
70
|
+
|
|
71
|
+
Supports ``scope`` (space-separated string) and ``scp`` (list).
|
|
72
|
+
"""
|
|
73
|
+
scope = claims.get("scope")
|
|
74
|
+
if isinstance(scope, str):
|
|
75
|
+
return scope.split()
|
|
76
|
+
|
|
77
|
+
scp = claims.get("scp")
|
|
78
|
+
if isinstance(scp, list):
|
|
79
|
+
return [str(s) for s in scp]
|
|
80
|
+
|
|
81
|
+
return []
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Data models for remem-auth."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthMethod(str, Enum):
|
|
10
|
+
"""How a user was authenticated."""
|
|
11
|
+
|
|
12
|
+
JWT = "jwt"
|
|
13
|
+
STATIC = "static"
|
|
14
|
+
NONE = "none"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IdpConfig(BaseModel):
|
|
18
|
+
"""Configuration for a single identity provider."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
issuer: str
|
|
22
|
+
jwks_uri: str
|
|
23
|
+
audience: str | None = None
|
|
24
|
+
algorithms: list[str] = Field(default_factory=lambda: ["RS256"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthenticatedUser(BaseModel):
|
|
28
|
+
"""Represents a successfully authenticated user."""
|
|
29
|
+
|
|
30
|
+
subject: str
|
|
31
|
+
email: str | None = None
|
|
32
|
+
name: str | None = None
|
|
33
|
+
auth_method: AuthMethod
|
|
34
|
+
idp_name: str | None = None
|
|
35
|
+
claims: dict[str, Any] = Field(default_factory=dict)
|
|
36
|
+
token: str = Field(default="", exclude=True, repr=False)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Token extraction from HTTP request headers."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@runtime_checkable
|
|
8
|
+
class HasHeaders(Protocol):
|
|
9
|
+
"""Any object that exposes a ``headers`` mapping (e.g. Starlette Request)."""
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def headers(self) -> Mapping[str, str]: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_header(headers: Mapping[str, str], *names: str) -> str:
|
|
16
|
+
"""Return the first truthy header value found, or empty string."""
|
|
17
|
+
for name in names:
|
|
18
|
+
value = headers.get(name)
|
|
19
|
+
if value:
|
|
20
|
+
return value
|
|
21
|
+
return ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def extract_token(request: HasHeaders) -> str | None:
|
|
25
|
+
"""Extract a bearer token from request headers.
|
|
26
|
+
|
|
27
|
+
Checks ``Authorization: Bearer <token>`` first, then falls back to
|
|
28
|
+
the ``api-key`` header. Returns *None* if neither is present.
|
|
29
|
+
|
|
30
|
+
Both lower-case and title-case header names are tried so that plain
|
|
31
|
+
``dict`` callers (outside Starlette's case-insensitive Headers) work.
|
|
32
|
+
"""
|
|
33
|
+
headers = request.headers
|
|
34
|
+
|
|
35
|
+
auth_header = _get_header(headers, "authorization", "Authorization")
|
|
36
|
+
if auth_header.lower().startswith("bearer "):
|
|
37
|
+
return auth_header[7:].strip()
|
|
38
|
+
|
|
39
|
+
api_key = _get_header(headers, "api-key", "Api-Key")
|
|
40
|
+
if api_key:
|
|
41
|
+
return api_key.strip()
|
|
42
|
+
|
|
43
|
+
return None
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Core token verification engine."""
|
|
2
|
+
|
|
3
|
+
import jwt
|
|
4
|
+
from jwt import PyJWKClient
|
|
5
|
+
|
|
6
|
+
from ._config import AuthConfig
|
|
7
|
+
from ._models import AuthMethod, AuthenticatedUser, IdpConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthenticationError(Exception):
|
|
11
|
+
"""Raised when token verification fails."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _IdpVerifier:
|
|
15
|
+
"""Verifies JWTs for a single identity provider."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, idp: IdpConfig, *, verify_exp: bool = True, verify_aud: bool = True):
|
|
18
|
+
self._idp = idp
|
|
19
|
+
self._jwks_client = PyJWKClient(idp.jwks_uri)
|
|
20
|
+
self._verify_exp = verify_exp
|
|
21
|
+
self._verify_aud = verify_aud
|
|
22
|
+
|
|
23
|
+
def verify(self, token: str) -> AuthenticatedUser:
|
|
24
|
+
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
|
25
|
+
|
|
26
|
+
has_audience = self._verify_aud and self._idp.audience
|
|
27
|
+
options: dict = {
|
|
28
|
+
"verify_exp": self._verify_exp,
|
|
29
|
+
"verify_aud": has_audience,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
decode_kwargs: dict = {
|
|
33
|
+
"algorithms": self._idp.algorithms,
|
|
34
|
+
"issuer": self._idp.issuer,
|
|
35
|
+
"options": options,
|
|
36
|
+
}
|
|
37
|
+
if has_audience:
|
|
38
|
+
decode_kwargs["audience"] = self._idp.audience
|
|
39
|
+
|
|
40
|
+
payload = jwt.decode(token, signing_key.key, **decode_kwargs)
|
|
41
|
+
return _payload_to_user(payload, token=token, idp_name=self._idp.name)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _payload_to_user(
|
|
45
|
+
payload: dict, *, token: str, idp_name: str | None = None
|
|
46
|
+
) -> AuthenticatedUser:
|
|
47
|
+
"""Convert a JWT payload dict into an AuthenticatedUser."""
|
|
48
|
+
subject = payload.get("sub", "")
|
|
49
|
+
email = payload.get("email") or payload.get("preferred_username") or payload.get("upn")
|
|
50
|
+
name = payload.get("name")
|
|
51
|
+
|
|
52
|
+
return AuthenticatedUser(
|
|
53
|
+
subject=subject,
|
|
54
|
+
email=email,
|
|
55
|
+
name=name,
|
|
56
|
+
auth_method=AuthMethod.JWT,
|
|
57
|
+
idp_name=idp_name,
|
|
58
|
+
claims=payload,
|
|
59
|
+
token=token,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AuthVerifier:
|
|
64
|
+
"""Main verification engine supporting multiple IdPs and static tokens."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, config: AuthConfig):
|
|
67
|
+
self._config = config
|
|
68
|
+
self._static_tokens: set[str] = config.get_static_token_set()
|
|
69
|
+
self._issuer_to_verifier: dict[str, _IdpVerifier] = {}
|
|
70
|
+
|
|
71
|
+
for idp in config.get_idp_configs():
|
|
72
|
+
verifier = _IdpVerifier(
|
|
73
|
+
idp,
|
|
74
|
+
verify_exp=config.verify_exp,
|
|
75
|
+
verify_aud=config.verify_aud,
|
|
76
|
+
)
|
|
77
|
+
self._issuer_to_verifier[idp.issuer] = verifier
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def auth_enabled(self) -> bool:
|
|
81
|
+
return self._config.auth_enabled
|
|
82
|
+
|
|
83
|
+
def verify_token(self, token: str | None) -> AuthenticatedUser:
|
|
84
|
+
"""Verify a token and return the authenticated user.
|
|
85
|
+
|
|
86
|
+
Flow:
|
|
87
|
+
1. If auth not enabled → anonymous user (graceful degradation)
|
|
88
|
+
2. If no token → raise AuthenticationError
|
|
89
|
+
3. If token matches a static token → static AuthenticatedUser
|
|
90
|
+
4. Peek issuer from unverified decode → route to IdP verifier
|
|
91
|
+
"""
|
|
92
|
+
if not self.auth_enabled:
|
|
93
|
+
return AuthenticatedUser(
|
|
94
|
+
subject="anonymous",
|
|
95
|
+
auth_method=AuthMethod.NONE,
|
|
96
|
+
claims={},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if not token:
|
|
100
|
+
raise AuthenticationError("No token provided")
|
|
101
|
+
|
|
102
|
+
# Static token check — O(1) set lookup, cheapest path
|
|
103
|
+
if token in self._static_tokens:
|
|
104
|
+
return AuthenticatedUser(
|
|
105
|
+
subject="static-token-user",
|
|
106
|
+
auth_method=AuthMethod.STATIC,
|
|
107
|
+
claims={},
|
|
108
|
+
token=token,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# JWT verification — peek issuer then route
|
|
112
|
+
try:
|
|
113
|
+
unverified = jwt.decode(token, options={"verify_signature": False})
|
|
114
|
+
except jwt.DecodeError as exc:
|
|
115
|
+
raise AuthenticationError(f"Malformed token: {exc}") from exc
|
|
116
|
+
|
|
117
|
+
issuer = unverified.get("iss", "")
|
|
118
|
+
verifier = self._issuer_to_verifier.get(issuer)
|
|
119
|
+
if verifier is None:
|
|
120
|
+
raise AuthenticationError(f"Unknown issuer: {issuer!r}")
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
return verifier.verify(token)
|
|
124
|
+
except jwt.ExpiredSignatureError as exc:
|
|
125
|
+
raise AuthenticationError(f"Token expired: {exc}") from exc
|
|
126
|
+
except jwt.InvalidAudienceError as exc:
|
|
127
|
+
raise AuthenticationError(f"Invalid audience: {exc}") from exc
|
|
128
|
+
except jwt.InvalidIssuerError as exc:
|
|
129
|
+
raise AuthenticationError(f"Invalid issuer: {exc}") from exc
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
raise AuthenticationError(f"Token verification failed: {exc}") from exc
|
|
File without changes
|