opencode-openwebui-authentik 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -2
  3. package/index.js +654 -0
  4. package/package.json +19 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FedotCompot
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,202 @@
1
- # Temporary Holding Version
1
+ # opencode-openwebui-authentik
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Server plugin for [opencode](https://opencode.ai) v2 (tested against v2.1.2). It lets opencode
4
+ use an [Open WebUI](https://openwebui.com) instance that sits behind
5
+ [authentik](https://goauthentik.io) forward auth. You log in with your authentik account, so
6
+ you don't need an Open WebUI API key.
7
+
8
+ - A provider opts in with `"openwebuiAuthentik": true` in its `settings`, whatever its name.
9
+ You can mark several providers, one per Open WebUI instance.
10
+ - It needs no other settings. Open WebUI is the provider's `settings.baseURL`, and the
11
+ redirect that Open WebUI sends to logged-out clients gives both the authentik address
12
+ and the client ID of the Open WebUI proxy provider. See [Options](#options) to pin them.
13
+ - `opencode auth login` runs the authentik device-code login against a public OAuth2 client.
14
+ - The plugin exchanges that login for an authentik token of the Open WebUI proxy provider.
15
+ The exchange runs the Open WebUI application's access policies. The plugin then signs in
16
+ to Open WebUI to get a session token for your email.
17
+ - Each model request sends `Authorization: Bearer <authentik token>` and
18
+ `X-Open-WebUI-Token: <Open WebUI token>`.
19
+ - The plugin keeps each managed provider's model list in sync with what Open WebUI serves
20
+ you: right after login and every 5 minutes. Models from `/api/models` that aren't in your
21
+ config are added; configured models that Open WebUI no longer serves are removed. Models
22
+ you configure keep your settings (name, limits), so list a model only to override them.
23
+ - Tokens refresh automatically. If authentik rejects a request, the plugin asks authentik
24
+ whether the token is still valid. Only a dead token marks the login as needing a new
25
+ sign-in; a passing glitch is retried once and clears itself.
26
+
27
+ ## Server setup
28
+
29
+ The plugin expects this on the authentik and Open WebUI side:
30
+
31
+ - **CLI provider**: a public OAuth2 provider for the plugin, client ID `open-webui-cli`
32
+ unless you set `cliClientID`, with the `device_code` and `refresh_token` grants and the
33
+ `openid email profile offline_access` scopes. Use an authorization flow with explicit consent that is not
34
+ remembered: remembered consent lets anyone who tricks a user into opening a device link
35
+ log in silently.
36
+ - **Open WebUI proxy provider**: the forward-auth proxy provider of the Open WebUI
37
+ application. List the CLI provider under its JWT federation providers, so that a CLI
38
+ access token can be swapped for a proxy token (`client_credentials` grant with a JWT
39
+ assertion and the `ak_proxy` scope). Bind the users or groups allowed to use Open WebUI to
40
+ this application.
41
+ - **Reverse proxy in front of Open WebUI**: it checks every request against the authentik
42
+ outpost and passes the user's email on as Open WebUI's trusted email header
43
+ (`WEBUI_AUTH_TRUSTED_EMAIL_HEADER`). It also turns `X-Open-WebUI-Token` into the
44
+ `Authorization` header that Open WebUI sees. With nginx:
45
+
46
+ ```nginx
47
+ # only JWT-shaped Open WebUI tokens, never sk- keys
48
+ map $http_x_open_webui_token $owui_authorization {
49
+ "" $http_authorization;
50
+ "~^eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$" "Bearer $http_x_open_webui_token";
51
+ default "Bearer invalid";
52
+ }
53
+
54
+ location / {
55
+ auth_request /_authentik;
56
+ auth_request_set $ak_email $upstream_http_x_authentik_email;
57
+ proxy_set_header X-authentik-email $ak_email;
58
+ proxy_set_header Authorization $owui_authorization;
59
+ proxy_set_header X-Open-WebUI-Token "";
60
+ proxy_pass http://open-webui;
61
+ }
62
+ ```
63
+
64
+ Don't forward `X-Open-WebUI-Token` to the outpost in the `/_authentik` subrequest.
65
+
66
+ ## Install
67
+
68
+ 1. Install the plugin from npm. This adds it to `plugins` in your global opencode config:
69
+
70
+ ```sh
71
+ opencode plugin add opencode-openwebui-authentik
72
+ ```
73
+
74
+ 2. In `~/.config/opencode/opencode.jsonc`, mark each Open WebUI provider with
75
+ `"openwebuiAuthentik": true` in its `settings`, and remove `settings.apiKey` from it.
76
+
77
+ ```jsonc
78
+ {
79
+ "plugins": ["opencode-openwebui-authentik"],
80
+ "providers": {
81
+ "home-ai": {
82
+ "package": "@opencode/ai/providers/openai-compatible",
83
+ "settings": { "baseURL": "https://openwebui.example.com/api", "openwebuiAuthentik": true },
84
+ "models": {}
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
90
+ The marker stays inside opencode: it isn't sent with model requests. Providers without it
91
+ are left alone.
92
+ 3. Check that it loaded: `opencode plugin list` should show `openwebui-authentik`.
93
+
94
+ To upgrade later, run `opencode plugin update opencode-openwebui-authentik`.
95
+
96
+ ### Manual install
97
+
98
+ 1. Clone this repo somewhere stable.
99
+ 2. In `~/.config/opencode/opencode.jsonc`, add the clone's directory to `plugins` and mark
100
+ your providers as in step 2 above. The plugin path must be the directory, not `index.js`;
101
+ opencode ignores file paths here.
102
+
103
+ ```jsonc
104
+ {
105
+ "plugins": ["/home/<you>/code/opencode-openwebui-authentik"]
106
+ }
107
+ ```
108
+
109
+ Don't also copy the directory into `~/.config/opencode/plugins/`: that folder is loaded
110
+ automatically, so opencode would find the plugin twice and report the second copy as a
111
+ duplicate plugin ID.
112
+ 3. Check that it loaded: `opencode plugin list` should show `openwebui-authentik`.
113
+ 4. To upgrade, `git pull` in the clone.
114
+
115
+ ## Options
116
+
117
+ All options are optional. Set them on the provider marker, as an object instead of `true`,
118
+ for that provider only:
119
+
120
+ ```jsonc
121
+ "settings": {
122
+ "baseURL": "https://openwebui.example.com/api",
123
+ "openwebuiAuthentik": { "authentikURL": "https://authentik.example.com" }
124
+ }
125
+ ```
126
+
127
+ or in the plugin entry `{ "package": "opencode-openwebui-authentik", "options": { ... } }`
128
+ for every marked provider. A marker's settings win over the plugin options.
129
+
130
+ - `authentikURL`: the authentik address. Discovered from Open WebUI's redirect when unset;
131
+ when set, the plugin refuses a redirect anywhere else. Set it to keep a compromised Open
132
+ WebUI host from sending you to a fake authentik login.
133
+ - `proxyClientID`: the client ID of the Open WebUI proxy provider, discovered when unset.
134
+ With both `authentikURL` and `proxyClientID` set, the plugin skips the discovery request.
135
+ - `cliClientID`: the client ID of the CLI provider, default `open-webui-cli`.
136
+ - `modelsRefreshMinutes` (plugin options only): how often to re-sync models, default `5`.
137
+ `0` turns the sync off and leaves the configured models alone.
138
+
139
+ Changing a provider's marker or `baseURL` takes effect without a restart.
140
+
141
+ ## Log in
142
+
143
+ ```sh
144
+ opencode auth login home-ai --method authentik
145
+ ```
146
+
147
+ Use the provider ID of each marked provider.
148
+
149
+ Open the URL it prints. authentik shows a consent screen; approve it only if you started this
150
+ login yourself. The code is in the browser's address bar, not on the page. `opencode auth
151
+ list` should then list your email under the provider.
152
+
153
+ Never approve a device login from a link someone sent you. That gives them your Open WebUI
154
+ access.
155
+
156
+ ## Log out
157
+
158
+ ```sh
159
+ opencode auth logout home-ai
160
+ ```
161
+
162
+ This deletes the stored credential on this machine. The authentik refresh token stays valid
163
+ until it expires or an admin revokes it in authentik.
164
+
165
+ ## Errors
166
+
167
+ - `Set settings.baseURL on the <id> provider`: the provider is marked but has no valid
168
+ `baseURL`, so the plugin can't tell where Open WebUI is.
169
+ - `No authentik login is active for <id>`: run the login command for that provider.
170
+ - `authentik rejected the Open WebUI credential`: an admin revoked your Open WebUI token, or it
171
+ expired. Log in again.
172
+ - `authentik could not verify the Open WebUI credential`: authentik rejected the request but
173
+ still reports the token as valid, usually because authentik was briefly unreachable. The
174
+ plugin retries once; otherwise try again shortly.
175
+ - `Could not find the Open WebUI authentik client`: Open WebUI did not redirect to an
176
+ authentik authorize URL, redirected to a different `authentikURL` than the one you pinned,
177
+ or redirected from HTTPS to plain HTTP. Check the provider's `baseURL`, or pin
178
+ `authentikURL` and `proxyClientID`.
179
+ - `Could not get an Open WebUI token from authentik`: the token exchange failed. Usually this
180
+ means the access policies deny you, or your email is missing or duplicated. The plugin
181
+ keeps your login and retries after 30 seconds.
182
+
183
+ ## Removing access
184
+
185
+ authentik runs the access policies only when it issues an Open WebUI token. A user who loses
186
+ access keeps it until their current proxy token expires, then gets the token exchange error
187
+ above. To cut someone off immediately, deactivate them in authentik, which deletes all their
188
+ tokens. To keep the account active instead, delete their OAuth access tokens for the Open
189
+ WebUI proxy provider and their refresh tokens for the CLI provider (Directory > Users > user >
190
+ Credentials / Tokens). Revoking sessions is not enough. The outpost can take up to a minute to
191
+ notice.
192
+
193
+ ## Tests
194
+
195
+ `bun test` runs the plugin against mock authentik and Open WebUI servers.
196
+
197
+ ## Known limitation
198
+
199
+ If authentik rejects the token (after an admin revokes it, or when authentik is briefly
200
+ unreachable), the forward auth redirects the request to authentik. opencode follows the
201
+ redirect and still sends the `X-Open-WebUI-Token` header, so that request leaks the Open WebUI
202
+ token to authentik. The plugin then drops that token and does not send it again.
package/index.js ADDED
@@ -0,0 +1,654 @@
1
+ const MARKER = "openwebuiAuthentik"
2
+ const SETTINGS = ["authentikURL", "cliClientID", "proxyClientID"]
3
+ const DEFAULT_CLI_CLIENT_ID = "open-webui-cli"
4
+ const MODELS_REFRESH_MINUTES = 5
5
+
6
+ const METHOD_ID = "authentik"
7
+ const AUTHORIZE_PATH = "/application/o/authorize/"
8
+ const CLI_SCOPE = "openid email profile offline_access"
9
+ const PROXY_SCOPE = "openid email profile ak_proxy"
10
+ const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
11
+ const JWT_ASSERTION = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
12
+ const REFRESH_GRACE_MS = 30_000
13
+ const HTTP_TIMEOUT_MS = 30_000
14
+ const SLOW_DOWN_MS = 5_000
15
+ const REREAD_MS = 10_000
16
+ const REREAD_INTERVAL_MS = 1_000
17
+ const REJECTED_RETRY_MS = 2_000
18
+ // core rejects an empty secret before hooks run
19
+ const MINT_FAILED = "authentik-mint-failed"
20
+
21
+ export default {
22
+ id: "openwebui-authentik",
23
+ setup: async (ctx) => {
24
+ const shutdown = new AbortController()
25
+ const managed = new Map()
26
+ const refreshMs = modelsRefreshMinutes(ctx.options) * 60_000
27
+ const syncAll = () => refreshMs > 0 && managed.forEach((provider) => provider.sync())
28
+ let adopting = Promise.resolve()
29
+ const reconcile = () =>
30
+ (adopting = adopting.then(() => adopt(ctx, managed, shutdown.signal, refreshMs > 0)).catch(() => {}))
31
+ await reconcile()
32
+ if (refreshMs > 0) every(refreshMs, syncAll, shutdown.signal).catch(() => {})
33
+ onEvents(ctx, { credential: syncAll, provider: reconcile }, shutdown.signal).catch(() => {})
34
+ return () => shutdown.abort()
35
+ },
36
+ }
37
+
38
+ // unlisted providers stay managed; the list is partial at startup
39
+ async function adopt(ctx, managed, shutdown, sync) {
40
+ const { data = [] } = await ctx.provider.list()
41
+ for (const provider of data) {
42
+ const marked = Boolean(provider.settings?.[MARKER])
43
+ const config = marked ? configure(ctx.options, provider) : undefined
44
+ const current = managed.get(provider.id)
45
+ if (current?.key === config?.key) continue
46
+ if (current) {
47
+ managed.delete(provider.id)
48
+ await current.dispose()
49
+ }
50
+ if (!config) continue
51
+ const adopted = await manage(ctx, config, shutdown)
52
+ managed.set(provider.id, adopted)
53
+ if (sync) adopted.sync()
54
+ }
55
+ }
56
+
57
+ async function manage(ctx, settings, shutdown) {
58
+ const stop = new AbortController()
59
+ const signal = AbortSignal.any([shutdown, stop.signal])
60
+ const config = {
61
+ ...settings,
62
+ origin: async () => {
63
+ if (!settings.openwebuiOrigin) throw new Error(`Set settings.baseURL on the ${settings.providerID} provider`)
64
+ return settings.openwebuiOrigin
65
+ },
66
+ }
67
+ const registrations = []
68
+ const relogin = `run: opencode auth login ${config.providerID} --method ${METHOD_ID}`
69
+ const reread = async () => {
70
+ const connection = await ctx.integration.connection.active(config.providerID)
71
+ return connection && ctx.integration.connection.resolve(connection)
72
+ }
73
+ const authentik = createAuthentik(config, relogin, signal, reread)
74
+ const openwebui = createOpenWebUI(config, signal, authentik.rejection)
75
+ const pending = new Map()
76
+ const retried = new Set()
77
+ let flagged
78
+ let models
79
+ let syncing = Promise.resolve()
80
+
81
+ registrations.push(
82
+ await ctx.integration.transform((editor) => {
83
+ editor.method.remove(config.providerID, { type: "key" })
84
+ editor.method.update({
85
+ integrationID: config.providerID,
86
+ method: { id: METHOD_ID, type: "oauth", label: "authentik (device login)" },
87
+ authorize: () => authentik.authorize(),
88
+ refresh: (credential) => authentik.refresh(credential),
89
+ label: (credential) => account(credential.access),
90
+ })
91
+ }),
92
+ )
93
+
94
+ registrations.push(
95
+ await ctx.model.transform((editor) => {
96
+ if (!models) return
97
+ const configured = new Set(editor.list(config.providerID).map((model) => model.id))
98
+ for (const id of configured) if (!models.has(id)) editor.remove(config.providerID, id)
99
+ for (const [id, name] of models) {
100
+ if (configured.has(id)) continue
101
+ editor.update(config.providerID, id, (model) => {
102
+ model.name = name
103
+ })
104
+ }
105
+ }),
106
+ )
107
+
108
+ const current = async () => {
109
+ const connection = await ctx.integration.connection.active(config.providerID)
110
+ if (connection?.type !== "credential" || connection.method !== "oauth")
111
+ throw new Error(`No authentik login is active for ${config.providerID}; ${relogin}`)
112
+ const credential = await ctx.integration.connection.resolve(connection)
113
+ if (credential?.type !== "oauth" || credential.methodID !== METHOD_ID)
114
+ throw new Error(`The active ${config.providerID} credential is not an authentik login; ${relogin}`)
115
+ if (!credential.access || credential.access === MINT_FAILED)
116
+ throw new Error(authentik.mintFailure(credential.refresh))
117
+ return { connection, credential }
118
+ }
119
+
120
+ const setStatus = (connection, status) =>
121
+ ctx.integration.connection.status({ integrationID: config.providerID, connection, status }).catch(() => {})
122
+
123
+ const needsAuth = async (connection, message) => {
124
+ flagged = connection
125
+ await setStatus(connection, { status: "needs_auth", message })
126
+ }
127
+
128
+ const flagRevoked = (connection) => async (error) => {
129
+ if (error instanceof AuthentikRejected && error.revoked) await needsAuth(connection, error.message)
130
+ throw error
131
+ }
132
+
133
+ const healthy = async () => {
134
+ if (!flagged) return
135
+ const connection = flagged
136
+ flagged = undefined
137
+ await setStatus(connection, undefined)
138
+ }
139
+
140
+ const fetchModels = async () => {
141
+ const { connection, credential } = await current()
142
+ const served = await openwebui.models(credential.access).catch(flagRevoked(connection))
143
+ if (models && sameModels(models, served)) return
144
+ models = served
145
+ await ctx.model.reload()
146
+ }
147
+
148
+ registrations.push(
149
+ await ctx.session.hook(
150
+ "http.request",
151
+ async (event) => {
152
+ const origin = new URL(event.request.url).origin
153
+ const expected = await config.origin()
154
+ if (origin !== expected)
155
+ throw new Error(`Refusing to send authentik credentials to ${origin}; expected ${expected}`)
156
+ const { connection, credential } = await current()
157
+ const token = await openwebui.token(credential.access).catch(flagRevoked(connection))
158
+ event.request.headers.set("authorization", `Bearer ${credential.access}`)
159
+ event.request.headers.set("x-open-webui-token", token)
160
+ },
161
+ { providerID: config.providerID },
162
+ ),
163
+ )
164
+
165
+ registrations.push(
166
+ await ctx.session.hook(
167
+ "http.response",
168
+ async (event) => {
169
+ const response = event.response
170
+ const session = event.sessionID
171
+ // title and generate requests never reach the retry hook
172
+ const tracked = event.kind === "primary" || event.kind === "compaction"
173
+ if (tracked) pending.delete(session)
174
+ if (rejectedByAuthentik(response)) {
175
+ await response.body?.cancel().catch(() => {})
176
+ const proxyToken = bearer(event.request)
177
+ await openwebui.forget(proxyToken)
178
+ const error = await authentik.rejection(response, proxyToken)
179
+ if (error.revoked) {
180
+ const connection = await ctx.integration.connection.active(config.providerID).catch(() => undefined)
181
+ if (connection) await needsAuth(connection, error.message)
182
+ } else if (tracked) pending.set(session, REJECTED_RETRY_MS)
183
+ event.response = Response.json(
184
+ { error: { type: "authentication_error", message: error.message } },
185
+ { status: 401 },
186
+ )
187
+ return
188
+ }
189
+ if (response.status === 401) {
190
+ await openwebui.forget(bearer(event.request), event.request.headers.get("x-open-webui-token"))
191
+ if (tracked) pending.set(session, 0)
192
+ return
193
+ }
194
+ if (tracked) retried.delete(session)
195
+ if (response.ok) await healthy()
196
+ },
197
+ { providerID: config.providerID },
198
+ ),
199
+ )
200
+
201
+ registrations.push(
202
+ await ctx.session.hook(
203
+ "retry",
204
+ (event) => {
205
+ const delay = pending.get(event.sessionID)
206
+ pending.delete(event.sessionID)
207
+ if (delay === undefined || event.error.status !== 401 || retried.has(event.sessionID)) return
208
+ retried.add(event.sessionID)
209
+ event.decision = { retry: true, delay }
210
+ },
211
+ { providerID: config.providerID },
212
+ ),
213
+ )
214
+
215
+ return {
216
+ key: config.key,
217
+ sync: () => (syncing = syncing.then(() => fetchModels().catch(() => {}))),
218
+ dispose: async () => {
219
+ stop.abort()
220
+ await Promise.all(registrations.map((registration) => registration.dispose()))
221
+ await ctx.model.reload().catch(() => {})
222
+ },
223
+ }
224
+ }
225
+
226
+ function configure(options, provider) {
227
+ const marker = provider.settings[MARKER]
228
+ const baseURL = provider.settings.baseURL
229
+ const merged = {
230
+ authentikURL: "",
231
+ cliClientID: DEFAULT_CLI_CLIENT_ID,
232
+ proxyClientID: "",
233
+ ...strings(options),
234
+ ...strings(isObject(marker) ? marker : {}),
235
+ }
236
+ return {
237
+ ...merged,
238
+ providerID: provider.id,
239
+ authentikURL: merged.authentikURL.replace(/\/+$/, ""),
240
+ openwebuiOrigin: originOf(baseURL),
241
+ key: JSON.stringify([marker, baseURL]),
242
+ }
243
+ }
244
+
245
+ function originOf(url) {
246
+ try {
247
+ return new URL(url).origin
248
+ } catch {
249
+ return undefined
250
+ }
251
+ }
252
+
253
+ function strings(options) {
254
+ return Object.fromEntries(
255
+ SETTINGS.filter((key) => typeof options?.[key] === "string" && options[key]).map((key) => [key, options[key]]),
256
+ )
257
+ }
258
+
259
+ function modelsRefreshMinutes(options) {
260
+ const minutes = options?.modelsRefreshMinutes
261
+ return Number.isFinite(minutes) && minutes >= 0 ? minutes : MODELS_REFRESH_MINUTES
262
+ }
263
+
264
+ function isObject(value) {
265
+ return typeof value === "object" && value !== null && !Array.isArray(value)
266
+ }
267
+
268
+ async function every(ms, fn, signal) {
269
+ while (true) {
270
+ await sleep(ms, signal)
271
+ fn()
272
+ }
273
+ }
274
+
275
+ async function onEvents(ctx, handlers, signal) {
276
+ for await (const event of ctx.event.subscribe({ signal })) {
277
+ if (typeof event?.type !== "string") continue
278
+ if (event.type.startsWith("credential.")) handlers.credential()
279
+ else if (event.type === "provider.updated") handlers.provider()
280
+ }
281
+ }
282
+
283
+ function sameModels(a, b) {
284
+ return a.size === b.size && [...a].every(([id, name]) => b.get(id) === name)
285
+ }
286
+
287
+ function createAuthentik(config, relogin, shutdown, reread) {
288
+ const flights = new Map()
289
+ const mintErrors = new Map()
290
+ const rereading = new Set()
291
+ let site =
292
+ config.authentikURL && config.proxyClientID
293
+ ? Promise.resolve({ authentikURL: config.authentikURL, proxyClientID: config.proxyClientID })
294
+ : undefined
295
+
296
+ const discover = async () => {
297
+ const origin = await config.origin()
298
+ const response = await fetch(`${origin}/`, { redirect: "manual", signal: timeout(shutdown) })
299
+ await response.body?.cancel().catch(() => {})
300
+ const location = response.headers.get("location")
301
+ const target = location ? new URL(location, origin) : undefined
302
+ const authentikURL = target?.pathname.endsWith(AUTHORIZE_PATH)
303
+ ? `${target.origin}${target.pathname.slice(0, -AUTHORIZE_PATH.length)}`
304
+ : undefined
305
+ const clientID = target?.searchParams.get("client_id")
306
+ const downgraded = target?.protocol !== "https:" && origin.startsWith("https:")
307
+ if (!authentikURL || !clientID || downgraded || (config.authentikURL && authentikURL !== config.authentikURL))
308
+ throw new Error(`Could not find the Open WebUI authentik client at ${origin} (HTTP ${response.status})`)
309
+ return { authentikURL: config.authentikURL || authentikURL, proxyClientID: config.proxyClientID || clientID }
310
+ }
311
+
312
+ const discovered = () => {
313
+ site ??= discover().catch((error) => {
314
+ site = undefined
315
+ throw error
316
+ })
317
+ return site
318
+ }
319
+
320
+ const endpoint = async (path) => `${(await discovered()).authentikURL}/application/o/${path}/`
321
+
322
+ const credential = (refresh, minted) => ({
323
+ type: "oauth",
324
+ methodID: METHOD_ID,
325
+ refresh,
326
+ access: minted.access,
327
+ expires: minted.expires,
328
+ })
329
+
330
+ const mint = async (cliAccess) => {
331
+ const response = await postForm(
332
+ await endpoint("token"),
333
+ {
334
+ grant_type: "client_credentials",
335
+ client_id: (await discovered()).proxyClientID,
336
+ client_assertion_type: JWT_ASSERTION,
337
+ client_assertion: cliAccess,
338
+ scope: PROXY_SCOPE,
339
+ },
340
+ shutdown,
341
+ )
342
+ if (!response.ok || typeof response.body.access_token !== "string") {
343
+ if (response.body.error === "invalid_client" && !config.proxyClientID) site = undefined
344
+ throw new Error(`authentik refused to mint an Open WebUI token (${describe(response)})`)
345
+ }
346
+ return { access: response.body.access_token, expires: expiry(response.body) }
347
+ }
348
+
349
+ const poll = async (device, deadline) => {
350
+ let interval = (Number.isFinite(device.interval) ? device.interval : 5) * 1000
351
+ while (true) {
352
+ await sleep(interval, shutdown)
353
+ if (Date.now() >= deadline) throw new Error(`The authentik device code expired before approval; ${relogin}`)
354
+ const response = await postForm(
355
+ await endpoint("token"),
356
+ { grant_type: DEVICE_GRANT, client_id: config.cliClientID, device_code: device.device_code },
357
+ shutdown,
358
+ )
359
+ if (response.ok) return response.body
360
+ const error = response.body.error
361
+ if (error === "authorization_pending") continue
362
+ if (error === "slow_down") {
363
+ interval += SLOW_DOWN_MS
364
+ continue
365
+ }
366
+ if (error === "expired_token") throw new Error(`The authentik device code expired before approval; ${relogin}`)
367
+ if (error === "access_denied") throw new Error("The sign-in was denied in authentik")
368
+ throw new Error(`authentik device login failed (${describe(response)})`)
369
+ }
370
+ }
371
+
372
+ const authorize = async () => {
373
+ const response = await postForm(
374
+ await endpoint("device"),
375
+ { client_id: config.cliClientID, scope: CLI_SCOPE },
376
+ shutdown,
377
+ )
378
+ const device = response.body
379
+ if (!response.ok || typeof device.device_code !== "string" || typeof device.user_code !== "string")
380
+ throw new Error(`authentik device authorization failed (${describe(response)})`)
381
+ const expiresAt = Date.now() + (Number.isFinite(device.expires_in) ? device.expires_in : 600) * 1000
382
+ const callback = poll(device, expiresAt).then(async (tokens) => {
383
+ if (typeof tokens.refresh_token !== "string" || !tokens.refresh_token)
384
+ throw new Error("authentik issued no refresh token; the CLI provider must grant offline_access")
385
+ return credential(tokens.refresh_token, await mint(tokens.access_token))
386
+ })
387
+ // core awaits it later
388
+ callback.catch(() => {})
389
+ return {
390
+ mode: "auto",
391
+ url: device.verification_uri_complete ?? device.verification_uri,
392
+ instructions: `Approve only if you started this login yourself. The browser address bar shows the code ${device.user_code}; to type it in, open ${device.verification_uri}`,
393
+ expiresAt,
394
+ callback,
395
+ }
396
+ }
397
+
398
+ const rotate = async (stored) => {
399
+ if (!stored.refresh) throw new Error(`The authentik login has no refresh token; ${relogin}`)
400
+ const response = await postForm(
401
+ await endpoint("token"),
402
+ { grant_type: "refresh_token", client_id: config.cliClientID, refresh_token: stored.refresh },
403
+ shutdown,
404
+ )
405
+ if (response.body.error === "invalid_grant") {
406
+ const current = await rotatedElsewhere(stored.refresh)
407
+ if (current) return credential(current.refresh, current)
408
+ }
409
+ if (!response.ok || typeof response.body.access_token !== "string")
410
+ throw new Error(`authentik refused to refresh the login (${describe(response)}); ${relogin}`)
411
+ const refresh = response.body.refresh_token || stored.refresh
412
+ try {
413
+ return credential(refresh, await mint(response.body.access_token))
414
+ } catch (error) {
415
+ const failed = credential(refresh, { access: MINT_FAILED, expires: 0 })
416
+ mintErrors.set(refresh, error instanceof Error ? error.message : String(error))
417
+ remember(refresh, Promise.resolve(failed))
418
+ return failed
419
+ }
420
+ }
421
+
422
+ // another opencode process may have rotated it first
423
+ const rotatedElsewhere = async (spent) => {
424
+ rereading.add(spent)
425
+ try {
426
+ const deadline = Date.now() + REREAD_MS
427
+ while (true) {
428
+ const current = await reread().catch(() => undefined)
429
+ if (current?.type === "oauth" && current.methodID === METHOD_ID && current.refresh && current.refresh !== spent)
430
+ return current
431
+ if (Date.now() >= deadline) return undefined
432
+ await sleep(REREAD_INTERVAL_MS, shutdown)
433
+ }
434
+ } finally {
435
+ rereading.delete(spent)
436
+ }
437
+ }
438
+
439
+ const prune = () => {
440
+ const now = Date.now()
441
+ for (const [key, flight] of flights) {
442
+ if (flight.settled !== undefined && now - flight.settled > REFRESH_GRACE_MS) {
443
+ flights.delete(key)
444
+ mintErrors.delete(key)
445
+ }
446
+ }
447
+ }
448
+
449
+ const remember = (key, promise) => {
450
+ const flight = { promise, settled: undefined }
451
+ flights.set(key, flight)
452
+ promise.then(
453
+ () => (flight.settled = Date.now()),
454
+ () => (flight.settled = Date.now()),
455
+ )
456
+ return flight
457
+ }
458
+
459
+ const refresh = (stored) => {
460
+ // core re-enters refresh while we re-read the spent token
461
+ if (rereading.has(stored.refresh)) return Promise.reject(new Error("The authentik refresh token was already used"))
462
+ prune()
463
+ const existing = flights.get(stored.refresh)
464
+ if (existing) return existing.promise
465
+ return remember(stored.refresh, rotate(stored)).promise
466
+ }
467
+
468
+ const mintFailure = (refreshToken) => {
469
+ const reason = mintErrors.get(refreshToken)
470
+ return `Could not get an Open WebUI token from authentik${reason ? `: ${reason}` : ""}; it is retried after ${
471
+ REFRESH_GRACE_MS / 1000
472
+ }s, or ${relogin}`
473
+ }
474
+
475
+ const revoked = async (proxyToken) => {
476
+ const response = await endpoint("userinfo")
477
+ .then((url) =>
478
+ fetch(url, {
479
+ headers: { authorization: `Bearer ${proxyToken}`, accept: "application/json" },
480
+ redirect: "manual",
481
+ signal: timeout(shutdown),
482
+ }),
483
+ )
484
+ .catch(() => undefined)
485
+ await response?.body?.cancel().catch(() => {})
486
+ return response?.status === 401 && (response.headers.get("www-authenticate") ?? "").includes("invalid_token")
487
+ }
488
+
489
+ const rejection = async (response, proxyToken) => {
490
+ const seen = response.status >= 300 && response.status < 400 ? "a redirect" : "the sign-in page"
491
+ if (await revoked(proxyToken))
492
+ return new AuthentikRejected(
493
+ `authentik rejected the Open WebUI credential (got ${seen}, HTTP ${response.status}); ${relogin}`,
494
+ true,
495
+ )
496
+ return new AuthentikRejected(
497
+ `authentik could not verify the Open WebUI credential (got ${seen}, HTTP ${response.status}); try again shortly`,
498
+ false,
499
+ )
500
+ }
501
+
502
+ return { authorize, refresh, mintFailure, rejection }
503
+ }
504
+
505
+ function createOpenWebUI(config, shutdown, rejection) {
506
+ const sessions = new Map()
507
+
508
+ const signIn = async (proxyToken) => {
509
+ const response = await fetch(`${await config.origin()}/api/v1/auths/signin`, {
510
+ method: "POST",
511
+ headers: {
512
+ authorization: `Bearer ${proxyToken}`,
513
+ "content-type": "application/json",
514
+ accept: "application/json",
515
+ },
516
+ body: JSON.stringify({ email: "", password: "" }),
517
+ redirect: "manual",
518
+ signal: timeout(shutdown),
519
+ })
520
+ if (rejectedByAuthentik(response)) {
521
+ await response.body?.cancel().catch(() => {})
522
+ throw await rejection(response, proxyToken)
523
+ }
524
+ const body = await response.json().catch(() => ({}))
525
+ if (!response.ok || typeof body.token !== "string")
526
+ throw new Error(`Open WebUI sign-in failed (HTTP ${response.status}${body.detail ? `: ${body.detail}` : ""})`)
527
+ return { token: body.token, expires: Number.isFinite(body.expires_at) ? body.expires_at * 1000 - 60_000 : Infinity }
528
+ }
529
+
530
+ const token = async (proxyToken) => {
531
+ for (const key of sessions.keys()) if (key !== proxyToken) sessions.delete(key)
532
+ const cached = sessions.get(proxyToken)
533
+ if (cached && (cached.expires === undefined || cached.expires > Date.now())) return (await cached.promise).token
534
+ const entry = { promise: signIn(proxyToken), expires: undefined }
535
+ sessions.set(proxyToken, entry)
536
+ try {
537
+ const session = await entry.promise
538
+ entry.expires = session.expires
539
+ return session.token
540
+ } catch (error) {
541
+ if (sessions.get(proxyToken) === entry) sessions.delete(proxyToken)
542
+ throw error
543
+ }
544
+ }
545
+
546
+ const models = async (proxyToken) => {
547
+ const session = await token(proxyToken)
548
+ const response = await fetch(`${await config.origin()}/api/models`, {
549
+ headers: { authorization: `Bearer ${proxyToken}`, "x-open-webui-token": session, accept: "application/json" },
550
+ redirect: "manual",
551
+ signal: timeout(shutdown),
552
+ })
553
+ if (rejectedByAuthentik(response)) {
554
+ await response.body?.cancel().catch(() => {})
555
+ await forget(proxyToken)
556
+ throw await rejection(response, proxyToken)
557
+ }
558
+ if (response.status === 401) await forget(proxyToken, session)
559
+ const body = await response.json().catch(() => ({}))
560
+ if (!response.ok || !Array.isArray(body?.data))
561
+ throw new Error(`Open WebUI model list failed (HTTP ${response.status})`)
562
+ return new Map(
563
+ body.data
564
+ .filter((model) => typeof model?.id === "string" && model.id && !model.info?.meta?.hidden)
565
+ .map((model) => [model.id, typeof model.name === "string" && model.name ? model.name : model.id]),
566
+ )
567
+ }
568
+
569
+ const forget = async (proxyToken, rejected) => {
570
+ const cached = sessions.get(proxyToken)
571
+ if (!cached) return
572
+ const session = await cached.promise.catch(() => undefined)
573
+ if (rejected && session && session.token !== rejected) return
574
+ if (sessions.get(proxyToken) === cached) sessions.delete(proxyToken)
575
+ }
576
+
577
+ return { token, models, forget }
578
+ }
579
+
580
+ class AuthentikRejected extends Error {
581
+ constructor(message, revoked) {
582
+ super(message)
583
+ this.revoked = revoked
584
+ }
585
+ }
586
+
587
+ function rejectedByAuthentik(response) {
588
+ if (response.status >= 300 && response.status < 400) return true
589
+ const html = (response.headers.get("content-type") ?? "").includes("text/html")
590
+ return html && (response.status < 300 || response.status === 401 || response.status === 403)
591
+ }
592
+
593
+ function bearer(request) {
594
+ const value = request.headers.get("authorization") ?? ""
595
+ return value.toLowerCase().startsWith("bearer ") ? value.slice(7) : ""
596
+ }
597
+
598
+ async function postForm(url, params, shutdown) {
599
+ const response = await fetch(url, {
600
+ method: "POST",
601
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
602
+ body: new URLSearchParams(params),
603
+ redirect: "manual",
604
+ signal: timeout(shutdown),
605
+ })
606
+ const body = await response.json().catch(() => ({}))
607
+ return { ok: response.ok, status: response.status, body: body && typeof body === "object" ? body : {} }
608
+ }
609
+
610
+ function describe(response) {
611
+ const { error, error_description: detail } = response.body
612
+ if (typeof error !== "string") return `HTTP ${response.status}`
613
+ return detail ? `${error}: ${detail}` : error
614
+ }
615
+
616
+ function expiry(body) {
617
+ if (Number.isFinite(body.expires_in)) return Date.now() + Math.floor(body.expires_in * 1000)
618
+ const exp = claims(body.access_token)?.exp
619
+ return Number.isFinite(exp) ? exp * 1000 : Date.now()
620
+ }
621
+
622
+ function account(token) {
623
+ const payload = claims(token)
624
+ const name = payload?.email ?? payload?.preferred_username
625
+ return typeof name === "string" && name ? name : undefined
626
+ }
627
+
628
+ function claims(token) {
629
+ try {
630
+ const payload = token.split(".")[1]
631
+ return JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"))
632
+ } catch {
633
+ return undefined
634
+ }
635
+ }
636
+
637
+ function timeout(shutdown) {
638
+ return AbortSignal.any([shutdown, AbortSignal.timeout(HTTP_TIMEOUT_MS)])
639
+ }
640
+
641
+ function sleep(ms, signal) {
642
+ return new Promise((resolve, reject) => {
643
+ if (signal.aborted) return reject(signal.reason)
644
+ const timer = setTimeout(() => {
645
+ signal.removeEventListener("abort", abort)
646
+ resolve()
647
+ }, ms)
648
+ const abort = () => {
649
+ clearTimeout(timer)
650
+ reject(signal.reason)
651
+ }
652
+ signal.addEventListener("abort", abort, { once: true })
653
+ })
654
+ }
package/package.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "opencode-openwebui-authentik",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "opencode plugin: use Open WebUI behind authentik forward auth with your authentik login",
5
+ "keywords": ["opencode", "opencode-plugin", "open-webui", "authentik"],
6
+ "license": "MIT",
7
+ "author": "FedotCompot",
8
+ "type": "module",
9
+ "main": "index.js",
10
+ "exports": "./index.js",
11
+ "files": ["index.js"],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/FedotCompot/opencode-openwebui-authentik.git"
15
+ },
16
+ "homepage": "https://github.com/FedotCompot/opencode-openwebui-authentik#readme",
17
+ "bugs": "https://github.com/FedotCompot/opencode-openwebui-authentik/issues",
18
+ "scripts": {
19
+ "test": "bun test"
20
+ }
21
+ }