Skip to content

Credential broker service: GitHub App, sign-in, and installation-token minting #250

Closed

Description

@joelmoss

Parent

Part of #154 (remote agent sessions). Phase 4. Design doc docs/designs/remote-workrooms.md: OQ20 (decided 2026-09-27, PR #248) and Phase 4 "The credential broker is a new component". Research: docs/designs/oq20-remote-git-credentials.md (PR #248).

The plan below was changed by an eng review, a CEO review, a design review and an eng review of the design (2026-09-27/28). Where the original sections differ, "Amended plan" at the end wins.

Where it lives: Codaset (decided 2026-09-27, owner)

The broker is built into Codaset. The code lands there under joelmoss/codaset#40, and this issue holds the specification and tracks it from the Workroom side.

  • The stack fits. Codaset is Rails 8.1 on Postgres, with Redis and Sidekiq. Refresh tokens can use Active Record Encryption (encrypts), and the App's client secret and private key can go in Rails credentials.
  • It is already deployed at codaset.dev. Only /workroom, /up and a placeholder home page are live there; the rest of the app sits behind if ENV.key?('WIP') in config/routes.rb.
  • The broker's routes join the live ones outside the WIP gate, and sit outside Codaset's session-cookie Authentication concern: the Mac and the VMs authenticate by signing requests with their own keys, not with a browser session.
  • Workroom users become Codaset users (decided 2026-09-27, owner). Signing in with GitHub creates a Codaset User (with its personal organisation), or links to an existing one by the GitHub account's verified primary email. An unverified email never links. The GitHub identity is stored on its own table, keyed by GitHub user ID.

What to build (as filed)

The server half of OQ20. The broker holds each user's GitHub access and mints short-lived, one-repository tokens for remote workrooms. The Mac and the remote VMs never hold a long-lived GitHub credential.

  • One Workroom GitHub App, asking for repository permissions only, and the account permission to read email addresses. Leave "Expire user authorization tokens" on.
  • Sign-in through the App's browser flow on codaset.dev; the broker keeps the user token and refresh token, encrypted, and refreshes them server-side. The Mac registers the public half of a device key and signs every later request with it (client side: Credential broker clients: Mac sign-in, agent enrolment, and the git credential helper #251).
  • Enrolment codes bound to one workroom and one repository; a VM redeems one by registering its own key, bound to that workroom's ID.
  • Minting installation tokens narrowed to one repository.
  • The authorization check: the user must be able to push to the repository.
  • The base clone token: a read-only installation token the Mac asks for while awake.
  • Revocation: cancelling a grant stops minting; revoking a device key stops that Mac.

Amended plan (2026-09-28)

Accounts and sign-in

  • Sign-in is a loopback redirect with PKCE (ephemeral 127.0.0.1 port, 60 s single-use code), not the device flow. A new email creates a Codaset user; an existing account links only after an emailed code (a stale GitHub email must not take over an account); an account already linked to another GitHub user is refused.
  • The browser ends on a codaset.dev result page that says "Workroom is signed in as @login" only after the Mac confirms it stored its key: a server-side sign-in attempt (Solid Cache, 10 min, bound to a per-browser nonce cookie) moves pending → redeemed → completed, and the Mac sends a signed "complete" request after storing credentials. Nothing personal travels in URLs.
  • Web sign-in (same GitHub flow, no loopback) creates a Codaset session for the codaset.dev pages below.

Signed requests

  • DPoP-shaped ES256 JWT per request (typ, jwk, htm, htu, iat, jti); iat within 60 s, a stale_proof 401 carries Date for one skew-corrected retry; jti kept 125 s in Solid Cache and the replay store fails closed.

Grants and tokens

  • Everything is keyed by repository ID. Grant creation runs the App-authenticated installation lookup first (a user token can't see a private repo outside the install), then the user's push check by ID. Whether the App covers a repository is decided by a metadata-read token request with repository_ids: [id] (revoked at once); only GitHub's "not accessible to the parent installation" 422 means not covered.
  • Tokens carry contents: write and workflows: write.
  • Re-check at every mint: a dead user token (401 on the current token, or bad_refresh_token) suspends all of that user's grants until they sign in again; push: false withdraws the grant; a 404 checks coverage (App removed or repository deselected → suspended with app_not_installed, recovers after reinstall; still covered → withdrawn); SAML 403, 5xx, 429 and timeouts keep the last answer. Orgs with IP allow lists get a typed ip_allow_list error and are unsupported for now.
  • User-token refresh is single-flight under a row lock; a suspension job carries a token generation and does nothing if the user has signed in since.
  • Issued tokens are stored (encrypted) and revoked: cancel, withdrawal, suspension and Mac removal mark tokens for revocation in the same transaction; a job revokes them (204 or 401 counts as done); each broker request re-queues lost revocations. "Remove this Mac and cut off its workrooms" cancels every grant that Mac created.
  • Base-clone token: installation lookup, then a user read check, then a read-only token (not stored; can outlive a Mac removal by up to 1 h).

Pages on codaset.dev (styles: Codaset's app style for account pages, the /workroom page style for the install page)

  • Workroom access at /workroom/account: signed-in Macs (remove: "Sign this Mac out" or "Sign out and cut off GitHub access", with live workroom counts; the workroom machines keep running), and "Check a repository" (ready / you requested this / not available, 30 checks an hour per user).
  • /install/{owner}: public, static page for org admins, with GitHub's install link and a "Copy link" for writers.
  • Per-cause sign-in error pages, an account-link code page, and a signed-out entry page.

Operations

  • Rate limits in Solid Cache; structured logs; an append-only broker_events audit table (90+ days); a runbook. No feature flag: rollback reverts the deploy, then runs a task from the pre-revert commit that revokes every live token.

Acceptance criteria

  • A user signs in from the Mac (loopback + PKCE), and the broker refreshes their token across the 8-hour expiry with no client involved.
  • A first GitHub sign-in creates a Codaset user with a personal organisation; an existing account links only after its emailed code; an account linked to another GitHub user is refused.
  • The result page says "signed in" only after the Mac's confirmation; every sign-in failure has its own page and one next step.
  • A VM that redeemed an enrolment code gets a token that can push to its one repository (including workflow files) and cannot reach any other.
  • An enrolment code cannot be redeemed twice, or after it expires, or for a different workroom or repository.
  • Grants and mints follow the repository ID through a rename; a new repository under the old name gets nothing.
  • A user without push access is refused even where the App is installed; a missing install answers app_not_installed with the install page link.
  • Re-check outcomes follow the amended rules (suspend on a dead token, withdraw on no push, coverage check on 404, keep the last answer on transient failures).
  • Cancelling a grant stops minting at once and revokes its unexpired tokens, even if the first revocation job is lost.
  • Refresh tokens, issued tokens, the client secret and the private key are stored encrypted and never logged.
  • A read-only base-clone token cannot push.
  • The broker and its pages answer at codaset.dev with WIP unset; the rest of the app stays behind it (route-set test).

Blocked by

None.

Filed by Claude Code at the owner's request, 2026-09-27. Hosting and accounts sections updated at the owner's request, 2026-09-27. Amended plan added by Claude Code after the 2026-09-27/28 reviews.

Activity

  1. added 3 commits that reference this issue on Sep 28, 2026
  2. joelmoss commented on Oct 2, 2026

    @joelmoss
    OwnerAuthor

    🤖 Left by Claude Code

    Built in joelmoss/codaset#43 (merged 2026-10-01). Closing. The design doc's OQ20 records it as built.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions