feat(antigravity-auth): separate google-auth module with dynamic port allocation

- Separate Google Antigravity auth to 'oh-my-opencode/google-auth' subpath
- 'oh-my-opencode/auth' now exports OpenAI Codex auth plugin
- Implement dynamic port allocation to avoid port conflicts
- Add userAgent, requestId, sessionId fields for Antigravity API compatibility
- Add debug logging for troubleshooting (ANTIGRAVITY_DEBUG=1)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2025-12-13 00:30:17 +09:00
parent 16393b2554
commit 0bf853d9ef
7 changed files with 180 additions and 177 deletions

View File

@@ -17,10 +17,14 @@
"types": "./dist/auth.d.ts", "types": "./dist/auth.d.ts",
"import": "./dist/auth.js" "import": "./dist/auth.js"
}, },
"./google-auth": {
"types": "./dist/google-auth.d.ts",
"import": "./dist/google-auth.js"
},
"./schema.json": "./dist/oh-my-opencode.schema.json" "./schema.json": "./dist/oh-my-opencode.schema.json"
}, },
"scripts": { "scripts": {
"build": "bun build src/index.ts src/auth.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun run build:schema", "build": "bun build src/index.ts src/auth.ts src/google-auth.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun run build:schema",
"build:schema": "bun run script/build-schema.ts", "build:schema": "bun run script/build-schema.ts",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"prepublishOnly": "bun run clean && bun run build", "prepublishOnly": "bun run clean && bun run build",

View File

@@ -1 +1,11 @@
export { createGoogleAntigravityAuthPlugin as default } from "./auth/antigravity" /**
* OpenAI Codex Auth Plugin re-export
*
* This module re-exports the OpenAI Codex OAuth authentication plugin,
* enabling users to authenticate with their ChatGPT Plus/Pro subscription.
*
* For Google Antigravity auth, use `oh-my-opencode/google-auth` instead.
*
* @see https://github.com/numman-ali/opencode-openai-codex-auth
*/
export { OpenAIAuthPlugin as default } from "opencode-openai-codex-auth"

View File

@@ -117,16 +117,10 @@ export function decodeState(encoded: string): OAuthState {
} }
} }
/**
* Build the OAuth authorization URL with PKCE.
*
* @param projectId - Optional GCP project ID to include in state
* @param clientId - Optional custom client ID (defaults to ANTIGRAVITY_CLIENT_ID)
* @returns Authorization result with URL and verifier
*/
export async function buildAuthURL( export async function buildAuthURL(
projectId?: string, projectId?: string,
clientId: string = ANTIGRAVITY_CLIENT_ID clientId: string = ANTIGRAVITY_CLIENT_ID,
port: number = ANTIGRAVITY_CALLBACK_PORT
): Promise<AuthorizationResult> { ): Promise<AuthorizationResult> {
const pkce = await generatePKCEPair() const pkce = await generatePKCEPair()
@@ -135,9 +129,11 @@ export async function buildAuthURL(
projectId, projectId,
} }
const redirectUri = `http://localhost:${port}/oauth-callback`
const url = new URL(GOOGLE_AUTH_URL) const url = new URL(GOOGLE_AUTH_URL)
url.searchParams.set("client_id", clientId) url.searchParams.set("client_id", clientId)
url.searchParams.set("redirect_uri", ANTIGRAVITY_REDIRECT_URI) url.searchParams.set("redirect_uri", redirectUri)
url.searchParams.set("response_type", "code") url.searchParams.set("response_type", "code")
url.searchParams.set("scope", ANTIGRAVITY_SCOPES.join(" ")) url.searchParams.set("scope", ANTIGRAVITY_SCOPES.join(" "))
url.searchParams.set("state", encodeState(state)) url.searchParams.set("state", encodeState(state))
@@ -165,14 +161,16 @@ export async function exchangeCode(
code: string, code: string,
verifier: string, verifier: string,
clientId: string = ANTIGRAVITY_CLIENT_ID, clientId: string = ANTIGRAVITY_CLIENT_ID,
clientSecret: string = ANTIGRAVITY_CLIENT_SECRET clientSecret: string = ANTIGRAVITY_CLIENT_SECRET,
port: number = ANTIGRAVITY_CALLBACK_PORT
): Promise<AntigravityTokenExchangeResult> { ): Promise<AntigravityTokenExchangeResult> {
const redirectUri = `http://localhost:${port}/oauth-callback`
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: clientId, client_id: clientId,
client_secret: clientSecret, client_secret: clientSecret,
code, code,
grant_type: "authorization_code", grant_type: "authorization_code",
redirect_uri: ANTIGRAVITY_REDIRECT_URI, redirect_uri: redirectUri,
code_verifier: verifier, code_verifier: verifier,
}) })
@@ -236,97 +234,88 @@ export async function fetchUserInfo(
} }
} }
/** export interface CallbackServerHandle {
* Start a local HTTP server to receive OAuth callback. port: number
* waitForCallback: () => Promise<CallbackResult>
* @param timeoutMs - Timeout in milliseconds (default: 5 minutes) close: () => void
* @returns Promise that resolves with callback result }
*/
export function startCallbackServer( export function startCallbackServer(
timeoutMs: number = 5 * 60 * 1000 timeoutMs: number = 5 * 60 * 1000
): Promise<CallbackResult> { ): CallbackServerHandle {
return new Promise((resolve, reject) => { let server: ReturnType<typeof Bun.serve> | null = null
let server: ReturnType<typeof Bun.serve> | null = null let timeoutId: ReturnType<typeof setTimeout> | null = null
let timeoutId: ReturnType<typeof setTimeout> | null = null let resolveCallback: ((result: CallbackResult) => void) | null = null
let rejectCallback: ((error: Error) => void) | null = null
const cleanup = () => {
if (timeoutId) { const cleanup = () => {
clearTimeout(timeoutId) if (timeoutId) {
timeoutId = null clearTimeout(timeoutId)
} timeoutId = null
if (server) { }
server.stop() if (server) {
server = null server.stop()
} server = null
} }
}
// Set timeout
timeoutId = setTimeout(() => { server = Bun.serve({
cleanup() port: 0,
reject(new Error("OAuth callback timeout")) fetch(request: Request): Response {
}, timeoutMs) const url = new URL(request.url)
try { if (url.pathname === "/oauth-callback") {
server = Bun.serve({ const code = url.searchParams.get("code") || ""
port: ANTIGRAVITY_CALLBACK_PORT, const state = url.searchParams.get("state") || ""
fetch(request: Request): Response { const error = url.searchParams.get("error") || undefined
const url = new URL(request.url)
let responseBody: string
if (url.pathname === "/oauth-callback") { if (code && !error) {
const code = url.searchParams.get("code") || "" responseBody =
const state = url.searchParams.get("state") || "" "<html><body><h1>Login successful</h1><p>You can close this window.</p></body></html>"
const error = url.searchParams.get("error") || undefined } else {
responseBody =
// Respond to browser "<html><body><h1>Login failed</h1><p>Please check the CLI output.</p></body></html>"
let responseBody: string }
if (code && !error) {
responseBody = setTimeout(() => {
"<html><body><h1>Login successful</h1><p>You can close this window.</p></body></html>" cleanup()
} else { if (resolveCallback) {
responseBody = resolveCallback({ code, state, error })
"<html><body><h1>Login failed</h1><p>Please check the CLI output.</p></body></html>" }
} }, 100)
// Schedule cleanup and resolve return new Response(responseBody, {
setTimeout(() => { status: 200,
cleanup() headers: { "Content-Type": "text/html" },
resolve({ code, state, error }) })
}, 100) }
return new Response(responseBody, { return new Response("Not Found", { status: 404 })
status: 200, },
headers: { "Content-Type": "text/html" }, })
})
} const actualPort = server.port as number
return new Response("Not Found", { status: 404 }) const waitForCallback = (): Promise<CallbackResult> => {
}, return new Promise((resolve, reject) => {
}) resolveCallback = resolve
} catch (err) { rejectCallback = reject
cleanup()
reject( timeoutId = setTimeout(() => {
new Error( cleanup()
`Failed to start callback server: ${err instanceof Error ? err.message : String(err)}` reject(new Error("OAuth callback timeout"))
) }, timeoutMs)
) })
} }
})
return {
port: actualPort,
waitForCallback,
close: cleanup,
}
} }
/**
* Perform complete OAuth flow:
* 1. Start callback server
* 2. Build auth URL
* 3. Wait for callback
* 4. Exchange code for tokens
* 5. Fetch user info
*
* @param projectId - Optional GCP project ID
* @param openBrowser - Function to open URL in browser
* @param clientId - Optional custom client ID (defaults to ANTIGRAVITY_CLIENT_ID)
* @param clientSecret - Optional custom client secret (defaults to ANTIGRAVITY_CLIENT_SECRET)
* @returns Object with tokens and user info
*/
export async function performOAuthFlow( export async function performOAuthFlow(
projectId?: string, projectId?: string,
openBrowser?: (url: string) => Promise<void>, openBrowser?: (url: string) => Promise<void>,
@@ -337,43 +326,36 @@ export async function performOAuthFlow(
userInfo: AntigravityUserInfo userInfo: AntigravityUserInfo
verifier: string verifier: string
}> { }> {
// Build auth URL first to get the verifier const serverHandle = startCallbackServer()
const auth = await buildAuthURL(projectId, clientId)
// Start callback server try {
const callbackPromise = startCallbackServer() const auth = await buildAuthURL(projectId, clientId, serverHandle.port)
// Open browser (caller provides implementation) if (openBrowser) {
if (openBrowser) { await openBrowser(auth.url)
await openBrowser(auth.url) }
}
// Wait for callback const callback = await serverHandle.waitForCallback()
const callback = await callbackPromise
if (callback.error) { if (callback.error) {
throw new Error(`OAuth error: ${callback.error}`) throw new Error(`OAuth error: ${callback.error}`)
} }
if (!callback.code) { if (!callback.code) {
throw new Error("No authorization code received") throw new Error("No authorization code received")
} }
// Verify state and extract verifier const state = decodeState(callback.state)
const state = decodeState(callback.state) if (state.verifier !== auth.verifier) {
if (state.verifier !== auth.verifier) { throw new Error("PKCE verifier mismatch - possible CSRF attack")
throw new Error("PKCE verifier mismatch - possible CSRF attack") }
}
// Exchange code for tokens const tokens = await exchangeCode(callback.code, auth.verifier, clientId, clientSecret, serverHandle.port)
const tokens = await exchangeCode(callback.code, auth.verifier, clientId, clientSecret) const userInfo = await fetchUserInfo(tokens.access_token)
// Fetch user info return { tokens, userInfo, verifier: auth.verifier }
const userInfo = await fetchUserInfo(tokens.access_token) } catch (err) {
serverHandle.close()
return { throw err
tokens,
userInfo,
verifier: auth.verifier,
} }
} }

View File

@@ -99,13 +99,25 @@ export async function createGoogleAntigravityAuthPlugin({
auth: () => Promise<Auth>, auth: () => Promise<Auth>,
provider: Provider provider: Provider
): Promise<Record<string, unknown>> => { ): Promise<Record<string, unknown>> => {
// Check if current auth is OAuth type
const currentAuth = await auth() const currentAuth = await auth()
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.log("[antigravity-plugin] loader called")
console.log("[antigravity-plugin] auth type:", currentAuth?.type)
console.log("[antigravity-plugin] auth keys:", Object.keys(currentAuth || {}))
}
if (!isOAuthAuth(currentAuth)) { if (!isOAuthAuth(currentAuth)) {
// Not OAuth auth, return empty (fallback to default fetch) if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.log("[antigravity-plugin] NOT OAuth auth, returning empty")
}
return {} return {}
} }
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.log("[antigravity-plugin] OAuth auth detected, creating custom fetch")
}
cachedClientId = cachedClientId =
(provider.options?.clientId as string) || ANTIGRAVITY_CLIENT_ID (provider.options?.clientId as string) || ANTIGRAVITY_CLIENT_ID
cachedClientSecret = cachedClientSecret =
@@ -167,6 +179,7 @@ export async function createGoogleAntigravityAuthPlugin({
return { return {
fetch: antigravityFetch, fetch: antigravityFetch,
apiKey: "antigravity-oauth",
} }
}, },
@@ -188,10 +201,8 @@ export async function createGoogleAntigravityAuthPlugin({
* @returns Authorization result with URL and callback * @returns Authorization result with URL and callback
*/ */
authorize: async (): Promise<AuthOuathResult> => { authorize: async (): Promise<AuthOuathResult> => {
const { url, verifier } = await buildAuthURL(undefined, cachedClientId) const serverHandle = startCallbackServer()
const { url, verifier } = await buildAuthURL(undefined, cachedClientId, serverHandle.port)
// Start local callback server to receive OAuth callback
const callbackPromise = startCallbackServer()
return { return {
url, url,
@@ -199,35 +210,24 @@ export async function createGoogleAntigravityAuthPlugin({
"Complete the sign-in in your browser. We'll automatically detect when you're done.", "Complete the sign-in in your browser. We'll automatically detect when you're done.",
method: "auto", method: "auto",
/**
* Callback function invoked when OAuth redirect is received.
* Exchanges code for tokens and fetches project context.
*/
callback: async () => { callback: async () => {
try { try {
// Wait for OAuth callback const result = await serverHandle.waitForCallback()
const result = await callbackPromise
// Check for errors
if (result.error) { if (result.error) {
if (process.env.ANTIGRAVITY_DEBUG === "1") { if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.error( console.error(`[antigravity-plugin] OAuth error: ${result.error}`)
`[antigravity-plugin] OAuth error: ${result.error}`
)
} }
return { type: "failed" as const } return { type: "failed" as const }
} }
if (!result.code) { if (!result.code) {
if (process.env.ANTIGRAVITY_DEBUG === "1") { if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.error( console.error("[antigravity-plugin] No authorization code received")
"[antigravity-plugin] No authorization code received"
)
} }
return { type: "failed" as const } return { type: "failed" as const }
} }
// Verify state and extract original verifier
const state = decodeState(result.state) const state = decodeState(result.state)
if (state.verifier !== verifier) { if (state.verifier !== verifier) {
if (process.env.ANTIGRAVITY_DEBUG === "1") { if (process.env.ANTIGRAVITY_DEBUG === "1") {
@@ -236,27 +236,19 @@ export async function createGoogleAntigravityAuthPlugin({
return { type: "failed" as const } return { type: "failed" as const }
} }
const tokens = await exchangeCode(result.code, verifier, cachedClientId, cachedClientSecret) const tokens = await exchangeCode(result.code, verifier, cachedClientId, cachedClientSecret, serverHandle.port)
// Fetch user info (optional, for logging)
try { try {
const userInfo = await fetchUserInfo(tokens.access_token) const userInfo = await fetchUserInfo(tokens.access_token)
if (process.env.ANTIGRAVITY_DEBUG === "1") { if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.log( console.log(`[antigravity-plugin] Authenticated as: ${userInfo.email}`)
`[antigravity-plugin] Authenticated as: ${userInfo.email}`
)
} }
} catch { } catch {
// User info is optional, continue without it // User info is optional
} }
// Fetch project context for Antigravity API const projectContext = await fetchProjectContext(tokens.access_token)
const projectContext = await fetchProjectContext(
tokens.access_token
)
// Format refresh token with project info for storage
// Format: refreshToken|projectId|managedProjectId
const formattedRefresh = formatTokenForStorage( const formattedRefresh = formatTokenForStorage(
tokens.refresh_token, tokens.refresh_token,
projectContext.cloudaicompanionProject || "", projectContext.cloudaicompanionProject || "",
@@ -270,6 +262,7 @@ export async function createGoogleAntigravityAuthPlugin({
expires: Date.now() + tokens.expires_in * 1000, expires: Date.now() + tokens.expires_in * 1000,
} }
} catch (error) { } catch (error) {
serverHandle.close()
if (process.env.ANTIGRAVITY_DEBUG === "1") { if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.error( console.error(
`[antigravity-plugin] OAuth flow failed: ${ `[antigravity-plugin] OAuth flow failed: ${

View File

@@ -128,30 +128,32 @@ export function getDefaultEndpoint(): string {
return ANTIGRAVITY_ENDPOINT_FALLBACKS[0] return ANTIGRAVITY_ENDPOINT_FALLBACKS[0]
} }
/** function generateRequestId(): string {
* Wrap a request body in Antigravity format. return `agent-${crypto.randomUUID()}`
* Creates a new object without modifying the original. }
*
* @param body - Original request payload function generateSessionId(): string {
* @param projectId - GCP project ID const n = Math.floor(Math.random() * 9_000_000_000_000_000_000)
* @param modelName - Model identifier return `-${n}`
* @returns Wrapped request body in Antigravity format }
*/
export function wrapRequestBody( export function wrapRequestBody(
body: Record<string, unknown>, body: Record<string, unknown>,
projectId: string, projectId: string,
modelName: string modelName: string
): AntigravityRequestBody { ): AntigravityRequestBody {
// Clone the body to avoid mutation
const requestPayload = { ...body } const requestPayload = { ...body }
// Remove model from inner request (it's in wrapper)
delete requestPayload.model delete requestPayload.model
return { return {
project: projectId, project: projectId,
model: modelName, model: modelName,
request: requestPayload, userAgent: "antigravity",
requestId: generateRequestId(),
request: {
...requestPayload,
sessionId: generateSessionId(),
},
} }
} }

View File

@@ -73,6 +73,10 @@ export interface AntigravityRequestBody {
project: string project: string
/** Model identifier (e.g., "gemini-3-pro-preview") */ /** Model identifier (e.g., "gemini-3-pro-preview") */
model: string model: string
/** User agent identifier */
userAgent: string
/** Unique request ID */
requestId: string
/** The actual request payload */ /** The actual request payload */
request: Record<string, unknown> request: Record<string, unknown>
} }

8
src/google-auth.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { Plugin } from "@opencode-ai/plugin"
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity"
const GoogleAntigravityAuthPlugin: Plugin = async (ctx) => {
return createGoogleAntigravityAuthPlugin(ctx)
}
export default GoogleAntigravityAuthPlugin