webgate 2.6.0__tar.gz → 2.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {webgate-2.6.0 → webgate-2.7.0}/.github/workflows/demo.yml +4 -2
- {webgate-2.6.0 → webgate-2.7.0}/.github/workflows/release.yml +5 -0
- {webgate-2.6.0 → webgate-2.7.0}/CHANGELOG.md +43 -0
- {webgate-2.6.0 → webgate-2.7.0}/PKG-INFO +4 -2
- {webgate-2.6.0 → webgate-2.7.0}/README.md +3 -1
- {webgate-2.6.0 → webgate-2.7.0}/ROADMAP.md +5 -0
- webgate-2.7.0/VERSION +1 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/changelog.md +43 -0
- webgate-2.7.0/docs/guide/sso.md +83 -0
- webgate-2.7.0/docs/screenshots/v2/audit.png +0 -0
- webgate-2.7.0/docs/screenshots/v2/palette.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v2/settings.png +0 -0
- webgate-2.7.0/docs/screenshots/v2/sftp.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v2/site-manager.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/mkdocs.yml +1 -0
- {webgate-2.6.0 → webgate-2.7.0}/pyproject.toml +1 -1
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/app.py +8 -1
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/auth/models.py +4 -0
- webgate-2.7.0/src/webgate/auth/oidc.py +278 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/auth/routes.py +112 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/config.py +13 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/db/engine.py +1 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/runtime_config/registry.py +127 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/static/index.html +37 -1
- webgate-2.7.0/tests/test_sso.py +365 -0
- {webgate-2.6.0 → webgate-2.7.0}/uv.lock +1 -1
- webgate-2.6.0/VERSION +0 -1
- webgate-2.6.0/docs/screenshots/v2/audit.png +0 -0
- webgate-2.6.0/docs/screenshots/v2/palette.png +0 -0
- webgate-2.6.0/docs/screenshots/v2/sftp.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/.dockerignore +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/.github/workflows/ci.yml +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/.github/workflows/docs.yml +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/.gitignore +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/DESIGN.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/Dockerfile +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/Dockerfile.demo +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/Dockerfile.ssh-demo +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/LICENSE +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/PRODUCT.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/RELEASING.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/compose.dev.yml +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/compose.ha.yml +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/compose.playground.yml +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/compose.yml +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/deploy/nginx-ha.conf +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/LOCAL_TESTING.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/api/auth.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/api/files.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/api/servers.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/api/terminal.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/architecture.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/getting-started/installation.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/getting-started/quickstart.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/getting-started/upgrade.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/guide/access-control.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/guide/advanced.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/guide/files.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/guide/ha.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/guide/integrations.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/guide/servers.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/guide/settings.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/guide/split.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/guide/terminal.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/guide/users.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/index.md +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/access-control.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/audit.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/edit-access-control.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/editor.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/light-theme.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/login.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/new-server-form.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/01-login.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/02-alice-ldap-empty.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/03-add-server-jump-via.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/04-dashboard-3-servers.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/05-terminal-via-jump-host.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/06-snippet-whoami.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/07-shared-joiner.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/07-shared-owner.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/08-webhook-fired.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/09-recordings-list.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/10-recording-replay.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/11-bob-limited.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/sftp-restricted.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/sftp.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/site-manager.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/split-view.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/ssh-disabled.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/terminal.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/users.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.3/01-login-demo-banner.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.3/02-dashboard-jump-host.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.3/03-terminal-snippets-jump.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.3/04-snippet-executed.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.3/05-sftp-via-jump.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.3/06-webhooks-modal.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.3/07-webhook-test-fired.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.3/08-add-server-jump-via.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.4/01-shared-terminal-owner.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.4/02-shared-terminal-joiner.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.4/03-recording-replay.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.4/04-recordings-modal.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v2/agent.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v2/branding.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v2/light.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v2/login.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v2/terminal.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v2/users.png +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/fly.toml +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/scripts/release_notes.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/__init__.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/__main__.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/__init__.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/cache.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/conversation.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/memory.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/models.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/provider.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/routes.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/service.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/sftp_tools.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/store.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/tools.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/audit/__init__.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/audit/models.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/audit/service.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/auth/__init__.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/auth/ldap.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/auth/service.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/backup/__init__.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/backup/models.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/backup/routes.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/backup/service.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/branding/__init__.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/branding/routes.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/branding/store.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/db/__init__.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/demo.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/files/__init__.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/files/limits.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/files/models.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/files/pool.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/files/routes.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/files/sftp_service.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/recordings/__init__.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/recordings/models.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/recordings/recorder.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/recordings/routes.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/runtime_config/__init__.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/runtime_config/routes.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/runtime_config/store.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/servers/__init__.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/servers/crypto.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/servers/hostkeys.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/servers/models.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/servers/monitor.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/servers/routes.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/servers/service.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/snippets/__init__.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/snippets/models.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/snippets/routes.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/terminal/__init__.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/terminal/routes.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/terminal/shared.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/terminal/ssh_session.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/terminal/ws_handler.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/webhooks/__init__.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/webhooks/dispatcher.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/webhooks/models.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/src/webgate/webhooks/routes.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/__init__.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/conftest.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/e2e/conftest.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/e2e/sshlab.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/e2e/test_ui.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_agent_conversation.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_agent_memory.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_agent_settings.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_agent_sftp_tools.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_agent_tools.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_audit_coverage.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_auth.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_backup.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_branding.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_credential_rotation.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_files.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_files_listing.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_host_keys.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_idle_sessions.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_limits.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_migrations.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_monitor.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_recordings.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_release_notes.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_secret_key_gate.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_servers.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_session_limit.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_settings.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_snippets.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_terminal.py +0 -0
- {webgate-2.6.0 → webgate-2.7.0}/tests/test_webhook_dispatch.py +0 -0
|
@@ -4,8 +4,10 @@ name: Deploy demo
|
|
|
4
4
|
# it spent months serving a version several releases behind the repository.
|
|
5
5
|
|
|
6
6
|
on:
|
|
7
|
-
release:
|
|
8
|
-
|
|
7
|
+
# Called by release.yml rather than triggered by the release it creates: GitHub
|
|
8
|
+
# does not start workflows from events raised with GITHUB_TOKEN, so a
|
|
9
|
+
# `release: published` trigger here never fires and the demo silently stays behind.
|
|
10
|
+
workflow_call:
|
|
9
11
|
workflow_dispatch:
|
|
10
12
|
|
|
11
13
|
permissions:
|
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2.7.0 (2026-09-17) — single sign-on
|
|
4
|
+
|
|
5
|
+
LDAP already existed, but no company running Entra ID, Okta or Google Workspace is
|
|
6
|
+
going to keep a second directory for one tool. It was the first thing anyone asked
|
|
7
|
+
about, and the thing most likely to decide whether webgate gets deployed at all.
|
|
8
|
+
|
|
9
|
+
### Single sign-on (OpenID Connect)
|
|
10
|
+
|
|
11
|
+
**Admin → Settings → Single sign-on**, alongside local accounts and LDAP rather than
|
|
12
|
+
instead of them. Register one redirect URI with your provider, fill in the issuer,
|
|
13
|
+
client ID, secret and scopes, and everything else — the endpoints, the signing keys —
|
|
14
|
+
comes from the provider's discovery document.
|
|
15
|
+
|
|
16
|
+
- **The ID token is actually verified**: signature against the provider's published
|
|
17
|
+
keys, plus issuer, audience, expiry, and a nonce tied to that one sign-in, so a token
|
|
18
|
+
captured from another flow cannot be replayed into this one. A token that merely
|
|
19
|
+
decodes proves nothing.
|
|
20
|
+
- **Authorization code with PKCE**, so it is safe with or without a client secret.
|
|
21
|
+
- **The in-progress sign-in lives in the database**, not in worker memory. The browser
|
|
22
|
+
can come back to a different worker than the one it left, which is the whole point of
|
|
23
|
+
the stateless design.
|
|
24
|
+
- **The session token never travels in a URL.** The callback hands the page a one-time
|
|
25
|
+
code, good for about a minute, which it trades for a session — so nothing sensitive
|
|
26
|
+
reaches browser history or a proxy log.
|
|
27
|
+
- **A provider group grants nothing until an admin maps it**, exactly as LDAP works. A
|
|
28
|
+
directory group created next month cannot quietly open a webgate group that happens
|
|
29
|
+
to share its name. A separate admin-groups list grants admin.
|
|
30
|
+
- Sign-ins are audited as `sso_login`, with the groups granted.
|
|
31
|
+
- Accounts created this way have **no local password** — nothing to reset, nothing to
|
|
32
|
+
leak.
|
|
33
|
+
|
|
34
|
+
Twenty-one tests, run against a real identity provider started for the test: it
|
|
35
|
+
publishes a discovery document and a JWKS and signs its own tokens, so the
|
|
36
|
+
verification is exercised rather than mocked away. A token signed with the wrong key,
|
|
37
|
+
issued for another audience, expired, or carrying another sign-in's nonce is refused,
|
|
38
|
+
and each of those is a test.
|
|
39
|
+
|
|
40
|
+
[Setup, and what to do when nobody gets any groups](https://kalexnolasco.github.io/webgate/guide/sso/).
|
|
41
|
+
|
|
42
|
+
354 tests: 345 unit, 9 browser.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
3
46
|
## v2.6.0 (2026-09-16) — rotating the secret key no longer breaks everything
|
|
4
47
|
|
|
5
48
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: webgate
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.0
|
|
4
4
|
Summary: Self-hosted web application for remote server management via SSH terminal and SFTP file browser
|
|
5
5
|
Project-URL: Homepage, https://github.com/kalexnolasco/webgate
|
|
6
6
|
Project-URL: Documentation, https://kalexnolasco.github.io/webgate/
|
|
@@ -189,7 +189,7 @@ flowchart TB
|
|
|
189
189
|
| **SFTP** | Full file ops + drag & drop upload, **sortable columns**, **multi-select** with batch ZIP download and delete, hidden-file toggle, in-browser editor (CodeMirror 6), PDF/image preview |
|
|
190
190
|
| **Server Registry** | Groups, tags, password/key auth, encrypted at rest (Fernet), **verified host keys** (TOFU), **favourites and recents**, import/export JSON, **jump host / bastion** chaining |
|
|
191
191
|
| **Access Control** | Admin/user roles, per-server SSH/SFTP toggles, SFTP path restrictions, read-only SFTP mode, group-based visibility |
|
|
192
|
-
| **Auth** | JWT + bcrypt locally, **2FA TOTP**, **API keys** for automation, **LDAP / Active Directory**
|
|
192
|
+
| **Auth** | **Single sign-on (OpenID Connect)** — Entra ID, Okta, Google Workspace, Keycloak — with group→role mapping, plus JWT + bcrypt locally, **2FA TOTP**, **API keys** for automation, **LDAP / Active Directory** |
|
|
193
193
|
| **Compliance** | **Session recording** to asciinema cast files with browser replay, off by default and opted into per server, **audit log** covering every SFTP operation, registry and account change, searchable by filename, **webhooks** (HMAC-signed) on key events |
|
|
194
194
|
| **Migration** | **Full-state backup and restore** — servers with credentials, users, groups, webhooks and API keys in one passphrase-encrypted file, portable between instances |
|
|
195
195
|
| **AI agent** | Per-server iterative chat over **Ollama** or **OpenRouter**, read-only inspection tools, **works on SFTP-only hosts**, cached results and searchable findings — configured in the admin panel, off until then |
|
|
@@ -727,6 +727,7 @@ The demo middleware blocks all writes on `/api/*` (login, terminal share and tot
|
|
|
727
727
|
| **Servers** | `GET/POST/PUT/DELETE /api/servers`, `POST /api/servers/{id}/test`, `GET /api/servers/groups`, `POST /api/servers/import`, `GET /api/servers/export`, `GET /api/servers/status` |
|
|
728
728
|
| **Terminal** | `WS /api/ws/terminal/{server_id}` (owner), `WS /api/ws/terminal/quick` (one-off), `WS /api/ws/terminal/join/{token}?mode=rw\|ro` (joiner), `POST/DELETE /api/terminal/share/{session_id}` |
|
|
729
729
|
| **Files (SFTP)** | `GET /ls`, `GET /read`, `GET /download`, `GET /download-zip` (one directory), `POST /download-zip` (a chosen selection), `POST /upload`, `PUT /write`, `POST /mkdir`, `POST /rename`, `DELETE /delete`, `POST /chmod`, `GET /stat` (all under `/api/files/{server_id}/`) |
|
|
730
|
+
| **Single sign-on** | `GET /api/auth/sso/start`, `GET /api/auth/sso/callback`, `POST /api/auth/sso/exchange` |
|
|
730
731
|
| **Settings** | `GET /api/settings`, `PUT /api/settings`, `POST /api/settings/reset` (admin only) |
|
|
731
732
|
| **Agent** | `GET/PUT /api/agent/settings`, `POST /api/agent/models`, `POST /api/agent/chat/{server_id}`, `GET/DELETE /api/agent/conversations/{server_id}`, `GET /api/agent/findings` |
|
|
732
733
|
| **Branding** | `GET /api/branding` (public), `PUT/DELETE /api/branding` (admin only) |
|
|
@@ -790,6 +791,7 @@ docker compose -f compose.dev.yml up --build
|
|
|
790
791
|
|
|
791
792
|
## Security
|
|
792
793
|
|
|
794
|
+
- **Single sign-on** verifies the ID token properly: signature against the provider's published keys, plus issuer, audience, expiry and a per-sign-in nonce. The flow uses PKCE, its state lives in the database so it works across workers, and the session token is handed over as a one-time code rather than in a URL
|
|
793
795
|
- **Host keys are verified** — trust on first use. The first connection to a server records the key it presents; every connection after that is checked against it *before authentication runs*, so a mismatch sends nothing. Accepting a changed key is a deliberate, audited admin action. This covers the terminal, SFTP, the connection pool, the status monitor, the agent and jump hosts
|
|
794
796
|
- All SSH passwords and private keys are encrypted at rest with **Fernet** (key derived from `WEBGATE_SECRET_KEY`)
|
|
795
797
|
- **The default secret key is refused.** That key signs every session token and derives the credential encryption key, and its default is published in this repository — a deployment that never changed it will hand a valid admin token to anyone who asks. webgate now stops at startup rather than serving under it, unless it is bound to loopback only
|
|
@@ -140,7 +140,7 @@ flowchart TB
|
|
|
140
140
|
| **SFTP** | Full file ops + drag & drop upload, **sortable columns**, **multi-select** with batch ZIP download and delete, hidden-file toggle, in-browser editor (CodeMirror 6), PDF/image preview |
|
|
141
141
|
| **Server Registry** | Groups, tags, password/key auth, encrypted at rest (Fernet), **verified host keys** (TOFU), **favourites and recents**, import/export JSON, **jump host / bastion** chaining |
|
|
142
142
|
| **Access Control** | Admin/user roles, per-server SSH/SFTP toggles, SFTP path restrictions, read-only SFTP mode, group-based visibility |
|
|
143
|
-
| **Auth** | JWT + bcrypt locally, **2FA TOTP**, **API keys** for automation, **LDAP / Active Directory**
|
|
143
|
+
| **Auth** | **Single sign-on (OpenID Connect)** — Entra ID, Okta, Google Workspace, Keycloak — with group→role mapping, plus JWT + bcrypt locally, **2FA TOTP**, **API keys** for automation, **LDAP / Active Directory** |
|
|
144
144
|
| **Compliance** | **Session recording** to asciinema cast files with browser replay, off by default and opted into per server, **audit log** covering every SFTP operation, registry and account change, searchable by filename, **webhooks** (HMAC-signed) on key events |
|
|
145
145
|
| **Migration** | **Full-state backup and restore** — servers with credentials, users, groups, webhooks and API keys in one passphrase-encrypted file, portable between instances |
|
|
146
146
|
| **AI agent** | Per-server iterative chat over **Ollama** or **OpenRouter**, read-only inspection tools, **works on SFTP-only hosts**, cached results and searchable findings — configured in the admin panel, off until then |
|
|
@@ -678,6 +678,7 @@ The demo middleware blocks all writes on `/api/*` (login, terminal share and tot
|
|
|
678
678
|
| **Servers** | `GET/POST/PUT/DELETE /api/servers`, `POST /api/servers/{id}/test`, `GET /api/servers/groups`, `POST /api/servers/import`, `GET /api/servers/export`, `GET /api/servers/status` |
|
|
679
679
|
| **Terminal** | `WS /api/ws/terminal/{server_id}` (owner), `WS /api/ws/terminal/quick` (one-off), `WS /api/ws/terminal/join/{token}?mode=rw\|ro` (joiner), `POST/DELETE /api/terminal/share/{session_id}` |
|
|
680
680
|
| **Files (SFTP)** | `GET /ls`, `GET /read`, `GET /download`, `GET /download-zip` (one directory), `POST /download-zip` (a chosen selection), `POST /upload`, `PUT /write`, `POST /mkdir`, `POST /rename`, `DELETE /delete`, `POST /chmod`, `GET /stat` (all under `/api/files/{server_id}/`) |
|
|
681
|
+
| **Single sign-on** | `GET /api/auth/sso/start`, `GET /api/auth/sso/callback`, `POST /api/auth/sso/exchange` |
|
|
681
682
|
| **Settings** | `GET /api/settings`, `PUT /api/settings`, `POST /api/settings/reset` (admin only) |
|
|
682
683
|
| **Agent** | `GET/PUT /api/agent/settings`, `POST /api/agent/models`, `POST /api/agent/chat/{server_id}`, `GET/DELETE /api/agent/conversations/{server_id}`, `GET /api/agent/findings` |
|
|
683
684
|
| **Branding** | `GET /api/branding` (public), `PUT/DELETE /api/branding` (admin only) |
|
|
@@ -741,6 +742,7 @@ docker compose -f compose.dev.yml up --build
|
|
|
741
742
|
|
|
742
743
|
## Security
|
|
743
744
|
|
|
745
|
+
- **Single sign-on** verifies the ID token properly: signature against the provider's published keys, plus issuer, audience, expiry and a per-sign-in nonce. The flow uses PKCE, its state lives in the database so it works across workers, and the session token is handed over as a one-time code rather than in a URL
|
|
744
746
|
- **Host keys are verified** — trust on first use. The first connection to a server records the key it presents; every connection after that is checked against it *before authentication runs*, so a mismatch sends nothing. Accepting a changed key is a deliberate, audited admin action. This covers the terminal, SFTP, the connection pool, the status monitor, the agent and jump hosts
|
|
745
747
|
- All SSH passwords and private keys are encrypted at rest with **Fernet** (key derived from `WEBGATE_SECRET_KEY`)
|
|
746
748
|
- **The default secret key is refused.** That key signs every session token and derives the credential encryption key, and its default is published in this repository — a deployment that never changed it will hand a valid admin token to anyone who asks. webgate now stops at startup rather than serving under it, unless it is bound to loopback only
|
|
@@ -96,6 +96,11 @@ Tracks the development plan for **webgate**. Items are organized by release.
|
|
|
96
96
|
- [x] **A per-account cap on concurrent SSH sessions**
|
|
97
97
|
- [x] **The demo banner no longer covers the interface**, and every diagram in the repository renders legibly on both GitHub grounds
|
|
98
98
|
|
|
99
|
+
### v2.7.0 — Single sign-on (2026-09-17)
|
|
100
|
+
- [x] **OpenID Connect sign-on** for Entra ID, Okta, Google Workspace, Keycloak and Authentik, with the ID token verified against the provider's keys and a per-sign-in nonce
|
|
101
|
+
- [x] **Provider groups map to webgate groups**, and an unmapped group grants nothing
|
|
102
|
+
- [x] **The session token is handed over as a one-time code**, never in a URL
|
|
103
|
+
|
|
99
104
|
---
|
|
100
105
|
|
|
101
106
|
## Planned
|
webgate-2.7.0/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2.7.0
|
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2.7.0 (2026-09-17) — single sign-on
|
|
4
|
+
|
|
5
|
+
LDAP already existed, but no company running Entra ID, Okta or Google Workspace is
|
|
6
|
+
going to keep a second directory for one tool. It was the first thing anyone asked
|
|
7
|
+
about, and the thing most likely to decide whether webgate gets deployed at all.
|
|
8
|
+
|
|
9
|
+
### Single sign-on (OpenID Connect)
|
|
10
|
+
|
|
11
|
+
**Admin → Settings → Single sign-on**, alongside local accounts and LDAP rather than
|
|
12
|
+
instead of them. Register one redirect URI with your provider, fill in the issuer,
|
|
13
|
+
client ID, secret and scopes, and everything else — the endpoints, the signing keys —
|
|
14
|
+
comes from the provider's discovery document.
|
|
15
|
+
|
|
16
|
+
- **The ID token is actually verified**: signature against the provider's published
|
|
17
|
+
keys, plus issuer, audience, expiry, and a nonce tied to that one sign-in, so a token
|
|
18
|
+
captured from another flow cannot be replayed into this one. A token that merely
|
|
19
|
+
decodes proves nothing.
|
|
20
|
+
- **Authorization code with PKCE**, so it is safe with or without a client secret.
|
|
21
|
+
- **The in-progress sign-in lives in the database**, not in worker memory. The browser
|
|
22
|
+
can come back to a different worker than the one it left, which is the whole point of
|
|
23
|
+
the stateless design.
|
|
24
|
+
- **The session token never travels in a URL.** The callback hands the page a one-time
|
|
25
|
+
code, good for about a minute, which it trades for a session — so nothing sensitive
|
|
26
|
+
reaches browser history or a proxy log.
|
|
27
|
+
- **A provider group grants nothing until an admin maps it**, exactly as LDAP works. A
|
|
28
|
+
directory group created next month cannot quietly open a webgate group that happens
|
|
29
|
+
to share its name. A separate admin-groups list grants admin.
|
|
30
|
+
- Sign-ins are audited as `sso_login`, with the groups granted.
|
|
31
|
+
- Accounts created this way have **no local password** — nothing to reset, nothing to
|
|
32
|
+
leak.
|
|
33
|
+
|
|
34
|
+
Twenty-one tests, run against a real identity provider started for the test: it
|
|
35
|
+
publishes a discovery document and a JWKS and signs its own tokens, so the
|
|
36
|
+
verification is exercised rather than mocked away. A token signed with the wrong key,
|
|
37
|
+
issued for another audience, expired, or carrying another sign-in's nonce is refused,
|
|
38
|
+
and each of those is a test.
|
|
39
|
+
|
|
40
|
+
[Setup, and what to do when nobody gets any groups](https://kalexnolasco.github.io/webgate/guide/sso/).
|
|
41
|
+
|
|
42
|
+
354 tests: 345 unit, 9 browser.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
3
46
|
## v2.6.0 (2026-09-16) — rotating the secret key no longer breaks everything
|
|
4
47
|
|
|
5
48
|
### Fixed
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Single sign-on
|
|
2
|
+
|
|
3
|
+
**Admin → Settings → Single sign-on.** OpenID Connect, which is what Entra ID, Okta,
|
|
4
|
+
Google Workspace, Keycloak and Authentik all speak.
|
|
5
|
+
|
|
6
|
+
LDAP still works, and so do local accounts. Turning this on adds a button to the
|
|
7
|
+
sign-in screen; it does not take anything away.
|
|
8
|
+
|
|
9
|
+
## What to register with your provider
|
|
10
|
+
|
|
11
|
+
One redirect URI:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
https://your-webgate/api/auth/sso/callback
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then fill in four things here: the **issuer URL**, the **client ID**, the **client
|
|
18
|
+
secret** if your provider issued one, and the **scopes**. Everything else — the
|
|
19
|
+
authorize and token endpoints, the signing keys — is read from
|
|
20
|
+
`{issuer}/.well-known/openid-configuration`, so there is nothing else to copy across.
|
|
21
|
+
|
|
22
|
+
| Provider | Issuer looks like |
|
|
23
|
+
|---|---|
|
|
24
|
+
| Entra ID | `https://login.microsoftonline.com/<tenant-id>/v2.0` |
|
|
25
|
+
| Okta | `https://<org>.okta.com/oauth2/default` |
|
|
26
|
+
| Google Workspace | `https://accounts.google.com` |
|
|
27
|
+
| Keycloak | `https://<host>/realms/<realm>` |
|
|
28
|
+
| Authentik | `https://<host>/application/o/<slug>/` |
|
|
29
|
+
|
|
30
|
+
!!! tip "Behind a proxy that rewrites the host"
|
|
31
|
+
webgate works out its own address from the request. If a proxy changes it, set
|
|
32
|
+
**Public URL** and the redirect will be that plus `/api/auth/sso/callback`.
|
|
33
|
+
|
|
34
|
+
## Groups decide what people can reach
|
|
35
|
+
|
|
36
|
+
A group from your directory means nothing here until an admin maps it:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{"infra-oncall": "prod", "platform": "core"}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
An unmapped group grants nothing. That is deliberate: a directory group created next
|
|
43
|
+
month must not quietly open a webgate group that happens to share its name.
|
|
44
|
+
|
|
45
|
+
**Admin groups** is a separate list — membership in any of them makes the person a
|
|
46
|
+
webgate admin, and admins see every server.
|
|
47
|
+
|
|
48
|
+
Both are re-read on every sign-in, so removing somebody from a group in your directory
|
|
49
|
+
takes effect the next time they sign in. It does not end a session already open; the
|
|
50
|
+
session-token lifetime under **Security** decides that.
|
|
51
|
+
|
|
52
|
+
### If nobody gets any groups
|
|
53
|
+
|
|
54
|
+
Your provider is probably not sending the claim. Two things to check: the **scopes**
|
|
55
|
+
usually need an extra one (`groups` on Okta and Keycloak, `GroupMember.Read.All`
|
|
56
|
+
consented on Entra), and the **groups claim** has to match the name your provider
|
|
57
|
+
uses. Entra sends group *object ids* by default rather than names, unless the app
|
|
58
|
+
registration is set to emit names.
|
|
59
|
+
|
|
60
|
+
## What happens on sign-in
|
|
61
|
+
|
|
62
|
+
1. The button sends the browser to your provider, with PKCE and a one-time `state` and
|
|
63
|
+
`nonce`.
|
|
64
|
+
2. Your provider sends it back to the callback with a code.
|
|
65
|
+
3. webgate exchanges the code, then **verifies the ID token** — signature against your
|
|
66
|
+
provider's published keys, issuer, audience, expiry, and that the nonce matches the
|
|
67
|
+
one it issued for this sign-in.
|
|
68
|
+
4. The account is created or refreshed, with the groups from the mapping.
|
|
69
|
+
5. The browser is handed a **one-time code**, and trades it for a session. The session
|
|
70
|
+
token is never put in a URL, so it cannot end up in browser history or a proxy log.
|
|
71
|
+
|
|
72
|
+
The in-progress sign-in lives in the database, not in memory, so the browser can come
|
|
73
|
+
back to a different worker than the one it left. Abandoned attempts are cleared after
|
|
74
|
+
ten minutes.
|
|
75
|
+
|
|
76
|
+
## Notes
|
|
77
|
+
|
|
78
|
+
- A person who signs in this way has **no local password**. There is nothing to reset
|
|
79
|
+
and nothing to leak; your provider is the credential.
|
|
80
|
+
- 2FA is your provider's business here. webgate's own TOTP applies to local accounts.
|
|
81
|
+
- Every sign-in is recorded as `sso_login` in the audit log, with the groups granted.
|
|
82
|
+
- Turning SSO off leaves the accounts it created. They simply have no working password
|
|
83
|
+
until an admin sets one.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -46,6 +46,7 @@ nav:
|
|
|
46
46
|
- Split View: guide/split.md
|
|
47
47
|
- User Management: guide/users.md
|
|
48
48
|
- Access model (groups, LDAP, tags): guide/access-control.md
|
|
49
|
+
- Single sign-on (OIDC): guide/sso.md
|
|
49
50
|
- Advanced (jump host, snippets, sharing, recording): guide/advanced.md
|
|
50
51
|
- Integrations (webhooks, LDAP, API keys, reverse proxy): guide/integrations.md
|
|
51
52
|
- Settings (admin panel): guide/settings.md
|
|
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
13
13
|
from webgate import __version__
|
|
14
14
|
from webgate.agent.routes import router as agent_router
|
|
15
15
|
from webgate.agent.store import load_config
|
|
16
|
+
from webgate.auth import oidc
|
|
16
17
|
from webgate.auth.routes import limiter
|
|
17
18
|
from webgate.auth.routes import router as auth_router
|
|
18
19
|
from webgate.auth.service import seed_admin
|
|
@@ -95,7 +96,13 @@ def create_app() -> FastAPI:
|
|
|
95
96
|
agent_available = False
|
|
96
97
|
if not settings.demo_mode:
|
|
97
98
|
agent_available = (await load_config(session)).enabled
|
|
98
|
-
return {
|
|
99
|
+
return {
|
|
100
|
+
"demo_mode": settings.demo_mode,
|
|
101
|
+
"agent_available": agent_available,
|
|
102
|
+
# The sign-in screen needs this before anyone has authenticated.
|
|
103
|
+
"sso_enabled": oidc.enabled(),
|
|
104
|
+
"sso_name": oidc.provider_name() if oidc.enabled() else "",
|
|
105
|
+
}
|
|
99
106
|
|
|
100
107
|
if settings.demo_mode:
|
|
101
108
|
# In demo mode block any state-changing request on /api/* except an
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Single sign-on over OpenID Connect.
|
|
2
|
+
|
|
3
|
+
LDAP already existed, but no company running Entra ID, Okta or Google Workspace is
|
|
4
|
+
going to keep a second directory for one tool. This is the authorization code flow
|
|
5
|
+
with PKCE, which every one of those providers speaks.
|
|
6
|
+
|
|
7
|
+
Two things shape the implementation:
|
|
8
|
+
|
|
9
|
+
* **The gateway may be several workers.** The state, nonce and PKCE verifier are
|
|
10
|
+
written to the database, not held in memory, because the browser can come back to a
|
|
11
|
+
different worker than the one it left. The same table carries the one-time code the
|
|
12
|
+
callback hands the page, so the session token never appears in a URL, in browser
|
|
13
|
+
history or in a proxy log.
|
|
14
|
+
* **An ID token is only worth what its verification is worth.** The signature is
|
|
15
|
+
checked against the provider's published keys, and the issuer, audience, expiry and
|
|
16
|
+
nonce are all checked too. A token that merely decodes proves nothing.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import secrets
|
|
26
|
+
import time
|
|
27
|
+
from datetime import UTC, datetime, timedelta
|
|
28
|
+
from typing import Any
|
|
29
|
+
from urllib.parse import urlencode
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
from jose import jwt
|
|
33
|
+
from sqlalchemy import DateTime, String, Text, delete, select
|
|
34
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
35
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
36
|
+
|
|
37
|
+
from webgate.db.engine import Base
|
|
38
|
+
from webgate.runtime_config import store as runtime
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
# A sign-in that takes longer than this was abandoned, and the row is a stale secret.
|
|
43
|
+
FLOW_TTL = timedelta(minutes=10)
|
|
44
|
+
# The handover code is used within a second of being issued; a minute is generous.
|
|
45
|
+
CODE_TTL = timedelta(minutes=1)
|
|
46
|
+
DISCOVERY_TTL = 3600.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class OidcError(Exception):
|
|
50
|
+
"""Something the operator or the provider has to fix, phrased for whoever sees it."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OidcFlow(Base):
|
|
54
|
+
"""One sign-in attempt, in progress.
|
|
55
|
+
|
|
56
|
+
In the database rather than in memory so the callback can land on any worker,
|
|
57
|
+
which is the whole point of the stateless design.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
__tablename__ = "oidc_flows"
|
|
61
|
+
|
|
62
|
+
state: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
63
|
+
nonce: Mapped[str] = mapped_column(String(64))
|
|
64
|
+
verifier: Mapped[str] = mapped_column(String(128))
|
|
65
|
+
redirect_uri: Mapped[str] = mapped_column(Text, default="")
|
|
66
|
+
# Set once the provider has been believed: the page trades this for a session.
|
|
67
|
+
handover: Mapped[str] = mapped_column(String(64), default="")
|
|
68
|
+
user_id: Mapped[int | None] = mapped_column(nullable=True)
|
|
69
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(UTC))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_discovery: dict[str, tuple[float, dict[str, Any]]] = {}
|
|
73
|
+
_jwks: dict[str, tuple[float, dict[str, Any]]] = {}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def enabled() -> bool:
|
|
77
|
+
return bool(runtime.get("oidc_enabled") and runtime.get("oidc_issuer"))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def provider_name() -> str:
|
|
81
|
+
"""What the button on the sign-in screen should say."""
|
|
82
|
+
return str(runtime.get("oidc_display_name") or "").strip() or "single sign-on"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _b64url(raw: bytes) -> str:
|
|
86
|
+
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def _fetch(url: str, cache: dict[str, tuple[float, dict[str, Any]]]) -> dict[str, Any]:
|
|
90
|
+
hit = cache.get(url)
|
|
91
|
+
if hit and time.monotonic() - hit[0] < DISCOVERY_TTL:
|
|
92
|
+
return hit[1]
|
|
93
|
+
try:
|
|
94
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
95
|
+
resp = await client.get(url)
|
|
96
|
+
resp.raise_for_status()
|
|
97
|
+
data: dict[str, Any] = resp.json()
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
raise OidcError(f"Could not reach the identity provider at {url}: {exc}") from exc
|
|
100
|
+
cache[url] = (time.monotonic(), data)
|
|
101
|
+
return data
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def discover() -> dict[str, Any]:
|
|
105
|
+
issuer = str(runtime.get("oidc_issuer")).rstrip("/")
|
|
106
|
+
return await _fetch(f"{issuer}/.well-known/openid-configuration", _discovery)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def begin(session: AsyncSession, redirect_uri: str) -> str:
|
|
110
|
+
"""Record a new sign-in attempt and return the URL to send the browser to."""
|
|
111
|
+
if not enabled():
|
|
112
|
+
raise OidcError("Single sign-on is not configured on this gateway")
|
|
113
|
+
|
|
114
|
+
config = await discover()
|
|
115
|
+
verifier = _b64url(secrets.token_bytes(64))
|
|
116
|
+
challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
|
|
117
|
+
flow = OidcFlow(
|
|
118
|
+
state=secrets.token_urlsafe(32),
|
|
119
|
+
nonce=secrets.token_urlsafe(24),
|
|
120
|
+
verifier=verifier,
|
|
121
|
+
redirect_uri=redirect_uri,
|
|
122
|
+
)
|
|
123
|
+
session.add(flow)
|
|
124
|
+
# Abandoned attempts are stale secrets; clear them out on the way past.
|
|
125
|
+
await session.execute(
|
|
126
|
+
delete(OidcFlow).where(OidcFlow.created_at < datetime.now(UTC) - FLOW_TTL)
|
|
127
|
+
)
|
|
128
|
+
await session.commit()
|
|
129
|
+
|
|
130
|
+
query = {
|
|
131
|
+
"response_type": "code",
|
|
132
|
+
"client_id": str(runtime.get("oidc_client_id")),
|
|
133
|
+
"redirect_uri": redirect_uri,
|
|
134
|
+
"scope": str(runtime.get("oidc_scopes")) or "openid profile email",
|
|
135
|
+
"state": flow.state,
|
|
136
|
+
"nonce": flow.nonce,
|
|
137
|
+
"code_challenge": challenge,
|
|
138
|
+
"code_challenge_method": "S256",
|
|
139
|
+
}
|
|
140
|
+
return f"{config['authorization_endpoint']}?{urlencode(query)}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def _claims(code: str, flow: OidcFlow) -> dict[str, Any]:
|
|
144
|
+
"""Trade the code for an ID token, and believe it only once it checks out."""
|
|
145
|
+
config = await discover()
|
|
146
|
+
data = {
|
|
147
|
+
"grant_type": "authorization_code",
|
|
148
|
+
"code": code,
|
|
149
|
+
"redirect_uri": flow.redirect_uri,
|
|
150
|
+
"client_id": str(runtime.get("oidc_client_id")),
|
|
151
|
+
"code_verifier": flow.verifier,
|
|
152
|
+
}
|
|
153
|
+
secret = str(runtime.get("oidc_client_secret"))
|
|
154
|
+
if secret:
|
|
155
|
+
data["client_secret"] = secret
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
async with httpx.AsyncClient(timeout=15) as client:
|
|
159
|
+
resp = await client.post(config["token_endpoint"], data=data)
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
raise OidcError(f"Could not reach the provider's token endpoint: {exc}") from exc
|
|
162
|
+
if resp.status_code >= 400:
|
|
163
|
+
detail = resp.text[:200]
|
|
164
|
+
raise OidcError(f"The provider rejected the sign-in ({resp.status_code}): {detail}")
|
|
165
|
+
|
|
166
|
+
id_token = resp.json().get("id_token")
|
|
167
|
+
if not id_token:
|
|
168
|
+
raise OidcError("The provider returned no id_token; check that the scope includes openid")
|
|
169
|
+
|
|
170
|
+
jwks = await _fetch(config["jwks_uri"], _jwks)
|
|
171
|
+
try:
|
|
172
|
+
claims: dict[str, Any] = jwt.decode(
|
|
173
|
+
id_token,
|
|
174
|
+
jwks,
|
|
175
|
+
algorithms=config.get("id_token_signing_alg_values_supported") or ["RS256"],
|
|
176
|
+
audience=str(runtime.get("oidc_client_id")),
|
|
177
|
+
issuer=config.get("issuer") or str(runtime.get("oidc_issuer")).rstrip("/"),
|
|
178
|
+
)
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
raise OidcError(f"The provider's token did not verify: {exc}") from exc
|
|
181
|
+
|
|
182
|
+
# Without this a token minted for another sign-in could be replayed into this one.
|
|
183
|
+
if claims.get("nonce") != flow.nonce:
|
|
184
|
+
raise OidcError("The provider's token was issued for a different sign-in")
|
|
185
|
+
return claims
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _as_list(raw: object) -> list[str]:
|
|
189
|
+
if isinstance(raw, str):
|
|
190
|
+
return [raw]
|
|
191
|
+
if isinstance(raw, list):
|
|
192
|
+
return [str(x) for x in raw] # pyright: ignore[reportUnknownArgumentType]
|
|
193
|
+
return []
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def identity(claims: dict[str, Any]) -> tuple[str, list[str], bool]:
|
|
197
|
+
"""Username, webgate groups, and whether they are an admin.
|
|
198
|
+
|
|
199
|
+
Group names come from the provider and mean nothing here until an admin maps
|
|
200
|
+
them, exactly as LDAP works -- so a new directory group cannot silently grant
|
|
201
|
+
access to a server group of the same name.
|
|
202
|
+
"""
|
|
203
|
+
username = str(
|
|
204
|
+
claims.get(str(runtime.get("oidc_username_claim")))
|
|
205
|
+
or claims.get("preferred_username")
|
|
206
|
+
or claims.get("email")
|
|
207
|
+
or claims.get("sub")
|
|
208
|
+
or ""
|
|
209
|
+
).strip()
|
|
210
|
+
if not username:
|
|
211
|
+
raise OidcError(
|
|
212
|
+
"The provider's token carried no username. Set the username claim in "
|
|
213
|
+
"Admin -> Settings -> Single sign-on to one your provider sends."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
from_provider = _as_list(claims.get(str(runtime.get("oidc_groups_claim"))))
|
|
217
|
+
try:
|
|
218
|
+
mapping: dict[str, str] = json.loads(str(runtime.get("oidc_group_map")) or "{}")
|
|
219
|
+
except json.JSONDecodeError:
|
|
220
|
+
logger.warning("The SSO group mapping is not valid JSON; treating it as empty")
|
|
221
|
+
mapping = {}
|
|
222
|
+
try:
|
|
223
|
+
admin_groups = set(json.loads(str(runtime.get("oidc_admin_groups")) or "[]"))
|
|
224
|
+
except json.JSONDecodeError:
|
|
225
|
+
admin_groups = set()
|
|
226
|
+
|
|
227
|
+
groups = sorted({mapping[g] for g in from_provider if g in mapping})
|
|
228
|
+
return username, groups, bool(admin_groups & set(from_provider))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def take_flow(session: AsyncSession, state: str) -> OidcFlow:
|
|
232
|
+
"""The recorded attempt for this state, consumed so it cannot be replayed."""
|
|
233
|
+
flow = (
|
|
234
|
+
await session.execute(select(OidcFlow).where(OidcFlow.state == state))
|
|
235
|
+
).scalar_one_or_none()
|
|
236
|
+
if flow is None:
|
|
237
|
+
raise OidcError(
|
|
238
|
+
"This sign-in is no longer valid. It may have been completed already, or "
|
|
239
|
+
"taken too long. Start again."
|
|
240
|
+
)
|
|
241
|
+
if datetime.now(UTC) - flow.created_at.replace(tzinfo=UTC) > FLOW_TTL:
|
|
242
|
+
await session.delete(flow)
|
|
243
|
+
await session.commit()
|
|
244
|
+
raise OidcError("This sign-in took too long. Start again.")
|
|
245
|
+
return flow
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def complete(session: AsyncSession, code: str, flow: OidcFlow) -> dict[str, Any]:
|
|
249
|
+
return await _claims(code, flow)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
async def issue_handover(session: AsyncSession, flow: OidcFlow, user_id: int) -> str:
|
|
253
|
+
"""A one-time code the page trades for a session token.
|
|
254
|
+
|
|
255
|
+
Redirecting with the session token in the URL would put it in browser history and
|
|
256
|
+
in every proxy log on the way. This is single-use and lives about a minute.
|
|
257
|
+
"""
|
|
258
|
+
flow.handover = secrets.token_urlsafe(32)
|
|
259
|
+
flow.user_id = user_id
|
|
260
|
+
flow.created_at = datetime.now(UTC)
|
|
261
|
+
await session.commit()
|
|
262
|
+
return flow.handover
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
async def redeem_handover(session: AsyncSession, handover: str) -> int:
|
|
266
|
+
"""The user behind a handover code, which is destroyed in the process."""
|
|
267
|
+
flow = (
|
|
268
|
+
await session.execute(select(OidcFlow).where(OidcFlow.handover == handover))
|
|
269
|
+
).scalar_one_or_none()
|
|
270
|
+
if flow is None or flow.user_id is None:
|
|
271
|
+
raise OidcError("This sign-in code is not valid")
|
|
272
|
+
expired = datetime.now(UTC) - flow.created_at.replace(tzinfo=UTC) > CODE_TTL
|
|
273
|
+
user_id = flow.user_id
|
|
274
|
+
await session.delete(flow)
|
|
275
|
+
await session.commit()
|
|
276
|
+
if expired:
|
|
277
|
+
raise OidcError("This sign-in code has expired. Start again.")
|
|
278
|
+
return user_id
|