feat: gate hosted app with GitHub org auth

This commit is contained in:
Peter Steinberger 2026-05-08 09:11:41 +01:00
parent 2d3a2cf0e7
commit c11251f313
No known key found for this signature in database
20 changed files with 476 additions and 100 deletions

View File

@ -135,6 +135,8 @@ HTTP endpoint also exists. GitHub OAuth is opt-in via:
CLICKCLACK_PUBLIC_URL=https://chat.example.com
CLICKCLACK_GITHUB_CLIENT_ID=...
CLICKCLACK_GITHUB_CLIENT_SECRET=...
CLICKCLACK_GITHUB_ALLOWED_ORG=openclaw
CLICKCLACK_DEV_BOOTSTRAP=false
```
Details and trade-offs in [docs/features/auth.md](docs/features/auth.md).

View File

@ -59,7 +59,7 @@ func serve(args []string) error {
flags.String("data", "./data", "data directory")
flags.String("db", "", "database URL")
configPath := flags.String("config", "", "config file")
devBootstrap := flags.Bool("dev-bootstrap", true, "create a local owner/workspace/channel if no user exists")
flags.Bool("dev-bootstrap", true, "create a local owner/workspace/channel if no user exists")
if err := flags.Parse(args); err != nil {
return err
}
@ -82,7 +82,7 @@ func serve(args []string) error {
if err := st.Migrate(ctx); err != nil {
return err
}
if *devBootstrap {
if cfg.DevBootstrap {
user, err := st.EnsureBootstrap(ctx, "Local Captain", "local@clickclack.chat")
if err != nil {
return err
@ -91,11 +91,13 @@ func serve(args []string) error {
}
log.Printf("ClickClack listening on %s", displayURL(cfg.Addr))
server := httpapi.New(st, realtime.NewHub(), httpapi.Options{
UploadDir: filepath.Join(cfg.Data, "uploads"),
UploadDir: filepath.Join(cfg.Data, "uploads"),
DisableDevAuth: !cfg.DevBootstrap,
GitHubOAuth: httpapi.GitHubOAuthConfig{
ClientID: cfg.GitHubClientID,
ClientSecret: cfg.GitHubClientSecret,
PublicURL: cfg.PublicURL,
AllowedOrg: cfg.GitHubAllowedOrg,
},
})
return httpapi.ListenAndServe(ctx, cfg.Addr, server.Handler())
@ -309,6 +311,8 @@ func applyFlagOverrides(flags *flag.FlagSet, cfg *config.Config) {
cfg.Data = f.Value.String()
case "db":
cfg.DB = f.Value.String()
case "dev-bootstrap":
cfg.DevBootstrap = f.Value.String() == "true"
}
})
}

View File

@ -3,6 +3,7 @@ package config
import (
"encoding/json"
"os"
"strconv"
)
type Config struct {
@ -10,12 +11,14 @@ type Config struct {
Data string `json:"data"`
DB string `json:"db"`
PublicURL string `json:"public_url"`
DevBootstrap bool `json:"dev_bootstrap"`
GitHubClientID string `json:"github_client_id"`
GitHubClientSecret string `json:"github_client_secret"`
GitHubAllowedOrg string `json:"github_allowed_org"`
}
func Defaults() Config {
return Config{Addr: ":8080", Data: "./data"}
return Config{Addr: ":8080", Data: "./data", DevBootstrap: true}
}
func Load(path string) (Config, error) {
@ -32,12 +35,22 @@ func Load(path string) (Config, error) {
if env := os.Getenv("CLICKCLACK_PUBLIC_URL"); env != "" {
cfg.PublicURL = env
}
if env := os.Getenv("CLICKCLACK_DEV_BOOTSTRAP"); env != "" {
value, err := strconv.ParseBool(env)
if err != nil {
return Config{}, err
}
cfg.DevBootstrap = value
}
if env := os.Getenv("CLICKCLACK_GITHUB_CLIENT_ID"); env != "" {
cfg.GitHubClientID = env
}
if env := os.Getenv("CLICKCLACK_GITHUB_CLIENT_SECRET"); env != "" {
cfg.GitHubClientSecret = env
}
if env := os.Getenv("CLICKCLACK_GITHUB_ALLOWED_ORG"); env != "" {
cfg.GitHubAllowedOrg = env
}
if path == "" {
return cfg, nil
}

View File

@ -11,13 +11,15 @@ func TestLoadDefaultsEnvAndFile(t *testing.T) {
t.Setenv("CLICKCLACK_DATA", "/tmp/clickclack")
t.Setenv("CLICKCLACK_DB", "sqlite:///tmp/clickclack.db")
t.Setenv("CLICKCLACK_PUBLIC_URL", "https://clickclack.test")
t.Setenv("CLICKCLACK_DEV_BOOTSTRAP", "false")
t.Setenv("CLICKCLACK_GITHUB_CLIENT_ID", "client")
t.Setenv("CLICKCLACK_GITHUB_CLIENT_SECRET", "secret")
t.Setenv("CLICKCLACK_GITHUB_ALLOWED_ORG", "openclaw")
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
if cfg.Addr != ":9000" || cfg.Data != "/tmp/clickclack" || cfg.DB != "sqlite:///tmp/clickclack.db" || cfg.PublicURL != "https://clickclack.test" || cfg.GitHubClientID != "client" || cfg.GitHubClientSecret != "secret" {
if cfg.Addr != ":9000" || cfg.Data != "/tmp/clickclack" || cfg.DB != "sqlite:///tmp/clickclack.db" || cfg.PublicURL != "https://clickclack.test" || cfg.DevBootstrap || cfg.GitHubClientID != "client" || cfg.GitHubClientSecret != "secret" || cfg.GitHubAllowedOrg != "openclaw" {
t.Fatalf("unexpected env config: %#v", cfg)
}
@ -37,8 +39,10 @@ func TestLoadDefaultsEnvAndFile(t *testing.T) {
t.Setenv("CLICKCLACK_DATA", "")
t.Setenv("CLICKCLACK_DB", "")
t.Setenv("CLICKCLACK_PUBLIC_URL", "")
t.Setenv("CLICKCLACK_DEV_BOOTSTRAP", "")
t.Setenv("CLICKCLACK_GITHUB_CLIENT_ID", "")
t.Setenv("CLICKCLACK_GITHUB_CLIENT_SECRET", "")
t.Setenv("CLICKCLACK_GITHUB_ALLOWED_ORG", "")
emptyPath := filepath.Join(t.TempDir(), "empty.json")
if err := os.WriteFile(emptyPath, []byte(`{}`), 0o644); err != nil {
t.Fatal(err)
@ -53,6 +57,11 @@ func TestLoadDefaultsEnvAndFile(t *testing.T) {
if _, err := Load(filepath.Join(t.TempDir(), "missing.json")); err == nil {
t.Fatal("expected missing config error")
}
t.Setenv("CLICKCLACK_DEV_BOOTSTRAP", "not-bool")
if _, err := Load(""); err == nil {
t.Fatal("expected bad bool env error")
}
t.Setenv("CLICKCLACK_DEV_BOOTSTRAP", "")
badPath := filepath.Join(t.TempDir(), "bad.json")
if err := os.WriteFile(badPath, []byte(`{`), 0o644); err != nil {
t.Fatal(err)

View File

@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
@ -16,16 +17,20 @@ import (
)
type GitHubOAuthConfig struct {
ClientID string
ClientSecret string
PublicURL string
AuthURL string
TokenURL string
UserURL string
EmailsURL string
HTTPClient *http.Client
ClientID string
ClientSecret string
PublicURL string
AuthURL string
TokenURL string
UserURL string
EmailsURL string
MembershipURL string
AllowedOrg string
HTTPClient *http.Client
}
var errGitHubOrgDenied = errors.New("github account is not a member of the allowed organization")
func (c GitHubOAuthConfig) withDefaults() GitHubOAuthConfig {
if c.AuthURL == "" {
c.AuthURL = "https://github.com/login/oauth/authorize"
@ -39,6 +44,9 @@ func (c GitHubOAuthConfig) withDefaults() GitHubOAuthConfig {
if c.EmailsURL == "" {
c.EmailsURL = "https://api.github.com/user/emails"
}
if c.MembershipURL == "" {
c.MembershipURL = "https://api.github.com/user/memberships/orgs/"
}
if c.HTTPClient == nil {
c.HTTPClient = http.DefaultClient
}
@ -59,7 +67,7 @@ func (s *Server) githubStart(w http.ResponseWriter, r *http.Request) {
values := url.Values{
"client_id": {s.githubOAuth.ClientID},
"redirect_uri": {s.githubRedirectURL(r)},
"scope": {"read:user user:email"},
"scope": {s.githubScope()},
"state": {state},
}
http.Redirect(w, r, s.githubOAuth.AuthURL+"?"+values.Encode(), http.StatusFound)
@ -86,6 +94,14 @@ func (s *Server) githubCallback(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadGateway, err)
return
}
if err := s.ensureGitHubOrgMembership(r.Context(), token); err != nil {
if errors.Is(err, errGitHubOrgDenied) {
writeError(w, http.StatusForbidden, err)
return
}
writeError(w, http.StatusBadGateway, err)
return
}
user, err := s.store.UpsertIdentityUser(r.Context(), store.UpsertIdentityUserInput{
Provider: "github",
ProviderSubject: strconv.FormatInt(profile.ID, 10),
@ -195,6 +211,52 @@ func (s *Server) githubGetJSON(ctx context.Context, endpoint, token string, out
return json.NewDecoder(resp.Body).Decode(out)
}
func (s *Server) ensureGitHubOrgMembership(ctx context.Context, token string) error {
org := strings.TrimSpace(s.githubOAuth.AllowedOrg)
if org == "" {
return nil
}
endpoint := strings.TrimRight(s.githubOAuth.MembershipURL, "/") + "/" + url.PathEscape(org)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := s.githubOAuth.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusForbidden {
return errGitHubOrgDenied
}
if resp.StatusCode >= 300 {
return fmt.Errorf("github organization membership check failed: %s", resp.Status)
}
var membership struct {
State string `json:"state"`
Organization struct {
Login string `json:"login"`
} `json:"organization"`
}
if err := json.NewDecoder(resp.Body).Decode(&membership); err != nil {
return err
}
if !strings.EqualFold(membership.State, "active") || !strings.EqualFold(membership.Organization.Login, org) {
return errGitHubOrgDenied
}
return nil
}
func (s *Server) githubScope() string {
scope := "read:user user:email"
if strings.TrimSpace(s.githubOAuth.AllowedOrg) != "" {
scope += " read:org"
}
return scope
}
func (s *Server) githubRedirectURL(r *http.Request) string {
base := strings.TrimRight(s.githubOAuth.PublicURL, "/")
if base == "" {

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
@ -176,6 +177,109 @@ func TestGitHubOAuthFlow(t *testing.T) {
}
}
func TestGitHubOAuthAllowedOrg(t *testing.T) {
t.Parallel()
ctx := context.Background()
dataDir := t.TempDir()
st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
switch r.FormValue("code") {
case "member":
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "member-token"})
case "denied":
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "denied-token"})
default:
w.WriteHeader(http.StatusBadRequest)
}
case "/user":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "login": "octo", "email": "octo@example.com"})
case "/memberships/orgs/openclaw":
if r.Header.Get("Authorization") == "Bearer denied-token" {
w.WriteHeader(http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"state": "active",
"organization": map[string]any{"login": "OpenClaw"},
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(provider.Close)
server := httptest.NewServer(New(st, realtime.NewHub(), Options{GitHubOAuth: GitHubOAuthConfig{
ClientID: "client",
ClientSecret: "secret",
AuthURL: provider.URL + "/authorize",
TokenURL: provider.URL + "/token",
UserURL: provider.URL + "/user",
EmailsURL: provider.URL + "/emails",
MembershipURL: provider.URL + "/memberships/orgs/",
AllowedOrg: "openclaw",
}}).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/github/start")
if err != nil {
t.Fatal(err)
}
location, err := url.Parse(resp.Header.Get("Location"))
if err != nil {
t.Fatal(err)
}
if scope := location.Query().Get("scope"); scope != "read:user user:email read:org" {
t.Fatalf("unexpected scope %q", scope)
}
stateCookie := findCookie(resp.Cookies(), "cc_github_state")
resp.Body.Close()
if stateCookie == nil {
t.Fatal("expected state cookie")
}
req, err := http.NewRequest(http.MethodGet, server.URL+"/api/auth/github/callback?code=member&state="+stateCookie.Value, nil)
if err != nil {
t.Fatal(err)
}
req.AddCookie(stateCookie)
resp, err = client.Do(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusFound {
t.Fatalf("expected member callback redirect, got %s", resp.Status)
}
resp.Body.Close()
req, err = http.NewRequest(http.MethodGet, server.URL+"/api/auth/github/callback?code=denied&state="+stateCookie.Value, nil)
if err != nil {
t.Fatal(err)
}
req.AddCookie(stateCookie)
resp, err = client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected org denied, got %s", resp.Status)
}
}
func TestGitHubOAuthErrors(t *testing.T) {
t.Parallel()
st := newEmptyHTTPStore(t)
@ -198,3 +302,12 @@ func TestGitHubOAuthErrors(t *testing.T) {
t.Fatal("expected bad github api url error")
}
}
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
for _, cookie := range cookies {
if cookie.Name == name {
return cookie
}
}
return nil
}

View File

@ -20,19 +20,27 @@ import (
)
type Server struct {
store store.Store
hub *realtime.Hub
uploadDir string
githubOAuth GitHubOAuthConfig
store store.Store
hub *realtime.Hub
uploadDir string
githubOAuth GitHubOAuthConfig
disableDevAuth bool
}
type Options struct {
UploadDir string
GitHubOAuth GitHubOAuthConfig
UploadDir string
GitHubOAuth GitHubOAuthConfig
DisableDevAuth bool
}
func New(st store.Store, hub *realtime.Hub, options Options) *Server {
return &Server{store: st, hub: hub, uploadDir: options.UploadDir, githubOAuth: options.GitHubOAuth.withDefaults()}
return &Server{
store: st,
hub: hub,
uploadDir: options.UploadDir,
githubOAuth: options.GitHubOAuth.withDefaults(),
disableDevAuth: options.DisableDevAuth,
}
}
func (s *Server) Handler() http.Handler {
@ -78,6 +86,7 @@ func (s *Server) Handler() http.Handler {
})
r.NotFound(s.serveSPA)
r.Head("/*", s.serveSPA)
r.Get("/*", s.serveSPA)
return r
}
@ -315,6 +324,9 @@ func (s *Server) currentUser(r *http.Request) (store.User, error) {
if cookie, err := r.Cookie("cc_session"); err == nil && cookie.Value != "" {
return s.store.GetSessionUser(r.Context(), cookie.Value)
}
if s.disableDevAuth {
return store.User{}, errors.New("authentication required")
}
if id := r.Header.Get("X-ClickClack-User"); id != "" {
return s.store.GetUser(r.Context(), id)
}

View File

@ -45,6 +45,8 @@ func TestChatAPIVerticalSlice(t *testing.T) {
server := httptest.NewServer(New(st, hub, Options{UploadDir: filepath.Join(dataDir, "uploads")}).Handler())
t.Cleanup(server.Close)
expectStatus(t, http.MethodHead, server.URL+"/", nil, http.StatusOK)
me := getJSON[struct {
User store.User `json:"user"`
}](t, server.URL+"/api/me")
@ -421,6 +423,59 @@ func getBody(t *testing.T, endpoint string) string {
return string(body)
}
func TestDisableDevAuthRequiresSession(t *testing.T) {
t.Parallel()
ctx := context.Background()
dataDir := t.TempDir()
st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(New(st, realtime.NewHub(), Options{DisableDevAuth: true}).Handler())
t.Cleanup(server.Close)
expectStatus(t, http.MethodGet, server.URL+"/api/me", nil, http.StatusUnauthorized)
req, err := http.NewRequest(http.MethodGet, server.URL+"/api/me", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-ClickClack-User", owner.ID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected dev user header to be ignored, got %s", resp.Status)
}
session, err := st.CreateSession(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
req, err = http.NewRequest(http.MethodGet, server.URL+"/api/me", nil)
if err != nil {
t.Fatal(err)
}
req.AddCookie(&http.Cookie{Name: "cc_session", Value: session.Token})
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected session auth, got %s", resp.Status)
}
}
func readEventType(t *testing.T, conn *websocket.Conn, eventType string) store.Event {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ClickClack</title>
<script type="module" crossorigin src="/assets/index-BXsmUSij.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CljoeKn6.css">
<script type="module" crossorigin src="/assets/index-Bi0i2ETT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D1YUowl3.css">
</head>
<body>
<div id="app"></div>

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { api } from "./lib/api";
import { APIError, api } from "./lib/api";
import { markdown, time } from "./lib/format";
import type { Channel, DirectConversation, Message, RealtimeEvent, SearchResult, ThreadState, Upload, User, Workspace } from "./lib/types";
@ -24,6 +24,7 @@
let searchResults: SearchResult[] = [];
let pendingUpload: Upload | null = null;
let status = "loading";
let authRequired = false;
let socket: WebSocket | null = null;
let reconnectTimer: number | undefined;
@ -47,6 +48,11 @@
await loadWorkspaces();
status = "ready";
} catch (error) {
if (error instanceof APIError && (error.status === 401 || error.status === 403)) {
authRequired = true;
status = "auth";
return;
}
status = error instanceof Error ? error.message : "Could not load ClickClack";
}
}
@ -238,6 +244,24 @@
<meta name="color-scheme" content="light dark" />
</svelte:head>
{#if authRequired}
<main class="auth-shell">
<section class="auth-panel" aria-label="Sign in">
<div class="brand">
<div class="mark">cc</div>
<div>
<strong>ClickClack</strong>
<span>OpenClaw workspace chat</span>
</div>
</div>
<div class="auth-copy">
<h1>Sign in to ClickClack</h1>
<p>GitHub access is limited to active members of the OpenClaw organization.</p>
</div>
<a class="github-login" href="/api/auth/github/start">Continue with GitHub</a>
</section>
</main>
{:else}
<div class="shell">
<aside class="sidebar" aria-label="Workspace and channel navigation">
<div class="brand">
@ -470,3 +494,4 @@
{/if}
</aside>
</div>
{/if}

View File

@ -1,3 +1,12 @@
export class APIError extends Error {
constructor(
public status: number,
body: string,
) {
super(body);
}
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Accept", "application/json");
@ -5,7 +14,7 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
headers.set("Content-Type", "application/json");
const response = await fetch(path, { ...init, headers });
if (!response.ok) {
throw new Error(await response.text());
throw new APIError(response.status, await response.text());
}
return response.json() as Promise<T>;
}

View File

@ -54,6 +54,55 @@ button {
cursor: pointer;
}
.auth-shell {
display: grid;
min-height: 100vh;
place-items: center;
padding: 24px;
}
.auth-panel {
display: grid;
gap: 24px;
width: min(100%, 420px);
border: 1px solid var(--line);
border-radius: 8px;
background: color-mix(in srgb, var(--panel) 92%, transparent);
box-shadow: var(--shadow);
padding: 28px;
}
.auth-copy {
display: grid;
gap: 8px;
}
.auth-copy h1,
.auth-copy p {
margin: 0;
}
.auth-copy h1 {
font-size: 28px;
line-height: 1.1;
}
.auth-copy p {
color: var(--muted);
line-height: 1.5;
}
.github-login {
display: grid;
min-height: 44px;
place-items: center;
border-radius: 8px;
background: var(--ink);
color: var(--panel);
font-weight: 900;
text-decoration: none;
}
.shell {
display: grid;
grid-template-columns: 260px minmax(0, 1fr) minmax(320px, 28vw);

View File

@ -25,10 +25,11 @@ hook in `cmd/clickclack/main.go`.
| `--data` | `CLICKCLACK_DATA` | `./data` | Data root for DB, uploads, logs. |
| `--db` | `CLICKCLACK_DB` | derived | DB URL. Defaults to `sqlite://<data>/clickclack.db`. |
| `--config` | — | unset | JSON config file. |
| `--dev-bootstrap` | — | `true` | `serve` only. Creates a default user/workspace/channel if the DB is empty. |
| `--dev-bootstrap` | `CLICKCLACK_DEV_BOOTSTRAP` | `true` | `serve` only. Creates a default user/workspace/channel and enables local dev auth fallbacks. |
| — | `CLICKCLACK_PUBLIC_URL` | unset | External URL. Used to build the GitHub OAuth callback. |
| — | `CLICKCLACK_GITHUB_CLIENT_ID` | unset | GitHub OAuth app client ID. |
| — | `CLICKCLACK_GITHUB_CLIENT_SECRET`| unset | GitHub OAuth app client secret. |
| — | `CLICKCLACK_GITHUB_ALLOWED_ORG` | unset | Optional GitHub org login gate. Requires `read:org` scope. |
## Config file
@ -37,9 +38,11 @@ hook in `cmd/clickclack/main.go`.
"addr": ":8080",
"data": "./data",
"db": "sqlite:///var/lib/clickclack/clickclack.db",
"dev_bootstrap": false,
"public_url": "https://chat.example.com",
"github_client_id": "Iv1.xxxxxxxxxxxx",
"github_client_secret": "..."
"github_client_secret": "...",
"github_allowed_org": "openclaw"
}
```
@ -70,4 +73,5 @@ clickclack serve \
```
Combine with real auth (magic links or GitHub OAuth) so the
"first-user-in-DB" fallback in `currentUser` never kicks in.
"first-user-in-DB" fallback in `currentUser` never kicks in. In containers,
`CLICKCLACK_DEV_BOOTSTRAP=false` is the easiest way to enforce the same mode.

View File

@ -111,10 +111,14 @@ If you want GitHub login, set:
CLICKCLACK_PUBLIC_URL=https://chat.example.com
CLICKCLACK_GITHUB_CLIENT_ID=...
CLICKCLACK_GITHUB_CLIENT_SECRET=...
CLICKCLACK_GITHUB_ALLOWED_ORG=openclaw
CLICKCLACK_DEV_BOOTSTRAP=false
```
Configure the GitHub OAuth app callback to
`<public-url>/api/auth/github/callback`. See [features/auth.md](features/auth.md).
`<public-url>/api/auth/github/callback`. When `CLICKCLACK_GITHUB_ALLOWED_ORG`
is set, ClickClack asks GitHub for `read:org` and only accepts active members
of that org. See [features/auth.md](features/auth.md).
## Migrations

View File

@ -84,6 +84,7 @@ before serving:
CLICKCLACK_PUBLIC_URL=https://chat.example.com
CLICKCLACK_GITHUB_CLIENT_ID=...
CLICKCLACK_GITHUB_CLIENT_SECRET=...
CLICKCLACK_GITHUB_ALLOWED_ORG=openclaw
```
Without those, `GET /api/auth/github/start` returns `501`.
@ -93,12 +94,17 @@ Flow:
1. `GET /api/auth/github/start` sets a state cookie and redirects to GitHub.
2. GitHub redirects back to `GET /api/auth/github/callback?code&state`.
3. The handler exchanges the code, fetches `/user` and primary `/user/emails`,
upserts a user keyed by `(provider="github", provider_subject=<github id>)`,
creates a session, sets `cc_session`, redirects to `/`.
checks org membership when `CLICKCLACK_GITHUB_ALLOWED_ORG` is set, upserts a
user keyed by `(provider="github", provider_subject=<github id>)`, creates a
session, sets `cc_session`, redirects to `/`.
The redirect URL is derived from `CLICKCLACK_PUBLIC_URL` when set, otherwise
from the request scheme/host. Configure GitHub with `<public-url>/api/auth/github/callback`.
Org-gated deployments request `read:org`. GitHub only returns private org
membership after the user grants that scope, so OpenClaw-only hosting should set
`CLICKCLACK_GITHUB_ALLOWED_ORG=openclaw` and `CLICKCLACK_DEV_BOOTSTRAP=false`.
## Authorization
Every store mutation that touches a workspace runs `requireMembership` (or the

View File

@ -56,6 +56,8 @@ paths:
description: Session created and redirected to app
"400":
description: Invalid OAuth callback
"403":
description: GitHub account is not allowed
/api/me:
get:
operationId: getMe

View File

@ -590,6 +590,13 @@ export interface operations {
};
content?: never;
};
/** @description GitHub account is not allowed */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getMe: {