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.
Files changed (203) hide show
  1. {webgate-2.6.0 → webgate-2.7.0}/.github/workflows/demo.yml +4 -2
  2. {webgate-2.6.0 → webgate-2.7.0}/.github/workflows/release.yml +5 -0
  3. {webgate-2.6.0 → webgate-2.7.0}/CHANGELOG.md +43 -0
  4. {webgate-2.6.0 → webgate-2.7.0}/PKG-INFO +4 -2
  5. {webgate-2.6.0 → webgate-2.7.0}/README.md +3 -1
  6. {webgate-2.6.0 → webgate-2.7.0}/ROADMAP.md +5 -0
  7. webgate-2.7.0/VERSION +1 -0
  8. {webgate-2.6.0 → webgate-2.7.0}/docs/changelog.md +43 -0
  9. webgate-2.7.0/docs/guide/sso.md +83 -0
  10. webgate-2.7.0/docs/screenshots/v2/audit.png +0 -0
  11. webgate-2.7.0/docs/screenshots/v2/palette.png +0 -0
  12. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v2/settings.png +0 -0
  13. webgate-2.7.0/docs/screenshots/v2/sftp.png +0 -0
  14. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v2/site-manager.png +0 -0
  15. {webgate-2.6.0 → webgate-2.7.0}/mkdocs.yml +1 -0
  16. {webgate-2.6.0 → webgate-2.7.0}/pyproject.toml +1 -1
  17. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/app.py +8 -1
  18. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/auth/models.py +4 -0
  19. webgate-2.7.0/src/webgate/auth/oidc.py +278 -0
  20. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/auth/routes.py +112 -0
  21. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/config.py +13 -0
  22. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/db/engine.py +1 -0
  23. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/runtime_config/registry.py +127 -0
  24. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/static/index.html +37 -1
  25. webgate-2.7.0/tests/test_sso.py +365 -0
  26. {webgate-2.6.0 → webgate-2.7.0}/uv.lock +1 -1
  27. webgate-2.6.0/VERSION +0 -1
  28. webgate-2.6.0/docs/screenshots/v2/audit.png +0 -0
  29. webgate-2.6.0/docs/screenshots/v2/palette.png +0 -0
  30. webgate-2.6.0/docs/screenshots/v2/sftp.png +0 -0
  31. {webgate-2.6.0 → webgate-2.7.0}/.dockerignore +0 -0
  32. {webgate-2.6.0 → webgate-2.7.0}/.github/workflows/ci.yml +0 -0
  33. {webgate-2.6.0 → webgate-2.7.0}/.github/workflows/docs.yml +0 -0
  34. {webgate-2.6.0 → webgate-2.7.0}/.gitignore +0 -0
  35. {webgate-2.6.0 → webgate-2.7.0}/DESIGN.md +0 -0
  36. {webgate-2.6.0 → webgate-2.7.0}/Dockerfile +0 -0
  37. {webgate-2.6.0 → webgate-2.7.0}/Dockerfile.demo +0 -0
  38. {webgate-2.6.0 → webgate-2.7.0}/Dockerfile.ssh-demo +0 -0
  39. {webgate-2.6.0 → webgate-2.7.0}/LICENSE +0 -0
  40. {webgate-2.6.0 → webgate-2.7.0}/PRODUCT.md +0 -0
  41. {webgate-2.6.0 → webgate-2.7.0}/RELEASING.md +0 -0
  42. {webgate-2.6.0 → webgate-2.7.0}/compose.dev.yml +0 -0
  43. {webgate-2.6.0 → webgate-2.7.0}/compose.ha.yml +0 -0
  44. {webgate-2.6.0 → webgate-2.7.0}/compose.playground.yml +0 -0
  45. {webgate-2.6.0 → webgate-2.7.0}/compose.yml +0 -0
  46. {webgate-2.6.0 → webgate-2.7.0}/deploy/nginx-ha.conf +0 -0
  47. {webgate-2.6.0 → webgate-2.7.0}/docs/LOCAL_TESTING.md +0 -0
  48. {webgate-2.6.0 → webgate-2.7.0}/docs/api/auth.md +0 -0
  49. {webgate-2.6.0 → webgate-2.7.0}/docs/api/files.md +0 -0
  50. {webgate-2.6.0 → webgate-2.7.0}/docs/api/servers.md +0 -0
  51. {webgate-2.6.0 → webgate-2.7.0}/docs/api/terminal.md +0 -0
  52. {webgate-2.6.0 → webgate-2.7.0}/docs/architecture.md +0 -0
  53. {webgate-2.6.0 → webgate-2.7.0}/docs/getting-started/installation.md +0 -0
  54. {webgate-2.6.0 → webgate-2.7.0}/docs/getting-started/quickstart.md +0 -0
  55. {webgate-2.6.0 → webgate-2.7.0}/docs/getting-started/upgrade.md +0 -0
  56. {webgate-2.6.0 → webgate-2.7.0}/docs/guide/access-control.md +0 -0
  57. {webgate-2.6.0 → webgate-2.7.0}/docs/guide/advanced.md +0 -0
  58. {webgate-2.6.0 → webgate-2.7.0}/docs/guide/files.md +0 -0
  59. {webgate-2.6.0 → webgate-2.7.0}/docs/guide/ha.md +0 -0
  60. {webgate-2.6.0 → webgate-2.7.0}/docs/guide/integrations.md +0 -0
  61. {webgate-2.6.0 → webgate-2.7.0}/docs/guide/servers.md +0 -0
  62. {webgate-2.6.0 → webgate-2.7.0}/docs/guide/settings.md +0 -0
  63. {webgate-2.6.0 → webgate-2.7.0}/docs/guide/split.md +0 -0
  64. {webgate-2.6.0 → webgate-2.7.0}/docs/guide/terminal.md +0 -0
  65. {webgate-2.6.0 → webgate-2.7.0}/docs/guide/users.md +0 -0
  66. {webgate-2.6.0 → webgate-2.7.0}/docs/index.md +0 -0
  67. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/access-control.png +0 -0
  68. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/audit.png +0 -0
  69. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/edit-access-control.png +0 -0
  70. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/editor.png +0 -0
  71. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/light-theme.png +0 -0
  72. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/login.png +0 -0
  73. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/new-server-form.png +0 -0
  74. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/01-login.png +0 -0
  75. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/02-alice-ldap-empty.png +0 -0
  76. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/03-add-server-jump-via.png +0 -0
  77. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/04-dashboard-3-servers.png +0 -0
  78. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/05-terminal-via-jump-host.png +0 -0
  79. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/06-snippet-whoami.png +0 -0
  80. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/07-shared-joiner.png +0 -0
  81. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/07-shared-owner.png +0 -0
  82. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/08-webhook-fired.png +0 -0
  83. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/09-recordings-list.png +0 -0
  84. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/10-recording-replay.png +0 -0
  85. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/playground/11-bob-limited.png +0 -0
  86. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/sftp-restricted.png +0 -0
  87. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/sftp.png +0 -0
  88. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/site-manager.png +0 -0
  89. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/split-view.png +0 -0
  90. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/ssh-disabled.png +0 -0
  91. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/terminal.png +0 -0
  92. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/users.png +0 -0
  93. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.3/01-login-demo-banner.png +0 -0
  94. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.3/02-dashboard-jump-host.png +0 -0
  95. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.3/03-terminal-snippets-jump.png +0 -0
  96. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.3/04-snippet-executed.png +0 -0
  97. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.3/05-sftp-via-jump.png +0 -0
  98. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.3/06-webhooks-modal.png +0 -0
  99. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.3/07-webhook-test-fired.png +0 -0
  100. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.3/08-add-server-jump-via.png +0 -0
  101. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.4/01-shared-terminal-owner.png +0 -0
  102. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.4/02-shared-terminal-joiner.png +0 -0
  103. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.4/03-recording-replay.png +0 -0
  104. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v0.4/04-recordings-modal.png +0 -0
  105. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v2/agent.png +0 -0
  106. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v2/branding.png +0 -0
  107. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v2/light.png +0 -0
  108. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v2/login.png +0 -0
  109. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v2/terminal.png +0 -0
  110. {webgate-2.6.0 → webgate-2.7.0}/docs/screenshots/v2/users.png +0 -0
  111. {webgate-2.6.0 → webgate-2.7.0}/fly.toml +0 -0
  112. {webgate-2.6.0 → webgate-2.7.0}/scripts/release_notes.py +0 -0
  113. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/__init__.py +0 -0
  114. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/__main__.py +0 -0
  115. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/__init__.py +0 -0
  116. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/cache.py +0 -0
  117. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/conversation.py +0 -0
  118. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/memory.py +0 -0
  119. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/models.py +0 -0
  120. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/provider.py +0 -0
  121. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/routes.py +0 -0
  122. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/service.py +0 -0
  123. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/sftp_tools.py +0 -0
  124. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/store.py +0 -0
  125. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/agent/tools.py +0 -0
  126. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/audit/__init__.py +0 -0
  127. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/audit/models.py +0 -0
  128. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/audit/service.py +0 -0
  129. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/auth/__init__.py +0 -0
  130. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/auth/ldap.py +0 -0
  131. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/auth/service.py +0 -0
  132. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/backup/__init__.py +0 -0
  133. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/backup/models.py +0 -0
  134. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/backup/routes.py +0 -0
  135. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/backup/service.py +0 -0
  136. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/branding/__init__.py +0 -0
  137. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/branding/routes.py +0 -0
  138. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/branding/store.py +0 -0
  139. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/db/__init__.py +0 -0
  140. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/demo.py +0 -0
  141. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/files/__init__.py +0 -0
  142. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/files/limits.py +0 -0
  143. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/files/models.py +0 -0
  144. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/files/pool.py +0 -0
  145. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/files/routes.py +0 -0
  146. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/files/sftp_service.py +0 -0
  147. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/recordings/__init__.py +0 -0
  148. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/recordings/models.py +0 -0
  149. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/recordings/recorder.py +0 -0
  150. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/recordings/routes.py +0 -0
  151. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/runtime_config/__init__.py +0 -0
  152. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/runtime_config/routes.py +0 -0
  153. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/runtime_config/store.py +0 -0
  154. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/servers/__init__.py +0 -0
  155. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/servers/crypto.py +0 -0
  156. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/servers/hostkeys.py +0 -0
  157. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/servers/models.py +0 -0
  158. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/servers/monitor.py +0 -0
  159. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/servers/routes.py +0 -0
  160. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/servers/service.py +0 -0
  161. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/snippets/__init__.py +0 -0
  162. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/snippets/models.py +0 -0
  163. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/snippets/routes.py +0 -0
  164. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/terminal/__init__.py +0 -0
  165. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/terminal/routes.py +0 -0
  166. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/terminal/shared.py +0 -0
  167. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/terminal/ssh_session.py +0 -0
  168. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/terminal/ws_handler.py +0 -0
  169. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/webhooks/__init__.py +0 -0
  170. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/webhooks/dispatcher.py +0 -0
  171. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/webhooks/models.py +0 -0
  172. {webgate-2.6.0 → webgate-2.7.0}/src/webgate/webhooks/routes.py +0 -0
  173. {webgate-2.6.0 → webgate-2.7.0}/tests/__init__.py +0 -0
  174. {webgate-2.6.0 → webgate-2.7.0}/tests/conftest.py +0 -0
  175. {webgate-2.6.0 → webgate-2.7.0}/tests/e2e/conftest.py +0 -0
  176. {webgate-2.6.0 → webgate-2.7.0}/tests/e2e/sshlab.py +0 -0
  177. {webgate-2.6.0 → webgate-2.7.0}/tests/e2e/test_ui.py +0 -0
  178. {webgate-2.6.0 → webgate-2.7.0}/tests/test_agent_conversation.py +0 -0
  179. {webgate-2.6.0 → webgate-2.7.0}/tests/test_agent_memory.py +0 -0
  180. {webgate-2.6.0 → webgate-2.7.0}/tests/test_agent_settings.py +0 -0
  181. {webgate-2.6.0 → webgate-2.7.0}/tests/test_agent_sftp_tools.py +0 -0
  182. {webgate-2.6.0 → webgate-2.7.0}/tests/test_agent_tools.py +0 -0
  183. {webgate-2.6.0 → webgate-2.7.0}/tests/test_audit_coverage.py +0 -0
  184. {webgate-2.6.0 → webgate-2.7.0}/tests/test_auth.py +0 -0
  185. {webgate-2.6.0 → webgate-2.7.0}/tests/test_backup.py +0 -0
  186. {webgate-2.6.0 → webgate-2.7.0}/tests/test_branding.py +0 -0
  187. {webgate-2.6.0 → webgate-2.7.0}/tests/test_credential_rotation.py +0 -0
  188. {webgate-2.6.0 → webgate-2.7.0}/tests/test_files.py +0 -0
  189. {webgate-2.6.0 → webgate-2.7.0}/tests/test_files_listing.py +0 -0
  190. {webgate-2.6.0 → webgate-2.7.0}/tests/test_host_keys.py +0 -0
  191. {webgate-2.6.0 → webgate-2.7.0}/tests/test_idle_sessions.py +0 -0
  192. {webgate-2.6.0 → webgate-2.7.0}/tests/test_limits.py +0 -0
  193. {webgate-2.6.0 → webgate-2.7.0}/tests/test_migrations.py +0 -0
  194. {webgate-2.6.0 → webgate-2.7.0}/tests/test_monitor.py +0 -0
  195. {webgate-2.6.0 → webgate-2.7.0}/tests/test_recordings.py +0 -0
  196. {webgate-2.6.0 → webgate-2.7.0}/tests/test_release_notes.py +0 -0
  197. {webgate-2.6.0 → webgate-2.7.0}/tests/test_secret_key_gate.py +0 -0
  198. {webgate-2.6.0 → webgate-2.7.0}/tests/test_servers.py +0 -0
  199. {webgate-2.6.0 → webgate-2.7.0}/tests/test_session_limit.py +0 -0
  200. {webgate-2.6.0 → webgate-2.7.0}/tests/test_settings.py +0 -0
  201. {webgate-2.6.0 → webgate-2.7.0}/tests/test_snippets.py +0 -0
  202. {webgate-2.6.0 → webgate-2.7.0}/tests/test_terminal.py +0 -0
  203. {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
- types: [published]
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:
@@ -121,3 +121,8 @@ jobs:
121
121
  tag_name: ${{ inputs.tag || github.ref_name }}
122
122
  name: v${{ needs.verify.outputs.version }}
123
123
  body_path: notes.md
124
+
125
+ demo:
126
+ needs: github_release
127
+ uses: ./.github/workflows/demo.yml
128
+ secrets: inherit
@@ -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.6.0
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** with group→role mapping |
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** with group→role mapping |
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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webgate"
3
- version = "2.6.0"
3
+ version = "2.7.0"
4
4
  description = "Self-hosted web application for remote server management via SSH terminal and SFTP file browser"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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 {"demo_mode": settings.demo_mode, "agent_available": agent_available}
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
@@ -77,6 +77,10 @@ class UserUpdateGroups(BaseModel):
77
77
  allowed_groups: list[str]
78
78
 
79
79
 
80
+ class SsoExchange(BaseModel):
81
+ code: str
82
+
83
+
80
84
  class ChangePassword(BaseModel):
81
85
  new_password: str
82
86
 
@@ -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