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.
@@ -0,0 +1,10 @@
1
+ .worktrees/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .pytest_cache/
10
+ .venv/
@@ -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