agentplane-registry 0.0.1__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,34 @@
1
+ # internal working documents — not part of the public repo
2
+ CLAUDE.md
3
+ SPEC.md
4
+ .claude/
5
+
6
+ # python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *.egg-info/
10
+ .venv/
11
+ dist/
12
+ build/
13
+
14
+ # tooling caches
15
+ .pytest_cache/
16
+ .mypy_cache/
17
+ .ruff_cache/
18
+ .coverage
19
+ htmlcov/
20
+
21
+ # local databases and state
22
+ *.db
23
+ registry.db
24
+ runtime.db
25
+
26
+ # environments / secrets
27
+ .env
28
+ .env.*
29
+
30
+ # editors / OS
31
+ .vscode/
32
+ .idea/
33
+ .DS_Store
34
+ Thumbs.db
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentplane-registry
3
+ Version: 0.0.1
4
+ Summary: agentplane discovery service (standalone-capable)
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: agentplane-core<0.1.0,>=0.0.1
8
+ Requires-Dist: aiosqlite>=0.20
9
+ Requires-Dist: fastapi>=0.115
10
+ Requires-Dist: fastmcp==3.4.4
11
+ Requires-Dist: httpx>=0.27
12
+ Requires-Dist: opentelemetry-api>=1.27
13
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27
14
+ Requires-Dist: opentelemetry-sdk>=1.27
15
+ Requires-Dist: pydantic-settings>=2.4
16
+ Requires-Dist: pyjwt[crypto]>=2.9
17
+ Requires-Dist: sqlalchemy[asyncio]>=2.0.30
18
+ Requires-Dist: uvicorn>=0.30
19
+ Provides-Extra: postgres
20
+ Requires-Dist: asyncpg>=0.29; extra == 'postgres'
21
+ Requires-Dist: pgvector>=0.3; extra == 'postgres'
22
+ Provides-Extra: semantic
23
+ Requires-Dist: numpy>=1.26; extra == 'semantic'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # agentplane-registry
27
+
28
+ Standalone-capable discovery service for A2A agents and MCP servers:
29
+ register, tag, search (text + optional semantic), health-check.
30
+
31
+ - Runs alone: SQLite by default, auth optional (generic OIDC, any issuer),
32
+ no calls to other agentplane services.
33
+ - Extras: `agentplane-registry[semantic]` (numpy + embeddings via an
34
+ OpenAI-compatible endpoint), `[postgres]` (asyncpg). Without extras the
35
+ service degrades gracefully and announces features via `GET /capabilities`.
36
+ - Stores **gateway URLs only**; private/internal hosts are rejected unless
37
+ `AGENTPLANE_REGISTRY_ALLOW_PRIVATE_URLS=true`.
38
+
39
+ Run it:
40
+
41
+ ```
42
+ uvicorn --factory agentplane_registry.app:create_app --port 8100
43
+ ```
@@ -0,0 +1,18 @@
1
+ # agentplane-registry
2
+
3
+ Standalone-capable discovery service for A2A agents and MCP servers:
4
+ register, tag, search (text + optional semantic), health-check.
5
+
6
+ - Runs alone: SQLite by default, auth optional (generic OIDC, any issuer),
7
+ no calls to other agentplane services.
8
+ - Extras: `agentplane-registry[semantic]` (numpy + embeddings via an
9
+ OpenAI-compatible endpoint), `[postgres]` (asyncpg). Without extras the
10
+ service degrades gracefully and announces features via `GET /capabilities`.
11
+ - Stores **gateway URLs only**; private/internal hosts are rejected unless
12
+ `AGENTPLANE_REGISTRY_ALLOW_PRIVATE_URLS=true`.
13
+
14
+ Run it:
15
+
16
+ ```
17
+ uvicorn --factory agentplane_registry.app:create_app --port 8100
18
+ ```
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "agentplane-registry"
3
+ version = "0.0.1"
4
+ description = "agentplane discovery service (standalone-capable)"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.12"
8
+ dependencies = [
9
+ "agentplane-core>=0.0.1,<0.1.0",
10
+ "fastapi>=0.115",
11
+ "sqlalchemy[asyncio]>=2.0.30",
12
+ "aiosqlite>=0.20",
13
+ "httpx>=0.27",
14
+ "pydantic-settings>=2.4",
15
+ "pyjwt[crypto]>=2.9",
16
+ # exact pin — fastmcp ships breaking changes in minor releases (CLAUDE.md)
17
+ "fastmcp==3.4.4",
18
+ "opentelemetry-api>=1.27",
19
+ "opentelemetry-sdk>=1.27",
20
+ "opentelemetry-exporter-otlp-proto-http>=1.27",
21
+ "uvicorn>=0.30",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ semantic = ["numpy>=1.26"]
26
+ postgres = ["asyncpg>=0.29", "pgvector>=0.3"]
27
+
28
+ [build-system]
29
+ requires = ["hatchling"]
30
+ build-backend = "hatchling.build"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/agentplane_registry"]
34
+
35
+ [tool.uv.sources]
36
+ agentplane-core = { workspace = true }
@@ -0,0 +1,8 @@
1
+ """agentplane-registry: standalone-capable discovery service."""
2
+
3
+ from agentplane_registry.app import create_app
4
+ from agentplane_registry.settings import REGISTRY_VERSION, RegistrySettings
5
+
6
+ __version__ = REGISTRY_VERSION
7
+
8
+ __all__ = ["REGISTRY_VERSION", "RegistrySettings", "create_app"]
@@ -0,0 +1,252 @@
1
+ """Registry REST API (SPEC §5.1), prefix ``/api/v1``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ from typing import Annotated, Literal
10
+
11
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
12
+ from sqlalchemy import func, select
13
+ from sqlalchemy.exc import IntegrityError
14
+
15
+ from agentplane_core import (
16
+ Capabilities,
17
+ EntryKind,
18
+ HealthStatus,
19
+ Page,
20
+ RegistryEntry,
21
+ RegistryEntryCreate,
22
+ RegistryEntryPatch,
23
+ SearchQuery,
24
+ serialize_card,
25
+ )
26
+ from agentplane_registry.auth import Principal
27
+ from agentplane_registry.db import Database, EntryRow, row_to_entry
28
+ from agentplane_registry.search import RegistrySearch
29
+ from agentplane_registry.settings import REGISTRY_VERSION, RegistrySettings
30
+ from agentplane_registry.urlcheck import is_private_url
31
+
32
+
33
+ @dataclass
34
+ class RegistryState:
35
+ """Shared service state stored on the FastAPI app."""
36
+
37
+ db: Database
38
+ settings: RegistrySettings
39
+ search: RegistrySearch
40
+
41
+
42
+ def _state(request: Request) -> RegistryState:
43
+ state: RegistryState = request.app.state.registry
44
+ return state
45
+
46
+
47
+ async def _principal(request: Request) -> Principal:
48
+ principal: Principal = await request.app.state.authenticator.authenticate(request)
49
+ return principal
50
+
51
+
52
+ State = Annotated[RegistryState, Depends(_state)]
53
+ Caller = Annotated[Principal, Depends(_principal)]
54
+
55
+ router = APIRouter(prefix="/api/v1")
56
+ health_router = APIRouter()
57
+
58
+
59
+ def _visible(row: EntryRow, caller: Principal, auth_mode: str) -> bool:
60
+ if auth_mode == "none" or caller.is_admin:
61
+ return True
62
+ return row.owner == caller.sub
63
+
64
+
65
+ def _check_url(url: str, settings: RegistrySettings) -> None:
66
+ if not settings.allow_private_urls and is_private_url(url):
67
+ raise HTTPException(
68
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
69
+ detail="url must be a public gateway URL (set ALLOW_PRIVATE_URLS to override)",
70
+ )
71
+
72
+
73
+ @router.post("/agents", status_code=status.HTTP_201_CREATED, response_model=RegistryEntry)
74
+ async def register_entry(body: RegistryEntryCreate, state: State, caller: Caller) -> RegistryEntry:
75
+ _check_url(body.url, state.settings)
76
+ now = datetime.now(UTC)
77
+ row = EntryRow(
78
+ id=str(uuid.uuid4()),
79
+ kind=body.kind,
80
+ name=body.card.name,
81
+ owner=caller.sub,
82
+ url=body.url,
83
+ card_json=_dump_card(body),
84
+ tags_json=list(body.tags),
85
+ status="starting",
86
+ created_at=now,
87
+ updated_at=now,
88
+ )
89
+ try:
90
+ async with state.db.session() as session, session.begin():
91
+ session.add(row)
92
+ except IntegrityError:
93
+ raise HTTPException(
94
+ status.HTTP_409_CONFLICT,
95
+ detail=f"entry named {body.card.name!r} already exists for this owner",
96
+ ) from None
97
+ entry = row_to_entry(row)
98
+ await state.search.index(entry)
99
+ return entry
100
+
101
+
102
+ def _dump_card(body: RegistryEntryCreate | RegistryEntryPatch) -> str:
103
+ return json.dumps(serialize_card(body.card), sort_keys=True)
104
+
105
+
106
+ @router.get("/agents", response_model=Page)
107
+ async def list_entries(
108
+ state: State,
109
+ caller: Caller,
110
+ kind: Annotated[EntryKind | None, Query()] = None,
111
+ status_filter: Annotated[HealthStatus | None, Query(alias="status")] = None,
112
+ tags: Annotated[str, Query(description="comma-separated, AND semantics")] = "",
113
+ owner: Annotated[Literal["me", "all"], Query()] = "me",
114
+ limit: Annotated[int, Query(ge=1, le=200)] = 50,
115
+ offset: Annotated[int, Query(ge=0)] = 0,
116
+ ) -> Page:
117
+ if owner == "all" and state.settings.auth_mode == "oidc" and not caller.is_admin:
118
+ raise HTTPException(status.HTTP_403_FORBIDDEN, detail="owner=all requires admin")
119
+ stmt = select(EntryRow).order_by(EntryRow.created_at.desc())
120
+ count_stmt = select(func.count()).select_from(EntryRow)
121
+ if state.settings.auth_mode == "oidc" and owner == "me" and not caller.is_admin:
122
+ stmt = stmt.where(EntryRow.owner == caller.sub)
123
+ count_stmt = count_stmt.where(EntryRow.owner == caller.sub)
124
+ if kind is not None:
125
+ stmt = stmt.where(EntryRow.kind == kind)
126
+ count_stmt = count_stmt.where(EntryRow.kind == kind)
127
+ if status_filter is not None:
128
+ stmt = stmt.where(EntryRow.status == status_filter)
129
+ count_stmt = count_stmt.where(EntryRow.status == status_filter)
130
+
131
+ async with state.db.session() as session:
132
+ rows = (await session.execute(stmt)).scalars().all()
133
+
134
+ wanted_tags = {t.strip() for t in tags.split(",") if t.strip()}
135
+ entries = [
136
+ row_to_entry(row) for row in rows if not wanted_tags or wanted_tags <= set(row.tags_json)
137
+ ]
138
+ total = len(entries)
139
+ return Page(items=entries[offset : offset + limit], total=total, limit=limit, offset=offset)
140
+
141
+
142
+ @router.get("/agents/search", response_model=Page)
143
+ async def search_entries(
144
+ state: State,
145
+ caller: Caller,
146
+ response: Response,
147
+ q: Annotated[str, Query()] = "",
148
+ tags: Annotated[str, Query()] = "",
149
+ kind: Annotated[EntryKind | None, Query()] = None,
150
+ status_filter: Annotated[HealthStatus | None, Query(alias="status")] = None,
151
+ semantic: Annotated[bool, Query()] = False,
152
+ limit: Annotated[int, Query(ge=1, le=200)] = 50,
153
+ offset: Annotated[int, Query(ge=0)] = 0,
154
+ ) -> Page:
155
+ owner_filter: str | None = None
156
+ if state.settings.auth_mode == "oidc" and not caller.is_admin:
157
+ owner_filter = caller.sub
158
+ if semantic and not state.search.semantic_enabled:
159
+ response.headers["X-Degraded"] = "semantic"
160
+ semantic = False
161
+ query = SearchQuery(
162
+ q=q,
163
+ tags=[t.strip() for t in tags.split(",") if t.strip()],
164
+ kind=kind,
165
+ status=status_filter,
166
+ semantic=semantic,
167
+ owner=owner_filter,
168
+ limit=limit,
169
+ offset=offset,
170
+ )
171
+ return await state.search.search(query)
172
+
173
+
174
+ @router.get("/agents/{entry_id}", response_model=RegistryEntry)
175
+ async def get_entry(entry_id: uuid.UUID, state: State, caller: Caller) -> RegistryEntry:
176
+ row = await _load_visible(entry_id, state, caller)
177
+ return row_to_entry(row)
178
+
179
+
180
+ async def _load_visible(entry_id: uuid.UUID, state: RegistryState, caller: Principal) -> EntryRow:
181
+ async with state.db.session() as session:
182
+ row = await session.get(EntryRow, str(entry_id))
183
+ if row is None or not _visible(row, caller, state.settings.auth_mode):
184
+ raise HTTPException(status.HTTP_404_NOT_FOUND, detail="entry not found")
185
+ return row
186
+
187
+
188
+ def _require_owner(row: EntryRow, caller: Principal, auth_mode: str) -> None:
189
+ if auth_mode == "oidc" and not caller.is_admin and row.owner != caller.sub:
190
+ raise HTTPException(status.HTTP_403_FORBIDDEN, detail="owner or admin required")
191
+
192
+
193
+ @router.put("/agents/{entry_id}", response_model=RegistryEntry)
194
+ async def update_entry(
195
+ entry_id: uuid.UUID, body: RegistryEntryPatch, state: State, caller: Caller
196
+ ) -> RegistryEntry:
197
+ row = await _load_visible(entry_id, state, caller)
198
+ _require_owner(row, caller, state.settings.auth_mode)
199
+ if body.url is not None:
200
+ _check_url(body.url, state.settings)
201
+ async with state.db.session() as session, session.begin():
202
+ fresh = await session.get(EntryRow, str(entry_id))
203
+ if fresh is None:
204
+ raise HTTPException(status.HTTP_404_NOT_FOUND, detail="entry not found")
205
+ if body.card is not None:
206
+ fresh.card_json = _dump_card(body)
207
+ fresh.name = body.card.name
208
+ if body.url is not None:
209
+ fresh.url = body.url
210
+ if body.tags is not None:
211
+ fresh.tags_json = list(body.tags)
212
+ fresh.updated_at = datetime.now(UTC)
213
+ row = fresh
214
+ entry = row_to_entry(row)
215
+ await state.search.index(entry)
216
+ return entry
217
+
218
+
219
+ @router.delete("/agents/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
220
+ async def delete_entry(entry_id: uuid.UUID, state: State, caller: Caller) -> None:
221
+ row = await _load_visible(entry_id, state, caller)
222
+ _require_owner(row, caller, state.settings.auth_mode)
223
+ async with state.db.session() as session, session.begin():
224
+ fresh = await session.get(EntryRow, str(entry_id))
225
+ if fresh is not None:
226
+ await session.delete(fresh)
227
+ await state.search.remove(entry_id)
228
+
229
+
230
+ @router.get("/capabilities", response_model=Capabilities)
231
+ async def capabilities(state: State) -> Capabilities:
232
+ return Capabilities(
233
+ semantic_search=state.search.semantic_enabled,
234
+ auth=state.settings.auth_mode,
235
+ version=REGISTRY_VERSION,
236
+ )
237
+
238
+
239
+ @health_router.get("/healthz")
240
+ async def healthz() -> dict[str, str]:
241
+ return {"status": "ok"}
242
+
243
+
244
+ @health_router.get("/readyz")
245
+ async def readyz(request: Request) -> dict[str, str]:
246
+ state: RegistryState = request.app.state.registry
247
+ async with state.db.session() as session:
248
+ await session.execute(select(1))
249
+ return {"status": "ready"}
250
+
251
+
252
+ __all__ = ["RegistryState", "health_router", "router"]
@@ -0,0 +1,60 @@
1
+ """Registry app factory: standalone-capable, SQLite by default, auth optional."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+ from contextlib import asynccontextmanager
7
+
8
+ from fastapi import FastAPI
9
+
10
+ from agentplane_registry.api import RegistryState, health_router, router
11
+ from agentplane_registry.auth import Authenticator
12
+ from agentplane_registry.db import Database
13
+ from agentplane_registry.embeddings import EmbeddingsClient
14
+ from agentplane_registry.health import HealthJob
15
+ from agentplane_registry.search import RegistrySearch
16
+ from agentplane_registry.settings import REGISTRY_VERSION, RegistrySettings
17
+ from agentplane_registry.tracing import setup_tracing
18
+
19
+
20
+ def create_app(settings: RegistrySettings | None = None, *, run_health_job: bool = True) -> FastAPI:
21
+ """Build the registry service; everything optional degrades gracefully."""
22
+ cfg = settings or RegistrySettings()
23
+ db = Database(cfg.db_url)
24
+ embeddings = (
25
+ EmbeddingsClient(cfg.embeddings_base_url, cfg.embeddings_model)
26
+ if cfg.embeddings_base_url and cfg.embeddings_model
27
+ else None
28
+ )
29
+ search = RegistrySearch(db, embeddings)
30
+ health_job = HealthJob(db, cfg)
31
+
32
+ @asynccontextmanager
33
+ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
34
+ await db.create_all()
35
+ if run_health_job:
36
+ health_job.start()
37
+ try:
38
+ yield
39
+ finally:
40
+ if run_health_job:
41
+ await health_job.stop()
42
+ await db.dispose()
43
+
44
+ app = FastAPI(title="agentplane-registry", version=REGISTRY_VERSION, lifespan=lifespan)
45
+ app.state.registry = RegistryState(db=db, settings=cfg, search=search)
46
+ app.state.authenticator = Authenticator(cfg)
47
+ app.state.health_job = health_job
48
+ app.include_router(router)
49
+ app.include_router(health_router)
50
+ setup_tracing(app, service_name="agentplane-registry")
51
+ return app
52
+
53
+
54
+ def main() -> None: # pragma: no cover - process entrypoint
55
+ import uvicorn # noqa: PLC0415 - optional server dep, only needed for the entrypoint
56
+
57
+ uvicorn.run(create_app(), host="0.0.0.0", port=8100)
58
+
59
+
60
+ __all__ = ["create_app", "main"]
@@ -0,0 +1,139 @@
1
+ """Optional OIDC auth (SPEC §5.5): any issuer, JWKS cache, role mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass, field
7
+
8
+ import httpx
9
+ import jwt
10
+ from fastapi import HTTPException, Request, status
11
+ from jwt.types import Options
12
+
13
+ from agentplane_registry.settings import RegistrySettings
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Principal:
18
+ """Authenticated caller."""
19
+
20
+ sub: str
21
+ roles: frozenset[str] = field(default_factory=frozenset)
22
+ is_admin: bool = False
23
+
24
+
25
+ ANONYMOUS = Principal(sub="anonymous", roles=frozenset(), is_admin=False)
26
+
27
+
28
+ def _claim_path(claims: dict[str, object], path: str) -> object:
29
+ value: object = claims
30
+ for part in path.split("."):
31
+ if not isinstance(value, dict):
32
+ return None
33
+ value = value.get(part)
34
+ return value
35
+
36
+
37
+ class OidcValidator:
38
+ """Validates JWTs against a generic OIDC issuer (discovery + JWKS cache)."""
39
+
40
+ def __init__(
41
+ self,
42
+ issuer: str,
43
+ audience: str,
44
+ roles_claim: str,
45
+ admin_role: str,
46
+ *,
47
+ jwks_ttl_s: float = 300.0,
48
+ ) -> None:
49
+ self._issuer = issuer.rstrip("/")
50
+ self._audience = audience
51
+ self._roles_claim = roles_claim
52
+ self._admin_role = admin_role
53
+ self._jwks_ttl_s = jwks_ttl_s
54
+ self._jwks: dict[str, jwt.PyJWK] = {}
55
+ self._jwks_fetched_at = 0.0
56
+
57
+ async def _refresh_jwks(self) -> None:
58
+ async with httpx.AsyncClient(timeout=10.0) as client:
59
+ discovery = await client.get(f"{self._issuer}/.well-known/openid-configuration")
60
+ discovery.raise_for_status()
61
+ jwks_uri = discovery.json().get("jwks_uri")
62
+ if not isinstance(jwks_uri, str):
63
+ raise RuntimeError("issuer discovery returned no jwks_uri")
64
+ jwks_response = await client.get(jwks_uri)
65
+ jwks_response.raise_for_status()
66
+ keys = jwt.PyJWKSet.from_dict(jwks_response.json()).keys
67
+ self._jwks = {key.key_id: key for key in keys if key.key_id}
68
+ self._jwks_fetched_at = time.monotonic()
69
+
70
+ async def _key_for(self, kid: str) -> jwt.PyJWK:
71
+ stale = time.monotonic() - self._jwks_fetched_at > self._jwks_ttl_s
72
+ if kid not in self._jwks or stale:
73
+ await self._refresh_jwks()
74
+ key = self._jwks.get(kid)
75
+ if key is None:
76
+ raise jwt.InvalidTokenError(f"unknown key id {kid!r}")
77
+ return key
78
+
79
+ async def validate(self, token: str) -> Principal:
80
+ header = jwt.get_unverified_header(token)
81
+ kid = header.get("kid")
82
+ if not isinstance(kid, str):
83
+ raise jwt.InvalidTokenError("token has no kid header")
84
+ key = await self._key_for(kid)
85
+ options: Options = {"verify_aud": bool(self._audience)}
86
+ claims = jwt.decode(
87
+ token,
88
+ key=key.key,
89
+ algorithms=["RS256", "ES256"],
90
+ audience=self._audience or None,
91
+ issuer=self._issuer,
92
+ options=options,
93
+ )
94
+ roles_value = _claim_path(claims, self._roles_claim)
95
+ roles = (
96
+ frozenset(str(role) for role in roles_value)
97
+ if isinstance(roles_value, list)
98
+ else frozenset()
99
+ )
100
+ sub = claims.get("sub")
101
+ if not isinstance(sub, str) or not sub:
102
+ raise jwt.InvalidTokenError("token has no sub")
103
+ return Principal(sub=sub, roles=roles, is_admin=self._admin_role in roles)
104
+
105
+
106
+ class Authenticator:
107
+ """Resolves the request principal according to AUTH_MODE."""
108
+
109
+ def __init__(self, settings: RegistrySettings) -> None:
110
+ self.mode = settings.auth_mode
111
+ self._validator: OidcValidator | None = None
112
+ if self.mode == "oidc":
113
+ if not settings.oidc_issuer:
114
+ raise RuntimeError("AUTH_MODE=oidc requires AGENTPLANE_REGISTRY_OIDC_ISSUER")
115
+ self._validator = OidcValidator(
116
+ settings.oidc_issuer,
117
+ settings.oidc_audience,
118
+ settings.roles_claim,
119
+ settings.admin_role,
120
+ )
121
+
122
+ async def authenticate(self, request: Request) -> Principal:
123
+ if self.mode == "none" or self._validator is None:
124
+ return ANONYMOUS
125
+ header = request.headers.get("Authorization", "")
126
+ scheme, _, token = header.partition(" ")
127
+ if scheme.lower() != "bearer" or not token:
128
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="missing bearer token")
129
+ try:
130
+ return await self._validator.validate(token)
131
+ except jwt.InvalidTokenError as exc:
132
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
133
+ except httpx.HTTPError as exc:
134
+ raise HTTPException(
135
+ status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"issuer unreachable: {exc}"
136
+ ) from exc
137
+
138
+
139
+ __all__ = ["ANONYMOUS", "Authenticator", "OidcValidator", "Principal"]