Skip to content

Commit 2043cf6

Browse files
amirejazclaude
andauthored
Add CIMD storage decorator for embedded AS (#5343)
* Add CIMD storage decorator for embedded AS (Phase 2 PR 2) The CIMDStorageDecorator wraps storage.Storage and intercepts GetClient calls for HTTPS client_id values. When the embedded AS receives a client_id like https://vscode.dev/oauth/client-metadata.json, the decorator fetches the CIMD document via pkg/oauthproto/cimd, validates it, builds a fosite.Client, caches the result with a configurable fallback TTL, and deduplicates concurrent fetches for the same URL via singleflight. Key design decisions: - Embeds storage.Storage so all ~30 other methods delegate transparently - Unwrap() exposes the underlying storage for the DCRCredentialStore and RedisStorage type assertions in server_impl.go to reach the concrete backend through the decorator layer - LoopbackClient wraps clients with loopback redirect URIs for RFC 8252 §7.3 dynamic port matching - NewCIMDStorageDecorator returns base unchanged when enabled=false (no allocation); fails loudly for invalid cacheMaxSize runLegacyMigration extracted from newServer to keep the function under the gocyclo limit after the Unwrap additions; both the DCRCredentialStore assertion and the RedisStorage migration now use the same Unwrap pattern. Incorporates all changes from PR 1 (pkg/oauthproto/cimd sub-package, networking.FetchJSON with WithMaxResponseSize, IsPrivateIP reuse). Relates to #4825 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * Address Copilot review comments on CIMD storage decorator cimd_decorator.go: - Fix docstring: TTL is fixed (not from Cache-Control); Cache-Control parsing is a documented follow-up - Force token_endpoint_auth_method to "none": the embedded AS only advertises "none" in discovery, so accepting other values creates an inconsistent client; always override regardless of what the document says - Fix LoopbackClient dropping TokenEndpointAuthMethod: was passing defaultClient (no auth method) instead of openIDClient.DefaultClient (carries the auth method) to NewLoopbackClient - Fix singleflight context: use context.WithoutCancel(ctx) so one caller cancelling does not abort the shared in-flight fetch for other waiters; HTTP client inside FetchClientMetadataDocument already enforces its own 5-second timeout cimd_decorator_test.go: - Replace flaky time.Sleep with a startBarrier channel: goroutines signal before calling fetchOrCached; draining all signals before closing ready makes singleflight deduplication deterministic - Fix CIDM → CIMD typo in test name server_impl.go: - Fix error message: report baseStore type (concrete backend) not stor type (possibly a decorator) when DCRCredentialStore assertion fails - Extract unwrapStorage helper to deduplicate the identical Unwrap() inline logic in newServer and runLegacyMigration Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * Address agent review feedback on CIMD storage decorator Issue 1 (blocking): actually fix LoopbackClient dropping TokenEndpointAuthMethod. Change LoopbackClient to embed *fosite.DefaultOpenIDConnectClient instead of *fosite.DefaultClient, preserving TokenEndpointAuthMethod through the wrapper. Update NewLoopbackClient signature, registration.New(), buildFositeClient, and all test call sites accordingly. Previous "fix" was wrong: openIDClient.DefaultClient is the same pointer as defaultClient — the behaviour was identical to the original broken code. Issue 2: remove dead `enabled bool` field from CIMDStorageDecorator. The constructor returns base unchanged when enabled=false so the struct is never instantiated with enabled=false; the field was never read. Issue 3: add TestCIMDStorageDecorator_FetchOrCached_FetchFailureReturnsNotFound covering the error path and verifying errors.Is(err, fosite.ErrNotFound). Issue 4: add CIMD decorator section to docs/arch/11-auth-server-storage.md covering what the decorator does, the Unwrap() pattern, and the air-gapped caveat. Issue 5: replace //nolint:forcetypeassert with a safe type assertion that returns an observable error instead of panicking on type mismatch. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * Fix two remaining nits - server_impl.go: remove stray blank comment line - cimd_decorator_test.go: add TokenEndpointAuthMethod assertion to LoopbackClient test to catch any future regression Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * Reject CIMD docs that declare unsupported token_endpoint_auth_method The embedded AS only supports "none" for CIMD clients. Previously, buildFositeClient silently overrode any declared auth method to "none", which was misleading: a client that claimed private_key_jwt would be treated as public without any indication of the mismatch. Now fetch() rejects documents that declare a method other than "none" before buildFositeClient is reached. buildFositeClient only fills in "none" as a default when the field is omitted, which matches the comment on defaultCIMDTokenEndpointAuthMethod. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 08baf2d commit 2043cf6

6 files changed

Lines changed: 751 additions & 23 deletions

File tree

docs/arch/11-auth-server-storage.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,33 @@ When passing configuration across process boundaries (operator → proxy-runner)
233233
- [Redis Storage Configuration Guide](../redis-storage.md) — User-facing setup guide
234234
- [Operator Architecture](09-operator-architecture.md) — CRD and controller design
235235
- [Core Concepts](02-core-concepts.md) — Platform terminology
236+
237+
## CIMD Storage Decorator
238+
239+
When `authServer.cimd.enabled: true` is set, the embedded authorization server wraps its storage backend in a `CIMDStorageDecorator` before passing it to fosite. This decorator enables MCP clients to present HTTPS URLs as `client_id` values without first calling `/oauth/register`.
240+
241+
### What it does
242+
243+
`CIMDStorageDecorator` embeds the full `storage.Storage` interface and overrides only `GetClient`. When fosite calls `GetClient("https://vscode.dev/oauth/client-metadata.json")` during an authorization request:
244+
245+
1. The decorator detects the HTTPS URL using `oauthproto.IsClientIDMetadataDocumentURL`
246+
2. It fetches the Client ID Metadata Document from that URL via `pkg/oauthproto/cimd.FetchClientMetadataDocument` (with SSRF protection, 10 KB cap, 5-second timeout)
247+
3. It builds a `fosite.Client` from the document fields, caches it with a configurable TTL, and returns it to fosite
248+
4. Concurrent fetches for the same URL are deduplicated via `singleflight`
249+
250+
All other `Storage` methods (`RegisterClient`, token storage, upstream token storage, etc.) delegate to the underlying backend unchanged. DCR clients (opaque string IDs) continue to work exactly as before.
251+
252+
### Unwrap pattern
253+
254+
`CIMDStorageDecorator` implements `Unwrap() Storage` to expose the concrete backend through the decorator layer. Two call sites in `server_impl.go` depend on this:
255+
256+
- **`DCRCredentialStore` assertion** (`newServer`): The `DCRCredentialStore` interface is narrower than `Storage` and not embedded in it. The assertion `unwrapStorage(stor).(storage.DCRCredentialStore)` reaches the concrete backend through the decorator.
257+
- **`RedisStorage` migration** (`runLegacyMigration`): A type assertion to `*storage.RedisStorage` is needed to run a one-shot data migration. Same `unwrapStorage` call.
258+
259+
Both call sites use the `unwrapStorage(stor)` helper rather than asserting directly on `stor`.
260+
261+
### Air-gapped environments
262+
263+
When the embedded authorization server is deployed in an environment that cannot reach `https://toolhive.dev/oauth/client-metadata.json` or any public CIMD metadata URL, set `authServer.cimd.enabled: false`. Clients will fall back to DCR (`/oauth/register`) which uses only the local storage backend and requires no outbound connectivity.
264+
265+
**Implementation:** `pkg/authserver/storage/cimd_decorator.go`

pkg/authserver/server/registration/client.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,14 @@ import (
4343
// native apps that register redirect URIs like "http://localhost/callback" and then
4444
// request authorization with dynamic ports like "http://localhost:57403/callback".
4545
type LoopbackClient struct {
46-
*fosite.DefaultClient
46+
*fosite.DefaultOpenIDConnectClient
4747
}
4848

49-
// NewLoopbackClient creates a new LoopbackClient wrapping the provided DefaultClient.
50-
func NewLoopbackClient(client *fosite.DefaultClient) *LoopbackClient {
51-
return &LoopbackClient{DefaultClient: client}
49+
// NewLoopbackClient creates a new LoopbackClient wrapping the provided client.
50+
// The wrapper preserves all OIDC fields (including TokenEndpointAuthMethod)
51+
// while adding RFC 8252 §7.3 dynamic port matching for loopback redirect URIs.
52+
func NewLoopbackClient(client *fosite.DefaultOpenIDConnectClient) *LoopbackClient {
53+
return &LoopbackClient{DefaultOpenIDConnectClient: client}
5254
}
5355

5456
// MatchRedirectURI checks if the given redirect URI matches one of the client's
@@ -167,8 +169,14 @@ func New(cfg Config) (fosite.Client, error) {
167169

168170
// Wrap public clients in LoopbackClient for RFC 8252 Section 7.3
169171
// dynamic port matching for native app loopback redirect URIs.
172+
// Use DefaultOpenIDConnectClient so TokenEndpointAuthMethod ("none" for
173+
// public clients) is preserved through the LoopbackClient wrapper.
170174
if cfg.Public {
171-
return NewLoopbackClient(defaultClient), nil
175+
oidcClient := &fosite.DefaultOpenIDConnectClient{
176+
DefaultClient: defaultClient,
177+
TokenEndpointAuthMethod: "none",
178+
}
179+
return NewLoopbackClient(oidcClient), nil
172180
}
173181

174182
return defaultClient, nil

pkg/authserver/server/registration/client_test.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func TestNewLoopbackClient(t *testing.T) {
3232
Public: true,
3333
}
3434

35-
client := NewLoopbackClient(defaultClient)
35+
client := NewLoopbackClient(&fosite.DefaultOpenIDConnectClient{DefaultClient: defaultClient})
3636

3737
assert.NotNil(t, client)
3838
assert.Equal(t, "test-client", client.GetID())
@@ -196,10 +196,12 @@ func TestLoopbackClient_MatchRedirectURI(t *testing.T) {
196196
t.Run(tt.name, func(t *testing.T) {
197197
t.Parallel()
198198

199-
client := NewLoopbackClient(&fosite.DefaultClient{
200-
ID: "test-client",
201-
RedirectURIs: tt.registeredURIs,
202-
Public: true,
199+
client := NewLoopbackClient(&fosite.DefaultOpenIDConnectClient{
200+
DefaultClient: &fosite.DefaultClient{
201+
ID: "test-client",
202+
RedirectURIs: tt.registeredURIs,
203+
Public: true,
204+
},
203205
})
204206

205207
result := client.MatchRedirectURI(tt.requestedURI)
@@ -247,10 +249,12 @@ func TestLoopbackClient_GetMatchingRedirectURI(t *testing.T) {
247249
t.Run(tt.name, func(t *testing.T) {
248250
t.Parallel()
249251

250-
client := NewLoopbackClient(&fosite.DefaultClient{
251-
ID: "test-client",
252-
RedirectURIs: tt.registeredURIs,
253-
Public: true,
252+
client := NewLoopbackClient(&fosite.DefaultOpenIDConnectClient{
253+
DefaultClient: &fosite.DefaultClient{
254+
ID: "test-client",
255+
RedirectURIs: tt.registeredURIs,
256+
Public: true,
257+
},
254258
})
255259

256260
result := client.GetMatchingRedirectURI(tt.requestedURI)

pkg/authserver/server_impl.go

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,10 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se
107107
// provably safe for the production backends; surfacing a bad backend as
108108
// a constructor error keeps misconfiguration fail-loud at boot rather
109109
// than at first DCR resolve.
110-
dcrStore, ok := stor.(storage.DCRCredentialStore)
110+
baseStore := unwrapStorage(stor)
111+
dcrStore, ok := baseStore.(storage.DCRCredentialStore)
111112
if !ok {
112-
return nil, fmt.Errorf("storage backend %T does not implement storage.DCRCredentialStore", stor)
113+
return nil, fmt.Errorf("storage backend %T does not implement storage.DCRCredentialStore", baseStore)
113114
}
114115

115116
slog.Debug("creating OAuth2 configuration")
@@ -168,13 +169,8 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se
168169

169170
// Run one-shot bulk migration of legacy data before handler construction.
170171
// TODO(migration): Remove once all deployments have upgraded past this version.
171-
if rs, ok := stor.(*storage.RedisStorage); ok {
172-
for i := range cfg.Upstreams {
173-
upCfg := &cfg.Upstreams[i]
174-
if err := rs.MigrateLegacyUpstreamData(ctx, upCfg.Name, string(upCfg.Type)); err != nil {
175-
return nil, fmt.Errorf("legacy data migration failed for upstream %q: %w", upCfg.Name, err)
176-
}
177-
}
172+
if err := runLegacyMigration(ctx, stor, cfg.Upstreams); err != nil {
173+
return nil, err
178174
}
179175

180176
handlerInstance, err := handlers.NewHandler(fositeProvider, authServerConfig, stor, upstreams)
@@ -294,3 +290,31 @@ func createProvider(authServerConfig *oauthserver.AuthorizationServerConfig, sto
294290
compose.OAuth2PKCEFactory, // PKCE for public clients
295291
)
296292
}
293+
294+
// unwrapStorage peels off one decorator layer if the storage implements
295+
// Unwrap(), returning the concrete backend. Both newServer (DCRCredentialStore
296+
// assertion) and runLegacyMigration (RedisStorage type assertion) need this.
297+
func unwrapStorage(stor storage.Storage) storage.Storage {
298+
if unwrapper, ok := stor.(interface{ Unwrap() storage.Storage }); ok {
299+
return unwrapper.Unwrap()
300+
}
301+
return stor
302+
}
303+
304+
// runLegacyMigration runs one-shot Redis data migrations before handlers are
305+
// constructed. It is a no-op for non-Redis backends and passes through any
306+
// decorator wrapping so the concrete type can be reached.
307+
func runLegacyMigration(ctx context.Context, stor storage.Storage, upstreams []UpstreamConfig) error {
308+
base := unwrapStorage(stor)
309+
rs, ok := base.(*storage.RedisStorage)
310+
if !ok {
311+
return nil
312+
}
313+
for i := range upstreams {
314+
upCfg := &upstreams[i]
315+
if err := rs.MigrateLegacyUpstreamData(ctx, upCfg.Name, string(upCfg.Type)); err != nil {
316+
return fmt.Errorf("legacy data migration failed for upstream %q: %w", upCfg.Name, err)
317+
}
318+
}
319+
return nil
320+
}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package storage
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"net/url"
10+
"strings"
11+
"time"
12+
13+
lru "github.com/hashicorp/golang-lru/v2"
14+
"github.com/ory/fosite"
15+
"golang.org/x/sync/singleflight"
16+
17+
"github.com/stacklok/toolhive/pkg/authserver/server/registration"
18+
"github.com/stacklok/toolhive/pkg/oauthproto"
19+
"github.com/stacklok/toolhive/pkg/oauthproto/cimd"
20+
)
21+
22+
// CIMDStorageDecorator wraps storage.Storage and intercepts GetClient calls
23+
// for HTTPS client_id values, fetching and caching the corresponding Client
24+
// ID Metadata Document instead of requiring prior DCR registration.
25+
//
26+
// All other Storage methods delegate to the underlying storage unchanged.
27+
// Only GetClient is overridden. DCR clients (opaque IDs) continue to work
28+
// exactly as before.
29+
type CIMDStorageDecorator struct {
30+
Storage // embed full interface — all methods delegate
31+
sf singleflight.Group // deduplicates concurrent fetches for the same URL
32+
cache *lru.Cache[string, *cimdCacheEntry]
33+
ttl time.Duration
34+
}
35+
36+
type cimdCacheEntry struct {
37+
client fosite.Client
38+
expires time.Time
39+
}
40+
41+
// NewCIMDStorageDecorator wraps base with CIMD client lookup. When enabled=false
42+
// it returns base unchanged (no allocation). cacheMaxSize must be >= 1;
43+
// fallbackTTL is the fixed TTL applied to every cache entry (Cache-Control
44+
// header parsing is not yet implemented; all entries use this value).
45+
func NewCIMDStorageDecorator(
46+
base Storage,
47+
enabled bool,
48+
cacheMaxSize int,
49+
fallbackTTL time.Duration,
50+
) (Storage, error) {
51+
if !enabled {
52+
return base, nil
53+
}
54+
55+
if cacheMaxSize < 1 {
56+
return nil, fmt.Errorf("CIMD storage decorator cacheMaxSize must be >= 1, got %d", cacheMaxSize)
57+
}
58+
59+
c, err := lru.New[string, *cimdCacheEntry](cacheMaxSize)
60+
if err != nil {
61+
return nil, fmt.Errorf("failed to create CIMD LRU cache: %w", err)
62+
}
63+
64+
return &CIMDStorageDecorator{
65+
Storage: base,
66+
cache: c,
67+
ttl: fallbackTTL,
68+
}, nil
69+
}
70+
71+
// GetClient intercepts HTTPS client_id values to resolve them via CIMD.
72+
// Opaque DCR-issued IDs are delegated to the underlying storage unchanged.
73+
func (d *CIMDStorageDecorator) GetClient(ctx context.Context, id string) (fosite.Client, error) {
74+
if !oauthproto.IsClientIDMetadataDocumentURL(id) {
75+
return d.Storage.GetClient(ctx, id)
76+
}
77+
return d.fetchOrCached(ctx, id)
78+
}
79+
80+
// Unwrap returns the underlying storage so that type assertions (e.g. for
81+
// storage.DCRCredentialStore in server_impl.go) can reach the concrete type.
82+
func (d *CIMDStorageDecorator) Unwrap() Storage {
83+
return d.Storage
84+
}
85+
86+
func (d *CIMDStorageDecorator) fetchOrCached(ctx context.Context, id string) (fosite.Client, error) {
87+
// Check cache first (outside singleflight to avoid holding the group lock for cache hits)
88+
if entry, ok := d.cache.Get(id); ok && time.Now().Before(entry.expires) {
89+
return entry.client, nil
90+
}
91+
92+
// Deduplicate concurrent fetches for the same URL. The shared fetch uses a
93+
// context detached from the caller so that one caller cancelling does not
94+
// abort the in-flight request for other waiters. The HTTP client inside
95+
// FetchClientMetadataDocument enforces its own 5-second timeout.
96+
fetchCtx := context.WithoutCancel(ctx)
97+
result, err, _ := d.sf.Do(id, func() (interface{}, error) {
98+
// Re-check cache inside singleflight (another goroutine may have populated it)
99+
if entry, ok := d.cache.Get(id); ok && time.Now().Before(entry.expires) {
100+
return entry.client, nil
101+
}
102+
return d.fetch(fetchCtx, id)
103+
})
104+
if err != nil {
105+
return nil, err
106+
}
107+
client, ok := result.(fosite.Client)
108+
if !ok {
109+
return nil, fmt.Errorf("CIMD singleflight returned unexpected type %T", result)
110+
}
111+
return client, nil
112+
}
113+
114+
func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Client, error) {
115+
doc, err := cimd.FetchClientMetadataDocument(ctx, id)
116+
if err != nil {
117+
return nil, fmt.Errorf("%w: %w", fosite.ErrNotFound.WithHint("CIMD fetch failed"), err)
118+
}
119+
120+
// Reject documents that declare an auth method this AS does not support.
121+
// The embedded AS only advertises "none"; accepting a doc that says
122+
// "private_key_jwt" and then silently treating the client as public would
123+
// mislead operators and break clients that actually try to use JWT assertions.
124+
if m := doc.TokenEndpointAuthMethod; m != "" && m != defaultCIMDTokenEndpointAuthMethod {
125+
return nil, fmt.Errorf("%w: CIMD document at %s claims token_endpoint_auth_method %q "+
126+
"but this server only supports %q",
127+
fosite.ErrNotFound.WithHint("unsupported token_endpoint_auth_method"),
128+
id, m, defaultCIMDTokenEndpointAuthMethod)
129+
}
130+
131+
client := buildFositeClient(doc)
132+
133+
d.cache.Add(id, &cimdCacheEntry{
134+
client: client,
135+
expires: time.Now().Add(d.ttl),
136+
})
137+
138+
return client, nil
139+
}
140+
141+
// defaultCIMDGrantTypes are the OAuth 2.0 grant types applied when the CIMD
142+
// document omits grant_types. CIMD clients are typically public native apps
143+
// that use the authorization code flow with refresh token rotation.
144+
var defaultCIMDGrantTypes = []string{"authorization_code", "refresh_token"}
145+
146+
// defaultCIMDResponseTypes are the OAuth 2.0 response types applied when the
147+
// CIMD document omits response_types.
148+
var defaultCIMDResponseTypes = []string{"code"}
149+
150+
// defaultCIMDTokenEndpointAuthMethod is the token endpoint authentication
151+
// method applied when the CIMD document omits token_endpoint_auth_method.
152+
// Documents that declare any other value are rejected by fetch() before
153+
// buildFositeClient is called.
154+
const defaultCIMDTokenEndpointAuthMethod = "none"
155+
156+
// buildFositeClient converts a ClientMetadataDocument into a fosite.Client.
157+
// Redirect URIs containing http://localhost are wrapped in a LoopbackClient
158+
// so that RFC 8252 §7.3 dynamic port matching applies.
159+
func buildFositeClient(doc *cimd.ClientMetadataDocument) fosite.Client {
160+
grantTypes := doc.GrantTypes
161+
if len(grantTypes) == 0 {
162+
grantTypes = defaultCIMDGrantTypes
163+
}
164+
165+
responseTypes := doc.ResponseTypes
166+
if len(responseTypes) == 0 {
167+
responseTypes = defaultCIMDResponseTypes
168+
}
169+
170+
tokenEndpointAuthMethod := doc.TokenEndpointAuthMethod
171+
if tokenEndpointAuthMethod == "" {
172+
tokenEndpointAuthMethod = defaultCIMDTokenEndpointAuthMethod
173+
}
174+
175+
var scopes []string
176+
if doc.Scope != "" {
177+
scopes = strings.Fields(doc.Scope)
178+
}
179+
180+
defaultClient := &fosite.DefaultClient{
181+
ID: doc.ClientID,
182+
RedirectURIs: doc.RedirectURIs,
183+
GrantTypes: grantTypes,
184+
ResponseTypes: responseTypes,
185+
Scopes: scopes,
186+
// CIMD clients don't pre-declare audience; leave empty so the AS
187+
// applies its own audience policy rather than rejecting all values.
188+
Audience: nil,
189+
Public: true,
190+
}
191+
192+
openIDClient := &fosite.DefaultOpenIDConnectClient{
193+
DefaultClient: defaultClient,
194+
TokenEndpointAuthMethod: tokenEndpointAuthMethod,
195+
}
196+
197+
// Wrap in LoopbackClient when any redirect URI targets localhost so that
198+
// RFC 8252 §7.3 dynamic port matching works for native app clients.
199+
// Pass openIDClient directly so TokenEndpointAuthMethod is preserved —
200+
// LoopbackClient now embeds *fosite.DefaultOpenIDConnectClient.
201+
if hasLoopbackRedirectURI(doc.RedirectURIs) {
202+
return registration.NewLoopbackClient(openIDClient)
203+
}
204+
205+
return openIDClient
206+
}
207+
208+
// hasLoopbackRedirectURI returns true when any of the redirect URIs in the
209+
// list targets a loopback address over HTTP. The host is parsed from each URI
210+
// to prevent bypass via hosts like "http://localhost.evil.com/".
211+
func hasLoopbackRedirectURI(uris []string) bool {
212+
for _, uri := range uris {
213+
parsed, err := url.Parse(uri)
214+
if err != nil {
215+
continue
216+
}
217+
if parsed.Scheme == "http" && oauthproto.IsLoopbackHost(parsed.Hostname()) {
218+
return true
219+
}
220+
}
221+
return false
222+
}

0 commit comments

Comments
 (0)