Auth
ClickClack accepts five ways to identify a caller, in order of precedence. The resolver lives in apps/api/internal/httpapi/server.go (currentActor).
Authorization: Bearer <token>— bearer session token orccb_...bot- Session cookie —
cc_sessionby default, or the configured namespaced - Cloudflare Access assertion — when trusted-proxy authentication is
X-ClickClack-User: usr_...header — explicit user impersonation for local- Dev fallback — the very first user in the database. Enabled only by
token. Bot tokens resolve to the bot user plus token workspace/scopes.
cookie. It is HTTP-only and set by magic-link consume and GitHub OAuth.
configured, a valid Cf-Access-Jwt-Assertion provisions or resolves the asserted email and creates a normal ClickClack session.
development and tests, accepted only from loopback clients using local request hosts.
clickclack serve --dev-bootstrap=true so a fresh checkout can boot into a working app without any token plumbing, and accepted only from local requests.
The dev fallback must stay off in any non-local deployment. --dev-bootstrap=false is the default; require real sessions.
#Local owner bootstrap
clickclack serve --dev-bootstrap=true calls Store.EnsureBootstrap. That helper:
- Returns the first user if one exists.
- Otherwise creates a
Local Captainuser, aClickClackworkspace, and a
general channel, then returns the new user.
To pin the owner identity instead, run the CLI before serving:
clickclack admin bootstrap --name "Peter" --email steipete@gmail.com
That prints the new usr_... ID. Pass it back via X-ClickClack-User or use the magic-link flow to mint a session.
#Magic links
Magic-link tokens are short-lived bearer credentials. In local dev mode the HTTP request endpoint returns the token for convenience. With dev auth disabled, the request endpoint is disabled until SMTP delivery exists; create tokens with the admin CLI instead. The consume endpoint exchanges a token for a durable session. In local dev mode, the HTTP request endpoint returns the token only for loopback clients using local request hosts.
POST /api/auth/magic/request
{ "email": "steipete@gmail.com", "display_name": "Peter" }
POST /api/auth/magic/consume
{ "token": "<token>" }
Magic-link consume requests must use Content-Type: application/json. Browser requests with cross-site Origin or Sec-Fetch-Site headers are rejected so a foreign site cannot force a browser session onto another account.
For production-style deployments, use the CLI delivery path until SMTP delivery exists:
clickclack admin magic-link create --email steipete@gmail.com --name "Peter"
The client CLI can consume that token directly:
clickclack login --magic-token mgt_...
For remote human-operated clients, use the resulting bearer session token. For hosted bots, prefer a scoped ccb_... bot token from admin bot create. The CLI will not send a stored bearer token to a different --server, and it skips stored bearer tokens when --user / CLICKCLACK_USER_ID is set without an explicit --token.
ConsumeMagicLink returns {user, session, token} and sets the configured HTTP-only session cookie. Browsers can drop the body; non-browser clients should hold the session.token for the Authorization header. Session cookies default to Secure outside local dev HTTP, even if a reverse proxy omits HTTPS headers. Duplicate cookies with the active session-cookie name are rejected instead of relying on cookie ordering.
#Trusted proxy (Cloudflare Access)
Team deployments may put ClickClack behind a Cloudflare Tunnel and Cloudflare Access. Configure the Access team HTTPS origin and the application's audience tag together:
CLICKCLACK_ACCESS_TEAM_DOMAIN=https://openclaw.cloudflareaccess.com
CLICKCLACK_ACCESS_AUD=<application-audience-tag>
The equivalent serve flags are --access-team-domain and --access-aud; the JSON config keys are access_team_domain and access_aud. Setting only one value fails startup validation. Leaving both unset disables this auth path, and the Access assertion header is ignored.
For a request without a valid ClickClack session cookie, the server verifies the Cf-Access-Jwt-Assertion as an RS256 JWT. It fetches keys only from <team-domain>/cdn-cgi/access/certs, does not follow redirects, caches the JWKS with bounded expiry, and refreshes an unknown key ID at most once every 30 seconds. The issuer must exactly match the configured team domain, the audience must include the configured tag, expiration and issued-at claims must be valid, and the email claim is required.
Cloudflare Access service tokens produce assertions without an email claim, so this path rejects them. Automation must use ClickClack bot tokens instead.
After verification, ClickClack resolves or creates the human user by normalized email and joins the default workspace. The first Access user becomes its owner; later users join as members. The current request is authenticated immediately, and the response sets the same configured HTTP-only session cookie used by magic-link and GitHub OAuth sign-in. Later HTTP and WebSocket requests use that cookie without revalidating Access on every request. Unsafe requests carrying either an Access assertion or a ClickClack session cookie remain subject to the existing same-origin and X-ClickClack-CSRF checks, including the first SSO request before a local cookie exists.
This mode trusts Cloudflare Access as the deployment's identity boundary. Do not expose the ClickClack origin directly around Access, and do not configure these settings for a proxy that can pass client-supplied assertion headers.
#GitHub OAuth (optional)
GitHub OAuth is opt-in. Set the public URL, client ID, and client secret (or the equivalent config keys) before serving:
CLICKCLACK_PUBLIC_URL=https://chat.example.com
# Optional only when trusted instances share one hostname:
# CLICKCLACK_COOKIE_NAMESPACE=production
CLICKCLACK_GITHUB_CLIENT_ID=...
CLICKCLACK_GITHUB_CLIENT_SECRET=...
# Optional org gate:
# CLICKCLACK_GITHUB_ALLOWED_ORG=openclaw
# Optional moderator org for open guest login:
# CLICKCLACK_GITHUB_MODERATOR_ORG=openclaw
Without a client ID and client secret, GET /api/auth/github/start returns 501.
Flow:
GET /api/auth/github/startcreates a database-backed, ten-minute OAuth- GitHub redirects back to
GET /api/auth/github/callback?code&state. - The handler atomically consumes the state only when the browser binding
- The handler joins the appropriate workspace, creates a session, sets the
transaction, sets an HTTP-only browser-binding cookie, and redirects to GitHub with a SHA-256 PKCE challenge.
matches, exchanges the code with the stored PKCE verifier and exact redirect URI, fetches /user and primary /user/emails, checks configured org membership, and upserts the GitHub identity.
configured session cookie, and redirects to /.
The server stores hashes of state and browser-binding values. The short-lived PKCE verifier is stored because the callback must send it to GitHub. State is single-use, survives process restarts and multiple replicas, and permits up to eight concurrent starts for one browser. Expired rows are removed during new starts. Global pending-row limits bound abandoned or hostile starts.
The desktop app uses the same GitHub callback through a system-browser handoff:
- The app creates a high-entropy verifier and opens
- After the normal GitHub callback succeeds, the server redirects to
- The app posts the grant and its verifier to
- The app calls
/api/methrough that same Electron session and loads/app
GET /api/auth/github/desktop/start?code_challenge=<SHA-256 challenge>&desktop_protocol=2 in the default browser.
chat.clickclack.desktop:/auth/callback?code=<opaque one-time grant>. No GitHub or ClickClack session token is placed in the URL.
POST /api/auth/github/desktop/consume. The exact initiating server atomically invalidates the grant, creates a session, and sets the configured cookie in Electron's persistent session.
only after the server confirms the authenticated user. The desktop client never depends on a particular cookie name.
Desktop transactions expire after ten minutes; completed grants expire after five. Grants are persisted so the callback and redemption can hit different replicas or a restarted process. The verifier binding prevents another local application from redeeming a custom-protocol callback it intercepts. Grant codes are stored only as hashes and are single-use. Protocol-1 callbacks carry a 32-character lowercase hexadecimal grant; protocol-2 callbacks carry a 43-character unpadded base64url grant. The consume endpoint accepts exactly those two formats during the compatibility window.
Protocol-1 desktop clients remain compatible with deployments using the default cookie names and receive the legacy clickclack://auth/callback handoff. Namespaced deployments return HTTP 426 before redirecting an old client to GitHub, because that client cannot verify a namespaced session. Protocol-2 clients accept both callback formats so they can sign in to old and new servers.
The redirect URL is always derived from CLICKCLACK_PUBLIC_URL in production. Request-host derivation exists only for explicit loopback development. Configure GitHub with <public-url>/api/auth/github/callback.
OAuth starts are public endpoints. The database enforces global and per-browser pending-row bounds. A deployment may also rate-limit /api/auth/github/start, /api/auth/github/desktop/start, and /api/auth/github/desktop/consume at an edge that has a trustworthy client identity. Do not derive security limits from untrusted forwarded IP headers or add a naive application-level IP limiter. See the deployment documentation for hosted edge ownership and the optional self-hosted Nginx example, capacity limits, monitoring, and sensitive logging requirements.
When metrics are enabled, clickclack_github_oauth_events_total exposes only a fixed event category, including starts, state rejection, provider failure, capacity rejection, desktop protocol mismatch, and successful handoff. It never labels state, grants, users, cookies, callback parameters, or tokens.
Without CLICKCLACK_GITHUB_ALLOWED_ORG, any GitHub account can sign in and is automatically joined to an isolated Guests workspace. When CLICKCLACK_GITHUB_MODERATOR_ORG is set, non-members of that org start as waiting-room guests with a three-post daily budget until a moderator promotes them to member; matching GitHub org members become moderators in the guest workspace. If the moderator org is unset, open-login users join as normal members so the workspace cannot become ownerless.
Open guest deployments with a moderator org, and org-gated deployments, request read:org. GitHub only returns private org membership after the user grants that scope, so team-only hosting should set CLICKCLACK_GITHUB_ALLOWED_ORG.
Guest restrictions and moderator controls are documented in moderation.md.
#Authorization
Every store mutation that touches a workspace runs requireMembership (or the in-tx variant). API handlers do not duplicate that check — trust the store layer for it. WebSocket subscriptions revalidate GetWorkspace before upgrading.
Workspace roles are owner, moderator, member, guest, and bot. The store enforces guest room visibility, guest post budgets, timeout/block state, and moderator rank before writes. HTTP handlers still call store methods rather than duplicating those checks.
Bot tokens add a second layer on top of membership: scope checks and a token workspace check. See bots.md.
#Sessions
sessions are bearer tokens with an expires_at. GetSessionUser resolves the token to a User. There is no refresh flow — issue a new session when one expires.
#What is intentionally missing
- Email/password login.
- Password reset.
- SMTP delivery for magic links (V0 prints the token; V1 will add delivery).
- Per-channel ACLs and a historical moderation audit log.