Repository navigation
feat: opt-in encrypted Redis store for OAuth proxy state (#52) - #53
Merged
Merged
Conversation
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
REDIS_URL wires a Fernet-encrypted RedisStore into AzureProvider so client registrations and tokens survive container recreation, scale-to-zero and multiple replicas. JWT_SIGNING_KEY decouples signing from CLIENT_SECRET. Unset, the provider is built exactly as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…#52) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…scopes (#52) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Blank signing keys create predictable encryption, and the required refresh-after-restart flow lacks integration coverage.
Review effort: Balanced
Findings: 1
Open (2)
What changed in this PR
Adds optional encrypted Redis persistence for OAuth proxy state, enabling resilient authentication across restarts and replicas.
Changes:
- Adds Redis-backed encrypted OAuth storage and fixed signing/encryption keys.
- Documents deployment configuration and migration behavior.
- Adds unit, integration, and CI Redis coverage.
| File | Description |
|---|---|
src/config.py |
Adds Redis and encryption settings. |
src/auth_provider.py |
Configures encrypted Redis storage. |
tests/test_config.py |
Tests storage configuration validation. |
tests/test_auth_provider.py |
Tests provider storage wiring. |
tests/test_redis_storage_integration.py |
Tests Redis persistence, encryption, and TTLs. |
.github/workflows/ci.yml |
Adds a Redis CI service. |
pyproject.toml |
Adds the Redis storage dependency. |
uv.lock |
Locks Redis dependencies. |
README.md |
Documents persistent OAuth storage. |
.env.example |
Adds example Redis settings. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…st isolation
- env_ignore_empty: JWT_SIGNING_KEY= no longer becomes SecretStr('') and
derives a publicly computable Redis key (Opus HIGH)
- JWT_SIGNING_KEY min 32 chars; REDIS_URL path must be a DB index; REDIS_URL
hidden from repr
- derive the JWT key once and pass bytes (no double 1M-iteration PBKDF2)
- explicit Redis timeouts (2 s) and retry budget (2) instead of redis-py's
implicit 5 s x 10
- make_provider_kwargs imports before patching: test_auth_provider.py failed
when run alone (Fable HIGH)
- real-Redis test: a rotated key reads as a miss, not an exception
- fastmcp<4 (derived key mirrors its scheme); narrow the setex filter
- docs: percent-encoding, no Redis Cluster, registrations never expire,
set STORAGE_ENCRYPTION_KEY explicitly in production
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eview) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Member
Author
Dual-model review (Opus + Fable): findings and fixesBoth reviewers returned REQUEST_CHANGES. Each found a different HIGH. Both HIGHs and every consensus and MEDIUM finding are fixed in
Not changed, with reason:
Verification after the fixes:
|
…token after restart (#53 Copilot review) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Closes #52.
Adds an opt-in, encrypted Redis store for the OAuth proxy's state. With it, sign-ins survive container recreation, scale-to-zero and multiple replicas. A fixed
JWT_SIGNING_KEYalso lets you rotate the Entra secret without signing everyone out. With nothing set, the provider is built exactly as before.Evidence (driven against real Entra + Graph; tokens redacted)
The client's own
POST /tokenrefresh_tokengrant, replayed verbatim:main)REDIS_URLset)401 {"error":"invalid_client","error_description":"Invalid client_id"}200 {"access_token":"<redacted>","token_type":"Bearer","expires_in":5208,...}BASE_URL+ Redis) refreshing a token replica A issued401 invalid_client¹200, thenget_mewith B's new token → OKCLIENT_SECRET¹401 invalid_client(registration unknown)401 invalid_grant: registration still readable (withJWT_SIGNING_KEY+STORAGE_ENCRYPTION_KEYset)REDIS_URL+ both keys,docker rm -f+ new container, refresh200, thenget_mewith the new token → OKdocker rm -f+docker runof the built image, thenget_mewith the access token issued before both restartsget_me -> {'displayName': 'Martin Laukkanen', ...}¹ Probed with a deliberately bogus refresh token:
invalid_grantmeans a known client,invalid_clientan unknown one. The rotation row therefore proves the registration survives a secret rotation, not a real refresh (with a rotated fake secret, Entra would reject the upstream refresh). FastMCP rotates the refresh token on every use, so reusing a consumed one returnsinvalid_grantby design.What lands in Redis after a real sign-in (all entries
__encrypted_data__, 0 in plaintext). TTLs are FastMCP's own:Root cause
src/auth_provider.pybuiltAzureProviderwithoutclient_storageorjwt_signing_key. FastMCP then keeps all OAuth state in aFileTreeStoreinside the container, at a path derived fromCLIENT_SECRET. Recreating the container, scaling to zero, adding a replica, or rotating the secret each lose that state.What changed
flowchart LR S[Settings] -->|REDIS_URL unset| D["AzureProvider(client_storage=None)<br/>FastMCP file store, as today"] S -->|REDIS_URL set| R["Redis.from_url(REDIS_URL)<br/>(keeps rediss:// TLS)"] R --> RS[RedisStore] K["STORAGE_ENCRYPTION_KEY<br/>or derived like FastMCP"] --> F[FernetEncryptionWrapper<br/>raise_on_decryption_error=False] RS --> F --> P["AzureProvider(client_storage=…,<br/>jwt_signing_key=JWT_SIGNING_KEY)"]src/config.py: addsREDIS_URL(RedisDsn:redis:///rediss://only),JWT_SIGNING_KEYandSTORAGE_ENCRYPTION_KEY(bothSecretStr).hide_input_in_errors=True, so a malformed URL never prints the Redis password.STORAGE_ENCRYPTION_KEYwithoutREDIS_URLrefuses to start, as decided at the gate.env_ignore_empty=Trueapplies to all settings: an emptyVAR=now means "use the default" (e.g.ALLOWED_ORIGINS=falls back to the default rather than failing validation).src/auth_provider.py:_redis_client_storage()follows FastMCP's documented production pattern. With no explicit key, the encryption key is derived exactly as FastMCP derives its default store key, reusing itsderive_jwt_keywith the same salts.RedisStore(url=...)rebuilds the client from host, port, db and password and drops TLS.rediss://yields a plaintextConnectionand fails against Azure Redis. So the client is built withRedis.from_url, which yields anSSLConnection, and that is pinned by a test.RedisStoreandFernetEncryptionWrapperfrom the already-lockedpy-key-value-aio0.4.4, which now gets the[redis]extra (addsredis).derive_jwt_keycomes from FastMCP..env.exampleexplain when Redis is needed, thatREDIS_URLis a credential, and that enabling it signs everyone out once (no migration, as decided at the gate).Edge cases driven
REDIS_URL→ValidationError: REDIS_URL — URL scheme should be 'redis' or 'rediss', password not echoed.STORAGE_ENCRYPTION_KEY requires REDIS_URL.401 invalid_client(reconnect) with no traceback.500in 0.16 s, with a log namingredis.exceptions.ConnectionError ... :6380. Redis hung →500in 4.05 s (explicit 2 s timeouts + 1 retry; main's implicit defaults measured 5.0 s). Redis back → works again with no server restart. A 500 (retry) is deliberate here: a 401 would make clients discard their sign-in.JWT_SIGNING_KEY=(empty) → treated as unset. A key shorter than 32 characters or whitespace-only, or a non-numeric DB path, → startup error.list_tools→ 18 tools, andlist_my_tasksreturns your tasks through the Redis-backed container.Tests
tests/test_config.py: defaults,redis/redissaccepted, invalid URL fails without leaking it, key-without-Redis, bad Fernet key.tests/test_auth_provider.py: noREDIS_URL→client_storage=None,jwt_signing_key=None.REDIS_URL→ encryptedRedisStore(TLS kept forrediss://, DB index from the path).JWT_SIGNING_KEYforwarded. Encryption key: explicit, derived from the secret, and derived fromJWT_SIGNING_KEY.tests/test_redis_storage_integration.py(real Redis; CI gains aredis:7-alpineservice, and the test skips withoutREDIS_TEST_URL):exchange_authorization_code/exchange_refresh_token, with only Entra's token endpoint stubbed (mutation-checked)/ponytail-review: one shrink applied (the consent-wiring test reuses the new helper, -14 lines). The Fernet validator and the FastMCP-mirroring key derivation are kept on purpose.🤖 Generated with Claude Code