Skip to content

Commit 26de780

Browse files
brettheapclaude
andcommitted
feat: add OpenAI Codex OAuth plugin (layer 1)
Implements built-in OAuth authentication for ChatGPT Plus/Pro subscribers to use OpenAI models via their existing subscription. Features: - PKCE OAuth flow with OpenAI's auth endpoints - Local callback server on port 1455 - Cross-platform browser launching - Automatic token refresh - Integrated as default plugin in opencode Based on: https://github.com/numman-ali/opencode-openai-codex-auth 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b268bed commit 26de780

13 files changed

Lines changed: 853 additions & 0 deletions

File tree

bun.lock

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Authentication Successful - OpenCode</title>
7+
<style>
8+
* {
9+
margin: 0;
10+
padding: 0;
11+
box-sizing: border-box;
12+
}
13+
14+
body {
15+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
16+
background: linear-gradient(135deg, #10a37f 0%, #1a7f64 100%);
17+
min-height: 100vh;
18+
display: flex;
19+
align-items: center;
20+
justify-content: center;
21+
color: white;
22+
}
23+
24+
.container {
25+
text-align: center;
26+
padding: 2rem;
27+
max-width: 500px;
28+
}
29+
30+
.icon {
31+
width: 80px;
32+
height: 80px;
33+
margin: 0 auto 1.5rem;
34+
background: rgba(255, 255, 255, 0.2);
35+
border-radius: 50%;
36+
display: flex;
37+
align-items: center;
38+
justify-content: center;
39+
animation: pulse 2s ease-in-out infinite;
40+
}
41+
42+
.icon svg {
43+
width: 40px;
44+
height: 40px;
45+
}
46+
47+
@keyframes pulse {
48+
0%, 100% {
49+
transform: scale(1);
50+
opacity: 1;
51+
}
52+
50% {
53+
transform: scale(1.05);
54+
opacity: 0.9;
55+
}
56+
}
57+
58+
h1 {
59+
font-size: 2rem;
60+
font-weight: 600;
61+
margin-bottom: 1rem;
62+
}
63+
64+
p {
65+
font-size: 1.1rem;
66+
opacity: 0.9;
67+
line-height: 1.6;
68+
margin-bottom: 1.5rem;
69+
}
70+
71+
.hint {
72+
font-size: 0.9rem;
73+
opacity: 0.7;
74+
background: rgba(255, 255, 255, 0.1);
75+
padding: 0.75rem 1.5rem;
76+
border-radius: 8px;
77+
display: inline-block;
78+
}
79+
80+
.brand {
81+
margin-top: 3rem;
82+
opacity: 0.6;
83+
font-size: 0.85rem;
84+
}
85+
86+
.brand strong {
87+
font-weight: 600;
88+
}
89+
</style>
90+
</head>
91+
<body>
92+
<div class="container">
93+
<div class="icon">
94+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
95+
<polyline points="20 6 9 17 4 12"></polyline>
96+
</svg>
97+
</div>
98+
99+
<h1>Authentication Successful!</h1>
100+
101+
<p>
102+
You've successfully connected your ChatGPT account to OpenCode.
103+
You can now use OpenAI models with your subscription.
104+
</p>
105+
106+
<div class="hint">
107+
You can close this window and return to your terminal.
108+
</div>
109+
110+
<div class="brand">
111+
Powered by <strong>OpenCode</strong>
112+
</div>
113+
</div>
114+
115+
<script>
116+
// Auto-close after 5 seconds
117+
setTimeout(() => {
118+
window.close();
119+
}, 5000);
120+
</script>
121+
</body>
122+
</html>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"$schema": "https://json.schemastore.org/package.json",
3+
"name": "@opencode-ai/openai-codex-auth",
4+
"version": "1.0.0",
5+
"type": "module",
6+
"scripts": {
7+
"typecheck": "tsgo --noEmit",
8+
"build": "tsc"
9+
},
10+
"exports": {
11+
".": "./src/index.ts"
12+
},
13+
"files": [
14+
"dist",
15+
"assets"
16+
],
17+
"dependencies": {
18+
"@openauthjs/openauth": "^0.4.3",
19+
"hono": "^4.10.4"
20+
},
21+
"peerDependencies": {
22+
"@opencode-ai/plugin": "workspace:*"
23+
},
24+
"devDependencies": {
25+
"@tsconfig/node22": "catalog:",
26+
"@types/node": "catalog:",
27+
"typescript": "catalog:",
28+
"@typescript/native-preview": "catalog:"
29+
}
30+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/**
2+
* Core OAuth implementation with PKCE
3+
*/
4+
5+
import { randomBytes, createHash } from "crypto"
6+
import { OAUTH_CONFIG } from "../constants"
7+
import type { PKCEChallenge, OAuthTokenResponse, OAuthError, AuthCallbackResult } from "../types"
8+
import { openBrowser } from "./browser"
9+
import { startCallbackServer } from "./server"
10+
11+
/**
12+
* Generate a cryptographically secure random string
13+
*/
14+
function generateRandomString(length: number): string {
15+
return randomBytes(length).toString("base64url").slice(0, length)
16+
}
17+
18+
/**
19+
* Create a SHA256 hash and return as base64url
20+
*/
21+
function sha256(input: string): string {
22+
return createHash("sha256").update(input).digest("base64url")
23+
}
24+
25+
/**
26+
* Generate PKCE challenge (code_verifier and code_challenge)
27+
*/
28+
export function createPKCEChallenge(): PKCEChallenge {
29+
// Generate a random 43-128 character code verifier
30+
const codeVerifier = generateRandomString(64)
31+
// Create the code challenge using S256 method
32+
const codeChallenge = sha256(codeVerifier)
33+
// Generate state for CSRF protection
34+
const state = generateRandomString(32)
35+
36+
return {
37+
codeVerifier,
38+
codeChallenge,
39+
state,
40+
}
41+
}
42+
43+
/**
44+
* Build the authorization URL with PKCE parameters
45+
*/
46+
export function buildAuthorizationUrl(pkce: PKCEChallenge): string {
47+
const params = new URLSearchParams({
48+
client_id: OAUTH_CONFIG.clientId,
49+
redirect_uri: OAUTH_CONFIG.redirectUri,
50+
response_type: "code",
51+
scope: OAUTH_CONFIG.scopes.join(" "),
52+
state: pkce.state,
53+
code_challenge: pkce.codeChallenge,
54+
code_challenge_method: "S256",
55+
audience: OAUTH_CONFIG.audience,
56+
})
57+
58+
return `${OAUTH_CONFIG.authorizationUrl}?${params.toString()}`
59+
}
60+
61+
/**
62+
* Exchange authorization code for tokens
63+
*/
64+
export async function exchangeCodeForTokens(
65+
code: string,
66+
codeVerifier: string
67+
): Promise<OAuthTokenResponse> {
68+
const response = await fetch(OAUTH_CONFIG.tokenUrl, {
69+
method: "POST",
70+
headers: {
71+
"Content-Type": "application/x-www-form-urlencoded",
72+
},
73+
body: new URLSearchParams({
74+
grant_type: "authorization_code",
75+
client_id: OAUTH_CONFIG.clientId,
76+
code,
77+
redirect_uri: OAUTH_CONFIG.redirectUri,
78+
code_verifier: codeVerifier,
79+
}),
80+
})
81+
82+
if (!response.ok) {
83+
const error = (await response.json()) as OAuthError
84+
throw new Error(`Token exchange failed: ${error.error_description || error.error}`)
85+
}
86+
87+
return response.json() as Promise<OAuthTokenResponse>
88+
}
89+
90+
/**
91+
* Refresh an access token using the refresh token
92+
*/
93+
export async function refreshAccessToken(refreshToken: string): Promise<OAuthTokenResponse> {
94+
const response = await fetch(OAUTH_CONFIG.tokenUrl, {
95+
method: "POST",
96+
headers: {
97+
"Content-Type": "application/x-www-form-urlencoded",
98+
},
99+
body: new URLSearchParams({
100+
grant_type: "refresh_token",
101+
client_id: OAUTH_CONFIG.clientId,
102+
refresh_token: refreshToken,
103+
}),
104+
})
105+
106+
if (!response.ok) {
107+
const error = (await response.json()) as OAuthError
108+
throw new Error(`Token refresh failed: ${error.error_description || error.error}`)
109+
}
110+
111+
return response.json() as Promise<OAuthTokenResponse>
112+
}
113+
114+
/**
115+
* Decode a JWT token to extract claims (without verification)
116+
*/
117+
export function decodeJWT(token: string): Record<string, unknown> {
118+
try {
119+
const parts = token.split(".")
120+
if (parts.length !== 3) {
121+
return {}
122+
}
123+
const payload = Buffer.from(parts[1], "base64url").toString("utf-8")
124+
return JSON.parse(payload)
125+
} catch {
126+
return {}
127+
}
128+
}
129+
130+
/**
131+
* Perform the complete OAuth flow
132+
* Returns authorization URL and callback handler
133+
*/
134+
export async function initiateOAuthFlow(): Promise<{
135+
url: string
136+
instructions: string
137+
method: "auto"
138+
callback: () => Promise<AuthCallbackResult>
139+
}> {
140+
const pkce = createPKCEChallenge()
141+
const authUrl = buildAuthorizationUrl(pkce)
142+
143+
// Start the callback server before returning
144+
const callbackPromise = startCallbackServer(pkce.state)
145+
146+
return {
147+
url: authUrl,
148+
instructions: "Complete the authentication in your browser. You will be redirected back automatically.",
149+
method: "auto" as const,
150+
callback: async (): Promise<AuthCallbackResult> => {
151+
try {
152+
// Open browser
153+
await openBrowser(authUrl)
154+
155+
// Wait for callback
156+
const result = await callbackPromise
157+
158+
// Exchange code for tokens
159+
const tokens = await exchangeCodeForTokens(result.code, pkce.codeVerifier)
160+
161+
// Calculate expiry time
162+
const expiresAt = Date.now() + tokens.expires_in * 1000
163+
164+
return {
165+
type: "success",
166+
refresh: tokens.refresh_token,
167+
access: tokens.access_token,
168+
expires: expiresAt,
169+
}
170+
} catch (error) {
171+
return {
172+
type: "failed",
173+
error: error instanceof Error ? error.message : "Unknown error",
174+
}
175+
}
176+
},
177+
}
178+
}

0 commit comments

Comments
 (0)