Skip to content

Commit f047ee8

Browse files
committed
test(auth): cover device code login
1 parent 9771d13 commit f047ee8

4 files changed

Lines changed: 209 additions & 10 deletions

File tree

docs/getting-started.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,9 @@ The browser-based OAuth flow uses the same local callback port as Codex CLI: `ht
7676
If you are on SSH, WSL, or another environment where the browser callback flow is inconvenient:
7777

7878
1. rerun `opencode auth login`
79-
2. choose `ChatGPT Plus/Pro (Manual URL Paste)`
80-
3. paste the full redirect URL after login completes in the browser
79+
2. choose `ChatGPT Plus/Pro MULTI (Device Code)`
80+
3. open the verification link, enter the one-time code, and wait for login to finish
81+
4. if device code is unavailable on your auth server, fall back to `ChatGPT Plus/Pro MULTI (Manual URL Paste)`
8182

8283
## Add the Plugin to OpenCode
8384

docs/troubleshooting.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,9 @@ Failed to access Codex API
182182

183183
1. **Manual URL paste:**
184184
- Re-run `opencode auth login`
185-
- Select **"ChatGPT Plus/Pro (Manual URL Paste)"**
186-
- Paste the full redirect URL after login
185+
- Select **"ChatGPT Plus/Pro MULTI (Device Code)"** first if you are on SSH, WSL, or a headless machine
186+
- If device code is unavailable, fall back to **"ChatGPT Plus/Pro MULTI (Manual URL Paste)"**
187+
- Paste the full redirect URL after login when using the manual flow
187188

188189
2. **Check port 1455 availability:**
189190
```bash
@@ -207,7 +208,7 @@ Failed to access Codex API
207208
**Solutions:**
208209
- Re-run `opencode auth login` to generate a fresh URL
209210
- Open the URL directly in browser (don't use a stale link)
210-
- For SSH/WSL/remote, use **"Manual URL Paste"** option
211+
- For SSH/WSL/remote, use **"Device Code"** first, then **"Manual URL Paste"** if needed
211212

212213
</details>
213214

@@ -617,7 +618,7 @@ ssh -L 1455:localhost:1455 user@remote
617618

618619
**Docker / Containers:**
619620
- OAuth with localhost redirect doesn't work in containers
620-
- Use SSH port forwarding or manual URL flow
621+
- Use Device Code first, then SSH port forwarding or manual URL flow if needed
621622

622623
</details>
623624

test/device-code.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("../lib/auth/auth.js", () => ({
4+
AUTHORIZE_URL: "https://auth.openai.com/oauth/authorize",
5+
CLIENT_ID: "test-client-id",
6+
exchangeAuthorizationCode: vi.fn(async () => ({
7+
type: "success" as const,
8+
access: "device-access",
9+
refresh: "device-refresh",
10+
expires: Date.now() + 60_000,
11+
})),
12+
}));
13+
14+
vi.mock("../lib/logger.js", () => ({
15+
logError: vi.fn(),
16+
}));
17+
18+
import {
19+
buildDeviceCodeInstructions,
20+
completeDeviceCodeSession,
21+
createDeviceCodeSession,
22+
} from "../lib/auth/device-code.js";
23+
import { exchangeAuthorizationCode } from "../lib/auth/auth.js";
24+
25+
describe("device-code auth", () => {
26+
beforeEach(() => {
27+
vi.clearAllMocks();
28+
});
29+
30+
afterEach(() => {
31+
vi.unstubAllGlobals();
32+
});
33+
34+
it("creates a device-code session from the auth server response", async () => {
35+
globalThis.fetch = vi.fn(async () =>
36+
new Response(
37+
JSON.stringify({
38+
device_auth_id: "device-auth-1",
39+
usercode: "ABCD-EFGH",
40+
interval: "7",
41+
}),
42+
{ status: 200 },
43+
),
44+
) as typeof fetch;
45+
46+
const result = await createDeviceCodeSession();
47+
48+
expect(result).toEqual({
49+
type: "ready",
50+
session: {
51+
verificationUrl: "https://auth.openai.com/codex/device",
52+
userCode: "ABCD-EFGH",
53+
deviceAuthId: "device-auth-1",
54+
intervalSeconds: 7,
55+
},
56+
});
57+
if (result.type === "ready") {
58+
expect(buildDeviceCodeInstructions(result.session)).toContain("ABCD-EFGH");
59+
}
60+
});
61+
62+
it("reports when device-code login is unavailable", async () => {
63+
globalThis.fetch = vi.fn(async () => new Response("missing", { status: 404 })) as typeof fetch;
64+
65+
const result = await createDeviceCodeSession();
66+
67+
expect(result.type).toBe("failed");
68+
if (result.type === "failed") {
69+
expect(result.failure.reason).toBe("http_error");
70+
expect(result.failure.statusCode).toBe(404);
71+
expect(result.failure.message).toContain("not enabled");
72+
}
73+
});
74+
75+
it("polls until an authorization code is issued and exchanges it", async () => {
76+
globalThis.fetch = vi
77+
.fn()
78+
.mockResolvedValueOnce(new Response("", { status: 403 }))
79+
.mockResolvedValueOnce(
80+
new Response(
81+
JSON.stringify({
82+
authorization_code: "auth-code-1",
83+
code_verifier: "code-verifier-1",
84+
}),
85+
{ status: 200 },
86+
),
87+
) as typeof fetch;
88+
89+
const result = await completeDeviceCodeSession({
90+
verificationUrl: "https://auth.openai.com/codex/device",
91+
userCode: "ABCD-EFGH",
92+
deviceAuthId: "device-auth-1",
93+
intervalSeconds: 0,
94+
});
95+
96+
expect(result.type).toBe("success");
97+
expect(vi.mocked(exchangeAuthorizationCode)).toHaveBeenCalledWith(
98+
"auth-code-1",
99+
"code-verifier-1",
100+
"https://auth.openai.com/deviceauth/callback",
101+
);
102+
});
103+
104+
it("times out when no authorization code is issued", async () => {
105+
globalThis.fetch = vi.fn(async () => new Response("", { status: 403 })) as typeof fetch;
106+
107+
const result = await completeDeviceCodeSession(
108+
{
109+
verificationUrl: "https://auth.openai.com/codex/device",
110+
userCode: "ABCD-EFGH",
111+
deviceAuthId: "device-auth-1",
112+
intervalSeconds: 0,
113+
},
114+
{ maxWaitMs: 0 },
115+
);
116+
117+
expect(result).toEqual({
118+
type: "failed",
119+
reason: "unknown",
120+
message: "Device code authorization timed out after 15 minutes",
121+
});
122+
});
123+
});

test/index.test.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,29 @@ vi.mock("../lib/auth/server.js", () => ({
7474
})),
7575
}));
7676

77+
vi.mock("../lib/auth/device-code.js", () => ({
78+
createDeviceCodeSession: vi.fn(async () => ({
79+
type: "ready" as const,
80+
session: {
81+
verificationUrl: "https://auth.openai.com/codex/device",
82+
userCode: "ABCD-EFGH",
83+
deviceAuthId: "device-auth-1",
84+
intervalSeconds: 1,
85+
},
86+
})),
87+
buildDeviceCodeInstructions: vi.fn(
88+
(session: { verificationUrl: string; userCode: string }) =>
89+
`Open this link and sign in: ${session.verificationUrl}\nEnter this one-time code: ${session.userCode}\nThis code expires in about 15 minutes.`,
90+
),
91+
completeDeviceCodeSession: vi.fn(async () => ({
92+
type: "success" as const,
93+
access: "device-access-token",
94+
refresh: "device-refresh-token",
95+
expires: Date.now() + 3600_000,
96+
idToken: "device-id-token",
97+
})),
98+
}));
99+
77100
vi.mock("../lib/cli.js", () => ({
78101
promptLoginMode: vi.fn(async () => ({ mode: "add" })),
79102
promptAddAnotherAccount: vi.fn(async () => false),
@@ -554,15 +577,16 @@ describe("OpenAIOAuthPlugin", () => {
554577
expect(plugin.tool["codex-import"]).toBeDefined();
555578
});
556579

557-
it("has two auth methods", () => {
558-
expect(plugin.auth.methods).toHaveLength(2);
580+
it("has three auth methods", () => {
581+
expect(plugin.auth.methods).toHaveLength(3);
559582
expect(plugin.auth.methods[0].label).toBe("ChatGPT Plus/Pro MULTI (Codex Subscription)");
560-
expect(plugin.auth.methods[1].label).toBe("ChatGPT Plus/Pro MULTI (Manual URL Paste)");
583+
expect(plugin.auth.methods[1].label).toBe("ChatGPT Plus/Pro MULTI (Device Code)");
584+
expect(plugin.auth.methods[2].label).toBe("ChatGPT Plus/Pro MULTI (Manual URL Paste)");
561585
});
562586

563587
it("rejects manual OAuth callbacks with mismatched state", async () => {
564588
const authModule = await import("../lib/auth/auth.js");
565-
const manualMethod = plugin.auth.methods[1] as unknown as {
589+
const manualMethod = plugin.auth.methods[2] as unknown as {
566590
authorize: () => Promise<{
567591
validate: (input: string) => string | undefined;
568592
callback: (input: string) => Promise<{ type: string; reason?: string; message?: string }>;
@@ -578,6 +602,56 @@ describe("OpenAIOAuthPlugin", () => {
578602
expect(result.reason).toBe("invalid_response");
579603
expect(vi.mocked(authModule.exchangeAuthorizationCode)).not.toHaveBeenCalled();
580604
});
605+
606+
it("suggests device code when browser callback server is unavailable", async () => {
607+
const serverModule = await import("../lib/auth/server.js");
608+
vi.mocked(serverModule.startLocalOAuthServer).mockResolvedValueOnce({
609+
ready: false,
610+
close: vi.fn(),
611+
waitForCode: vi.fn(async () => null),
612+
port: 1455,
613+
});
614+
615+
const autoMethod = plugin.auth.methods[0] as unknown as {
616+
authorize: (inputs?: Record<string, string>) => Promise<{
617+
instructions: string;
618+
callback: () => Promise<{ type: string; message?: string }>;
619+
}>;
620+
};
621+
622+
const authResult = await autoMethod.authorize({ loginMode: "add", accountCount: "1" });
623+
expect(authResult.instructions).toContain("Device Code");
624+
expect(authResult.instructions).toContain("Manual URL Paste");
625+
626+
const result = await authResult.callback();
627+
expect(result.type).toBe("failed");
628+
expect(result.message).toContain("Device Code");
629+
});
630+
631+
it("completes device code login and persists the account", async () => {
632+
const deviceModule = await import("../lib/auth/device-code.js");
633+
const deviceMethod = plugin.auth.methods[1] as unknown as {
634+
authorize: () => Promise<{
635+
instructions: string;
636+
callback: () => Promise<{ type: string }>;
637+
}>;
638+
};
639+
640+
const authResult = await deviceMethod.authorize();
641+
expect(authResult.instructions).toContain("ABCD-EFGH");
642+
expect(authResult.instructions).toContain("https://auth.openai.com/codex/device");
643+
644+
const result = await authResult.callback();
645+
expect(result.type).toBe("success");
646+
expect(vi.mocked(deviceModule.createDeviceCodeSession)).toHaveBeenCalledTimes(1);
647+
expect(vi.mocked(deviceModule.completeDeviceCodeSession)).toHaveBeenCalledTimes(1);
648+
expect(mockStorage.accounts).toHaveLength(1);
649+
expect(mockStorage.accounts[0]).toMatchObject({
650+
refreshToken: "device-refresh-token",
651+
accessToken: "device-access-token",
652+
accountId: "acc-1",
653+
});
654+
});
581655
});
582656

583657
describe("event handler", () => {

0 commit comments

Comments
 (0)