OAuth Discovery¶
RFC 9728 Protected Resource Metadata and JWT authentication for vgi-rpc HTTP services.
Quick Overview¶
vgi-rpc HTTP servers can advertise their OAuth configuration so clients discover auth requirements automatically — no out-of-band configuration needed.
Server setup¶
from vgi_rpc.http import OAuthResourceMetadata, jwt_authenticate, make_wsgi_app
from vgi_rpc import RpcServer
metadata = OAuthResourceMetadata(
resource="https://api.example.com/vgi",
authorization_servers=("https://auth.example.com",),
scopes_supported=("read", "write"),
)
auth = jwt_authenticate(
issuer="https://auth.example.com",
audience="https://api.example.com/vgi",
)
server = RpcServer(MyService, MyServiceImpl())
app = make_wsgi_app(
server,
authenticate=auth,
oauth_resource_metadata=metadata,
)
Client discovery¶
from vgi_rpc.http import http_oauth_metadata, http_connect
meta = http_oauth_metadata("https://api.example.com")
print(meta.authorization_servers) # ("https://auth.example.com",)
with http_connect(MyService, "https://api.example.com") as svc:
result = svc.protected_method()
How It Works¶
- Server serves
/.well-known/oauth-protected-resource(RFC 9728) - 401 responses include
WWW-Authenticate: Bearer resource_metadata="..."(and optionallyclient_id="...",client_secret="...",use_id_token_as_bearer="true") - Client fetches metadata to discover authorization server(s)
- Client authenticates with the AS and sends Bearer token
Discovery from a 401¶
If a client doesn't know the server's auth requirements upfront, it can discover them from a 401 response:
from vgi_rpc.http import parse_resource_metadata_url, parse_client_id, parse_client_secret, parse_use_id_token_as_bearer, fetch_oauth_metadata
# 1. Make a request that returns 401
resp = client.post("/vgi/my_method", ...)
# 2. Parse the metadata URL, optional client_id, client_secret, and use_id_token_as_bearer from WWW-Authenticate header
www_auth = resp.headers["www-authenticate"]
metadata_url = parse_resource_metadata_url(www_auth)
# "https://api.example.com/.well-known/oauth-protected-resource/vgi"
client_id = parse_client_id(www_auth) # e.g. "my-app" or None
client_secret = parse_client_secret(www_auth) # e.g. "my-secret" or None
use_id_token = parse_use_id_token_as_bearer(www_auth) # True or False
# 3. Fetch the metadata
meta = fetch_oauth_metadata(metadata_url)
print(meta.authorization_servers) # use these to authenticate
print(meta.client_id) # also available from the metadata document
print(meta.client_secret) # also available from the metadata document
OAuthResourceMetadata¶
Frozen dataclass configuring the server's RFC 9728 metadata document.
Pass to make_wsgi_app(oauth_resource_metadata=...) to enable OAuth discovery.
| Field | Type | Required | Description |
|---|---|---|---|
resource |
str |
Yes | Canonical URL of the protected resource |
authorization_servers |
tuple[str, ...] |
Yes | Authorization server issuer URLs (must be non-empty) |
scopes_supported |
tuple[str, ...] |
No | OAuth scopes the resource understands |
bearer_methods_supported |
tuple[str, ...] |
No | Token delivery methods (default ("header",)) |
resource_signing_alg_values_supported |
tuple[str, ...] |
No | JWS algorithms for signed responses |
resource_name |
str \| None |
No | Human-readable name |
resource_documentation |
str \| None |
No | URL to developer docs |
resource_policy_uri |
str \| None |
No | URL to privacy policy |
resource_tos_uri |
str \| None |
No | URL to terms of service |
client_id |
str \| None |
No | OAuth client_id for auth server (custom extension, not in RFC 9728) |
client_secret |
str \| None |
No | OAuth client_secret for auth server (custom extension, not in RFC 9728). Intended for public/PKCE clients (e.g. Google OAuth) where the secret is not truly confidential. |
use_id_token_as_bearer |
bool |
No | When True, clients should use the OIDC id_token as the Bearer token instead of access_token (custom extension, not in RFC 9728) |
Raises ValueError if resource is empty or authorization_servers is empty.
Bearer Token Authentication¶
For API keys, opaque tokens, or any non-JWT bearer token, use bearer_authenticate.
No extra dependencies beyond vgi-rpc[http].
bearer_authenticate()¶
Factory that creates a bearer-token authenticate callback with a custom validate function.
Supports any validation logic: database lookups, introspection endpoints, expiry checks, etc.
from vgi_rpc.http import bearer_authenticate, make_wsgi_app
from vgi_rpc import AuthContext, RpcServer
def validate(token: str) -> AuthContext:
# Look up token in database, call an introspection endpoint, etc.
user = db.get_user_by_api_key(token)
if user is None:
raise ValueError("Invalid API key")
return AuthContext(
domain="apikey",
authenticated=True,
principal=user.name,
claims={"role": user.role},
)
auth = bearer_authenticate(validate=validate)
server = RpcServer(MyService, MyServiceImpl())
app = make_wsgi_app(server, authenticate=auth)
| Parameter | Type | Description |
|---|---|---|
validate |
Callable[[str], AuthContext] |
Receives the raw token, returns AuthContext on success, raises ValueError on failure |
bearer_authenticate_static()¶
Convenience wrapper for a fixed set of known tokens. Useful for development, testing, or services with a small number of pre-shared API keys.
from vgi_rpc.http import bearer_authenticate_static, make_wsgi_app
from vgi_rpc import AuthContext, RpcServer
tokens = {
"key-abc123": AuthContext(domain="apikey", authenticated=True, principal="alice"),
"key-def456": AuthContext(domain="apikey", authenticated=True, principal="bob",
claims={"role": "admin"}),
}
auth = bearer_authenticate_static(tokens=tokens)
server = RpcServer(MyService, MyServiceImpl())
app = make_wsgi_app(server, authenticate=auth)
| Parameter | Type | Description |
|---|---|---|
tokens |
Mapping[str, AuthContext] |
Maps bearer token strings to pre-built AuthContext values |
chain_authenticate()¶
Compose multiple authenticate callbacks into a single callback.
Authenticators are tried in order — ValueError (bad credentials) falls through
to the next; PermissionError or other exceptions propagate immediately.
This lets you accept both JWT and API key tokens on the same server:
from vgi_rpc.http import (
bearer_authenticate_static,
chain_authenticate,
jwt_authenticate,
make_wsgi_app,
)
from vgi_rpc import AuthContext, RpcServer
# Accept JWTs from your identity provider
jwt_auth = jwt_authenticate(
issuer="https://auth.example.com",
audience="https://api.example.com/vgi",
)
# Also accept static API keys
api_key_auth = bearer_authenticate_static(tokens={
"sk-service-account": AuthContext(
domain="apikey", authenticated=True, principal="ci-bot",
),
})
# Try JWT first, fall back to API key lookup
auth = chain_authenticate(jwt_auth, api_key_auth)
server = RpcServer(MyService, MyServiceImpl())
app = make_wsgi_app(server, authenticate=auth)
| Behaviour | Exception | Result |
|---|---|---|
| Credentials accepted | (none) | Returns AuthContext, stops chain |
| Bad / missing credentials | ValueError |
Tries next authenticator |
| Authenticated but forbidden | PermissionError |
Propagates immediately (401) |
| Bug in authenticator | Any other exception | Propagates immediately (500) |
Raises ValueError at construction time if called with no authenticators.
jwt_authenticate()¶
Factory that creates a JWT-validating authenticate callback using Authlib.
from vgi_rpc.http import jwt_authenticate
auth = jwt_authenticate(
issuer="https://auth.example.com",
audience="https://api.example.com/vgi",
jwks_uri="https://auth.example.com/.well-known/jwks.json", # optional
principal_claim="sub", # default
domain="jwt", # default
)
| Parameter | Type | Default | Description |
|---|---|---|---|
issuer |
str |
required | Expected iss claim |
audience |
str |
required | Expected aud claim |
jwks_uri |
str \| None |
None |
JWKS URL (discovered from OIDC if None) |
claims_options |
Mapping \| None |
None |
Additional Authlib claim options |
principal_claim |
str |
"sub" |
JWT claim for AuthContext.principal |
domain |
str |
"jwt" |
Domain for AuthContext |
JWKS caching: Keys are fetched lazily on first request and cached in-process.
On unknown kid (decode error), keys are automatically refreshed once. Other
validation failures (expired token, wrong issuer/audience) fail immediately
without a network round-trip.
Requires pip install vgi-rpc[oauth] — raises a clear ImportError with
install instructions if Authlib is not available.
http_oauth_metadata()¶
Client-side function to discover a server's OAuth configuration.
from vgi_rpc.http import http_oauth_metadata
meta = http_oauth_metadata("https://api.example.com")
if meta is not None:
print(meta.authorization_servers)
Returns OAuthResourceMetadataResponse or None (if server returns 404).
Note: The prefix parameter (default "/vgi") must match the server's
make_wsgi_app(prefix=...). A mismatch results in a 404 (None return).
fetch_oauth_metadata()¶
Fetch metadata from an explicit URL (typically from a 401 WWW-Authenticate header).
from vgi_rpc.http import fetch_oauth_metadata
meta = fetch_oauth_metadata("https://api.example.com/.well-known/oauth-protected-resource/vgi")
parse_resource_metadata_url()¶
Extract the resource_metadata URL from a WWW-Authenticate header.
from vgi_rpc.http import parse_resource_metadata_url
url = parse_resource_metadata_url('Bearer resource_metadata="https://..."')
# "https://..."
Returns None if the header doesn't contain resource_metadata.
parse_client_id()¶
Extract the client_id from a WWW-Authenticate header. Custom extension (not in RFC 9728).
from vgi_rpc.http import parse_client_id
client_id = parse_client_id('Bearer resource_metadata="https://...", client_id="my-app"')
# "my-app"
Returns None if the header doesn't contain client_id.
parse_client_secret()¶
Extract the client_secret from a WWW-Authenticate header. Custom extension (not in RFC 9728).
from vgi_rpc.http import parse_client_secret
client_secret = parse_client_secret('Bearer resource_metadata="https://...", client_secret="my-secret"')
# "my-secret"
Returns None if the header doesn't contain client_secret.
parse_use_id_token_as_bearer()¶
Extract the use_id_token_as_bearer flag from a WWW-Authenticate header. Custom extension (not in RFC 9728).
from vgi_rpc.http import parse_use_id_token_as_bearer
use_id_token = parse_use_id_token_as_bearer('Bearer resource_metadata="https://...", use_id_token_as_bearer="true"')
# True
Returns False if the header doesn't contain use_id_token_as_bearer.
OAuthResourceMetadataResponse¶
Frozen dataclass returned by http_oauth_metadata() and fetch_oauth_metadata().
Same fields as OAuthResourceMetadata (the server-side config class), including client_id, client_secret, and use_id_token_as_bearer.
Standards Compliance¶
- RFC 9728 — OAuth 2.0 Protected Resource Metadata
- RFC 8414 — OAuth 2.0 Authorization Server Metadata
- RFC 6750 — Bearer Token Usage
- Compatible with MCP's OAuth implementation
- Custom extensions:
client_id,client_secret, anduse_id_token_as_bearerfields onOAuthResourceMetadata/OAuthResourceMetadataResponseand inWWW-Authenticateheaders are not defined in RFC 9728