Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions apps/api/cmd/clickclack/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
111 changes: 111 additions & 0 deletions apps/api/internal/httpapi/openclawid.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ package httpapi

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"slices"
"strings"
"time"
Expand All @@ -30,6 +34,7 @@ const (
defaultOpenClawIDIssuer = "https://id.openclaw.ai/api/auth"
defaultOpenClawIDHTTPTimeout = 30 * time.Second
openClawIDTokenClockLeeway = 30 * time.Second
openClawIDDiscoveryMaxBytes = 64 << 10
)

const (
Expand Down Expand Up @@ -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 == "" {
Expand Down
122 changes: 122 additions & 0 deletions apps/api/internal/httpapi/openclawid_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
10 changes: 6 additions & 4 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<public-url>/api/auth/openclaw/callback` with the
identity provider. On the hosted Cloudflare deployment, set the credentials as
Serve fetches `<issuer>/.well-known/openid-configuration` and uses that
document's authorization and token endpoints. Register the redirect URI
`<public-url>/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
Expand Down
17 changes: 13 additions & 4 deletions docs/features/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,23 +260,32 @@ 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
```

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
`<issuer>/.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 `<issuer>/oauth2/authorize` and `<issuer>/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
`<issuer>/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 `<issuer>/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
Expand Down