You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Sub-task 2 of 3 for Phase 3 of the DCR story (#4976, parent #4979). Implement the Redis backend for DCRCredentialStore (defined in sub-issue 1), so an authserver backed by Redis Sentinel shares DCR credentials across replicas and survives restarts. This is the persistent half of the parent issue's deliverable; sub-issue 3 wires it up to EmbeddedAuthServer.
Context
#4978 (Phase 2) shipped an in-process in-memory DCRCredentialStore stub. Sub-issue 1 of this Phase 3 promotes the interface and value type into pkg/authserver/storage/ and lands the in-memory implementation. This sub-issue adds the Redis Sentinel implementation behind the same interface, so a single storage_type: redis config toggles DCR persistence alongside the rest of authserver state.
The Redis backend must:
Match the existing pkg/authserver/storage/ shape and follow the prior-art Redis key scheme thv:auth:{ns:name}:<type>:<id> (see pkg/authserver/storage/redis_keys.go:63-83) so DCR keys share the {ns:name} Redis Cluster hash tag with the rest of a server's state.
Honor client_secret_expires_at (RFC 7591 §3.2.1, unix seconds; 0 means "never") as the Redis TTL when the authorization server provides it. When absent or 0, no TTL is applied and entries are long-lived.
Obey .claude/rules/go-style.md §"Write to Durable Storage Before Updating In-Memory State" — durable first.
This sub-issue introduces no new protocol behavior. It is a drop-in persistence layer behind the interface from sub-issue 1.
Add KeyTypeDCR = "dcr" to pkg/authserver/storage/redis_keys.go adjacent to the existing KeyType* consts.
Add a DCR key helper (e.g. redisDCRKey(prefix string, key DCRKey) string) that produces {prefix}dcr:<id> where <id> is a deterministic, collision-free encoding of (issuer, redirect_uri, scopes_hash). Two safe encodings: (a) a length-prefixed format mirroring redisProviderKey at redis_keys.go:87-89; (b) a single SHA-256 hex over the tuple. Either is acceptable — pick one and document it adjacent to the helper. DO NOT use plain fmt.Sprintf("%s:%s:%s", ...) because redirect_uri contains colons.
RedisStorage.StoreDCRCredentials JSON-marshals the DCRCredentials value and calls SetEX with a TTL derived from the upstream's client_secret_expires_at:
When the AS advertised client_secret_expires_at > 0 (captured at registration time — plumb it through DCRCredentials or compute ttl = expiresAt - time.Now() at the call site and document the source), use ttl as the Redis TTL.
When client_secret_expires_at == 0 or not provided, use Set with no expiration (long-lived).
RedisStorage.GetDCRCredentials JSON-unmarshals the value; returns ErrNotFound on redis.Nil; returns the unwrapped error otherwise.
Compile-time interface-compliance assertion is added at the bottom of the file: _ DCRCredentialStore = (*RedisStorage)(nil).
Connection-error handling follows the existing drainCloseErr/warnOnCleanupErr patterns already used in redis.go.
Cross-cutting
task build, task test, task lint-fix, and task license-check all pass.
No secrets appear in any log record (grep assertion from Authserver DCR integration (Phase 2, Steps 2a-2g) #4978 applies unchanged: client_secret, registration_access_token, initial_access_token, refresh tokens are never arguments to slog.* calls).
Code reviewed and approved.
Technical Approach
Recommended Implementation
Redis backend — add KeyTypeDCR to redis_keys.go. Add a DCR key helper that handles colons in redirect_uri safely (length-prefix form or SHA-256 hex; pick one). StoreDCRCredentials JSON-marshals the value and calls SetEX with a TTL when client_secret_expires_at > 0, or Set with no expiration otherwise. GetDCRCredentials handles redis.Nil as ErrNotFound.
Plain testing + github.com/stretchr/testify per .claude/rules/testing.md — NOT Ginkgo. require.NoError over t.Fatal. Table-driven tests for the Redis key-encoding helper.
Redis integration tests use testcontainers (consistent with pkg/authserver/storage/redis_integration_test.go), NOT miniredis. The existing helper newRedisSentinelCluster + withIntegrationStorage is the shape to follow; add DCR coverage there rather than spinning up a separate harness. miniredis is not in go.mod and should not be added.
Integration tests live behind the //go:build integration tag already used by redis_integration_test.go. Extend that file for the Redis DCR coverage.
Write durable first per .claude/rules/go-style.md §"Write to Durable Storage Before Updating In-Memory State". This backend is a single durable write; no in-process cache sits in front of it, so the rule is trivially satisfied — but the invariant must be preserved if a write-through cache is added later.
SPDX 2-line header on any newly-created Go file. task license-fix catches omissions.
Always drive builds/tests/lint through task (task build, task test, task lint-fix, task license-check, task gen). Never go test ./... or golangci-lint run directly.
Commit-message style: imperative mood, capitalized subject, no trailing period, 50-char limit, no Conventional Commits prefixes (CLAUDE.md).
pkg/authserver/storage/redis_keys.go:85-89 — redisProviderKey length-prefix pattern for colon-containing inputs; follow the same shape if picking option (a) for the DCR key helper.
pkg/authserver/storage/redis.go:35-51 — warnOnCleanupErr + the best-effort-index-cleanup convention; DCR has no secondary index, so this is a one-pass Set/Get, but preserve the error-handling idioms.
pkg/authserver/storage/redis.go:103-110 — RedisStorage struct; add DCR methods as a new section after the upstream-token methods (follow the existing // --- section-header comment style).
pkg/authserver/storage/redis_integration_test.go:29-35 (testcontainers imports) and the newRedisSentinelCluster / withIntegrationStorage helpers in the same file — extend these rather than adding a second integration-test harness.
pkg/auth/oauth/dynamic_registration.go:142 — ClientSecretExpiresAt int64 field on the existing DCR response struct; this is the source of the Redis-TTL plumbing. Propagate it through DCRResolution / DCRCredentials (already allowed by Authserver DCR integration (Phase 2, Steps 2a-2g) #4978's capture of full RFC 7591 fields) or compute ttl at the call site when storing.
Component Interfaces
// pkg/authserver/storage/redis_keys.go (addition)// KeyTypeDCR is the key type for DCR credentials.constKeyTypeDCR="dcr"// redisDCRKey generates a Redis key for a DCR credential entry.// Uses a length-prefixed encoding to handle colons in RedirectURI safely,// mirroring redisProviderKey's approach.//// Format: "{prefix}dcr:<len(issuer)>:<issuer>:<len(redirect)>:<redirect>:<scopes_hash>"// The trailing scopes_hash is already SHA-256 hex and contains no colons.funcredisDCRKey(prefixstring, keyDCRKey) string {
returnfmt.Sprintf("%s%s:%d:%s:%d:%s:%s",
prefix, KeyTypeDCR,
len(key.Issuer), key.Issuer,
len(key.RedirectURI), key.RedirectURI,
key.ScopesHash)
}
Testing Strategy
Unit Tests
pkg/authserver/storage/redis_keys_test.go (new or extend existing): redisDCRKey is deterministic; distinct DCRKey tuples produce distinct Redis keys; keys containing colons in RedirectURI do not collide.
Integration Tests
pkg/authserver/storage/redis_integration_test.go (extend): Round-trip DCRCredentials through the real Redis Sentinel cluster; two distinct keys coexist; overwrite on matching key updates the value; StoreDCRCredentials with a non-zero client_secret_expires_at plumbed in results in an observable Redis TTL on the key (use TTL command through the test client to assert); StoreDCRCredentials without a TTL results in no expiration (TTL returns -1); concurrent Put / Get from multiple goroutines is race-free under go test -race.
Edge Cases
ErrNotFound plumbed through correctly (errors.Is(err, storage.ErrNotFound) holds).
client_secret_expires_at = 0 (RFC 7591 "never") means no Redis TTL, not a zero TTL (which would be immediate expiry).
A DCR key colliding with an unrelated Redis key type (e.g. upstream:...) is impossible because the KeyTypeDCR = "dcr" segment is distinct from all other KeyType* values in redis_keys.go.
Out of Scope
In-memory backend, types, and DCRCredentialStore interface — see sub-issue 1.
Wiring EmbeddedAuthServer to use the storage-level interface and removing the Phase 2 standalone in-memory stub — see sub-issue 3.
All other items listed in the parent issue's "Out of Scope" section (auto re-register, RFC 7592 rotation, cross-replica session/token delivery, OTEL metrics, CLI persistence, provider-specific quirks, delete API).
Description
Sub-task 2 of 3 for Phase 3 of the DCR story (#4976, parent #4979). Implement the Redis backend for
DCRCredentialStore(defined in sub-issue 1), so an authserver backed by Redis Sentinel shares DCR credentials across replicas and survives restarts. This is the persistent half of the parent issue's deliverable; sub-issue 3 wires it up toEmbeddedAuthServer.Context
#4978 (Phase 2) shipped an in-process in-memory
DCRCredentialStorestub. Sub-issue 1 of this Phase 3 promotes the interface and value type intopkg/authserver/storage/and lands the in-memory implementation. This sub-issue adds the Redis Sentinel implementation behind the same interface, so a singlestorage_type: redisconfig toggles DCR persistence alongside the rest of authserver state.The Redis backend must:
pkg/authserver/storage/shape and follow the prior-art Redis key schemethv:auth:{ns:name}:<type>:<id>(seepkg/authserver/storage/redis_keys.go:63-83) so DCR keys share the{ns:name}Redis Cluster hash tag with the rest of a server's state.client_secret_expires_at(RFC 7591 §3.2.1, unix seconds;0means "never") as the Redis TTL when the authorization server provides it. When absent or0, no TTL is applied and entries are long-lived..claude/rules/go-style.md§"Write to Durable Storage Before Updating In-Memory State" — durable first.This sub-issue introduces no new protocol behavior. It is a drop-in persistence layer behind the interface from sub-issue 1.
Dependencies: sub-issue 1 (types, interface, in-memory implementation)
Blocks: sub-issue 3 (wiring)
Acceptance Criteria
Redis backend —
pkg/authserver/storage/redis.go+redis_keys.goKeyTypeDCR = "dcr"topkg/authserver/storage/redis_keys.goadjacent to the existingKeyType*consts.redisDCRKey(prefix string, key DCRKey) string) that produces{prefix}dcr:<id>where<id>is a deterministic, collision-free encoding of(issuer, redirect_uri, scopes_hash). Two safe encodings: (a) a length-prefixed format mirroringredisProviderKeyatredis_keys.go:87-89; (b) a single SHA-256 hex over the tuple. Either is acceptable — pick one and document it adjacent to the helper. DO NOT use plainfmt.Sprintf("%s:%s:%s", ...)becauseredirect_uricontains colons.RedisStorage.StoreDCRCredentialsJSON-marshals theDCRCredentialsvalue and callsSetEXwith a TTL derived from the upstream'sclient_secret_expires_at:client_secret_expires_at > 0(captured at registration time — plumb it throughDCRCredentialsor computettl = expiresAt - time.Now()at the call site and document the source), usettlas the Redis TTL.client_secret_expires_at == 0or not provided, useSetwith no expiration (long-lived).RedisStorage.GetDCRCredentialsJSON-unmarshals the value; returnsErrNotFoundonredis.Nil; returns the unwrapped error otherwise._ DCRCredentialStore = (*RedisStorage)(nil).drainCloseErr/warnOnCleanupErrpatterns already used inredis.go.Cross-cutting
task build,task test,task lint-fix, andtask license-checkall pass.client_secret,registration_access_token,initial_access_token, refresh tokens are never arguments toslog.*calls).Technical Approach
Recommended Implementation
Redis backend — add
KeyTypeDCRtoredis_keys.go. Add a DCR key helper that handles colons inredirect_urisafely (length-prefix form or SHA-256 hex; pick one).StoreDCRCredentialsJSON-marshals the value and callsSetEXwith a TTL whenclient_secret_expires_at > 0, orSetwith no expiration otherwise.GetDCRCredentialshandlesredis.NilasErrNotFound.Verify —
task build,task test,task lint-fix,task license-check. Run the secret-grep assertion from Authserver DCR integration (Phase 2, Steps 2a-2g) #4978.Patterns & Frameworks
testing+github.com/stretchr/testifyper.claude/rules/testing.md— NOT Ginkgo.require.NoErrorovert.Fatal. Table-driven tests for the Redis key-encoding helper.pkg/authserver/storage/redis_integration_test.go), NOTminiredis. The existing helpernewRedisSentinelCluster+withIntegrationStorageis the shape to follow; add DCR coverage there rather than spinning up a separate harness.miniredisis not ingo.modand should not be added.//go:build integrationtag already used byredis_integration_test.go. Extend that file for the Redis DCR coverage..claude/rules/go-style.md§"Write to Durable Storage Before Updating In-Memory State". This backend is a single durable write; no in-process cache sits in front of it, so the rule is trivially satisfied — but the invariant must be preserved if a write-through cache is added later.task license-fixcatches omissions.task(task build,task test,task lint-fix,task license-check,task gen). Nevergo test ./...orgolangci-lint rundirectly.CLAUDE.md).Code Pointers
pkg/authserver/storage/redis_keys.go:10-61—KeyType*consts; addKeyTypeDCR = "dcr"here.pkg/authserver/storage/redis_keys.go:85-89—redisProviderKeylength-prefix pattern for colon-containing inputs; follow the same shape if picking option (a) for the DCR key helper.pkg/authserver/storage/redis.go:35-51—warnOnCleanupErr+ the best-effort-index-cleanup convention; DCR has no secondary index, so this is a one-passSet/Get, but preserve the error-handling idioms.pkg/authserver/storage/redis.go:103-110—RedisStoragestruct; add DCR methods as a new section after the upstream-token methods (follow the existing// ---section-header comment style).pkg/authserver/storage/redis_integration_test.go:29-35(testcontainersimports) and thenewRedisSentinelCluster/withIntegrationStoragehelpers in the same file — extend these rather than adding a second integration-test harness.pkg/auth/oauth/dynamic_registration.go:142—ClientSecretExpiresAt int64field on the existing DCR response struct; this is the source of the Redis-TTL plumbing. Propagate it throughDCRResolution/DCRCredentials(already allowed by Authserver DCR integration (Phase 2, Steps 2a-2g) #4978's capture of full RFC 7591 fields) or computettlat the call site when storing.Component Interfaces
Testing Strategy
Unit Tests
pkg/authserver/storage/redis_keys_test.go(new or extend existing):redisDCRKeyis deterministic; distinctDCRKeytuples produce distinct Redis keys; keys containing colons inRedirectURIdo not collide.Integration Tests
pkg/authserver/storage/redis_integration_test.go(extend): Round-tripDCRCredentialsthrough the real Redis Sentinel cluster; two distinct keys coexist; overwrite on matching key updates the value;StoreDCRCredentialswith a non-zeroclient_secret_expires_atplumbed in results in an observable Redis TTL on the key (useTTLcommand through the test client to assert);StoreDCRCredentialswithout a TTL results in no expiration (TTLreturns-1); concurrentPut/Getfrom multiple goroutines is race-free undergo test -race.Edge Cases
ErrNotFoundplumbed through correctly (errors.Is(err, storage.ErrNotFound)holds).client_secret_expires_at = 0(RFC 7591 "never") means no Redis TTL, not a zero TTL (which would be immediate expiry).upstream:...) is impossible because theKeyTypeDCR = "dcr"segment is distinct from all otherKeyType*values inredis_keys.go.Out of Scope
DCRCredentialStoreinterface — see sub-issue 1.EmbeddedAuthServerto use the storage-level interface and removing the Phase 2 standalone in-memory stub — see sub-issue 3.References
.claude/rules/go-style.md,.claude/rules/testing.md,.claude/rules/security.md,.claude/rules/pr-creation.md,CLAUDE.md.client_secret_expires_atsemantics:0means "never", unix seconds otherwise).