Skip to content

feat(auth): make browser OAuth user-controlled - #244

Open
Nowaker wants to merge 1 commit into
ndycode:mainfrom
Nowaker:fix/oauth-login-ux
Open

feat(auth): make browser OAuth user-controlled#244
Nowaker wants to merge 1 commit into
ndycode:mainfrom
Nowaker:fix/oauth-login-ux

Conversation

@Nowaker

@Nowaker Nowaker commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

  • What changed?
    • Adds Codex OAuth (Open URL Manually) as a first-class login method. It starts the localhost callback listener, prints the authorization URL, and lets the user open that URL in any browser without launching the OS default browser.
    • Moves default-browser and manual-browser OAuth onto one listener-first lifecycle with deadline-based cleanup and immediate, actionable opener-failure handling.
    • Makes Manual URL Paste accept either the complete localhost callback URL or the raw authorization code. Structured URL, query, fragment, and code#state input must carry the matching state; a raw code can omit state because PKCE still binds it to the login attempt.
    • Updates the OAuth documentation and adds direct lifecycle, method-order, parsing, state, opener-failure, cleanup, and real-port regression coverage.
  • Why is this needed?
    • The default OAuth option always opened the OS default browser. On a machine where that browser was already signed into a different ChatGPT account, the user had to log out of that account because the authorization URL was not printed and could not be opened in another browser.
    • Manual URL Paste was supposed to be the escape hatch, but pasting the raw code from a successful authorization failed. Pasting the complete localhost redirect had also been unreliable. That left no practical login path for choosing a different browser.
    • The new manual-browser method keeps the localhost listener active while exposing the same official OAuth URL for the browser of the user's choice. The paste method now accepts the two forms a user can actually copy while retaining state validation for structured callbacks and PKCE binding for raw codes.

Testing

  • npm run lint
  • npm run build
  • npm test
  • Not applicable

Additional verification:

  • Focused OAuth/auth/docs gate: 8 files, 403/403 tests passed.
  • npm run typecheck passed.
  • Full suite: 3,197 passed, 1 skipped, 1 failed. The single failure is test/paths.test.ts, which this change does not touch: it asserts that a path outside the home directory is rejected, and it fails only because the run used a redirected HOME. Against the ordinary environment that file passes 28/28 on this commit.
  • An isolated driver imported the built plugin and verified the four method labels and their order, a successful raw-code exchange through the flow's own PKCE verifier, and rejection of a structured callback whose state does not match, with no provider traffic.
  • Fresh real-provider OAuth was exercised through all three affected paths (manual-browser listener callback, full redirect-URL paste, raw-code paste) on the equivalent implementation before it was rebased onto current main.

Compliance Confirmation

  • This change stays within the repository scope and OpenAI Terms of Service expectations.
  • This change uses official authentication flows only and does not add bypass, scraping, or credential-sharing behavior.
  • I updated tests and documentation when the change affected users, maintainers, or repository behavior.

Notes

  • Linked issue: Not linked.
  • Follow-up work or rollout notes:
    • The auth method order becomes Default Browser, Open URL Manually, Device Code, and Manual URL Paste. Consumers that select an auth method by its numeric position will see Device Code move from 1 to 2 and Manual URL Paste from 2 to 3. Those positions are indexes into the list the plugin returns at runtime rather than documented identifiers, and main already moved them once the same way when Device Code was added in 9968199.
    • The undocumented direct-SDK noBrowser / no-browser input keys are no longer interpreted; the first-class Open URL Manually method supersedes them.
    • Localhost callback behavior remains on port 1455, and Device Code remains available when localhost callbacks are unreachable.
    • Not addressed here, and pre-existing on main: test/oauth-server.integration.test.ts and test/chaos/auth-faults.test.ts both bind port 1455, and Vitest runs files in parallel, so under load whichever binds second can observe ready=false and fail for a reason unrelated to its subject. This change adds coverage to the first of those files and therefore widens an existing window without creating it. I kept a fix out of this PR because it is test infrastructure rather than OAuth behavior; happy to send it separately.
    • The full suite above was run with a redirected HOME because test/chaos/auth-invalidated-401-stress.test.ts on main writes the developer's real account pool. That is fixed separately in fix(accounts): stop a disposed manager overwriting the account store #242 and is unrelated to this change.

note: greptile review for oc-chatgpt-multi-auth. cite files like lib/foo.ts:123. confirm regression tests + windows concurrency/token redaction coverage.

Greptile Summary

this pr adds a user-controlled browser oauth method, unifies loopback listener lifecycle handling, and permits pkce-bound raw authorization codes in manual paste.

  • adds listener-first default and manual-browser oauth flows with bounded cleanup
  • preserves state checks for structured callback input while accepting opaque raw codes
  • updates auth labels, documentation, and focused lifecycle tests
  • no windows filesystem or token-safety defect was identified; loopback concurrency remains the main area needing explicit host-level coverage

Confidence Score: 4/5

the pr appears safe to merge, with a non-blocking gap in host-level vitest coverage for abandoned manual-browser sessions and immediate retries.

the oauth state, pkce, token exchange, and listener cleanup paths remain protected; the only accepted concern is missing coverage for a concrete port-1455 concurrency scenario.

Files Needing Attention: test/index.test.ts

Important Files Changed

Filename Overview
lib/auth/loopback-flow.ts introduces a listener-first shared oauth session with normalized callback observation, pkce exchange, and close-once cleanup.
index.ts registers the manual-browser method and accepts raw manual codes while retaining state checks for structured callbacks.
test/index.test.ts covers the four-method host contract and main success and failure paths, but misses abandoned-session retry concurrency.
test/loopback-flow.test.ts adds focused lifecycle coverage for cleanup, opener failures, timeout, exchange, and callback behavior.
test/oauth-server.integration.test.ts adds real-port coverage for listener cleanup and abandonment behavior.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[choose oauth method] --> B{method}
  B -->|default browser| C[start loopback listener]
  B -->|open url manually| C
  C --> D[create state and pkce session]
  D --> E[receive state-checked callback]
  B -->|manual paste| F[parse callback url or raw code]
  F --> G{structured input}
  G -->|yes| H[validate state]
  G -->|no| I[retain opaque raw code]
  E --> J[exchange with session verifier]
  H --> J
  I --> J
  J --> K[resolve and persist account]
Loading
Prompt To Fix All With AI
### Issue 1
test/index.test.ts:816
**cover abandoned-session retry concurrency**

add a host-level vitest case where the manual-browser authorization is abandoned and another loopback login starts before the five-minute deadline. this concurrency scenario retains port 1455 and makes the retry report an unavailable listener, while the existing lower-level collision and cleanup tests do not cover the complete host method lifecycle.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "feat(auth): make browser OAuth user-cont..." | Re-trigger Greptile

Context used (4)

The first OAuth method opens whatever browser the OS has registered as
default. On a machine already signed into a different ChatGPT account
that is the wrong browser, and there was no way to choose another: the
URL was never printed, so it could not be opened elsewhere. Manual URL
Paste was the documented escape hatch, and it rejected a pasted raw
authorization code, leaving no working path at all.

Add `Codex OAuth (Open URL Manually)` as a first-class method. It binds
the loopback listener first, prints the authorize URL instead of opening
anything, and completes through localhost exactly like the default-browser
method - the two now share one session primitive rather than each owning
a private listener.

Manual URL Paste required a state parameter unconditionally, which is why
a raw code pasted on its own was rejected. What is required is now
decided by what the input is. A full callback URL, a bare query or a
fragment must carry a matching non-empty state, because that input
reproduces the callback's own parameters and a missing one means the
paste did not come from this attempt. A raw code is accepted without
state, since it carries none to compare and PKCE already binds it to the
attempt that produced it. The parser reports which of those forms it
saw, so the two cases cannot be mistaken for one another.

Two lifecycle defects are fixed alongside, because the shared primitive
is where they live:

- Callback observation now starts when the session is created rather than
  when the host invokes its callback, so an authorization the host
  abandons after taking the URL still releases port 1455 on the listener's
  existing five-minute deadline instead of pinning it for the lifetime of
  the process. The observation is normalized so it cannot reject, since an
  unobserved rejection in the window before the callback is awaited would
  be an unhandled rejection.
- A default browser that fails to launch previously left the user waiting
  on a page that never opened. `openBrowserUrl` returning false or
  throwing now closes the listener and reports a typed failure naming the
  manual-browser method.

Docs, docs-parity assertions and direct regressions cover all four
methods.

Implementation note: OpenCode discovers this plugin's auth methods at
runtime and presents their labels; numeric positions are transient
indexes into that returned list, not documented or persisted method
identifiers. This implementation inserts `Codex OAuth (Open URL
Manually)` after the default-browser entry, moving the later entries in
the runtime array. The first method also stops interpreting the
undocumented `noBrowser` and `no-browser` input keys; direct callers
should resolve the new named method from the returned method list
instead.

AI-Tool: opencode
AI-Model: anthropic/claude-opus-5
AI-Platform: linux
AI-Harness: Vibeterm 3e1f523-dirty
Copilot AI lite review requested due to automatic review settings September 2, 2026 04:04
@Nowaker
Nowaker requested a review from ndycode as a code owner September 2, 2026 04:04
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 7 seconds.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: 7eaf74a5-06b0-417e-ace9-c157102bb590

📥 Commits

Reviewing files that changed from the base of the PR and between cd548ad and 51efc68.

📒 Files selected for processing (15)
  • AGENTS.md
  • docs/architecture.md
  • docs/development/ARCHITECTURE.md
  • docs/faq.md
  • docs/getting-started.md
  • docs/troubleshooting.md
  • index.ts
  • lib/AGENTS.md
  • lib/auth/loopback-flow.ts
  • lib/constants.ts
  • test/doc-parity.test.ts
  • test/index.test.ts
  • test/loopback-flow.test.ts
  • test/oauth-server.integration.test.ts
  • test/server.unit.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

This PR makes OAuth in the browser flow user-controlled by adding a manual “open URL yourself” method and unifying the loopback-listener lifecycle so the listener starts before any browser action, with consistent cleanup and improved manual input parsing.

Changes:

  • Adds a shared loopback session primitive (startLoopbackFlow) that owns listener-first startup, opener failure handling, and close-once cleanup.
  • Introduces a new auth label/method (“Open URL Manually”) and updates manual-paste behavior to accept raw codes as well as full callback URLs (with state validation for structured inputs).
  • Expands unit/integration/docs parity tests and updates documentation to reflect the four-method OAuth contract.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
lib/auth/loopback-flow.ts Adds listener-first loopback OAuth session primitive with typed lifecycle outcomes and close-once cleanup.
index.ts Switches default browser OAuth to startLoopbackFlow, adds “Open URL Manually” method, and updates manual paste validation/callback handling.
lib/constants.ts Adds new auth label + updated instructions for manual browser and manual paste flows.
test/loopback-flow.test.ts New failing-first unit coverage for the loopback session lifecycle contract.
test/oauth-server.integration.test.ts Adds integration coverage for real-port listener behavior and post-redirect cleanup.
test/server.unit.test.ts Adds unit coverage for idempotent close and post-close cancellation behavior.
test/index.test.ts Updates plugin auth-method contract to four methods and adds coverage for new paths and parsing rules.
test/doc-parity.test.ts Adds doc parity assertions to keep auth labels/count in docs aligned with runtime constants.
docs/* Updates getting started, troubleshooting, FAQ, and architecture docs to describe the new method and behavior.
AGENTS.md / lib/AGENTS.md Updates agent maps to include the new loopback lifecycle module.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread index.ts
Comment on lines +671 to +696
const manualInputRejection = (
parsed: AuthorizationInputParseResult,
expectedState: string,
): string | undefined => {
if (!parsed.code) {
return "No authorization code found. Paste the raw code, or the full callback URL (e.g., http://localhost:1455/auth/callback?code=...). If browser callback keeps failing, retry with Device Code.";
}
switch (parsed.source) {
case "raw":
return undefined;
case "url":
case "query":
case "fragment":
if (!parsed.state) {
return "That callback URL carries no OAuth state, so it cannot be matched to this login attempt. Paste the complete callback URL including its state parameter, paste the raw code on its own, or retry with Device Code.";
}
if (parsed.state !== expectedState) {
return "OAuth state mismatch. Restart login and paste the code or callback URL generated for this login attempt, or retry with Device Code.";
}
return undefined;
default: {
const unreachable: never = parsed;
return unreachable;
}
}
};
Comment on lines +119 to +146
it("captures a provider-shaped redirect on the real listener without the host awaiting first", async () => {
// Given a ready manual-browser session on the real callback listener,
// with only the network exchange stubbed
const exchange = { code: "", verifier: "", redirectUri: "" };
const session = await startLoopbackFlow({
openBrowser: false,
deps: {
exchangeAuthorizationCode: async (code, verifier, redirectUri) => {
exchange.code = code;
exchange.verifier = verifier;
exchange.redirectUri = redirectUri ?? "";
return { type: "success", access: "a", refresh: "r", expires: 1 };
},
},
});
if (session.type !== "ready") {
throw new Error(`expected ready session, got ${session.type}`);
}
openSession = session;
const state = new URL(session.url).searchParams.get("state");
expect(state).toBeTruthy();

// When the browser follows the provider redirect back to loopback, in the
// parameter shape the real provider sends, before the host calls back
const providerCode = "ac_ZmFrZS1hdXRoLWNvZGU.ZmFrZS1zaWduYXR1cmU";
const response = await fetch(
`http://127.0.0.1:1455/auth/callback?code=${providerCode}&scope=openid+profile+email+offline_access&state=${state}`,
);
Comment on lines +184 to +196
await expect
.poll(
async () => {
try {
await fetch("http://127.0.0.1:1455/auth/callback?code=x&state=y");
return "open";
} catch {
return "closed";
}
},
{ timeout: 5000 },
)
.toBe("closed");
Comment thread test/doc-parity.test.ts
Comment on lines +633 to +634
expect(gettingStarted).toContain("four");
expect(gettingStarted).not.toContain("**three**");
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