From d852ae197c8aa3a9128e84ad95678be1d4efbecd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 6 Oct 2026 21:17:30 -0700 Subject: [PATCH] fix(auth): tolerate default issuer discovery server errors Preserve strict custom issuer discovery and explicit endpoint overrides. Co-authored-by: Sebastien Tardif --- CHANGELOG.md | 2 + apps/api/internal/httpapi/openclawid.go | 4 +- apps/api/internal/httpapi/openclawid_test.go | 47 ++++++++++++++++++++ docs/features/auth.md | 2 +- 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3ca5fbc..bbbb3127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Keep startup available during default OpenClaw ID discovery server errors while custom issuers still fail closed. Thanks @SebTardif. + ## 0.7.0 - 2026-09-30 **Highlights:** Personal channel order follows your account across browsers and devices. diff --git a/apps/api/internal/httpapi/openclawid.go b/apps/api/internal/httpapi/openclawid.go index 9c6d8614..4f854dd2 100644 --- a/apps/api/internal/httpapi/openclawid.go +++ b/apps/api/internal/httpapi/openclawid.go @@ -132,7 +132,9 @@ func (c OpenClawIDConfig) fetchOIDCDiscovery(ctx context.Context) (oidcDiscovery return oidcDiscoveryDocument{}, errOIDCDiscoveryUnavailable } defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusNotImplemented { + // Only the default issuer can use known endpoints when discovery is unavailable. + if resp.StatusCode == http.StatusNotFound || (resp.StatusCode >= 500 && resp.StatusCode < 600) { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, openClawIDDiscoveryMaxBytes)) return oidcDiscoveryDocument{}, errOIDCDiscoveryUnavailable } if resp.StatusCode != http.StatusOK { diff --git a/apps/api/internal/httpapi/openclawid_test.go b/apps/api/internal/httpapi/openclawid_test.go index 0bb4dd56..59002b3b 100644 --- a/apps/api/internal/httpapi/openclawid_test.go +++ b/apps/api/internal/httpapi/openclawid_test.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "encoding/json" "errors" + "fmt" "github.com/openclaw/clickclack/apps/api/internal/store" "io" "net/http" @@ -557,6 +558,48 @@ func TestOpenClawIDApplyDiscoveryDefaultIssuerFallsBack(t *testing.T) { } } +func TestOpenClawIDApplyDiscoveryUnavailableStatuses(t *testing.T) { + t.Parallel() + for _, code := range []int{404, 500, 501, 502, 503, 504, 599} { + for _, issuer := range []string{defaultOpenClawIDIssuer, "https://id.example.com/oidc"} { + t.Run(fmt.Sprintf("%d/%s", code, issuer), func(t *testing.T) { + t.Parallel() + cfg := OpenClawIDConfig{ + ClientID: "client", ClientSecret: "test-secret", Issuer: issuer, + HTTPClient: &http.Client{Transport: oidcDiscoveryTransport(func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader("unavailable")), Header: make(http.Header), Request: r}, nil + })}, + } + resolved, err := cfg.ApplyDiscovery(context.Background()) + if issuer != defaultOpenClawIDIssuer { + if !errors.Is(err, errOIDCDiscoveryUnavailable) { + t.Fatalf("custom issuer must fail closed, got %v", err) + } + return + } + if err != nil { + t.Fatal(err) + } + if resolved.AuthURL != issuer+"/oauth2/authorize" || resolved.TokenURL != issuer+"/oauth2/token" { + t.Fatalf("unexpected fallback endpoints auth=%q token=%q", resolved.AuthURL, resolved.TokenURL) + } + for _, field := range []string{"authorization", "token"} { + explicit := cfg + if field == "authorization" { + explicit.AuthURL = "https://configured.example/authorize" + } else { + explicit.TokenURL = "https://configured.example/token" + } + got, err := explicit.ApplyDiscovery(context.Background()) + if err != nil || (field == "authorization" && got.AuthURL != explicit.AuthURL) || (field == "token" && got.TokenURL != explicit.TokenURL) { + t.Fatalf("fallback did not preserve explicit %s endpoint: %v", field, err) + } + } + }) + } + } +} + func TestOpenClawIDApplyDiscoveryRejectsIssuerMismatch(t *testing.T) { t.Parallel() provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -642,6 +685,10 @@ func TestOpenClawIDDiscoveryRejectsMalformedMetadata(t *testing.T) { {"insecure endpoint", `{"issuer":"https://id.openclaw.ai/api/auth","authorization_endpoint":"http://remote.example/authorize","token_endpoint":"https://id.openclaw.ai/token"}`, 200}, {"missing endpoint", `{"issuer":"https://id.openclaw.ai/api/auth"}`, 200}, {"redirect", "", 302}, + {"client error", "", 400}, + {"unauthorized", "", 401}, + {"rate limited", "", 429}, + {"outside server error range", "", 600}, } { t.Run(tc.name, func(t *testing.T) { cfg := OpenClawIDConfig{ClientID: "client", ClientSecret: "test-secret", HTTPClient: &http.Client{Transport: oidcDiscoveryTransport(func(r *http.Request) (*http.Response, error) { diff --git a/docs/features/auth.md b/docs/features/auth.md index d317c231..adef6e82 100644 --- a/docs/features/auth.md +++ b/docs/features/auth.md @@ -278,7 +278,7 @@ At serve startup, ClickClack GETs document's `authorization_endpoint` and `token_endpoint`. The document `issuer` must match `OPENCLAW_ID_ISSUER`. The default OpenClaw ID issuer may fall back to `/oauth2/authorize` and `/oauth2/token` when discovery is -unavailable (network failure, HTTP 404, or HTTP 501). Malformed metadata and +unavailable (network failure, HTTP 404, or HTTP 5xx). Malformed metadata and redirects always fail closed. Any other issuer requires a usable discovery document. Explicit endpoint overrides are preserved independently. Discovery requests are bounded to 30 seconds and 64 KiB; endpoints require HTTPS except