Skip to content

Commit 0c1bf08

Browse files
authored
feat(core): add Poe browser OAuth (#47883)
1 parent 8b09f64 commit 0c1bf08

3 files changed

Lines changed: 499 additions & 0 deletions

File tree

packages/core/src/plugin/provider.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { OpenAICompatiblePlugin } from "./provider/openai-compatible.js"
2525
import { OpencodePlugin } from "./provider/opencode.js"
2626
import { OpenRouterPlugin } from "./provider/openrouter.js"
2727
import { PerplexityPlugin } from "./provider/perplexity.js"
28+
import { PoePlugin } from "./provider/poe.js"
2829
import { SapAICorePlugin } from "./provider/sap-ai-core.js"
2930
import { VercelPlugin } from "./provider/vercel.js"
3031
import { VenicePlugin } from "./provider/venice.js"
@@ -60,6 +61,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
6061
OpenAIPlugin,
6162
OpenRouterPlugin,
6263
PerplexityPlugin,
64+
PoePlugin,
6365
SapAICorePlugin,
6466
VercelPlugin,
6567
VenicePlugin,
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { define } from "@opencode/plugin/effect/plugin"
2+
import { Clock, Deferred, Effect, Option, Schema } from "effect"
3+
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
4+
import type { ServerResponse } from "node:http"
5+
import { Credential } from "../../credential.js"
6+
import { Integration } from "../../integration.js"
7+
import { OauthCallbackPage } from "../../oauth/page.js"
8+
9+
const integrationID = Integration.ID.make("poe")
10+
const methodID = Integration.MethodID.make("browser")
11+
const clientID = "client_728290227fc048cc9262091a1ea197ea"
12+
const issuer = "https://poe.com"
13+
const maxExpiry = 8_640_000_000_000_000
14+
const Token = Schema.Struct({
15+
api_key: Schema.Trim.check(Schema.isNonEmpty(), Schema.isPattern(/^\S+$/)),
16+
api_key_expires_in: Schema.optional(Schema.NullOr(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)))),
17+
})
18+
const decodeError = Schema.decodeUnknownOption(
19+
Schema.fromJsonString(
20+
Schema.Struct({ error: Schema.optional(Schema.String), error_description: Schema.optional(Schema.String) }),
21+
),
22+
)
23+
24+
export const PoePlugin = define({
25+
id: "opencode.provider.poe",
26+
effect: Effect.fn(function* (ctx) {
27+
const http = yield* HttpClient.HttpClient
28+
yield* ctx.integration.transform((editor) => {
29+
editor.method.update({
30+
integrationID,
31+
method: { id: methodID, type: "oauth", label: "Login with Poe (browser)" },
32+
// Poe-issued API keys remain usable until expiry, then require another login.
33+
refresh: (value) =>
34+
Clock.currentTimeMillis.pipe(
35+
Effect.flatMap((now) =>
36+
value.expires > now
37+
? Effect.succeed(value)
38+
: Effect.fail(new Error("Poe API key expired. Log in with Poe again.")),
39+
),
40+
),
41+
authorize: () =>
42+
Effect.gen(function* () {
43+
const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
44+
const challenge = Buffer.from(
45+
yield* Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))),
46+
).toString("base64url")
47+
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
48+
const callback = yield* Deferred.make<{ code: string; response: ServerResponse }, Error>()
49+
const { createServer } = yield* Effect.promise(() => import("node:http"))
50+
const { EventEmitter } = yield* Effect.promise(() => import("node:events"))
51+
const server = createServer((request, response) => {
52+
const url = new URL(request.url ?? "/", "http://127.0.0.1")
53+
if (request.method !== "GET" || url.pathname !== "/callback") {
54+
response.writeHead(404).end()
55+
return
56+
}
57+
const error = callbackError(url.searchParams, state)
58+
if (error) {
59+
response
60+
.writeHead(400, { "Content-Type": "text/html" })
61+
.end(OauthCallbackPage.error(error, { provider: "Poe" }))
62+
Effect.runSync(Deferred.fail(callback, new Error(error)))
63+
return
64+
}
65+
if (!Effect.runSync(Deferred.succeed(callback, { code: url.searchParams.get("code") ?? "", response })))
66+
response.writeHead(409).end("OAuth callback already received")
67+
})
68+
yield* Effect.addFinalizer(() =>
69+
Effect.sync(() => {
70+
server.close()
71+
server.closeAllConnections()
72+
}),
73+
)
74+
yield* Effect.tryPromise(() => EventEmitter.once(server.listen(0, "127.0.0.1"), "listening"))
75+
const address = server.address()
76+
if (!address || typeof address === "string")
77+
return yield* Effect.fail(new Error("Missing OAuth callback port"))
78+
const redirect = `http://127.0.0.1:${address.port}/callback`
79+
return {
80+
mode: "auto" as const,
81+
url: `${issuer}/oauth/authorize?${new URLSearchParams({
82+
response_type: "code",
83+
client_id: clientID,
84+
redirect_uri: redirect,
85+
scope: "apikey:create",
86+
code_challenge: challenge,
87+
code_challenge_method: "S256",
88+
state,
89+
}).toString()}`,
90+
instructions: "Complete authorization in your browser. This window will close automatically.",
91+
callback: Effect.gen(function* () {
92+
const request = yield* Deferred.await(callback)
93+
const respond = (error?: string) =>
94+
Effect.sync(() =>
95+
request.response
96+
.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
97+
.end(
98+
error
99+
? OauthCallbackPage.error(error, { provider: "Poe" })
100+
: OauthCallbackPage.success({ provider: "Poe" }),
101+
),
102+
)
103+
return yield* exchangeCode(http, { code: request.code, redirect, verifier }).pipe(
104+
Effect.tap(() => respond()),
105+
Effect.tapError((error) => respond(error.message)),
106+
// Bun's server.closeAllConnections() leaves an unanswered callback response pending.
107+
Effect.onInterrupt(() => Effect.sync(() => request.response.destroy())),
108+
)
109+
}),
110+
}
111+
}),
112+
})
113+
})
114+
}),
115+
})
116+
117+
function callbackError(params: URLSearchParams, state: string) {
118+
if (params.get("state") !== state) return "Invalid OAuth state"
119+
// Poe's client pins this issuer but does not require iss; its documented callbacks may omit it.
120+
if (params.has("iss") && params.get("iss") !== issuer) return "Invalid OAuth issuer"
121+
if (params.has("error")) {
122+
const detail = params.get("error_description") || params.get("error") || "Authorization denied"
123+
return detail.includes(state) ? "Poe authorization failed" : detail
124+
}
125+
return params.get("code")?.trim() ? undefined : "Missing authorization code"
126+
}
127+
128+
function exchangeCode(http: HttpClient.HttpClient, input: { code: string; redirect: string; verifier: string }) {
129+
return Effect.gen(function* () {
130+
const response = yield* http
131+
.execute(
132+
HttpClientRequest.post("https://api.poe.com/token").pipe(
133+
HttpClientRequest.bodyUrlParams({
134+
grant_type: "authorization_code",
135+
client_id: clientID,
136+
code: input.code,
137+
redirect_uri: input.redirect,
138+
code_verifier: input.verifier,
139+
}),
140+
),
141+
)
142+
.pipe(Effect.mapError(() => new Error("Poe token exchange request failed")))
143+
if (response.status < 200 || response.status >= 300) {
144+
const error = Option.getOrUndefined(decodeError(yield* response.text.pipe(Effect.orElseSucceed(() => ""))))
145+
const detail = error?.error_description || error?.error
146+
return yield* Effect.fail(
147+
new Error(
148+
detail && ![input.code, input.verifier].some((secret) => detail.includes(secret))
149+
? `Poe token exchange failed: ${detail}`
150+
: `Poe token exchange failed (${response.status})`,
151+
),
152+
)
153+
}
154+
const token = yield* HttpClientResponse.schemaBodyJson(Token)(response).pipe(
155+
Effect.mapError(() => new Error("Invalid Poe token response")),
156+
)
157+
const expires =
158+
token.api_key_expires_in == null ? maxExpiry : (yield* Clock.currentTimeMillis) + token.api_key_expires_in * 1000
159+
if (!Number.isSafeInteger(expires) || expires > maxExpiry)
160+
return yield* Effect.fail(new Error("Invalid Poe API key expiry"))
161+
return Credential.OAuth.make({ type: "oauth", methodID, access: token.api_key, refresh: "", expires })
162+
})
163+
}

0 commit comments

Comments
 (0)