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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Added "Sign in with OpenClaw ID" browser login through the first-party OIDC provider at id.openclaw.ai, reusing the existing OAuth transaction store, email-linked user provisioning, and session cookies alongside GitHub login.
- Updated the minimum Go toolchain to 1.26.6 to resolve GO-2026-5026, GO-2026-5972, GO-2026-6090, and GO-2026-6218.

## v0.3.0 - 2026-08-13
Expand Down
6 changes: 6 additions & 0 deletions apps/api/cmd/clickclack/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ 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,
},
Access: httpapi.AccessConfig{
TeamDomain: cfg.AccessTeamDomain,
Audience: cfg.AccessAUD,
Expand Down
67 changes: 45 additions & 22 deletions apps/api/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,31 @@ import (
)

type Config struct {
Addr string `json:"addr"`
Data string `json:"data"`
DB string `json:"db"`
Uploads string `json:"uploads"`
Environment string `json:"environment"`
MetricsEnabled bool `json:"metrics_enabled"`
PublicURL string `json:"public_url"`
PublicAPIURL string `json:"public_api_url"`
EmbedFrameAncestors []string `json:"embed_frame_ancestors"`
CookieNamespace string `json:"cookie_namespace"`
DevBootstrap bool `json:"dev_bootstrap"`
GitHubClientID string `json:"github_client_id"`
GitHubClientSecret string `json:"github_client_secret"`
GitHubAllowedOrg string `json:"github_allowed_org"`
GitHubModeratorOrg string `json:"github_moderator_org"`
AccessTeamDomain string `json:"access_team_domain"`
AccessAUD string `json:"access_aud"`
PushoverAPIToken string `json:"pushover_api_token"`
R2AccountID string `json:"r2_account_id"`
R2AccessKeyID string `json:"r2_access_key_id"`
R2SecretAccessKey string `json:"r2_secret_access_key"`
R2Endpoint string `json:"r2_endpoint"`
Addr string `json:"addr"`
Data string `json:"data"`
DB string `json:"db"`
Uploads string `json:"uploads"`
Environment string `json:"environment"`
MetricsEnabled bool `json:"metrics_enabled"`
PublicURL string `json:"public_url"`
PublicAPIURL string `json:"public_api_url"`
EmbedFrameAncestors []string `json:"embed_frame_ancestors"`
CookieNamespace string `json:"cookie_namespace"`
DevBootstrap bool `json:"dev_bootstrap"`
GitHubClientID string `json:"github_client_id"`
GitHubClientSecret string `json:"github_client_secret"`
GitHubAllowedOrg string `json:"github_allowed_org"`
GitHubModeratorOrg string `json:"github_moderator_org"`
OpenClawIDClientID string `json:"openclaw_id_client_id"`
OpenClawIDClientSecret string `json:"openclaw_id_client_secret"`
OpenClawIDIssuer string `json:"openclaw_id_issuer"`
AccessTeamDomain string `json:"access_team_domain"`
AccessAUD string `json:"access_aud"`
PushoverAPIToken string `json:"pushover_api_token"`
R2AccountID string `json:"r2_account_id"`
R2AccessKeyID string `json:"r2_access_key_id"`
R2SecretAccessKey string `json:"r2_secret_access_key"`
R2Endpoint string `json:"r2_endpoint"`
}

func Defaults() Config {
Expand Down Expand Up @@ -111,6 +114,15 @@ func Load(path string) (Config, error) {
if env := os.Getenv("CLICKCLACK_GITHUB_MODERATOR_ORG"); env != "" {
cfg.GitHubModeratorOrg = env
}
if env := os.Getenv("OPENCLAW_ID_CLIENT_ID"); env != "" {
cfg.OpenClawIDClientID = env
}
if env := os.Getenv("OPENCLAW_ID_CLIENT_SECRET"); env != "" {
cfg.OpenClawIDClientSecret = env
}
if env := os.Getenv("OPENCLAW_ID_ISSUER"); env != "" {
cfg.OpenClawIDIssuer = env
}
if env := os.Getenv("CLICKCLACK_ACCESS_TEAM_DOMAIN"); env != "" {
cfg.AccessTeamDomain = env
}
Expand Down Expand Up @@ -182,6 +194,14 @@ func (c *Config) ValidateServe() error {
if (allowedOrg != "" || moderatorOrg != "") && !hasClientID {
return errors.New("GitHub organization settings require GitHub OAuth credentials")
}
openclawClientID := strings.TrimSpace(c.OpenClawIDClientID)
openclawClientSecret := strings.TrimSpace(c.OpenClawIDClientSecret)
if (openclawClientID != "") != (openclawClientSecret != "") {
return errors.New("OPENCLAW_ID_CLIENT_ID and OPENCLAW_ID_CLIENT_SECRET must be configured together")
}
if openclawClientID != "" && publicURL == "" {
return errors.New("OpenClaw ID sign-in requires CLICKCLACK_PUBLIC_URL")
}
if _, err := authpolicy.NewCookieNames(namespace, publicURL, publicAPIURL); err != nil {
return fmt.Errorf("cookie policy: %w", err)
}
Expand All @@ -193,6 +213,9 @@ func (c *Config) ValidateServe() error {
c.GitHubClientSecret = clientSecret
c.GitHubAllowedOrg = allowedOrg
c.GitHubModeratorOrg = moderatorOrg
c.OpenClawIDClientID = openclawClientID
c.OpenClawIDClientSecret = openclawClientSecret
c.OpenClawIDIssuer = strings.TrimSpace(c.OpenClawIDIssuer)
return nil
}

Expand Down
11 changes: 11 additions & 0 deletions apps/api/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ func TestLoadDefaultsEnvAndFile(t *testing.T) {
t.Setenv("CLICKCLACK_GITHUB_CLIENT_SECRET", "secret")
t.Setenv("CLICKCLACK_GITHUB_ALLOWED_ORG", "openclaw")
t.Setenv("CLICKCLACK_GITHUB_MODERATOR_ORG", "openclaw")
t.Setenv("OPENCLAW_ID_CLIENT_ID", "ocid-client")
t.Setenv("OPENCLAW_ID_CLIENT_SECRET", "ocid-secret")
t.Setenv("OPENCLAW_ID_ISSUER", "https://id.openclaw.test/api/auth")
t.Setenv("CLICKCLACK_PUSHOVER_API_TOKEN", "app-token")
t.Setenv("CLICKCLACK_R2_ACCOUNT_ID", "account")
t.Setenv("CLICKCLACK_R2_ACCESS_KEY_ID", "access")
Expand All @@ -34,6 +37,9 @@ func TestLoadDefaultsEnvAndFile(t *testing.T) {
if cfg.Addr != ":9000" || cfg.Data != "/tmp/clickclack" || cfg.DB != "sqlite:///tmp/clickclack.db" || cfg.Uploads != "r2://clickclack-uploads/prod" || cfg.Environment != "fakeco" || !cfg.MetricsEnabled || cfg.PublicURL != "https://clickclack.test" || cfg.PublicAPIURL != "https://api.clickclack.test/services/clickclack/" || len(cfg.EmbedFrameAncestors) != 2 || cfg.EmbedFrameAncestors[0] != "https://control.example.com" || cfg.CookieNamespace != "prod-2" || cfg.DevBootstrap || cfg.GitHubClientID != "client" || cfg.GitHubClientSecret != "secret" || cfg.GitHubAllowedOrg != "openclaw" || cfg.GitHubModeratorOrg != "openclaw" || cfg.PushoverAPIToken != "app-token" || cfg.R2AccountID != "account" || cfg.R2AccessKeyID != "access" || cfg.R2SecretAccessKey != "secret-access" || cfg.R2Endpoint != "https://r2.example.com" {
t.Fatalf("unexpected env config: %#v", cfg)
}
if cfg.OpenClawIDClientID != "ocid-client" || cfg.OpenClawIDClientSecret != "ocid-secret" || cfg.OpenClawIDIssuer != "https://id.openclaw.test/api/auth" {
t.Fatalf("unexpected OpenClaw ID env config: %#v", cfg)
}

path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"addr":":7000","data":"/data"}`), 0o644); err != nil {
Expand Down Expand Up @@ -62,6 +68,9 @@ func TestLoadDefaultsEnvAndFile(t *testing.T) {
t.Setenv("CLICKCLACK_GITHUB_CLIENT_SECRET", "")
t.Setenv("CLICKCLACK_GITHUB_ALLOWED_ORG", "")
t.Setenv("CLICKCLACK_GITHUB_MODERATOR_ORG", "")
t.Setenv("OPENCLAW_ID_CLIENT_ID", "")
t.Setenv("OPENCLAW_ID_CLIENT_SECRET", "")
t.Setenv("OPENCLAW_ID_ISSUER", "")
t.Setenv("CLICKCLACK_PUSHOVER_API_TOKEN", "")
t.Setenv("CLICKCLACK_R2_ACCOUNT_ID", "")
t.Setenv("CLICKCLACK_R2_ACCESS_KEY_ID", "")
Expand Down Expand Up @@ -164,6 +173,8 @@ func TestValidateServe(t *testing.T) {
{"missing client secret", Config{PublicURL: "https://chat.example.com", GitHubClientID: "client"}},
{"oauth without public url", Config{GitHubClientID: "client", GitHubClientSecret: "secret"}},
{"org without oauth", Config{GitHubAllowedOrg: "openclaw"}},
{"missing openclaw id client secret", Config{PublicURL: "https://chat.example.com", OpenClawIDClientID: "client"}},
{"openclaw id without public url", Config{OpenClawIDClientID: "client", OpenClawIDClientSecret: "secret"}},
{"access domain only", Config{AccessTeamDomain: "https://openclaw.cloudflareaccess.com"}},
{"access audience only", Config{AccessAUD: "test-aud"}},
{"access domain must use https", Config{AccessTeamDomain: "http://openclaw.cloudflareaccess.com", AccessAUD: "test-aud"}},
Expand Down
37 changes: 30 additions & 7 deletions apps/api/internal/httpapi/observability.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,19 @@ type metricValue struct {
}

type metricsRegistry struct {
mu sync.Mutex
values map[metricKey]metricValue
githubOAuthEvents map[string]uint64
ready bool
readyMu sync.RWMutex
mu sync.Mutex
values map[metricKey]metricValue
githubOAuthEvents map[string]uint64
openclawOAuthEvents map[string]uint64
ready bool
readyMu sync.RWMutex
}

func newMetricsRegistry() *metricsRegistry {
return &metricsRegistry{
values: make(map[metricKey]metricValue),
githubOAuthEvents: make(map[string]uint64),
values: make(map[metricKey]metricValue),
githubOAuthEvents: make(map[string]uint64),
openclawOAuthEvents: make(map[string]uint64),
}
}

Expand Down Expand Up @@ -123,6 +125,15 @@ func (s *Server) recordGitHubOAuthEvent(event string) {
s.metrics.mu.Unlock()
}

func (s *Server) recordOpenClawIDOAuthEvent(event string) {
if s.metrics == nil {
return
}
s.metrics.mu.Lock()
s.metrics.openclawOAuthEvents[event]++
s.metrics.mu.Unlock()
}

func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
Expand Down Expand Up @@ -166,6 +177,12 @@ func (m *metricsRegistry) render(build buildMetadata) string {
oauthEvents = append(oauthEvents, event)
oauthEventValues[event] = value
}
openclawEvents := make([]string, 0, len(m.openclawOAuthEvents))
openclawEventValues := make(map[string]uint64, len(m.openclawOAuthEvents))
for event, value := range m.openclawOAuthEvents {
openclawEvents = append(openclawEvents, event)
openclawEventValues[event] = value
}
m.mu.Unlock()
sort.Slice(keys, func(i, j int) bool {
if keys[i].Method != keys[j].Method {
Expand Down Expand Up @@ -204,6 +221,12 @@ func (m *metricsRegistry) render(build buildMetadata) string {
for _, event := range oauthEvents {
fmt.Fprintf(&out, "clickclack_github_oauth_events_total{event=\"%s\"} %d\n", metricLabel(event), oauthEventValues[event])
}
sort.Strings(openclawEvents)
out.WriteString("# HELP clickclack_openclaw_id_oauth_events_total OpenClaw ID OIDC lifecycle events by bounded event category.\n")
out.WriteString("# TYPE clickclack_openclaw_id_oauth_events_total counter\n")
for _, event := range openclawEvents {
fmt.Fprintf(&out, "clickclack_openclaw_id_oauth_events_total{event=\"%s\"} %d\n", metricLabel(event), openclawEventValues[event])
}
return out.String()
}

Expand Down
Loading
Loading