Skip to content

Commit 005cbe2

Browse files
shivdeep1claude
andcommitted
add OAuth 2.1 authorization server with client credentials and token exchange
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6760e43 commit 005cbe2

22 files changed

Lines changed: 3378 additions & 1 deletion

airlock/config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,16 @@ class AirlockConfig(BaseSettings):
203203
# Event bus drain timeout during shutdown (seconds).
204204
event_bus_drain_timeout_seconds: float = Field(default=30.0, ge=1.0, le=600.0)
205205

206+
# -----------------------------------------------------------------------
207+
# OAuth 2.1
208+
# -----------------------------------------------------------------------
209+
oauth_enabled: bool = True
210+
oauth_required: bool = False
211+
oauth_token_ttl_seconds: int = Field(default=3600, ge=60, le=86400)
212+
oauth_max_delegation_depth: int = Field(default=5, ge=1, le=20)
213+
oauth_allowed_scopes: str = "verify:read,trust:write,agent:manage,delegation:exchange,compliance:read"
214+
oauth_dynamic_registration: bool = True
215+
206216
@property
207217
def is_production(self) -> bool:
208218
return self.env == "production"

airlock/gateway/app.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
236236
app.state.chain_registry = chain_registry
237237
app.state.precommit_store = precommit_store
238238

239+
# OAuth 2.1 store
240+
if cfg.oauth_enabled:
241+
from airlock.oauth.store import OAuthStore as _OAuthStore
242+
243+
app.state.oauth_store = _OAuthStore()
244+
else:
245+
app.state.oauth_store = None
246+
239247
# Argon2id bounded verification worker pool
240248
import asyncio as _asyncio
241249

@@ -312,6 +320,11 @@ def _cors_origins() -> list[str]:
312320

313321
register_a2a_routes(app)
314322

323+
if cfg.oauth_enabled:
324+
from airlock.gateway.oauth_routes import register_oauth_routes
325+
326+
register_oauth_routes(app)
327+
315328
if (cfg.admin_token or "").strip():
316329
from airlock.gateway.admin_routes import router as admin_router
317330

airlock/gateway/oauth_routes.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
from __future__ import annotations
2+
3+
"""FastAPI router for OAuth 2.1 endpoints."""
4+
5+
import logging
6+
7+
from fastapi import APIRouter, FastAPI, Request
8+
from fastapi.responses import JSONResponse
9+
10+
from airlock.oauth.discovery import build_discovery_metadata, build_jwks
11+
from airlock.oauth.introspection import introspect_token
12+
from airlock.oauth.models import TokenRequest
13+
from airlock.oauth.registration import RegistrationError, RegistrationRequest, register_client
14+
from airlock.oauth.server import OAuthError, handle_token_request
15+
16+
logger = logging.getLogger(__name__)
17+
18+
oauth_router = APIRouter(tags=["oauth"])
19+
20+
21+
@oauth_router.post("/oauth/token")
22+
async def token_endpoint(request: Request) -> JSONResponse:
23+
"""OAuth 2.1 token endpoint supporting client_credentials and token_exchange."""
24+
form = await request.form()
25+
token_request = TokenRequest(
26+
grant_type=str(form.get("grant_type", "")),
27+
client_assertion=str(form.get("client_assertion", "")) or None,
28+
client_assertion_type=str(form.get("client_assertion_type", "")) or None,
29+
scope=str(form.get("scope", "")) or None,
30+
subject_token=str(form.get("subject_token", "")) or None,
31+
subject_token_type=str(form.get("subject_token_type", "")) or None,
32+
)
33+
34+
kp = request.app.state.airlock_kp
35+
cfg = request.app.state.config
36+
oauth_store = request.app.state.oauth_store
37+
38+
base_url = (cfg.public_base_url or cfg.default_gateway_url).rstrip("/")
39+
token_endpoint_url = f"{base_url}/oauth/token"
40+
41+
# Trust score lookup from reputation store
42+
def _trust_lookup(did: str) -> tuple[float, int]:
43+
reputation = getattr(request.app.state, "reputation", None)
44+
if reputation is None:
45+
return 0.0, 0
46+
record = reputation.get(did)
47+
if record is None:
48+
return 0.0, 0
49+
return record.score, record.tier
50+
51+
try:
52+
response = handle_token_request(
53+
token_request,
54+
oauth_store=oauth_store,
55+
signing_key=kp.signing_key,
56+
verify_key=kp.verify_key,
57+
issuer_did=kp.did,
58+
token_endpoint=token_endpoint_url,
59+
ttl_seconds=cfg.oauth_token_ttl_seconds,
60+
max_delegation_depth=cfg.oauth_max_delegation_depth,
61+
allowed_scopes=cfg.oauth_allowed_scopes,
62+
trust_score_lookup=_trust_lookup,
63+
)
64+
except OAuthError as exc:
65+
return JSONResponse(
66+
status_code=exc.status_code,
67+
content={"error": exc.error, "error_description": exc.description},
68+
)
69+
70+
return JSONResponse(
71+
status_code=200,
72+
content=response.model_dump(),
73+
headers={"Cache-Control": "no-store", "Pragma": "no-cache"},
74+
)
75+
76+
77+
@oauth_router.post("/oauth/register")
78+
async def registration_endpoint(body: RegistrationRequest, request: Request) -> JSONResponse:
79+
"""Dynamic client registration (RFC 7591)."""
80+
cfg = request.app.state.config
81+
82+
if not cfg.oauth_dynamic_registration:
83+
return JSONResponse(
84+
status_code=403,
85+
content={"error": "registration_disabled", "error_description": "Dynamic registration is disabled"},
86+
)
87+
88+
oauth_store = request.app.state.oauth_store
89+
90+
try:
91+
response = register_client(
92+
body,
93+
oauth_store=oauth_store,
94+
allowed_scopes=cfg.oauth_allowed_scopes,
95+
)
96+
except RegistrationError as exc:
97+
return JSONResponse(
98+
status_code=400,
99+
content={"error": exc.error, "error_description": exc.description},
100+
)
101+
102+
return JSONResponse(status_code=201, content=response.model_dump(mode="json"))
103+
104+
105+
@oauth_router.post("/oauth/introspect")
106+
async def introspection_endpoint(request: Request) -> JSONResponse:
107+
"""RFC 7662 token introspection."""
108+
form = await request.form()
109+
token_str = str(form.get("token", ""))
110+
111+
if not token_str:
112+
return JSONResponse(
113+
status_code=400,
114+
content={"error": "invalid_request", "error_description": "token parameter is required"},
115+
)
116+
117+
kp = request.app.state.airlock_kp
118+
oauth_store = request.app.state.oauth_store
119+
120+
def _trust_lookup(did: str) -> tuple[float, int]:
121+
reputation = getattr(request.app.state, "reputation", None)
122+
if reputation is None:
123+
return 0.0, 0
124+
record = reputation.get(did)
125+
if record is None:
126+
return 0.0, 0
127+
return record.score, record.tier
128+
129+
response = introspect_token(
130+
token_str,
131+
verify_key=kp.verify_key,
132+
issuer_did=kp.did,
133+
oauth_store=oauth_store,
134+
trust_score_lookup=_trust_lookup,
135+
)
136+
137+
return JSONResponse(
138+
status_code=200,
139+
content=response.model_dump(by_alias=True, exclude_none=True),
140+
)
141+
142+
143+
@oauth_router.post("/oauth/revoke")
144+
async def revocation_endpoint(request: Request) -> JSONResponse:
145+
"""Token revocation endpoint."""
146+
form = await request.form()
147+
token_str = str(form.get("token", ""))
148+
149+
if not token_str:
150+
return JSONResponse(
151+
status_code=400,
152+
content={"error": "invalid_request", "error_description": "token parameter is required"},
153+
)
154+
155+
kp = request.app.state.airlock_kp
156+
oauth_store = request.app.state.oauth_store
157+
158+
# Try to decode and revoke
159+
from airlock.oauth.token_validator import validate_access_token
160+
161+
try:
162+
payload = validate_access_token(
163+
token_str,
164+
verify_key=kp.verify_key,
165+
expected_issuer=kp.did,
166+
)
167+
jti = payload.get("jti", "")
168+
if jti:
169+
oauth_store.revoke_cascade(jti)
170+
except Exception:
171+
# Per RFC 7009, always return 200 even if token is invalid
172+
pass
173+
174+
return JSONResponse(status_code=200, content={})
175+
176+
177+
@oauth_router.get("/.well-known/openid-configuration")
178+
async def openid_configuration(request: Request) -> JSONResponse:
179+
"""OIDC discovery metadata endpoint."""
180+
cfg = request.app.state.config
181+
kp = request.app.state.airlock_kp
182+
base_url = (cfg.public_base_url or cfg.default_gateway_url).rstrip("/")
183+
184+
metadata = build_discovery_metadata(
185+
base_url=base_url,
186+
issuer_did=kp.did,
187+
)
188+
189+
return JSONResponse(status_code=200, content=metadata)
190+
191+
192+
@oauth_router.get("/.well-known/jwks.json")
193+
async def jwks_endpoint(request: Request) -> JSONResponse:
194+
"""JWKS endpoint exposing the gateway's Ed25519 public key."""
195+
kp = request.app.state.airlock_kp
196+
197+
jwks = build_jwks(verify_key=kp.verify_key)
198+
199+
return JSONResponse(
200+
status_code=200,
201+
content=jwks,
202+
headers={"Cache-Control": "public, max-age=3600"},
203+
)
204+
205+
206+
def register_oauth_routes(app: FastAPI) -> None:
207+
"""Mount all OAuth routes onto the FastAPI application."""
208+
app.include_router(oauth_router)
209+
logger.info("OAuth 2.1 routes registered")

airlock/oauth/__init__.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from __future__ import annotations
2+
3+
"""OAuth 2.1 authorization server module for the Airlock Protocol.
4+
5+
Provides client credentials grant with Ed25519 private_key_jwt authentication,
6+
RFC 8693 token exchange for delegation chains, EdDSA-signed JWT access tokens
7+
with trust score claims, and OIDC discovery metadata.
8+
"""
9+
10+
from airlock.oauth.discovery import build_discovery_metadata, build_jwks
11+
from airlock.oauth.introspection import introspect_token
12+
from airlock.oauth.models import (
13+
AgentIdentity,
14+
IntrospectionResponse,
15+
OAuthClient,
16+
OAuthToken,
17+
TokenRequest,
18+
TokenResponse,
19+
)
20+
from airlock.oauth.registration import register_client
21+
from airlock.oauth.scopes import AIRLOCK_SCOPES, is_scope_subset, validate_scopes
22+
from airlock.oauth.server import handle_token_request
23+
from airlock.oauth.store import OAuthStore
24+
from airlock.oauth.token_generator import generate_access_token
25+
from airlock.oauth.token_validator import validate_access_token
26+
27+
__all__ = [
28+
"AIRLOCK_SCOPES",
29+
"AgentIdentity",
30+
"IntrospectionResponse",
31+
"OAuthClient",
32+
"OAuthStore",
33+
"OAuthToken",
34+
"TokenRequest",
35+
"TokenResponse",
36+
"build_discovery_metadata",
37+
"build_jwks",
38+
"generate_access_token",
39+
"handle_token_request",
40+
"introspect_token",
41+
"is_scope_subset",
42+
"register_client",
43+
"validate_access_token",
44+
"validate_scopes",
45+
]

0 commit comments

Comments
 (0)