auth: accept EdDSA tokens + soft audience check (Better Auth compatibility)
build-image / test (push) Successful in 15s
build-image / build (push) Successful in 20s

Better Auth signs access tokens with EdDSA (Ed25519), not RS256. Accept EdDSA
(+ RS256/ES256), and validate issuer + signature + expiry strictly while checking
audience softly — single-resource server behind a dedicated AS with dynamic DCR
client ids, so issuer + signature is the trust boundary. Adds an EdDSA test.
This commit is contained in:
2026-07-05 21:55:52 -04:00
parent 8f3abbde8f
commit e50be99374
3 changed files with 54 additions and 15 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+21 -5
View File
@@ -18,8 +18,13 @@ import os
logger = logging.getLogger("intervals_icu_mcp_server") logger = logging.getLogger("intervals_icu_mcp_server")
# Algorithms we accept. Better Auth signs with EdDSA (Ed25519); RS256/ES256 are
# kept so the same verifier works against other OAuth servers.
_ALGORITHMS = ["EdDSA", "RS256", "ES256"]
class AuthentikTokenVerifier: class AuthentikTokenVerifier:
"""Verify RS256 Bearer JWTs against a JWKS endpoint (RFC 9068 style).""" """Verify Bearer JWTs against a JWKS endpoint (RFC 9068 style)."""
def __init__(self, jwks_uri: str, issuer: str, audience: list[str]): def __init__(self, jwks_uri: str, issuer: str, audience: list[str]):
import jwt # PyJWT import jwt # PyJWT
@@ -35,20 +40,31 @@ class AuthentikTokenVerifier:
try: try:
key = self._jwks.get_signing_key_from_jwt(token).key key = self._jwks.get_signing_key_from_jwt(token).key
# Validate issuer + signature + expiry strictly. Audience is checked
# softly below: this is a single-resource server behind a dedicated
# authorization server, and DCR clients have dynamic ids, so a valid
# signature + our issuer already establishes the token is for us.
claims = jwt.decode( claims = jwt.decode(
token, token,
key, key,
algorithms=["RS256"], algorithms=_ALGORITHMS,
issuer=self._issuer, issuer=self._issuer,
audience=self._audience, options={"require": ["exp", "iat", "iss"], "verify_aud": False},
options={"require": ["exp", "iat", "iss", "aud"]},
) )
except Exception as exc: # noqa: BLE001 - any failure means unauthenticated except Exception as exc: # noqa: BLE001 - any failure means unauthenticated
logger.debug("Token verification failed: %s", exc) logger.debug("Token verification failed: %s", exc)
return None return None
aud = claims.get("aud") aud = claims.get("aud")
resource = aud[0] if isinstance(aud, list) else aud auds = aud if isinstance(aud, list) else ([aud] if aud else [])
if auds and not any(a in self._audience for a in auds):
logger.warning(
"Token audience %s not in accepted %s; accepting (single resource).",
auds,
self._audience,
)
resource = auds[0] if auds else self._audience[0]
return AccessToken( return AccessToken(
token=token, token=token,
client_id=claims.get("azp") or resource, client_id=claims.get("azp") or resource,
+32 -9
View File
@@ -1,10 +1,10 @@
""" """
Tests for intervals_mcp_server.auth — native OAuth token verification. Tests for intervals_mcp_server.auth — native OAuth token verification.
Covers the real security logic: a valid RS256 JWT yields an AccessToken with the Covers the real security logic: a valid JWT (RS256 or Better Auth's EdDSA) yields
right claims; tampered / expired / wrong-audience / wrong-key tokens yield None; an AccessToken with the right claims; tampered / expired / wrong-issuer / wrong-key
the audience accepts both trailing-slash forms; and build_auth only enables auth tokens yield None; audience is checked softly (single resource); and build_auth
when the environment is configured. only enables auth when the environment is configured.
""" """
import asyncio import asyncio
@@ -13,7 +13,7 @@ import types
import jwt import jwt
import pytest import pytest
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
from intervals_mcp_server import auth as auth_mod from intervals_mcp_server import auth as auth_mod
from intervals_mcp_server.auth import AuthentikTokenVerifier, _audience_variants, build_auth from intervals_mcp_server.auth import AuthentikTokenVerifier, _audience_variants, build_auth
@@ -76,11 +76,34 @@ def test_expired_token_rejected():
assert asyncio.run(v.verify_token(tok)) is None assert asyncio.run(v.verify_token(tok)) is None
def test_wrong_audience_rejected(): def test_wrong_audience_tolerated_single_resource():
# Audience is checked softly: this is a single-resource server behind a
# dedicated authorization server with dynamic (DCR) client ids, so a genuine
# token — correct issuer + signature + not expired — is accepted even if its
# audience differs. The trust boundary is issuer + signature (asserted by the
# wrong-issuer / wrong-key tests below); a mismatch is logged, not rejected.
priv, pub = _keypair() priv, pub = _keypair()
v = _verifier(pub, ["https://someone-else"]) # verifier expects a different aud v = _verifier(pub, ["https://someone-else"])
tok = _token(priv, aud=RESOURCE) tok = _token(priv, aud="https://some-other-resource")
assert asyncio.run(v.verify_token(tok)) is None result = asyncio.run(v.verify_token(tok))
assert result is not None
assert result.subject == "user-123"
def test_eddsa_token_accepted():
# Better Auth signs access tokens with EdDSA (Ed25519); the verifier must
# accept them, not just RS256.
priv = ed25519.Ed25519PrivateKey.generate()
v = _verifier(priv.public_key(), [RESOURCE])
now = int(time.time())
tok = jwt.encode(
{"iss": ISSUER, "aud": RESOURCE, "exp": now + 3600, "iat": now, "sub": "user-ed"},
priv,
algorithm="EdDSA",
)
result = asyncio.run(v.verify_token(tok))
assert result is not None
assert result.subject == "user-ed"
def test_wrong_issuer_rejected(): def test_wrong_issuer_rejected():