feat(auth): add generic OpenID Connect login - #354
obelix58143 wants to merge 20 commits into
Conversation
Adds an OIDC sign-in provider so any spec-compliant identity provider (Authentik, Keycloak, Zitadel, Pocket ID, Entra ID, ...) can be used for login and signup, not just Google and GitHub. Refs trypostit#303. Endpoints are taken from the provider's discovery document, so only the issuer, client id and secret have to be configured. Security: - PKCE (S256) on every authorization request - a per-request nonce that has to come back inside the ID token - the ID token verified against the provider's JWKS, plus issuer, audience and expiry; the key set is refetched once if verification fails, so a key rotation does not lock everyone out - an email the provider reports as unverified can never be used to claim an existing account - the session id is regenerated after sign-in Logout: when the provider publishes an end_session_endpoint, logging out of TryPost also ends the session at the provider (RP-initiated logout), so "log out" is not undone by the next click on a shared machine. Groups: OIDC_ALLOWED_GROUPS restricts who may sign in at all. For self-hosted single-team installs, OIDC_AUTO_JOIN_ENABLED places new users on the shared account with a configured role, so provider group membership is the only onboarding step instead of one invite per person. Everything is off by default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A parsed key set holds OpenSSL key objects, which no serializing cache store can write - logins failed with "Serialization of OpenSSLAsymmetricKey is not allowed" on any install using Redis, file or database cache. Cache the JWKS document instead and parse it per use; parsing is cheap next to the HTTP round-trip the cache saves. Adds a regression test that exercises the path through a serializing cache store, which the array store used elsewhere in the suite cannot catch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gn-in Refusing sign-in outright locks out every provider that does no email verification and honestly reports email_verified=false - Pocket ID and a plain Authentik install among them. The risk is narrower than that: an unverified address must not be able to adopt an account that already exists locally. Signing in still works and creates a separate account, where no takeover is possible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An instance that sits behind an identity provider usually does not want the local password form: it is a second way in that the provider's policies (MFA, lockout, offboarding) do not cover. PASSWORD_LOGIN_ENABLED=false closes the login, password-reset and password-registration endpoints, not just the form in the UI, so the form cannot simply be posted to directly. The login and register pages themselves stay reachable, since that is where the provider buttons live. The setting is ignored while no other provider is enabled, so a single environment variable can never lock every user out of an instance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The logout request carried url('/') as post_logout_redirect_uri. That URI
has to match one registered with the provider character for character, and
a trailing slash is enough to miss - Pocket ID registers
"https://app.example.com/" while url('/') yields no trailing slash.
Providers reject the whole end-session request on a mismatch, so the user
is left signed in at the provider while the application says they are
signed out. That is the worst outcome of the three, so nothing is sent
unless OIDC_POST_LOGOUT_REDIRECT_URI is set; without it the browser simply
stays on the provider's page and the session really ends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Logout is posted by Inertia, so returning redirect()->away() made the client follow it with fetch(). That triggers a CORS preflight against the identity provider, which answers 404 to the OPTIONS request - the browser never navigates, and the provider session stays alive while the app says the user is signed out. Inertia::location answers Inertia requests with 409 plus X-Inertia-Location, which the client turns into a full page visit, and still answers a non-Inertia request with an ordinary redirect. Covered by a test that posts with the X-Inertia header, since a plain POST cannot tell the two apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without this, a self-hosted instance still needs a standing local admin account, which is exactly the password account an SSO-only setup is trying to get rid of - and which nobody can reach once PASSWORD_LOGIN_ENABLED is off. OIDC_ADMIN_GROUPS names the groups whose members administer the workspace. The role is applied on every sign-in, both ways: joining the group grants admin, leaving it drops back to OIDC_AUTO_JOIN_ROLE. Offboarding then happens in one place, at the provider. Left empty, nothing changes and roles stay under the application's own management, as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Most providers send groups as an array, but some (ADFS and a few Keycloak mappers among them) send one space- or comma-separated string. Casting that to an array yields a single element, so every group check silently matched nothing and access control quietly did the opposite of what the operator configured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auto-join suppresses the personal workspace because it expects a shared account to join. On a fresh install there is none yet, so the very first user ended up with an account, no workspace, and nowhere to work - and since later users join the account that has none, nobody could recover from it either. When there is nothing to join, create the workspace an ordinary signup would have created. That also makes an SSO-only install work from an empty database, without seeding a local password account first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ownership is resolved through account.owner_id and outranks the workspace role. Syncing the owner's role therefore displayed them as a member while every permission stayed in place - a right shown as lost that was not, which is the wrong way round for an access control feature to be wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ownership is resolved through account.owner_id and outranks the workspace role, so whoever holds it is permanently outside the group system that is meant to govern access - on an SSO-only instance, the one exception too many. It also cannot be handed over: there is no transfer, and deleting the owner takes the account and its workspaces with it. OIDC_RELEASE_OWNERSHIP clears the owner on sign-in, so every right comes from provider groups. accounts.owner_id is nullable and every check against it is a comparison, so this is a supported state: the owner-only actions (deleting a workspace, billing) become unavailable to everyone, while connecting accounts, managing the team and inviting run on the admin role, which does follow the groups. Off by default; nothing changes unless an operator asks for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds adversarial coverage for the classic ways JWT verification is got wrong, each forged against a throwaway key pair and run through the real validation path: - alg=none, signature stripped - algorithm confusion: HS256 signed with the provider's public key - signed by a different key - issued for a different audience (token substitution) - issued by a different issuer - expired - nonce from another login (replay), and no nonce at all - the same token used twice, to show the nonce is consumed A control case asserts a valid token is still accepted. That control matters: the first run had every attack "rejected" because the test request carried no session, so validation failed before reaching any signature check. Nine green attack tests proved nothing until the control proved the harness worked at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The earlier tests replaced the Socialite driver with a mock, which skips
state validation entirely - so the protection against a login handed to a
victim by an attacker had never actually run. These drive the real
provider:
- a callback whose state does not match the session is refused
- a callback with no state at all is refused
- the authorization request carries state, nonce and PKCE, all held
server-side, with the challenge derived from the stored verifier
Also asserts a failed sign-in does not write the client secret or any
token into the log, since the callback logs why it failed.
Both OIDC routes are public and make the server call out to the provider
on every request, so they are throttled like the other public auth
endpoints.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With password sign-in switched off the page still read "Enter your email and password below to log in", above a form that only offers the identity provider button. Adds a separate line for that case and a test that every string the login page can reach is actually translated - a missing key renders as the raw key name, which is how nobody noticed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5c4d151 to
1de4c6d
Compare
|
@obelix58143 you need always translate the i18n for all languages. |
The six OIDC strings and the password-less login description existed only in English and German. Every other locale fell back to English mid-page, which is exactly what a self-hosted instance running in one language does not want on its login screen. All sixteen locales now carry the same key set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Done — all sixteen locales now carry the six OIDC strings and I also checked the whole These are AI translations, not reviewed by native speakers — happy to take corrections on any of them. |
paulocastellano
left a comment
There was a problem hiding this comment.
@obelix58143 also i think it's missing a lot of tests for this pull request.
Can you improve it please?
v6 is out on the maintainer's call. The lock already resolved to v7.1.0, so only the constraint and the content hash change - no package moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Twenty-one cases across the parts of the flow that had none: - discovery: a document missing the endpoints we need, a response that is not a document at all, an unconfigured URL, a bare issuer expanded to the well-known path, one that already points at it, and the fact that the document is fetched once rather than on every sign-in - a provider with no logout endpoint, and one that is unreachable - neither may stop anyone logging out - userinfo: a provider that exposes none, and the merge with the ID token claims, in both directions. Providers commonly put `groups` in the ID token only, and losing it there would silently strip everyone of their group-derived role - the subject claim, without which there is nothing stable to tie an account to, and the display-name fallbacks - the token exchange itself, which had no coverage at all: a valid response, one with no ID token, and one carrying a token signed with a key the provider never published - linking a provider from the settings page, including the case that matters: a subject already linked to another account cannot be taken over Each was checked by breaking the implementation and confirming the test turns red, rather than trusting a green first run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both done.
Tests: +21 cases, on the paths that genuinely had none rather than more of
Each new test was checked by breaking the implementation and confirming it |
Adds sign-in through any spec-compliant OpenID Connect provider, so a
self-hosted instance can use the identity provider it already runs instead of
Google or GitHub. Refs #303.
Endpoints come from the provider's discovery document, so only the issuer and
the client credentials have to be configured. Everything is off by default:
without
OIDC_AUTH_ENABLEDnothing changes.Security
server-side verifier
expiry; the key set is refetched once on failure so a key rotation does not
lock everyone out
local account
Covered by adversarial tests rather than assertion:
alg=none, algorithmconfusion (HS256 signed with the provider's public key), a foreign signing key,
a foreign audience, a foreign issuer, expiry, a replayed nonce, a missing nonce,
and reuse of the same token. A control case asserts a valid token is still
accepted, and separate cases drive the real provider to prove state validation
and the PKCE binding actually run.
Logout
When the provider publishes an
end_session_endpoint, logging out ends thesession there too. The response is an Inertia location visit, because a plain
redirect is followed by
fetch()and dies on the provider's CORS preflight -the browser never navigates and the provider session survives while the
application reports the user as signed out.
A post-logout redirect is only sent when
OIDC_POST_LOGOUT_REDIRECT_URIisconfigured: it has to match a URI registered with the provider character for
character, and providers reject the whole logout on a mismatch.
Groups
OIDC_ALLOWED_GROUPSrestricts who may sign in.OIDC_ADMIN_GROUPSderives theworkspace role from group membership on every sign-in, both ways, so offboarding
happens in one place.
OIDC_AUTO_JOIN_ENABLEDplaces new users on the sharedaccount for single-team installs, instead of one invite per person.
OIDC_RELEASE_OWNERSHIPclears the account owner. Ownership outranks theworkspace role, so whoever holds it is permanently outside the group system;
accounts.owner_idis nullable and every check against it is a comparison, soclearing it is a supported state where only the owner-only actions (deleting a
workspace, billing) become unavailable.
Also here
PASSWORD_LOGIN_ENABLED=falsecloses the password endpoints - sign-in, resetand password registration - not just the form. It is ignored while no other
provider is configured, so it cannot lock an instance out.
Translations are English and German only. Machine-translating security-facing
strings into the other fourteen locales seemed worse than letting them fall
back until someone who speaks them translates.
Testing
67 tests in
tests/Feature/Auth/OidcAuthTest.php, Pint clean. Running againstPocket ID: sign-in, group-derived roles, auto-join and provider logout all
verified on a live instance.