Skip to content

Commit 7e7ac7c

Browse files
saravmajesticclaude
andcommitted
feat: [AI-7520] exchange the login_token server-side (no api_key in loopback URL)
The loopback now receives a short-lived `token` (login_token) instead of the raw key; callback() exchanges it via POST /auth/social/exchange (AltimateApi. exchangeSocialToken) for the auth_token and saves that. Single code path for both the Google and email/password connect flows. State validation, loopback bind, HTML escaping, and the pending-Map/one-time-server logic are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f5596b4 commit 7e7ac7c

2 files changed

Lines changed: 40 additions & 6 deletions

File tree

packages/opencode/src/altimate/api/client.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,35 @@ export namespace AltimateApi {
196196
}
197197
}
198198

199+
/**
200+
* Exchange a short-lived social `login_token` for the user's gateway
201+
* `auth_token` via POST {altimateUrl}/auth/social/exchange. The token is
202+
* one-time and short-lived, which keeps the raw api_key out of the loopback
203+
* callback URL. Throws on a non-ok response or a missing `auth_token`.
204+
*/
205+
export async function exchangeSocialToken(altimateUrl: string, instance: string, token: string): Promise<string> {
206+
const url = `${altimateUrl.replace(/\/+$/, "")}/auth/social/exchange`
207+
// upstream_fix parity: bound the request so a network stall can't hang the callback.
208+
const controller = new AbortController()
209+
const timeout = setTimeout(() => controller.abort(), 15_000)
210+
const res = await fetch(url, {
211+
method: "POST",
212+
headers: {
213+
"x-tenant": instance,
214+
"Content-Type": "application/json",
215+
},
216+
body: JSON.stringify({ token }),
217+
signal: controller.signal,
218+
}).finally(() => clearTimeout(timeout))
219+
if (!res.ok) {
220+
const body = await res.text().catch(() => "")
221+
throw new Error(`Social token exchange failed (${res.status} ${res.statusText}) ${body}`)
222+
}
223+
const data = (await res.json()) as { auth_token?: string }
224+
if (!data.auth_token) throw new Error("Social token exchange did not return an auth_token")
225+
return data.auth_token
226+
}
227+
199228
async function request(creds: AltimateCredentials, method: string, endpoint: string, body?: unknown) {
200229
const url = `${creds.altimateUrl}${endpoint}`
201230
const res = await fetch(url, {

packages/opencode/src/altimate/plugin/altimate.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ const HTML_ERROR = (msg: string) => `<!doctype html><meta charset="utf-8"><title
3636
interface CallbackResult {
3737
api_url: string
3838
instance: string
39-
api_key: string
39+
// Short-lived, one-time login_token delivered by the browser. Exchanged for
40+
// the gateway auth_token in callback() — the raw api_key never rides in the URL.
41+
token: string
4042
}
4143

4244
interface Pending {
@@ -83,17 +85,17 @@ async function startCallbackServer(): Promise<void> {
8385
return
8486
}
8587

86-
const apiKey = url.searchParams.get("key")
88+
const token = url.searchParams.get("token")
8789
const instance = url.searchParams.get("instance")
8890
const apiUrl = url.searchParams.get("url") || DEFAULT_API_URL
89-
if (!apiKey || !instance) {
91+
if (!token || !instance) {
9092
const msg = "Missing credential in callback"
9193
entry.reject(new Error(msg))
9294
html(400, HTML_ERROR(msg))
9395
return
9496
}
9597

96-
entry.resolve({ api_url: apiUrl, instance, api_key: apiKey })
98+
entry.resolve({ api_url: apiUrl, instance, token })
9799
html(200, HTML_SUCCESS)
98100
})
99101

@@ -183,15 +185,18 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise<Hooks> {
183185
if (!AltimateApi.isValidInstanceName(creds.instance)) {
184186
throw new Error(`invalid instance name from callback: ${creds.instance}`)
185187
}
188+
// Exchange the short-lived, one-time login_token for the gateway
189+
// auth_token server-side — the raw api_key never rides in the URL.
190+
const authToken = await AltimateApi.exchangeSocialToken(creds.api_url, creds.instance, creds.token)
186191
// Persist to ~/.altimate/altimate.json — the provider loader
187192
// reads this first (it carries the instance/tenant + api_url
188193
// the generic auth.json store can't).
189194
await AltimateApi.saveCredentials({
190195
altimateUrl: creds.api_url,
191196
altimateInstanceName: creds.instance,
192-
altimateApiKey: creds.api_key,
197+
altimateApiKey: authToken,
193198
})
194-
return { type: "success", key: creds.api_key, provider: "altimate-backend" }
199+
return { type: "success", key: authToken, provider: "altimate-backend" }
195200
} catch (err) {
196201
// Log the reason (CSRF / timeout / invalid instance / …). Runs in the
197202
// server process, so this goes to the log, not the TUI display.

0 commit comments

Comments
 (0)