Authentication covers the browser product, WebSocket, local MCP, repository tools, and Copilot. A verified GitHub user supplies presence and attribution. Optional instance admission lists restrict who may use Chopin. Browser routes, WebSockets, and the hosted agent intersect the user's repository role with the GitHub App installation. Local MCP instead checks the repository role granted directly to its caller-supplied bearer token.
Create one GitHub App for each deployment. Use these registration settings:
Homepage URL: <APP_ORIGIN>
Callback URL: <APP_ORIGIN>/auth/github/callback
Setup URL: <APP_ORIGIN>/auth/github/setup
- Leave Expire user authorization tokens enabled.
- Leave Request user authorization (OAuth) during installation disabled.
- Leave device flow disabled for a hosted deployment; local device-flow sign-in (below) requires enabling it on the App instead.
- Enable Redirect on update when a setup URL is configured.
- Disable webhooks.
- Select Any account so personal accounts and organizations can install it.
- Prefer Only select repositories while installing it.
Repository permissions for the complete product are:
Contents: Read-only
Pull requests: Read-only
Checks: Read-only
Commit statuses: Read-only
Issues: Read-only
Metadata: Read-only (automatic)
Contents backs file, tree, code-search, and commit-history tools. Pull requests
and Issues also back the summaries behind pull request and issue links in a
document; without Issues, issue links stay plain links. The other read-only
permissions back the hosted GitHub MCP pull-request toolset. Existing
installations must approve the added Issues permission before issue summaries
appear. Chopin
does not request repository, organization, or account write permission. An
AGENT=off deployment only needs Contents and automatic Metadata access unless
organization admission is enabled.
Organization admission additionally requires:
Organization permissions:
Members: Read-only
The App must be installed on every allowed organization, and an organization owner must approve this permission. Existing installations continue with their old permissions until the owner approves the update.
No App ID, private key, JWT, installation access token, or webhook secret is used. Chopin acts on behalf of each signed-in user with a GitHub App user access token so repository-role checks and the user's Copilot entitlement remain theirs.
Configure Chopin with the App's slug and OAuth client credentials. A minimal production environment contains:
STORAGE_DRIVER=postgres
DATABASE_URL=postgresql://<user>:<password>@<database-host>:5432/<database>
APP_ORIGIN=https://chopin.example
GITHUB_APP_SLUG=chopin-example
GITHUB_APP_CLIENT_ID=...
GITHUB_APP_CLIENT_SECRET=...
GITHUB_ALLOWED_USERS=octocat,hubot
GITHUB_ALLOWED_ORGANIZATIONS=githubnext
SESSION_ENCRYPTION_KEY=<64 hex characters>
The client ID is distinct from the numeric App ID. Generate the encryption key
with openssl rand -hex 32; it protects the short-lived OAuth attempt cookie
and, through a separate derived key, hosted session credentials in PostgreSQL.
Keep it stable across releases and store it in deployment secrets, outside the
database and source control. Replacing it invalidates existing hosted logins.
APP_ORIGIN must be exactly one HTTP(S) origin: no credentials, path, query,
fragment, or trailing slash. HTTPS is required except for loopback development,
such as http://127.0.0.1:8787. Callback URLs are built only from this value,
never from incoming Host or forwarded headers.
The complete variable reference and deployment procedures live in Self-hosting.
AUTH_MODE=local replaces the browser authorization-code flow with GitHub's
device flow and persists the resulting credential so a restarted process can
resume the same login. It is a loopback-only mode, not an alternative hosted
deployment: SERVER_HOST must be 127.0.0.1, ::1, or localhost, and
APP_ORIGIN must be an exact HTTP(S) loopback origin on the same port. The
Docker image binds 0.0.0.0 and is incompatible with local mode; run from a
source checkout instead.
Device flow authenticates with the App's public client ID only.
GITHUB_APP_CLIENT_SECRET is not read in local mode; a value left over from a
hosted .env is ignored. Enable device flow on the App used for local mode
(this is the opposite of the hosted guidance above), and keep Expire user
authorization tokens enabled so expiring access and refresh tokens are
issued.
The browser flow is:
POST /auth/devicerequests a device code from GitHub and returns the user-facing code, verification URL, and expiry to the browser. The privatedevice_codenever leaves the server. The response also sets an HttpOnly, origin-checked attempt cookie that binds later requests to the same browser.- The browser shows the code and polls
GET /auth/deviceroughly every one to two seconds. The server polls GitHub independently, honoring GitHub's advertised interval and anyslow_downincrease, so approval from another device or browser still completes the attempt. - Once GitHub reports the grant, the browser calls
POST /auth/device/complete, which validates the authorized identity and admission, then attempts credential persistence. - If persistence needs plaintext consent,
complete(or the nextGET) reports{status: "consent", path}, the exact local file path. The browser shows the plaintext-storage warning;POST /auth/device/consent {accept}either finishes sign-in or cancels it. Declining or dismissing this prompt discards the pending credential and cancels the attempt without creating a session; it does not fall back to an in-memory-only login. POST /auth/device/cancelcancels an attempt at any point and discards any pending credential.
Device routes do not return 401, which the browser client treats as an
invalid session and reloads. A missing attempt cookie reports cancelled;
state-changing requests with a mismatched Origin return 403.
Credential persistence prefers the operating system's credential store
(macOS Keychain, Windows Credential Manager, or a Linux secret service such as
gnome-keyring, through Bun.secrets). That attempt is bounded to five
seconds so an unavailable or locked store cannot hang sign-in; a store call
that is still in flight when the bound expires is left running and its
eventual result is discarded (a late success is deleted, not kept) so it can
never resurrect a declined or superseded attempt.
Automated tests exercise this boundary through an injected NativeSecrets
implementation (a file-backed fake in Playwright, an in-memory fake in unit
tests), not a live OS vault. apps/server/src/auth/local-store.test.ts also
has one opt-in test, gated on TEST_NATIVE_SECRETS=1, that round-trips a
dummy credential through the real store. That check has only run against
Linux libsecret/gnome-keyring; macOS Keychain and Windows Credential Manager
behavior is implemented against the same Bun.secrets API but is not yet
verified on those platforms. Windows also defaults to
CRED_PERSIST_ENTERPRISE (roaming) persistence for Bun.secrets.set; a
machine-local persist: "local" option exists in Bun's API but Chopin does
not currently set it or verify its behavior.
When the store is missing, inaccessible, disabled, or times out, Chopin keeps the credential in memory and asks for plaintext consent instead of writing anything, using this exact warning and choice text:
System vault not available
The recommended secure storage (keychain, keyring, or credential manager) could not be found or accessed. You may need to install or configure one.
Storing the token in the config file saves it as plain text, which is insecure. If you decline, sign-in will be cancelled and no account state will be changed.
Store token in plain text config file?
Yes, store in plain text (insecure)
No, cancel sign-in
Accepting writes the credential to a JSON file under the directory named by
CHOPIN_LOCAL_CREDENTIALS_DIR (default $XDG_CONFIG_HOME/chopin, or
~/.config/chopin on Linux, ~/Library/Application Support/Chopin on macOS,
%APPDATA%\Chopin on Windows), with a newly created directory at 0700 and
the credential file at 0600 on Unix, and an atomic write-then-rename so a
crash mid-write cannot leave a partially written file. These permissions
restrict which local account can read the file; they do not encrypt it or
make it equivalent to an OS credential store. Chopin never writes it inside
the repository or process working directory. Consent applies to this login
only: it is not a standing "prefer plaintext" preference for future accounts,
and a later secure-store failure during refresh never falls back to writing
plaintext silently.
Restoring after a restart uses a separate long-lived, HttpOnly,
browser-binding cookie set only on successful sign-in. On the next
/api/session call, Chopin loads the persisted credential for that binding,
checks the installation, origin, App client ID, and account against the
stored record, revalidates or refreshes the token, rechecks identity and
admission exactly as a fresh sign-in would, and issues a new ordinary session.
Startup still clears every process-local session and Planner ownership, so a
restored login does not reclaim Planner ownership or replay interrupted model
work; the returning browser only skips repeating the device-flow prompt.
Logout in local mode deletes the persisted credential from whichever backend stored it and clears the session and binding cookies. Because device-issued refresh tokens do not require the client secret, but revoking the grant on GitHub's side does, local logout cannot revoke the GitHub authorization itself; the user must remove it from Settings > Applications on GitHub to fully revoke access. A stale or delayed store write after logout cannot revive the login, because logout also invalidates the browser binding it depended on.
Each browser has its own binding; configured instance admission lists still apply to every authorized account. Loopback binding does not make this a mode for internet-facing or exposed multi-user deployments.
The authentication mode does not change the Planner's tools. Under
HARNESS=atomic, every Planner session, local or hosted, has shell and
filesystem access as the server process's user; loopback binding is not a
filesystem sandbox. See Full Atomic Planner.
GITHUB_ALLOWED_USERS and GITHUB_ALLOWED_ORGANIZATIONS are optional,
comma-separated, case-insensitive GitHub login lists. If both are empty or
unset, every verified GitHub user is admitted. If either has entries, a user is
admitted when their current username is listed or they have active membership
in any listed organization. Explicit usernames therefore also provide a
break-glass path when organization verification is unavailable.
Organization checks use the caller's token with
GET /user/memberships/orgs/{org}, which sees private membership when the App
has Members read access. Pending invitations and outside collaborators are not
admitted, nor are billing managers who are not organization members.
Public-membership lookup is not used. Organization admission does not restrict
repository ownership. Browser and hosted authorization retain separate App
installation and repository role checks; local MCP checks the supplied token's
repository role directly.
Admission results are cached by a hash of the access token for 30 seconds.
Browser requests, open-socket authorization, MCP requests, and Planner
permission callbacks recheck the policy. A definitive removal revokes the
browser session, its persisted credentials, and Planner ownership at the next browser or
socket recheck; a Planner permission callback refuses the operation immediately. GitHub outages,
rate limits, malformed responses, blocked Apps, and missing permission fail
closed for new requests but do not revoke an established browser session; they
are reported as a temporary 503 and retried later.
Configuration is read at process startup. Restart after changing either list; restored sessions must pass the new policy. GitHub usernames and organization names can be renamed, so update the lists when that happens.
Authorization and installation are separate GitHub App operations. A user may authorize the App without installing it. After sign-in, Chopin lists only the personal and organization installations that user can access and only the repositories selected for each installation. The repository picker links to the App installation page when access has not been installed or needs updating.
The picker loads every repository page in the background and keeps a validated
snapshot for the lifetime of the browser tab. Stale snapshots are revalidated
page by page with GitHub ETags when search begins. The snapshot is scoped to the
GitHub user and cleared on logout, account changes, and the installation setup
callback. Listing responses remain no-store; the server forwards conditional
requests but does not retain picker repository data.
Local MCP is an intentional exception to this installation boundary. Its bearer token is authenticated independently and authorized with a direct repository lookup. An MCP-created document for a repository outside the App installation is not available through browser routes, WebSockets, or the hosted agent until the installation includes that repository. See Local agent MCP.
invoke_planner is the one MCP tool that starts a hosted agent turn. It never
lends the caller's bearer to the Planner: the turn runs under the channel's
existing Planner owner, and a channel without one is claimed only for the
caller's own live browser login, which must already have passed the
installation-gated owner checks.
The authorization-code flow uses state, S256 PKCE, the exact configured
callback, and the App client secret. It does not request OAuth scopes;
permissions come from the App registration and each installation. The setup
callback ignores GitHub's untrusted installation_id query parameter, clears
the browser's installation snapshot, and redirects into the product. The next
picker or authorization request re-queries GitHub with the signed-in user's
token.
When a signed-out browser opens a document deep link, the sign-in request sends
the current path, query, and fragment as return_to. Chopin accepts only one
validated root-relative product path, stores it inside the encrypted OAuth
attempt cookie, and redirects there after a successful callback. Absolute and
protocol-relative URLs, control characters, backslashes, and API, auth,
WebSocket, or MCP paths fall back to /; a callback query cannot override the
stored value. GitHub App installation setup remains separate: its callback still
ignores return paths and redirects to /?repository_access=changed.
Organization members may need an owner to approve installation or new permissions. For an organization using SAML SSO, establish an active SAML session before authorizing. If organization repositories remain absent, revoke the App under Authorized GitHub Apps and authorize it again while that SAML session is active.
Webhooks are intentionally disabled for self-hosted deployments. Installation, repository-selection, and revocation changes are observed through request-time checks with a cache no longer than the one-minute open-socket recheck interval.
Environment-specific callback and proxy configuration is documented in Remote development.
The browser receives an HttpOnly, SameSite=Lax cookie containing a random session ID and a 256-bit secret. Hosted sessions persist the secret's SHA-256 hash and encrypted GitHub access/refresh tokens and token expirations, alongside the session ID, user ID, absolute expiry, creation time, and credential revision. The raw cookie secret is never stored. Plaintext tokens live in process memory only while the session is loaded.
Credentials use AES-256-GCM with a fresh random nonce on every write and a
purpose-specific key derived from SESSION_ENCRYPTION_KEY using HKDF-SHA-256.
Authenticated data binds the ciphertext to the application origin, GitHub App
client ID, session/user IDs, creation and expiry times, verifier hash, and
credential revision. Altering those fields or copying ciphertext to another
session fails authentication. A database dump alone contains no directly usable
browser secret or GitHub token; access to the deployment key as well permits
token decryption. Backups retain encrypted credentials according to their own
retention policy and need the same key protection as the live database.
After a restart, a valid browser cookie lazily restores its session and checks GitHub identity and current instance admission before authenticating a request. Knowing a session ID or user ID cannot restore it for internal Planner or MCP use. Repository authorization continues to check App installation and role. Local device mode uses its separate credential store and metadata-only session rows instead of database credential encryption.
GitHub App user access tokens expire after eight hours. Chopin refreshes five minutes early and rotates the one-use refresh token. Hosted refresh commits the encrypted replacement with a credential-revision check before publishing it in memory. Concurrent restores and refreshes share work. Logout blocks the memory entry immediately and deletes the durable row before reporting success; a racing refresh can update an existing revision only and cannot recreate a deleted row. A failed logout write reports an error and remains blocked locally for retry. If a process crashes after GitHub rotates a token but before the database commits its replacement, that session may require sign-in again. A failed replacement write revokes the session rather than retaining a consumed refresh token.
A rejected refresh token or a second API 401 deletes the matching session
and its persisted credentials. Network failures, rate limits, malformed responses, and GitHub 5xx
responses do not delete it. A transient proactive refresh may continue using
the still-valid access token; after access expiry it reports a temporary error
and retains the session for retry.
Hosted sessions expire absolutely after 30 days; restoration never extends that deadline. Deployments preserve them when the database, encryption key, origin, and GitHub App client ID are stable. The first release adding persistence still requires one sign-in because previous processes never stored credentials. In local mode, the session created after a restart is a new session bound to the restored device-flow credential, not the same session ID; see Local device-flow sign-in for the restore path. After acquiring the database writer lease, every new process deletes expired and metadata-only session rows and clears all Planner ownership before accepting traffic. Valid encrypted hosted sessions survive. Documents, transcripts, reserved Planner context fields, and repository installations remain durable. Logout deletes the process-local session and durable row but does not revoke the GitHub App authorization.
OAuth state, the PKCE verifier, and the validated browser return path are held in
a separate encrypted, ten-minute HttpOnly cookie. Browser state-changing routes
and WebSocket upgrades require an Origin header exactly equal to APP_ORIGIN.
The bearer-authenticated MCP route accepts a missing Origin, as non-browser
clients normally omit it, but rejects a present mismatched Origin. Open sockets
periodically recheck the process-local session, instance admission, and
installation repository permission. On restart, a returning local browser
restores a new session on /api/session; a hosted browser restores its existing
session on its first authenticated request.
When a credential rotates, any Planner SDK session holding the previous token is aborted and discarded before refresh. A later turn recreates it from the durable transcript and document. An interrupted turn is not replayed automatically because it may already have made durable tool changes.
GET /auth/github
GET /auth/github/callback
GET /auth/github/install
GET /auth/github/setup
GET /api/session
GET /api/github/installations?page=1
GET /api/github/installations/:installationId/repositories?page=1
GET /api/repositories/:owner/:repository/channels
POST /api/repositories/:owner/:repository/channels
GET /api/repositories/:owner/:repository/documents/:slug
GET /api/channels/:channelId
PATCH /api/channels/:channelId
POST /api/channels/:channelId/agent/reset
GET /api/channels/:channelId/github-references?ref=owner/repository/pull/12
POST /auth/logout
Local mode (AUTH_MODE=local) additionally registers these routes. It disables
the authorization-code flow, so /auth/github and /auth/github/callback
return 404; /auth/github/install and /auth/github/setup keep working:
POST /auth/device
GET /auth/device
POST /auth/device/complete
POST /auth/device/consent
POST /auth/device/cancel
The repository-scoped document endpoint backs readable browser URLs. Existing UUID routes remain internal API and collaboration entry points.
API and authentication paths are owned by the server in development and production.
Live collaboration is multiplexed over /ws. The Projects sidebar's
/ws/sidebar socket admits any authenticated browser session with the exact
Origin, then checks GitHub read access for each repository before it delivers
that repository's decision counts; see Repository channels. External coding agents use the
separate Streamable HTTP endpoint at /mcp; unlike the browser API, its
caller-supplied bearer is independent of the GitHub App installation and can
perform write-authorized document and implementation lifecycle operations.