Skip to content

Persistent DCRCredentialStore: Redis backend #5184

@tgrunnagle

Description

@tgrunnagle

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 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.

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.go

  • 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

  1. 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.

  2. Verifytask 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

  • 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).

Code Pointers

  • pkg/authserver/storage/redis_keys.go:10-61KeyType* consts; add KeyTypeDCR = "dcr" here.
  • pkg/authserver/storage/redis_keys.go:85-89redisProviderKey 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-51warnOnCleanupErr + 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-110RedisStorage 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:142ClientSecretExpiresAt 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.
const KeyTypeDCR = "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.
func redisDCRKey(prefix string, key DCRKey) string {
    return fmt.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).

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    authenhancementNew feature or requestgoPull requests that update go code

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions