opencode-auth-proxy 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] - 2024-01-01
9
+
10
+ ### Added
11
+ - Initial release of OpenCode Auth Proxy Plugin
12
+ - Google OAuth 2.0 authentication with PKCE support
13
+ - Session management with HMAC-signed cookies
14
+ - Token persistence to local filesystem
15
+ - WebSocket proxy support
16
+ - Health check endpoints
17
+ - Configurable via environment variables or config file
18
+
19
+ ### Security
20
+ - PKCE (Proof Key for Code Exchange) implementation
21
+ - HMAC-SHA256 session cookie signing
22
+ - HttpOnly and SameSite cookie attributes
23
+ - Automatic secure cookie detection for HTTPS
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 milc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,265 @@
1
+ # OpenCode Auth Proxy Plugin
2
+
3
+ ![License](https://img.shields.io/npm/l/opencode-auth-proxy)
4
+ ![Version](https://img.shields.io/npm/v/opencode-auth-proxy)
5
+
6
+ **Google OAuth authentication proxy for OpenCode.** This plugin provides a secure OAuth 2.0 flow with PKCE (Proof Key for Code Exchange) for authenticating with Google services, managing sessions, and proxying requests to protected endpoints.
7
+
8
+ ## Features
9
+
10
+ - 🔐 Secure OAuth 2.0 flow with PKCE
11
+ - 🍪 Session management with HMAC-signed cookies
12
+ - 🔄 Automatic token refresh and persistence
13
+ - 🛡️ Request proxying with authentication checks
14
+ - 🌐 WebSocket support for real-time connections
15
+ - ⚙️ Configurable via environment variables or config file
16
+
17
+ ## Prerequisites
18
+
19
+ - [Opencode CLI](https://opencode.ai) installed
20
+ - A Google Cloud project with OAuth 2.0 credentials
21
+ - Node.js 20 or higher
22
+
23
+ ## Installation
24
+
25
+ Add the plugin to your OpenCode server configuration file (`opencode.server.json`):
26
+
27
+ ```json
28
+ {
29
+ "$schema": "https://opencode.ai/config.json",
30
+ "plugin": ["opencode-auth-proxy"]
31
+ }
32
+ ```
33
+
34
+ ## Configuration
35
+
36
+ The plugin can be configured via environment variables or the OpenCode config file.
37
+
38
+ ### Environment Variables
39
+
40
+ ```bash
41
+ PORT=4096 # Proxy server port (default: 4096)
42
+ TARGET_HOST=127.0.0.1 # Target server host (default: 127.0.0.1)
43
+ TARGET_PORT=4097 # Target server port (default: 4097)
44
+ GOOGLE_REDIRECT_CLIENT_ID=your-client-id # Google OAuth client ID (required)
45
+ GOOGLE_REDIRECT_CLIENT_SECRET=your-secret # Google OAuth client secret (required)
46
+ GOOGLE_REDIRECT_URI=http://localhost:4096/auth/google/callback # OAuth redirect URI
47
+ SESSION_SECRET=your-secret-key # Session signing secret (required)
48
+ SESSION_COOKIE_NAME=opencode_session # Session cookie name (default: opencode_session)
49
+ COOKIE_SECURE=false # Use secure cookies (default: auto-detect)
50
+ TOKEN_PATH=~/.opencode-proxy/google-auth.json # Token storage path
51
+ ```
52
+
53
+ ### Config File
54
+
55
+ Add configuration to your `opencode.json`:
56
+
57
+ ```json
58
+ {
59
+ "$schema": "https://opencode.ai/config.json",
60
+ "authProxy": {
61
+ "port": 4096,
62
+ "targetHost": "127.0.0.1",
63
+ "targetPort": 4097,
64
+ "googleClientId": "your-client-id",
65
+ "googleClientSecret": "your-client-secret",
66
+ "googleRedirectUri": "http://localhost:4096/auth/google/callback",
67
+ "sessionSecret": "your-secret-key",
68
+ "sessionCookieName": "opencode_session",
69
+ "cookieSecure": false,
70
+ "tokenPath": "~/.opencode-proxy/google-auth.json"
71
+ }
72
+ }
73
+ ```
74
+
75
+ ## Google Cloud Setup
76
+
77
+ 1. Go to the [Google Cloud Console](https://console.cloud.google.com/)
78
+ 2. Create or select a project
79
+ 3. Enable the **Google+ API** and **OAuth2 API**
80
+ 4. Go to **APIs & Services** > **Credentials**
81
+ 5. Create an **OAuth 2.0 Client ID**
82
+ - Application type: **Web application**
83
+ - Authorized redirect URIs: `http://localhost:4096/auth/google/callback` (or your configured redirect URI)
84
+ 6. Copy the **Client ID** and **Client Secret**
85
+
86
+ ## Usage
87
+
88
+ ### Starting Authentication
89
+
90
+ Navigate to `/auth/google/start` in your browser or use:
91
+
92
+ ```bash
93
+ curl http://localhost:4096/auth/google/start
94
+ ```
95
+
96
+ You can optionally pass a `project` query parameter:
97
+
98
+ ```bash
99
+ curl "http://localhost:4096/auth/google/start?project=my-project-id"
100
+ ```
101
+
102
+ ### OAuth Flow
103
+
104
+ 1. User visits `/auth/google/start`
105
+ 2. Plugin redirects to Google OAuth consent screen
106
+ 3. User authorizes the application
107
+ 4. Google redirects to `/auth/google/callback`
108
+ 5. Plugin exchanges authorization code for tokens
109
+ 6. Session cookie is set
110
+ 7. User is redirected to the application
111
+
112
+ ### Accessing Protected Endpoints
113
+
114
+ Once authenticated, the plugin will:
115
+ - Check for a valid session cookie on all non-public paths
116
+ - Proxy authenticated requests to the target server
117
+ - Reject unauthenticated requests with 401 or redirect to login
118
+
119
+ ### Public Endpoints
120
+
121
+ The following paths are publicly accessible (no authentication required):
122
+
123
+ - `/health` - Health check endpoint
124
+ - `/global/health` - Global health check
125
+ - `/auth/google/start` - Start OAuth flow
126
+ - `/auth/google/config` - OAuth configuration info
127
+ - `/auth/google/callback` - OAuth callback handler
128
+
129
+ ### Token Management
130
+
131
+ Tokens are automatically persisted to the configured `tokenPath`. To retrieve the current token:
132
+
133
+ ```bash
134
+ curl http://localhost:4096/auth/google/token
135
+ ```
136
+
137
+ ## API Endpoints
138
+
139
+ ### `GET /health` or `GET /global/health`
140
+
141
+ Health check endpoint that also checks upstream server status.
142
+
143
+ **Response:**
144
+ ```json
145
+ {
146
+ "status": "ok",
147
+ "healthy": true,
148
+ "target": "http://127.0.0.1:4097",
149
+ "upstream": {
150
+ "ok": true,
151
+ "status": 200,
152
+ "body": { ... }
153
+ }
154
+ }
155
+ ```
156
+
157
+ ### `GET /auth/google/config`
158
+
159
+ Returns OAuth configuration (client ID is partially masked).
160
+
161
+ **Response:**
162
+ ```json
163
+ {
164
+ "redirect_uri": "http://localhost:4096/auth/google/callback",
165
+ "callback_path": "/auth/google/callback",
166
+ "client_id": "12345678901234567890...",
167
+ "client_secret_set": true,
168
+ "scopes": [
169
+ "https://www.googleapis.com/auth/cloud-platform",
170
+ "https://www.googleapis.com/auth/userinfo.email",
171
+ "https://www.googleapis.com/auth/userinfo.profile"
172
+ ]
173
+ }
174
+ ```
175
+
176
+ ### `GET /auth/google/start?project=<project-id>`
177
+
178
+ Initiates the OAuth flow. Redirects to Google's consent screen.
179
+
180
+ ### `GET /auth/google/callback`
181
+
182
+ OAuth callback handler. Processes the authorization code and sets session cookie.
183
+
184
+ ### `GET /auth/google/token`
185
+
186
+ Retrieves the stored OAuth token.
187
+
188
+ **Response:**
189
+ ```json
190
+ {
191
+ "token": {
192
+ "type": "success",
193
+ "refresh": "refresh-token",
194
+ "access": "access-token",
195
+ "expires": 1234567890,
196
+ "email": "user@example.com",
197
+ "projectId": "project-id",
198
+ "storedAt": "2024-01-01T00:00:00.000Z"
199
+ }
200
+ }
201
+ ```
202
+
203
+ ## Security
204
+
205
+ - **PKCE**: Uses Proof Key for Code Exchange to prevent authorization code interception
206
+ - **HMAC Signing**: Session cookies are signed with HMAC-SHA256
207
+ - **HttpOnly Cookies**: Session cookies are marked HttpOnly to prevent XSS attacks
208
+ - **SameSite**: Cookies use SameSite=Lax to prevent CSRF attacks
209
+ - **Secure Cookies**: Automatically enabled for HTTPS redirect URIs
210
+
211
+ ## Troubleshooting
212
+
213
+ ### Port Already in Use
214
+
215
+ If the default port (4096) is in use, configure a different port:
216
+
217
+ ```json
218
+ {
219
+ "authProxy": {
220
+ "port": 5000
221
+ }
222
+ }
223
+ ```
224
+
225
+ Or set the `PORT` environment variable.
226
+
227
+ ### Session Not Persisting
228
+
229
+ Ensure `SESSION_SECRET` is set and consistent across restarts. The secret is used to sign and verify session cookies.
230
+
231
+ ### OAuth Errors
232
+
233
+ Check the OpenCode logs for detailed OAuth error messages. Common issues:
234
+
235
+ - **Invalid redirect URI**: Ensure the redirect URI in Google Cloud Console matches exactly
236
+ - **Missing scopes**: The plugin requests cloud-platform, userinfo.email, and userinfo.profile scopes
237
+ - **Client secret mismatch**: Verify the client secret is correct
238
+
239
+ ### Debugging
240
+
241
+ Check OpenCode server logs for detailed plugin logs. The plugin logs all authentication attempts, proxy errors, and configuration issues.
242
+
243
+ ## Development
244
+
245
+ To develop on this plugin locally:
246
+
247
+ 1. **Clone or navigate to the plugin directory**
248
+
249
+ 2. **Link**: Update your OpenCode server config to point to your local directory:
250
+
251
+ ```json
252
+ {
253
+ "plugin": ["file:///absolute/path/to/opencode-auth-proxy"]
254
+ }
255
+ ```
256
+
257
+ 3. **Install dependencies**:
258
+
259
+ ```bash
260
+ npm install
261
+ ```
262
+
263
+ ## License
264
+
265
+ MIT
@@ -0,0 +1,588 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import http, { IncomingMessage, ServerResponse } from "http"
3
+ import httpProxy from "http-proxy"
4
+ import fs from "fs"
5
+ import path from "path"
6
+ import crypto from "crypto"
7
+ import os from "os"
8
+
9
+ type AuthProxyConfig = {
10
+ port?: number
11
+ targetPort?: number
12
+ targetHost?: string
13
+ googleClientId?: string
14
+ googleClientSecret?: string
15
+ googleRedirectUri?: string
16
+ sessionSecret?: string
17
+ sessionCookieName?: string
18
+ cookieSecure?: boolean
19
+ tokenPath?: string
20
+ }
21
+
22
+ type TokenSuccess = {
23
+ type: "success"
24
+ refresh: string
25
+ access: string
26
+ expires: number
27
+ email?: string
28
+ projectId: string
29
+ }
30
+
31
+ type TokenPayload = TokenSuccess | { type: "failed"; error: string }
32
+
33
+ const GOOGLE_SCOPES = [
34
+ "https://www.googleapis.com/auth/cloud-platform",
35
+ "https://www.googleapis.com/auth/userinfo.email",
36
+ "https://www.googleapis.com/auth/userinfo.profile",
37
+ ]
38
+
39
+ function base64url(buf: Buffer): string {
40
+ return buf
41
+ .toString("base64")
42
+ .replace(/\+/g, "-")
43
+ .replace(/\//g, "_")
44
+ .replace(/=+$/, "")
45
+ }
46
+
47
+ function encodeState(payload: { verifier: string; projectId: string }): string {
48
+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")
49
+ }
50
+
51
+ function decodeState(state: string): { verifier: string; projectId: string } {
52
+ const normalized = state.replace(/-/g, "+").replace(/_/g, "/")
53
+ const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=")
54
+ const json = Buffer.from(padded, "base64").toString("utf8")
55
+ const parsed = JSON.parse(json)
56
+ return { verifier: parsed.verifier || "", projectId: parsed.projectId || "" }
57
+ }
58
+
59
+ function generatePkce() {
60
+ const verifier = base64url(crypto.randomBytes(32))
61
+ const challenge = base64url(crypto.createHash("sha256").update(verifier).digest())
62
+ return { verifier, challenge }
63
+ }
64
+
65
+ function parseCookies(req: IncomingMessage): Record<string, string> {
66
+ const header = req.headers.cookie
67
+ if (!header) return {}
68
+ return header.split(";").reduce<Record<string, string>>((acc, part) => {
69
+ const [key, ...valueParts] = part.trim().split("=")
70
+ if (!key) return acc
71
+ acc[key] = valueParts.join("=")
72
+ return acc
73
+ }, {})
74
+ }
75
+
76
+ function signSession(sessionSecret: string): string {
77
+ const sessionData = Buffer.from("authenticated", "utf8").toString("base64url")
78
+ const signature = crypto.createHmac("sha256", sessionSecret).update(sessionData).digest("hex")
79
+ return `${sessionData}.${signature}`
80
+ }
81
+
82
+ function verifySession(token: string, sessionSecret: string): boolean {
83
+ const [sessionPart, signature] = token.split(".")
84
+ if (!sessionPart || !signature) return false
85
+ const expected = crypto.createHmac("sha256", sessionSecret).update(sessionPart).digest("hex")
86
+ return crypto.timingSafeEqual(Buffer.from(signature, "utf8"), Buffer.from(expected, "utf8"))
87
+ }
88
+
89
+ function expandPath(filePath: string): string {
90
+ if (filePath.startsWith("~/")) {
91
+ return path.join(os.homedir(), filePath.slice(2))
92
+ }
93
+ return filePath
94
+ }
95
+
96
+ function loadToken(tokenPath: string): TokenSuccess | null {
97
+ try {
98
+ const expandedPath = expandPath(tokenPath)
99
+ const raw = fs.readFileSync(expandedPath, "utf8")
100
+ const parsed = JSON.parse(raw)
101
+ if (typeof parsed !== "object" || !parsed) return null
102
+ if ("access" in parsed && "refresh" in parsed) {
103
+ return parsed as TokenSuccess
104
+ }
105
+ return null
106
+ } catch {
107
+ return null
108
+ }
109
+ }
110
+
111
+ function persistToken(token: TokenSuccess, tokenPath: string) {
112
+ const expandedPath = expandPath(tokenPath)
113
+ const dir = path.dirname(expandedPath)
114
+ fs.mkdirSync(dir, { recursive: true })
115
+ fs.writeFileSync(expandedPath, JSON.stringify(token, null, 2))
116
+ }
117
+
118
+ async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs = 10000): Promise<Response> {
119
+ const controller = new AbortController()
120
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
121
+ try {
122
+ return await fetch(url, { ...options, signal: controller.signal })
123
+ } finally {
124
+ clearTimeout(timeout)
125
+ }
126
+ }
127
+
128
+ async function authorizeGoogle(
129
+ clientId: string,
130
+ redirectUri: string,
131
+ projectId = "",
132
+ ): Promise<{ url: string; verifier: string; projectId: string }> {
133
+ const pkce = generatePkce()
134
+ const url = new URL("https://accounts.google.com/o/oauth2/v2/auth")
135
+ url.searchParams.set("client_id", clientId)
136
+ url.searchParams.set("response_type", "code")
137
+ url.searchParams.set("redirect_uri", redirectUri)
138
+ url.searchParams.set("scope", GOOGLE_SCOPES.join(" "))
139
+ url.searchParams.set("code_challenge", pkce.challenge)
140
+ url.searchParams.set("code_challenge_method", "S256")
141
+ url.searchParams.set("state", encodeState({ verifier: pkce.verifier, projectId: projectId || "" }))
142
+ url.searchParams.set("access_type", "offline")
143
+ url.searchParams.set("prompt", "consent")
144
+ return { url: url.toString(), verifier: pkce.verifier, projectId: projectId || "" }
145
+ }
146
+
147
+ async function exchangeGoogle(
148
+ code: string,
149
+ state: string,
150
+ clientId: string,
151
+ clientSecret: string,
152
+ redirectUri: string,
153
+ log: (data: any) => Promise<void>,
154
+ ): Promise<TokenPayload> {
155
+ try {
156
+ const { verifier } = decodeState(state || "")
157
+ const startTime = Date.now()
158
+ const requestBody = new URLSearchParams({
159
+ client_id: clientId,
160
+ client_secret: clientSecret,
161
+ code,
162
+ grant_type: "authorization_code",
163
+ redirect_uri: redirectUri,
164
+ code_verifier: verifier,
165
+ })
166
+ await log({
167
+ service: "opencode-auth-proxy",
168
+ level: "info",
169
+ message: "OAuth token exchange request",
170
+ extra: {
171
+ redirect_uri: redirectUri,
172
+ client_id: clientId,
173
+ client_secret_length: clientSecret?.length || 0,
174
+ code_length: code?.length || 0,
175
+ has_verifier: !!verifier,
176
+ },
177
+ })
178
+ const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
179
+ method: "POST",
180
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
181
+ body: requestBody,
182
+ })
183
+ if (!tokenResponse.ok) {
184
+ const errorText = await tokenResponse.text()
185
+ let parsedError: any = {}
186
+ try {
187
+ parsedError = JSON.parse(errorText)
188
+ } catch {
189
+ parsedError = { raw: errorText }
190
+ }
191
+ await log({
192
+ service: "opencode-auth-proxy",
193
+ level: "error",
194
+ message: "OAuth token exchange failed",
195
+ extra: {
196
+ status: tokenResponse.status,
197
+ statusText: tokenResponse.statusText,
198
+ error: parsedError,
199
+ redirect_uri: redirectUri,
200
+ redirect_uri_length: redirectUri.length,
201
+ client_id: clientId,
202
+ client_id_length: clientId.length,
203
+ client_secret_set: !!clientSecret,
204
+ client_secret_length: clientSecret?.length || 0,
205
+ },
206
+ })
207
+ const errorMessage = parsedError.error_description || parsedError.error || errorText
208
+ return { type: "failed", error: errorMessage }
209
+ }
210
+
211
+ const tokenPayload = (await tokenResponse.json()) as {
212
+ access_token: string
213
+ expires_in: number
214
+ refresh_token?: string
215
+ }
216
+ const userInfoResponse = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
217
+ headers: { Authorization: `Bearer ${tokenPayload.access_token}` },
218
+ })
219
+ const userInfo = userInfoResponse.ok ? ((await userInfoResponse.json()) as { email?: string }) : {}
220
+
221
+ const refreshToken = tokenPayload.refresh_token
222
+ if (!refreshToken) return { type: "failed", error: "Missing refresh token in response" }
223
+
224
+ return {
225
+ type: "success",
226
+ refresh: refreshToken,
227
+ access: tokenPayload.access_token,
228
+ expires: startTime + (tokenPayload.expires_in || 0) * 1000,
229
+ email: userInfo.email,
230
+ projectId: "",
231
+ }
232
+ } catch (error) {
233
+ return { type: "failed", error: error instanceof Error ? error.message : "Unknown error" }
234
+ }
235
+ }
236
+
237
+ function normalizeRedirectUri(uri: string | undefined, defaultPort: number): string {
238
+ const defaultUri = `http://localhost:${defaultPort}/auth/google/callback`
239
+ const rawUri = uri?.trim() || defaultUri
240
+ try {
241
+ const url = new URL(rawUri)
242
+ if (url.pathname.endsWith("/") && url.pathname !== "/") {
243
+ url.pathname = url.pathname.replace(/\/+$/, "")
244
+ return url.toString()
245
+ }
246
+ return rawUri
247
+ } catch (err) {
248
+ return rawUri
249
+ }
250
+ }
251
+
252
+ function getCallbackPath(redirectUri: string): string {
253
+ try {
254
+ const url = new URL(redirectUri)
255
+ return url.pathname || "/auth/google/callback"
256
+ } catch {
257
+ return "/auth/google/callback"
258
+ }
259
+ }
260
+
261
+ const opencodeAuthProxy: Plugin = async ({ client, directory }) => {
262
+ await client.app.log({
263
+ service: "opencode-auth-proxy",
264
+ level: "info",
265
+ message: "Plugin initializing",
266
+ extra: {
267
+ hasEnvPort: !!process.env.PORT,
268
+ hasEnvSessionSecret: !!process.env.SESSION_SECRET,
269
+ hasEnvGoogleId: !!process.env.GOOGLE_REDIRECT_CLIENT_ID,
270
+ directory,
271
+ },
272
+ })
273
+
274
+ const config = (client.app.config as { authProxy?: AuthProxyConfig })?.authProxy
275
+
276
+ const PORT = config?.port ?? Number(process.env.PORT ?? 4096)
277
+ const TARGET_HOST = config?.targetHost ?? (process.env.TARGET_HOST ?? "127.0.0.1")
278
+ const TARGET_PORT = config?.targetPort ?? Number(process.env.TARGET_PORT ?? 4097)
279
+ const GOOGLE_CLIENT_ID =
280
+ process.env.GOOGLE_REDIRECT_CLIENT_ID?.trim() ?? config?.googleClientId?.trim() ?? ""
281
+ const GOOGLE_CLIENT_SECRET =
282
+ process.env.GOOGLE_REDIRECT_CLIENT_SECRET?.trim() ?? config?.googleClientSecret?.trim() ?? ""
283
+ const GOOGLE_REDIRECT_URI = normalizeRedirectUri(
284
+ config?.googleRedirectUri ?? process.env.GOOGLE_REDIRECT_URI,
285
+ PORT,
286
+ )
287
+ const providedSessionSecret = process.env.SESSION_SECRET?.trim() ?? config?.sessionSecret?.trim()
288
+ const SESSION_SECRET = providedSessionSecret ?? crypto.randomBytes(32).toString("hex")
289
+ if (!providedSessionSecret) {
290
+ await client.app.log({
291
+ service: "opencode-auth-proxy",
292
+ level: "warn",
293
+ message: "SESSION_SECRET not provided, auto-generating. Sessions will not persist across restarts.",
294
+ })
295
+ }
296
+ const SESSION_COOKIE_NAME = config?.sessionCookieName ?? process.env.SESSION_COOKIE_NAME ?? "opencode_session"
297
+ const TOKEN_PATH =
298
+ config?.tokenPath || process.env.TOKEN_PATH || path.join(os.homedir(), ".opencode-proxy", "google-auth.json")
299
+
300
+ const COOKIE_SECURE = (() => {
301
+ if (config?.cookieSecure !== undefined) return config.cookieSecure
302
+ if (process.env.COOKIE_SECURE !== undefined) return process.env.COOKIE_SECURE !== "false"
303
+ try {
304
+ const redirectUrl = new URL(GOOGLE_REDIRECT_URI)
305
+ return redirectUrl.protocol === "https:"
306
+ } catch {
307
+ return false
308
+ }
309
+ })()
310
+
311
+
312
+ if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
313
+ await client.app.log({
314
+ service: "opencode-auth-proxy",
315
+ level: "error",
316
+ message: "Google OAuth credentials are required (GOOGLE_REDIRECT_CLIENT_ID and GOOGLE_REDIRECT_CLIENT_SECRET)",
317
+ extra: {
318
+ hasClientId: !!GOOGLE_CLIENT_ID,
319
+ hasClientSecret: !!GOOGLE_CLIENT_SECRET,
320
+ hasEnvClientId: !!process.env.GOOGLE_REDIRECT_CLIENT_ID,
321
+ hasEnvClientSecret: !!process.env.GOOGLE_REDIRECT_CLIENT_SECRET,
322
+ },
323
+ })
324
+ return {}
325
+ }
326
+
327
+ const target = `http://${TARGET_HOST}:${TARGET_PORT}`
328
+ const CALLBACK_PATH = getCallbackPath(GOOGLE_REDIRECT_URI)
329
+ const PUBLIC_PATHS = new Set([
330
+ "/health",
331
+ "/global/health",
332
+ "/auth/google/start",
333
+ "/auth/google/config",
334
+ CALLBACK_PATH,
335
+ "/auth/google/callback",
336
+ ])
337
+
338
+ function isPublicPath(pathname: string): boolean {
339
+ return PUBLIC_PATHS.has(pathname)
340
+ }
341
+
342
+ const proxy = httpProxy.createProxyServer({
343
+ target,
344
+ changeOrigin: true,
345
+ ws: true,
346
+ })
347
+
348
+ proxy.on("error", async (err: any, req: IncomingMessage, res: any) => {
349
+ const url = req.url || "/"
350
+ const method = req.method || "UNKNOWN"
351
+ await client.app.log({
352
+ service: "opencode-auth-proxy",
353
+ level: "error",
354
+ message: "Proxy error",
355
+ extra: {
356
+ message: err?.message,
357
+ code: err?.code,
358
+ errno: err?.errno,
359
+ syscall: err?.syscall,
360
+ target,
361
+ url,
362
+ method,
363
+ stack: err?.stack,
364
+ },
365
+ })
366
+ const response = res as ServerResponse | undefined
367
+ if (response && !response.headersSent) {
368
+ response.writeHead(502, { "Content-Type": "application/json" })
369
+ response.end(
370
+ JSON.stringify({
371
+ error: "Proxy error",
372
+ message: err?.message,
373
+ code: err?.code,
374
+ target,
375
+ }),
376
+ )
377
+ }
378
+ })
379
+
380
+ const server = http.createServer(async (req: IncomingMessage, res: ServerResponse) => {
381
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`)
382
+ const pathname = url.pathname || "/"
383
+ const accept = String(req.headers.accept || "")
384
+ const isSSE = accept.includes("text/event-stream") || pathname === "/event" || pathname === "/global/event"
385
+
386
+ const rejectRequest = async () => {
387
+ if (isSSE) {
388
+ await client.app.log({
389
+ service: "opencode-auth-proxy",
390
+ level: "warn",
391
+ message: "Unauthorized request",
392
+ extra: { method: req.method, path: pathname, kind: "sse" },
393
+ })
394
+ res.writeHead(401, { "Content-Type": "application/json" })
395
+ res.write(JSON.stringify({ error: "unauthorized" }))
396
+ } else if (req.method && req.method.toUpperCase() === "GET") {
397
+ await client.app.log({
398
+ service: "opencode-auth-proxy",
399
+ level: "warn",
400
+ message: "Unauthorized request",
401
+ extra: { method: req.method, path: pathname, kind: "redirect" },
402
+ })
403
+ res.writeHead(302, { Location: "/auth/google/start" })
404
+ } else {
405
+ await client.app.log({
406
+ service: "opencode-auth-proxy",
407
+ level: "warn",
408
+ message: "Unauthorized request",
409
+ extra: { method: req.method, path: pathname, kind: "api" },
410
+ })
411
+ res.writeHead(401, { "Content-Type": "application/json" })
412
+ res.write(JSON.stringify({ error: "unauthorized" }))
413
+ }
414
+ res.end()
415
+ }
416
+
417
+ if (!isPublicPath(pathname)) {
418
+ const cookies = parseCookies(req)
419
+ const isAuthenticated = cookies[SESSION_COOKIE_NAME]
420
+ ? verifySession(cookies[SESSION_COOKIE_NAME], SESSION_SECRET)
421
+ : false
422
+ if (!isAuthenticated) {
423
+ await rejectRequest()
424
+ return
425
+ }
426
+ }
427
+
428
+ if (pathname === "/health" || pathname === "/global/health") {
429
+ const upstream = await (async () => {
430
+ try {
431
+ const response = await fetchWithTimeout(`${target}/global/health`, { method: "GET" }, 2000)
432
+ const body = await response
433
+ .json()
434
+ .catch(async () => ({ text: await response.text().catch(() => "") }))
435
+ return { ok: response.ok, status: response.status, body }
436
+ } catch (e) {
437
+ return { ok: false, error: e instanceof Error ? e.message : String(e) }
438
+ }
439
+ })()
440
+ res.writeHead(200, { "Content-Type": "application/json" })
441
+ res.end(JSON.stringify({ status: "ok", healthy: true, target, upstream }))
442
+ return
443
+ }
444
+
445
+ if (pathname === "/auth/google/config") {
446
+ res.writeHead(200, { "Content-Type": "application/json" })
447
+ res.end(
448
+ JSON.stringify({
449
+ redirect_uri: GOOGLE_REDIRECT_URI,
450
+ callback_path: CALLBACK_PATH,
451
+ client_id: GOOGLE_CLIENT_ID.substring(0, 20) + "...",
452
+ client_secret_set: !!GOOGLE_CLIENT_SECRET,
453
+ scopes: GOOGLE_SCOPES,
454
+ }),
455
+ )
456
+ return
457
+ }
458
+
459
+ if (pathname === "/auth/google/start") {
460
+ try {
461
+ const projectId = url.searchParams.get("project") || ""
462
+ const result = await authorizeGoogle(GOOGLE_CLIENT_ID, GOOGLE_REDIRECT_URI, projectId)
463
+ res.writeHead(302, { Location: result.url })
464
+ res.end()
465
+ } catch (err) {
466
+ await client.app.log({
467
+ service: "opencode-auth-proxy",
468
+ level: "error",
469
+ message: "Auth start error",
470
+ extra: { error: err instanceof Error ? err.message : String(err) },
471
+ })
472
+ res.writeHead(500, { "Content-Type": "application/json" })
473
+ res.end(JSON.stringify({ error: (err as Error)?.message || "auth start failed" }))
474
+ }
475
+ return
476
+ }
477
+
478
+ if (pathname === CALLBACK_PATH || pathname === "/auth/google/callback") {
479
+ const code = url.searchParams.get("code")
480
+ const state = url.searchParams.get("state") || ""
481
+ if (!code) {
482
+ res.writeHead(400, { "Content-Type": "application/json" })
483
+ res.end(JSON.stringify({ error: "missing code" }))
484
+ return
485
+ }
486
+ try {
487
+ const result = await exchangeGoogle(
488
+ code,
489
+ state,
490
+ GOOGLE_CLIENT_ID,
491
+ GOOGLE_CLIENT_SECRET,
492
+ GOOGLE_REDIRECT_URI,
493
+ client.app.log.bind(client.app),
494
+ )
495
+ if (result.type !== "success") {
496
+ res.writeHead(500, { "Content-Type": "application/json" })
497
+ res.end(JSON.stringify(result))
498
+ return
499
+ }
500
+ const payload = { ...result, storedAt: new Date().toISOString() }
501
+ persistToken(payload, TOKEN_PATH)
502
+
503
+ const cookie = `${SESSION_COOKIE_NAME}=${signSession(SESSION_SECRET)}; Path=/; HttpOnly; SameSite=Lax${COOKIE_SECURE ? "; Secure" : ""}`
504
+ const redirectTo = url.searchParams.get("redirect")
505
+ const targetPath = redirectTo && redirectTo.startsWith("/") ? redirectTo : "/"
506
+
507
+ res.writeHead(302, { Location: targetPath, "Set-Cookie": cookie })
508
+ res.end()
509
+ } catch (err) {
510
+ await client.app.log({
511
+ service: "opencode-auth-proxy",
512
+ level: "error",
513
+ message: "Auth callback error",
514
+ extra: { error: err instanceof Error ? err.message : String(err) },
515
+ })
516
+ res.writeHead(500, { "Content-Type": "application/json" })
517
+ res.end(JSON.stringify({ error: (err as Error)?.message || "auth callback failed" }))
518
+ }
519
+ return
520
+ }
521
+
522
+ if (url.pathname === "/auth/google/token") {
523
+ const token = loadToken(TOKEN_PATH)
524
+ if (!token) {
525
+ res.writeHead(404, { "Content-Type": "application/json" })
526
+ res.end(JSON.stringify({ error: "token not found" }))
527
+ return
528
+ }
529
+ res.writeHead(200, { "Content-Type": "application/json" })
530
+ res.end(JSON.stringify({ token }))
531
+ return
532
+ }
533
+
534
+ proxy.web(req, res, { target })
535
+ })
536
+
537
+ server.on("upgrade", async (req, socket, head) => {
538
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`)
539
+ if (!isPublicPath(url.pathname || "/")) {
540
+ const cookies = parseCookies(req)
541
+ const isAuthenticated = cookies[SESSION_COOKIE_NAME]
542
+ ? verifySession(cookies[SESSION_COOKIE_NAME], SESSION_SECRET)
543
+ : false
544
+ if (!isAuthenticated) {
545
+ await client.app.log({
546
+ service: "opencode-auth-proxy",
547
+ level: "warn",
548
+ message: "Unauthorized WebSocket upgrade",
549
+ extra: { method: "UPGRADE", path: url.pathname || "/" },
550
+ })
551
+ socket.destroy()
552
+ return
553
+ }
554
+ }
555
+ proxy.ws(req, socket, head)
556
+ })
557
+
558
+ try {
559
+ server.listen(PORT, async () => {
560
+ await client.app.log({
561
+ service: "opencode-auth-proxy",
562
+ level: "info",
563
+ message: `Proxy server started`,
564
+ extra: {
565
+ port: PORT,
566
+ target,
567
+ },
568
+ })
569
+ })
570
+ } catch (error) {
571
+ await client.app.log({
572
+ service: "opencode-auth-proxy",
573
+ level: "error",
574
+ message: "Failed to start proxy server",
575
+ extra: {
576
+ error: error instanceof Error ? error.message : String(error),
577
+ port: PORT,
578
+ },
579
+ })
580
+ }
581
+
582
+ return {
583
+ event: async () => {},
584
+ }
585
+ }
586
+
587
+ export { opencodeAuthProxy }
588
+ export default opencodeAuthProxy
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "opencode-auth-proxy",
3
+ "version": "1.0.0",
4
+ "description": "Google OAuth authentication proxy plugin for OpenCode - provides secure OAuth flow with session management",
5
+ "type": "module",
6
+ "module": "opencode-auth-proxy.ts",
7
+ "main": "opencode-auth-proxy.ts",
8
+ "exports": {
9
+ ".": "./opencode-auth-proxy.ts"
10
+ },
11
+ "files": [
12
+ "opencode-auth-proxy.ts",
13
+ "README.md",
14
+ "LICENSE",
15
+ "CHANGELOG.md"
16
+ ],
17
+ "keywords": [
18
+ "opencode",
19
+ "google",
20
+ "oauth",
21
+ "plugin",
22
+ "auth",
23
+ "proxy",
24
+ "authentication"
25
+ ],
26
+ "author": "milc",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/opencode-ai/opencode-auth-proxy.git"
31
+ },
32
+ "engines": {
33
+ "node": ">=20.0.0"
34
+ },
35
+ "peerDependencies": {
36
+ "typescript": "^5"
37
+ },
38
+ "dependencies": {
39
+ "@opencode-ai/plugin": "1.0.220",
40
+ "http-proxy": "^1.18.1"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^20.0.0"
44
+ }
45
+ }