Skip to content

feat(auth): add generic OpenID Connect login - #354

Open
obelix58143 wants to merge 20 commits into
trypostit:mainfrom
obelix58143:feat/generic-oidc-login
Open

obelix58143 wants to merge 20 commits into
trypostit:mainfrom
obelix58143:feat/generic-oidc-login

Conversation

@obelix58143

Copy link
Copy Markdown

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_ENABLED nothing changes.

Security

  • PKCE (S256) on every authorization request, with the challenge derived from a
    server-side verifier
  • 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 on failure so a key rotation does not
    lock everyone out
  • an address the provider reports as unverified can never adopt an existing
    local account
  • the session id is regenerated after sign-in
  • both routes are throttled

Covered by adversarial tests rather than assertion: alg=none, algorithm
confusion (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 the
session 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_URI is
configured: 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_GROUPS restricts who may sign in. OIDC_ADMIN_GROUPS derives the
workspace role from group membership on every sign-in, both ways, so offboarding
happens in one place. OIDC_AUTO_JOIN_ENABLED places new users on the shared
account for single-team installs, instead of one invite per person.

OIDC_RELEASE_OWNERSHIP clears the account owner. Ownership outranks the
workspace role, so whoever holds it is permanently outside the group system;
accounts.owner_id is nullable and every check against it is a comparison, so
clearing it is a supported state where only the owner-only actions (deleting a
workspace, billing) become unavailable.

Also here

PASSWORD_LOGIN_ENABLED=false closes the password endpoints - sign-in, reset
and 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 against
Pocket ID: sign-in, group-derived roles, auto-join and provider logout all
verified on a live instance.

obelix58143 and others added 16 commits September 14, 2026 16:41
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>
@obelix58143
obelix58143 force-pushed the feat/generic-oidc-login branch from 5c4d151 to 1de4c6d Compare September 14, 2026 14:47
@paulocastellano

Copy link
Copy Markdown
Contributor

@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>
@obelix58143

obelix58143 commented Sep 14, 2026

Copy link
Copy Markdown
Author

Done — all sixteen locales now carry the six OIDC strings and login.description_without_password, matched to the tone each file already uses.

I also checked the whole lang/ tree while I was in there: every locale is now key-for-key identical to en across all 24 files.

These are AI translations, not reviewed by native speakers — happy to take corrections on any of them.

@paulocastellano paulocastellano left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@obelix58143 also i think it's missing a lot of tests for this pull request.

Can you improve it please?

Comment thread composer.json Outdated
obelix58143 and others added 2 commits September 14, 2026 23:54
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>
@obelix58143

Copy link
Copy Markdown
Author

Both done.

firebase/php-jwt is now ^7.0 only. The lock had already resolved to
v7.1.0, so only the constraint and the content hash move — no package changes.

Tests: +21 cases, on the paths that genuinely had none rather than more of
what was already covered.

  • Discovery: a document missing the endpoints we need, a response that
    isn't 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 instead of on every sign-in.
  • A provider with no logout endpoint, and one that's unreachable — neither
    may stop anyone logging out.
  • userinfo: a provider exposing 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 — that one is worth a look.
  • The subject claim (without it there's nothing stable to tie an account
    to) and the display-name fallbacks.
  • The token exchange, 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 from the settings page, including the case that matters: a
    subject already linked to another account can't be taken over.

Each new test was checked by breaking the implementation and confirming it
turns red — a green first run isn't evidence on its own. Happy to add more
wherever you think the coverage is still thin; if there's a particular area
you had in mind, say which and I'll go at that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants