diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts index 4b49d2a74890..ac216deb3f4b 100644 --- a/packages/opencode/src/account/account.ts +++ b/packages/opencode/src/account/account.ts @@ -396,10 +396,22 @@ const layer: Layer.Layer { + const url = new URL(parsed.verification_uri_complete, `${normalizedServer}/`) + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("expected HTTP(S)") + return url + }, + catch: (cause) => + new AccountServiceError({ + message: `Invalid device verification URL: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }) return new Login({ code: parsed.device_code, user: parsed.user_code, - url: `${normalizedServer}${parsed.verification_uri_complete}`, + url: verification.href, server: normalizedServer, expiry: parsed.expires_in, interval: parsed.interval, diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index 672d54971623..d6e3018e575b 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -10,6 +10,7 @@ import { Account } from "../../src/account/account" import { AccessToken, AccountID, + AccountServiceError, AccountTransportError, DeviceCode, Login, @@ -100,6 +101,93 @@ it.live("login normalizes trailing slashes in the provided server URL", () => }), ) +it.live("login resolves the verification URL against the server origin", () => + Effect.gen(function* () { + const client = HttpClient.make((req) => + Effect.succeed( + json(req, { + device_code: "device-code", + user_code: "user-code", + verification_uri_complete: "/console/device?user_code=user-code", + expires_in: 600, + interval: 5, + }), + ), + ) + + const result = yield* Account.use.login("https://one.example.com/console").pipe(Effect.provide(live(client))) + + expect(result.server).toBe("https://one.example.com/console") + expect(result.url).toBe("https://one.example.com/console/device?user_code=user-code") + }), +) + +it.live("login resolves relative verification URLs beneath the server path", () => + Effect.gen(function* () { + const client = HttpClient.make((req) => + Effect.succeed( + json(req, { + device_code: "device-code", + user_code: "user-code", + verification_uri_complete: "device?user_code=user-code", + expires_in: 600, + interval: 5, + }), + ), + ) + + const result = yield* Account.use.login("https://one.example.com/console").pipe(Effect.provide(live(client))) + + expect(result.url).toBe("https://one.example.com/console/device?user_code=user-code") + }), +) + +it.live("login rejects malformed verification URLs", () => + Effect.gen(function* () { + const client = HttpClient.make((req) => + Effect.succeed( + json(req, { + device_code: "device-code", + user_code: "user-code", + verification_uri_complete: "http://[::1", + expires_in: 600, + interval: 5, + }), + ), + ) + + const error = yield* Effect.flip( + Account.use.login("https://one.example.com/console").pipe(Effect.provide(live(client))), + ) + + expect(error).toBeInstanceOf(AccountServiceError) + expect(error.message).toContain("Invalid device verification URL") + }), +) + +it.live("login rejects non-HTTP verification URLs", () => + Effect.gen(function* () { + const client = HttpClient.make((req) => + Effect.succeed( + json(req, { + device_code: "device-code", + user_code: "user-code", + verification_uri_complete: "file:///tmp/device", + expires_in: 600, + interval: 5, + }), + ), + ) + + const error = yield* Effect.flip( + Account.use.login("https://one.example.com/console").pipe(Effect.provide(live(client))), + ) + + expect(error).toBeInstanceOf(AccountServiceError) + expect(error.message).toContain("Invalid device verification URL: expected HTTP(S)") + }), +) + it.live("login maps transport failures to account transport errors", () => Effect.gen(function* () { const client = HttpClient.make((req) =>