From 01fa869899c07cb30fbcf5bb4baafbfbfdef6e27 Mon Sep 17 00:00:00 2001 From: Adam Claassens Date: Mon, 14 Sep 2026 13:38:06 +0200 Subject: [PATCH] feat(auth): discover OIDC authorize and token endpoints OpenClaw ID still concatenates {issuer}/oauth2/* as the default. Other issuers, including Kanidm, publish different authorization and token paths. Fetch OpenID Connect discovery at serve startup and fail closed when a custom issuer has no usable document. --- apps/api/cmd/clickclack/main.go | 16 ++- apps/api/internal/httpapi/openclawid.go | 111 +++++++++++++++++ apps/api/internal/httpapi/openclawid_test.go | 122 +++++++++++++++++++ docs/deployment.md | 10 +- docs/features/auth.md | 17 ++- 5 files changed, 262 insertions(+), 14 deletions(-) diff --git a/apps/api/cmd/clickclack/main.go b/apps/api/cmd/clickclack/main.go index d27bf27e..4430f804 100644 --- a/apps/api/cmd/clickclack/main.go +++ b/apps/api/cmd/clickclack/main.go @@ -143,6 +143,15 @@ func serve(args []string) error { if cfg.PushoverAPIToken != "" { pushNotifier = httpapi.NewPushoverNotifier(cfg.PushoverAPIToken) } + openclawID, err := httpapi.OpenClawIDConfig{ + ClientID: cfg.OpenClawIDClientID, + ClientSecret: cfg.OpenClawIDClientSecret, + Issuer: cfg.OpenClawIDIssuer, + PublicURL: cfg.PublicURL, + }.ApplyDiscovery(ctx) + if err != nil { + return err + } log.Printf("ClickClack listening on %s", displayURL(cfg.Addr)) server := httpapi.New(st, realtime.NewHub(), httpapi.Options{ UploadStorage: uploads, @@ -160,12 +169,7 @@ func serve(args []string) error { AllowedOrg: cfg.GitHubAllowedOrg, ModeratorOrg: cfg.GitHubModeratorOrg, }, - OpenClawID: httpapi.OpenClawIDConfig{ - ClientID: cfg.OpenClawIDClientID, - ClientSecret: cfg.OpenClawIDClientSecret, - Issuer: cfg.OpenClawIDIssuer, - PublicURL: cfg.PublicURL, - }, + OpenClawID: openclawID, Access: httpapi.AccessConfig{ TeamDomain: cfg.AccessTeamDomain, Audience: cfg.AccessAUD, diff --git a/apps/api/internal/httpapi/openclawid.go b/apps/api/internal/httpapi/openclawid.go index 619834dc..6bf48716 100644 --- a/apps/api/internal/httpapi/openclawid.go +++ b/apps/api/internal/httpapi/openclawid.go @@ -2,9 +2,13 @@ package httpapi import ( "context" + "encoding/json" "errors" + "fmt" + "io" "log" "net/http" + "net/url" "slices" "strings" "time" @@ -30,6 +34,7 @@ const ( defaultOpenClawIDIssuer = "https://id.openclaw.ai/api/auth" defaultOpenClawIDHTTPTimeout = 30 * time.Second openClawIDTokenClockLeeway = 30 * time.Second + openClawIDDiscoveryMaxBytes = 64 << 10 ) const ( @@ -62,6 +67,112 @@ func (c OpenClawIDConfig) withDefaults() OpenClawIDConfig { return c } +type oidcDiscoveryDocument struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` +} + +// ApplyDiscovery fills AuthURL and TokenURL from the issuer's OpenID Connect +// Discovery document. Concatenating {issuer}/oauth2/authorize is the OpenClaw +// ID default; Kanidm and other IdPs publish different authorization and token +// paths. Explicit AuthURL and TokenURL skip the fetch. A custom issuer without +// a usable discovery document fails closed. +func (c OpenClawIDConfig) ApplyDiscovery(ctx context.Context) (OpenClawIDConfig, error) { + explicitAuth := strings.TrimSpace(c.AuthURL) != "" + explicitToken := strings.TrimSpace(c.TokenURL) != "" + c = c.withDefaults() + if c.ClientID == "" || c.ClientSecret == "" { + return c, nil + } + if explicitAuth && explicitToken { + return c, nil + } + doc, err := c.fetchOIDCDiscovery(ctx) + if err != nil { + if c.Issuer == defaultOpenClawIDIssuer { + return c, nil + } + return OpenClawIDConfig{}, err + } + if strings.TrimRight(strings.TrimSpace(doc.Issuer), "/") != c.Issuer { + return OpenClawIDConfig{}, errors.New("oidc discovery issuer does not match OPENCLAW_ID_ISSUER") + } + authURL, err := parseOIDCEndpoint(doc.AuthorizationEndpoint) + if err != nil { + return OpenClawIDConfig{}, fmt.Errorf("oidc authorization_endpoint: %w", err) + } + tokenURL, err := parseOIDCEndpoint(doc.TokenEndpoint) + if err != nil { + return OpenClawIDConfig{}, fmt.Errorf("oidc token_endpoint: %w", err) + } + c.AuthURL = authURL + c.TokenURL = tokenURL + return c, nil +} + +func (c OpenClawIDConfig) fetchOIDCDiscovery(ctx context.Context) (oidcDiscoveryDocument, error) { + discovery, err := url.Parse(c.Issuer + "/.well-known/openid-configuration") + if err != nil || !oidcHTTPURLAllowed(discovery) { + return oidcDiscoveryDocument{}, errors.New("oidc discovery url is not allowed") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, discovery.String(), nil) + if err != nil { + return oidcDiscoveryDocument{}, errors.New("oidc discovery request failed") + } + resp, err := discoveryHTTPClient(c.HTTPClient).Do(req) + if err != nil { + return oidcDiscoveryDocument{}, errors.New("oidc discovery request failed") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, openClawIDDiscoveryMaxBytes)) + return oidcDiscoveryDocument{}, fmt.Errorf("oidc discovery returned %s", resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, openClawIDDiscoveryMaxBytes+1)) + if err != nil { + return oidcDiscoveryDocument{}, errors.New("oidc discovery body unreadable") + } + if len(body) > openClawIDDiscoveryMaxBytes { + return oidcDiscoveryDocument{}, errors.New("oidc discovery body too large") + } + var doc oidcDiscoveryDocument + if err := json.Unmarshal(body, &doc); err != nil { + return oidcDiscoveryDocument{}, errors.New("oidc discovery document is not json") + } + return doc, nil +} + +func discoveryHTTPClient(base *http.Client) *http.Client { + cloned := *base + cloned.CheckRedirect = func(*http.Request, []*http.Request) error { + return errors.New("oidc discovery refused a redirect") + } + return &cloned +} + +func parseOIDCEndpoint(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || !oidcHTTPURLAllowed(parsed) { + return "", errors.New("endpoint is not an allowed http(s) url") + } + return parsed.String(), nil +} + +func oidcHTTPURLAllowed(parsed *url.URL) bool { + if parsed == nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return false + } + switch parsed.Scheme { + case "https": + return true + case "http": + return isLocalHostPort(parsed.Host) + default: + return false + } +} + func (s *Server) openclawIDStart(w http.ResponseWriter, r *http.Request) { s.recordOpenClawIDOAuthEvent(openclawIDOAuthEventBrowserStart) if s.openclawID.ClientID == "" || s.openclawID.ClientSecret == "" { diff --git a/apps/api/internal/httpapi/openclawid_test.go b/apps/api/internal/httpapi/openclawid_test.go index 1b5dea3b..27569484 100644 --- a/apps/api/internal/httpapi/openclawid_test.go +++ b/apps/api/internal/httpapi/openclawid_test.go @@ -472,3 +472,125 @@ func TestOpenClawIDOAuthStoreFailureBranches(t *testing.T) { t.Fatalf("expected missing code rejection, got %d", recorder.Code) } } + +type failRoundTrip struct{} + +func (failRoundTrip) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("offline") +} + +func TestOpenClawIDApplyDiscoveryUsesKanidmShapedEndpoints(t *testing.T) { + t.Parallel() + var issuer string + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth2/openid/clickclack/.well-known/openid-configuration" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": issuer, + "authorization_endpoint": strings.TrimSuffix(issuer, "/oauth2/openid/clickclack") + "/ui/oauth2", + "token_endpoint": strings.TrimSuffix(issuer, "/oauth2/openid/clickclack") + "/oauth2/token", + }) + })) + t.Cleanup(provider.Close) + issuer = provider.URL + "/oauth2/openid/clickclack" + base := strings.TrimSuffix(provider.URL, "/") + resolved, err := OpenClawIDConfig{ + ClientID: "clickclack", + ClientSecret: "secret", + Issuer: issuer, + HTTPClient: provider.Client(), + }.ApplyDiscovery(context.Background()) + if err != nil { + t.Fatal(err) + } + if resolved.AuthURL != base+"/ui/oauth2" || resolved.TokenURL != base+"/oauth2/token" { + t.Fatalf("unexpected discovered endpoints auth=%q token=%q", resolved.AuthURL, resolved.TokenURL) + } + st := newEmptyHTTPStore(t) + server := httptest.NewServer(New(st, realtime.NewHub(), Options{OpenClawID: resolved}).Handler()) + t.Cleanup(server.Close) + client := &http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Get(server.URL + "/api/auth/openclaw/start") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusFound || !strings.HasPrefix(resp.Header.Get("Location"), base+"/ui/oauth2?") { + t.Fatalf("expected Kanidm authorize redirect, got %s %s", resp.Status, resp.Header.Get("Location")) + } +} + +func TestOpenClawIDApplyDiscoveryCustomIssuerRequiresDocument(t *testing.T) { + t.Parallel() + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(provider.Close) + _, err := OpenClawIDConfig{ + ClientID: "clickclack", + ClientSecret: "secret", + Issuer: provider.URL + "/oauth2/openid/clickclack", + HTTPClient: provider.Client(), + }.ApplyDiscovery(context.Background()) + if err == nil { + t.Fatal("expected custom issuer without discovery to fail closed") + } +} + +func TestOpenClawIDApplyDiscoveryDefaultIssuerFallsBack(t *testing.T) { + t.Parallel() + resolved, err := OpenClawIDConfig{ + ClientID: "client", + ClientSecret: "secret", + HTTPClient: &http.Client{Timeout: time.Second, Transport: failRoundTrip{}}, + }.ApplyDiscovery(context.Background()) + if err != nil { + t.Fatal(err) + } + if resolved.AuthURL != defaultOpenClawIDIssuer+"/oauth2/authorize" || resolved.TokenURL != defaultOpenClawIDIssuer+"/oauth2/token" { + t.Fatalf("unexpected fallback endpoints auth=%q token=%q", resolved.AuthURL, resolved.TokenURL) + } +} + +func TestOpenClawIDApplyDiscoveryRejectsIssuerMismatch(t *testing.T) { + t.Parallel() + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": "https://evil.example.com/oauth2/openid/clickclack", + "authorization_endpoint": "https://evil.example.com/ui/oauth2", + "token_endpoint": "https://evil.example.com/oauth2/token", + }) + })) + t.Cleanup(provider.Close) + _, err := OpenClawIDConfig{ + ClientID: "clickclack", + ClientSecret: "secret", + Issuer: provider.URL + "/oauth2/openid/clickclack", + HTTPClient: provider.Client(), + }.ApplyDiscovery(context.Background()) + if err == nil { + t.Fatal("expected discovery issuer mismatch to fail") + } +} + +func TestOpenClawIDApplyDiscoverySkipsFetchWhenEndpointsAreExplicit(t *testing.T) { + t.Parallel() + resolved, err := OpenClawIDConfig{ + ClientID: "clickclack", + ClientSecret: "secret", + Issuer: "https://idm.example.com/oauth2/openid/clickclack", + AuthURL: "https://idm.example.com/ui/oauth2", + TokenURL: "https://idm.example.com/oauth2/token", + HTTPClient: &http.Client{Timeout: time.Second, Transport: failRoundTrip{}}, + }.ApplyDiscovery(context.Background()) + if err != nil { + t.Fatal(err) + } + if resolved.AuthURL != "https://idm.example.com/ui/oauth2" || resolved.TokenURL != "https://idm.example.com/oauth2/token" { + t.Fatalf("explicit endpoints were rewritten: auth=%q token=%q", resolved.AuthURL, resolved.TokenURL) + } +} diff --git a/docs/deployment.md b/docs/deployment.md index 0cfb3d29..60b21125 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -243,12 +243,14 @@ If you want first-party OpenClaw ID (OIDC) login, set: CLICKCLACK_PUBLIC_URL=https://chat.example.com OPENCLAW_ID_CLIENT_ID=... OPENCLAW_ID_CLIENT_SECRET=... -# Optional issuer override (default https://id.openclaw.ai/api/auth): -# OPENCLAW_ID_ISSUER=https://id.openclaw.ai/api/auth +# Optional issuer override (default https://id.openclaw.ai/api/auth). +# For Kanidm, use the per-client issuer from discovery, for example: +# OPENCLAW_ID_ISSUER=https://idm.example.com/oauth2/openid/clickclack ``` -Register the redirect URI `/api/auth/openclaw/callback` with the -identity provider. On the hosted Cloudflare deployment, set the credentials as +Serve fetches `/.well-known/openid-configuration` and uses that +document's authorization and token endpoints. Register the redirect URI +`/api/auth/openclaw/callback` with the identity provider. On the hosted Cloudflare deployment, set the credentials as Worker secrets (`wrangler secret put OPENCLAW_ID_CLIENT_ID` and `wrangler secret put OPENCLAW_ID_CLIENT_SECRET`); the Worker passes them into the container when present. See [features/auth.md](features/auth.md) for the diff --git a/docs/features/auth.md b/docs/features/auth.md index 6081b82b..4cf8cf41 100644 --- a/docs/features/auth.md +++ b/docs/features/auth.md @@ -260,7 +260,7 @@ confidential OAuth 2.1 client. Configure the server through the environment: ```sh OPENCLAW_ID_CLIENT_ID=... OPENCLAW_ID_CLIENT_SECRET=... -# Optional issuer override for staging or tests: +# Optional issuer override for staging, tests, or another OIDC provider: # OPENCLAW_ID_ISSUER=https://id.openclaw.ai/api/auth ``` @@ -268,15 +268,24 @@ Without a client ID and client secret, `GET /api/auth/openclaw/start` returns `501`. Both credentials must be configured together, and the flow requires `CLICKCLACK_PUBLIC_URL`. +At serve startup, ClickClack GETs +`/.well-known/openid-configuration` (no redirects) and uses that +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 +absent. Any other issuer fails closed without a usable discovery document. +Kanidm's discovery document is the supported way to point this client at +Kanidm; do not concatenate Kanidm's issuer with `/oauth2/authorize`. + Flow: 1. `GET /api/auth/openclaw/start` reuses the GitHub OAuth transaction store: a database-backed, ten-minute transaction, the same HTTP-only browser-binding - cookie, and a SHA-256 PKCE challenge, then redirects to - `/oauth2/authorize` with `scope=openid profile email`. + cookie, and a SHA-256 PKCE challenge, then redirects to the discovered + `authorization_endpoint` with `scope=openid profile email`. 2. OpenClaw ID redirects back to `GET /api/auth/openclaw/callback?code&state`. 3. The handler atomically consumes the state only when the browser binding - matches, exchanges the code at `/oauth2/token` using + matches, exchanges the code at the discovered `token_endpoint` using `client_secret_basic` plus the stored PKCE verifier, and reads the returned `id_token`. The token arrives directly from the issuer over TLS on an authenticated confidential-client exchange, so no local JWKS signature check