feat: gate hosted app with GitHub org auth
This commit is contained in:
parent
2d3a2cf0e7
commit
c11251f313
@ -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).
|
||||
|
||||
@ -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"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
68
apps/api/internal/webassets/dist/assets/index-Bi0i2ETT.js
vendored
Normal file
68
apps/api/internal/webassets/dist/assets/index-Bi0i2ETT.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
apps/api/internal/webassets/dist/index.html
vendored
4
apps/api/internal/webassets/dist/index.html
vendored
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
7
packages/sdk-ts/src/generated/openapi.d.ts
vendored
7
packages/sdk-ts/src/generated/openapi.d.ts
vendored
@ -590,6 +590,13 @@ export interface operations {
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description GitHub account is not allowed */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
getMe: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user