Sign in through an OpenID Connect provider (Authentik, Keycloak…) (#106)

plMail could only sign people in with a local password. On an install that
already runs Authentik or Keycloak, that is one more account and password
per person, outside the provider's access policy.

A second authenticator on the `main` firewall, beside the password form.
Both end in the same token for the same User, so the user checker, the login
throttle and TOTP two-factor apply unchanged. Authorization code flow with
PKCE, state and nonce on league/oauth2-client's GenericProvider; endpoints
come from the provider's discovery document.

- Set up under a new Admin → SSO tab; the OIDC_* variables are the fallback
  for an install configured from its compose file, and a saved provider wins
  whole. This lets an administrator who points it at a provider of their own
  sign in as any account without a second factor; the security model says so.
- The ID token's signature is not verified: it only ever comes from the
  token endpoint over TLS (OIDC Core § 3.1.3.7 step 6). So the issuer and
  endpoints must be https and no request to the provider follows a redirect.
  Accepting an ID token from anywhere else needs JWKS verification first.
- An account is linked by (issuer, sub). The address is used for the first
  link only, and only when the provider reports it verified.
- Auto-creation is off by default and creates an ordinary account with no
  usable password.
- app:user:oidc-unlink forgets a link from the console, for somebody deleted
  and re-added at the provider.

Migration: two nullable columns and a unique index on `user`, and a one-row
table `oidc_config`. Nothing backfilled; reversible.

Not run: Playwright on this branch. No automated test makes a round trip
against a real provider; that was checked by hand on a local test stack.

Closes #91

Co-Authored-By: Paul Lützner <mail@pluetzner.de>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 22:17:00 +02:00
1 parent 8efe6d6621
commit 09c503c61c
61 files changed
+3362 -7

No files matched your search

+25 -1
View File
@@ -77,7 +77,7 @@
</a>
{% endif %}
{% if section not in ['performance', 'logs', 'insight-reports', 'address', 'integrations', 'push', 'ai', 'backup', 'updates', 'reset', 'demo'] %}
{% if section not in ['performance', 'logs', 'insight-reports', 'address', 'integrations', 'push', 'sso', 'ai', 'backup', 'updates', 'reset', 'demo'] %}
<span class="ml-auto text-xs text-ink-faint">{{ 'admin.refresh_hint'|trans }}</span>
{% endif %}
</div>
@@ -158,6 +158,16 @@
<i class="fa-solid fa-bell w-4 {{ section == 'push' ? 'text-ink-faint' : '' }}" aria-hidden="true"></i>
{{ 'admin.nav.push'|trans }}
</a>
{# Beside the other credentials pasted out of
somebody's console, and a section of its
own rather than a row under integrations:
those are services people use once they
are in, and this is how they get in. #}
<a href="{{ path('app_admin_dashboard', { section: 'sso' }) }}"
class="{{ section == 'sso' ? navActive : navInactive }}">
<i class="fa-solid fa-key w-4 {{ section == 'sso' ? 'text-ink-faint' : '' }}" aria-hidden="true"></i>
{{ 'admin.nav.sso'|trans }}
</a>
{# After push and before users: it configures
an outside service the way push does, and
everything below this point is about people
@@ -353,6 +363,20 @@
</div>
</turbo-frame>
{% elseif section == 'sso' %}
{# No auto-refresh, for the reason push
gives: this frame holds a form with a
secret being pasted into it. #}
<turbo-frame id="admin-sso"
class="block space-y-3"
src="{{ path('app_admin_sso_settings') }}"
loading="lazy">
<div class="py-10 text-center text-sm text-ink-faint">
<i class="fa-solid fa-circle-notch fa-spin mr-2" aria-hidden="true"></i>{{ 'admin.loading'|trans }}
</div>
</turbo-frame>
{% elseif section == 'users' %}
{# No auto-refresh, same reasoning as
+105
View File
@@ -0,0 +1,105 @@
{% import '_partials/_card_chrome.html.twig' as chrome %}
{#
admin/sso/_frame.html.twig — Admin → SSO.
Accepts:
config — App\Entity\Oidc\OidcConfig (unsaved when nothing was ever configured)
form — App\Form\Admin\OidcConfigType, bound to it
saved — true immediately after a successful save
source — App\Service\Oidc\OidcSettings::SOURCE_*: where sign-in takes its
configuration from right now
The chip reads `source` and not the row, because what an administrator needs
to know is what the login page is doing: a provider set in the environment is
single sign-on switched on with nothing in this form, and saying "not set up"
over it would send them looking for a fault that is not there.
The client secret is never rendered. The field is write-only and its
placeholder says only whether one exists.
No auto-refresh wraps this frame — it is configuration, and a poll would empty
a field mid-paste.
#}
{# Layout classes belong on the placeholder in admin/index.html.twig, not
here — see admin/push/_frame.html.twig. #}
<turbo-frame id="admin-sso">
<div class="{{ chrome.shell() }}">
<div class="flex items-center gap-2.5 {{ chrome.band() }}">
<i class="fa-solid fa-key text-xs text-ink-faint" aria-hidden="true"></i>
<h2 class="text-sm font-semibold text-ink">{{ 'admin.sso.heading'|trans }}</h2>
{% if source == 'admin' %}
<span class="ml-auto text-[11px] font-medium px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-500">
{{ 'admin.sso.status.on'|trans }}
</span>
{% elseif source == 'environment' %}
<span class="ml-auto text-[11px] font-medium px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-500">
{{ 'admin.sso.status.environment'|trans }}
</span>
{% else %}
<span class="ml-auto text-[11px] font-medium px-2 py-0.5 rounded-full bg-raised text-ink-muted">
{{ 'admin.sso.status.off'|trans }}
</span>
{% endif %}
</div>
<div class="{{ chrome.body() }} space-y-4">
{% if saved %}
<div role="status" class="flex items-start gap-2.5 rounded-lg bg-emerald-500/10 px-3.5 py-3 text-sm text-emerald-500">
<i class="fa-solid fa-circle-check mt-0.5 shrink-0" aria-hidden="true"></i>
<p class="leading-snug">{{ 'admin.sso.saved'|trans }}</p>
</div>
{% endif %}
<p class="text-sm text-ink-soft leading-relaxed">{{ 'admin.sso.intro'|trans }}</p>
{# The one value that goes the other way: out of plMail and into
the provider's console. Copyable, like the mail registrations'. #}
<div class="flex flex-col gap-1.5">
<span class="block text-[13px] font-medium leading-none text-ink-soft">
{{ 'admin.integrations.field.redirect_uri'|trans }}
</span>
<div class="flex items-center gap-2"
data-controller="ui--clipboard"
data-ui--clipboard-confirm-text-value="{{ 'clipboard.copied'|trans }}"
data-ui--clipboard-failed-text-value="{{ 'clipboard.copy_failed'|trans }}">
<code data-ui--clipboard-target="source"
class="flex-1 min-w-0 truncate rounded-lg bg-sunken border border-line px-3 py-2 text-xs text-ink-muted">{{ url('app_login_oidc_callback') }}</code>
<button type="button"
data-action="click->ui--clipboard#copy"
class="shrink-0 h-9 w-9 flex items-center justify-center rounded-lg text-ink-soft
bg-field border border-field hover:bg-hover transition-colors"
aria-label="{{ 'admin.integrations.copy_redirect_uri'|trans }}">
<i class="fa-solid fa-copy text-xs" aria-hidden="true"></i>
</button>
</div>
<p class="text-xs text-ink-faint">{{ 'admin.sso.redirect_uri_help'|trans }}</p>
</div>
{{ form_start(form, { attr: { 'data-turbo-frame': 'admin-sso' } }) }}
<div class="space-y-4">
{{ form_widget(form) }}
<div class="flex justify-end">
<button type="submit"
class="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-sm font-medium
text-accent-ink bg-accent hover:bg-accent-strong transition-colors cursor-pointer">
<i class="fa-solid fa-floppy-disk text-xs" aria-hidden="true"></i>
{{ 'admin.sso.save'|trans }}
</button>
</div>
</div>
{{ form_end(form) }}
</div>
</div>
{# px-4, the house inset — see admin/push/_frame.html.twig. #}
<p class="px-4 text-xs text-ink-faint leading-relaxed">
<i class="fa-solid fa-circle-info mr-1" aria-hidden="true"></i>
{{ 'admin.sso.env_note'|trans }}
</p>
</turbo-frame>
+28
View File
@@ -45,6 +45,34 @@
{# Card #}
<div class="bg-surface rounded-pane shadow-sm border border-line p-8">
{# Single sign-on, above the password form because on an
install that has it, it is how most people are meant to come
in. A plain link: the provider's page is a full navigation
anyway, and Turbo would only try to fetch it. #}
{% if oidc.isEnabled %}
<a href="{{ path('app_login_oidc') }}"
data-turbo="false"
class="flex items-center justify-center gap-2 w-full border border-line bg-surface hover:bg-hover
text-ink text-sm font-medium py-2 rounded-lg transition-colors
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 ring-offset-surface">
<i class="fa-solid fa-key" aria-hidden="true"></i>
{% if oidc.label is not null %}
{{ 'login.oidc.button_named'|trans({'%provider%': oidc.label}) }}
{% else %}
{{ 'login.oidc.button'|trans }}
{% endif %}
</a>
{# No role="separator": that role makes its content
presentational, and a screen reader would then skip the
word that says the form below is the other way in. #}
<div class="flex items-center gap-3 my-6 text-xs text-ink-faint">
<span class="flex-1 border-t border-line"></span>
{{ 'login.oidc.or'|trans }}
<span class="flex-1 border-t border-line"></span>
</div>
{% endif %}
{# Full page load on submit: Turbo swaps the <body> only, so the
appearance the server renders onto <html> would be ignored
until the next real navigation. #}