mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
release-2.9.0 (#1532)
* fix(macos): skip single-instance lock in Mac App Store builds (#1454)
fix(macos): skip single-instance lock in Mac App Store builds
* Fix/backend optional sharp (#1455)
Fix/backend optional sharp
* feat: issue #1218 (#1218)
https://github.com/Termix-SSH/Support/issues/1218
* feat: Add option to use saved global custom theme under Connection Defaults (#1209)
https://github.com/Termix-SSH/Support/issues/1209
* feat: Add Fleet Sharing functionality to the UI (#1228)
https://github.com/Termix-SSH/Support/issues/1228
* feat: Add a list view to the Docker management page (#1237)
https://github.com/Termix-SSH/Support/issues/1237
* feat: issue #1264 (#1264)
https://github.com/Termix-SSH/Support/issues/1264
* feat: 2FA With FortiToken (#1288)
https://github.com/Termix-SSH/Support/issues/1288
* feat: Allow to disable showing paths to folders (#1274)
https://github.com/Termix-SSH/Support/issues/1274
* feat: Sync Network Graph between Desktop and Remote Server (#1245)
https://github.com/Termix-SSH/Support/issues/1245
* feat: Version Number on offline servers (#1291)
https://github.com/Termix-SSH/Support/issues/1291
* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)
https://github.com/Termix-SSH/Support/issues/1302
* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)
https://github.com/Termix-SSH/Support/issues/1289
* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)
https://github.com/Termix-SSH/Support/issues/1303
* fix: I can't send a file on its own. (#1304)
https://github.com/Termix-SSH/Support/issues/1304
* fix: sudo password not autofilling (#1248)
https://github.com/Termix-SSH/Support/issues/1248
* fix: node-MainThread (#1300)
https://github.com/Termix-SSH/Support/issues/1300
* fix: Tunnel authentication with shared credentials (#1295)
https://github.com/Termix-SSH/Support/issues/1295
* fix: host key updates not syncing, metrics 404 race on first connect
Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.
* chore: increment ver
* feat: add category and icon fields to plugin manifest schema
* ci(deps): bump the github-actions group with 2 updates (#1458)
Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).
Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)
Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)
---
updated-dependencies:
- dependency-name: crowdin/github-action
dependency-version: '3'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
- dependency-name: actions/github-script
dependency-version: '9'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)
Bumps the dev-patch-updates group with 13 updates:
| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |
Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)
Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)
Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)
Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)
Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)
Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)
Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)
Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)
Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)
Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)
Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)
Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)
Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)
---
updated-dependencies:
- dependency-name: "@codemirror/commands"
dependency-version: 6.11.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
dependency-version: 6.7.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
dependency-version: 6.43.12
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
dependency-version: 10.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
dependency-version: 16.3.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
dependency-version: 14.6.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
dependency-version: 1.15.6
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
dependency-version: 6.1.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: cytoscape
dependency-version: 3.34.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
dependency-version: 0.5.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: i18next
dependency-version: 26.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: prettier
dependency-version: 3.9.8
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: react-i18next
dependency-version: 17.0.14
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)
Bumps the prod-patch-updates group with 6 updates:
| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |
Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)
Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)
Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)
Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)
Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)
Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)
---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
dependency-version: 3.14.13
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: compression
dependency-version: 1.8.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jose
dependency-version: 6.2.12
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jszip
dependency-version: 3.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: socks
dependency-version: 2.8.10
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: undici
dependency-version: 8.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* feat: add plugin worker protocol, bootstrap and plugin_storage table
Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.
* feat: add allowlisted host view for plugins
An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.
* feat: add plugin loader with manifest validation and crash restart
Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.
The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.
loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.
* feat: route internal events through a shared plugin event bus
Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.
Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.
* feat: add plugin ctx broker with permission gate and audit
Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.
Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.
fix: stop plugin workers seeing plaintext credentials in error messages
A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:
Authentication failed for root using password <SECRET>
where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.
Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.
Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.
* feat: dispatch plugin HTTP routes and wire the runtime into startup
ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.
Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.
fix: correct the nginx plugin-api location regex
The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.
* fix: update stale SFTP transfer test for single file destination paths
The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.
* feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
* feat: ship docker as a first-party in-process plugin
Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
* feat: ship host metrics as a first-party in-process plugin
* fix: harden plugin auth and add per-request identity for ctx calls
Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.
* feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
* fix: remove compiled plugin JS left behind in plugins/ after build
copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.
* feat: add a permission-gated backend service registry for plugins
* feat: add a permission-gated UI action registry and action slots for plugins
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
* feat: add cross-plugin secret references gated by the provider's RBAC permission
* feat: move proxmox discovery/import/sync into a first-party plugin
* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin
Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
* feat: wire remote-desktop plugin into core and rename i18n keys
Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.
* feat: move fleets into a first-party plugin
* feat: move automations into a first-party plugin
* feat: remove legacy alert rules, superseded by automations
Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.
* feat: move network topology into a first-party plugin
* feat: move tailscale device discovery and settings into a plugin
Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.
* feat: move workspaces into a first-party plugin
* feat: move web endpoint into a first-party plugin
* feat: collapse plugin runtime to one tier behind a plugin SDK
* feat: make each plugin an npm workspace built by a termix-plugin CLI
* feat: give plugins their own tables, migrations and sync entities
* feat: serve plugin http and websockets from one mount instead of ports
* feat: namespace plugin permissions and keep them valid when a plugin is off
* feat: give plugins their own settings and one settings screen
* feat: load plugin frontends at runtime and remove plugin ids from the shell
* fix: mock the ai status fetch in its activate test
* feat: add auth extension points and one ssh connect pipeline
* feat: rebuild workspaces as the reference plugin
* fix: gate second factors after SSO/LDAP behind an admin setting
* feat: convert network-topology to a full plugin (B1)
* feat: move snippets backend and tables into a plugin (B2)
* feat: point core frontend snippets pickers at the plugin (B2)
* feat: add the snippets plugin (B2)
* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)
* feat: finish the tailscale plugin, including SSH auth (B3)
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* chore: update proxmox and host-metrics locale files for B5
* chore: regenerate drizzle migration journals for B5
* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox
* feat: move proxmox node stats and collectors into the proxmox plugin
* feat: drop proxmox host columns, use plugin settings instead
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* feat: convert file manager into a plugin
* feat: convert ssh tunnels into a plugin
* feat: finish the web-endpoint plugin, add ctx.desktop (B8)
* feat: move the terminal fully into the ssh-terminal plugin (B9)
* feat: convert tmux monitoring into a plugin (B10)
* feat: convert serial console into the serial plugin
* feat: move session sharing and collab rooms into a plugin (B12)
* feat: turn session recording into a plugin (B13)
* feat: finish the remote-desktop plugin (B14)
* feat: finish the host-metrics plugin, move host status into core (B16)
* feat: finish the docker plugin (B15)
* feat: finish the automations plugin (B17)
* feat: finish the ai plugin (B18)
* feat: finish the homepage plugin, move service links and canvas out of core (B19)
* feat: convert wake-on-lan into a plugin (B20)
* feat: convert secret-sources into a plugin (B20)
* feat: move totp and passkeys into plugins (C1)
* feat: move sso and ldap logins into plugins (C2)
* feat: move opkssh and warpgate into plugins (C3)
* feat: move step-ca into a plugin (C4)
* feat: move vault ssh signing into a plugin (C5)
* feat: move termix identity into a plugin (C6)
* feat: move acme certificate automation into a plugin (C7)
* fix: finish 2.9.0 plugin moves and get every check green
* chore: lock the plugin boundary and remove 2.9.0 leftovers
* fix: harden the plugin runtime after the 2.9.0 security review
* feat: add plugin packing, signing, pinned trust and tmxplug bundling
* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught
* fix: give bundled plugin backends a real require for CJS deps
* feat: improve UI loading time and jitter
* feat: show credential sharing inline in the sidebar like host sharing
* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning
* feat: unify alerts into one plugin, channels, rules and inbox
* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
* fix: set blacksmith builder cache-key to stop broken gha cache fallback
* fix: link plugin-sdk workspace before npm ci in docker deps stage
* fix: link every plugin workspace before npm ci in docker deps stage
* fix(host-metrics): exclude image and firmware filesystems (#1469)
* fix(host-metrics): exclude image and firmware filesystems
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(hosts): normalize imported jump host references (#1470)
* fix(hosts): normalize imported jump host references
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent (#1476)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent
* fix(dashboard): reuse translated host status labels (#1475)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(dashboard): reuse translated host status labels
* docs(api): clarify local and remote version fields (#1474)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* docs(api): clarify local and remote version fields
* fix(file-manager): preserve selected backgrounds on hover (#1473)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): preserve selection feedback on hover
* test(ui): await dialog focus cleanup before teardown
* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): focus file search with Ctrl or Cmd F
* fix: keep rounded-full circular in production builds
* fix(file-manager): cancel uploads and stop remote writes (#1477)
* fix(file-manager): cancel uploads and stop remote writes
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): apply compact density to directory tree (#1478)
* fix(file-manager): apply compact density to directory tree
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): preserve font sizes in compact mode (#1480)
* fix(file-manager): preserve font sizes in compact mode
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): prioritize file names in narrow lists (#1483)
* fix(file-manager): prioritize file names in narrow lists
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(remote-desktop): probe the saved host guacd endpoint (#1481)
* fix(remote-desktop): probe the saved host guacd endpoint
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* fix(homepage): check allowlisted private service reachability (#1479)
* fix(homepage): check allowlisted private service reachability
* test(css): exercise the configured PostCSS plugin chain
* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)
* fix(remote-desktop): read clipboard only on explicit paste
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* chore: simplify plugin readmes, changelogs and manifest descriptions
* fix: keep plugins loaded after signing in from the login screen
* fix(file-manager): accept OS file drops over existing rows (#1484)
* fix(file-manager): accept OS file drops over existing rows
* test(file-manager): target the file label in drop tests
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(file-manager): pipeline SFTP download reads (#1485)
* feat: add plugin sdk build to beta release
* fix: make link-store secret check immune to random base64 matches
* fix(ldap): preserve the directory-provided bind DN (#1486)
* fix: compute beta changes across dev branches and move the beta tag
* chore: untrack plugin sdk ARCHITECTURE.md
* chore: remove references to plugin sdk ARCHITECTURE.md
* feat: merge host editor terminal tabs into one owned by ssh-terminal
* feat: add descriptions to plugin host enable toggles
* fix(file-manager): show download progress in file viewers (#1487)
* fix(electron): signal backend readiness over IPC (#1488)
* fix(split-screen): allow layout creation without randomUUID (#1490)
* fix(split-screen): allow layout creation without randomUUID
* test(credentials): avoid flaky Ed25519 key generation
* fix(sidebar): remeasure visible rows after density changes (#1489)
* fix(sidebar): remeasure visible rows after density changes
* test(credentials): avoid flaky Ed25519 key generation
* feat(split-screen): redesign split screen with free-form panes and pane picker
* feat(sidebar): add host click behavior setting, default to always opening a new tab
* feat(snippets): redesign panel to match the host list
* fix(session-recording): stop session logs panel from refetching in a loop
* feat(snippets): bring back sharing, target hosts and drag reorder
* feat(ssh-terminal): add toolbar position, start state, display and fade host settings
* feat(ssh-terminal): add host setting to turn off password prompt auto-fill
* fix(ai): stop streams getting cut off and keep tool steps across messages
* feat: improve quick connect system using plugin api
* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport
* feat: move terminal settings, look and ssh tools into ssh-terminal plugin
* feat: move snippets, keybinding actions and macros out of core into plugins
* feat: store rdp, vnc and telnet logins in a generic per-protocol table
* feat: type the plugin host record, move plugin strings out of core and add lint checks
* feat: replace core homepage widget registry with generic extension points
* feat: plugins only see their own host settings in the browser
* feat: harden plugin api for separate repos, signed updates and generic external login
* fix: keep plugin css below core utilities and version plugin assets by their css
* fix: load and save plugin host settings in the editor and tighten settings row spacing
* feat: redesign user host defaults system
* fix(remote-desktop): release held keys on focus loss (#1498)
* fix(session-sharing): replace local presentation after takeover (#1500)
* fix(file-manager): refresh disk usage with directory listing (#1503)
* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)
* feat(remote-desktop): expose display zoom controls for RDP (#1514)
* fix(file-manager): restore text selection and copying in Markdown previews (#1518)
* fix(sidebar): keep compact host addresses visible (#1524)
* fix(hosts): preserve plugin settings when reopening the editor (#1499)
* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)
* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)
* fix(session-sharing): show participant presence in member terminals (#1504)
* fix(ai): sync provider configuration and keys across linked devices (#1506)
* feat(ssh-terminal): add a duplicate tab action (#1510)
* feat(file-manager): allow disabling move-to-trash confirmation (#1511)
* feat(file-manager): default to the remote login directory (#1512)
* feat(homepage): synchronize canvas layouts across devices (#1515)
* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)
* feat(tmux-monitor): add a unified multi-host view (#1520)
* fix(electron): allow explicit quit with active connections (#1522)
* feat: reconnect all disconnected terminal tabs (#1523)
* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)
* fix(database): preserve RDP domain during startup backfill (#1497)
* feat(sidebar): add keyboard type-ahead search for hosts (#1516)
* feat(automations): add per-host maintenance mode and recurring schedules (#1521)
* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)
* fix(session-sharing): use the linked server for desktop meetings and links (#1507)
* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)
* fix: type errors and lint failures from merged plugin prs
* feat(installer): make the Windows desktop shortcut optional (#1517)
* fix: ship the plugin sdk and serialport in desktop builds
* fix: prefer ipv4 when resolving ssh host names (#1365)
* fix: readable terminal selection with the light theme (#1319)
* fix: build desktop session share links on the linked server (#1350)
* fix: show meeting guests to members and hide the roster from guests (#1353)
* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)
* fix: show disk usage for the mount being browsed (#1347)
* fix: load the linux tray icon reliably and log tray failures (#1332)
* fix: cover the gnome close path in the desktop quit test (#1332)
* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)
* feat: optional auto reconnect for dropped ssh sessions (#1321)
* feat: tighter compact host rows with inline tags (#1325)
* feat: bring back the alert feed homepage widget
* feat: pick target terminals for snippets again
* feat: share button on connection tabs again
* feat: recent servers list when linking the desktop app
* feat: list the fixed app shortcuts in keyboard shortcuts
* fix: translate hardcoded toasts and drop em dashes from ui text
* fix: translate leftover hardcoded ui text
* fix: toolbar files button opens at the shell directory again
* fix: show the remote desktop note under connection origin again
* chore: openapi docs for host metrics manager routes
* chore: openapi docs for undocumented routes and fix two broken doc blocks
* fix: declare terminal.listSessions use for snippet targets
* revert: drop the alert feed widget, the notification center replaces it
* feat: restore the alert feed homepage widget
* fix: quick connect fills the host address from plugin auth editors like tailscale
* fix: docker container logs use the base background color
* chore: fix dependabot alerts and apply safe npm upgrades
* fix: proxmox stats tab reads its switch from plugin host settings
* fix: count plugin ssh logins toward host status and stop showing reachable as a warning
* fix: hide the built-in source pill in the host editor
* fix: host status shows online when the port answers and only warns on a failed login
* fix: drop the top border on the split layout menu
* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default
* fix: remote desktop auth method labels and notes for vnc and telnet
* fix: add description to the web endpoint enable switch
* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release
* feat: move telemetry out of core into a usage statistics plugin
* fix: recent activity card no longer cuts off host names early
* fix: remove duplicate top border on split screen menu
* fix: redesign session log player controls, fix tooltip overlap and drop dot separators
* fix: file manager and sftp text showing raw i18n keys
* fix: onboarding rerunning for existing users after upgrading
* fix: fleet detail buttons overflowing the sidebar
* fix: session log player scrolling off screen horizontally
* fix: network graph lag when adding hosts and on every render
* fix: maintenance index name, shared host read only view and badge polling
* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag
* chore: update release notes
* fix: remote desktop tabs reconnecting on every tab switch
* fix: build plugin sdk before linting in release workflow
* chore: lint, format, and bump version to 2.9.0
* chore: skip crowdin pre-translate in release for now
* chore: sync Crowdin translations for 2.9.0
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
4405 files changed
+1341026
-289722
No files matched your search
@@ -0,0 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
First release, bundled with Termix 2.9.0.
|
||||
|
||||
Lets users sign in with an OpenID Connect provider, GitHub or Google. Each provider gets its own login button, and group claims can make users admins.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Single sign-on
|
||||
|
||||
Sign in with an OpenID Connect provider, GitHub or Google. Each provider gets its own button on the login screen.
|
||||
|
||||
## Features
|
||||
|
||||
- Sign in with any OpenID Connect provider, GitHub or Google.
|
||||
- Each provider gets its own button on the login screen.
|
||||
- Make members of an admin group Termix admins, read from a group claim.
|
||||
- Supports back-channel logout.
|
||||
|
||||
## Settings
|
||||
|
||||
### Admin
|
||||
|
||||
- Providers: add a provider, then register the redirect URI it shows with your identity provider. Providers set up before 2.9 keep the old redirect URI until you turn it off.
|
||||
|
||||
## Permissions
|
||||
|
||||
- `sso.manage`: Add, edit and remove SSO providers. Only admins have it by default.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run build # build into dist/
|
||||
npm run test # run this plugin's tests
|
||||
npm run typecheck # type-check this plugin
|
||||
```
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Loading...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "View docs",
|
||||
"none": "No SSO providers configured.",
|
||||
"add": "Add Provider",
|
||||
"edit": "Edit Provider",
|
||||
"delete": "Delete Provider",
|
||||
"deleteConfirm": "Delete this provider? Associated users will be unable to login.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "Provider Type",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save Provider",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "Cancel",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "Issuer URL",
|
||||
"authUrl": "Authorization URL",
|
||||
"tokenUrl": "Token URL",
|
||||
"userIdentifier": "User Identifier Path",
|
||||
"displayName": "Display Name Path",
|
||||
"scopes": "Scopes",
|
||||
"userinfoUrl": "Override Userinfo URL",
|
||||
"groupClaim": "Group Claim",
|
||||
"groupClaimDesc": "Optional. The claim path that contains the user's groups. Defaults to groups, roles, then group. Use this for providers with a custom claim (e.g. Zitadel).",
|
||||
"allowedUsers": "Allowed Users",
|
||||
"allowedUsersDesc": "One email per line. Leave empty to allow all.",
|
||||
"adminGroup": "Admin Group",
|
||||
"adminGroupDesc": "Users in this group are granted admin. Leave empty to disable group sync.",
|
||||
"caCert": "Custom CA Certificate",
|
||||
"caCertDesc": "Optional. PEM-encoded CA certificate for OIDC providers using a private or self-signed CA. Leave empty to use the system trust store."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Teken aan met SSO",
|
||||
"loginWithProvider": "Teken aan met {{name}}",
|
||||
"loading": "Laai tans...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Bestuur SSO-verskaffers",
|
||||
"description": "Voeg die SSO-verskaffers by, verander en verwyder waarmee mense kan aanmeld."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Bekyk dokumente",
|
||||
"none": "Geen SSO-verskaffers gekonfigureer nie.",
|
||||
"add": "Voeg Verskaffer by",
|
||||
"edit": "Wysig Verskaffer",
|
||||
"delete": "Vee Verskaffer uit",
|
||||
"deleteConfirm": "Vee hierdie verskaffer uit? Geassosieerde gebruikers sal nie kan aanmeld nie.",
|
||||
"name": "Vertoonnaam",
|
||||
"namePlaceholder": "My SSO-verskaffer",
|
||||
"nameRequired": "'n Vertoonnaam word vereis",
|
||||
"type": "Verskaffertipe",
|
||||
"enabled": "Geaktiveer",
|
||||
"save": "Stoor Verskaffer",
|
||||
"saving": "Stoor...",
|
||||
"saved": "Gestoor",
|
||||
"saveFailed": "Kon nie stoor nie",
|
||||
"deleted": "Verwyder",
|
||||
"deleteFailed": "Kon nie verwyder nie",
|
||||
"loadFailed": "Kon nie SSO-verskaffers laai nie",
|
||||
"cancel": "Kanselleer",
|
||||
"copy": "Kopieer",
|
||||
"copied": "Gekopieer",
|
||||
"redirectUri": "Herlei URI",
|
||||
"redirectUriDesc": "Registreer hierdie URL as die aanstuur- of terugbel-URL in jou identiteitsverskaffer.",
|
||||
"legacyCallback": "Behou die ou aanstuur-URI",
|
||||
"legacyCallbackDesc": "Hierdie verskaffer is voor Termix 2.9 opgestel en gebruik steeds die ou aanstuur-URI, wat steeds werk. Om na die nuwe een oor te skakel, voeg eers {{uri}} by jou identiteitsverskaffer, en skakel dit dan af.",
|
||||
"authorizationUrl": "Magtigings-URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Kliënt-ID",
|
||||
"clientSecret": "Kliëntgeheim",
|
||||
"secretKeep": "'n Geheim is gestoor. Los dit leeg om dit te behou.",
|
||||
"issuerUrl": "Uitreiker-URL",
|
||||
"authUrl": "Magtigings-URL",
|
||||
"tokenUrl": "Teken-URL",
|
||||
"userIdentifier": "Gebruikersidentifiseerderpad",
|
||||
"displayName": "Vertoonnaampad",
|
||||
"scopes": "Omvangsgebiede",
|
||||
"userinfoUrl": "Oorskryf Gebruikersinligting URL",
|
||||
"groupClaim": "Groep Eis",
|
||||
"groupClaimDesc": "Opsioneel. Die eispad wat die gebruiker se groepe bevat. Standaard is groepe, rolle, dan groep. Gebruik dit vir verskaffers met 'n persoonlike eis (bv. Zitadel).",
|
||||
"allowedUsers": "Toegelate Gebruikers",
|
||||
"allowedUsersDesc": "Een e-pos per reël. Los leeg om alles toe te laat.",
|
||||
"adminGroup": "Admingroep",
|
||||
"adminGroupDesc": "Gebruikers in hierdie groep kry administrasie. Los leeg om groepsinchronisasie te deaktiveer.",
|
||||
"caCert": "Pasgemaakte CA-sertifikaat",
|
||||
"caCertDesc": "Opsioneel. PEM-geënkodeerde CA-sertifikaat vir OIDC-verskaffers wat 'n private of selfgetekende CA gebruik. Los leeg om die stelselvertrouensberging te gebruik."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Meld outomaties aan met SSO",
|
||||
"description": "Stuur besoekers direk na die eerste SSO-verskaffer in plaas daarvan om die aanmeldvorm te wys. Die OIDC_SILENT_LOGIN_DEFAULT omgewingveranderlike oorskryf dit."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "سجّل الدخول باستخدام تسجيل الدخول الموحد (SSO)",
|
||||
"loginWithProvider": "تسجيل الدخول باستخدام {{name}}",
|
||||
"loading": "جارٍ التحميل...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "إدارة موفري تسجيل الدخول الموحد (SSO)",
|
||||
"description": "أضف، غيّر، واحذف مزودي خدمة تسجيل الدخول الموحد (SSO) الذين يمكن للأشخاص تسجيل الدخول باستخدامهم."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "عرض المستندات",
|
||||
"none": "لم يتم تكوين أي مزود SSO.",
|
||||
"add": "إضافة مزود",
|
||||
"edit": "تعديل المزود",
|
||||
"delete": "حذف المزود",
|
||||
"deleteConfirm": "حذف هذا المزود؟ لن يتمكن المستخدمون المرتبطون من تسجيل الدخول.",
|
||||
"name": "اسم العرض",
|
||||
"namePlaceholder": "موفر خدمة تسجيل الدخول الموحد الخاص بي",
|
||||
"nameRequired": "اسم العرض مطلوب",
|
||||
"type": "نوع المزود",
|
||||
"enabled": "مُمكّن",
|
||||
"save": "حفظ المزود",
|
||||
"saving": "جارٍ الحفظ...",
|
||||
"saved": "محفوظ",
|
||||
"saveFailed": "فشل في الحفظ",
|
||||
"deleted": "محذوف",
|
||||
"deleteFailed": "فشل في الحذف",
|
||||
"loadFailed": "فشل تحميل موفري تسجيل الدخول الموحد",
|
||||
"cancel": "إلغاء",
|
||||
"copy": "نسخ",
|
||||
"copied": "تم النسخ",
|
||||
"redirectUri": "عنوان إعادة التوجيه",
|
||||
"redirectUriDesc": "قم بتسجيل عنوان URL هذا كعنوان URL لإعادة التوجيه أو عنوان URL للرد في موفر الهوية الخاص بك.",
|
||||
"legacyCallback": "احتفظ بمعرف إعادة التوجيه القديم",
|
||||
"legacyCallbackDesc": "تم إعداد هذا المزوّد قبل إصدار Termix 2.9، ولا يزال يستخدم عنوان إعادة التوجيه القديم، والذي يعمل بشكل سليم. للانتقال إلى العنوان الجديد، أضف {{uri}} إلى مزوّد الهوية الخاص بك أولاً، ثم عطّل هذا الخيار.",
|
||||
"authorizationUrl": "رابط التفويض: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "معرف العميل",
|
||||
"clientSecret": "سر العميل",
|
||||
"secretKeep": "تم حفظ السر. اترك هذا الحقل فارغاً للحفاظ عليه.",
|
||||
"issuerUrl": "رابط المُصدر",
|
||||
"authUrl": "رابط التفويض",
|
||||
"tokenUrl": "رابط الرمز المميز",
|
||||
"userIdentifier": "مسار معرّف المستخدم",
|
||||
"displayName": "مسار اسم العرض",
|
||||
"scopes": "النطاقات",
|
||||
"userinfoUrl": "تجاوز رابط Userinfo",
|
||||
"groupClaim": "مطالبة المجموعة",
|
||||
"groupClaimDesc": "اختياري. مسار المطالبة الذي يحتوي على مجموعات المستخدم. القيمة الافتراضية هي groups، ثم roles، ثم group. استخدم هذا للموفّرين الذين لديهم مطالبة مخصصة (مثل Zitadel).",
|
||||
"allowedUsers": "المستخدمون المسموح لهم",
|
||||
"allowedUsersDesc": "بريد إلكتروني واحد في كل سطر. اتركه فارغًا للسماح للجميع.",
|
||||
"adminGroup": "مجموعة المسؤولين",
|
||||
"adminGroupDesc": "يُمنح المستخدمون في هذه المجموعة صلاحيات المسؤول. اتركه فارغًا لتعطيل مزامنة المجموعة.",
|
||||
"caCert": "شهادة CA مخصصة",
|
||||
"caCertDesc": "اختياري. شهادة CA بترميز PEM لموفّري OIDC الذين يستخدمون شهادة CA خاصة أو موقّعة ذاتيًا. اتركه فارغًا لاستخدام مخزن الثقة في النظام."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "تسجيل الدخول باستخدام تسجيل الدخول الموحد (SSO) تلقائيًا",
|
||||
"description": "قم بتوجيه الزوار مباشرةً إلى أول مزود خدمة تسجيل دخول موحد (SSO) بدلاً من عرض نموذج تسجيل الدخول. يُمكن تجاوز هذا الإعداد باستخدام متغير البيئة OIDC_SILENT_LOGIN_DEFAULT."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Влизане с SSO",
|
||||
"loginWithProvider": "Влезте с {{name}}",
|
||||
"loading": "Зареждане...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Управление на доставчици на SSO",
|
||||
"description": "Добавяне, промяна и премахване на доставчици на SSO, с които хората могат да влизат."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Преглед на документи",
|
||||
"none": "Няма конфигурирани доставчици на SSO.",
|
||||
"add": "Добавяне на доставчик",
|
||||
"edit": "Редактиране на доставчик",
|
||||
"delete": "Изтриване на доставчик",
|
||||
"deleteConfirm": "Да се изтрие ли този доставчик? Свързаните потребители няма да могат да влязат.",
|
||||
"name": "Показвано име",
|
||||
"namePlaceholder": "Моят доставчик на SSO",
|
||||
"nameRequired": "Изисква се показвано име",
|
||||
"type": "Тип доставчик",
|
||||
"enabled": "Активирано",
|
||||
"save": "Запазване на доставчика",
|
||||
"saving": "Запазване...",
|
||||
"saved": "Запазено",
|
||||
"saveFailed": "Запазването не бе успешно",
|
||||
"deleted": "Изтрито",
|
||||
"deleteFailed": "Изтриването не бе успешно",
|
||||
"loadFailed": "Зареждането на доставчиците на SSO не бе успешно",
|
||||
"cancel": "Отказ",
|
||||
"copy": "Копиране",
|
||||
"copied": "Копирано",
|
||||
"redirectUri": "URI адрес за пренасочване",
|
||||
"redirectUriDesc": "Регистрирайте този URL адрес като URL адрес за пренасочване или обратно извикване във вашия доставчик на идентичност.",
|
||||
"legacyCallback": "Запазете стария URI адрес за пренасочване",
|
||||
"legacyCallbackDesc": "Този доставчик е настроен преди Termix 2.9 и все още използва стария URI за пренасочване, който продължава да работи. За да преминете към новия, първо добавете {{uri}} към вашия доставчик на идентичност, след което го изключете.",
|
||||
"authorizationUrl": "URL адрес за оторизация: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Идентификационен номер на клиента",
|
||||
"clientSecret": "Клиентска тайна",
|
||||
"secretKeep": "Тайната е запазена. Оставете това празно, за да я запазите.",
|
||||
"issuerUrl": "URL адрес на издателя",
|
||||
"authUrl": "URL адрес за оторизация",
|
||||
"tokenUrl": "URL адрес на токена",
|
||||
"userIdentifier": "Път на потребителския идентификатор",
|
||||
"displayName": "Път на показваното име",
|
||||
"scopes": "Обхвати",
|
||||
"userinfoUrl": "Замяна на URL адреса на потребителската информация",
|
||||
"groupClaim": "Групово искане",
|
||||
"groupClaimDesc": "Незадължително. Пътят на твърдението, който съдържа групите на потребителя. По подразбиране са групи, роли и след това група. Използвайте това за доставчици с персонализирано твърдение (напр. Zitadel).",
|
||||
"allowedUsers": "Разрешени потребители",
|
||||
"allowedUsersDesc": "По един имейл на ред. Оставете празно, за да разрешите всички.",
|
||||
"adminGroup": "Административна група",
|
||||
"adminGroupDesc": "Потребителите в тази група са с администраторски права. Оставете празно, за да деактивирате синхронизирането на групата.",
|
||||
"caCert": "Персонализиран CA сертификат",
|
||||
"caCertDesc": "Незадължително. PEM-кодиран CA сертификат за OIDC доставчици, използващи частен или самоподписан CA. Оставете празно, за да използвате хранилището за доверени сертификати на системата."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Влизане с SSO автоматично",
|
||||
"description": "Изпращайте посетителите директно към първия SSO доставчик, вместо да показвате формата за вход. Променливата на средата OIDC_SILENT_LOGIN_DEFAULT отменя това."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Loading...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "View docs",
|
||||
"none": "No SSO providers configured.",
|
||||
"add": "Add Provider",
|
||||
"edit": "Edit Provider",
|
||||
"delete": "Delete Provider",
|
||||
"deleteConfirm": "Delete this provider? Associated users will be unable to login.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "Provider Type",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save Provider",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "Cancel",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "Issuer URL",
|
||||
"authUrl": "Authorization URL",
|
||||
"tokenUrl": "Token URL",
|
||||
"userIdentifier": "User Identifier Path",
|
||||
"displayName": "Display Name Path",
|
||||
"scopes": "Scopes",
|
||||
"userinfoUrl": "Override Userinfo URL",
|
||||
"groupClaim": "Group Claim",
|
||||
"groupClaimDesc": "Optional. The claim path that contains the user's groups. Defaults to groups, roles, then group. Use this for providers with a custom claim (e.g. Zitadel).",
|
||||
"allowedUsers": "Allowed Users",
|
||||
"allowedUsersDesc": "One email per line. Leave empty to allow all.",
|
||||
"adminGroup": "Admin Group",
|
||||
"adminGroupDesc": "Users in this group are granted admin. Leave empty to disable group sync.",
|
||||
"caCert": "Custom CA Certificate",
|
||||
"caCertDesc": "Optional. PEM-encoded CA certificate for OIDC providers using a private or self-signed CA. Leave empty to use the system trust store."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Inicia la sessió amb SSO",
|
||||
"loginWithProvider": "Inicia la sessió amb {{name}}",
|
||||
"loading": "S'està carregant...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Gestionar proveïdors d'SSO",
|
||||
"description": "Afegiu, canvieu i elimineu els proveïdors d'SSO amb què els usuaris poden iniciar la sessió."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Veure documents",
|
||||
"none": "No hi ha cap proveïdor d'SSO configurat.",
|
||||
"add": "Afegeix un proveïdor",
|
||||
"edit": "Edita el proveïdor",
|
||||
"delete": "Suprimeix el proveïdor",
|
||||
"deleteConfirm": "Voleu suprimir aquest proveïdor? Els usuaris associats no podran iniciar la sessió.",
|
||||
"name": "Nom de visualització",
|
||||
"namePlaceholder": "El meu proveïdor d'SSO",
|
||||
"nameRequired": "Cal un nom de visualització",
|
||||
"type": "Tipus de proveïdor",
|
||||
"enabled": "Activat",
|
||||
"save": "Desa el proveïdor",
|
||||
"saving": "Desant...",
|
||||
"saved": "Desat",
|
||||
"saveFailed": "No s'ha pogut desar",
|
||||
"deleted": "Suprimit",
|
||||
"deleteFailed": "No s'ha pogut suprimir",
|
||||
"loadFailed": "No s'han pogut carregar els proveïdors d'SSO",
|
||||
"cancel": "Cancel·la",
|
||||
"copy": "Còpia",
|
||||
"copied": "Copiat",
|
||||
"redirectUri": "URI de redirecció",
|
||||
"redirectUriDesc": "Registra aquesta URL com a URL de redirecció o de devolució de trucada al teu proveïdor d'identitat.",
|
||||
"legacyCallback": "Mantingueu l'URI de redirecció antiga",
|
||||
"legacyCallbackDesc": "Aquest proveïdor es va configurar abans de Termix 2.9 i encara utilitza l'URI de redirecció antic, que continua funcionant. Per canviar al nou, afegiu primer {{uri}} al vostre proveïdor d'identitat i després desactiveu-lo.",
|
||||
"authorizationUrl": "URL d'autorització: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "ID de client",
|
||||
"clientSecret": "Secret del client",
|
||||
"secretKeep": "S'ha desat un secret. Deixa aquest espai buit per conservar-lo.",
|
||||
"issuerUrl": "URL de l'emissor",
|
||||
"authUrl": "URL d'autorització",
|
||||
"tokenUrl": "URL del testimoni",
|
||||
"userIdentifier": "Ruta d'identificació d'usuari",
|
||||
"displayName": "Camí del nom de visualització",
|
||||
"scopes": "Àmbits",
|
||||
"userinfoUrl": "Substitueix l'URL d'informació de l'usuari",
|
||||
"groupClaim": "Reclamació de grup",
|
||||
"groupClaimDesc": "Opcional. La ruta de reclamació que conté els grups de l'usuari. Per defecte, s'utilitzen grups, rols i, a continuació, grup. Feu servir això per a proveïdors amb una reclamació personalitzada (per exemple, Zitadel).",
|
||||
"allowedUsers": "Usuaris permesos",
|
||||
"allowedUsersDesc": "Un correu electrònic per línia. Deixeu-ho en blanc per permetre-ho tot.",
|
||||
"adminGroup": "Grup d'administradors",
|
||||
"adminGroupDesc": "Els usuaris d'aquest grup tenen dret d'administrador. Deixeu-ho en blanc per desactivar la sincronització del grup.",
|
||||
"caCert": "Certificat CA personalitzat",
|
||||
"caCertDesc": "Opcional. Certificat CA codificat amb PEM per a proveïdors OIDC que utilitzen una CA privada o autosignada. Deixeu-ho en blanc per utilitzar el magatzem de confiança del sistema."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Inicia la sessió amb SSO automàticament",
|
||||
"description": "Envia els visitants directament al primer proveïdor d'SSO en comptes de mostrar el formulari d'inici de sessió. La variable d'entorn OIDC_SILENT_LOGIN_DEFAULT ho anul·la."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Přihlásit se pomocí SSO",
|
||||
"loginWithProvider": "Přihlaste se pomocí {{name}}",
|
||||
"loading": "Načítání...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Správa poskytovatelů jednotného přihlašování (SSO)",
|
||||
"description": "Přidávat, měnit a odebírat poskytovatele SSO, přes které se lidé mohou přihlašovat."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Zobrazit dokumenty",
|
||||
"none": "Nejsou nakonfigurováni žádní poskytovatelé jednotného přihlašování (SSO).",
|
||||
"add": "Přidat poskytovatele",
|
||||
"edit": "Upravit poskytovatele",
|
||||
"delete": "Smazat poskytovatele",
|
||||
"deleteConfirm": "Smazat tohoto poskytovatele? Přidružení uživatelé se nebudou moci přihlásit.",
|
||||
"name": "Zobrazovaný název",
|
||||
"namePlaceholder": "Můj poskytovatel SSO",
|
||||
"nameRequired": "Je vyžadováno zobrazované jméno",
|
||||
"type": "Typ poskytovatele",
|
||||
"enabled": "Povoleno",
|
||||
"save": "Uložit poskytovatele",
|
||||
"saving": "Ukládání…",
|
||||
"saved": "Uloženo",
|
||||
"saveFailed": "Uložení se nezdařilo",
|
||||
"deleted": "Smazáno",
|
||||
"deleteFailed": "Smazání se nezdařilo",
|
||||
"loadFailed": "Načtení poskytovatelů SSO se nezdařilo",
|
||||
"cancel": "Zrušit",
|
||||
"copy": "Kopie",
|
||||
"copied": "Zkopírováno",
|
||||
"redirectUri": "URI přesměrování",
|
||||
"redirectUriDesc": "Zaregistrujte tuto URL adresu jako URL adresu pro přesměrování nebo zpětné volání u svého poskytovatele identity.",
|
||||
"legacyCallback": "Zachovat starý identifikátor URI přesměrování",
|
||||
"legacyCallbackDesc": "Tento poskytovatel byl nastaven před verzí Termix 2.9 a stále používá starý URI pro přesměrování, který funguje. Chcete-li přejít na nového, nejprve přidejte do svého poskytovatele identity {{uri}} a poté tuto možnost vypněte.",
|
||||
"authorizationUrl": "Autorizační URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "ID klienta",
|
||||
"clientSecret": "Tajemství klienta",
|
||||
"secretKeep": "Tajný kód je uložen. Pro jeho zachování nechte toto pole prázdné.",
|
||||
"issuerUrl": "URL vydavatele",
|
||||
"authUrl": "Autorizační URL",
|
||||
"tokenUrl": "URL tokenu",
|
||||
"userIdentifier": "Cesta k identifikátoru uživatele",
|
||||
"displayName": "Cesta k zobrazovanému názvu",
|
||||
"scopes": "Rozsahy",
|
||||
"userinfoUrl": "Přepsat URL s uživatelskými informacemi",
|
||||
"groupClaim": "Skupinový nárok",
|
||||
"groupClaimDesc": "Volitelné. Cesta k deklaraci identity, která obsahuje skupiny uživatele. Výchozí nastavení je skupiny, role a poté skupina. Použijte pro poskytovatele s vlastní deklarací identity (např. Zitadel).",
|
||||
"allowedUsers": "Povolení uživatelé",
|
||||
"allowedUsersDesc": "Jeden e-mail na řádek. Pro povolení všech ponechte prázdné.",
|
||||
"adminGroup": "Skupina administrátorů",
|
||||
"adminGroupDesc": "Uživatelé v této skupině mají oprávnění k administraci. Pro deaktivaci synchronizace skupiny ponechte pole prázdné.",
|
||||
"caCert": "Vlastní certifikát certifikační autority",
|
||||
"caCertDesc": "Volitelné. Certifikát certifikační autority s kódováním PEM pro poskytovatele OIDC používající soukromou nebo samopodepsanou certifikační autoritu. Pro použití systémového úložiště důvěryhodných certifikátů ponechte pole prázdné."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Automatické přihlášení pomocí SSO",
|
||||
"description": "Odesílejte návštěvníky přímo k prvnímu poskytovateli SSO namísto zobrazení přihlašovacího formuláře. Proměnná prostředí OIDC_SILENT_LOGIN_DEFAULT toto přepíše."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Log ind med SSO",
|
||||
"loginWithProvider": "Log ind med {{name}}",
|
||||
"loading": "Indlæser...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Administrer SSO-udbydere",
|
||||
"description": "Tilføj, rediger og fjern de SSO-udbydere, som folk kan logge ind med."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Se dokumenter",
|
||||
"none": "Ingen SSO-udbydere er konfigureret.",
|
||||
"add": "Tilføj udbyder",
|
||||
"edit": "Rediger udbyder",
|
||||
"delete": "Slet udbyder",
|
||||
"deleteConfirm": "Vil du slette denne udbyder? Tilknyttede brugere vil ikke kunne logge ind.",
|
||||
"name": "Vist navn",
|
||||
"namePlaceholder": "Min SSO-udbyder",
|
||||
"nameRequired": "Et visningsnavn er påkrævet",
|
||||
"type": "Udbydertype",
|
||||
"enabled": "Aktiveret",
|
||||
"save": "Gem udbyder",
|
||||
"saving": "Gemmer...",
|
||||
"saved": "Gemt",
|
||||
"saveFailed": "Kunne ikke gemme",
|
||||
"deleted": "Slettet",
|
||||
"deleteFailed": "Kunne ikke slette",
|
||||
"loadFailed": "Kunne ikke indlæse SSO-udbydere",
|
||||
"cancel": "Ophæve",
|
||||
"copy": "Kopi",
|
||||
"copied": "Kopieret",
|
||||
"redirectUri": "Omdirigerings-URI",
|
||||
"redirectUriDesc": "Registrer denne URL som omdirigerings- eller tilbagekalds-URL i din identitetsudbyder.",
|
||||
"legacyCallback": "Behold den gamle omdirigerings-URI",
|
||||
"legacyCallbackDesc": "Denne udbyder blev oprettet før Termix 2.9 og bruger stadig den gamle omdirigerings-URI, som fortsat fungerer. For at skifte til den nye skal du først tilføje {{uri}} i din identitetsudbyder og derefter slå dette fra.",
|
||||
"authorizationUrl": "Autorisations-URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Klient-ID",
|
||||
"clientSecret": "Klienthemmelighed",
|
||||
"secretKeep": "En hemmelighed er gemt. Lad dette felt være tomt for at beholde den.",
|
||||
"issuerUrl": "Udsteders URL",
|
||||
"authUrl": "Autorisations-URL",
|
||||
"tokenUrl": "Token-URL",
|
||||
"userIdentifier": "Brugeridentifikatorsti",
|
||||
"displayName": "Sti til visningsnavn",
|
||||
"scopes": "Omfang",
|
||||
"userinfoUrl": "Tilsidesæt brugerinfo-URL",
|
||||
"groupClaim": "Gruppekrav",
|
||||
"groupClaimDesc": "Valgfrit. Kravsstien, der indeholder brugerens grupper. Standardindstillingen er grupper, roller og derefter gruppe. Brug dette til udbydere med et brugerdefineret krav (f.eks. Zitadel).",
|
||||
"allowedUsers": "Tilladte brugere",
|
||||
"allowedUsersDesc": "Én e-mail pr. linje. Lad feltet stå tomt for at tillade alle.",
|
||||
"adminGroup": "Admingruppe",
|
||||
"adminGroupDesc": "Brugere i denne gruppe har administratorrettigheder. Lad feltet stå tomt for at deaktivere gruppesynkronisering.",
|
||||
"caCert": "Brugerdefineret CA-certifikat",
|
||||
"caCertDesc": "Valgfrit. PEM-kodet CA-certifikat til OIDC-udbydere, der bruger en privat eller selvsigneret CA. Lad feltet stå tomt for at bruge systemets tillidslager."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Log automatisk ind med SSO",
|
||||
"description": "Send besøgende direkte til den første SSO-udbyder i stedet for at vise loginformularen. Miljøvariablen OIDC_SILENT_LOGIN_DEFAULT tilsidesætter dette."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Mit SSO anmelden",
|
||||
"loginWithProvider": "Anmeldung mit {{name}}",
|
||||
"loading": "Laden...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "SSO-Anbieter verwalten",
|
||||
"description": "Fügen Sie SSO-Anbieter hinzu, ändern oder entfernen Sie diese, mit denen sich Benutzer anmelden können."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Dokumentation ansehen",
|
||||
"none": "Keine SSO-Anbieter konfiguriert.",
|
||||
"add": "Anbieter hinzufügen",
|
||||
"edit": "Anbieter bearbeiten",
|
||||
"delete": "Anbieter löschen",
|
||||
"deleteConfirm": "Diesen Anbieter löschen? Zugehörige Benutzer können sich nicht mehr anmelden.",
|
||||
"name": "Anzeigename",
|
||||
"namePlaceholder": "Mein SSO-Anbieter",
|
||||
"nameRequired": "Ein Anzeigename ist erforderlich.",
|
||||
"type": "Anbietertyp",
|
||||
"enabled": "Aktiviert",
|
||||
"save": "Anbieter speichern",
|
||||
"saving": "Wird gespeichert …",
|
||||
"saved": "Gespeichert",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"deleted": "Gelöscht",
|
||||
"deleteFailed": "Löschen fehlgeschlagen",
|
||||
"loadFailed": "SSO-Anbieter konnten nicht geladen werden",
|
||||
"cancel": "Abbrechen",
|
||||
"copy": "Kopieren",
|
||||
"copied": "Kopiert",
|
||||
"redirectUri": "Umleitungs-URI",
|
||||
"redirectUriDesc": "Registrieren Sie diese URL als Weiterleitungs- oder Rückruf-URL bei Ihrem Identitätsanbieter.",
|
||||
"legacyCallback": "Behalte die alte Umleitungs-URI bei.",
|
||||
"legacyCallbackDesc": "Dieser Anbieter wurde vor Termix 2.9 eingerichtet und verwendet noch die alte Umleitungs-URI, die weiterhin funktioniert. Um zur neuen URI zu wechseln, fügen Sie zunächst {{uri}} in Ihrem Identitätsanbieter hinzu und deaktivieren Sie diese Option anschließend.",
|
||||
"authorizationUrl": "Autorisierungs-URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Client-ID",
|
||||
"clientSecret": "Client-Secret",
|
||||
"secretKeep": "Ein Geheimnis ist gewahrt. Lassen Sie dieses Feld leer, um es zu bewahren.",
|
||||
"issuerUrl": "Issuer-URL",
|
||||
"authUrl": "Autorisierungs-URL",
|
||||
"tokenUrl": "Token-URL",
|
||||
"userIdentifier": "Benutzerkennungspfad",
|
||||
"displayName": "Anzeigename-Pfad",
|
||||
"scopes": "Scopes",
|
||||
"userinfoUrl": "Userinfo-URL überschreiben",
|
||||
"groupClaim": "Gruppen-Claim",
|
||||
"groupClaimDesc": "Optional. Der Claim-Pfad, der die Gruppen des Benutzers enthält. Standardmäßig groups, roles, dann group. Verwenden Sie dies für Anbieter mit einem benutzerdefinierten Claim (z. B. Zitadel).",
|
||||
"allowedUsers": "Zugelassene Benutzer",
|
||||
"allowedUsersDesc": "Eine E-Mail pro Zeile. Leer lassen, um alle zuzulassen.",
|
||||
"adminGroup": "Admin-Gruppe",
|
||||
"adminGroupDesc": "Benutzer in dieser Gruppe erhalten Admin-Berechtigungen. Leer lassen, um die Gruppensynchronisierung zu deaktivieren.",
|
||||
"caCert": "Benutzerdefiniertes CA-Zertifikat",
|
||||
"caCertDesc": "Optional. PEM-kodiertes CA-Zertifikat für OIDC-Anbieter, die eine private oder selbstsignierte Zertifizierungsstelle verwenden. Leer lassen, um den System-Vertrauensspeicher zu verwenden."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Automatische Anmeldung mit SSO",
|
||||
"description": "Besucher werden direkt zum ersten SSO-Anbieter weitergeleitet, anstatt das Anmeldeformular anzuzeigen. Die Umgebungsvariable OIDC_SILENT_LOGIN_DEFAULT überschreibt dies."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Σύνδεση με SSO",
|
||||
"loginWithProvider": "Σύνδεση με {{name}}",
|
||||
"loading": "Φόρτωση...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Διαχείριση παρόχων SSO",
|
||||
"description": "Προσθέστε, αλλάξτε και καταργήστε τους παρόχους SSO με τους οποίους μπορούν να συνδεθούν οι χρήστες."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Προβολή εγγράφων",
|
||||
"none": "Δεν έχουν ρυθμιστεί πάροχοι SSO.",
|
||||
"add": "Προσθήκη παρόχου",
|
||||
"edit": "Επεξεργασία παρόχου",
|
||||
"delete": "Διαγραφή παρόχου",
|
||||
"deleteConfirm": "Διαγραφή αυτού του παρόχου; Οι συνδεδεμένοι χρήστες δεν θα μπορούν να συνδεθούν.",
|
||||
"name": "Εμφανιζόμενο όνομα",
|
||||
"namePlaceholder": "Ο πάροχος SSO μου",
|
||||
"nameRequired": "Απαιτείται εμφανιζόμενο όνομα",
|
||||
"type": "Τύπος παρόχου",
|
||||
"enabled": "Ενεργοποιημένο",
|
||||
"save": "Αποθήκευση παρόχου",
|
||||
"saving": "Οικονομία...",
|
||||
"saved": "Αποθηκεύτηκε",
|
||||
"saveFailed": "Η αποθήκευση απέτυχε",
|
||||
"deleted": "Διαγράφηκε",
|
||||
"deleteFailed": "Η διαγραφή απέτυχε",
|
||||
"loadFailed": "Αποτυχία φόρτωσης παρόχων SSO",
|
||||
"cancel": "Ματαίωση",
|
||||
"copy": "Αντίγραφο",
|
||||
"copied": "Αντιγράφηκε",
|
||||
"redirectUri": "URI ανακατεύθυνσης",
|
||||
"redirectUriDesc": "Καταχωρίστε αυτήν τη διεύθυνση URL ως διεύθυνση URL ανακατεύθυνσης ή επανάκλησης στον πάροχο ταυτότητάς σας.",
|
||||
"legacyCallback": "Διατηρήστε το παλιό URI ανακατεύθυνσης",
|
||||
"legacyCallbackDesc": "Αυτός ο πάροχος είχε ρυθμιστεί πριν από το Termix 2.9 και εξακολουθεί να χρησιμοποιεί το παλιό URI ανακατεύθυνσης, το οποίο συνεχίζει να λειτουργεί. Για να μεταβείτε στον νέο, προσθέστε πρώτα το {{uri}} στον πάροχο ταυτότητάς σας και, στη συνέχεια, απενεργοποιήστε αυτήν τη λειτουργία.",
|
||||
"authorizationUrl": "URL εξουσιοδότησης: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Αναγνωριστικό πελάτη",
|
||||
"clientSecret": "Μυστικό Πελάτη",
|
||||
"secretKeep": "Ένα μυστικό αποθηκεύτηκε. Αφήστε το κενό για να το διατηρήσετε.",
|
||||
"issuerUrl": "URL εκδότη",
|
||||
"authUrl": "URL εξουσιοδότησης",
|
||||
"tokenUrl": "URL διακριτικού",
|
||||
"userIdentifier": "Διαδρομή αναγνωριστικού χρήστη",
|
||||
"displayName": "Διαδρομή ονόματος εμφάνισης",
|
||||
"scopes": "Πεδία",
|
||||
"userinfoUrl": "Παράκαμψη URL πληροφοριών χρήστη",
|
||||
"groupClaim": "Ομαδική αξίωση",
|
||||
"groupClaimDesc": "Προαιρετικό. Η διαδρομή αξίωσης που περιέχει τις ομάδες του χρήστη. Η προεπιλεγμένη τιμή είναι ομάδες, ρόλοι και στη συνέχεια ομάδα. Χρησιμοποιήστε αυτήν τη διαδρομή για παρόχους με προσαρμοσμένη αξίωση (π.χ. Zitadel).",
|
||||
"allowedUsers": "Επιτρεπόμενοι χρήστες",
|
||||
"allowedUsersDesc": "Ένα email ανά γραμμή. Αφήστε το κενό για να επιτρέπονται όλα.",
|
||||
"adminGroup": "Ομάδα Διαχειριστών",
|
||||
"adminGroupDesc": "Οι χρήστες σε αυτήν την ομάδα έχουν παραχωρηθεί ως διαχειριστές. Αφήστε το κενό για να απενεργοποιήσετε τον συγχρονισμό ομάδας.",
|
||||
"caCert": "Προσαρμοσμένο Πιστοποιητικό CA",
|
||||
"caCertDesc": "Προαιρετικό. Πιστοποιητικό CA με κωδικοποίηση PEM για παρόχους OIDC που χρησιμοποιούν ιδιωτική ή αυτο-υπογεγραμμένη αρχή έκδοσης πιστοποιητικών. Αφήστε το κενό για να χρησιμοποιήσετε τον χώρο αποθήκευσης αξιοπιστίας του συστήματος."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Αυτόματη σύνδεση με SSO",
|
||||
"description": "Στέλνει τους επισκέπτες απευθείας στον πρώτο πάροχο SSO αντί να εμφανίζει τη φόρμα σύνδεσης. Η μεταβλητή περιβάλλοντος OIDC_SILENT_LOGIN_DEFAULT παρακάμπτει αυτό."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Iniciar sesión con SSO",
|
||||
"loginWithProvider": "Iniciar sesión con {{name}}",
|
||||
"loading": "Cargando...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Gestionar proveedores de inicio de sesión único (SSO)",
|
||||
"description": "Agregue, cambie y elimine los proveedores de inicio de sesión único (SSO) con los que las personas pueden iniciar sesión."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Ver documentación",
|
||||
"none": "No hay proveedores SSO configurados.",
|
||||
"add": "Añadir proveedor",
|
||||
"edit": "Editar proveedor",
|
||||
"delete": "Eliminar proveedor",
|
||||
"deleteConfirm": "¿Eliminar este proveedor? Los usuarios asociados no podrán iniciar sesión.",
|
||||
"name": "Nombre para mostrar",
|
||||
"namePlaceholder": "Mi proveedor de SSO",
|
||||
"nameRequired": "Se requiere un nombre para mostrar.",
|
||||
"type": "Tipo de proveedor",
|
||||
"enabled": "Activado",
|
||||
"save": "Guardar proveedor",
|
||||
"saving": "Guardando...",
|
||||
"saved": "Guardado",
|
||||
"saveFailed": "Error al guardar",
|
||||
"deleted": "Eliminado",
|
||||
"deleteFailed": "Error al eliminar",
|
||||
"loadFailed": "No se pudieron cargar los proveedores de SSO.",
|
||||
"cancel": "Cancelar",
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado",
|
||||
"redirectUri": "URI de redirección",
|
||||
"redirectUriDesc": "Registra esta URL como URL de redireccionamiento o de devolución de llamada en tu proveedor de identidad.",
|
||||
"legacyCallback": "Mantén la URI de redireccionamiento antigua.",
|
||||
"legacyCallbackDesc": "Este proveedor se configuró antes de Termix 2.9 y aún utiliza la URI de redireccionamiento antigua, que sigue funcionando. Para cambiar a la nueva, primero añade {{uri}} a tu proveedor de identidad y luego desactiva esta opción.",
|
||||
"authorizationUrl": "URL de autorización: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "ID de cliente",
|
||||
"clientSecret": "Secreto de cliente",
|
||||
"secretKeep": "Un secreto está guardado. Deja esto en blanco para conservarlo.",
|
||||
"issuerUrl": "URL del emisor",
|
||||
"authUrl": "URL de autorización",
|
||||
"tokenUrl": "URL del token",
|
||||
"userIdentifier": "Ruta del identificador de usuario",
|
||||
"displayName": "Ruta del nombre para mostrar",
|
||||
"scopes": "Ámbitos",
|
||||
"userinfoUrl": "Sobrescribir URL de Userinfo",
|
||||
"groupClaim": "Claim de grupo",
|
||||
"groupClaimDesc": "Opcional. La ruta del claim que contiene los grupos del usuario. Por defecto, se usan 'groups', 'roles' y luego 'group'. Úselo para proveedores con un claim personalizado (p. ej. Zitadel).",
|
||||
"allowedUsers": "Usuarios permitidos",
|
||||
"allowedUsersDesc": "Un correo electrónico por línea. Déjelo vacío para permitir todos.",
|
||||
"adminGroup": "Grupo de administradores",
|
||||
"adminGroupDesc": "Los usuarios de este grupo reciben permisos de administrador. Déjelo vacío para desactivar la sincronización de grupos.",
|
||||
"caCert": "Certificado CA personalizado",
|
||||
"caCertDesc": "Opcional. Certificado CA codificado en PEM para proveedores OIDC que utilicen una CA privada o autofirmada. Déjelo vacío para usar el almacén de confianza del sistema."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Inicia sesión automáticamente con SSO.",
|
||||
"description": "En lugar de mostrar el formulario de inicio de sesión, redirige a los visitantes directamente al primer proveedor de SSO. La variable de entorno OIDC_SILENT_LOGIN_DEFAULT anula esta configuración."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Kirjaudu sisään kertakirjautumisella",
|
||||
"loginWithProvider": "Kirjaudu sisään tunnuksella {{name}}",
|
||||
"loading": "Ladataan...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Hallinnoi kertakirjautumispalveluntarjoajia",
|
||||
"description": "Lisää, muuta ja poista kertakirjautumisen tarjoajia, joilla käyttäjät voivat kirjautua sisään."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Näytä dokumentit",
|
||||
"none": "SSO-palveluntarjoajia ei ole määritetty.",
|
||||
"add": "Lisää palveluntarjoaja",
|
||||
"edit": "Muokkaa palveluntarjoajaa",
|
||||
"delete": "Poista palveluntarjoaja",
|
||||
"deleteConfirm": "Poistetaanko tämä palveluntarjoaja? Liitännäiset käyttäjät eivät voi kirjautua sisään.",
|
||||
"name": "Näyttönimi",
|
||||
"namePlaceholder": "SSO-palveluntarjoajani",
|
||||
"nameRequired": "Näyttönimi on pakollinen",
|
||||
"type": "Palveluntarjoajan tyyppi",
|
||||
"enabled": "Käytössä",
|
||||
"save": "Tallenna palveluntarjoaja",
|
||||
"saving": "Tallennetaan...",
|
||||
"saved": "Tallennettu",
|
||||
"saveFailed": "Tallennus epäonnistui",
|
||||
"deleted": "Poistettu",
|
||||
"deleteFailed": "Poistaminen epäonnistui",
|
||||
"loadFailed": "SSO-palveluntarjoajien lataaminen epäonnistui",
|
||||
"cancel": "Peruuttaa",
|
||||
"copy": "Kopioida",
|
||||
"copied": "Kopioitu",
|
||||
"redirectUri": "Uudelleenohjauksen URI",
|
||||
"redirectUriDesc": "Rekisteröi tämä URL-osoite uudelleenohjaus- tai takaisinkutsuosoitteeksi identiteetintarjoajassasi.",
|
||||
"legacyCallback": "Säilytä vanha uudelleenohjaus-URI",
|
||||
"legacyCallbackDesc": "Tämä palveluntarjoaja määritettiin ennen Termix 2.9:ää ja käyttää edelleen vanhaa uudelleenohjaus-URI:a, joka toimii edelleen. Siirry uuteen lisäämällä ensin {{uri}} identiteetintarjoajaasi ja poistamalla tämä sitten käytöstä.",
|
||||
"authorizationUrl": "Valtuutus-URL-osoite: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Asiakastunnus",
|
||||
"clientSecret": "Asiakkaan salaisuus",
|
||||
"secretKeep": "Salaisuus on tallennettu. Jätä tämä tyhjäksi, jos haluat sen säilyvän.",
|
||||
"issuerUrl": "Liikkeeseenlaskijan URL-osoite",
|
||||
"authUrl": "Valtuutus-URL-osoite",
|
||||
"tokenUrl": "Tunnuksen URL-osoite",
|
||||
"userIdentifier": "Käyttäjätunnisteen polku",
|
||||
"displayName": "Näyttönimen polku",
|
||||
"scopes": "Laajuusalueet",
|
||||
"userinfoUrl": "Käyttäjätietojen URL-osoitteen ohitus",
|
||||
"groupClaim": "Ryhmävaatimus",
|
||||
"groupClaimDesc": "Valinnainen. Vaatimuspolku, joka sisältää käyttäjän ryhmät. Oletusarvo on ryhmät, roolit ja lopuksi ryhmä. Käytä tätä palveluntarjoajille, joilla on mukautettu vaatimus (esim. Zitadel).",
|
||||
"allowedUsers": "Sallitut käyttäjät",
|
||||
"allowedUsersDesc": "Yksi sähköpostiosoite riviä kohden. Jätä tyhjäksi salliaksesi kaikki.",
|
||||
"adminGroup": "Hallintaryhmä",
|
||||
"adminGroupDesc": "Tämän ryhmän käyttäjille on myönnetty järjestelmänvalvojan oikeudet. Jätä tyhjäksi, jos haluat poistaa ryhmän synkronoinnin käytöstä.",
|
||||
"caCert": "Mukautettu CA-varmenne",
|
||||
"caCertDesc": "Valinnainen. PEM-koodattu CA-varmenne OIDC-palveluntarjoajille, jotka käyttävät yksityistä tai itse allekirjoitettua CA:ta. Jätä tyhjäksi, jos haluat käyttää järjestelmän luotettavuussäilöä."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Kirjaudu sisään automaattisesti kertakirjautumisella",
|
||||
"description": "Ohjaa kävijät suoraan ensimmäiselle SSO-palveluntarjoajalle kirjautumislomakkeen näyttämisen sijaan. OIDC_SILENT_LOGIN_DEFAULT-ympäristömuuttuja korvaa tämän."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Se connecter avec l'authentification unique",
|
||||
"loginWithProvider": "Se connecter avec {{name}}",
|
||||
"loading": "Chargement...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Gérer les fournisseurs SSO",
|
||||
"description": "Ajouter, modifier et supprimer les fournisseurs d'authentification unique (SSO) avec lesquels les utilisateurs peuvent se connecter."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Voir la documentation",
|
||||
"none": "Aucun fournisseur SSO configuré.",
|
||||
"add": "Ajouter un fournisseur",
|
||||
"edit": "Modifier le fournisseur",
|
||||
"delete": "Supprimer le fournisseur",
|
||||
"deleteConfirm": "Supprimer ce fournisseur ? Les utilisateurs associés ne pourront plus se connecter.",
|
||||
"name": "Nom affiché",
|
||||
"namePlaceholder": "Mon fournisseur SSO",
|
||||
"nameRequired": "Un nom d'affichage est requis.",
|
||||
"type": "Type de fournisseur",
|
||||
"enabled": "Activé",
|
||||
"save": "Enregistrer le fournisseur",
|
||||
"saving": "Enregistrement…",
|
||||
"saved": "Enregistré",
|
||||
"saveFailed": "Échec de l'enregistrement",
|
||||
"deleted": "Supprimé",
|
||||
"deleteFailed": "Échec de la suppression",
|
||||
"loadFailed": "Échec du chargement des fournisseurs SSO",
|
||||
"cancel": "Annuler",
|
||||
"copy": "Copier",
|
||||
"copied": "Copié",
|
||||
"redirectUri": "URI de redirection",
|
||||
"redirectUriDesc": "Enregistrez cette URL comme URL de redirection ou de rappel auprès de votre fournisseur d'identité.",
|
||||
"legacyCallback": "Conserver l'ancienne URI de redirection",
|
||||
"legacyCallbackDesc": "Ce fournisseur a été configuré avant Termix 2.9 et utilise toujours l'ancienne URI de redirection, qui reste fonctionnelle. Pour passer à la nouvelle, ajoutez d'abord {{uri}} à votre fournisseur d'identité, puis désactivez-le.",
|
||||
"authorizationUrl": "URL d'autorisation : {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "ID client",
|
||||
"clientSecret": "Secret client",
|
||||
"secretKeep": "Un secret est préservé. Laissez cet emplacement vide pour le conserver.",
|
||||
"issuerUrl": "URL de l'émetteur",
|
||||
"authUrl": "URL d'autorisation",
|
||||
"tokenUrl": "URL du jeton",
|
||||
"userIdentifier": "Chemin de l'identifiant utilisateur",
|
||||
"displayName": "Chemin du nom affiché",
|
||||
"scopes": "Scopes",
|
||||
"userinfoUrl": "URL Userinfo personnalisée",
|
||||
"groupClaim": "Revendication de groupe",
|
||||
"groupClaimDesc": "Facultatif. Chemin de la revendication contenant les groupes de l'utilisateur. Par défaut : groups, roles, puis group. Utilisez ce champ pour les fournisseurs avec une revendication personnalisée (ex. Zitadel).",
|
||||
"allowedUsers": "Utilisateurs autorisés",
|
||||
"allowedUsersDesc": "Une adresse e-mail par ligne. Laissez vide pour autoriser tout le monde.",
|
||||
"adminGroup": "Groupe administrateur",
|
||||
"adminGroupDesc": "Les utilisateurs de ce groupe ont les droits administrateur. Laissez vide pour désactiver la synchronisation de groupe.",
|
||||
"caCert": "Certificat CA personnalisé",
|
||||
"caCertDesc": "Facultatif. Certificat CA au format PEM pour les fournisseurs OIDC utilisant une CA privée ou auto-signée. Laissez vide pour utiliser le magasin de confiance du système."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Connectez-vous automatiquement avec l'authentification unique (SSO).",
|
||||
"description": "Dirigez les visiteurs directement vers le premier fournisseur d'authentification unique (SSO) au lieu d'afficher le formulaire de connexion. La variable d'environnement OIDC_SILENT_LOGIN_DEFAULT remplace ce comportement."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "כניסה באמצעות כניסה יחידה (SSO)",
|
||||
"loginWithProvider": "התחברות באמצעות {{name}}",
|
||||
"loading": "טְעִינָה...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "ניהול ספקי SSO",
|
||||
"description": "הוספה, שינוי והסרה של ספקי SSO שאנשים יכולים להתחבר איתם."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "הצג מסמכים",
|
||||
"none": "לא הוגדרו ספקי SSO.",
|
||||
"add": "הוסף ספק",
|
||||
"edit": "עריכת ספק",
|
||||
"delete": "מחיקת ספק",
|
||||
"deleteConfirm": "למחוק ספק זה? משתמשים משויכים לא יוכלו להתחבר.",
|
||||
"name": "שם תצוגה",
|
||||
"namePlaceholder": "ספק ה-SSO שלי",
|
||||
"nameRequired": "נדרש שם תצוגה",
|
||||
"type": "סוג ספק",
|
||||
"enabled": "מופעל",
|
||||
"save": "שמור ספק",
|
||||
"saving": "חִסָכוֹן...",
|
||||
"saved": "נשמר",
|
||||
"saveFailed": "השמירה נכשלה",
|
||||
"deleted": "נמחק",
|
||||
"deleteFailed": "המחיקה נכשלה",
|
||||
"loadFailed": "נכשלה טעינת ספקי SSO",
|
||||
"cancel": "לְבַטֵל",
|
||||
"copy": "לְהַעְתִיק",
|
||||
"copied": "מוּעֲתָק",
|
||||
"redirectUri": "כתובת URL להפניה מחדש",
|
||||
"redirectUriDesc": "רשום כתובת URL זו ככתובת URL להפניה או לקריאה חוזרת בספק הזהויות שלך.",
|
||||
"legacyCallback": "שמור את כתובת ה-URI הישנה להפניה",
|
||||
"legacyCallbackDesc": "ספק זה הוגדר לפני Termix 2.9 ועדיין משתמש ב-URI להפניה מחדש הישן, שממשיך לעבוד. כדי לעבור ל-URI החדש, הוסף תחילה {{uri}} לספק הזהויות שלך, ולאחר מכן כבה אפשרות זו.",
|
||||
"authorizationUrl": "כתובת אתר לאימות: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "מזהה לקוח",
|
||||
"clientSecret": "סוד הלקוח",
|
||||
"secretKeep": "סוד נשמר. השאר שדה זה ריק כדי לשמור אותו.",
|
||||
"issuerUrl": "כתובת URL של המנפיק",
|
||||
"authUrl": "כתובת אתר להרשאה",
|
||||
"tokenUrl": "כתובת אתר של אסימון",
|
||||
"userIdentifier": "נתיב מזהה המשתמש",
|
||||
"displayName": "נתיב שם התצוגה",
|
||||
"scopes": "טווחים",
|
||||
"userinfoUrl": "עקיפת כתובת URL של פרטי משתמש",
|
||||
"groupClaim": "תביעה קבוצתית",
|
||||
"groupClaimDesc": "אופציונלי. נתיב התביעה המכיל את קבוצות המשתמש. ברירת המחדל היא קבוצות, תפקידים ולאחר מכן קבוצה. השתמש באפשרות זו עבור ספקים עם תביעה מותאמת אישית (למשל Zitadel).",
|
||||
"allowedUsers": "משתמשים מורשים",
|
||||
"allowedUsersDesc": "אימייל אחד בכל שורה. יש להשאיר ריק כדי לאפשר את כולם.",
|
||||
"adminGroup": "קבוצת ניהול",
|
||||
"adminGroupDesc": "למשתמשים בקבוצה זו יש הרשאה לניהול. יש להשאיר ריק כדי להשבית את סנכרון הקבוצה.",
|
||||
"caCert": "תעודת CA מותאמת אישית",
|
||||
"caCertDesc": "אופציונלי. אישור CA מקודד PEM עבור ספקי OIDC המשתמשים באישור CA פרטי או חתום עצמית. השאר ריק כדי להשתמש במאגר האמון של המערכת."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "כניסה אוטומטית באמצעות SSO",
|
||||
"description": "שלח מבקרים ישירות לספק ה-SSO הראשון במקום להציג את טופס ההתחברות. משתנה הסביבה OIDC_SILENT_LOGIN_DEFAULT עוקף זאת."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "{{name}} से लॉगिन करें",
|
||||
"loading": "लोड हो रहा है...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "दस्तावेज़ देखें",
|
||||
"none": "कोई SSO प्रदाता कॉन्फ़िगर नहीं है।",
|
||||
"add": "प्रदाता जोड़ें",
|
||||
"edit": "प्रदाता संपादित करें",
|
||||
"delete": "प्रदाता हटाएँ",
|
||||
"deleteConfirm": "इस प्रदाता को हटाएँ? इससे जुड़े उपयोगकर्ता लॉगिन नहीं कर पाएंगे।",
|
||||
"name": "प्रदर्शन नाम",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "प्रदाता प्रकार",
|
||||
"enabled": "सक्षम",
|
||||
"save": "प्रदाता सहेजें",
|
||||
"saving": "सहेजा जा रहा है...",
|
||||
"saved": "सहेजा गया",
|
||||
"saveFailed": "सहेजने में विफल",
|
||||
"deleted": "हटाया गया",
|
||||
"deleteFailed": "हटाने में विफल",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "रद्द करें",
|
||||
"copy": "कॉपी",
|
||||
"copied": "कॉपी किया गया",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "क्लाइंट आईडी",
|
||||
"clientSecret": "क्लाइंट सीक्रेट",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "जारीकर्ता URL",
|
||||
"authUrl": "प्राधिकरण URL",
|
||||
"tokenUrl": "टोकन URL",
|
||||
"userIdentifier": "उपयोगकर्ता पहचानकर्ता पथ",
|
||||
"displayName": "प्रदर्शन नाम पथ",
|
||||
"scopes": "स्कोप",
|
||||
"userinfoUrl": "Userinfo URL ओवरराइड करें",
|
||||
"groupClaim": "समूह दावा",
|
||||
"groupClaimDesc": "वैकल्पिक। वह दावा पथ जिसमें उपयोगकर्ता के समूह शामिल हैं। डिफ़ॉल्ट रूप से groups, roles, फिर group होता है। कस्टम दावे वाले प्रदाताओं के लिए इसका उपयोग करें (जैसे Zitadel)।",
|
||||
"allowedUsers": "अनुमत उपयोगकर्ता",
|
||||
"allowedUsersDesc": "प्रति पंक्ति एक ईमेल। सभी को अनुमति देने के लिए खाली छोड़ें।",
|
||||
"adminGroup": "व्यवस्थापक समूह",
|
||||
"adminGroupDesc": "इस समूह के उपयोगकर्ताओं को व्यवस्थापक अधिकार दिए जाते हैं। समूह सिंक अक्षम करने के लिए खाली छोड़ें।",
|
||||
"caCert": "कस्टम CA प्रमाणपत्र",
|
||||
"caCertDesc": "वैकल्पिक। निजी या स्व-हस्ताक्षरित CA का उपयोग करने वाले OIDC प्रदाताओं के लिए PEM-एन्कोडेड CA प्रमाणपत्र। सिस्टम ट्रस्ट स्टोर का उपयोग करने के लिए खाली छोड़ें।"
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Bejelentkezés egyszeri bejelentkezéssel",
|
||||
"loginWithProvider": "Bejelentkezés {{name}} használatával",
|
||||
"loading": "Terhelés...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "SSO-szolgáltatók kezelése",
|
||||
"description": "Hozzáadhatja, módosíthatja és eltávolíthatja azokat az SSO-szolgáltatókat, amelyekkel a felhasználók bejelentkezhetnek."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Dokumentumok megtekintése",
|
||||
"none": "Nincsenek konfigurálva SSO-szolgáltatók.",
|
||||
"add": "Szolgáltató hozzáadása",
|
||||
"edit": "Szolgáltató szerkesztése",
|
||||
"delete": "Szolgáltató törlése",
|
||||
"deleteConfirm": "Törli ezt a szolgáltatót? A társított felhasználók nem fognak tudni bejelentkezni.",
|
||||
"name": "Megjelenített név",
|
||||
"namePlaceholder": "Az SSO-szolgáltatóm",
|
||||
"nameRequired": "Megjelenített név megadása kötelező",
|
||||
"type": "Szolgáltató típusa",
|
||||
"enabled": "Engedélyezve",
|
||||
"save": "Szolgáltató mentése",
|
||||
"saving": "Megtakarítás...",
|
||||
"saved": "Mentett",
|
||||
"saveFailed": "Nem sikerült menteni",
|
||||
"deleted": "Törölt",
|
||||
"deleteFailed": "Nem sikerült törölni",
|
||||
"loadFailed": "Nem sikerült betölteni az SSO-szolgáltatókat",
|
||||
"cancel": "Mégsem",
|
||||
"copy": "Másolat",
|
||||
"copied": "Másolva",
|
||||
"redirectUri": "Átirányítási URI",
|
||||
"redirectUriDesc": "Regisztrálja ezt az URL-címet átirányítási vagy visszahívási URL-címként az identitásszolgáltatójában.",
|
||||
"legacyCallback": "Tartsa meg a régi átirányítási URI-t",
|
||||
"legacyCallbackDesc": "Ez a szolgáltató a Termix 2.9 előtt lett beállítva, és továbbra is a régi átirányítási URI-t használja, amely továbbra is működik. Az újra való áttéréshez először add hozzá a {{uri}} karakterláncot az identitásszolgáltatódhoz, majd kapcsold ki ezt.",
|
||||
"authorizationUrl": "Engedélyezési URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Ügyfél-azonosító",
|
||||
"clientSecret": "Ügyféltitkos kód",
|
||||
"secretKeep": "Egy titok elmentve. Hagyd ezt üresen, hogy megmaradjon.",
|
||||
"issuerUrl": "Kibocsátó URL-címe",
|
||||
"authUrl": "Engedélyezési URL",
|
||||
"tokenUrl": "Token URL",
|
||||
"userIdentifier": "Felhasználói azonosító útvonala",
|
||||
"displayName": "Megjelenített név útvonala",
|
||||
"scopes": "Hatókör",
|
||||
"userinfoUrl": "Felhasználói adatok URL-címének felülbírálása",
|
||||
"groupClaim": "Csoportos követelés",
|
||||
"groupClaimDesc": "Opcionális. A felhasználó csoportjait tartalmazó igénylési útvonal. Alapértelmezés szerint csoportok, szerepkörök, majd csoport. Ezt egyéni igényléssel rendelkező szolgáltatóknál használja (pl. Zitadel).",
|
||||
"allowedUsers": "Engedélyezett felhasználók",
|
||||
"allowedUsersDesc": "Egy e-mail cím soronként. Hagyja üresen, ha az összeset engedélyezi.",
|
||||
"adminGroup": "Adminisztrátori csoport",
|
||||
"adminGroupDesc": "A csoport felhasználói adminisztrátori jogosultsággal rendelkeznek. Hagyja üresen a csoport szinkronizálásának letiltásához.",
|
||||
"caCert": "Egyéni CA tanúsítvány",
|
||||
"caCertDesc": "Opcionális. PEM-kódolású CA-tanúsítvány OIDC-szolgáltatók számára, privát vagy önaláírt CA-t használva. Hagyja üresen a rendszer megbízható tárolójának használatához."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Automatikus bejelentkezés egyszeri bejelentkezéssel",
|
||||
"description": "A látogatókat közvetlenül az első SSO-szolgáltatóhoz irányítsa a bejelentkezési űrlap megjelenítése helyett. Az OIDC_SILENT_LOGIN_DEFAULT környezeti változó felülírja ezt."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Loading...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "View docs",
|
||||
"none": "No SSO providers configured.",
|
||||
"add": "Add Provider",
|
||||
"edit": "Edit Provider",
|
||||
"delete": "Delete Provider",
|
||||
"deleteConfirm": "Delete this provider? Associated users will be unable to login.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "Provider Type",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save Provider",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "Cancel",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "Issuer URL",
|
||||
"authUrl": "Authorization URL",
|
||||
"tokenUrl": "Token URL",
|
||||
"userIdentifier": "User Identifier Path",
|
||||
"displayName": "Display Name Path",
|
||||
"scopes": "Scopes",
|
||||
"userinfoUrl": "Override Userinfo URL",
|
||||
"groupClaim": "Group Claim",
|
||||
"groupClaimDesc": "Optional. The claim path that contains the user's groups. Defaults to groups, roles, then group. Use this for providers with a custom claim (e.g. Zitadel).",
|
||||
"allowedUsers": "Allowed Users",
|
||||
"allowedUsersDesc": "One email per line. Leave empty to allow all.",
|
||||
"adminGroup": "Admin Group",
|
||||
"adminGroupDesc": "Users in this group are granted admin. Leave empty to disable group sync.",
|
||||
"caCert": "Custom CA Certificate",
|
||||
"caCertDesc": "Optional. PEM-encoded CA certificate for OIDC providers using a private or self-signed CA. Leave empty to use the system trust store."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Accedi con SSO",
|
||||
"loginWithProvider": "Accedi con {{name}}",
|
||||
"loading": "Caricamento...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Gestire i provider SSO",
|
||||
"description": "Aggiungi, modifica e rimuovi i provider SSO con cui gli utenti possono accedere."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Vedi documenti",
|
||||
"none": "Nessun provider SSO configurato.",
|
||||
"add": "Aggiungi provider",
|
||||
"edit": "Modifica provider",
|
||||
"delete": "Elimina provider",
|
||||
"deleteConfirm": "Eliminare questo provider? Gli utenti associati non potranno più accedere.",
|
||||
"name": "Nome visualizzato",
|
||||
"namePlaceholder": "Il mio provider SSO",
|
||||
"nameRequired": "È richiesto un nome visualizzato",
|
||||
"type": "Tipo provider",
|
||||
"enabled": "Abilitato",
|
||||
"save": "Salva provider",
|
||||
"saving": "Salvando...",
|
||||
"saved": "Salvato",
|
||||
"saveFailed": "Salvataggio non riuscito",
|
||||
"deleted": "Eliminato",
|
||||
"deleteFailed": "Eliminazione non riuscita",
|
||||
"loadFailed": "Impossibile caricare i provider SSO",
|
||||
"cancel": "Annulla",
|
||||
"copy": "Copia",
|
||||
"copied": "Copiato",
|
||||
"redirectUri": "URI di reindirizzamento",
|
||||
"redirectUriDesc": "Registra questo URL come URL di reindirizzamento o di callback nel tuo provider di identità.",
|
||||
"legacyCallback": "Mantieni il vecchio URI di reindirizzamento",
|
||||
"legacyCallbackDesc": "Questo provider è stato configurato prima di Termix 2.9 e utilizza ancora il vecchio URI di reindirizzamento, che continua a funzionare. Per passare a quello nuovo, aggiungi prima {{uri}} al tuo provider di identità, quindi disattiva questa opzione.",
|
||||
"authorizationUrl": "URL di autorizzazione: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "ID client",
|
||||
"clientSecret": "Segreto client",
|
||||
"secretKeep": "Un segreto è stato custodito. Lascia questo campo vuoto per mantenerlo.",
|
||||
"issuerUrl": "URL dell'emittente",
|
||||
"authUrl": "URL di autorizzazione",
|
||||
"tokenUrl": "URL token",
|
||||
"userIdentifier": "Percorso identificatore utente",
|
||||
"displayName": "Percorso nome visualizzato",
|
||||
"scopes": "Ambiti",
|
||||
"userinfoUrl": "Sovrascrivi URL Userinfo",
|
||||
"groupClaim": "Attributo gruppo",
|
||||
"groupClaimDesc": "Opzionale. Il percorso dell'attributo contenente i gruppi dell'utente. Il valore predefinito è groups, roles, quindi group. Da utilizzare per provider con un attributo personalizzato (es. Zitadel).",
|
||||
"allowedUsers": "Utenti consentiti",
|
||||
"allowedUsersDesc": "Un'email per riga. Lascia vuoto per consentire a tutti.",
|
||||
"adminGroup": "Gruppo amministratori",
|
||||
"adminGroupDesc": "Gli utenti di questo gruppo ottengono i diritti di amministratore. Lascia vuoto per disabilitare la sincronizzazione dei gruppi.",
|
||||
"caCert": "Certificato CA personalizzato",
|
||||
"caCertDesc": "Opzionale. Certificato CA in formato PEM per provider OIDC che utilizzano una CA privata o autofirmata. Lascia vuoto per usare l'archivio dei certificati attendibili di sistema."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Accedi automaticamente con SSO",
|
||||
"description": "Indirizza i visitatori direttamente al primo provider SSO anziché mostrare il modulo di accesso. La variabile d'ambiente OIDC_SILENT_LOGIN_DEFAULT sovrascrive questa impostazione."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "SSOでサインイン",
|
||||
"loginWithProvider": "{{name}}でログイン",
|
||||
"loading": "読み込み中...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "SSOプロバイダーを管理する",
|
||||
"description": "ユーザーがサインインに使用できるSSOプロバイダーを追加、変更、削除します。"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "ドキュメントを見る",
|
||||
"none": "SSOプロバイダーが設定されていません。",
|
||||
"add": "追加",
|
||||
"edit": "編集",
|
||||
"delete": "削除",
|
||||
"deleteConfirm": "このプロバイダーを削除しますか?関連付けられているユーザーはログインできなくなります。",
|
||||
"name": "表示名",
|
||||
"namePlaceholder": "私のSSOプロバイダー",
|
||||
"nameRequired": "表示名は必須です",
|
||||
"type": "プロバイダータイプ",
|
||||
"enabled": "有効",
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
"saved": "保存しました",
|
||||
"saveFailed": "保存に失敗しました",
|
||||
"deleted": "削除しました",
|
||||
"deleteFailed": "削除に失敗しました",
|
||||
"loadFailed": "SSOプロバイダーの読み込みに失敗しました",
|
||||
"cancel": "キャンセル",
|
||||
"copy": "コピー",
|
||||
"copied": "コピーしました",
|
||||
"redirectUri": "リダイレクトURI",
|
||||
"redirectUriDesc": "このURLを、ご利用のIDプロバイダーのリダイレクトURLまたはコールバックURLとして登録してください。",
|
||||
"legacyCallback": "古いリダイレクトURIを保持する",
|
||||
"legacyCallbackDesc": "このプロバイダはTermix 2.9より前に設定されたもので、古いリダイレクトURIがまだ使用されていますが、これは引き続き機能します。新しいURIに移行するには、まずIDプロバイダに {{uri}} を追加し、その後この設定を無効にしてください。",
|
||||
"authorizationUrl": "認証URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "クライアントID",
|
||||
"clientSecret": "クライアントシークレット",
|
||||
"secretKeep": "秘密が守られました。秘密を守るには、この欄を空欄のままにしてください。",
|
||||
"issuerUrl": "発行者URL",
|
||||
"authUrl": "認証URL",
|
||||
"tokenUrl": "トークンURL",
|
||||
"userIdentifier": "ユーザー識別子パス",
|
||||
"displayName": "表示名パス",
|
||||
"scopes": "スコープ",
|
||||
"userinfoUrl": "Userinfo URLの上書き",
|
||||
"groupClaim": "グループクレーム",
|
||||
"groupClaimDesc": "オプション。ユーザーのグループを含むクレームパス。デフォルトは groups, roles, group の順。カスタムクレームを使うプロバイダー用 (例: Zitadel)。",
|
||||
"allowedUsers": "許可ユーザー",
|
||||
"allowedUsersDesc": "1行に1つのメールアドレス。空の場合は全員許可。",
|
||||
"adminGroup": "管理者グループ",
|
||||
"adminGroupDesc": "このグループのユーザーは管理者権限が付与されます。空にするとグループ同期は無効です。",
|
||||
"caCert": "カスタムCA証明書",
|
||||
"caCertDesc": "オプション。プライベートまたは自己署名CAを使用するOIDCプロバイダー用のPEMエンコードCA証明書。空の場合はシステムの信頼ストアを使用。"
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "SSOで自動的にサインイン",
|
||||
"description": "ログインフォームを表示せずに、訪問者を最初のSSOプロバイダーに直接誘導します。この設定は、環境変数OIDC_SILENT_LOGIN_DEFAULTで上書きされます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "SSO로 로그인",
|
||||
"loginWithProvider": "{{name}}으로 로그인",
|
||||
"loading": "로딩 중...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "SSO 제공업체 관리",
|
||||
"description": "사용자가 로그인할 수 있는 SSO 제공업체를 추가, 변경 및 삭제할 수 있습니다."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "문서 보기",
|
||||
"none": "설정된 SSO 제공자가 없습니다.",
|
||||
"add": "제공자 추가",
|
||||
"edit": "제공자 편집",
|
||||
"delete": "제공자 삭제",
|
||||
"deleteConfirm": "이 제공자를 삭제하시겠습니까? 연결된 사용자는 로그인할 수 없습니다.",
|
||||
"name": "표시 이름",
|
||||
"namePlaceholder": "내 SSO 제공업체",
|
||||
"nameRequired": "표시 이름은 필수입니다.",
|
||||
"type": "제공자 유형",
|
||||
"enabled": "사용",
|
||||
"save": "제공자 저장",
|
||||
"saving": "저장 중...",
|
||||
"saved": "저장됨",
|
||||
"saveFailed": "저장 실패",
|
||||
"deleted": "삭제됨",
|
||||
"deleteFailed": "삭제 실패",
|
||||
"loadFailed": "SSO 공급자를 로드하는 데 실패했습니다.",
|
||||
"cancel": "취소",
|
||||
"copy": "복사",
|
||||
"copied": "복사됨",
|
||||
"redirectUri": "리디렉션 URI",
|
||||
"redirectUriDesc": "이 URL을 ID 공급자에서 리디렉션 또는 콜백 URL로 등록하세요.",
|
||||
"legacyCallback": "기존 리디렉션 URI를 유지하세요",
|
||||
"legacyCallbackDesc": "이 공급자는 Termix 2.9 이전에 설정되었으며 여전히 이전 리디렉션 URI를 사용하고 있지만 정상적으로 작동합니다. 새 URI로 전환하려면 먼저 ID 공급자에 {{uri}} 를 추가한 다음 이 기능을 비활성화하십시오.",
|
||||
"authorizationUrl": "인증 URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "클라이언트 ID",
|
||||
"clientSecret": "클라이언트 시크릿",
|
||||
"secretKeep": "비밀이 저장되었습니다. 비밀을 지키려면 이 칸을 비워 두세요.",
|
||||
"issuerUrl": "Issuer URL",
|
||||
"authUrl": "인증 URL",
|
||||
"tokenUrl": "토큰 URL",
|
||||
"userIdentifier": "사용자 식별자 경로",
|
||||
"displayName": "표시 이름 경로",
|
||||
"scopes": "스코프",
|
||||
"userinfoUrl": "Userinfo URL 재정의",
|
||||
"groupClaim": "그룹 클레임",
|
||||
"groupClaimDesc": "선택 사항입니다. 사용자 그룹이 포함된 클레임 경로입니다. 기본값은 groups, roles, group 순서로 검색됩니다. 사용자 정의 클레임이 있는 제공자(예: Zitadel)의 경우 이 값을 사용하세요.",
|
||||
"allowedUsers": "허용 사용자",
|
||||
"allowedUsersDesc": "한 줄에 이메일 하나씩 입력하세요. 비워두면 모두 허용됩니다.",
|
||||
"adminGroup": "관리자 그룹",
|
||||
"adminGroupDesc": "이 그룹의 사용자에게 관리자 권한이 부여됩니다. 비워두면 그룹 동기화가 비활성화됩니다.",
|
||||
"caCert": "사용자 정의 CA 인증서",
|
||||
"caCertDesc": "선택 사항입니다. 개인 또는 자체 서명 CA를 사용하는 OIDC 제공자를 위한 PEM 인코딩 CA 인증서입니다. 비워두면 시스템 신뢰 저장소가 사용됩니다."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "SSO로 자동 로그인",
|
||||
"description": "방문자를 로그인 양식을 표시하는 대신 첫 번째 SSO 제공업체로 바로 보냅니다. OIDC_SILENT_LOGIN_DEFAULT 환경 변수는 이 설정을 재정의합니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Loading...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "View docs",
|
||||
"none": "No SSO providers configured.",
|
||||
"add": "Add Provider",
|
||||
"edit": "Edit Provider",
|
||||
"delete": "Delete Provider",
|
||||
"deleteConfirm": "Delete this provider? Associated users will be unable to login.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "Provider Type",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save Provider",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "Cancel",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "Issuer URL",
|
||||
"authUrl": "Authorization URL",
|
||||
"tokenUrl": "Token URL",
|
||||
"userIdentifier": "User Identifier Path",
|
||||
"displayName": "Display Name Path",
|
||||
"scopes": "Scopes",
|
||||
"userinfoUrl": "Override Userinfo URL",
|
||||
"groupClaim": "Group Claim",
|
||||
"groupClaimDesc": "Optional. The claim path that contains the user's groups. Defaults to groups, roles, then group. Use this for providers with a custom claim (e.g. Zitadel).",
|
||||
"allowedUsers": "Allowed Users",
|
||||
"allowedUsersDesc": "One email per line. Leave empty to allow all.",
|
||||
"adminGroup": "Admin Group",
|
||||
"adminGroupDesc": "Users in this group are granted admin. Leave empty to disable group sync.",
|
||||
"caCert": "Custom CA Certificate",
|
||||
"caCertDesc": "Optional. PEM-encoded CA certificate for OIDC providers using a private or self-signed CA. Leave empty to use the system trust store."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Laster...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "View docs",
|
||||
"none": "No SSO providers configured.",
|
||||
"add": "Add Provider",
|
||||
"edit": "Edit Provider",
|
||||
"delete": "Delete Provider",
|
||||
"deleteConfirm": "Delete this provider? Associated users will be unable to login.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "Provider Type",
|
||||
"enabled": "Aktivert",
|
||||
"save": "Save Provider",
|
||||
"saving": "Lagrer...",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Kunne ikke lagre",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Kunne ikke slette",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "Avbryt",
|
||||
"copy": "Kopier",
|
||||
"copied": "Copied",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Klient-ID",
|
||||
"clientSecret": "Klienthemmelighet",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "Utsteder-URL",
|
||||
"authUrl": "Autorisasjons-URL",
|
||||
"tokenUrl": "Token-URL",
|
||||
"userIdentifier": "Sti til brukeridentifikator",
|
||||
"displayName": "Sti til visningsnavn",
|
||||
"scopes": "Scopes",
|
||||
"userinfoUrl": "Override Userinfo URL",
|
||||
"groupClaim": "Group Claim",
|
||||
"groupClaimDesc": "Optional. The claim path that contains the user's groups. Defaults to groups, roles, then group. Use this for providers with a custom claim (e.g. Zitadel).",
|
||||
"allowedUsers": "Allowed Users",
|
||||
"allowedUsersDesc": "One email per line. Leave empty to allow all.",
|
||||
"adminGroup": "Admin Group",
|
||||
"adminGroupDesc": "Users in this group are granted admin. Leave empty to disable group sync.",
|
||||
"caCert": "Custom CA Certificate",
|
||||
"caCertDesc": "Optional. PEM-encoded CA certificate for OIDC providers using a private or self-signed CA. Leave empty to use the system trust store."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "Zaloguj się za pomocą {{name}}",
|
||||
"loading": "Ładowanie...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Zobacz dokumentację",
|
||||
"none": "Nie skonfigurowano dostawców SSO.",
|
||||
"add": "Dodaj dostawcę",
|
||||
"edit": "Edytuj dostawcę",
|
||||
"delete": "Usuń dostawcę",
|
||||
"deleteConfirm": "Usunąć tego dostawcę? Powiązani użytkownicy nie będą mogli się zalogować.",
|
||||
"name": "Nazwa wyświetlana",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "Typ dostawcy",
|
||||
"enabled": "Włączona",
|
||||
"save": "Zapisz dostawcę",
|
||||
"saving": "Zapisywanie...",
|
||||
"saved": "Zapisano",
|
||||
"saveFailed": "Nie udało się zapisać",
|
||||
"deleted": "Usunięto",
|
||||
"deleteFailed": "Nie udało się usunąć",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "Anuluj",
|
||||
"copy": "Kopiuj",
|
||||
"copied": "Skopiowano",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Identyfikator klienta",
|
||||
"clientSecret": "Sekret klienta",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "Adres URL wystawcy",
|
||||
"authUrl": "Adres URL autoryzacji",
|
||||
"tokenUrl": "Adres URL tokena",
|
||||
"userIdentifier": "Ścieżka identyfikatora użytkownika",
|
||||
"displayName": "Ścieżka nazwy wyświetlanej",
|
||||
"scopes": "Zakresy",
|
||||
"userinfoUrl": "Zastąp adres URL informacji o użytkowniku",
|
||||
"groupClaim": "Claim grupy",
|
||||
"groupClaimDesc": "Opcjonalnie. Ścieżka claimu zawierającego grupy użytkownika. Domyślnie: groups, roles, potem group. Użyj tego dla dostawców z niestandardowym claimem (np. Zitadel).",
|
||||
"allowedUsers": "Dozwoleni użytkownicy",
|
||||
"allowedUsersDesc": "Jeden adres e-mail w każdym wierszu. Pozostaw puste, aby zezwolić na wszystkie.",
|
||||
"adminGroup": "Grupa administratorów",
|
||||
"adminGroupDesc": "Użytkownicy w tej grupie otrzymują uprawnienia administratora. Pozostaw puste, aby wyłączyć synchronizację grupy.",
|
||||
"caCert": "Niestandardowy certyfikat CA",
|
||||
"caCertDesc": "Opcjonalnie. Certyfikat CA w formacie PEM dla dostawców OIDC korzystających z prywatnego lub samopodpisanego CA. Pozostaw puste, aby użyć systemowego magazynu zaufanych certyfikatów."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Carregando...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Documentação",
|
||||
"none": "No SSO providers configured.",
|
||||
"add": "Add Provider",
|
||||
"edit": "Edit Provider",
|
||||
"delete": "Delete Provider",
|
||||
"deleteConfirm": "Delete this provider? Associated users will be unable to login.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "Provider Type",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save Provider",
|
||||
"saving": "Salvando...",
|
||||
"saved": "Salvo",
|
||||
"saveFailed": "Falha ao salvar",
|
||||
"deleted": "Apagado",
|
||||
"deleteFailed": "Falha ao apagar",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "Cancelar",
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "Issuer URL",
|
||||
"authUrl": "Authorization URL",
|
||||
"tokenUrl": "Token URL",
|
||||
"userIdentifier": "User Identifier Path",
|
||||
"displayName": "Display Name Path",
|
||||
"scopes": "Scopes",
|
||||
"userinfoUrl": "Override Userinfo URL",
|
||||
"groupClaim": "Group Claim",
|
||||
"groupClaimDesc": "Optional. The claim path that contains the user's groups. Defaults to groups, roles, then group. Use this for providers with a custom claim (e.g. Zitadel).",
|
||||
"allowedUsers": "Allowed Users",
|
||||
"allowedUsersDesc": "One email per line. Leave empty to allow all.",
|
||||
"adminGroup": "Admin Group",
|
||||
"adminGroupDesc": "Users in this group are granted admin. Leave empty to disable group sync.",
|
||||
"caCert": "Custom CA Certificate",
|
||||
"caCertDesc": "Optional. PEM-encoded CA certificate for OIDC providers using a private or self-signed CA. Leave empty to use the system trust store."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "Iniciar sessão com {{name}}",
|
||||
"loading": "A carregar...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Ver docs",
|
||||
"none": "Nenhum provedor de SSO configurado.",
|
||||
"add": "Adicionar Provedor",
|
||||
"edit": "Editar Provedor",
|
||||
"delete": "Eliminar Provedor",
|
||||
"deleteConfirm": "Eliminar este provedor? Os utilizadores associados não poderão iniciar sessão.",
|
||||
"name": "Nome de exibição",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "Tipo de Provedor",
|
||||
"enabled": "Ativado",
|
||||
"save": "Guardar Provedor",
|
||||
"saving": "A guardar...",
|
||||
"saved": "Guardado",
|
||||
"saveFailed": "Falha ao guardar",
|
||||
"deleted": "Eliminado",
|
||||
"deleteFailed": "Falha ao eliminar",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "Cancelar",
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "ID do cliente",
|
||||
"clientSecret": "Segredo do cliente",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "URL do emissor",
|
||||
"authUrl": "URL de autorização",
|
||||
"tokenUrl": "URL do token",
|
||||
"userIdentifier": "Caminho do identificador do utilizador",
|
||||
"displayName": "Caminho do nome de exibição",
|
||||
"scopes": "Âmbitos",
|
||||
"userinfoUrl": "Substituir URL do Userinfo",
|
||||
"groupClaim": "Atributo de grupo",
|
||||
"groupClaimDesc": "Opcional. O caminho do atributo que contém os grupos do utilizador. Predefinido para groups, roles e depois group. Utilize para fornecedores com um atributo personalizado (ex.: Zitadel).",
|
||||
"allowedUsers": "Utilizadores permitidos",
|
||||
"allowedUsersDesc": "Um email por linha. Deixe em branco para permitir todos.",
|
||||
"adminGroup": "Grupo de administração",
|
||||
"adminGroupDesc": "Os utilizadores neste grupo recebem privilégios de administrador. Deixe em branco para desativar a sincronização de grupos.",
|
||||
"caCert": "Certificado CA personalizado",
|
||||
"caCertDesc": "Opcional. Certificado CA codificado em PEM para fornecedores OIDC que utilizam uma CA privada ou autoassinada. Deixe em branco para utilizar o repositório de confiança do sistema."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Conectați-vă cu SSO",
|
||||
"loginWithProvider": "Autentificare cu {{name}}",
|
||||
"loading": "Încărcare...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Gestionați furnizorii SSO",
|
||||
"description": "Adăugați, modificați și eliminați furnizorii SSO cu care utilizatorii se pot conecta."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Vizualizați documentele",
|
||||
"none": "Nu sunt configurați furnizori SSO.",
|
||||
"add": "Adăugați furnizor",
|
||||
"edit": "Editați furnizorul",
|
||||
"delete": "Ștergeți furnizorul",
|
||||
"deleteConfirm": "Ștergeți acest furnizor? Utilizatorii asociați nu se vor putea conecta.",
|
||||
"name": "Numele de afișare",
|
||||
"namePlaceholder": "Furnizorul meu SSO",
|
||||
"nameRequired": "Este obligatoriu un nume afișat",
|
||||
"type": "Tipul furnizorului",
|
||||
"enabled": "Activat",
|
||||
"save": "Salvați furnizorul",
|
||||
"saving": "Economisire...",
|
||||
"saved": "Salvat",
|
||||
"saveFailed": "Salvarea nu a reușit",
|
||||
"deleted": "Șters",
|
||||
"deleteFailed": "Ștergerea nu a reușit",
|
||||
"loadFailed": "Furnizorii SSO nu au putut fi încărcați.",
|
||||
"cancel": "Anula",
|
||||
"copy": "Copie",
|
||||
"copied": "Copiat",
|
||||
"redirectUri": "URI de redirecționare",
|
||||
"redirectUriDesc": "Înregistrați această adresă URL ca adresă URL de redirecționare sau de apel invers în furnizorul dvs. de identitate.",
|
||||
"legacyCallback": "Păstrați vechiul URI de redirecționare",
|
||||
"legacyCallbackDesc": "Acest furnizor a fost configurat înainte de Termix 2.9 și folosește în continuare vechiul URI de redirecționare, care funcționează în continuare. Pentru a trece la cel nou, adăugați mai întâi {{uri}} în furnizorul de identitate, apoi dezactivați-l.",
|
||||
"authorizationUrl": "URL de autorizare: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "ID client",
|
||||
"clientSecret": "Secretul clientului",
|
||||
"secretKeep": "Un secret este salvat. Lăsați acest câmp gol pentru a-l păstra.",
|
||||
"issuerUrl": "URL-ul emitentului",
|
||||
"authUrl": "URL de autorizare",
|
||||
"tokenUrl": "Adresa URL a jetonului",
|
||||
"userIdentifier": "Calea de identificare a utilizatorului",
|
||||
"displayName": "Calea numelui afișat",
|
||||
"scopes": "Scopuri",
|
||||
"userinfoUrl": "Suprascrieți adresa URL a informațiilor utilizatorului",
|
||||
"groupClaim": "Revendicare de grup",
|
||||
"groupClaimDesc": "Opțional. Calea de revendicare care conține grupurile utilizatorului. Implicit, se utilizează grupuri, roluri, apoi grup. Folosiți această opțiune pentru furnizorii cu o revendicare personalizată (de exemplu, Zitadel).",
|
||||
"allowedUsers": "Utilizatori permiși",
|
||||
"allowedUsersDesc": "Un e-mail pe rând. Lăsați necompletat pentru a permite accesul tuturor.",
|
||||
"adminGroup": "Grup de administratori",
|
||||
"adminGroupDesc": "Utilizatorii din acest grup au drepturi de administrator. Lăsați câmpul gol pentru a dezactiva sincronizarea grupului.",
|
||||
"caCert": "Certificat CA personalizat",
|
||||
"caCertDesc": "Opțional. Certificat CA codificat PEM pentru furnizorii OIDC care utilizează un CA privat sau autosemnat. Lăsați câmpul gol pentru a utiliza depozitul de încredere al sistemului."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Conectați-vă automat cu SSO",
|
||||
"description": "Trimiteți vizitatorii direct către primul furnizor SSO în loc să afișați formularul de conectare. Variabila de mediu OIDC_SILENT_LOGIN_DEFAULT are prioritate față de această opțiune."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "Войти через {{name}}",
|
||||
"loading": "Загрузка...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Документация",
|
||||
"none": "Нет настроенных провайдеров SSO.",
|
||||
"add": "Добавить провайдера",
|
||||
"edit": "Редактировать провайдера",
|
||||
"delete": "Удалить провайдера",
|
||||
"deleteConfirm": "Удалить провайдера? Связанные пользователи не смогут войти.",
|
||||
"name": "Отображаемое имя",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "Тип провайдера",
|
||||
"enabled": "Включено",
|
||||
"save": "Сохранить провайдера",
|
||||
"saving": "Сохранение…",
|
||||
"saved": "Сохранено",
|
||||
"saveFailed": "Не удалось сохранить",
|
||||
"deleted": "Удалено",
|
||||
"deleteFailed": "Не удалось удалить",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "Отмена",
|
||||
"copy": "Копировать",
|
||||
"copied": "Скопировано",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "URL издателя",
|
||||
"authUrl": "URL авторизации",
|
||||
"tokenUrl": "URL токена",
|
||||
"userIdentifier": "Путь к идентификатору пользователя",
|
||||
"displayName": "Путь к отображаемому имени",
|
||||
"scopes": "Области",
|
||||
"userinfoUrl": "Переопределить URL Userinfo",
|
||||
"groupClaim": "Утверждение группы",
|
||||
"groupClaimDesc": "Необязательно. Путь к утверждению, содержащему группы пользователя. По умолчанию проверяются groups, roles, затем group. Используйте для провайдеров с настраиваемым утверждением (например, Zitadel).",
|
||||
"allowedUsers": "Разрешенные пользователи",
|
||||
"allowedUsersDesc": "По одному email на строку. Оставьте пустым, чтобы разрешить всем.",
|
||||
"adminGroup": "Группа администраторов",
|
||||
"adminGroupDesc": "Пользователи в этой группе получают права администратора. Оставьте пустым, чтобы отключить синхронизацию групп.",
|
||||
"caCert": "Пользовательский сертификат ЦС",
|
||||
"caCertDesc": "Необязательно. Сертификат ЦС в формате PEM для провайдеров OIDC, использующих частный или самоподписанный ЦС. Оставьте пустым, чтобы использовать системное хранилище доверенных сертификатов."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Loading...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "View docs",
|
||||
"none": "No SSO providers configured.",
|
||||
"add": "Add Provider",
|
||||
"edit": "Edit Provider",
|
||||
"delete": "Delete Provider",
|
||||
"deleteConfirm": "Delete this provider? Associated users will be unable to login.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "Provider Type",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save Provider",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "Cancel",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "Issuer URL",
|
||||
"authUrl": "Authorization URL",
|
||||
"tokenUrl": "Token URL",
|
||||
"userIdentifier": "User Identifier Path",
|
||||
"displayName": "Display Name Path",
|
||||
"scopes": "Scopes",
|
||||
"userinfoUrl": "Override Userinfo URL",
|
||||
"groupClaim": "Group Claim",
|
||||
"groupClaimDesc": "Optional. The claim path that contains the user's groups. Defaults to groups, roles, then group. Use this for providers with a custom claim (e.g. Zitadel).",
|
||||
"allowedUsers": "Allowed Users",
|
||||
"allowedUsersDesc": "One email per line. Leave empty to allow all.",
|
||||
"adminGroup": "Admin Group",
|
||||
"adminGroupDesc": "Users in this group are granted admin. Leave empty to disable group sync.",
|
||||
"caCert": "Custom CA Certificate",
|
||||
"caCertDesc": "Optional. PEM-encoded CA certificate for OIDC providers using a private or self-signed CA. Leave empty to use the system trust store."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Loading...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "View docs",
|
||||
"none": "No SSO providers configured.",
|
||||
"add": "Add Provider",
|
||||
"edit": "Edit Provider",
|
||||
"delete": "Delete Provider",
|
||||
"deleteConfirm": "Delete this provider? Associated users will be unable to login.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "Provider Type",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save Provider",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "Cancel",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "Issuer URL",
|
||||
"authUrl": "Authorization URL",
|
||||
"tokenUrl": "Token URL",
|
||||
"userIdentifier": "User Identifier Path",
|
||||
"displayName": "Display Name Path",
|
||||
"scopes": "Scopes",
|
||||
"userinfoUrl": "Override Userinfo URL",
|
||||
"groupClaim": "Group Claim",
|
||||
"groupClaimDesc": "Optional. The claim path that contains the user's groups. Defaults to groups, roles, then group. Use this for providers with a custom claim (e.g. Zitadel).",
|
||||
"allowedUsers": "Allowed Users",
|
||||
"allowedUsersDesc": "One email per line. Leave empty to allow all.",
|
||||
"adminGroup": "Admin Group",
|
||||
"adminGroupDesc": "Users in this group are granted admin. Leave empty to disable group sync.",
|
||||
"caCert": "Custom CA Certificate",
|
||||
"caCertDesc": "Optional. PEM-encoded CA certificate for OIDC providers using a private or self-signed CA. Leave empty to use the system trust store."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Loading...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "View docs",
|
||||
"none": "No SSO providers configured.",
|
||||
"add": "Add Provider",
|
||||
"edit": "Edit Provider",
|
||||
"delete": "Delete Provider",
|
||||
"deleteConfirm": "Delete this provider? Associated users will be unable to login.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "Provider Type",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save Provider",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "Cancel",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "Issuer URL",
|
||||
"authUrl": "Authorization URL",
|
||||
"tokenUrl": "Token URL",
|
||||
"userIdentifier": "User Identifier Path",
|
||||
"displayName": "Display Name Path",
|
||||
"scopes": "Scopes",
|
||||
"userinfoUrl": "Override Userinfo URL",
|
||||
"groupClaim": "Group Claim",
|
||||
"groupClaimDesc": "Optional. The claim path that contains the user's groups. Defaults to groups, roles, then group. Use this for providers with a custom claim (e.g. Zitadel).",
|
||||
"allowedUsers": "Allowed Users",
|
||||
"allowedUsersDesc": "One email per line. Leave empty to allow all.",
|
||||
"adminGroup": "Admin Group",
|
||||
"adminGroupDesc": "Users in this group are granted admin. Leave empty to disable group sync.",
|
||||
"caCert": "Custom CA Certificate",
|
||||
"caCertDesc": "Optional. PEM-encoded CA certificate for OIDC providers using a private or self-signed CA. Leave empty to use the system trust store."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "{{name}} ile giriş yap",
|
||||
"loading": "Yükleniyor...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Belgelere bak",
|
||||
"none": "Hiçbir SSO sağlayıcısı yapılandırılmamış.",
|
||||
"add": "Sağlayıcı Ekle",
|
||||
"edit": "Sağlayıcıyı Düzenle",
|
||||
"delete": "Sağlayıcıyı Sil",
|
||||
"deleteConfirm": "Bu sağlayıcı silinsin mi? İlişkili kullanıcılar oturum açamaz.",
|
||||
"name": "Görünen Ad",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "Sağlayıcı Türü",
|
||||
"enabled": "Etkin",
|
||||
"save": "Sağlayıcıyı Kaydet",
|
||||
"saving": "Kaydediliyor...",
|
||||
"saved": "Kaydedildi",
|
||||
"saveFailed": "Kaydedilemedi",
|
||||
"deleted": "Silindi",
|
||||
"deleteFailed": "Silinemedi",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "İptal",
|
||||
"copy": "Kopyala",
|
||||
"copied": "Kopyalandı",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "İstemci Kimliği",
|
||||
"clientSecret": "İstemci Sırrı",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "Sağlayıcı URL'si",
|
||||
"authUrl": "Yetkilendirme URL'si",
|
||||
"tokenUrl": "Belirteç URL'si",
|
||||
"userIdentifier": "Kullanıcı Tanımlayıcı Yolu",
|
||||
"displayName": "Görünen Ad Yolu",
|
||||
"scopes": "Kapsamlar",
|
||||
"userinfoUrl": "Userinfo URL'sini Geçersiz Kıl",
|
||||
"groupClaim": "Grup Talebi",
|
||||
"groupClaimDesc": "İsteğe bağlı. Kullanıcının gruplarını içeren talep yolu. Varsayılan olarak groups, roles, ardından group kullanılır. Özel talep kullanan sağlayıcılar için bunu kullanın (örn. Zitadel).",
|
||||
"allowedUsers": "İzin Verilen Kullanıcılar",
|
||||
"allowedUsersDesc": "Her satıra bir e-posta. Tümüne izin vermek için boş bırakın.",
|
||||
"adminGroup": "Yönetici Grubu",
|
||||
"adminGroupDesc": "Bu gruptaki kullanıcılara yönetici yetkisi verilir. Grup eşitlemeyi devre dışı bırakmak için boş bırakın.",
|
||||
"caCert": "Özel CA Sertifikası",
|
||||
"caCertDesc": "İsteğe bağlı. Özel veya kendinden imzalı CA kullanan OIDC sağlayıcıları için PEM kodlu CA sertifikası. Sistem güven deposunu kullanmak için boş bırakın."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Loading...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "View docs",
|
||||
"none": "No SSO providers configured.",
|
||||
"add": "Add Provider",
|
||||
"edit": "Edit Provider",
|
||||
"delete": "Delete Provider",
|
||||
"deleteConfirm": "Delete this provider? Associated users will be unable to login.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "Provider Type",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save Provider",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "Cancel",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "Issuer URL",
|
||||
"authUrl": "Authorization URL",
|
||||
"tokenUrl": "Token URL",
|
||||
"userIdentifier": "User Identifier Path",
|
||||
"displayName": "Display Name Path",
|
||||
"scopes": "Scopes",
|
||||
"userinfoUrl": "Override Userinfo URL",
|
||||
"groupClaim": "Group Claim",
|
||||
"groupClaimDesc": "Optional. The claim path that contains the user's groups. Defaults to groups, roles, then group. Use this for providers with a custom claim (e.g. Zitadel).",
|
||||
"allowedUsers": "Allowed Users",
|
||||
"allowedUsersDesc": "One email per line. Leave empty to allow all.",
|
||||
"adminGroup": "Admin Group",
|
||||
"adminGroupDesc": "Users in this group are granted admin. Leave empty to disable group sync.",
|
||||
"caCert": "Custom CA Certificate",
|
||||
"caCertDesc": "Optional. PEM-encoded CA certificate for OIDC providers using a private or self-signed CA. Leave empty to use the system trust store."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "Đăng nhập bằng {{name}}",
|
||||
"loading": "Đang tải...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Xem tài liệu",
|
||||
"none": "Chưa có nhà cung cấp SSO.",
|
||||
"add": "Thêm nhà cung cấp",
|
||||
"edit": "Chỉnh sửa nhà cung cấp",
|
||||
"delete": "Xóa nhà cung cấp",
|
||||
"deleteConfirm": "Xóa nhà cung cấp này? Người dùng liên kết sẽ không thể đăng nhập.",
|
||||
"name": "Tên hiển thị",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "Loại nhà cung cấp",
|
||||
"enabled": "Đã bật",
|
||||
"save": "Lưu nhà cung cấp",
|
||||
"saving": "Đang lưu...",
|
||||
"saved": "Đã lưu",
|
||||
"saveFailed": "Không thể lưu",
|
||||
"deleted": "Đã xóa",
|
||||
"deleteFailed": "Không thể xóa",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "Hủy",
|
||||
"copy": "Sao chép",
|
||||
"copied": "Đã sao chép",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "Issuer URL",
|
||||
"authUrl": "URL ủy quyền",
|
||||
"tokenUrl": "Token URL",
|
||||
"userIdentifier": "Đường dẫn định danh người dùng",
|
||||
"displayName": "Đường dẫn tên hiển thị",
|
||||
"scopes": "Phạm vi",
|
||||
"userinfoUrl": "Ghi đè URL Userinfo",
|
||||
"groupClaim": "Group Claim",
|
||||
"groupClaimDesc": "Tùy chọn. Đường dẫn claim chứa các nhóm của người dùng. Mặc định là groups, roles, rồi group. Sử dụng cho các nhà cung cấp có claim tùy chỉnh (ví dụ: Zitadel).",
|
||||
"allowedUsers": "Người dùng được phép",
|
||||
"allowedUsersDesc": "Mỗi email một dòng. Để trống để cho phép tất cả.",
|
||||
"adminGroup": "Nhóm Admin",
|
||||
"adminGroupDesc": "Người dùng trong nhóm này được cấp quyền admin. Để trống để tắt đồng bộ nhóm.",
|
||||
"caCert": "Chứng chỉ CA tùy chỉnh",
|
||||
"caCertDesc": "Tùy chọn. Chứng chỉ CA mã hóa PEM cho các nhà cung cấp OIDC sử dụng CA riêng hoặc tự ký. Để trống để sử dụng kho tin cậy hệ thống."
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "使用{{name}}登录",
|
||||
"loading": "加载中...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "查看文档",
|
||||
"none": "未配置 SSO 提供商。",
|
||||
"add": "添加提供商",
|
||||
"edit": "编辑提供商",
|
||||
"delete": "删除提供商",
|
||||
"deleteConfirm": "删除此提供商?关联用户将无法登录。",
|
||||
"name": "显示名称",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "提供商类型",
|
||||
"enabled": "已启用",
|
||||
"save": "保存提供商",
|
||||
"saving": "保存中...",
|
||||
"saved": "已保存",
|
||||
"saveFailed": "保存失败",
|
||||
"deleted": "已删除",
|
||||
"deleteFailed": "删除失败",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "取消",
|
||||
"copy": "复制",
|
||||
"copied": "已复制",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "客户端 ID",
|
||||
"clientSecret": "客户端密钥",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "颁发者 URL",
|
||||
"authUrl": "授权 URL",
|
||||
"tokenUrl": "令牌 URL",
|
||||
"userIdentifier": "用户标识符路径",
|
||||
"displayName": "显示名称路径",
|
||||
"scopes": "作用域",
|
||||
"userinfoUrl": "覆盖 Userinfo URL",
|
||||
"groupClaim": "组声明",
|
||||
"groupClaimDesc": "可选。包含用户组的声明路径。默认依次使用 groups、roles、group。用于具有自定义声明的提供商(如 Zitadel)。",
|
||||
"allowedUsers": "允许的用户",
|
||||
"allowedUsersDesc": "每行一个邮箱。留空表示允许所有用户。",
|
||||
"adminGroup": "管理员组",
|
||||
"adminGroupDesc": "此组的用户将被授予管理员权限。留空可禁用组同步。",
|
||||
"caCert": "自定义 CA 证书",
|
||||
"caCertDesc": "可选。PEM 编码的 CA 证书,用于使用私有或自签名 CA 的 OIDC 提供商。留空则使用系统信任存储。"
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithProvider": "使用 {{name}} 登入",
|
||||
"loading": "載入中...",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage SSO providers",
|
||||
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "檢視文件",
|
||||
"none": "尚未設定 SSO 提供者。",
|
||||
"add": "新增提供者",
|
||||
"edit": "編輯提供者",
|
||||
"delete": "刪除提供者",
|
||||
"deleteConfirm": "刪除此提供者?相關使用者將無法登入。",
|
||||
"name": "顯示名稱",
|
||||
"namePlaceholder": "My SSO provider",
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "提供者類型",
|
||||
"enabled": "已啟用",
|
||||
"save": "儲存提供者",
|
||||
"saving": "儲存中…",
|
||||
"saved": "已儲存",
|
||||
"saveFailed": "儲存失敗",
|
||||
"deleted": "已刪除",
|
||||
"deleteFailed": "刪除失敗",
|
||||
"loadFailed": "Failed to load SSO providers",
|
||||
"cancel": "取消",
|
||||
"copy": "複製",
|
||||
"copied": "已複製",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||
"legacyCallback": "Keep the old redirect URI",
|
||||
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||
"authorizationUrl": "Authorization URL: {{url}}"
|
||||
},
|
||||
"fields": {
|
||||
"clientId": "用戶端 ID",
|
||||
"clientSecret": "用戶端密鑰",
|
||||
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||
"issuerUrl": "發行者 URL",
|
||||
"authUrl": "授權 URL",
|
||||
"tokenUrl": "權杖 URL",
|
||||
"userIdentifier": "使用者識別碼路徑",
|
||||
"displayName": "顯示名稱路徑",
|
||||
"scopes": "範圍",
|
||||
"userinfoUrl": "覆寫使用者資訊 URL",
|
||||
"groupClaim": "群組宣告",
|
||||
"groupClaimDesc": "選用。包含使用者群組的宣告路徑。預設為 groups、roles、group。用於自訂宣告的提供者(例如 Zitadel)。",
|
||||
"allowedUsers": "允許的使用者",
|
||||
"allowedUsersDesc": "每行一個電子郵件。留空以允許全部。",
|
||||
"adminGroup": "管理員群組",
|
||||
"adminGroupDesc": "此群組中的使用者將被授予管理員權限。留空以停用群組同步。",
|
||||
"caCert": "自訂 CA 憑證",
|
||||
"caCertDesc": "選用。PEM 編碼的 CA 憑證,用於使用私有或自簽 CA 的 OIDC 提供者。留空以使用系統信任儲存區。"
|
||||
},
|
||||
"settings": {
|
||||
"silentLoginDefault": {
|
||||
"label": "Sign in with SSO automatically",
|
||||
"description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"id": "sso",
|
||||
"name": "Single sign-on",
|
||||
"version": "1.0.0",
|
||||
"description": "Sign in with an OpenID Connect provider, GitHub or Google. Each provider gets its own button on the login screen.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Access & Security",
|
||||
"icon": "KeyRound",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": [
|
||||
"db:own",
|
||||
"kv:own",
|
||||
"secrets:own",
|
||||
"network:serve",
|
||||
"auth:provide",
|
||||
"ui:surface"
|
||||
],
|
||||
"contributes": {
|
||||
"auth": {
|
||||
"loginMethods": ["oidc"]
|
||||
},
|
||||
"permissions": [
|
||||
{
|
||||
"name": "manage",
|
||||
"titleKey": "permissions.manage.title",
|
||||
"descriptionKey": "permissions.manage.description",
|
||||
"defaultRoles": ["admin"]
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"admin": [
|
||||
{
|
||||
"key": "providers",
|
||||
"type": "custom",
|
||||
"component": "providers",
|
||||
"permission": "manage"
|
||||
},
|
||||
{
|
||||
"key": "silentLoginDefault",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.silentLoginDefault.label",
|
||||
"descriptionKey": "settings.silentLoginDefault.description",
|
||||
"default": false,
|
||||
"permission": "manage"
|
||||
}
|
||||
]
|
||||
},
|
||||
"http": {
|
||||
"legacyRedirects": [
|
||||
{
|
||||
"from": "/users/oidc-config",
|
||||
"to": "/config"
|
||||
},
|
||||
{
|
||||
"from": "/users/oidc/callback",
|
||||
"to": "/callback",
|
||||
"status": 308
|
||||
},
|
||||
{
|
||||
"from": "/users/oidc/backchannel-logout",
|
||||
"to": "/backchannel-logout",
|
||||
"status": 308
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"],
|
||||
"desktop": "server"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
-- sso 0001: adopt_sso_providers
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sso_providers` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`name` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`enabled` boolean NOT NULL DEFAULT true,
|
||||
`display_order` int NOT NULL DEFAULT 0,
|
||||
`config` text NOT NULL,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP)
|
||||
);
|
||||
RENAME TABLE `sso_providers` TO `p_sso_providers`;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- sso 0002: legacy_callback
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
ALTER TABLE `p_sso_providers` ADD COLUMN `legacy_callback` boolean NOT NULL DEFAULT false;
|
||||
-- Providers that existed before 2.9 were registered with the identity
|
||||
-- provider under /users/oidc/callback, so they keep sending that URI.
|
||||
UPDATE `p_sso_providers` SET `legacy_callback` = true;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- sso 0001: adopt_sso_providers
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "sso_providers" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
"display_order" integer NOT NULL DEFAULT 0,
|
||||
"config" text NOT NULL,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
ALTER TABLE "sso_providers" RENAME TO "p_sso_providers";
|
||||
@@ -0,0 +1,7 @@
|
||||
-- sso 0002: legacy_callback
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
ALTER TABLE "p_sso_providers" ADD COLUMN "legacy_callback" boolean NOT NULL DEFAULT false;
|
||||
-- Providers that existed before 2.9 were registered with the identity
|
||||
-- provider under /users/oidc/callback, so they keep sending that URI.
|
||||
UPDATE "p_sso_providers" SET "legacy_callback" = true;
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tables": {
|
||||
"providers": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"notNull": true,
|
||||
"defaultValue": true
|
||||
},
|
||||
"displayOrder": {
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"defaultValue": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"legacyCallback": {
|
||||
"type": "boolean",
|
||||
"notNull": true,
|
||||
"defaultValue": false
|
||||
}
|
||||
},
|
||||
"indexes": [],
|
||||
"adopts": "sso_providers"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
-- sso 0001: adopt_sso_providers
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "sso_providers" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"name" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"enabled" integer NOT NULL DEFAULT 1,
|
||||
"display_order" integer NOT NULL DEFAULT 0,
|
||||
"config" text NOT NULL,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
ALTER TABLE "sso_providers" RENAME TO "p_sso_providers";
|
||||
@@ -0,0 +1,7 @@
|
||||
-- sso 0002: legacy_callback
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
ALTER TABLE "p_sso_providers" ADD COLUMN "legacy_callback" integer NOT NULL DEFAULT 0;
|
||||
-- Providers that existed before 2.9 were registered with the identity
|
||||
-- provider under /users/oidc/callback, so they keep sending that URI.
|
||||
UPDATE "p_sso_providers" SET "legacy_callback" = 1;
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@termix-plugin/sso",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p .",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"dependencies": {
|
||||
"jose": "^6.2.12",
|
||||
"lucide-react": "^1.33.0",
|
||||
"undici": "^8.10.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"jsdom": "^30.0.1",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { providers } from "./tables.js";
|
||||
import { createProviderStore } from "./providers.js";
|
||||
import { createSsoLogin, METHOD_ID } from "./login.js";
|
||||
import { PUBLIC_PATHS, registerSsoRoutes } from "./routes.js";
|
||||
|
||||
/**
|
||||
* Whether the login page starts the first provider without a click. The
|
||||
* OIDC_SILENT_LOGIN_DEFAULT environment variable pins it either way.
|
||||
*/
|
||||
export async function silentLoginDefault(ctx: PluginContext): Promise<boolean> {
|
||||
const pinned = process.env.OIDC_SILENT_LOGIN_DEFAULT;
|
||||
if (pinned !== undefined) return pinned.trim().toLowerCase() === "true";
|
||||
return (await ctx.settings.get("silentLoginDefault")) === true;
|
||||
}
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const table = await ctx.db.define(providers);
|
||||
const store = createProviderStore(ctx, table);
|
||||
const login = createSsoLogin(ctx, store);
|
||||
|
||||
// One button per enabled provider.
|
||||
ctx.auth.registerLoginMethod({
|
||||
id: METHOD_ID,
|
||||
labelKey: "loginWithSso",
|
||||
icon: "key-round",
|
||||
kind: "redirect",
|
||||
external: true,
|
||||
describe: async () => {
|
||||
const autoStart = await silentLoginDefault(ctx);
|
||||
return (await store.listInstances()).map((instance, index) => ({
|
||||
...instance,
|
||||
enabled: true,
|
||||
...(autoStart && index === 0 ? { autoStart: true } : {}),
|
||||
}));
|
||||
},
|
||||
start: login.start,
|
||||
callback: login.callback,
|
||||
});
|
||||
|
||||
registerSsoRoutes(
|
||||
ctx.http.router<Router>({ public: PUBLIC_PATHS }),
|
||||
ctx,
|
||||
store,
|
||||
login,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* The OIDC, GitHub and Google sign-in. start builds the provider's authorize
|
||||
* URL; callback turns the provider's answer into a verified identity. Core
|
||||
* finds or provisions the user and issues the session.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
LoginMethodError,
|
||||
type PluginContext,
|
||||
type PluginLoginRequest,
|
||||
type PluginVerifiedIdentity,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
OIDCTokenFormatError,
|
||||
buildFetchOptions,
|
||||
extractOidcGroupsFromSources,
|
||||
generatePkceCodeChallenge,
|
||||
generatePkceCodeVerifier,
|
||||
getDesktopCallbackUrl,
|
||||
getNestedValue,
|
||||
parseOidcRoleMap,
|
||||
resolveOidcMappedRoles,
|
||||
verifyOIDCToken,
|
||||
} from "./oidc-protocol.js";
|
||||
import {
|
||||
ENV_INSTANCE_ID,
|
||||
ENV_PROVIDER_ID,
|
||||
type ProviderStore,
|
||||
} from "./providers.js";
|
||||
import type { OidcConfig, ResolvedProvider } from "./types.js";
|
||||
|
||||
export const METHOD_ID = "oidc";
|
||||
export const CALLBACK_PATH = "/plugin-api/sso/callback";
|
||||
/** The redirect URI identity providers were set up with before 2.9. */
|
||||
export const LEGACY_CALLBACK_PATH = "/users/oidc/callback";
|
||||
|
||||
const STATE_TTL_MS = 10 * 60 * 1000;
|
||||
const STATE_PREFIX = "state:";
|
||||
|
||||
/** An error raised after the return address is known redirects there. */
|
||||
export class RedirectLoginError extends LoginMethodError {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly returnTo: string,
|
||||
status = 400,
|
||||
code?: string,
|
||||
) {
|
||||
super(message, status, code);
|
||||
this.name = "RedirectLoginError";
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingState {
|
||||
nonce: string;
|
||||
backendCallback: string;
|
||||
frontendOrigin: string;
|
||||
rememberMe: boolean;
|
||||
providerId: number | null;
|
||||
codeVerifier: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
function nonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function header(request: PluginLoginRequest, name: string): string {
|
||||
const value = request.headers[name];
|
||||
return typeof (Array.isArray(value) ? value[0] : value) === "string"
|
||||
? String(Array.isArray(value) ? value[0] : value)
|
||||
: "";
|
||||
}
|
||||
|
||||
export function redirectUriFor(
|
||||
baseUrl: string,
|
||||
provider: Pick<ResolvedProvider, "legacyCallback">,
|
||||
): string {
|
||||
return `${baseUrl}${provider.legacyCallback ? LEGACY_CALLBACK_PATH : CALLBACK_PATH}`;
|
||||
}
|
||||
|
||||
export type SsoLogin = ReturnType<typeof createSsoLogin>;
|
||||
|
||||
export function createSsoLogin(ctx: PluginContext, store: ProviderStore) {
|
||||
async function saveState(state: string, pending: PendingState) {
|
||||
// Drop anything abandoned so the store does not grow.
|
||||
const now = Date.now();
|
||||
for (const key of await ctx.kv.list()) {
|
||||
if (!key.startsWith(STATE_PREFIX)) continue;
|
||||
const entry = (await ctx.kv.get(key)) as PendingState | undefined;
|
||||
if (!entry || now - entry.createdAt > STATE_TTL_MS) {
|
||||
await ctx.kv.delete(key);
|
||||
}
|
||||
}
|
||||
await ctx.kv.set(`${STATE_PREFIX}${state}`, pending);
|
||||
}
|
||||
|
||||
async function takeState(state: string): Promise<PendingState | null> {
|
||||
const key = `${STATE_PREFIX}${state}`;
|
||||
const entry = (await ctx.kv.get(key)) as PendingState | undefined;
|
||||
if (!entry) return null;
|
||||
await ctx.kv.delete(key);
|
||||
return Date.now() - entry.createdAt > STATE_TTL_MS ? null : entry;
|
||||
}
|
||||
|
||||
function frontendOriginFor(
|
||||
request: PluginLoginRequest,
|
||||
baseUrl: string,
|
||||
): string {
|
||||
const { desktopCallbackPort, appCallbackUrl } = request.query;
|
||||
if (desktopCallbackPort !== undefined && desktopCallbackPort !== "") {
|
||||
const url = getDesktopCallbackUrl(desktopCallbackPort);
|
||||
if (!url) {
|
||||
throw new LoginMethodError("Invalid desktop callback port", 400);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
if (typeof appCallbackUrl === "string" && appCallbackUrl) {
|
||||
let callbackUrl: URL;
|
||||
try {
|
||||
callbackUrl = new URL(appCallbackUrl);
|
||||
} catch {
|
||||
throw new LoginMethodError("Invalid app callback URL", 400);
|
||||
}
|
||||
if (callbackUrl.protocol !== "termix-mobile:") {
|
||||
throw new LoginMethodError("Unsupported app callback URL", 400);
|
||||
}
|
||||
return callbackUrl.toString();
|
||||
}
|
||||
const referer = header(request, "referer");
|
||||
if (referer) {
|
||||
try {
|
||||
const refererUrl = new URL(referer);
|
||||
return `${refererUrl.protocol}//${refererUrl.host}`;
|
||||
} catch {
|
||||
// fall through to the request's own origin
|
||||
}
|
||||
}
|
||||
return new URL(baseUrl).origin;
|
||||
}
|
||||
|
||||
async function start(
|
||||
request: PluginLoginRequest,
|
||||
instanceId: string | null,
|
||||
): Promise<{ redirectUrl: string }> {
|
||||
const requested =
|
||||
instanceId ??
|
||||
(typeof request.query.providerId === "string"
|
||||
? request.query.providerId
|
||||
: null);
|
||||
const rowId =
|
||||
requested && requested !== ENV_INSTANCE_ID
|
||||
? Number.parseInt(requested, 10)
|
||||
: null;
|
||||
if (rowId !== null && !Number.isInteger(rowId)) {
|
||||
throw new LoginMethodError("OIDC not configured", 404);
|
||||
}
|
||||
const provider = await store.resolve(rowId);
|
||||
if (!provider) throw new LoginMethodError("OIDC not configured", 404);
|
||||
|
||||
const baseUrl = ctx.http.baseUrl(request);
|
||||
const backendCallback = redirectUriFor(baseUrl, provider);
|
||||
const frontendOrigin = frontendOriginFor(request, baseUrl);
|
||||
const state = crypto.randomUUID();
|
||||
const nonce = crypto.randomUUID();
|
||||
const codeVerifier = generatePkceCodeVerifier();
|
||||
|
||||
await saveState(state, {
|
||||
nonce,
|
||||
backendCallback,
|
||||
frontendOrigin,
|
||||
rememberMe: String(request.query.rememberMe) === "true",
|
||||
providerId: provider.rowId,
|
||||
codeVerifier,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
const authUrl = new URL(provider.config.authorization_url);
|
||||
authUrl.searchParams.set("client_id", provider.config.client_id);
|
||||
authUrl.searchParams.set("redirect_uri", backendCallback);
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
authUrl.searchParams.set("scope", provider.config.scopes);
|
||||
authUrl.searchParams.set("state", state);
|
||||
authUrl.searchParams.set("nonce", nonce);
|
||||
authUrl.searchParams.set(
|
||||
"code_challenge",
|
||||
generatePkceCodeChallenge(codeVerifier),
|
||||
);
|
||||
authUrl.searchParams.set("code_challenge_method", "S256");
|
||||
return { redirectUrl: authUrl.toString() };
|
||||
}
|
||||
|
||||
async function exchangeCode(
|
||||
config: OidcConfig,
|
||||
code: string,
|
||||
pending: PendingState,
|
||||
withVerifier: boolean,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(config.token_url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.client_id,
|
||||
client_secret: config.client_secret,
|
||||
code,
|
||||
redirect_uri: pending.backendCallback,
|
||||
...(withVerifier ? { code_verifier: pending.codeVerifier } : {}),
|
||||
}),
|
||||
...buildFetchOptions(config.ca_cert),
|
||||
} as RequestInit);
|
||||
if (!response.ok) {
|
||||
ctx.log.error(
|
||||
"Token exchange failed",
|
||||
new Error(`HTTP ${response.status}: ${await response.text()}`),
|
||||
);
|
||||
throw new LoginMethodError("Failed to exchange authorization code", 400);
|
||||
}
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function providerKey(provider: ResolvedProvider): string {
|
||||
return provider.rowId === null ? ENV_PROVIDER_ID : String(provider.rowId);
|
||||
}
|
||||
|
||||
async function githubIdentity(
|
||||
provider: ResolvedProvider,
|
||||
code: string,
|
||||
pending: PendingState,
|
||||
): Promise<PluginVerifiedIdentity> {
|
||||
const tokenData = await exchangeCode(provider.config, code, pending, false);
|
||||
const fetchOptions = buildFetchOptions(provider.config.ca_cert);
|
||||
const headers = {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Termix",
|
||||
};
|
||||
const userInfoResponse = await fetch("https://api.github.com/user", {
|
||||
headers,
|
||||
...fetchOptions,
|
||||
} as RequestInit);
|
||||
if (!userInfoResponse.ok) {
|
||||
throw new LoginMethodError("Failed to get GitHub user information", 400);
|
||||
}
|
||||
const userInfo = (await userInfoResponse.json()) as Record<string, unknown>;
|
||||
|
||||
const emailResponse = await fetch("https://api.github.com/user/emails", {
|
||||
headers,
|
||||
});
|
||||
if (emailResponse.ok) {
|
||||
const emails = (await emailResponse.json()) as Array<{
|
||||
email: string;
|
||||
primary: boolean;
|
||||
verified: boolean;
|
||||
}>;
|
||||
const primary = emails.find((e) => e.primary && e.verified);
|
||||
if (primary) userInfo.email = primary.email;
|
||||
}
|
||||
|
||||
const subject = String(userInfo.id ?? userInfo.login);
|
||||
const legacyIdentifier = `github:${provider.rowId}:${subject}`;
|
||||
return {
|
||||
kind: "external",
|
||||
provider: providerKey(provider),
|
||||
subject,
|
||||
email: (userInfo.email as string | undefined) ?? null,
|
||||
name: ((userInfo.name || userInfo.login) as string) || legacyIdentifier,
|
||||
allowedUsers: provider.config.allowed_users ?? null,
|
||||
legacy: { identifier: legacyIdentifier, providerRowId: provider.rowId },
|
||||
returnTo: pending.frontendOrigin,
|
||||
rememberMe: pending.rememberMe,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchUserInfo(
|
||||
config: OidcConfig,
|
||||
accessToken: unknown,
|
||||
userInfo: Record<string, unknown> | null,
|
||||
claimSources: Record<string, unknown>[],
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const fetchOptions = buildFetchOptions(config.ca_cert);
|
||||
const issuer = config.issuer_url.endsWith("/")
|
||||
? config.issuer_url.slice(0, -1)
|
||||
: config.issuer_url;
|
||||
const base = issuer.replace(/\/application\/o\/[^/]+$/, "");
|
||||
const urls: string[] = [];
|
||||
|
||||
try {
|
||||
const discovery = await fetch(
|
||||
`${issuer}/.well-known/openid-configuration`,
|
||||
fetchOptions as RequestInit,
|
||||
);
|
||||
if (discovery.ok) {
|
||||
const document = (await discovery.json()) as Record<string, unknown>;
|
||||
if (typeof document.userinfo_endpoint === "string") {
|
||||
urls.push(document.userinfo_endpoint);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.log.warn(`OIDC discovery failed: ${String(error)}`);
|
||||
}
|
||||
|
||||
if (config.userinfo_url) urls.unshift(config.userinfo_url);
|
||||
urls.push(
|
||||
`${base}/userinfo/`,
|
||||
`${base}/userinfo`,
|
||||
`${issuer}/userinfo/`,
|
||||
`${issuer}/userinfo`,
|
||||
`${base}/oauth2/userinfo/`,
|
||||
`${base}/oauth2/userinfo`,
|
||||
`${issuer}/oauth2/userinfo/`,
|
||||
`${issuer}/oauth2/userinfo`,
|
||||
);
|
||||
|
||||
if (!accessToken) return userInfo;
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
...fetchOptions,
|
||||
} as RequestInit);
|
||||
if (response.ok) {
|
||||
const fetched = (await response.json()) as Record<string, unknown>;
|
||||
claimSources.push(fetched);
|
||||
return { ...userInfo, ...fetched };
|
||||
}
|
||||
ctx.log.warn(`Userinfo endpoint ${url} answered ${response.status}`);
|
||||
} catch (error) {
|
||||
ctx.log.warn(`Userinfo endpoint ${url} failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
async function oidcIdentity(
|
||||
provider: ResolvedProvider,
|
||||
code: string,
|
||||
pending: PendingState,
|
||||
): Promise<PluginVerifiedIdentity> {
|
||||
const { config } = provider;
|
||||
const tokenData = await exchangeCode(config, code, pending, true);
|
||||
|
||||
let userInfo: Record<string, unknown> | null = null;
|
||||
const claimSources: Record<string, unknown>[] = [];
|
||||
|
||||
if (typeof tokenData.id_token === "string") {
|
||||
try {
|
||||
userInfo = await verifyOIDCToken(
|
||||
tokenData.id_token,
|
||||
config.issuer_url,
|
||||
config.client_id,
|
||||
config.ca_cert,
|
||||
);
|
||||
if (userInfo.nonce !== pending.nonce) {
|
||||
ctx.log.warn("OIDC ID token nonce mismatch");
|
||||
throw new LoginMethodError("Invalid OIDC token nonce", 401);
|
||||
}
|
||||
claimSources.push(userInfo);
|
||||
} catch (error) {
|
||||
// A token that is not a JWS carries no claims we could trust; fall
|
||||
// through to userinfo. Signature and claim failures still reject.
|
||||
if (!(error instanceof OIDCTokenFormatError)) throw error;
|
||||
userInfo = null;
|
||||
ctx.log.warn(
|
||||
`OIDC ID token cannot be verified, using userinfo: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
userInfo = await fetchUserInfo(
|
||||
config,
|
||||
tokenData.access_token,
|
||||
userInfo,
|
||||
claimSources,
|
||||
);
|
||||
if (!userInfo) {
|
||||
throw new LoginMethodError("Failed to get user information", 400);
|
||||
}
|
||||
|
||||
const identifier = (getNestedValue(userInfo, config.identifier_path) ||
|
||||
userInfo[config.identifier_path] ||
|
||||
userInfo.sub ||
|
||||
userInfo.email ||
|
||||
userInfo.preferred_username) as string;
|
||||
const name = (getNestedValue(userInfo, config.name_path) ||
|
||||
userInfo[config.name_path] ||
|
||||
userInfo.name ||
|
||||
userInfo.given_name ||
|
||||
identifier) as string;
|
||||
if (!identifier) {
|
||||
throw new LoginMethodError(
|
||||
`User identifier not found at path: ${config.identifier_path}. Available fields: ${Object.keys(userInfo).join(", ")}`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const groups = extractOidcGroupsFromSources(
|
||||
claimSources,
|
||||
config.group_claim,
|
||||
);
|
||||
const isAdmin = config.admin_group
|
||||
? groups.includes(config.admin_group)
|
||||
: undefined;
|
||||
|
||||
let roles: { desired: string[]; managed: string[] } | undefined;
|
||||
const roleMap = parseOidcRoleMap(
|
||||
config.role_map ?? process.env.OIDC_ROLE_MAP,
|
||||
);
|
||||
if (roleMap.size > 0) {
|
||||
const { desired, managed } = resolveOidcMappedRoles(groups, roleMap);
|
||||
roles = { desired: [...desired], managed: [...managed] };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "external",
|
||||
provider: providerKey(provider),
|
||||
subject: String(identifier),
|
||||
// An address the provider says it has not verified does not count
|
||||
// toward the allowed list.
|
||||
email:
|
||||
userInfo.email_verified === false || userInfo.email_verified === "false"
|
||||
? null
|
||||
: ((userInfo.email as string | undefined) ?? null),
|
||||
name,
|
||||
groups,
|
||||
isAdmin,
|
||||
allowedUsers: config.allowed_users ?? null,
|
||||
roles,
|
||||
logoutClaims: {
|
||||
providerId: provider.rowId,
|
||||
sub: typeof userInfo.sub === "string" ? userInfo.sub : null,
|
||||
sid: typeof userInfo.sid === "string" ? userInfo.sid : null,
|
||||
},
|
||||
legacy: { identifier: String(identifier), providerRowId: provider.rowId },
|
||||
returnTo: pending.frontendOrigin,
|
||||
rememberMe: pending.rememberMe,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's callback. Throws RedirectLoginError once the return
|
||||
* address is known, and a plain LoginMethodError (answered as JSON) before.
|
||||
*/
|
||||
async function callback(
|
||||
request: PluginLoginRequest,
|
||||
): Promise<PluginVerifiedIdentity> {
|
||||
const source = { ...request.query, ...(request.body ?? {}) };
|
||||
const { code, state } = source as Record<string, unknown>;
|
||||
if (!nonEmpty(code) || !nonEmpty(state)) {
|
||||
throw new LoginMethodError("Code and state are required", 400);
|
||||
}
|
||||
const pending = await takeState(state);
|
||||
if (!pending) throw new LoginMethodError("Invalid state parameter", 400);
|
||||
|
||||
try {
|
||||
const provider = await store.resolve(pending.providerId);
|
||||
if (!provider) throw new LoginMethodError("OIDC not configured", 500);
|
||||
// GitHub does not issue OIDC id_tokens; it has its own token exchange.
|
||||
return provider.type === "github"
|
||||
? await githubIdentity(provider, code, pending)
|
||||
: await oidcIdentity(provider, code, pending);
|
||||
} catch (error) {
|
||||
if (error instanceof LoginMethodError) {
|
||||
throw new RedirectLoginError(
|
||||
error.message,
|
||||
pending.frontendOrigin,
|
||||
error.status,
|
||||
error.code,
|
||||
);
|
||||
}
|
||||
ctx.log.error(
|
||||
"OIDC callback failed",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
throw new RedirectLoginError(
|
||||
"OIDC authentication failed",
|
||||
pending.frontendOrigin,
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { start, callback };
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* OpenID Connect protocol helpers: token verification, groups and the role
|
||||
* map, back-channel logout tokens and the provider defaults. Nothing here
|
||||
* touches storage.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { Agent } from "undici";
|
||||
import type { OidcConfig } from "./types.js";
|
||||
|
||||
const BACKCHANNEL_LOGOUT_EVENT =
|
||||
"http://schemas.openid.net/event/backchannel-logout";
|
||||
|
||||
/**
|
||||
* Raised when a token cannot be verified because it is not a compact JWS,
|
||||
* as opposed to a signature or claim check that actually failed.
|
||||
*/
|
||||
export class OIDCTokenFormatError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "OIDCTokenFormatError";
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeIssuer(url: string): string {
|
||||
return url
|
||||
.trim()
|
||||
.replace(/\/+$/, "")
|
||||
.replace(/\/\.well-known\/openid-configuration$/, "");
|
||||
}
|
||||
|
||||
export function buildFetchOptions(caCert?: string): Record<string, unknown> {
|
||||
if (!caCert || !caCert.trim()) return {};
|
||||
return { dispatcher: new Agent({ connect: { ca: caCert } }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders why a fetch failed in a form an administrator can act on.
|
||||
*
|
||||
* undici reports every transport failure as the same "fetch failed" message
|
||||
* and puts the reason that actually matters -- ENOTFOUND, ECONNREFUSED,
|
||||
* UNABLE_TO_VERIFY_LEAF_SIGNATURE, a timeout -- on the cause. Reporting only
|
||||
* the outer message says nothing at all.
|
||||
*/
|
||||
export function describeFetchFailure(error: unknown): string {
|
||||
if (!(error instanceof Error)) return String(error);
|
||||
const cause = (error as { cause?: unknown }).cause;
|
||||
if (cause instanceof Error) {
|
||||
const code = (cause as { code?: unknown }).code;
|
||||
return code
|
||||
? `${error.message}: ${cause.message} (${code})`
|
||||
: `${error.message}: ${cause.message}`;
|
||||
}
|
||||
return cause ? `${error.message}: ${String(cause)}` : error.message;
|
||||
}
|
||||
|
||||
export function getOidcConfigFromEnv(): OidcConfig | null {
|
||||
const client_id = process.env.OIDC_CLIENT_ID;
|
||||
const client_secret = process.env.OIDC_CLIENT_SECRET;
|
||||
const issuer_url = process.env.OIDC_ISSUER_URL;
|
||||
const authorization_url = process.env.OIDC_AUTHORIZATION_URL;
|
||||
const token_url = process.env.OIDC_TOKEN_URL;
|
||||
|
||||
if (
|
||||
!client_id ||
|
||||
!client_secret ||
|
||||
!issuer_url ||
|
||||
!authorization_url ||
|
||||
!token_url
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
client_id,
|
||||
client_secret,
|
||||
issuer_url,
|
||||
authorization_url,
|
||||
token_url,
|
||||
userinfo_url: process.env.OIDC_USERINFO_URL || "",
|
||||
identifier_path: process.env.OIDC_IDENTIFIER_PATH || "sub",
|
||||
name_path: process.env.OIDC_NAME_PATH || "name",
|
||||
scopes: process.env.OIDC_SCOPES || "openid email profile",
|
||||
allowed_users: process.env.OIDC_ALLOWED_USERS || "",
|
||||
admin_group: process.env.OIDC_ADMIN_GROUP || "",
|
||||
group_claim: process.env.OIDC_GROUP_CLAIM || "",
|
||||
role_map: process.env.OIDC_ROLE_MAP || "",
|
||||
};
|
||||
}
|
||||
|
||||
export function isOIDCEnvOverrideEnabled(): boolean {
|
||||
return process.env.OIDC_ENV_OVERRIDE?.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a group name for comparison. Providers are inconsistent about
|
||||
* whether they emit bare names (`devops-interns`) or full paths
|
||||
* (`/devops-interns`, Keycloak's "Full group path" option), so leading slashes
|
||||
* are stripped and case is ignored.
|
||||
*/
|
||||
function normalizeGroupName(group: string): string {
|
||||
return group.trim().replace(/^\/+/, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `OIDC_ROLE_MAP` into a group -> role-name lookup.
|
||||
*
|
||||
* Format is a comma- or newline-separated list of `group:role` pairs, e.g.
|
||||
* `devops-interns:devops-intern,devops-seniors:devops-senior`. Group keys are
|
||||
* normalized via {@link normalizeGroupName}; role names are passed through
|
||||
* verbatim because they must match `roles.name` exactly.
|
||||
*
|
||||
* Malformed entries are skipped rather than throwing - a typo in one pair must
|
||||
* not lock every user out of login.
|
||||
*/
|
||||
export function parseOidcRoleMap(raw?: string | null): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
if (!raw || !raw.trim()) return map;
|
||||
|
||||
for (const entry of raw.split(/[\n,]/)) {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// rsplit on the last ":" so group names containing a colon still work.
|
||||
const separator = trimmed.lastIndexOf(":");
|
||||
if (separator <= 0 || separator === trimmed.length - 1) continue;
|
||||
|
||||
const group = normalizeGroupName(trimmed.slice(0, separator));
|
||||
const roleName = trimmed.slice(separator + 1).trim();
|
||||
if (!group || !roleName) continue;
|
||||
|
||||
map.set(group, roleName);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which mapped roles a user should hold, given their provider groups.
|
||||
*
|
||||
* Returns both the `desired` roles (mapped groups the user is actually in) and
|
||||
* the full set of `managed` roles (every role named in the map). Callers must
|
||||
* only ever add/remove roles within `managed` - roles assigned by hand in
|
||||
* Termix, and the `admin`/`user` roles maintained by the admin-group sync, are
|
||||
* deliberately left alone.
|
||||
*/
|
||||
export function resolveOidcMappedRoles(
|
||||
groups: string[],
|
||||
roleMap: Map<string, string>,
|
||||
): { desired: Set<string>; managed: Set<string> } {
|
||||
const managed = new Set(roleMap.values());
|
||||
const desired = new Set<string>();
|
||||
|
||||
for (const group of groups) {
|
||||
const roleName = roleMap.get(normalizeGroupName(group));
|
||||
if (roleName) desired.add(roleName);
|
||||
}
|
||||
|
||||
return { desired, managed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the list of group/role names from an OIDC userInfo payload.
|
||||
*
|
||||
* When `groupClaim` is set, that claim is read first (useful for providers like
|
||||
* Zitadel that nest roles under a custom path such as
|
||||
* `urn:zitadel:iam:org:project:roles`). Otherwise the common `groups`, `roles`
|
||||
* and `group` claims are tried. Values may be an array, a comma-separated
|
||||
* string, or an object whose keys are the group names.
|
||||
*/
|
||||
export function extractOidcGroups(
|
||||
userInfo: Record<string, unknown>,
|
||||
groupClaim?: string,
|
||||
): string[] {
|
||||
let raw: unknown;
|
||||
if (groupClaim && groupClaim.trim()) {
|
||||
raw = userInfo[groupClaim.trim()];
|
||||
}
|
||||
if (raw === undefined || raw === null) {
|
||||
raw = userInfo.groups ?? userInfo.roles ?? userInfo.group;
|
||||
}
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map(String);
|
||||
}
|
||||
if (typeof raw === "string") {
|
||||
return raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (raw && typeof raw === "object") {
|
||||
return Object.keys(raw as Record<string, unknown>);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC providers may return group claims in the ID token, userinfo response,
|
||||
* or both. Keep every verified source authoritative instead of letting a
|
||||
* sparse userinfo payload overwrite claims from the ID token.
|
||||
*/
|
||||
export function extractOidcGroupsFromSources(
|
||||
sources: Record<string, unknown>[],
|
||||
groupClaim?: string,
|
||||
): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
sources.flatMap((source) => extractOidcGroups(source, groupClaim)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export async function verifyOIDCToken(
|
||||
idToken: string,
|
||||
issuerUrl: string,
|
||||
clientId: string,
|
||||
caCert?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const segments = idToken.split(".");
|
||||
if (segments.length !== 3) {
|
||||
throw new OIDCTokenFormatError(
|
||||
segments.length === 5
|
||||
? "Token is a JWE (encrypted). Termix cannot verify encrypted tokens; disable token encryption for this client in your OIDC provider."
|
||||
: `Token is not a compact JWS: expected 3 segments, got ${segments.length}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const fetchOptions = buildFetchOptions(caCert);
|
||||
const configuredIssuerUrl = issuerUrl.trim().replace(/\/+$/, "");
|
||||
const normalizedIssuerUrl = normalizeIssuer(issuerUrl);
|
||||
const possibleIssuers = [
|
||||
normalizedIssuerUrl,
|
||||
normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, ""),
|
||||
...(configuredIssuerUrl === normalizedIssuerUrl ? [issuerUrl] : []),
|
||||
];
|
||||
|
||||
const jwksUrls = [
|
||||
`${normalizedIssuerUrl}/.well-known/jwks.json`,
|
||||
`${normalizedIssuerUrl}/jwks/`,
|
||||
`${normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, "")}/.well-known/jwks.json`,
|
||||
];
|
||||
|
||||
// Every attempt records why it failed. Without this the only thing an
|
||||
// administrator ever sees is "Failed to fetch JWKS from any URL", which
|
||||
// does not distinguish an issuer URL typo from a proxy, a private CA, or
|
||||
// a provider outage.
|
||||
const attempts: string[] = [];
|
||||
|
||||
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
|
||||
try {
|
||||
const discoveryResponse = await fetch(discoveryUrl, fetchOptions);
|
||||
if (!discoveryResponse.ok) {
|
||||
attempts.push(`${discoveryUrl}: HTTP ${discoveryResponse.status}`);
|
||||
} else {
|
||||
const discovery = (await discoveryResponse.json()) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (typeof discovery.jwks_uri === "string" && discovery.jwks_uri) {
|
||||
jwksUrls.unshift(discovery.jwks_uri);
|
||||
} else {
|
||||
attempts.push(`${discoveryUrl}: no jwks_uri in the discovery document`);
|
||||
}
|
||||
}
|
||||
} catch (discoveryError) {
|
||||
attempts.push(`${discoveryUrl}: ${describeFetchFailure(discoveryError)}`);
|
||||
}
|
||||
|
||||
let jwks: Record<string, unknown> | null = null;
|
||||
|
||||
for (const url of jwksUrls) {
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
attempts.push(`${url}: HTTP ${response.status}`);
|
||||
continue;
|
||||
}
|
||||
const jwksData = (await response.json()) as Record<string, unknown>;
|
||||
if (jwksData && Array.isArray(jwksData.keys)) {
|
||||
jwks = jwksData;
|
||||
break;
|
||||
}
|
||||
attempts.push(`${url}: response contains no "keys" array`);
|
||||
} catch (error) {
|
||||
attempts.push(`${url}: ${describeFetchFailure(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!jwks) {
|
||||
throw new Error(
|
||||
`Failed to fetch JWKS from any URL. Attempts:\n ${attempts.join("\n ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!jwks.keys || !Array.isArray(jwks.keys)) {
|
||||
throw new Error(
|
||||
`Invalid JWKS response structure. Expected 'keys' array, got: ${JSON.stringify(jwks)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { decodeProtectedHeader, importJWK, jwtVerify } = await import("jose");
|
||||
const header = decodeProtectedHeader(idToken);
|
||||
const keyId = header.kid;
|
||||
|
||||
const publicKey = jwks.keys.find(
|
||||
(key: Record<string, unknown>) => key.kid === keyId,
|
||||
);
|
||||
if (!publicKey) {
|
||||
throw new Error(
|
||||
`No matching public key found for key ID: ${keyId}. Available keys: ${jwks.keys.map((k: Record<string, unknown>) => k.kid).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const algorithm =
|
||||
typeof publicKey.alg === "string" ? publicKey.alg : header.alg;
|
||||
const key = await importJWK(publicKey, algorithm);
|
||||
|
||||
const { payload } = await jwtVerify(idToken, key, {
|
||||
issuer: possibleIssuers,
|
||||
audience: clientId,
|
||||
});
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
const GOOGLE_DEFAULTS = {
|
||||
issuer_url: "https://accounts.google.com",
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
token_url: "https://oauth2.googleapis.com/token",
|
||||
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo",
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
scopes: "openid email profile",
|
||||
};
|
||||
|
||||
const GITHUB_DEFAULTS = {
|
||||
issuer_url: "https://token.actions.githubusercontent.com",
|
||||
authorization_url: "https://github.com/login/oauth/authorize",
|
||||
token_url: "https://github.com/login/oauth/access_token",
|
||||
userinfo_url: "https://api.github.com/user",
|
||||
identifier_path: "id",
|
||||
name_path: "name",
|
||||
scopes: "read:user user:email",
|
||||
};
|
||||
|
||||
export function applyProviderDefaults(
|
||||
config: OidcConfig,
|
||||
providerType: string,
|
||||
): OidcConfig {
|
||||
const defaults =
|
||||
providerType === "google"
|
||||
? GOOGLE_DEFAULTS
|
||||
: providerType === "github"
|
||||
? GITHUB_DEFAULTS
|
||||
: null;
|
||||
if (!defaults) return config;
|
||||
return {
|
||||
...config,
|
||||
issuer_url: config.issuer_url || defaults.issuer_url,
|
||||
authorization_url: config.authorization_url || defaults.authorization_url,
|
||||
token_url: config.token_url || defaults.token_url,
|
||||
userinfo_url: config.userinfo_url || defaults.userinfo_url,
|
||||
identifier_path: config.identifier_path || defaults.identifier_path,
|
||||
name_path: config.name_path || defaults.name_path,
|
||||
scopes: config.scopes || defaults.scopes,
|
||||
};
|
||||
}
|
||||
|
||||
export type LogoutTokenClaims = {
|
||||
sub: string | null;
|
||||
sid: string | null;
|
||||
jti: string;
|
||||
};
|
||||
|
||||
export function validateLogoutTokenClaims(
|
||||
payload: Record<string, unknown>,
|
||||
): LogoutTokenClaims {
|
||||
if ("nonce" in payload) {
|
||||
throw new Error("logout_token must not contain a nonce claim");
|
||||
}
|
||||
|
||||
const event = (payload.events as Record<string, unknown> | undefined)?.[
|
||||
BACKCHANNEL_LOGOUT_EVENT
|
||||
];
|
||||
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
||||
throw new Error("logout_token missing back-channel logout event");
|
||||
}
|
||||
|
||||
if (!Number.isInteger(payload.iat)) {
|
||||
throw new Error("logout_token missing iat claim");
|
||||
}
|
||||
|
||||
const jti = typeof payload.jti === "string" ? payload.jti.trim() : "";
|
||||
if (!jti) {
|
||||
throw new Error("logout_token missing jti claim");
|
||||
}
|
||||
|
||||
const sub = typeof payload.sub === "string" ? payload.sub : null;
|
||||
const sid = typeof payload.sid === "string" ? payload.sid : null;
|
||||
if (!sub && !sid) {
|
||||
throw new Error("logout_token must contain sub and/or sid");
|
||||
}
|
||||
|
||||
return { sub, sid, jti };
|
||||
}
|
||||
|
||||
export async function validateLogoutToken(
|
||||
logoutToken: string,
|
||||
config: OidcConfig,
|
||||
): Promise<LogoutTokenClaims> {
|
||||
const payload = await verifyOIDCToken(
|
||||
logoutToken,
|
||||
config.issuer_url,
|
||||
config.client_id,
|
||||
config.ca_cert,
|
||||
);
|
||||
|
||||
return validateLogoutTokenClaims(payload);
|
||||
}
|
||||
|
||||
export function isValidOidcIssuer(value: unknown): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
["http:", "https:"].includes(url.protocol) &&
|
||||
!/\/userinfo\/?$/i.test(url.pathname)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getNestedValue(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
): unknown {
|
||||
if (!path || !obj) return null;
|
||||
return path
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(current, key) => (current as Record<string, unknown>)?.[key],
|
||||
obj,
|
||||
);
|
||||
}
|
||||
|
||||
// RFC 7636 PKCE: 43-128 char unreserved-character string.
|
||||
export function generatePkceCodeVerifier(): string {
|
||||
return crypto.randomBytes(64).toString("base64url");
|
||||
}
|
||||
|
||||
export function generatePkceCodeChallenge(verifier: string): string {
|
||||
return crypto.createHash("sha256").update(verifier).digest("base64url");
|
||||
}
|
||||
|
||||
/** The desktop app's loopback listener for a callback port it sent. */
|
||||
export function getDesktopCallbackUrl(value: unknown): string | null {
|
||||
if (typeof value !== "string" || !/^\d+$/.test(value)) return null;
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
||||
return `http://localhost:${port}/oidc-callback`;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { and, asc, desc, eq } from "drizzle-orm";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { applyProviderDefaults, normalizeIssuer } from "./oidc-protocol.js";
|
||||
import {
|
||||
SSO_PROVIDER_TYPES,
|
||||
type OidcConfig,
|
||||
type ProviderRow,
|
||||
type ResolvedProvider,
|
||||
type SsoProviderType,
|
||||
} from "./types.js";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// ctx.db.define hands the table back untyped and the drizzle handle is the
|
||||
// server's own, so both are typed at this module's edge.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/** Identities from the env-configured provider use this provider id. */
|
||||
export const ENV_PROVIDER_ID = "legacy-oidc";
|
||||
/** The login instance id of the env-configured provider. */
|
||||
export const ENV_INSTANCE_ID = "0";
|
||||
|
||||
const SECRET_FIELDS = ["client_secret"] as const;
|
||||
const LEGACY_PREFIXES = ["encoded:", "encrypted:"];
|
||||
|
||||
export function isSsoType(type: unknown): type is SsoProviderType {
|
||||
return SSO_PROVIDER_TYPES.includes(type as SsoProviderType);
|
||||
}
|
||||
|
||||
export function getOidcConfigFromEnv(): OidcConfig | null {
|
||||
const client_id = process.env.OIDC_CLIENT_ID;
|
||||
const client_secret = process.env.OIDC_CLIENT_SECRET;
|
||||
const issuer_url = process.env.OIDC_ISSUER_URL;
|
||||
const authorization_url = process.env.OIDC_AUTHORIZATION_URL;
|
||||
const token_url = process.env.OIDC_TOKEN_URL;
|
||||
if (
|
||||
!client_id ||
|
||||
!client_secret ||
|
||||
!issuer_url ||
|
||||
!authorization_url ||
|
||||
!token_url
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
client_id,
|
||||
client_secret,
|
||||
issuer_url,
|
||||
authorization_url,
|
||||
token_url,
|
||||
userinfo_url: process.env.OIDC_USERINFO_URL || "",
|
||||
identifier_path: process.env.OIDC_IDENTIFIER_PATH || "sub",
|
||||
name_path: process.env.OIDC_NAME_PATH || "name",
|
||||
scopes: process.env.OIDC_SCOPES || "openid email profile",
|
||||
allowed_users: process.env.OIDC_ALLOWED_USERS || "",
|
||||
admin_group: process.env.OIDC_ADMIN_GROUP || "",
|
||||
group_claim: process.env.OIDC_GROUP_CLAIM || "",
|
||||
role_map: process.env.OIDC_ROLE_MAP || "",
|
||||
};
|
||||
}
|
||||
|
||||
export function isEnvOverrideEnabled(): boolean {
|
||||
return process.env.OIDC_ENV_OVERRIDE?.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
function envProvider(config: OidcConfig): ResolvedProvider {
|
||||
// Env providers were set up against the 2.8 callback and cannot be edited.
|
||||
return { config, type: "oidc", rowId: null, legacyCallback: true };
|
||||
}
|
||||
|
||||
export type ProviderStore = ReturnType<typeof createProviderStore>;
|
||||
|
||||
export function createProviderStore(ctx: PluginContext, table: Table) {
|
||||
const client = () => ctx.db.client<Drizzle>();
|
||||
|
||||
/** Opens a stored secret: sealed, or the 2.8 base64 forms. */
|
||||
async function openSecret(value: string): Promise<string> {
|
||||
const prefix = LEGACY_PREFIXES.find((p) => value.startsWith(p));
|
||||
if (prefix) {
|
||||
try {
|
||||
return Buffer.from(value.slice(prefix.length), "base64").toString(
|
||||
"utf8",
|
||||
);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
const opened = await ctx.secrets.unseal(value);
|
||||
// A value seal() never wrote was stored in the clear by hand.
|
||||
return opened ?? value;
|
||||
}
|
||||
|
||||
async function parseConfig(raw: string): Promise<Record<string, unknown>> {
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
for (const field of SECRET_FIELDS) {
|
||||
const value = config[field];
|
||||
if (typeof value === "string" && value) {
|
||||
config[field] = await openSecret(value);
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async function sealConfig(config: Record<string, unknown>): Promise<string> {
|
||||
const out = { ...config };
|
||||
for (const field of SECRET_FIELDS) {
|
||||
const value = out[field];
|
||||
if (typeof value === "string" && value) {
|
||||
out[field] = await ctx.secrets.seal(value);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
|
||||
async function listRows(): Promise<ProviderRow[]> {
|
||||
const drizzle = await client();
|
||||
const rows = (await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.orderBy(asc(table.displayOrder), asc(table.id))) as ProviderRow[];
|
||||
return rows.filter((row) => isSsoType(row.type));
|
||||
}
|
||||
|
||||
async function findRow(id: number): Promise<ProviderRow | null> {
|
||||
const drizzle = await client();
|
||||
const rows = (await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.id, id))
|
||||
.limit(1)) as ProviderRow[];
|
||||
const row = rows[0];
|
||||
return row && isSsoType(row.type) ? row : null;
|
||||
}
|
||||
|
||||
async function resolveRow(row: ProviderRow): Promise<ResolvedProvider> {
|
||||
const type = row.type as SsoProviderType;
|
||||
const config = applyProviderDefaults(
|
||||
(await parseConfig(row.config)) as unknown as OidcConfig,
|
||||
type,
|
||||
);
|
||||
return {
|
||||
config,
|
||||
type,
|
||||
rowId: row.id,
|
||||
legacyCallback: !!row.legacyCallback,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
parseConfig,
|
||||
sealConfig,
|
||||
listRows,
|
||||
findRow,
|
||||
|
||||
/** Login instances: env override, rows, or the env provider alone. */
|
||||
async listInstances(): Promise<
|
||||
Array<{ id: string; label: string; type: SsoProviderType }>
|
||||
> {
|
||||
const env = getOidcConfigFromEnv();
|
||||
const envInstance = {
|
||||
id: ENV_INSTANCE_ID,
|
||||
label: "SSO",
|
||||
type: "oidc" as const,
|
||||
};
|
||||
if (env && isEnvOverrideEnabled()) return [envInstance];
|
||||
const rows = (await listRows()).filter((row) => row.enabled);
|
||||
const instances = rows.map((row) => ({
|
||||
id: String(row.id),
|
||||
label: row.name,
|
||||
type: row.type as SsoProviderType,
|
||||
}));
|
||||
if (instances.length === 0 && env) instances.push(envInstance);
|
||||
return instances;
|
||||
},
|
||||
|
||||
/**
|
||||
* The provider for a login. With no id, the env provider or the first
|
||||
* enabled row, as 2.8 did.
|
||||
*/
|
||||
async resolve(rowId: number | null): Promise<ResolvedProvider | null> {
|
||||
const env = getOidcConfigFromEnv();
|
||||
if (env && isEnvOverrideEnabled()) return envProvider(env);
|
||||
if (rowId != null) {
|
||||
const row = await findRow(rowId);
|
||||
return row?.enabled ? resolveRow(row) : null;
|
||||
}
|
||||
if (env) return envProvider(env);
|
||||
const first = (await listRows()).find((row) => row.enabled);
|
||||
return first ? resolveRow(first) : null;
|
||||
},
|
||||
|
||||
/** The provider a back-channel logout token's issuer belongs to. */
|
||||
async resolveByIssuer(issuer: string): Promise<ResolvedProvider | null> {
|
||||
const target = normalizeIssuer(issuer);
|
||||
const env = getOidcConfigFromEnv();
|
||||
const envMatches =
|
||||
!!env?.issuer_url && normalizeIssuer(env.issuer_url) === target;
|
||||
if (envMatches && isEnvOverrideEnabled()) return envProvider(env!);
|
||||
for (const row of await listRows()) {
|
||||
if (!row.enabled) continue;
|
||||
const resolved = await resolveRow(row);
|
||||
if (
|
||||
resolved.config.issuer_url &&
|
||||
normalizeIssuer(resolved.config.issuer_url) === target
|
||||
) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
return envMatches ? envProvider(env!) : null;
|
||||
},
|
||||
|
||||
async create(input: {
|
||||
name: string;
|
||||
type: SsoProviderType;
|
||||
enabled: boolean;
|
||||
displayOrder: number;
|
||||
config: Record<string, unknown>;
|
||||
}): Promise<ProviderRow> {
|
||||
const drizzle = await client();
|
||||
const now = new Date().toISOString();
|
||||
await drizzle.insert(table).values({
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
enabled: input.enabled,
|
||||
displayOrder: input.displayOrder,
|
||||
config: await sealConfig(input.config),
|
||||
legacyCallback: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.persist();
|
||||
// No returning() on every engine: find the row by what was written.
|
||||
const rows = (await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.name, input.name), eq(table.createdAt, now)))
|
||||
.orderBy(desc(table.id))
|
||||
.limit(1)) as ProviderRow[];
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
values: Partial<{
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
displayOrder: number;
|
||||
config: Record<string, unknown>;
|
||||
legacyCallback: boolean;
|
||||
}>,
|
||||
): Promise<ProviderRow | null> {
|
||||
const drizzle = await client();
|
||||
const { config, ...rest } = values;
|
||||
await drizzle
|
||||
.update(table)
|
||||
.set({
|
||||
...rest,
|
||||
...(config ? { config: await sealConfig(config) } : {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(table.id, id));
|
||||
await ctx.db.persist();
|
||||
return findRow(id);
|
||||
},
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle.delete(table).where(eq(table.id, id));
|
||||
await ctx.db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import {
|
||||
LoginMethodError,
|
||||
type PluginContext,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
applyProviderDefaults,
|
||||
isValidOidcIssuer,
|
||||
validateLogoutToken,
|
||||
} from "./oidc-protocol.js";
|
||||
import { isSsoType, type ProviderStore } from "./providers.js";
|
||||
import { METHOD_ID, redirectUriFor, type SsoLogin } from "./login.js";
|
||||
import type { OidcConfig, ProviderRow, SsoProviderType } from "./types.js";
|
||||
|
||||
export const PUBLIC_PATHS = [
|
||||
"/start",
|
||||
"/callback",
|
||||
"/backchannel-logout",
|
||||
"/config",
|
||||
];
|
||||
|
||||
const LOGOUT_JTI_TTL_MS = 5 * 60 * 1000;
|
||||
const REQUIRED_OIDC_FIELDS = [
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"issuer_url",
|
||||
"authorization_url",
|
||||
"token_url",
|
||||
] as const;
|
||||
|
||||
function fail(
|
||||
ctx: PluginContext,
|
||||
res: Response,
|
||||
message: string,
|
||||
error: unknown,
|
||||
) {
|
||||
ctx.log.error(
|
||||
message,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
|
||||
export function registerSsoRoutes(
|
||||
router: Router,
|
||||
ctx: PluginContext,
|
||||
store: ProviderStore,
|
||||
login: SsoLogin,
|
||||
): void {
|
||||
// Replayed logout tokens are answered but not acted on twice.
|
||||
const seenLogoutJti = new Map<string, number>();
|
||||
|
||||
async function present(req: Request, row: ProviderRow) {
|
||||
const config = await store.parseConfig(row.config);
|
||||
const { client_secret: secret, ...rest } = config;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
enabled: !!row.enabled,
|
||||
displayOrder: row.displayOrder,
|
||||
legacyCallback: !!row.legacyCallback,
|
||||
config: rest,
|
||||
hasClientSecret: typeof secret === "string" && secret.length > 0,
|
||||
redirectUri: redirectUriFor(ctx.http.baseUrl(req), {
|
||||
legacyCallback: !!row.legacyCallback,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Checks a full provider config; returns the error to show, if any. */
|
||||
function validate(type: SsoProviderType, config: Partial<OidcConfig>) {
|
||||
if (type === "oidc") {
|
||||
const missing = REQUIRED_OIDC_FIELDS.filter((field) => !config[field]);
|
||||
if (missing.length > 0) {
|
||||
return `Missing required OIDC fields: ${missing.join(", ")}`;
|
||||
}
|
||||
} else if (!config.client_id || !config.client_secret) {
|
||||
return "Client ID and Client Secret are required";
|
||||
}
|
||||
if (config.issuer_url && !isValidOidcIssuer(config.issuer_url)) {
|
||||
return "Issuer URL must be an HTTP(S) issuer and not a userinfo endpoint";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/sso/start:
|
||||
* get:
|
||||
* summary: Start an SSO login in the browser
|
||||
* description: Public. Sends the browser straight to the provider, so a link can start a login. The provider is picked by the provider query parameter, or the default one.
|
||||
* tags:
|
||||
* - SSO
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: provider
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 302:
|
||||
* description: Redirect to the identity provider.
|
||||
* 404:
|
||||
* description: SSO is not configured.
|
||||
*/
|
||||
router.get("/start", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const instance =
|
||||
typeof req.query.provider === "string" ? req.query.provider : null;
|
||||
const { redirectUrl } = await login.start(req as never, instance);
|
||||
res.redirect(redirectUrl);
|
||||
} catch (error) {
|
||||
if (error instanceof LoginMethodError) {
|
||||
res.status(error.status).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
fail(ctx, res, "Failed to start SSO login", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/sso/callback:
|
||||
* get:
|
||||
* summary: SSO callback
|
||||
* description: Public. The redirect URI to register with an identity provider. Exchanges the code and redirects back to the app with a session, a second-factor step or an error.
|
||||
* tags:
|
||||
* - SSO
|
||||
* responses:
|
||||
* 302:
|
||||
* description: Back to the app.
|
||||
* post:
|
||||
* summary: SSO callback (form post)
|
||||
* description: Same as the GET form, for providers that post their answer.
|
||||
* tags:
|
||||
* - SSO
|
||||
* responses:
|
||||
* 302:
|
||||
* description: Back to the app.
|
||||
*/
|
||||
const callback = (req: Request, res: Response) =>
|
||||
ctx.auth.completeRedirectLogin(METHOD_ID, req, res);
|
||||
router.get("/callback", callback);
|
||||
router.post("/callback", callback);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/sso/backchannel-logout:
|
||||
* post:
|
||||
* summary: OIDC back-channel logout
|
||||
* description: Public. Where an identity provider sends a signed logout token. Ends the Termix sessions that came from the matching provider login.
|
||||
* tags:
|
||||
* - SSO
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/x-www-form-urlencoded:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* logout_token:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Logout processed.
|
||||
* 400:
|
||||
* description: Missing, invalid or unknown logout token.
|
||||
*/
|
||||
router.post("/backchannel-logout", async (req: Request, res: Response) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
const logoutToken = (req.body as Record<string, unknown> | undefined)
|
||||
?.logout_token;
|
||||
if (typeof logoutToken !== "string" || !logoutToken) {
|
||||
res.status(400).json({ error: "missing logout_token" });
|
||||
return;
|
||||
}
|
||||
|
||||
let issuer: string | null = null;
|
||||
try {
|
||||
const parts = logoutToken.split(".");
|
||||
if (parts.length === 3) {
|
||||
const claims = JSON.parse(Buffer.from(parts[1], "base64").toString());
|
||||
issuer = typeof claims.iss === "string" ? claims.iss : null;
|
||||
}
|
||||
} catch {
|
||||
issuer = null;
|
||||
}
|
||||
if (!issuer) {
|
||||
res.status(400).json({ error: "invalid logout_token" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = await store.resolveByIssuer(issuer);
|
||||
if (!provider) {
|
||||
ctx.log.warn(`Back-channel logout for unknown issuer ${issuer}`);
|
||||
res.status(400).json({ error: "unknown issuer" });
|
||||
return;
|
||||
}
|
||||
const claims = await validateLogoutToken(logoutToken, provider.config);
|
||||
|
||||
const now = Date.now();
|
||||
for (const [key, expiry] of seenLogoutJti) {
|
||||
if (expiry <= now) seenLogoutJti.delete(key);
|
||||
}
|
||||
if (seenLogoutJti.has(claims.jti)) {
|
||||
res.status(200).json({ ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.auth.revokeSessions({
|
||||
providerId: provider.rowId,
|
||||
sub: claims.sub,
|
||||
sid: claims.sid,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(ctx, res, "logout processing failed", error);
|
||||
return;
|
||||
}
|
||||
seenLogoutJti.set(claims.jti, now + LOGOUT_JTI_TTL_MS);
|
||||
res.status(200).json({ ok: true });
|
||||
} catch (error) {
|
||||
ctx.log.warn(`Back-channel logout failed: ${String(error)}`);
|
||||
res.status(400).json({ error: "invalid logout_token" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/sso/config:
|
||||
* get:
|
||||
* summary: Default provider's public configuration
|
||||
* description: Public, for 2.8 clients. The client id, issuer, authorization URL and scopes of the default provider, or null.
|
||||
* tags:
|
||||
* - SSO
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Public configuration, or null.
|
||||
*/
|
||||
router.get("/config", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const provider = await store.resolve(null);
|
||||
if (!provider) {
|
||||
res.json(null);
|
||||
return;
|
||||
}
|
||||
const { config } = provider;
|
||||
res.json({
|
||||
client_id: config.client_id,
|
||||
issuer_url: config.issuer_url,
|
||||
authorization_url: config.authorization_url,
|
||||
scopes: config.scopes,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(ctx, res, "Failed to get OIDC config", error);
|
||||
}
|
||||
});
|
||||
|
||||
// Everything below manages providers.
|
||||
const manage = ctx.rbac.require("manage") as never;
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/sso/providers:
|
||||
* get:
|
||||
* summary: List SSO providers
|
||||
* description: Every OIDC, GitHub and Google provider with its redirect URI. Secrets are never sent, only whether one is set. Needs sso.manage.
|
||||
* tags:
|
||||
* - SSO
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Providers.
|
||||
* 403:
|
||||
* description: Missing permission.
|
||||
*/
|
||||
router.get("/providers", manage, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const rows = await store.listRows();
|
||||
res.json({
|
||||
providers: await Promise.all(rows.map((row) => present(req, row))),
|
||||
newRedirectUri: redirectUriFor(ctx.http.baseUrl(req), {
|
||||
legacyCallback: false,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(ctx, res, "Failed to list SSO providers", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/sso/providers:
|
||||
* post:
|
||||
* summary: Add an SSO provider
|
||||
* description: Adds an OIDC, GitHub or Google provider. GitHub and Google fill in their own endpoints. Needs sso.manage.
|
||||
* tags:
|
||||
* - SSO
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [oidc, github, google]
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* config:
|
||||
* type: object
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Provider added.
|
||||
* 400:
|
||||
* description: Missing or invalid fields.
|
||||
* 403:
|
||||
* description: Missing permission.
|
||||
*/
|
||||
router.post("/providers", manage, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
type,
|
||||
enabled = true,
|
||||
displayOrder = 0,
|
||||
config = {},
|
||||
} = req.body ?? {};
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
res.status(400).json({ error: "Provider name is required" });
|
||||
return;
|
||||
}
|
||||
if (!isSsoType(type)) {
|
||||
res.status(400).json({ error: "Invalid provider type" });
|
||||
return;
|
||||
}
|
||||
const full = applyProviderDefaults(config as OidcConfig, type);
|
||||
const invalid = validate(type, full);
|
||||
if (invalid) {
|
||||
res.status(400).json({ error: invalid });
|
||||
return;
|
||||
}
|
||||
const row = await store.create({
|
||||
name: name.trim(),
|
||||
type,
|
||||
enabled: !!enabled,
|
||||
displayOrder: Number(displayOrder) || 0,
|
||||
config: { ...(config as Record<string, unknown>) },
|
||||
});
|
||||
await ctx.audit.record({
|
||||
action: "sso_provider_create",
|
||||
resourceType: "sso_provider",
|
||||
resourceId: String(row.id),
|
||||
resourceName: row.name,
|
||||
success: true,
|
||||
});
|
||||
res.status(201).json(await present(req, row));
|
||||
} catch (error) {
|
||||
fail(ctx, res, "Failed to create SSO provider", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/sso/providers/{id}:
|
||||
* put:
|
||||
* summary: Update an SSO provider
|
||||
* description: Changes a provider. Config fields are merged over the stored ones; an empty client secret keeps the stored secret. legacyCallback false moves it to the new redirect URI. Needs sso.manage.
|
||||
* tags:
|
||||
* - SSO
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Provider updated.
|
||||
* 400:
|
||||
* description: Invalid fields.
|
||||
* 403:
|
||||
* description: Missing permission.
|
||||
* 404:
|
||||
* description: Provider not found.
|
||||
*/
|
||||
router.put("/providers/:id", manage, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number.parseInt(String(req.params.id), 10);
|
||||
const row = Number.isInteger(id) ? await store.findRow(id) : null;
|
||||
if (!row) {
|
||||
res.status(404).json({ error: "Provider not found" });
|
||||
return;
|
||||
}
|
||||
const { name, enabled, displayOrder, config, legacyCallback } =
|
||||
req.body ?? {};
|
||||
const values: Parameters<ProviderStore["update"]>[1] = {};
|
||||
if (name !== undefined) {
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
res.status(400).json({ error: "Provider name is required" });
|
||||
return;
|
||||
}
|
||||
values.name = name.trim();
|
||||
}
|
||||
if (enabled !== undefined) values.enabled = !!enabled;
|
||||
if (displayOrder !== undefined) {
|
||||
values.displayOrder = Number(displayOrder) || 0;
|
||||
}
|
||||
if (legacyCallback !== undefined) {
|
||||
values.legacyCallback = !!legacyCallback;
|
||||
}
|
||||
if (config && typeof config === "object") {
|
||||
const incoming = { ...(config as Record<string, unknown>) };
|
||||
if (!incoming.client_secret) delete incoming.client_secret;
|
||||
const merged = {
|
||||
...(await store.parseConfig(row.config)),
|
||||
...incoming,
|
||||
};
|
||||
const invalid = validate(
|
||||
row.type as SsoProviderType,
|
||||
applyProviderDefaults(merged as unknown as OidcConfig, row.type),
|
||||
);
|
||||
if (invalid) {
|
||||
res.status(400).json({ error: invalid });
|
||||
return;
|
||||
}
|
||||
values.config = merged;
|
||||
}
|
||||
const updated = await store.update(id, values);
|
||||
await ctx.audit.record({
|
||||
action: "sso_provider_update",
|
||||
resourceType: "sso_provider",
|
||||
resourceId: String(id),
|
||||
resourceName: updated?.name ?? row.name,
|
||||
success: true,
|
||||
});
|
||||
res.json(await present(req, updated ?? row));
|
||||
} catch (error) {
|
||||
fail(ctx, res, "Failed to update SSO provider", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/sso/providers/{id}:
|
||||
* delete:
|
||||
* summary: Delete an SSO provider
|
||||
* description: Refused while users still sign in with it. Needs sso.manage.
|
||||
* tags:
|
||||
* - SSO
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Provider deleted.
|
||||
* 403:
|
||||
* description: Missing permission.
|
||||
* 404:
|
||||
* description: Provider not found.
|
||||
* 409:
|
||||
* description: Users are still linked to it.
|
||||
*/
|
||||
router.delete(
|
||||
"/providers/:id",
|
||||
manage,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number.parseInt(String(req.params.id), 10);
|
||||
const row = Number.isInteger(id) ? await store.findRow(id) : null;
|
||||
if (!row) {
|
||||
res.status(404).json({ error: "Provider not found" });
|
||||
return;
|
||||
}
|
||||
if ((await ctx.auth.countLinkedUsers(String(id))) > 0) {
|
||||
res.status(409).json({
|
||||
error:
|
||||
"Users still sign in with this provider. Disable it instead, or remove those users first.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await store.remove(id);
|
||||
await ctx.audit.record({
|
||||
action: "sso_provider_delete",
|
||||
resourceType: "sso_provider",
|
||||
resourceId: String(id),
|
||||
resourceName: row.name,
|
||||
success: true,
|
||||
});
|
||||
res.json({ message: "SSO provider deleted" });
|
||||
} catch (error) {
|
||||
fail(ctx, res, "Failed to delete SSO provider", error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
boolean,
|
||||
defineTable,
|
||||
id,
|
||||
integer,
|
||||
text,
|
||||
timestamp,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* Adopted from core's sso_providers, so the column names are the legacy ones
|
||||
* and the rename carries every provider across. LDAP rows are moved out to
|
||||
* the ldap plugin by core's boot migration; until then they are ignored here.
|
||||
*/
|
||||
export const providers = adoptLegacyTable(
|
||||
"sso_providers",
|
||||
defineTable("providers", {
|
||||
id: id(),
|
||||
name: text().notNull(),
|
||||
type: text().notNull(),
|
||||
enabled: boolean().notNull().default(true),
|
||||
displayOrder: integer().notNull().default(0),
|
||||
config: text().notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
// Providers from before 2.9 keep sending the old redirect URI.
|
||||
legacyCallback: boolean().notNull().default(false),
|
||||
}),
|
||||
);
|
||||
|
||||
export const tables = [providers];
|
||||
@@ -0,0 +1,46 @@
|
||||
export type SsoProviderType = "oidc" | "github" | "google";
|
||||
|
||||
export const SSO_PROVIDER_TYPES: readonly SsoProviderType[] = [
|
||||
"oidc",
|
||||
"github",
|
||||
"google",
|
||||
];
|
||||
|
||||
export interface OidcConfig {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
userinfo_url: string;
|
||||
identifier_path: string;
|
||||
name_path: string;
|
||||
scopes: string;
|
||||
allowed_users: string;
|
||||
admin_group: string;
|
||||
group_claim?: string;
|
||||
role_map?: string;
|
||||
ca_cert?: string;
|
||||
}
|
||||
|
||||
/** A provider ready to sign someone in: secrets opened, defaults applied. */
|
||||
export interface ResolvedProvider {
|
||||
config: OidcConfig;
|
||||
type: SsoProviderType;
|
||||
/** The row id, or null for the provider configured through OIDC_* env vars. */
|
||||
rowId: number | null;
|
||||
/** Sends the 2.8 redirect URI, /users/oidc/callback, which core forwards. */
|
||||
legacyCallback: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderRow {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
displayOrder: number;
|
||||
config: string;
|
||||
legacyCallback: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useTranslation,
|
||||
type LoginMethodUIProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/** One button per enabled provider. */
|
||||
export function OidcLoginButtons({
|
||||
instances,
|
||||
disabled,
|
||||
startRedirect,
|
||||
}: LoginMethodUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
return (
|
||||
<>
|
||||
{instances.map((instance) => (
|
||||
<Button
|
||||
key={instance.id}
|
||||
onClick={async () => {
|
||||
setLoadingId(instance.id);
|
||||
try {
|
||||
await startRedirect(instance.id);
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
}}
|
||||
disabled={disabled || loadingId !== null}
|
||||
className="w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"
|
||||
>
|
||||
{loadingId === instance.id
|
||||
? t("loading")
|
||||
: t("loginWithProvider", { name: instance.label })}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { Copy, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Select2,
|
||||
Switch,
|
||||
copyToClipboard,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
createSsoApi,
|
||||
type SsoProvider,
|
||||
type SsoProviderInput,
|
||||
type SsoProviderType,
|
||||
} from "./sso-api";
|
||||
|
||||
const TYPE_LABELS: Record<SsoProviderType, string> = {
|
||||
oidc: "OIDC",
|
||||
github: "GitHub",
|
||||
google: "Google",
|
||||
};
|
||||
|
||||
const AUTHORIZATION_URLS: Record<"github" | "google", string> = {
|
||||
github: "https://github.com/login/oauth/authorize",
|
||||
google: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
};
|
||||
|
||||
type Fields = {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
userinfo_url: string;
|
||||
identifier_path: string;
|
||||
name_path: string;
|
||||
scopes: string;
|
||||
allowed_users: string;
|
||||
admin_group: string;
|
||||
group_claim: string;
|
||||
ca_cert: string;
|
||||
};
|
||||
|
||||
const EMPTY_FIELDS: Fields = {
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
issuer_url: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
userinfo_url: "",
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
scopes: "openid email profile",
|
||||
allowed_users: "",
|
||||
admin_group: "",
|
||||
group_claim: "",
|
||||
ca_cert: "",
|
||||
};
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
const err = error as {
|
||||
response?: { data?: { error?: string } };
|
||||
message?: string;
|
||||
};
|
||||
return err.response?.data?.error || err.message || fallback;
|
||||
}
|
||||
|
||||
const labelClass =
|
||||
"text-[10px] font-semibold text-muted-foreground uppercase tracking-widest";
|
||||
const areaClass =
|
||||
"w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono";
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className={labelClass}>
|
||||
{label}
|
||||
{required && <span className="text-accent-brand ml-1">*</span>}
|
||||
</label>
|
||||
{hint && (
|
||||
<span className="text-[10px] text-muted-foreground">{hint}</span>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RedirectUri({ uri }: { uri: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="flex-1 min-w-0 truncate text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-1">
|
||||
{uri}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
title={t("providers.copy")}
|
||||
onClick={async () => {
|
||||
await copyToClipboard(uri);
|
||||
toast.success(t("providers.copied"));
|
||||
}}
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
provider,
|
||||
newRedirectUri,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
provider: SsoProvider | null;
|
||||
newRedirectUri: string;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const api = createSsoApi(usePluginApi());
|
||||
const isEdit = provider !== null;
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState<SsoProviderType>("oidc");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [legacyCallback, setLegacyCallback] = useState(false);
|
||||
const [fields, setFields] = useState<Fields>(EMPTY_FIELDS);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(provider?.name ?? "");
|
||||
setType(provider?.type ?? "oidc");
|
||||
setEnabled(provider?.enabled ?? true);
|
||||
setLegacyCallback(provider?.legacyCallback ?? false);
|
||||
const config = provider?.config ?? {};
|
||||
const next = { ...EMPTY_FIELDS };
|
||||
for (const key of Object.keys(next) as Array<keyof Fields>) {
|
||||
const value = config[key];
|
||||
if (typeof value === "string") next[key] = value;
|
||||
}
|
||||
next.client_secret = "";
|
||||
setFields(next);
|
||||
}, [open, provider]);
|
||||
|
||||
const set = (key: keyof Fields) => (value: string) =>
|
||||
setFields((prev) => ({ ...prev, [key]: value }));
|
||||
const simplified = type !== "oidc";
|
||||
|
||||
async function save() {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("providers.nameRequired"));
|
||||
return;
|
||||
}
|
||||
const config: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value.trim()) config[key] = value.trim();
|
||||
}
|
||||
const input: SsoProviderInput = {
|
||||
name: name.trim(),
|
||||
type,
|
||||
enabled,
|
||||
config,
|
||||
...(isEdit ? { legacyCallback } : {}),
|
||||
};
|
||||
setSaving(true);
|
||||
try {
|
||||
if (provider) await api.update(provider.id, input);
|
||||
else await api.create(input);
|
||||
toast.success(t("providers.saved"));
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error, t("providers.saveFailed")));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const secretHint = provider?.hasClientSecret
|
||||
? t("fields.secretKeep")
|
||||
: undefined;
|
||||
const redirectUri = legacyCallback
|
||||
? (provider?.redirectUri ?? newRedirectUri)
|
||||
: newRedirectUri;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? t("providers.edit") : t("providers.add")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<Field label={t("providers.name")} required>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("providers.namePlaceholder")}
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{!isEdit && (
|
||||
<Field label={t("providers.type")}>
|
||||
<Select2
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as SsoProviderType)}
|
||||
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{(Object.keys(TYPE_LABELS) as SsoProviderType[]).map(
|
||||
(value) => (
|
||||
<option key={value} value={value}>
|
||||
{TYPE_LABELS[value]}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</Select2>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={labelClass}>{t("providers.enabled")}</label>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-3">
|
||||
<label className={labelClass}>{t("providers.redirectUri")}</label>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("providers.redirectUriDesc")}
|
||||
</span>
|
||||
<RedirectUri uri={redirectUri} />
|
||||
{isEdit && provider.legacyCallback && (
|
||||
<div className="flex items-start justify-between gap-3 pt-1">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("providers.legacyCallbackDesc", {
|
||||
uri: newRedirectUri,
|
||||
})}
|
||||
</span>
|
||||
<Switch
|
||||
checked={legacyCallback}
|
||||
onCheckedChange={setLegacyCallback}
|
||||
aria-label={t("providers.legacyCallback")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-border pt-3">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
<a
|
||||
href={
|
||||
simplified
|
||||
? "https://docs.termix.site/features/authentication/github-google"
|
||||
: "https://docs.termix.site/features/authentication/oidc"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("providers.docsLink")}
|
||||
</a>
|
||||
</span>
|
||||
<Field label={t("fields.clientId")} required>
|
||||
<Input
|
||||
value={fields.client_id}
|
||||
onChange={(e) => set("client_id")(e.target.value)}
|
||||
placeholder="your-client-id"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("fields.clientSecret")}
|
||||
required={!provider?.hasClientSecret}
|
||||
hint={secretHint}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={fields.client_secret}
|
||||
onChange={(e) => set("client_secret")(e.target.value)}
|
||||
placeholder="your-client-secret"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
{simplified ? (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("providers.authorizationUrl", {
|
||||
url: AUTHORIZATION_URLS[type as "github" | "google"],
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Field label={t("fields.issuerUrl")} required>
|
||||
<Input
|
||||
value={fields.issuer_url}
|
||||
onChange={(e) => set("issuer_url")(e.target.value)}
|
||||
placeholder="https://provider"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.authUrl")} required>
|
||||
<Input
|
||||
value={fields.authorization_url}
|
||||
onChange={(e) => set("authorization_url")(e.target.value)}
|
||||
placeholder="https://provider/oauth2/auth"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.tokenUrl")} required>
|
||||
<Input
|
||||
value={fields.token_url}
|
||||
onChange={(e) => set("token_url")(e.target.value)}
|
||||
placeholder="https://provider/oauth2/token"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.userIdentifier")} required>
|
||||
<Input
|
||||
value={fields.identifier_path}
|
||||
onChange={(e) => set("identifier_path")(e.target.value)}
|
||||
placeholder="sub"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.displayName")} required>
|
||||
<Input
|
||||
value={fields.name_path}
|
||||
onChange={(e) => set("name_path")(e.target.value)}
|
||||
placeholder="name"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.scopes")} required>
|
||||
<Input
|
||||
value={fields.scopes}
|
||||
onChange={(e) => set("scopes")(e.target.value)}
|
||||
placeholder="openid email profile"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.userinfoUrl")}>
|
||||
<Input
|
||||
value={fields.userinfo_url}
|
||||
onChange={(e) => set("userinfo_url")(e.target.value)}
|
||||
placeholder="https://provider/oauth2/userinfo"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("fields.groupClaim")}
|
||||
hint={t("fields.groupClaimDesc")}
|
||||
>
|
||||
<Input
|
||||
value={fields.group_claim}
|
||||
onChange={(e) => set("group_claim")(e.target.value)}
|
||||
placeholder="groups"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field
|
||||
label={t("fields.allowedUsers")}
|
||||
hint={t("fields.allowedUsersDesc")}
|
||||
>
|
||||
<textarea
|
||||
value={fields.allowed_users}
|
||||
onChange={(e) => set("allowed_users")(e.target.value)}
|
||||
placeholder={"user@example.com\nanother@example.com"}
|
||||
rows={3}
|
||||
className={areaClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("fields.adminGroup")}
|
||||
hint={t("fields.adminGroupDesc")}
|
||||
>
|
||||
<Input
|
||||
value={fields.admin_group}
|
||||
onChange={(e) => set("admin_group")(e.target.value)}
|
||||
placeholder="admin"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.caCert")} hint={t("fields.caCertDesc")}>
|
||||
<textarea
|
||||
value={fields.ca_cert}
|
||||
onChange={(e) => set("ca_cert")(e.target.value)}
|
||||
placeholder={
|
||||
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
|
||||
}
|
||||
rows={4}
|
||||
className={areaClass}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("providers.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? t("providers.saving") : t("providers.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The "providers" custom field on the plugin's admin settings page: the
|
||||
* provider list with an editor, which a schema field cannot express.
|
||||
*/
|
||||
export function ProvidersSetting() {
|
||||
const { t } = useTranslation();
|
||||
const api = createSsoApi(usePluginApi());
|
||||
const [providers, setProviders] = useState<SsoProvider[] | null>(null);
|
||||
const [newRedirectUri, setNewRedirectUri] = useState("");
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<SsoProvider | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const result = await api.list();
|
||||
setProviders(result.providers);
|
||||
setNewRedirectUri(result.newRedirectUri);
|
||||
} catch (error) {
|
||||
setProviders([]);
|
||||
toast.error(errorMessage(error, t("providers.loadFailed")));
|
||||
}
|
||||
// api is rebuilt each render from the same client.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function toggleEnabled(provider: SsoProvider) {
|
||||
try {
|
||||
await api.update(provider.id, { enabled: !provider.enabled });
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error, t("providers.saveFailed")));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(provider: SsoProvider) {
|
||||
if (!window.confirm(t("providers.deleteConfirm"))) return;
|
||||
try {
|
||||
await api.remove(provider.id);
|
||||
toast.success(t("providers.deleted"));
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error, t("providers.deleteFailed")));
|
||||
}
|
||||
}
|
||||
|
||||
if (providers === null) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/sso-providers"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("providers.docsLink")}
|
||||
</a>
|
||||
</span>
|
||||
{providers.length === 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("providers.none")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex flex-col gap-1.5 p-2 border border-border bg-background"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium truncate">
|
||||
{provider.name}
|
||||
</span>
|
||||
<span className="text-[9px] px-1 py-0.5 bg-muted text-muted-foreground font-mono uppercase">
|
||||
{TYPE_LABELS[provider.type] ?? provider.type}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={provider.enabled}
|
||||
onCheckedChange={() => void toggleEnabled(provider)}
|
||||
aria-label={t("providers.enabled")}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setEditing(provider);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
title={t("providers.edit")}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => void remove(provider)}
|
||||
title={t("providers.delete")}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<RedirectUri uri={provider.redirectUri} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{t("providers.add")}
|
||||
</Button>
|
||||
<ProviderDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
provider={editing}
|
||||
newRedirectUri={newRedirectUri}
|
||||
onSaved={() => void load()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { OidcLoginButtons } from "./OidcLoginButtons";
|
||||
import { ProvidersSetting } from "./ProvidersSetting";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerLoginMethod({
|
||||
id: "oidc",
|
||||
titleKey: "loginWithSso",
|
||||
component: OidcLoginButtons,
|
||||
});
|
||||
app.registerSettingsComponent("providers", ProvidersSetting);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export type SsoProviderType = "oidc" | "github" | "google";
|
||||
|
||||
export interface SsoProvider {
|
||||
id: number;
|
||||
name: string;
|
||||
type: SsoProviderType;
|
||||
enabled: boolean;
|
||||
displayOrder: number;
|
||||
legacyCallback: boolean;
|
||||
config: Record<string, unknown>;
|
||||
hasClientSecret: boolean;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
export interface SsoProviderInput {
|
||||
name?: string;
|
||||
type?: SsoProviderType;
|
||||
enabled?: boolean;
|
||||
legacyCallback?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Paths are relative to /plugin-api/sso/. */
|
||||
export function createSsoApi(api: PluginApiClient) {
|
||||
return {
|
||||
async list() {
|
||||
const { data } = await api.get<{
|
||||
providers: SsoProvider[];
|
||||
newRedirectUri: string;
|
||||
}>("/providers");
|
||||
return data;
|
||||
},
|
||||
async create(input: SsoProviderInput) {
|
||||
return (await api.post<SsoProvider>("/providers", input)).data;
|
||||
},
|
||||
async update(id: number, input: SsoProviderInput) {
|
||||
return (await api.put<SsoProvider>(`/providers/${id}`, input)).data;
|
||||
},
|
||||
async remove(id: number) {
|
||||
await api.delete(`/providers/${id}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import { vi } from "vitest";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
/** Core's 2.8 sso_providers, as db/index.ts created it. */
|
||||
export const LEGACY_DDL = `
|
||||
CREATE TABLE IF NOT EXISTS sso_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
config TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: {
|
||||
user?: string;
|
||||
body?: unknown;
|
||||
form?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any; location: string | null }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
// The IdP is stubbed through the global fetch; calls to the test server
|
||||
// itself still go out for real.
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
export async function startServer(
|
||||
options: {
|
||||
capabilities?: string[];
|
||||
permissions?: string[];
|
||||
linkedUsers?: Record<string, number>;
|
||||
before?: (sqlite: TestDb["sqlite"]) => void;
|
||||
settings?: Record<string, unknown>;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir, { before: options.before });
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username, is_admin) VALUES (?, ?, 1)")
|
||||
.run("admin", "admin");
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: options.capabilities ?? manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions,
|
||||
linkedUsers: options.linkedUsers,
|
||||
baseUrl: "https://termix.test",
|
||||
settings: options.settings,
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") || undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
async request(
|
||||
method,
|
||||
path,
|
||||
{ user = "admin", body, form, headers = {} } = {},
|
||||
) {
|
||||
const payload =
|
||||
form !== undefined
|
||||
? new URLSearchParams(form).toString()
|
||||
: body !== undefined && method !== "GET"
|
||||
? JSON.stringify(body)
|
||||
: undefined;
|
||||
const response = await realFetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(form !== undefined
|
||||
? { "content-type": "application/x-www-form-urlencoded" }
|
||||
: payload !== undefined
|
||||
? { "content-type": "application/json" }
|
||||
: {}),
|
||||
...headers,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
const text = await response.text();
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = text;
|
||||
}
|
||||
return {
|
||||
status: response.status,
|
||||
body: parsed,
|
||||
location: response.headers.get("location"),
|
||||
};
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const ISSUER = "https://idp.example";
|
||||
|
||||
export interface FakeIdp {
|
||||
/** Claims the next id_token carries. */
|
||||
claims: Record<string, unknown>;
|
||||
/** Form bodies the token endpoint received. */
|
||||
tokenRequests: URLSearchParams[];
|
||||
/** Signs any payload with the IdP's key, for logout tokens. */
|
||||
sign: (payload: Record<string, unknown>) => Promise<string>;
|
||||
/** Extra handlers, checked first. */
|
||||
routes: Map<string, () => Response>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal OpenID provider behind the global fetch: discovery, JWKS, a
|
||||
* token endpoint that issues a signed id_token and a userinfo endpoint.
|
||||
*/
|
||||
export async function installIdp(): Promise<FakeIdp> {
|
||||
const { exportJWK, generateKeyPair, SignJWT } = await import("jose");
|
||||
const { privateKey, publicKey } = await generateKeyPair("RS256");
|
||||
const jwk = { ...(await exportJWK(publicKey)), kid: "k1", alg: "RS256" };
|
||||
|
||||
const idp: FakeIdp = {
|
||||
claims: {},
|
||||
tokenRequests: [],
|
||||
routes: new Map(),
|
||||
sign: (payload) =>
|
||||
new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "RS256", kid: "k1" })
|
||||
.setIssuer(ISSUER)
|
||||
.setAudience("termix")
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("5m")
|
||||
.sign(privateKey),
|
||||
};
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: string | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const custom = idp.routes.get(url);
|
||||
if (custom) return custom();
|
||||
if (url.startsWith("http://127.0.0.1")) return realFetch(input, init);
|
||||
if (url === `${ISSUER}/.well-known/openid-configuration`) {
|
||||
return Response.json({
|
||||
jwks_uri: `${ISSUER}/jwks`,
|
||||
userinfo_endpoint: `${ISSUER}/userinfo`,
|
||||
});
|
||||
}
|
||||
if (url === `${ISSUER}/jwks`) return Response.json({ keys: [jwk] });
|
||||
if (url === `${ISSUER}/token`) {
|
||||
idp.tokenRequests.push(new URLSearchParams(String(init?.body)));
|
||||
return Response.json({
|
||||
access_token: "access",
|
||||
id_token: await idp.sign(idp.claims),
|
||||
});
|
||||
}
|
||||
if (url === `${ISSUER}/userinfo`) {
|
||||
return Response.json({ email: "alice@example.com" });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
}),
|
||||
);
|
||||
return idp;
|
||||
}
|
||||
|
||||
export const OIDC_CONFIG = {
|
||||
client_id: "termix",
|
||||
client_secret: "shhh",
|
||||
issuer_url: ISSUER,
|
||||
authorization_url: `${ISSUER}/authorize`,
|
||||
token_url: `${ISSUER}/token`,
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
scopes: "openid email profile",
|
||||
admin_group: "termix-admins",
|
||||
role_map: "ops-team:ops",
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifest from "../../manifest.json";
|
||||
|
||||
// Old URLs something outside Termix still calls keep reaching the plugin.
|
||||
const redirects = manifest.contributes.http.legacyRedirects as unknown[];
|
||||
|
||||
describe("legacy URLs", () => {
|
||||
it("declares them in a manifest that validates", () => {
|
||||
expect(validateManifest(manifest)).toEqual([]);
|
||||
});
|
||||
|
||||
it("redirects /users/oidc-config, which 2.8 clients read", () => {
|
||||
expect(redirects).toContainEqual({
|
||||
from: "/users/oidc-config",
|
||||
to: "/config",
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects /users/oidc/callback, which identity providers were set up with before 2.9", () => {
|
||||
expect(redirects).toContainEqual({
|
||||
from: "/users/oidc/callback",
|
||||
to: "/callback",
|
||||
status: 308,
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects /users/oidc/backchannel-logout, which identity providers send back-channel logouts to", () => {
|
||||
expect(redirects).toContainEqual({
|
||||
from: "/users/oidc/backchannel-logout",
|
||||
to: "/backchannel-logout",
|
||||
status: 308,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,533 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
describeFetchFailure,
|
||||
extractOidcGroups,
|
||||
extractOidcGroupsFromSources,
|
||||
parseOidcRoleMap,
|
||||
resolveOidcMappedRoles,
|
||||
validateLogoutTokenClaims,
|
||||
verifyOIDCToken,
|
||||
} from "../../src/backend/oidc-protocol.js";
|
||||
import {
|
||||
getOidcConfigFromEnv as getOIDCConfigFromEnv,
|
||||
isEnvOverrideEnabled as isOIDCEnvOverrideEnabled,
|
||||
} from "../../src/backend/providers.js";
|
||||
|
||||
const BACKCHANNEL_LOGOUT_EVENT =
|
||||
"http://schemas.openid.net/event/backchannel-logout";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("describeFetchFailure", () => {
|
||||
it("unwraps the undici cause, which carries the reason that matters", () => {
|
||||
// Every transport failure surfaces as this same outer message.
|
||||
const error = new TypeError("fetch failed", {
|
||||
cause: Object.assign(new Error("getaddrinfo ENOTFOUND idp.example"), {
|
||||
code: "ENOTFOUND",
|
||||
}),
|
||||
});
|
||||
expect(describeFetchFailure(error)).toBe(
|
||||
"fetch failed: getaddrinfo ENOTFOUND idp.example (ENOTFOUND)",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the outer message when there is no cause", () => {
|
||||
expect(describeFetchFailure(new Error("boom"))).toBe("boom");
|
||||
});
|
||||
|
||||
it("handles a non-Error throw", () => {
|
||||
expect(describeFetchFailure("nope")).toBe("nope");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyOIDCToken JWKS diagnostics", () => {
|
||||
const issuer = "https://login.microsoftonline.com/example/v2.0";
|
||||
const token = "header.payload.signature";
|
||||
|
||||
it("reports every attempted URL and why it failed", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("not found", { status: 404 }),
|
||||
);
|
||||
|
||||
const error = await verifyOIDCToken(token, issuer, "client").catch(
|
||||
(e) => e as Error,
|
||||
);
|
||||
expect(error.message).toMatch(/^Failed to fetch JWKS from any URL/);
|
||||
expect(error.message).toContain(
|
||||
`${issuer}/.well-known/openid-configuration: HTTP 404`,
|
||||
);
|
||||
expect(error.message).toContain(
|
||||
`${issuer}/.well-known/jwks.json: HTTP 404`,
|
||||
);
|
||||
expect(error.message).toContain(`${issuer}/jwks/: HTTP 404`);
|
||||
});
|
||||
|
||||
it("reports a transport failure with its underlying cause", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
||||
new TypeError("fetch failed", {
|
||||
cause: Object.assign(new Error("self-signed certificate"), {
|
||||
code: "SELF_SIGNED_CERT_IN_CHAIN",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const error = await verifyOIDCToken(token, issuer, "client").catch(
|
||||
(e) => e as Error,
|
||||
);
|
||||
expect(error.message).toContain(
|
||||
"self-signed certificate (SELF_SIGNED_CERT_IN_CHAIN)",
|
||||
);
|
||||
});
|
||||
|
||||
it("says so when discovery succeeds but advertises no jwks_uri", async () => {
|
||||
vi.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ issuer }), { status: 200 }),
|
||||
)
|
||||
.mockResolvedValue(new Response("not found", { status: 404 }));
|
||||
|
||||
const error = await verifyOIDCToken(token, issuer, "client").catch(
|
||||
(e) => e as Error,
|
||||
);
|
||||
expect(error.message).toContain("no jwks_uri in the discovery document");
|
||||
});
|
||||
|
||||
it("says so when a JWKS response carries no keys array", async () => {
|
||||
vi.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), {
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
const error = await verifyOIDCToken(token, issuer, "client").catch(
|
||||
(e) => e as Error,
|
||||
);
|
||||
expect(error.message).toContain(
|
||||
'https://idp.example/keys: response contains no "keys" array',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyOIDCToken", () => {
|
||||
it("accepts a discovery document URL as the configured issuer", async () => {
|
||||
const { exportJWK, generateKeyPair, SignJWT } = await import("jose");
|
||||
const { publicKey, privateKey } = await generateKeyPair("RS256");
|
||||
const jwk = await exportJWK(publicKey);
|
||||
jwk.kid = "google-key";
|
||||
|
||||
const issuer = "https://accounts.google.com";
|
||||
const clientId = "termix-client";
|
||||
const token = await new SignJWT({ sub: "user-1" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: jwk.kid })
|
||||
.setIssuer(issuer)
|
||||
.setAudience(clientId)
|
||||
.setExpirationTime("5m")
|
||||
.sign(privateKey);
|
||||
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ jwks_uri: `${issuer}/keys` }), {
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }),
|
||||
);
|
||||
|
||||
const payload = await verifyOIDCToken(
|
||||
token,
|
||||
`${issuer}/.well-known/openid-configuration`,
|
||||
clientId,
|
||||
);
|
||||
|
||||
expect(payload.sub).toBe("user-1");
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
`${issuer}/.well-known/openid-configuration`,
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the protected-header algorithm when the provider JWK omits alg", async () => {
|
||||
const { exportJWK, generateKeyPair, SignJWT } = await import("jose");
|
||||
const { publicKey, privateKey } = await generateKeyPair("RS256");
|
||||
const jwk = await exportJWK(publicKey);
|
||||
jwk.kid = "entra-key";
|
||||
|
||||
const issuer = "https://login.microsoftonline.com/example/v2.0";
|
||||
const clientId = "termix-client";
|
||||
const token = await new SignJWT({ sub: "user-1" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: jwk.kid })
|
||||
.setIssuer(issuer)
|
||||
.setAudience(clientId)
|
||||
.setExpirationTime("5m")
|
||||
.sign(privateKey);
|
||||
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), {
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }),
|
||||
);
|
||||
|
||||
const payload = await verifyOIDCToken(token, issuer, clientId);
|
||||
|
||||
expect(payload.sub).toBe("user-1");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOIDCConfigFromEnv", () => {
|
||||
const REQUIRED = [
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_ISSUER_URL",
|
||||
"OIDC_AUTHORIZATION_URL",
|
||||
"OIDC_TOKEN_URL",
|
||||
];
|
||||
const OPTIONAL = [
|
||||
"OIDC_USERINFO_URL",
|
||||
"OIDC_IDENTIFIER_PATH",
|
||||
"OIDC_NAME_PATH",
|
||||
"OIDC_SCOPES",
|
||||
"OIDC_ALLOWED_USERS",
|
||||
"OIDC_ADMIN_GROUP",
|
||||
"OIDC_ENV_OVERRIDE",
|
||||
];
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of [...REQUIRED, ...OPTIONAL]) {
|
||||
saved[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of [...REQUIRED, ...OPTIONAL]) {
|
||||
if (saved[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = saved[key];
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when any required variable is missing", () => {
|
||||
process.env.OIDC_CLIENT_ID = "id";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret";
|
||||
// issuer/authorization/token urls intentionally missing
|
||||
expect(getOIDCConfigFromEnv()).toBeNull();
|
||||
});
|
||||
|
||||
it("builds a config with defaults when all required vars are present", () => {
|
||||
process.env.OIDC_CLIENT_ID = "id";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret";
|
||||
process.env.OIDC_ISSUER_URL = "https://idp.example";
|
||||
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
|
||||
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
|
||||
|
||||
const config = getOIDCConfigFromEnv();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.client_id).toBe("id");
|
||||
expect(config?.identifier_path).toBe("sub");
|
||||
expect(config?.name_path).toBe("name");
|
||||
expect(config?.scopes).toBe("openid email profile");
|
||||
expect(config?.userinfo_url).toBe("");
|
||||
});
|
||||
|
||||
it("honors overrides for optional vars", () => {
|
||||
process.env.OIDC_CLIENT_ID = "id";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret";
|
||||
process.env.OIDC_ISSUER_URL = "https://idp.example";
|
||||
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
|
||||
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
|
||||
process.env.OIDC_IDENTIFIER_PATH = "email";
|
||||
process.env.OIDC_SCOPES = "openid";
|
||||
|
||||
const config = getOIDCConfigFromEnv();
|
||||
expect(config?.identifier_path).toBe("email");
|
||||
expect(config?.scopes).toBe("openid");
|
||||
});
|
||||
|
||||
it("only enables database recovery override when explicitly requested", () => {
|
||||
expect(isOIDCEnvOverrideEnabled()).toBe(false);
|
||||
process.env.OIDC_ENV_OVERRIDE = "true";
|
||||
expect(isOIDCEnvOverrideEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractOidcGroups", () => {
|
||||
it("reads the standard groups claim as an array", () => {
|
||||
expect(extractOidcGroups({ groups: ["admin", "user"] })).toEqual([
|
||||
"admin",
|
||||
"user",
|
||||
]);
|
||||
});
|
||||
|
||||
it("splits a comma-separated string claim", () => {
|
||||
expect(extractOidcGroups({ roles: "admin, user" })).toEqual([
|
||||
"admin",
|
||||
"user",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back through groups, roles, then group", () => {
|
||||
expect(extractOidcGroups({ group: "ops" })).toEqual(["ops"]);
|
||||
});
|
||||
|
||||
it("reads a custom claim path when provided", () => {
|
||||
const userInfo = {
|
||||
"zitadel:grants:groups:123": ["user", "admin"],
|
||||
groups: ["ignored"],
|
||||
};
|
||||
expect(extractOidcGroups(userInfo, "zitadel:grants:groups:123")).toEqual([
|
||||
"user",
|
||||
"admin",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses object keys as group names (Zitadel roles object)", () => {
|
||||
const userInfo = {
|
||||
"urn:zitadel:iam:org:project:roles": { admin: {}, user: {} },
|
||||
};
|
||||
expect(
|
||||
extractOidcGroups(userInfo, "urn:zitadel:iam:org:project:roles"),
|
||||
).toEqual(["admin", "user"]);
|
||||
});
|
||||
|
||||
it("falls back to defaults when the custom claim is absent", () => {
|
||||
expect(extractOidcGroups({ groups: ["admin"] }, "missing")).toEqual([
|
||||
"admin",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty array when no groups are present", () => {
|
||||
expect(extractOidcGroups({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractOidcGroupsFromSources", () => {
|
||||
it("preserves ID token groups when userinfo omits them", () => {
|
||||
expect(
|
||||
extractOidcGroupsFromSources([
|
||||
{ groups: ["admins", "users"] },
|
||||
{ sub: "user-1", name: "Example User" },
|
||||
]),
|
||||
).toEqual(["admins", "users"]);
|
||||
});
|
||||
|
||||
it("combines and deduplicates groups from both verified sources", () => {
|
||||
expect(
|
||||
extractOidcGroupsFromSources([
|
||||
{ roles: ["users", "operators"] },
|
||||
{ roles: ["operators", "admins"] },
|
||||
]),
|
||||
).toEqual(["users", "operators", "admins"]);
|
||||
});
|
||||
|
||||
it("supports a configured group claim across sources", () => {
|
||||
expect(
|
||||
extractOidcGroupsFromSources(
|
||||
[{ custom_groups: ["admins"] }, { custom_groups: ["users"] }],
|
||||
"custom_groups",
|
||||
),
|
||||
).toEqual(["admins", "users"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateLogoutTokenClaims", () => {
|
||||
const validClaims = {
|
||||
sub: "subject-1",
|
||||
sid: "session-1",
|
||||
iat: 1_783_641_600,
|
||||
jti: "logout-1",
|
||||
events: { [BACKCHANNEL_LOGOUT_EVENT]: {} },
|
||||
};
|
||||
|
||||
it("accepts a spec-compliant back-channel logout payload", () => {
|
||||
expect(validateLogoutTokenClaims(validClaims)).toEqual({
|
||||
sub: "subject-1",
|
||||
sid: "session-1",
|
||||
jti: "logout-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires the logout event to contain an object", () => {
|
||||
expect(() =>
|
||||
validateLogoutTokenClaims({
|
||||
...validClaims,
|
||||
events: { [BACKCHANNEL_LOGOUT_EVENT]: true },
|
||||
}),
|
||||
).toThrow("missing back-channel logout event");
|
||||
});
|
||||
|
||||
it("requires iat and jti claims", () => {
|
||||
expect(() =>
|
||||
validateLogoutTokenClaims({ ...validClaims, iat: undefined }),
|
||||
).toThrow("missing iat claim");
|
||||
expect(() =>
|
||||
validateLogoutTokenClaims({ ...validClaims, jti: "" }),
|
||||
).toThrow("missing jti claim");
|
||||
});
|
||||
|
||||
it("rejects nonce and requires sub or sid", () => {
|
||||
expect(() =>
|
||||
validateLogoutTokenClaims({ ...validClaims, nonce: "forbidden" }),
|
||||
).toThrow("must not contain a nonce");
|
||||
expect(() =>
|
||||
validateLogoutTokenClaims({ ...validClaims, sub: null, sid: null }),
|
||||
).toThrow("must contain sub and/or sid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOidcRoleMap", () => {
|
||||
it("returns an empty map for blank input", () => {
|
||||
expect(parseOidcRoleMap(undefined).size).toBe(0);
|
||||
expect(parseOidcRoleMap(null).size).toBe(0);
|
||||
expect(parseOidcRoleMap(" ").size).toBe(0);
|
||||
});
|
||||
|
||||
it("parses comma-separated group:role pairs", () => {
|
||||
const map = parseOidcRoleMap(
|
||||
"devops-interns:devops-intern,devops-seniors:devops-senior",
|
||||
);
|
||||
expect(map.get("devops-interns")).toBe("devops-intern");
|
||||
expect(map.get("devops-seniors")).toBe("devops-senior");
|
||||
expect(map.size).toBe(2);
|
||||
});
|
||||
|
||||
it("parses newline-separated pairs and trims whitespace", () => {
|
||||
const map = parseOidcRoleMap(" a : role-a \n b:role-b \n");
|
||||
expect(map.get("a")).toBe("role-a");
|
||||
expect(map.get("b")).toBe("role-b");
|
||||
});
|
||||
|
||||
it("normalizes leading slashes and case in group names", () => {
|
||||
const map = parseOidcRoleMap("/DevOps-Interns:devops-intern");
|
||||
expect(map.get("devops-interns")).toBe("devops-intern");
|
||||
});
|
||||
|
||||
it("skips malformed entries instead of throwing", () => {
|
||||
const map = parseOidcRoleMap("no-colon,:missing-group,missing-role:,ok:r");
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.get("ok")).toBe("r");
|
||||
});
|
||||
|
||||
it("splits on the last colon so group names may contain colons", () => {
|
||||
const map = parseOidcRoleMap("ns:team:role-x");
|
||||
expect(map.get("ns:team")).toBe("role-x");
|
||||
});
|
||||
|
||||
it("preserves role-name case verbatim", () => {
|
||||
// Role names must match roles.name exactly, so they are not lowercased.
|
||||
expect(parseOidcRoleMap("g:DevOps_Senior").get("g")).toBe("DevOps_Senior");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOidcMappedRoles", () => {
|
||||
const roleMap = parseOidcRoleMap(
|
||||
"devops-interns:devops-intern,devops-seniors:devops-senior",
|
||||
);
|
||||
|
||||
it("reports every mapped role as managed regardless of membership", () => {
|
||||
const { managed } = resolveOidcMappedRoles([], roleMap);
|
||||
expect([...managed].sort()).toEqual(["devops-intern", "devops-senior"]);
|
||||
});
|
||||
|
||||
it("desires only the roles whose groups the user is in", () => {
|
||||
const { desired } = resolveOidcMappedRoles(["devops-interns"], roleMap);
|
||||
expect([...desired]).toEqual(["devops-intern"]);
|
||||
});
|
||||
|
||||
it("matches full group paths emitted by Keycloak", () => {
|
||||
const { desired } = resolveOidcMappedRoles(["/devops-seniors"], roleMap);
|
||||
expect([...desired]).toEqual(["devops-senior"]);
|
||||
});
|
||||
|
||||
it("ignores groups that are not mapped", () => {
|
||||
const { desired } = resolveOidcMappedRoles(
|
||||
["finance", "devops-interns"],
|
||||
roleMap,
|
||||
);
|
||||
expect([...desired]).toEqual(["devops-intern"]);
|
||||
});
|
||||
|
||||
it("supports a user in multiple mapped groups", () => {
|
||||
const { desired } = resolveOidcMappedRoles(
|
||||
["devops-interns", "devops-seniors"],
|
||||
roleMap,
|
||||
);
|
||||
expect([...desired].sort()).toEqual(["devops-intern", "devops-senior"]);
|
||||
});
|
||||
|
||||
it("desires nothing when the map is empty", () => {
|
||||
const { desired, managed } = resolveOidcMappedRoles(
|
||||
["devops-interns"],
|
||||
new Map(),
|
||||
);
|
||||
expect(desired.size).toBe(0);
|
||||
expect(managed.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Imported as a namespace rather than destructured into the shared block at the
|
||||
// top of the file, so this suite stays independent of what that block binds.
|
||||
const oidcUtils = await import("../../src/backend/oidc-protocol.js");
|
||||
|
||||
describe("verifyOIDCToken token shape", () => {
|
||||
const issuer = "https://idp.example.com/application/o/termix";
|
||||
|
||||
// The shape check runs before any network call, so no fetch stub is needed.
|
||||
const fetchSpy = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
fetchSpy.mockReset();
|
||||
});
|
||||
|
||||
it("reports an encrypted (JWE) token as a format error", async () => {
|
||||
const jwe = ["header", "key", "iv", "ciphertext", "tag"].join(".");
|
||||
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken(jwe, issuer, "client"),
|
||||
).rejects.toThrow(oidcUtils.OIDCTokenFormatError);
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken(jwe, issuer, "client"),
|
||||
).rejects.toThrow(/JWE \(encrypted\)/);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports any other non-JWS segment count as a format error", async () => {
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken("header.payload", issuer, "client"),
|
||||
).rejects.toThrow(/expected 3 segments, got 2/);
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken("opaque", issuer, "client"),
|
||||
).rejects.toThrow(/expected 3 segments, got 1/);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a three-segment token through to key resolution", async () => {
|
||||
fetchSpy.mockResolvedValue({ ok: false });
|
||||
|
||||
// Reaches JWKS fetching, so it fails on the key lookup rather than the shape.
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken("header.payload.signature", issuer, "client"),
|
||||
).rejects.not.toThrow(oidcUtils.OIDCTokenFormatError);
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
LEGACY_DDL,
|
||||
OIDC_CONFIG,
|
||||
installIdp,
|
||||
startServer,
|
||||
type TestServer,
|
||||
} from "./helpers.js";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
/** A 2.8 database: one OIDC provider and one LDAP directory in one table. */
|
||||
function legacyRows(
|
||||
sqlite: Parameters<
|
||||
NonNullable<Parameters<typeof startServer>[0]>["before"]
|
||||
>[0],
|
||||
) {
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
const insert = sqlite.prepare(
|
||||
"INSERT INTO sso_providers (id, name, type, enabled, config) VALUES (?, ?, ?, ?, ?)",
|
||||
);
|
||||
insert.run(3, "Keycloak", "oidc", 1, JSON.stringify(OIDC_CONFIG));
|
||||
insert.run(4, "Corp LDAP", "ldap", 1, '{"host":"ldap.example"}');
|
||||
}
|
||||
|
||||
async function startLegacy(options: Parameters<typeof startServer>[0] = {}) {
|
||||
server = await startServer({ before: legacyRows, ...options });
|
||||
return server;
|
||||
}
|
||||
|
||||
/** Runs /start and returns the state the provider would echo back. */
|
||||
async function beginLogin(s: TestServer, query = "provider=3") {
|
||||
const started = await s.request("GET", `/start?${query}`, {
|
||||
headers: { referer: "https://app.termix.test/login" },
|
||||
});
|
||||
expect(started.status).toBe(302);
|
||||
return new URL(started.location!);
|
||||
}
|
||||
|
||||
describe("adopting sso_providers", () => {
|
||||
it("keeps every provider and marks the old ones for the 2.8 redirect URI", async () => {
|
||||
const s = await startLegacy();
|
||||
const rows = s.db.sqlite
|
||||
.prepare(
|
||||
"SELECT id, type, legacy_callback FROM p_sso_providers ORDER BY id",
|
||||
)
|
||||
.all();
|
||||
expect(rows).toEqual([
|
||||
{ id: 3, type: "oidc", legacy_callback: 1 },
|
||||
{ id: 4, type: "ldap", legacy_callback: 1 },
|
||||
]);
|
||||
|
||||
const listed = await s.request("GET", "/providers");
|
||||
expect(listed.status).toBe(200);
|
||||
// LDAP rows are the ldap plugin's; they are not listed here.
|
||||
expect(listed.body.providers).toHaveLength(1);
|
||||
expect(listed.body.providers[0]).toMatchObject({
|
||||
id: 3,
|
||||
name: "Keycloak",
|
||||
legacyCallback: true,
|
||||
hasClientSecret: true,
|
||||
redirectUri: "https://termix.test/users/oidc/callback",
|
||||
});
|
||||
expect(listed.body.providers[0].config.client_secret).toBeUndefined();
|
||||
expect(listed.body.newRedirectUri).toBe(
|
||||
"https://termix.test/plugin-api/sso/callback",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a fresh table on a new install", async () => {
|
||||
server = await startServer();
|
||||
const created = await server.request("POST", "/providers", {
|
||||
body: { name: "Keycloak", type: "oidc", config: OIDC_CONFIG },
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body).toMatchObject({
|
||||
legacyCallback: false,
|
||||
redirectUri: "https://termix.test/plugin-api/sso/callback",
|
||||
});
|
||||
const stored = server.db.sqlite
|
||||
.prepare("SELECT config FROM p_sso_providers")
|
||||
.get() as { config: string };
|
||||
expect(JSON.parse(stored.config).client_secret).toMatch(/^sealed:/);
|
||||
});
|
||||
|
||||
it("reads secrets 2.8 stored base64 encoded", async () => {
|
||||
server = await startServer({
|
||||
before: (sqlite) => {
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO sso_providers (id, name, type, config) VALUES (1, 'Old', 'oidc', ?)",
|
||||
)
|
||||
.run(
|
||||
JSON.stringify({
|
||||
...OIDC_CONFIG,
|
||||
client_secret: `encoded:${Buffer.from("old-secret").toString("base64")}`,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const idp = await installIdp();
|
||||
idp.claims = { sub: "sub-1" };
|
||||
const url = await beginLogin(server, "provider=1");
|
||||
idp.claims.nonce = url.searchParams.get("nonce");
|
||||
await server.request(
|
||||
"GET",
|
||||
`/callback?code=c&state=${url.searchParams.get("state")}`,
|
||||
);
|
||||
expect(idp.tokenRequests[0].get("client_secret")).toBe("old-secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider admin routes", () => {
|
||||
it("need sso.manage", async () => {
|
||||
const s = await startLegacy({ permissions: [] });
|
||||
for (const [method, path] of [
|
||||
["GET", "/providers"],
|
||||
["POST", "/providers"],
|
||||
["PUT", "/providers/3"],
|
||||
["DELETE", "/providers/3"],
|
||||
]) {
|
||||
expect((await s.request(method, path, { body: {} })).status).toBe(403);
|
||||
}
|
||||
// The public routes stay reachable.
|
||||
expect((await s.request("GET", "/config")).status).toBe(200);
|
||||
});
|
||||
|
||||
it("keeps a secret that is not sent again and can leave the old redirect URI", async () => {
|
||||
const s = await startLegacy();
|
||||
const updated = await s.request("PUT", "/providers/3", {
|
||||
body: { legacyCallback: false, config: { client_secret: "" } },
|
||||
});
|
||||
expect(updated.body).toMatchObject({
|
||||
legacyCallback: false,
|
||||
hasClientSecret: true,
|
||||
redirectUri: "https://termix.test/plugin-api/sso/callback",
|
||||
});
|
||||
});
|
||||
|
||||
it("validates a new provider", async () => {
|
||||
const s = await startLegacy();
|
||||
const missing = await s.request("POST", "/providers", {
|
||||
body: { name: "X", type: "oidc", config: { client_id: "a" } },
|
||||
});
|
||||
expect(missing.status).toBe(400);
|
||||
const userinfo = await s.request("POST", "/providers", {
|
||||
body: {
|
||||
name: "X",
|
||||
type: "oidc",
|
||||
config: { ...OIDC_CONFIG, issuer_url: "https://idp.example/userinfo" },
|
||||
},
|
||||
});
|
||||
expect(userinfo.status).toBe(400);
|
||||
const ldap = await s.request("POST", "/providers", {
|
||||
body: { name: "X", type: "ldap", config: {} },
|
||||
});
|
||||
expect(ldap.status).toBe(400);
|
||||
});
|
||||
|
||||
it("will not delete a provider people still sign in with", async () => {
|
||||
const s = await startLegacy({ linkedUsers: { "3": 2 } });
|
||||
expect((await s.request("DELETE", "/providers/3")).status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the login method", () => {
|
||||
it("offers one button per enabled provider", async () => {
|
||||
const s = await startLegacy();
|
||||
const method = s.mock.auth.loginMethods[0];
|
||||
expect(method).toMatchObject({
|
||||
id: "oidc",
|
||||
kind: "redirect",
|
||||
external: true,
|
||||
});
|
||||
expect(await method.describe!()).toEqual([
|
||||
{ id: "3", label: "Keycloak", type: "oidc", enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks the first provider to start on its own with silent sign-in on", async () => {
|
||||
const s = await startLegacy({ settings: { silentLoginDefault: true } });
|
||||
expect(await s.mock.auth.loginMethods[0].describe!()).toEqual([
|
||||
{
|
||||
id: "3",
|
||||
label: "Keycloak",
|
||||
type: "oidc",
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets OIDC_SILENT_LOGIN_DEFAULT override the setting", async () => {
|
||||
const s = await startLegacy({ settings: { silentLoginDefault: true } });
|
||||
vi.stubEnv("OIDC_SILENT_LOGIN_DEFAULT", "false");
|
||||
const [instance] = await s.mock.auth.loginMethods[0].describe!();
|
||||
expect(instance.autoStart).toBeUndefined();
|
||||
vi.stubEnv("OIDC_SILENT_LOGIN_DEFAULT", "true");
|
||||
s.mock.settings.clear();
|
||||
expect((await s.mock.auth.loginMethods[0].describe!())[0].autoStart).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the provider configured through the environment", async () => {
|
||||
server = await startServer();
|
||||
vi.stubEnv("OIDC_CLIENT_ID", "env-client");
|
||||
vi.stubEnv("OIDC_CLIENT_SECRET", "env-secret");
|
||||
vi.stubEnv("OIDC_ISSUER_URL", "https://idp.example");
|
||||
vi.stubEnv("OIDC_AUTHORIZATION_URL", "https://idp.example/authorize");
|
||||
vi.stubEnv("OIDC_TOKEN_URL", "https://idp.example/token");
|
||||
expect(await server.mock.auth.loginMethods[0].describe!()).toEqual([
|
||||
{ id: "0", label: "SSO", type: "oidc", enabled: true },
|
||||
]);
|
||||
const url = await beginLogin(server, "provider=0");
|
||||
expect(url.searchParams.get("redirect_uri")).toBe(
|
||||
"https://termix.test/users/oidc/callback",
|
||||
);
|
||||
});
|
||||
|
||||
it("starts with PKCE and the redirect URI the provider was set up with", async () => {
|
||||
const s = await startLegacy();
|
||||
const url = await beginLogin(s);
|
||||
expect(url.origin + url.pathname).toBe("https://idp.example/authorize");
|
||||
expect(url.searchParams.get("redirect_uri")).toBe(
|
||||
"https://termix.test/users/oidc/callback",
|
||||
);
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
expect(url.searchParams.get("code_challenge")).toBeTruthy();
|
||||
expect([...s.mock.kv.keys()]).toEqual([
|
||||
`state:${url.searchParams.get("state")}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers 404 for a provider that does not exist", async () => {
|
||||
const s = await startLegacy();
|
||||
expect((await s.request("GET", "/start?provider=99")).status).toBe(404);
|
||||
// An LDAP row is not an SSO provider.
|
||||
expect((await s.request("GET", "/start?provider=4")).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the callback", () => {
|
||||
it("verifies the id token and hands core the identity", async () => {
|
||||
const s = await startLegacy();
|
||||
const idp = await installIdp();
|
||||
const url = await beginLogin(s);
|
||||
idp.claims = {
|
||||
sub: "sub-1",
|
||||
sid: "sid-1",
|
||||
name: "Alice",
|
||||
nonce: url.searchParams.get("nonce"),
|
||||
groups: ["termix-admins", "ops-team"],
|
||||
};
|
||||
|
||||
const callback = await s.request(
|
||||
"GET",
|
||||
`/callback?code=abc&state=${url.searchParams.get("state")}`,
|
||||
);
|
||||
expect(callback.status).toBe(200);
|
||||
expect(callback.body.identity).toMatchObject({
|
||||
kind: "external",
|
||||
provider: "3",
|
||||
subject: "sub-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
isAdmin: true,
|
||||
roles: { desired: ["ops"], managed: ["ops"] },
|
||||
logoutClaims: { providerId: 3, sub: "sub-1", sid: "sid-1" },
|
||||
legacy: { identifier: "sub-1", providerRowId: 3 },
|
||||
returnTo: "https://app.termix.test",
|
||||
});
|
||||
|
||||
const token = idp.tokenRequests[0];
|
||||
expect(token.get("redirect_uri")).toBe(
|
||||
"https://termix.test/users/oidc/callback",
|
||||
);
|
||||
expect(token.get("code_verifier")).toBeTruthy();
|
||||
// The state is used up.
|
||||
expect(s.mock.kv.size).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts the provider's answer as a form post", async () => {
|
||||
const s = await startLegacy();
|
||||
const idp = await installIdp();
|
||||
const url = await beginLogin(s);
|
||||
idp.claims = { sub: "sub-1", nonce: url.searchParams.get("nonce") };
|
||||
const callback = await s.request("POST", "/callback", {
|
||||
form: { code: "abc", state: url.searchParams.get("state")! },
|
||||
});
|
||||
expect(callback.body.identity.subject).toBe("sub-1");
|
||||
});
|
||||
|
||||
it("sends the user back with an error on a nonce mismatch", async () => {
|
||||
const s = await startLegacy();
|
||||
const idp = await installIdp();
|
||||
const url = await beginLogin(s);
|
||||
idp.claims = { sub: "sub-1", nonce: "someone-elses" };
|
||||
const callback = await s.request(
|
||||
"GET",
|
||||
`/callback?code=abc&state=${url.searchParams.get("state")}`,
|
||||
);
|
||||
expect(callback.status).toBe(302);
|
||||
expect(callback.location).toMatch(/^https:\/\/app\.termix\.test\/\?error=/);
|
||||
expect(s.mock.auth.completedLogins).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses an unknown or replayed state", async () => {
|
||||
const s = await startLegacy();
|
||||
const idp = await installIdp();
|
||||
const url = await beginLogin(s);
|
||||
idp.claims = { sub: "sub-1", nonce: url.searchParams.get("nonce") };
|
||||
const state = url.searchParams.get("state");
|
||||
expect(
|
||||
(await s.request("GET", `/callback?code=a&state=${state}`)).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await s.request("GET", `/callback?code=a&state=${state}`)).status,
|
||||
).toBe(400);
|
||||
expect((await s.request("GET", "/callback?code=a&state=nope")).status).toBe(
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
it("signs GitHub users in by their numeric id", async () => {
|
||||
server = await startServer();
|
||||
const created = await server.request("POST", "/providers", {
|
||||
body: {
|
||||
name: "GitHub",
|
||||
type: "github",
|
||||
config: { client_id: "gh", client_secret: "gh-secret" },
|
||||
},
|
||||
});
|
||||
const idp = await installIdp();
|
||||
idp.routes.set("https://github.com/login/oauth/access_token", () =>
|
||||
Response.json({ access_token: "gh-token" }),
|
||||
);
|
||||
idp.routes.set("https://api.github.com/user", () =>
|
||||
Response.json({ id: 42, login: "octo" }),
|
||||
);
|
||||
idp.routes.set("https://api.github.com/user/emails", () =>
|
||||
Response.json([
|
||||
{ email: "octo@example.com", primary: true, verified: true },
|
||||
]),
|
||||
);
|
||||
const url = await beginLogin(server, `provider=${created.body.id}`);
|
||||
const callback = await server.request(
|
||||
"GET",
|
||||
`/callback?code=abc&state=${url.searchParams.get("state")}`,
|
||||
);
|
||||
expect(callback.body.identity).toMatchObject({
|
||||
provider: String(created.body.id),
|
||||
subject: "42",
|
||||
name: "octo",
|
||||
email: "octo@example.com",
|
||||
legacy: { identifier: `github:${created.body.id}:42` },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("back-channel logout", () => {
|
||||
const event = {
|
||||
"http://schemas.openid.net/event/backchannel-logout": {},
|
||||
};
|
||||
|
||||
it("ends the sessions from that provider login, once per token", async () => {
|
||||
const s = await startLegacy();
|
||||
const idp = await installIdp();
|
||||
const token = await idp.sign({ sid: "sid-1", jti: "j1", events: event });
|
||||
|
||||
const first = await s.request("POST", "/backchannel-logout", {
|
||||
form: { logout_token: token },
|
||||
});
|
||||
expect(first.status).toBe(200);
|
||||
expect(s.mock.auth.revokedSessions).toEqual([
|
||||
{ providerId: 3, sub: null, sid: "sid-1" },
|
||||
]);
|
||||
|
||||
const replay = await s.request("POST", "/backchannel-logout", {
|
||||
form: { logout_token: token },
|
||||
});
|
||||
expect(replay.status).toBe(200);
|
||||
expect(s.mock.auth.revokedSessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("refuses a token that is missing, unsigned or from an unknown issuer", async () => {
|
||||
const s = await startLegacy();
|
||||
const idp = await installIdp();
|
||||
expect(
|
||||
(await s.request("POST", "/backchannel-logout", { form: {} })).status,
|
||||
).toBe(400);
|
||||
const withNonce = await idp.sign({
|
||||
sid: "s",
|
||||
jti: "j2",
|
||||
nonce: "n",
|
||||
events: event,
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await s.request("POST", "/backchannel-logout", {
|
||||
form: { logout_token: withNonce },
|
||||
})
|
||||
).status,
|
||||
).toBe(400);
|
||||
const header = Buffer.from('{"alg":"none"}').toString("base64url");
|
||||
const body = Buffer.from('{"iss":"https://elsewhere.example"}').toString(
|
||||
"base64url",
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await s.request("POST", "/backchannel-logout", {
|
||||
form: { logout_token: `${header}.${body}.x` },
|
||||
})
|
||||
).status,
|
||||
).toBe(400);
|
||||
expect(s.mock.auth.revokedSessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capabilities", () => {
|
||||
it.each(["db:own", "auth:provide", "network:serve"])(
|
||||
"activate fails closed without %s",
|
||||
async (capability) => {
|
||||
const { manifest } = await import("./helpers.js");
|
||||
await expect(
|
||||
startServer({
|
||||
capabilities: manifest.capabilities.filter((c) => c !== capability),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it("cannot start a login without kv:own", async () => {
|
||||
const { manifest } = await import("./helpers.js");
|
||||
const s = await startLegacy({
|
||||
capabilities: manifest.capabilities.filter((c) => c !== "kv:own"),
|
||||
});
|
||||
expect((await s.request("GET", "/start?provider=3")).status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
const providers = {
|
||||
providers: [
|
||||
{
|
||||
id: 3,
|
||||
name: "Keycloak",
|
||||
type: "oidc",
|
||||
enabled: true,
|
||||
displayOrder: 0,
|
||||
legacyCallback: true,
|
||||
config: { client_id: "termix" },
|
||||
hasClientSecret: true,
|
||||
redirectUri: "https://termix.test/users/oidc/callback",
|
||||
},
|
||||
],
|
||||
newRedirectUri: "https://termix.test/plugin-api/sso/callback",
|
||||
};
|
||||
|
||||
describe(`${manifest.id} activate`, () => {
|
||||
it("registers the login buttons and the provider settings", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.loginMethods()).toEqual(["oidc"]);
|
||||
expect(rendered.registered.settingsComponents()).toEqual(["providers"]);
|
||||
});
|
||||
|
||||
it("draws a button per provider that starts the redirect", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
const startRedirect = vi.fn(async () => {});
|
||||
rendered.renderLoginMethod("oidc", {
|
||||
instances: [
|
||||
{ id: "3", label: "Keycloak" },
|
||||
{ id: "5", label: "Google" },
|
||||
],
|
||||
startRedirect,
|
||||
});
|
||||
fireEvent.click(screen.getByText("Login with Google"));
|
||||
await waitFor(() => expect(startRedirect).toHaveBeenCalledWith("5"));
|
||||
expect(screen.getByText("Login with Keycloak")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lists providers with the redirect URI to register", async () => {
|
||||
const get = vi.fn(async () => ({ data: providers }));
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: { get } as never,
|
||||
});
|
||||
rendered.renderSettingsComponent("providers");
|
||||
expect(await screen.findByText("Keycloak")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("https://termix.test/users/oidc/callback"),
|
||||
).toBeTruthy();
|
||||
expect(get).toHaveBeenCalledWith("/providers");
|
||||
});
|
||||
|
||||
it("adds a provider and never sends an empty secret", async () => {
|
||||
const get = vi.fn(async () => ({
|
||||
data: { providers: [], newRedirectUri: providers.newRedirectUri },
|
||||
}));
|
||||
const post = vi.fn(async () => ({ data: {} }));
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: { get, post } as never,
|
||||
});
|
||||
rendered.renderSettingsComponent("providers");
|
||||
fireEvent.click(await screen.findByText(locales.providers.add));
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText(locales.providers.namePlaceholder),
|
||||
{ target: { value: "Corp" } },
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText("your-client-id"), {
|
||||
target: { value: "termix" },
|
||||
});
|
||||
fireEvent.click(screen.getByText(locales.providers.save));
|
||||
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||
const [path, body] = post.mock.calls[0] as unknown as [
|
||||
string,
|
||||
{ name: string; type: string; config: Record<string, string> },
|
||||
];
|
||||
expect(path).toBe("/providers");
|
||||
expect(body).toMatchObject({ name: "Corp", type: "oidc" });
|
||||
expect(body.config.client_id).toBe("termix");
|
||||
expect(body.config).not.toHaveProperty("client_secret");
|
||||
});
|
||||
|
||||
it("removes everything on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, { manifest, locales });
|
||||
await app.deactivate();
|
||||
expect(app.registered.loginMethods()).toEqual([]);
|
||||
expect(app.registered.settingsComponents()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
Reference in new issue
Block a user