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.
- package/LICENSE +21 -0
- package/README.md +208 -2
- package/index.js +675 -0
- 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
|
-
#
|
|
1
|
+
# opencode-openwebui-authentik
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|