Skip to content

Commit 182188a

Browse files
committed
Drop PublicClient from DCR cache and flight keys
The original PublicClient-in-keys fix was defense-in-depth against a collision that today's two consumers cannot reach: the embedded authserver registers on AS-origin redirect URIs and the CLI registers on RFC 8252 loopback redirect URIs, and the two address spaces are disjoint. RedirectURI alone separates the public-client and confidential-client profiles at both the persistent-cache and singleflight layers. Encoding PublicClient additionally would invalidate every existing Redis-cached entry across a deployment without buying additional protection. Drop it from DCRKey and flightKeyOf and document the RedirectURI-disjointness invariant alongside the migration condition for a hypothetical future consumer that brings the two address spaces into collision.
1 parent fc4fa7d commit 182188a

4 files changed

Lines changed: 57 additions & 87 deletions

File tree

pkg/auth/dcr/resolver.go

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -93,32 +93,33 @@ import (
9393
// The two current call sites do not collide by construction — the embedded
9494
// authserver's redirect URI lives on the AS origin, and the CLI flow's
9595
// redirect URI lives on a loopback (http://localhost:{port}/callback per
96-
// RFC 8252 §7.3). A future consumer that defaults its redirect URI into
97-
// either of those spaces would silently coalesce; if a third profile is
98-
// added the flight key MUST gain a consumer-identifier component so a
99-
// collision is impossible rather than improbable.
96+
// RFC 8252 §7.3), so the disjoint RedirectURI address spaces separate
97+
// the public-client and confidential-client profiles at both the
98+
// singleflight and the persistent-cache layers. A future consumer that
99+
// defaults its redirect URI into either of those spaces would silently
100+
// coalesce; if a third profile is added the flight key (and persisted
101+
// DCRKey) MUST gain a consumer-identifier component so a collision is
102+
// impossible rather than improbable.
100103
var dcrFlight singleflight.Group
101104

102105
// flightKeyOf canonicalises a Key into the singleflight string used by
103-
// dcrFlight. The "\n" separator is safe because newline is not a valid byte
104-
// in any of the four components: URI reference characters in Issuer and
105-
// RedirectURI (RFC 3986 §2), hex digits in ScopesHash (the form
106-
// storage.ScopesHash always emits), and a single "1"/"0" character for
107-
// PublicClient. Exposed as a function so tests and future inspection
108-
// helpers can compute the exact key the resolver would route through
109-
// dcrFlight without re-implementing the concatenation.
106+
// dcrFlight. The "\n" separator is safe because newline is not a valid
107+
// byte in any of the three components: URI reference characters in
108+
// Issuer and RedirectURI (RFC 3986 §2), and hex digits in ScopesHash
109+
// (the form storage.ScopesHash always emits). Exposed as a function so
110+
// tests and future inspection helpers can compute the exact key the
111+
// resolver would route through dcrFlight without re-implementing the
112+
// concatenation.
110113
//
111-
// PublicClient is included to mirror the persisted DCRKey: a public PKCE
112-
// registration and a confidential-client registration that happened to
113-
// share an Issuer/RedirectURI/ScopesHash tuple must not coalesce through
114-
// the singleflight, because a follower would receive a registration of
115-
// the wrong client shape from the leader.
114+
// PublicClient is intentionally NOT part of the flight key — the
115+
// dcrFlight doc above explains why: today's two consumers register on
116+
// disjoint RedirectURI address spaces (AS-origin vs RFC 8252 loopback),
117+
// so the public-client and confidential-client profiles cannot share a
118+
// flight key by construction. The same property protects the persisted
119+
// DCRKey from cross-profile coalescence; the two layers are aligned
120+
// rather than asymmetric.
116121
func flightKeyOf(key Key) string {
117-
publicFlag := "0"
118-
if key.PublicClient {
119-
publicFlag = "1"
120-
}
121-
return key.Issuer + "\n" + key.RedirectURI + "\n" + key.ScopesHash + "\n" + publicFlag
122+
return key.Issuer + "\n" + key.RedirectURI + "\n" + key.ScopesHash
122123
}
123124

124125
// defaultUpstreamRedirectPath is the redirect path derived from the issuer
@@ -326,10 +327,9 @@ func ResolveCredentials(
326327

327328
scopes := slices.Clone(req.Scopes)
328329
key := Key{
329-
Issuer: req.Issuer,
330-
RedirectURI: redirectURI,
331-
ScopesHash: storage.ScopesHash(scopes),
332-
PublicClient: req.PublicClient,
330+
Issuer: req.Issuer,
331+
RedirectURI: redirectURI,
332+
ScopesHash: storage.ScopesHash(scopes),
333333
}
334334

335335
// Cache lookup short-circuits before any network I/O.

pkg/authserver/storage/redis_keys.go

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -97,29 +97,31 @@ func redisProviderKey(prefix, providerID, providerSubject string) string {
9797
// redisDCRKey generates a Redis key for a DCR credential entry, identifying the
9898
// (Issuer, RedirectURI, ScopesHash) tuple that DCRKey canonicalises.
9999
//
100-
// Format: "{prefix}dcr:<len(issuer)>:<issuer>:<len(redirect_uri)>:<redirect_uri>:<scopes_hash>:<public>"
100+
// Format: "{prefix}dcr:<len(issuer)>:<issuer>:<len(redirect_uri)>:<redirect_uri>:<scopes_hash>"
101101
//
102102
// The first two segments are length-prefixed to handle colons in RedirectURI
103103
// (and, for symmetry, Issuer) without ambiguity, mirroring redisProviderKey.
104104
// ScopesHash is expected to be a SHA-256 hex digest produced by
105105
// storage.ScopesHash — only [0-9a-f] and never colon-bearing — so it is
106-
// appended without a length prefix. The trailing PublicClient flag is the
107-
// literal "1" or "0" so confidential and public registrations never share a
108-
// cache entry even when the leading components coincide. The format is
109-
// robust for that domain; validateDCRCredentialsForStore (called by every
110-
// Store path) already rejects an empty ScopesHash, and callers are required
111-
// to compute the hash via storage.ScopesHash. Length-prefix collision-safety
112-
// is preserved on the leading segments either way.
106+
// appended without a length prefix. The format is robust for that domain;
107+
// validateDCRCredentialsForStore (called by every Store path) already
108+
// rejects an empty ScopesHash, and callers are required to compute the hash
109+
// via storage.ScopesHash. Length-prefix collision-safety is preserved on
110+
// the leading segments either way.
111+
//
112+
// The public-vs-confidential client distinction is intentionally NOT
113+
// encoded here — see DCRKey's doc for the rationale. Today's two consumers
114+
// register on disjoint RedirectURI address spaces (AS-origin vs RFC 8252
115+
// loopback) so the persisted key cannot collide across profiles. A future
116+
// consumer that defaults its RedirectURI into either space would need to
117+
// add the distinguishing component back to the key format alongside an
118+
// explicit migration story for existing Redis-cached entries.
113119
func redisDCRKey(prefix string, key DCRKey) string {
114-
publicFlag := "0"
115-
if key.PublicClient {
116-
publicFlag = "1"
117-
}
118-
return fmt.Sprintf("%s%s:%d:%s:%d:%s:%s:%s",
120+
return fmt.Sprintf("%s%s:%d:%s:%d:%s:%s",
119121
prefix, KeyTypeDCR,
120122
len(key.Issuer), key.Issuer,
121123
len(key.RedirectURI), key.RedirectURI,
122-
key.ScopesHash, publicFlag)
124+
key.ScopesHash)
123125
}
124126

125127
// redisUpstreamKey generates a Redis key for a per-provider upstream token entry.

pkg/authserver/storage/redis_test.go

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1951,7 +1951,7 @@ func TestRedisKeyGeneration(t *testing.T) {
19511951
assert.Equal(t, "test:auth:reqid:access:req-123", result)
19521952
})
19531953

1954-
t.Run("redisDCRKey confidential", func(t *testing.T) {
1954+
t.Run("redisDCRKey", func(t *testing.T) {
19551955
t.Parallel()
19561956
result := redisDCRKey("test:auth:", DCRKey{
19571957
Issuer: "https://thv.example.com",
@@ -1960,20 +1960,7 @@ func TestRedisKeyGeneration(t *testing.T) {
19601960
})
19611961
// 23 = len("https://thv.example.com"), 38 = len("https://thv.example.com/oauth/callback")
19621962
assert.Equal(t,
1963-
"test:auth:dcr:23:https://thv.example.com:38:https://thv.example.com/oauth/callback:abc123:0",
1964-
result)
1965-
})
1966-
1967-
t.Run("redisDCRKey public", func(t *testing.T) {
1968-
t.Parallel()
1969-
result := redisDCRKey("test:auth:", DCRKey{
1970-
Issuer: "https://thv.example.com",
1971-
RedirectURI: "https://thv.example.com/oauth/callback",
1972-
ScopesHash: "abc123",
1973-
PublicClient: true,
1974-
})
1975-
assert.Equal(t,
1976-
"test:auth:dcr:23:https://thv.example.com:38:https://thv.example.com/oauth/callback:abc123:1",
1963+
"test:auth:dcr:23:https://thv.example.com:38:https://thv.example.com/oauth/callback:abc123",
19771964
result)
19781965
})
19791966
}
@@ -2024,20 +2011,6 @@ func TestRedisDCRKey_Distinct(t *testing.T) {
20242011
a: mk("https://idp.example.com", "https://x.example.com:443/cb", "h1"),
20252012
b: mk("https://idp.example.com", "https://x.example.com/cb:443", "h1"),
20262013
},
2027-
{
2028-
// Public-client and confidential-client registrations for the
2029-
// same upstream/scopes/redirect must not share a cache entry.
2030-
// Pins the trailing PublicClient flag in the Redis key.
2031-
name: "different PublicClient flag",
2032-
a: DCRKey{
2033-
Issuer: "https://idp.example.com", RedirectURI: "https://x/cb", ScopesHash: "h1",
2034-
PublicClient: false,
2035-
},
2036-
b: DCRKey{
2037-
Issuer: "https://idp.example.com", RedirectURI: "https://x/cb", ScopesHash: "h1",
2038-
PublicClient: true,
2039-
},
2040-
},
20412014
}
20422015

20432016
for _, tc := range tests {

pkg/authserver/storage/types.go

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,20 @@ type DCRKey struct {
128128
// issuer, because the CLI has no separate local issuer of its own.
129129
// The cache is keyed by this value because two different consumers
130130
// registering against the same upstream are distinct OAuth clients and
131-
// must not share credentials. The (Issuer, RedirectURI, ScopesHash,
132-
// PublicClient) tuple keeps the two consumer profiles' entries apart
133-
// via the RedirectURI component (the embedded authserver registers an
134-
// AS-origin callback while the CLI registers a loopback callback), so a
135-
// collision between profiles is impossible by construction even when
136-
// the upstream is the same.
131+
// must not share credentials. The (Issuer, RedirectURI, ScopesHash)
132+
// tuple keeps the two consumer profiles' entries apart via the
133+
// RedirectURI component (the embedded authserver registers an
134+
// AS-origin callback while the CLI registers a loopback callback per
135+
// RFC 8252 §7.3 — the two address spaces are disjoint), so a collision
136+
// between profiles is impossible by construction even when the
137+
// upstream is the same. Public-client vs confidential-client
138+
// separation rides on that same RedirectURI disjointness at both the
139+
// persistent-cache and in-process singleflight layers; encoding it on
140+
// the key would invalidate every existing Redis-cached entry across a
141+
// deployment without buying additional protection. If a future
142+
// consumer brings the two address spaces into collision the key
143+
// format must gain a consumer-identifier component alongside an
144+
// explicit migration story.
137145
Issuer string
138146

139147
// RedirectURI is the redirect URI registered with the upstream
@@ -148,19 +156,6 @@ type DCRKey struct {
148156
// hand at call sites; the canonical form must be a single source of truth
149157
// so the key matches across processes and backends.
150158
ScopesHash string
151-
152-
// PublicClient indicates the registration was issued for a public
153-
// (PKCE) client with token_endpoint_auth_method=none, as opposed to a
154-
// confidential client with a shared secret. Including this in the key
155-
// is defense-in-depth: it makes the "public vs confidential
156-
// registrations must not share cache entries" property structural
157-
// rather than relying on RedirectURI happening to differ between
158-
// consumer profiles. Without this field, a future caller that defaulted
159-
// its redirect URI into the same address space as an existing consumer
160-
// (and asked for a public registration where the cached entry was
161-
// confidential, or vice versa) would silently receive a wrong-shape
162-
// resolution.
163-
PublicClient bool
164159
}
165160

166161
// ScopesHash returns the SHA-256 hex digest of the canonical OAuth scope set,

0 commit comments

Comments
 (0)