Skip to content

feat(copilot): in-app GitHub device-flow sign-in for the Copilot backend - #163

Open
SSKUltra wants to merge 13 commits into
0xsline:mainfrom
SSKUltra:feat/copilot-oauth-pr
Open

SSKUltra wants to merge 13 commits into
0xsline:mainfrom
SSKUltra:feat/copilot-oauth-pr

Conversation

@SSKUltra

Copy link
Copy Markdown
Contributor

Adds an in-app GitHub sign-in for the Copilot backend, so using Copilot no longer depends on the user having separately authenticated the Copilot CLI outside OpenChatCut.

Builds on the Copilot backend already merged in #139.

What this adds

  • Device flow, in the desktop app. server/copilot/oauth-{types,store,service,api}.ts implement the GitHub device-code flow end to end, with a standalone sign-in state shared through shared/copilot-auth.ts.
  • Encrypted at rest. Grants persist to ~/.openchatcut/copilot-auth-v1.enc; nothing lands in .env.local and no token is ever echoed back to the browser. The store covers restart, file permissions, unavailable storage and corrupted payloads.
  • Isolated. Sign-in uses the same isolated COPILOT_HOME the backend already uses, so in-app auth never disturbs the user's own ~/.copilot.
  • Settings UI. A CopilotAccountCard in the settings vendor pane shows the pending user code, account state, and sign-out.

Correctness details worth reviewing

  • Rotated tokens are preserved. Persisting a refreshed grant previously depended on a follow-up profile request; when that request failed, the rotated token was dropped and the user was silently signed out. The refresh path now persists without it.
  • Late cancellation is honored. Cancellation is re-checked after SDK shutdown, and a cancelled refresh can no longer grant a run lease.
  • A failed sign-in is recoverable. Previously, once app sign-in failed there was no way to reset it from the UI.

Verification

New npm run verify:copilot-auth gate (wired into verify:server-runs):

copilot-oauth-api.verify:     secretless device/refresh grants, safe errors, explicit isolated SDK auth
copilot-oauth-store.verify:   encrypted-only persistence, restart, permissions, unavailable storage, corruption
copilot-oauth-service.verify: polling, isolation, cancellation, expiry, rotation, sign-out, busy guards
copilot-oauth-http.verify:    same-origin guards, bounded JSON, safe state, pending ownership, sign-out
copilot-auth.verify:          HTTP contract, polling/races, composer sync, account UI

All pass on top of main; tsc -b is clean.

SSKUltra and others added 8 commits September 24, 2026 10:53
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Use the registered OAuth app without gh, persist credentials with OS-backed encryption, refresh device-flow tokens, guard account changes during active turns, and prevent fallback after sign-out.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Recheck cancellation after SDK shutdown and prevent a cancelled refresh from granting a run lease. Persist refreshed grants without a fallible profile request after rotation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ceiling

src/i18n/dict/it/settings.ts sat exactly at the 500-line ceiling, so the
Copilot sign-in strings pushed it to 519 and failed the gate.

It is the Italian counterpart of src/i18n/dict/en/settings.ts, which the
gate already exempts for the same reason: translation dictionary data with
no application logic, where "split by responsibility" has no meaning — the
file has exactly one responsibility and splitting it would only scatter the
locale across modules.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@SSKUltra

Copy link
Copy Markdown
Contributor Author

OAuth app is created in my Github account. Let me know and I'll transfer ownership to @0xsline.

@0xsline 0xsline left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough work here. The design held up well in review: you use your own OAuth App client ID and only documented GitHub endpoints. The token reaches Copilot through the SDK's supported path, is encrypted with safeStorage, and never reaches the renderer or the logs. The new routes have loopback/Host/Origin checks on top of the global guard.

Two things need to change before merge:

  1. OAuth App ownership. The project will register a maintainer-owned OAuth App (with Device Flow enabled) and post its Client ID here, so there's no need to transfer yours. Please swap it in server/copilot/oauth-api.ts once it's posted. In the same change, please reword the "this fork's" OAuth App text in README.md (around lines 303-306).
  2. CLI lockout. Take a user whose gh/Copilot CLI login already works. If they try in-app sign-in and it fails (denied, expired or org-blocked), the card keeps showing an error. The only visible reset is "Sign out of this app". That saves a signed-out marker, which from then on blocks the CLI fallback, and the only way back is deleting copilot-auth-v1.enc by hand. When the app never stored a login, signing out should just clear the error (or offer a separate "Dismiss").

Optional, if you're in there anyway:

  • After a successful refresh, always persist the new token pair. Quitting mid-refresh can currently leave the old, now-revoked pair on disk.
  • During the device-code poll, retry transient network/5xx errors until the code expires instead of aborting the whole sign-in.

SSKUltra and others added 4 commits September 27, 2026 13:01
A user whose gh/Copilot CLI login already works could try in-app sign-in,
have it fail (denied, expired, org-blocked), and then find "Sign out of
this app" as the only visible reset. That saved a signed-out marker, which
from then on blocked the CLI fallback until copilot-auth-v1.enc was
deleted by hand.

- Sign-out writes the marker only when the app holds a saved login
  (valid, expired or unreadable). With nothing saved it just clears the
  error and leaves the CLI fallback intact.
- The auth state reports savedLogin. Without one, the card offers
  "Dismiss" (POST /api/copilot/auth/dismiss, same guards) instead of
  sign-out. Dismiss never touches stored credentials.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Refreshing rotates the grant on GitHub, so the old pair is revoked from
then on. If sign-out or quit arrived mid-refresh, the service either
dropped the new pair or wrote the old, now-revoked one back to disk.

- Once a refresh succeeds, the new pair is persisted unconditionally.
  Cancellation only withholds the token (no run lease). A queued
  sign-out still overwrites the pair afterwards.
- The refresh request is no longer aborted once sent, because GitHub may
  already have rotated the grant. It stays bounded by the 15s request
  timeout.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A single network blip or GitHub 5xx while polling ended the whole
sign-in, even though the device code was still valid.

- Network failures and HTTP 5xx are marked transient. Polling retries
  them at the normal interval until the code expires, and the card shows
  a retrying notice meanwhile. OAuth rejections and 4xx still fail at
  once.
- Device codes are single-use, so once GitHub grants a token a transient
  profile-lookup failure retries only the lookup. The code is never
  polled again.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Resolve the CopilotVendorPane conflict by keeping this branch's
showLegacyNote gating together with upstream's onOpenPage link on
SettingsNoteAction (469a043).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@SSKUltra

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review @0xsline. Status on each point:

  1. OAuth App ownership. Sounds good, no transfer then. Since @0xsline is a personal account, only you can register the app. Once you post the Client ID, I'll swap it into  server/copilot/oauth-api.ts  and reword the "this fork's" text in  README.md  in one commit.

  2. CLI lockout. Fixed in 77e3f6d.

• Sign-out now writes the signed-out marker only when the app holds a saved login (valid, expired or unreadable). With nothing saved, it just clears the error, so a working gh/Copilot CLI login keeps working.
• The auth state now reports  savedLogin . Without one, the card shows Dismiss instead of "Sign out of this app". Dismiss uses a new  POST /api/copilot/auth/dismiss  route behind the same loopback/Host/Origin, content-type and empty-body checks, and never touches stored credentials.
• Tests cover: denied first sign-in → dismiss or sign-out → CLI fallback still returns  undefined  (no marker); an unreadable saved login still gets the marker; the UI hides sign-out when there's no saved login.

Optional items: both done.

• Always persist the refreshed pair (5bd2fb7). A successful refresh is now saved unconditionally, so cancelling only withholds the token and a queued sign-out still overwrites it afterwards. Previously, cancel/quit mid-refresh could drop the new pair or write the old, revoked one back. The refresh request is also no longer aborted once sent, since GitHub may already have rotated the grant; it stays bounded by the 15s request timeout.
• Retry transient poll errors (2b32424). Network failures and HTTP 5xx are marked transient and retried at the poll interval until the code expires, with a "retrying" notice on the card. OAuth rejections and 4xx still fail immediately. Device codes are single-use, so if GitHub has already granted a token, a transient profile-lookup failure retries only the lookup.

I checked each regression test against the pre-fix code, and each one fails there.

@0xsline

0xsline commented Oct 15, 2026

Copy link
Copy Markdown
Owner

Main has moved on: #200, #203, #207, #209, #211 and #213 are merged now, plus a fix for the >500-line verify gate that was failing npm test on main (server/key-probes.ts, commit dc17d44). Both of these PRs now conflict with main.

If you want, I can merge main into your branch and push the resolution back here (maintainer edits are enabled) so the diff is reviewable again — just say the word. The remaining review points from the earlier CHANGES_REQUESTED are still open on our side and we would like to see how they are addressed before merging.

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	package.json
@SSKUltra

Copy link
Copy Markdown
Contributor Author

Thanks @0xsline. I merged current main into this branch myself (5eb9e23), so the PR is mergeable again. Besides the package.json script conflict, the merge needed two small follow-ups:

  • server/copilot/oauth-store.ts used durableRemove, which main dropped when SQLite became the only project-store backend. Sign-out now does the same thing inline (rm + syncDirectory).
  • Main removed the storage-migration dialog strings, which included the 忽略 key that the Copilot card's Dismiss button reuses. I added the key back in en/it/ru.

npm run verify:server-runs (including verify:copilot-auth), the i18n verifies, the app typecheck and lint all pass.

On the earlier review: the CLI lockout fix and both optional items are already on the branch (77e3f6d, 5bd2fb7, 2b32424). Details are in my comment above.

The one open item is OAuth App ownership. Have you had a chance to register the maintainer-owned OAuth App (Device Flow enabled)? Once you post the Client ID, I'll swap it into server/copilot/oauth-api.ts and reword the "this fork's" OAuth App text in README.md in a single commit. If you'd rather, I can make that README change now and leave only the Client ID swap for later.

@SSKUltra
SSKUltra requested a review from 0xsline October 16, 2026 05:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants