Compare commits

...
2 changed files with 183 additions and 4 deletions
+28 -4
View File
@@ -25,6 +25,7 @@ const Token = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.String,
expires_in: Schema.Number,
org_id: Schema.optional(Schema.NullOr(Schema.String)),
})
const TokenPending = Schema.Struct({ error: Schema.String })
const DeviceToken = Schema.Union([Token, TokenPending])
@@ -42,7 +43,12 @@ function oauth(http: HttpClient.HttpClient) {
authorize: (answer) =>
Effect.gen(function* () {
const server = yield* normalizeServer(answer.server ?? defaultServer)
const device = yield* post(http, `${server}/auth/device/code`, { client_id: clientID }, Device)
const device = yield* post(
http,
`${server}/auth/device/code`,
{ client_id: clientID, supports_org_scope: true },
Device,
)
const verification = yield* Effect.try({
try: () => {
const url = new URL(device.verification_uri_complete, `${server}/`)
@@ -68,11 +74,23 @@ function oauth(http: HttpClient.HttpClient) {
{ grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID },
Token,
)
// Persist rotated tokens without depending on discovery requests.
return {
...credential,
access: token.access_token,
refresh: token.refresh_token,
expires: Date.now() + token.expires_in * 1000,
metadata:
token.org_id == null
? credential.metadata
: {
...credential.metadata,
orgID: token.org_id,
orgName:
credential.metadata?.orgID === token.org_id && typeof credential.metadata.orgName === "string"
? credential.metadata.orgName
: token.org_id,
},
}
}),
label: (credential) => (typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined),
@@ -286,7 +304,13 @@ function credential(http: HttpClient.HttpClient, server: string, token: typeof T
],
{ concurrency: 2 },
)
const org = orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0]
const org =
token.org_id == null
? orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0]
: orgs.find((org) => org.id === token.org_id)
if (token.org_id != null && !org) {
return yield* Effect.fail(new Error(`OpenCode organization not found: ${token.org_id}`))
}
return Credential.OAuth.make({
type: "oauth" as const,
methodID,
@@ -313,13 +337,13 @@ function get<S extends Schema.Top>(http: HttpClient.HttpClient, url: string, tok
function post<S extends Schema.Top>(
http: HttpClient.HttpClient,
url: string,
body: Record<string, string>,
body: Record<string, string | boolean>,
schema: S,
statusOk = true,
) {
return HttpClientRequest.post(url).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.String))(body),
HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Boolean])))(body),
Effect.flatMap((request) => http.execute(request)),
Effect.flatMap((response) => (statusOk ? HttpClientResponse.filterStatusOk(response) : Effect.succeed(response))),
Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
@@ -25,6 +25,48 @@ const addPlugin = Effect.fn(function* () {
yield* OpencodePlugin.effect(host)
})
function consoleServer(orgID: string | null | undefined, unavailable = false) {
const config: { authorization: string | null; orgID: string | null }[] = []
const requests: string[] = []
const server = Bun.serve({
port: 0,
fetch: async (request) => {
const path = new URL(request.url).pathname
requests.push(path)
if (path === "/auth/device/code") {
expect(await request.json()).toEqual({ client_id: "opencode-cli", supports_org_scope: true })
return Response.json({
device_code: "device",
user_code: "user",
verification_uri_complete: "/device?user_code=user",
expires_in: 60,
interval: 0,
})
}
if (path === "/auth/device/token") {
return Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 600, org_id: orgID })
}
if (unavailable && (path === "/api/user" || path === "/api/orgs")) {
return new Response("Unavailable", { status: 503 })
}
if (path === "/api/user") return Response.json({ id: "user", email: "user@example.com" })
if (path === "/api/orgs") {
return Response.json([
{ id: "org-z", name: "Zebra" },
{ id: "org-a", name: "Alpha" },
])
}
if (path === "/api/v2/config") {
config.push({ authorization: request.headers.get("authorization"), orgID: request.headers.get("x-org-id") })
if (orgID === "org-missing") return new Response("Forbidden", { status: 403 })
return Response.json({ providers: {} })
}
return new Response("Not found", { status: 404 })
},
})
return { server, config, requests }
}
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
@@ -161,6 +203,119 @@ describe("OpencodePlugin", () => {
),
)
for (const orgID of ["org-z", undefined, null, "org-missing"]) {
it.live(`uses the device token organization during authorization: ${orgID}`, () =>
Effect.acquireUseRelease(
Effect.sync(() => consoleServer(orgID)),
({ server, config }) =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("opencode")
const attempt = yield* integrations.oauth.connect({
integrationID,
methodID: Integration.MethodID.make("device"),
answer: { server: server.url.origin },
})
const status = yield* eventually(
integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }),
(status) => status.status !== "pending",
)
if (orgID === "org-missing") {
expect(status).toMatchObject({
status: "failed",
message: "OpenCode organization not found: org-missing",
})
expect(yield* credentials.list(integrationID)).toEqual([])
expect(config).toEqual([])
return
}
expect(status.status).toBe("complete")
expect((yield* credentials.list(integrationID))[0]).toMatchObject({
label: orgID === "org-z" ? "Zebra" : "Alpha",
value: {
type: "oauth",
access: "access",
refresh: "refresh",
metadata: {
server: server.url.origin,
accountID: "user",
email: "user@example.com",
orgID: orgID ?? "org-a",
orgName: orgID === "org-z" ? "Zebra" : "Alpha",
},
},
})
yield* eventually(
Effect.sync(() => config.length),
(count) => count > 0,
)
expect(config).toEqual([{ authorization: "Bearer access", orgID: orgID ?? "org-a" }])
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
}
for (const scenario of [
{ orgID: "org-z" },
{ orgID: undefined },
{ orgID: null },
{ orgID: "org-missing" },
{ orgID: "org-z", unavailable: true },
{ orgID: "org-a", unavailable: true },
]) {
it.live(
`persists rotated credentials for ${scenario.orgID}${scenario.unavailable ? " with discovery unavailable" : ""}`,
() =>
Effect.acquireUseRelease(
Effect.sync(() => consoleServer(scenario.orgID, scenario.unavailable)),
({ server, config, requests }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const initial = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
label: "Custom label",
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "expired-access",
refresh: "old-refresh",
expires: 0,
metadata: { server: server.url.origin, orgID: "org-a", orgName: "Alpha", custom: "preserved" },
}),
})
yield* addPlugin()
const stored = required(yield* credentials.get(initial.id))
expect(stored).toMatchObject({
label: "Custom label",
value: {
access: "access",
refresh: "refresh",
metadata: {
server: server.url.origin,
orgID: scenario.orgID ?? "org-a",
orgName: scenario.orgID === "org-a" ? "Alpha" : (scenario.orgID ?? "Alpha"),
custom: "preserved",
},
},
})
if (stored.value.type !== "oauth") throw new Error("Expected OAuth credential")
expect(stored.value.expires).toBeGreaterThan(Date.now())
if (scenario.orgID == null) expect(stored.value.metadata).toEqual(initial.value.metadata)
expect(config).toEqual([{ authorization: "Bearer access", orgID: scenario.orgID ?? "org-a" }])
const integrations = yield* Integration.Service
expect(
yield* integrations.connection.resolve({ type: "credential", id: initial.id, label: initial.label }),
).toEqual(stored.value)
expect(requests).toEqual(["/auth/device/token", "/api/v2/config"])
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
}
it.effect("rejects non-HTTP OpenCode servers", () =>
Effect.gen(function* () {
yield* addPlugin()