mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
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:
61 files changed
+3362
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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. #}
|
||||
|
||||
Reference in new issue
Block a user