diff --git a/json b/json index 69414ee..4fa23bd 100644 --- a/json +++ b/json @@ -1 +1 @@ -{"id":89,"owner":{"id":16,"login":"farhoodlabs","login_name":"","source_id":0,"full_name":"Farhood Labs","email":"","avatar_url":"https://git.farh.net/avatars/460b032d00536bd5638f3c0913c6a9d5","html_url":"https://git.farh.net/farhoodlabs","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2026-05-16T13:56:14Z","restricted":false,"active":false,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":0,"starred_repos_count":0,"username":"farhoodlabs"},"name":"intervalsicu-mcp","full_name":"farhoodlabs/intervalsicu-mcp","description":"Intervals.icu MCP server (FastMCP, native OAuth) — farhoodlabs build","empty":false,"private":true,"fork":false,"template":false,"mirror":false,"size":169,"language":"Python","languages_url":"https://git.farh.net/api/v1/repos/farhoodlabs/intervalsicu-mcp/languages","html_url":"https://git.farh.net/farhoodlabs/intervalsicu-mcp","url":"https://git.farh.net/api/v1/repos/farhoodlabs/intervalsicu-mcp","link":"","ssh_url":"git@git.farh.net:farhoodlabs/intervalsicu-mcp.git","clone_url":"https://git.farh.net/farhoodlabs/intervalsicu-mcp.git","original_url":"","website":"","stars_count":0,"forks_count":0,"watchers_count":8,"branch_count":1,"open_issues_count":0,"open_pr_counter":0,"release_counter":0,"default_branch":"main","archived":false,"created_at":"2026-07-04T17:57:36Z","updated_at":"2026-07-04T19:12:59Z","archived_at":"1970-01-01T00:00:00Z","permissions":{"admin":true,"push":true,"pull":true},"has_code":true,"has_issues":true,"internal_tracker":{"enable_time_tracker":true,"allow_only_contributors_to_track_time":true,"enable_issue_dependencies":true},"has_wiki":true,"has_pull_requests":true,"has_projects":true,"projects_mode":"all","has_releases":true,"has_packages":true,"has_actions":true,"ignore_whitespace_conflicts":false,"allow_merge_commits":true,"allow_rebase":true,"allow_rebase_explicit":true,"allow_squash_merge":true,"allow_fast_forward_only_merge":true,"allow_rebase_update":true,"allow_manual_merge":false,"autodetect_manual_merge":false,"default_delete_branch_after_merge":false,"default_merge_style":"merge","default_allow_maintainer_edit":true,"avatar_url":"","internal":false,"mirror_interval":"","object_format_name":"sha1","mirror_updated":"0001-01-01T00:00:00Z","topics":[],"licenses":[]} \ No newline at end of file +[{"name":"authorizationpolicy.yaml","path":"apps/intervals-mcp/authorizationpolicy.yaml","sha":"560aba90bc44be60582ab9e7ef09daec5f806c6e","last_commit_sha":"3712fab8f20a7c251fb23ff4b6033ba20097f5a9","last_committer_date":"2026-07-02T13:39:08-04:00","last_author_date":"2026-07-02T13:39:08-04:00","type":"file","size":321,"encoding":null,"content":null,"target":null,"url":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/contents/apps/intervals-mcp/authorizationpolicy.yaml?ref=main","html_url":"https://git.farh.net/farhoodliquor/infra/src/branch/main/apps/intervals-mcp/authorizationpolicy.yaml","git_url":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/git/blobs/560aba90bc44be60582ab9e7ef09daec5f806c6e","download_url":"https://git.farh.net/farhoodliquor/infra/raw/branch/main/apps/intervals-mcp/authorizationpolicy.yaml","submodule_git_url":null,"_links":{"self":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/contents/apps/intervals-mcp/authorizationpolicy.yaml?ref=main","git":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/git/blobs/560aba90bc44be60582ab9e7ef09daec5f806c6e","html":"https://git.farh.net/farhoodliquor/infra/src/branch/main/apps/intervals-mcp/authorizationpolicy.yaml"}},{"name":"deployment.yaml","path":"apps/intervals-mcp/deployment.yaml","sha":"a1a2fe59766dd4fae3e296e3619be6673a40fc9b","last_commit_sha":"77b3d1100a21142c82b25c92b523807922a574f8","last_committer_date":"2026-07-02T20:19:07-04:00","last_author_date":"2026-07-02T20:19:07-04:00","type":"file","size":6005,"encoding":null,"content":null,"target":null,"url":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/contents/apps/intervals-mcp/deployment.yaml?ref=main","html_url":"https://git.farh.net/farhoodliquor/infra/src/branch/main/apps/intervals-mcp/deployment.yaml","git_url":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/git/blobs/a1a2fe59766dd4fae3e296e3619be6673a40fc9b","download_url":"https://git.farh.net/farhoodliquor/infra/raw/branch/main/apps/intervals-mcp/deployment.yaml","submodule_git_url":null,"_links":{"self":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/contents/apps/intervals-mcp/deployment.yaml?ref=main","git":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/git/blobs/a1a2fe59766dd4fae3e296e3619be6673a40fc9b","html":"https://git.farh.net/farhoodliquor/infra/src/branch/main/apps/intervals-mcp/deployment.yaml"}},{"name":"httproute.yaml","path":"apps/intervals-mcp/httproute.yaml","sha":"4b26d09d4bf46afea316c89645ef93108c03c2c1","last_commit_sha":"3712fab8f20a7c251fb23ff4b6033ba20097f5a9","last_committer_date":"2026-07-02T13:39:08-04:00","last_author_date":"2026-07-02T13:39:08-04:00","type":"file","size":373,"encoding":null,"content":null,"target":null,"url":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/contents/apps/intervals-mcp/httproute.yaml?ref=main","html_url":"https://git.farh.net/farhoodliquor/infra/src/branch/main/apps/intervals-mcp/httproute.yaml","git_url":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/git/blobs/4b26d09d4bf46afea316c89645ef93108c03c2c1","download_url":"https://git.farh.net/farhoodliquor/infra/raw/branch/main/apps/intervals-mcp/httproute.yaml","submodule_git_url":null,"_links":{"self":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/contents/apps/intervals-mcp/httproute.yaml?ref=main","git":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/git/blobs/4b26d09d4bf46afea316c89645ef93108c03c2c1","html":"https://git.farh.net/farhoodliquor/infra/src/branch/main/apps/intervals-mcp/httproute.yaml"}},{"name":"kustomization.yaml","path":"apps/intervals-mcp/kustomization.yaml","sha":"02b5dfb99ba6315d71d2d484e593ed66e2936c90","last_commit_sha":"d3ccc85615d0575e7af3a5dd7eb0d6512df2edc2","last_committer_date":"2026-07-02T13:52:38-04:00","last_author_date":"2026-07-02T13:52:38-04:00","type":"file","size":223,"encoding":null,"content":null,"target":null,"url":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/contents/apps/intervals-mcp/kustomization.yaml?ref=main","html_url":"https://git.farh.net/farhoodliquor/infra/src/branch/main/apps/intervals-mcp/kustomization.yaml","git_url":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/git/blobs/02b5dfb99ba6315d71d2d484e593ed66e2936c90","download_url":"https://git.farh.net/farhoodliquor/infra/raw/branch/main/apps/intervals-mcp/kustomization.yaml","submodule_git_url":null,"_links":{"self":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/contents/apps/intervals-mcp/kustomization.yaml?ref=main","git":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/git/blobs/02b5dfb99ba6315d71d2d484e593ed66e2936c90","html":"https://git.farh.net/farhoodliquor/infra/src/branch/main/apps/intervals-mcp/kustomization.yaml"}},{"name":"service.yaml","path":"apps/intervals-mcp/service.yaml","sha":"6778da44087b9d2fbf722da697a72244a2b589c2","last_commit_sha":"3712fab8f20a7c251fb23ff4b6033ba20097f5a9","last_committer_date":"2026-07-02T13:39:08-04:00","last_author_date":"2026-07-02T13:39:08-04:00","type":"file","size":365,"encoding":null,"content":null,"target":null,"url":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/contents/apps/intervals-mcp/service.yaml?ref=main","html_url":"https://git.farh.net/farhoodliquor/infra/src/branch/main/apps/intervals-mcp/service.yaml","git_url":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/git/blobs/6778da44087b9d2fbf722da697a72244a2b589c2","download_url":"https://git.farh.net/farhoodliquor/infra/raw/branch/main/apps/intervals-mcp/service.yaml","submodule_git_url":null,"_links":{"self":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/contents/apps/intervals-mcp/service.yaml?ref=main","git":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/git/blobs/6778da44087b9d2fbf722da697a72244a2b589c2","html":"https://git.farh.net/farhoodliquor/infra/src/branch/main/apps/intervals-mcp/service.yaml"}},{"name":"ss-intervals-credentials.yaml","path":"apps/intervals-mcp/ss-intervals-credentials.yaml","sha":"34a78e397e9ef8138520636c1b67b2710bdfec31","last_commit_sha":"3712fab8f20a7c251fb23ff4b6033ba20097f5a9","last_committer_date":"2026-07-02T13:39:08-04:00","last_author_date":"2026-07-02T13:39:08-04:00","type":"file","size":1741,"encoding":null,"content":null,"target":null,"url":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/contents/apps/intervals-mcp/ss-intervals-credentials.yaml?ref=main","html_url":"https://git.farh.net/farhoodliquor/infra/src/branch/main/apps/intervals-mcp/ss-intervals-credentials.yaml","git_url":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/git/blobs/34a78e397e9ef8138520636c1b67b2710bdfec31","download_url":"https://git.farh.net/farhoodliquor/infra/raw/branch/main/apps/intervals-mcp/ss-intervals-credentials.yaml","submodule_git_url":null,"_links":{"self":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/contents/apps/intervals-mcp/ss-intervals-credentials.yaml?ref=main","git":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/git/blobs/34a78e397e9ef8138520636c1b67b2710bdfec31","html":"https://git.farh.net/farhoodliquor/infra/src/branch/main/apps/intervals-mcp/ss-intervals-credentials.yaml"}},{"name":"terraform-authentik.tf","path":"apps/intervals-mcp/terraform-authentik.tf","sha":"c3f697a67fa29533378cdba6dad0756bf57ba5ef","last_commit_sha":"ae5aa3394a254d70a1197e54446a5df520a95ff3","last_committer_date":"2026-07-02T15:50:52-04:00","last_author_date":"2026-07-02T15:50:52-04:00","type":"file","size":3803,"encoding":null,"content":null,"target":null,"url":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/contents/apps/intervals-mcp/terraform-authentik.tf?ref=main","html_url":"https://git.farh.net/farhoodliquor/infra/src/branch/main/apps/intervals-mcp/terraform-authentik.tf","git_url":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/git/blobs/c3f697a67fa29533378cdba6dad0756bf57ba5ef","download_url":"https://git.farh.net/farhoodliquor/infra/raw/branch/main/apps/intervals-mcp/terraform-authentik.tf","submodule_git_url":null,"_links":{"self":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/contents/apps/intervals-mcp/terraform-authentik.tf?ref=main","git":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/git/blobs/c3f697a67fa29533378cdba6dad0756bf57ba5ef","html":"https://git.farh.net/farhoodliquor/infra/src/branch/main/apps/intervals-mcp/terraform-authentik.tf"}},{"name":"terraform-authentik.yaml","path":"apps/intervals-mcp/terraform-authentik.yaml","sha":"e98be612a6b1decc64cc8e910ca95942b8221f8a","last_commit_sha":"3712fab8f20a7c251fb23ff4b6033ba20097f5a9","last_committer_date":"2026-07-02T13:39:08-04:00","last_author_date":"2026-07-02T13:39:08-04:00","type":"file","size":927,"encoding":null,"content":null,"target":null,"url":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/contents/apps/intervals-mcp/terraform-authentik.yaml?ref=main","html_url":"https://git.farh.net/farhoodliquor/infra/src/branch/main/apps/intervals-mcp/terraform-authentik.yaml","git_url":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/git/blobs/e98be612a6b1decc64cc8e910ca95942b8221f8a","download_url":"https://git.farh.net/farhoodliquor/infra/raw/branch/main/apps/intervals-mcp/terraform-authentik.yaml","submodule_git_url":null,"_links":{"self":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/contents/apps/intervals-mcp/terraform-authentik.yaml?ref=main","git":"https://git.farh.net/api/v1/repos/farhoodliquor/infra/git/blobs/e98be612a6b1decc64cc8e910ca95942b8221f8a","html":"https://git.farh.net/farhoodliquor/infra/src/branch/main/apps/intervals-mcp/terraform-authentik.yaml"}}] \ No newline at end of file diff --git a/src/intervals_mcp_server/auth.py b/src/intervals_mcp_server/auth.py index 2ebcd1f..3fcab70 100644 --- a/src/intervals_mcp_server/auth.py +++ b/src/intervals_mcp_server/auth.py @@ -18,8 +18,13 @@ import os 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: - """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]): import jwt # PyJWT @@ -35,20 +40,31 @@ class AuthentikTokenVerifier: try: 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( token, key, - algorithms=["RS256"], + algorithms=_ALGORITHMS, issuer=self._issuer, - audience=self._audience, - options={"require": ["exp", "iat", "iss", "aud"]}, + options={"require": ["exp", "iat", "iss"], "verify_aud": False}, ) except Exception as exc: # noqa: BLE001 - any failure means unauthenticated logger.debug("Token verification failed: %s", exc) return None 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( token=token, client_id=claims.get("azp") or resource, diff --git a/tests/test_auth.py b/tests/test_auth.py index ab838e2..c8a9eac 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,10 +1,10 @@ """ Tests for intervals_mcp_server.auth — native OAuth token verification. -Covers the real security logic: a valid RS256 JWT yields an AccessToken with the -right claims; tampered / expired / wrong-audience / wrong-key tokens yield None; -the audience accepts both trailing-slash forms; and build_auth only enables auth -when the environment is configured. +Covers the real security logic: a valid JWT (RS256 or Better Auth's EdDSA) yields +an AccessToken with the right claims; tampered / expired / wrong-issuer / wrong-key +tokens yield None; audience is checked softly (single resource); and build_auth +only enables auth when the environment is configured. """ import asyncio @@ -13,7 +13,7 @@ import types import jwt 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.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 -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() - v = _verifier(pub, ["https://someone-else"]) # verifier expects a different aud - tok = _token(priv, aud=RESOURCE) - assert asyncio.run(v.verify_token(tok)) is None + v = _verifier(pub, ["https://someone-else"]) + tok = _token(priv, aud="https://some-other-resource") + 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():