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