11import type { Hooks , PluginInput } from "@opencode-ai/plugin"
22import { OAUTH_DUMMY_KEY } from "../auth"
3- import { createServer } from "http"
43import { InstallationVersion } from "@opencode-ai/core/installation/version"
5- import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
64
7- // Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from
8- // non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships
9- // for desktop OAuth flows. Source of truth: hermes-agent PR #26534.
5+ // Public Grok-CLI OAuth client.
106const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
11- const AUTHORIZE_URL = "https://auth.x.ai/oauth2/authorize"
127const TOKEN_URL = "https://auth.x.ai/oauth2/token"
138// RFC 8628 device authorization grant. Confirmed exposed by xAI's
149// /.well-known/openid-configuration as `device_authorization_endpoint`
@@ -30,51 +25,15 @@ const DEVICE_CODE_SLOW_DOWN_INCREMENT_MS = 5_000
3025const DEVICE_CODE_DEFAULT_EXPIRES_MS = 5 * 60 * 1000
3126const OAUTH_POLLING_SAFETY_MARGIN_MS = 3_000
3227
33- // xAI rejects redirect_uris that don't match what was registered for the
34- // Grok-CLI client. The host:port pair is part of the registration, so we have
35- // to bind the loopback server to this exact port.
36- const OAUTH_HOST = "127.0.0.1"
37- const OAUTH_PORT = 56121
38- const OAUTH_REDIRECT_PATH = "/callback"
39- const REDIRECT_URI = `http://${ OAUTH_HOST } :${ OAUTH_PORT } ${ OAUTH_REDIRECT_PATH } `
40-
4128// Refresh the access token a little before it actually expires so a single
4229// long-running tool call doesn't have to recover from a mid-flight 401.
4330const ACCESS_TOKEN_REFRESH_SKEW_MS = 120_000
4431
4532interface XaiAuthPluginOptions {
46- authorizeUrl ?: string
4733 tokenUrl ?: string
4834 deviceAuthorizationUrl ?: string
4935}
5036
51- interface PkceCodes {
52- verifier : string
53- challenge : string
54- }
55-
56- async function generatePKCE ( ) : Promise < PkceCodes > {
57- const verifier = generateRandomString ( 64 )
58- const hash = await crypto . subtle . digest ( "SHA-256" , new TextEncoder ( ) . encode ( verifier ) )
59- return { verifier, challenge : base64UrlEncode ( hash ) }
60- }
61-
62- function generateRandomString ( length : number ) : string {
63- const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
64- return Array . from ( crypto . getRandomValues ( new Uint8Array ( length ) ) )
65- . map ( ( b ) => chars [ b % chars . length ] )
66- . join ( "" )
67- }
68-
69- function base64UrlEncode ( buffer : ArrayBuffer ) : string {
70- const binary = String . fromCharCode ( ...new Uint8Array ( buffer ) )
71- return btoa ( binary ) . replace ( / \+ / g, "-" ) . replace ( / \/ / g, "_" ) . replace ( / = + $ / , "" )
72- }
73-
74- function generateState ( ) : string {
75- return base64UrlEncode ( crypto . getRandomValues ( new Uint8Array ( 32 ) ) . buffer )
76- }
77-
7837interface TokenResponse {
7938 access_token : string
8039 refresh_token : string
@@ -115,55 +74,6 @@ export function accessTokenIsExpiring(
11574 }
11675}
11776
118- export function buildAuthorizeUrl (
119- pkce : PkceCodes ,
120- state : string ,
121- nonce : string ,
122- options : XaiAuthPluginOptions = { } ,
123- ) : string {
124- // `plan=generic` opts the consent screen into xAI's generic OAuth plan tier;
125- // without it, accounts.x.ai rejects loopback OAuth from non-allowlisted
126- // clients. `referrer=opencode` lets xAI attribute opencode-originated
127- // logins in their OAuth server logs (best-effort attribution while we
128- // continue to reuse the Grok-CLI client_id).
129- const params = new URLSearchParams ( {
130- response_type : "code" ,
131- client_id : CLIENT_ID ,
132- redirect_uri : REDIRECT_URI ,
133- scope : SCOPE ,
134- code_challenge : pkce . challenge ,
135- code_challenge_method : "S256" ,
136- state,
137- nonce,
138- plan : "generic" ,
139- referrer : "opencode" ,
140- } )
141- return `${ options . authorizeUrl ?? AUTHORIZE_URL } ?${ params . toString ( ) } `
142- }
143-
144- async function exchangeCodeForTokens (
145- code : string ,
146- pkce : PkceCodes ,
147- options : XaiAuthPluginOptions = { } ,
148- ) : Promise < TokenResponse > {
149- const response = await fetch ( options . tokenUrl ?? TOKEN_URL , {
150- method : "POST" ,
151- headers : authHeaders ( ) ,
152- body : new URLSearchParams ( {
153- grant_type : "authorization_code" ,
154- code,
155- redirect_uri : REDIRECT_URI ,
156- client_id : CLIENT_ID ,
157- code_verifier : pkce . verifier ,
158- } ) . toString ( ) ,
159- } )
160- if ( ! response . ok ) {
161- const detail = await response . text ( ) . catch ( ( ) => "" )
162- throw new Error ( `xAI token exchange failed (${ response . status } )${ detail ? `: ${ detail } ` : "" } ` )
163- }
164- return response . json ( ) as Promise < TokenResponse >
165- }
166-
16777async function refreshAccessToken ( refreshToken : string , options : XaiAuthPluginOptions = { } ) : Promise < TokenResponse > {
16878 const response = await fetch ( options . tokenUrl ?? TOKEN_URL , {
16979 method : "POST" ,
@@ -202,6 +112,7 @@ export async function requestDeviceCode(options: XaiAuthPluginOptions = {}): Pro
202112 body : new URLSearchParams ( {
203113 client_id : CLIENT_ID ,
204114 scope : SCOPE ,
115+ referrer : "opencode" ,
205116 } ) . toString ( ) ,
206117 } )
207118 if ( ! response . ok ) {
@@ -285,170 +196,6 @@ export async function pollDeviceCodeToken(
285196 throw new Error ( "xAI device authorization timed out" )
286197}
287198
288- // CORS allowlist for the loopback callback. The redirect_uri itself is
289- // already bound to 127.0.0.1 and gated by PKCE+state, so we only accept
290- // xAI's own auth origins for additional defense-in-depth on the OPTIONS
291- // preflight.
292- const CORS_ALLOWED_ORIGINS = new Set ( [ "https://accounts.x.ai" , "https://auth.x.ai" ] )
293-
294- interface PendingOAuth {
295- pkce : PkceCodes
296- state : string
297- resolve : ( tokens : TokenResponse ) => void
298- reject : ( error : Error ) => void
299- }
300-
301- let oauthServer : ReturnType < typeof createServer > | undefined
302- let pendingOAuth : PendingOAuth | undefined
303-
304- async function startOAuthServer ( ) : Promise < { port : number ; redirectUri : string } > {
305- if ( oauthServer ) return { port : OAUTH_PORT , redirectUri : REDIRECT_URI }
306-
307- const server = createServer ( ( req , res ) => {
308- const reqUrl = req . url || "/"
309- const url = new URL ( reqUrl , `http://${ OAUTH_HOST } :${ OAUTH_PORT } ` )
310-
311- const origin = req . headers [ "origin" ]
312- const allowOrigin = typeof origin === "string" && CORS_ALLOWED_ORIGINS . has ( origin ) ? origin : ""
313- if ( allowOrigin ) {
314- res . setHeader ( "Access-Control-Allow-Origin" , allowOrigin )
315- res . setHeader ( "Access-Control-Allow-Methods" , "GET, OPTIONS" )
316- res . setHeader ( "Access-Control-Allow-Headers" , "Content-Type" )
317- res . setHeader ( "Access-Control-Allow-Private-Network" , "true" )
318- res . setHeader ( "Vary" , "Origin" )
319- }
320-
321- if ( req . method === "OPTIONS" ) {
322- res . writeHead ( 204 )
323- res . end ( )
324- return
325- }
326-
327- if ( url . pathname === OAUTH_REDIRECT_PATH ) {
328- const code = url . searchParams . get ( "code" )
329- const state = url . searchParams . get ( "state" )
330- const error = url . searchParams . get ( "error" )
331- const errorDescription = url . searchParams . get ( "error_description" )
332-
333- if ( error ) {
334- const errorMsg = errorDescription || error
335- pendingOAuth ?. reject ( new Error ( errorMsg ) )
336- pendingOAuth = undefined
337- res . writeHead ( 200 , { "Content-Type" : "text/html" } )
338- res . end ( OauthCallbackPage . error ( errorMsg , { provider : "xAI" } ) )
339- return
340- }
341-
342- if ( ! code ) {
343- const errorMsg = "Missing authorization code"
344- pendingOAuth ?. reject ( new Error ( errorMsg ) )
345- pendingOAuth = undefined
346- res . writeHead ( 400 , { "Content-Type" : "text/html" } )
347- res . end ( OauthCallbackPage . error ( errorMsg , { provider : "xAI" } ) )
348- return
349- }
350-
351- if ( ! pendingOAuth || state !== pendingOAuth . state ) {
352- const errorMsg = "Invalid state - potential CSRF attack"
353- pendingOAuth ?. reject ( new Error ( errorMsg ) )
354- pendingOAuth = undefined
355- res . writeHead ( 400 , { "Content-Type" : "text/html" } )
356- res . end ( OauthCallbackPage . error ( errorMsg , { provider : "xAI" } ) )
357- return
358- }
359-
360- const current = pendingOAuth
361- pendingOAuth = undefined
362-
363- exchangeCodeForTokens ( code , current . pkce )
364- . then ( ( tokens ) => current . resolve ( tokens ) )
365- . catch ( ( err ) => current . reject ( err ) )
366-
367- res . writeHead ( 200 , { "Content-Type" : "text/html" } )
368- res . end ( OauthCallbackPage . success ( { provider : "xAI" } ) )
369- return
370- }
371-
372- if ( url . pathname === "/cancel" ) {
373- pendingOAuth ?. reject ( new Error ( "Login cancelled" ) )
374- pendingOAuth = undefined
375- res . writeHead ( 200 )
376- res . end ( "Login cancelled" )
377- return
378- }
379-
380- res . writeHead ( 404 )
381- res . end ( "Not found" )
382- } )
383-
384- // listen() failures (e.g. EADDRINUSE because Grok-CLI is bound to the same
385- // pinned port) must clear `oauthServer` and remove our error listener,
386- // otherwise the next startOAuthServer() short-circuits on the truthy check
387- // and returns a redirect_uri pointing at nothing.
388- await new Promise < void > ( ( resolve , reject ) => {
389- const onError = ( err : Error ) => {
390- oauthServer = undefined
391- reject ( err )
392- }
393- server . once ( "error" , onError )
394- server . listen ( OAUTH_PORT , OAUTH_HOST , ( ) => {
395- server . removeListener ( "error" , onError )
396- // After listen() succeeds, install a permanent log-only listener so
397- // that subsequent server errors (e.g. accept() failures, socket-level
398- // errors) don't trip Node's default "unhandled error event = throw"
399- // behavior and crash the entire opencode process. Matches the silent-
400- // swallow behavior the Codex plugin gets from its permanent
401- // `oauthServer!.on("error", reject)`.
402- resolve ( )
403- } )
404- oauthServer = server
405- } )
406-
407- return { port : OAUTH_PORT , redirectUri : REDIRECT_URI }
408- }
409-
410- function stopOAuthServer ( ) {
411- if ( oauthServer ) {
412- oauthServer . close ( )
413- oauthServer = undefined
414- }
415- }
416-
417- function waitForOAuthCallback ( pkce : PkceCodes , state : string ) : Promise < TokenResponse > {
418- // A previous in-flight authorize() that the user abandoned (or that is
419- // being superseded by a fresh attempt) still owns `pendingOAuth`. Reject
420- // it eagerly so its caller stops waiting on a state value that can never
421- // match the next callback.
422- if ( pendingOAuth ) {
423- pendingOAuth . reject ( new Error ( "Superseded by a newer xAI authorize request" ) )
424- pendingOAuth = undefined
425- }
426- return new Promise ( ( resolve , reject ) => {
427- const timeout = setTimeout (
428- ( ) => {
429- if ( pendingOAuth ) {
430- pendingOAuth = undefined
431- reject ( new Error ( "OAuth callback timeout - authorization took too long" ) )
432- }
433- } ,
434- 5 * 60 * 1000 ,
435- )
436-
437- pendingOAuth = {
438- pkce,
439- state,
440- resolve : ( tokens ) => {
441- clearTimeout ( timeout )
442- resolve ( tokens )
443- } ,
444- reject : ( error ) => {
445- clearTimeout ( timeout )
446- reject ( error )
447- } ,
448- }
449- } )
450- }
451-
452199interface RefreshResult {
453200 access : string
454201 refresh : string
@@ -548,40 +295,6 @@ export async function XaiAuthPlugin(input: PluginInput, options: XaiAuthPluginOp
548295 }
549296 } ,
550297 methods : [
551- {
552- label : "xAI Grok OAuth (SuperGrok Subscription)" ,
553- type : "oauth" ,
554- authorize : async ( ) => {
555- await startOAuthServer ( )
556- const pkce = await generatePKCE ( )
557- const state = generateState ( )
558- const nonce = generateState ( )
559- const authUrl = buildAuthorizeUrl ( pkce , state , nonce , options )
560-
561- const callbackPromise = waitForOAuthCallback ( pkce , state )
562-
563- return {
564- url : authUrl ,
565- instructions : "Complete authorization in your browser. This window will close automatically." ,
566- method : "auto" as const ,
567- callback : async ( ) => {
568- try {
569- const tokens = await callbackPromise
570- return {
571- type : "success" as const ,
572- refresh : tokens . refresh_token ,
573- access : tokens . access_token ,
574- expires : Date . now ( ) + ( tokens . expires_in ?? 3600 ) * 1000 ,
575- }
576- } catch ( err ) {
577- return { type : "failed" as const }
578- } finally {
579- stopOAuthServer ( )
580- }
581- } ,
582- }
583- } ,
584- } ,
585298 {
586299 // RFC 8628 device-code flow. The CLI prints a verification URL
587300 // and a short user_code that the user enters in a browser on
@@ -591,7 +304,7 @@ export async function XaiAuthPlugin(input: PluginInput, options: XaiAuthPluginOp
591304 // user's browser. Defends the only attack surface (the polling
592305 // loop) with the standard authorization_pending / slow_down
593306 // backoff and a hard deadline from xAI's `expires_in`.
594- label : "xAI Grok OAuth (Headless / Remote / VPS) " ,
307+ label : "SuperGrok Subscription " ,
595308 type : "oauth" ,
596309 authorize : async ( ) => {
597310 const device = await requestDeviceCode ( options )
0 commit comments