Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
12bbdf7
feat(auth): add generic OpenID Connect login
obelix58143 Sep 14, 2026
605ae52
fix(auth): cache the raw JWKS instead of the parsed key set
obelix58143 Sep 14, 2026
6a694c4
fix(auth): only block account adoption on an unverified email, not si…
obelix58143 Sep 14, 2026
24c4435
feat(auth): allow email + password sign-in to be switched off
obelix58143 Sep 14, 2026
a3b458f
fix(auth): only send a post-logout redirect when one is configured
obelix58143 Sep 14, 2026
6bce2ec
fix(auth): leave the provider through an Inertia location visit
obelix58143 Sep 14, 2026
a7c02da
feat(auth): derive the workspace role from provider groups
obelix58143 Sep 14, 2026
a97ab99
fix(auth): accept a groups claim sent as a string
obelix58143 Sep 14, 2026
86e61a2
fix(auth): give the first OIDC user a workspace on a fresh instance
obelix58143 Sep 14, 2026
211233f
fix(auth): leave the account owner out of the group role sync
obelix58143 Sep 14, 2026
baab458
fix(lang): use proper umlauts in the German strings
obelix58143 Sep 14, 2026
86a96d0
feat(auth): let an instance hand account ownership to the groups
obelix58143 Sep 14, 2026
45ae376
test(auth): attack the ID token validation instead of only using it
obelix58143 Sep 14, 2026
7949b37
test(auth): cover the round-trip, the log and add throttling
obelix58143 Sep 14, 2026
18601f1
fix(auth): stop promising a password field the login page does not show
obelix58143 Sep 14, 2026
1de4c6d
style: apply pint to the rebase resolutions
obelix58143 Sep 14, 2026
7b7a9b6
Merge branch 'main' into feat/generic-oidc-login
paulocastellano Sep 14, 2026
2eeac4e
i18n(auth): translate the OIDC strings into every language
obelix58143 Sep 14, 2026
7806e2d
build: require firebase/php-jwt v7 only
obelix58143 Sep 14, 2026
61b1e17
test(auth): cover the OIDC paths that were still untested
obelix58143 Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,45 @@ GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_AUTH_CALLBACK="${APP_URL}/auth/github/callback"

# Generic OpenID Connect (user login/signup) - works with any spec-compliant
# provider (Authentik, Keycloak, Pocket ID, Zitadel, ...).
# OIDC_DISCOVERY_URL takes the issuer URL or the full .well-known URL.
# OIDC_DISPLAY_NAME is what the login button says: "Log in with <name>".
# Register the callback below as the redirect URI in your provider.
# Switch off email + password sign-in once an identity provider is in place.
# Ignored while no other provider is enabled, so this cannot lock you out.
PASSWORD_LOGIN_ENABLED=true

OIDC_AUTH_ENABLED=false
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_DISCOVERY_URL=
OIDC_AUTH_CALLBACK="${APP_URL}/auth/oidc/callback"
OIDC_SCOPES="openid profile email"
OIDC_DISPLAY_NAME="SSO"
# Also end the session at the provider when logging out of TryPost.
OIDC_LOGOUT_ENABLED=true
# Optional: where the provider returns the browser after logout. Must match a
# post-logout redirect URI registered with the provider exactly; leave empty to
# simply stay on the provider's page.
OIDC_POST_LOGOUT_REDIRECT_URI=
# Restrict sign-in to provider groups (comma separated). Empty = no extra gate.
OIDC_GROUPS_CLAIM=groups
OIDC_ALLOWED_GROUPS=
# Place new OIDC users on the shared account instead of requiring an invite
# each. OIDC_AUTO_JOIN_ACCOUNT_ID defaults to the oldest account.
OIDC_AUTO_JOIN_ENABLED=false
OIDC_AUTO_JOIN_ROLE=member
# Groups whose members administer the workspace. With this set, the role of
# every OIDC user follows the provider on each sign-in - no standing local
# admin account needed.
OIDC_ADMIN_GROUPS=
# Clear the account owner so that every right is derived from provider groups.
# Owner-only actions (deleting a workspace, billing) then become unavailable to
# everyone; everything operational runs on the admin role.
OIDC_RELEASE_OWNERSHIP=false
OIDC_AUTO_JOIN_ACCOUNT_ID=

# Pinterest (https://developers.pinterest.com)
PINTEREST_CLIENT_ID=
PINTEREST_CLIENT_SECRET=
Expand Down
92 changes: 92 additions & 0 deletions app/Actions/Auth/JoinOidcUserToAccount.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

namespace App\Actions\Auth;

use App\Actions\Account\CancelAccountSubscription;
use App\Enums\UserWorkspace\Role as WorkspaceRole;
use App\Models\Account;
use App\Models\User;
use Illuminate\Support\Facades\DB;

/**
* Puts a user who just signed in through OIDC onto the instance's shared
* account, so that group membership at the identity provider is all the
* onboarding a self-hosted team needs - no second invite per person.
*
* Mirrors AcceptInvite: move the account, attach the workspaces, then drop the
* empty personal account that signup leaves behind.
*/
class JoinOidcUserToAccount
{
public static function execute(User $user, WorkspaceRole $role): bool
{
$account = self::targetAccount();

if (! $account || $user->account_id === $account->id) {
return false;
}

$workspaces = $account->workspaces()->orderBy('created_at')->get();

// Nothing to join yet - the team has not created a workspace.
if ($workspaces->isEmpty()) {
return false;
}

$previousAccountId = $user->account_id;

DB::transaction(function () use ($user, $account, $workspaces, $role): void {
$user->update(['account_id' => $account->id]);
$user->refresh();

foreach ($workspaces as $workspace) {
$alreadyMember = $workspace->members()
->where('users.id', $user->id)
->exists();

// Never overwrite an existing pivot role (avoids demoting admins).
if (! $alreadyMember) {
$workspace->members()->attach($user->id, ['role' => $role->value]);
}
}

$user->update(['current_workspace_id' => $workspaces->first()->id]);
$user->refresh();
});

// Drop the personal account shell after commit, the same way invite
// acceptance does, so Stripe cancellation is not held inside the
// transaction.
if ($previousAccountId) {
$shell = Account::query()
->whereKey($previousAccountId)
->where('owner_id', $user->id)
->whereDoesntHave('workspaces')
->first();

if ($shell && CancelAccountSubscription::execute($shell)) {
$shell->delete();
}
}

return true;
}

/**
* The account new OIDC users are placed on. Configurable for instances that
* host more than one team; otherwise the oldest account, which on a
* self-hosted install is the one the first admin created.
*/
private static function targetAccount(): ?Account
{
$configured = config('trypost.oidc_auto_join_account_id');

if (filled($configured)) {
return Account::find($configured);
}

return Account::query()->orderBy('created_at')->first();
}
}
42 changes: 42 additions & 0 deletions app/Actions/Auth/ReleaseAccountOwnership.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace App\Actions\Auth;

use App\Models\User;

/**
* Hands an account over to group management by clearing its owner.
*
* Ownership outranks the workspace role, so as long as one person holds it
* their rights cannot follow their provider groups - they are permanently
* outside the very system that is supposed to govern access. On an instance
* where the provider decides who may do what, that is the one exception too
* many.
*
* `accounts.owner_id` is nullable and every check against it is a comparison,
* so an account without an owner is a supported state: the owner-only actions
* (deleting a workspace, billing) become unavailable to everyone, and
* everything operational - connecting accounts, managing the team, inviting -
* runs on the admin role, which does follow the groups.
*/
class ReleaseAccountOwnership
{
public static function execute(User $user): bool
{
if (! config('trypost.oidc_release_ownership')) {
return false;
}

$account = $user->account;

if (! $account || blank($account->owner_id)) {
return false;
}

$account->update(['owner_id' => null]);

return true;
}
}
65 changes: 65 additions & 0 deletions app/Actions/Auth/SyncOidcWorkspaceRole.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace App\Actions\Auth;

use App\Enums\UserWorkspace\Role as WorkspaceRole;
use App\Models\User;

/**
* Derives a user's workspace role from the groups their identity provider
* reports, on every sign-in.
*
* This is what lets an instance run without a standing local admin account:
* add somebody to the admin group at the provider and they can administer the
* workspace; take them out and they cannot, without anyone touching the
* application. Offboarding then happens in exactly one place.
*/
class SyncOidcWorkspaceRole
{
/**
* @param array<int, string> $groups Group names as reported by the provider.
*/
public static function execute(User $user, array $groups): void
{
$adminGroups = self::configuredAdminGroups();

// Without an admin group configured, roles stay under whoever manages
// them in the application - invites, or an admin changing them by hand.
if ($adminGroups === []) {
return;
}

// Ownership is resolved through account.owner_id and outranks the
// workspace role, so demoting an owner here would show "member" in the
// interface while they keep every permission. Leave owners alone rather
// than display a right they still have as one they lost.
if ($user->account?->owner_id === $user->id) {
return;
}

$target = array_intersect($groups, $adminGroups) !== []
? WorkspaceRole::Admin
: WorkspaceRole::tryFrom((string) config('trypost.oidc_auto_join_role')) ?? WorkspaceRole::Member;

foreach ($user->workspaces as $workspace) {
if ($workspace->pivot->role === $target->value) {
continue;
}

$workspace->members()->updateExistingPivot($user->id, ['role' => $target->value]);
}
}

/**
* @return array<int, string>
*/
private static function configuredAdminGroups(): array
{
return array_values(array_filter(array_map(
'trim',
explode(',', (string) config('trypost.oidc_admin_groups'))
)));
}
}
4 changes: 3 additions & 1 deletion app/Actions/User/CreateUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
class CreateUser
{
/**
* @param array{name: string, email: string, password?: string, google_id?: string, github_id?: string, email_verified_at?: \DateTimeInterface|null, is_invite?: bool, registration_ip?: string|null, locale?: string} $data
* @param array{name: string, email: string, password?: string, google_id?: string, github_id?: string, oidc_id?: string, email_verified_at?: \DateTimeInterface|null, is_invite?: bool, registration_ip?: string|null, locale?: string} $data
* @param array<string, string> $attributionParameters UTM parameters and ad click IDs (gclid, fbclid, etc.) captured before signup
*/
public static function execute(array $data, array $attributionParameters = []): User
Expand All @@ -45,6 +45,7 @@ public static function execute(array $data, array $attributionParameters = []):
'password' => data_get($data, 'password'),
'google_id' => data_get($data, 'google_id'),
'github_id' => data_get($data, 'github_id'),
'oidc_id' => data_get($data, 'oidc_id'),
'email_verified_at' => data_get($data, 'email_verified_at', $isInviteRegistration ? now() : null),
'account_id' => $account->id,
'registration_ip' => data_get($data, 'registration_ip'),
Expand All @@ -67,6 +68,7 @@ public static function execute(array $data, array $attributionParameters = []):
$authProvider = match (true) {
(bool) $user->google_id => 'google',
(bool) $user->github_id => 'github',
(bool) $user->oidc_id => 'oidc',
default => 'email',
};

Expand Down
4 changes: 4 additions & 0 deletions app/Enums/Auth/SocialAuthProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@ enum SocialAuthProvider: string
{
case Google = 'google';
case GitHub = 'github';
case Oidc = 'oidc';

public function label(): string
{
return match ($this) {
self::Google => 'Google',
self::GitHub => 'GitHub',
// Self-hosted providers are named by the operator, so the button
// can read "Login with <company> SSO" instead of "OIDC".
self::Oidc => (string) config('trypost.oidc_display_name'),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public function connectProvider(string $provider): RedirectResponse
return match ($socialProvider) {
SocialAuthProvider::Google => Socialite::driver('google-auth')->redirect(),
SocialAuthProvider::GitHub => Socialite::driver('github')->scopes(['read:user', 'user:email'])->redirect(),
SocialAuthProvider::Oidc => Socialite::driver('oidc')->redirect(),
};
}

Expand Down
65 changes: 63 additions & 2 deletions app/Http/Controllers/Auth/AuthenticatedSessionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@

namespace App\Http\Controllers\Auth;

use App\Enums\Auth\SocialAuthProvider;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\Auth\LoginRequest;
use App\Jobs\PostHog\SyncUser;
use App\Models\Invite;
use App\Services\PostHogService;
use App\Socialite\OidcProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response as HttpResponse;

class AuthenticatedSessionController extends Controller
{
Expand Down Expand Up @@ -57,14 +61,71 @@ public function store(LoginRequest $request): RedirectResponse
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
public function destroy(Request $request): RedirectResponse|HttpResponse
{
// Read before the session goes away: RP-initiated logout needs the ID
// token of this session as a hint for the provider.
$endSessionUrl = $this->oidcEndSessionUrl($request);

Auth::guard('web')->logout();

$request->session()->invalidate();

$request->session()->regenerateToken();

return redirect('/');
// Logging out is posted by Inertia, so an ordinary redirect would be
// followed by fetch() and die on the provider's CORS preflight - the
// browser never navigates and the provider session survives.
// Inertia::location makes the client do a full page visit, and still
// answers a non-Inertia request with a plain redirect.
return $endSessionUrl ? Inertia::location($endSessionUrl) : redirect('/');
}

/**
* Where to send the browser so the identity provider ends its own session
* as well. Without this, "log out" only clears the local session and the
* next click signs the same user straight back in - which is the opposite
* of what someone on a shared machine expects.
*
* Null when the user did not sign in through OIDC, when the provider
* publishes no logout endpoint, or when the operator turned it off.
*/
private function oidcEndSessionUrl(Request $request): ?string
{
if (! SocialAuthProvider::Oidc->isEnabled() || ! config('trypost.oidc_logout_enabled')) {
return null;
}

$idToken = $request->session()->get(OidcController::ID_TOKEN_SESSION_KEY);

if (! is_string($idToken) || blank($idToken)) {
return null;
}

try {
$driver = Socialite::driver('oidc');
} catch (\Throwable) {
return null;
}

if (! $driver instanceof OidcProvider || blank($endpoint = $driver->endSessionEndpoint())) {
return null;
}

$parameters = [
'id_token_hint' => $idToken,
'client_id' => config('services.oidc.client_id'),
];

// Only sent when the operator configured one. A post-logout redirect
// has to match a URI registered with the provider character for
// character; sending an unregistered one makes providers reject the
// whole request, and the user is left signed in while believing they
// are not. Without it they simply stay on the provider's page.
if (filled($returnTo = config('trypost.oidc_post_logout_redirect_uri'))) {
$parameters['post_logout_redirect_uri'] = $returnTo;
}

return $endpoint.(str_contains($endpoint, '?') ? '&' : '?').http_build_query($parameters);
}
}
Loading