From 12bbdf7fe9ac5a35eff67ee1a46f6acf3894412a Mon Sep 17 00:00:00 2001 From: obelix58143 <88147701+obelix58143@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:37:04 +0200 Subject: [PATCH 01/19] feat(auth): add generic OpenID Connect login 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 #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 --- .env.example | 23 ++ app/Actions/Auth/JoinOidcUserToAccount.php | 92 +++++ app/Actions/User/CreateUser.php | 4 +- app/Enums/Auth/SocialAuthProvider.php | 4 + .../App/Settings/AuthenticationController.php | 1 + .../Auth/AuthenticatedSessionController.php | 47 ++- app/Http/Controllers/Auth/OidcController.php | 228 ++++++++++++ .../Middleware/App/HandleInertiaRequests.php | 2 + app/Models/User.php | 1 + app/Providers/AppServiceProvider.php | 9 + app/Socialite/OidcProvider.php | 315 ++++++++++++++++ composer.json | 1 + composer.lock | 2 +- config/services.php | 10 + config/trypost.php | 16 + database/factories/UserFactory.php | 1 + ...9_14_120000_add_oidc_id_to_users_table.php | 24 ++ lang/de/auth.php | 6 + lang/en/auth.php | 6 + resources/js/components/auth/SocialLogin.vue | 12 +- routes/auth.php | 3 + tests/Feature/Auth/OidcAuthTest.php | 346 ++++++++++++++++++ 22 files changed, 1149 insertions(+), 4 deletions(-) create mode 100644 app/Actions/Auth/JoinOidcUserToAccount.php create mode 100644 app/Http/Controllers/Auth/OidcController.php create mode 100644 app/Socialite/OidcProvider.php create mode 100644 database/migrations/2026_09_14_120000_add_oidc_id_to_users_table.php create mode 100644 tests/Feature/Auth/OidcAuthTest.php diff --git a/.env.example b/.env.example index b1ff91de7..26fd7d9ba 100644 --- a/.env.example +++ b/.env.example @@ -165,6 +165,29 @@ 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 ". +# Register the callback below as the redirect URI in your provider. +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 +# 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 +OIDC_AUTO_JOIN_ACCOUNT_ID= + # Pinterest (https://developers.pinterest.com) PINTEREST_CLIENT_ID= PINTEREST_CLIENT_SECRET= diff --git a/app/Actions/Auth/JoinOidcUserToAccount.php b/app/Actions/Auth/JoinOidcUserToAccount.php new file mode 100644 index 000000000..a3aa51d48 --- /dev/null +++ b/app/Actions/Auth/JoinOidcUserToAccount.php @@ -0,0 +1,92 @@ +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(); + } +} diff --git a/app/Actions/User/CreateUser.php b/app/Actions/User/CreateUser.php index 2cae6e8b6..fd8cc3da5 100644 --- a/app/Actions/User/CreateUser.php +++ b/app/Actions/User/CreateUser.php @@ -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 $attributionParameters UTM parameters and ad click IDs (gclid, fbclid, etc.) captured before signup */ public static function execute(array $data, array $attributionParameters = []): User @@ -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'), @@ -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', }; diff --git a/app/Enums/Auth/SocialAuthProvider.php b/app/Enums/Auth/SocialAuthProvider.php index ebb2179f5..1d8d6f7e4 100644 --- a/app/Enums/Auth/SocialAuthProvider.php +++ b/app/Enums/Auth/SocialAuthProvider.php @@ -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 SSO" instead of "OIDC". + self::Oidc => (string) config('trypost.oidc_display_name'), }; } diff --git a/app/Http/Controllers/App/Settings/AuthenticationController.php b/app/Http/Controllers/App/Settings/AuthenticationController.php index 24fd1bfa0..fb263d211 100644 --- a/app/Http/Controllers/App/Settings/AuthenticationController.php +++ b/app/Http/Controllers/App/Settings/AuthenticationController.php @@ -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(), }; } diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index b586dd687..49ec3fb22 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -4,16 +4,19 @@ 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\Socialite\OidcProvider; use App\Services\PostHogService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Inertia\Inertia; use Inertia\Response; +use Laravel\Socialite\Facades\Socialite; class AuthenticatedSessionController extends Controller { @@ -59,12 +62,54 @@ public function store(LoginRequest $request): RedirectResponse */ public function destroy(Request $request): RedirectResponse { + // 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('/'); + return $endSessionUrl ? redirect()->away($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; + } + + return $endpoint.(str_contains($endpoint, '?') ? '&' : '?').http_build_query([ + 'id_token_hint' => $idToken, + 'post_logout_redirect_uri' => url('/'), + 'client_id' => config('services.oidc.client_id'), + ]); } } diff --git a/app/Http/Controllers/Auth/OidcController.php b/app/Http/Controllers/Auth/OidcController.php new file mode 100644 index 000000000..54a3dd201 --- /dev/null +++ b/app/Http/Controllers/Auth/OidcController.php @@ -0,0 +1,228 @@ +isEnabled(), 404); + + $this->storeAttributionParameters($request); + $this->storeInvite($request); + + return Socialite::driver('oidc')->redirect(); + } + + public function callback(Request $request): RedirectResponse + { + abort_unless(SocialAuthProvider::Oidc->isEnabled(), 404); + + $driver = Socialite::driver('oidc'); + + try { + $oidcUser = $driver->user(); + } catch (\Exception $e) { + // Misconfigured providers are painful to debug from a generic + // error page, so keep the reason in the log. + Log::warning('OIDC login failed: '.$e->getMessage()); + + return redirect()->route('login')->withErrors([ + 'email' => __('auth.oidc_failed'), + ]); + } + + if (! $this->groupsAllow($oidcUser)) { + return redirect()->route('login')->withErrors([ + 'email' => __('auth.oidc_group_denied'), + ]); + } + + if (blank($oidcUser->getEmail())) { + return redirect()->route('login')->withErrors([ + 'email' => __('auth.oidc_email_missing'), + ]); + } + + // Accounts are matched by email below, so an address the provider says + // it has not verified must not be accepted: it could otherwise be used + // to claim someone else's account. Marking it verified on our side + // would be untrue as well. + if (data_get($oidcUser->getRaw(), 'email_verified') === false) { + return redirect()->route('login')->withErrors([ + 'email' => __('auth.oidc_email_unverified'), + ]); + } + + if ($driver instanceof OidcProvider && filled($idToken = $driver->idToken())) { + $request->session()->put(self::ID_TOKEN_SESSION_KEY, $idToken); + } + + // `guest` middleware gates login/signup; `auth` gates the settings connect flow. + if (Auth::check()) { + return $this->connectToCurrentUser(Auth::user(), $oidcUser->getId()); + } + + $user = User::where('oidc_id', $oidcUser->getId()) + ->orWhere('email', $oidcUser->getEmail()) + ->first(); + + if ($user) { + return $this->loginExistingUser($user, $oidcUser->getId()); + } + + return $this->registerNewUser($oidcUser); + } + + private function connectToCurrentUser(User $user, string $oidcId): RedirectResponse + { + $existing = User::where('oidc_id', $oidcId) + ->where('id', '!=', $user->id) + ->first(); + + if ($existing) { + return redirect()->route('app.authentication.edit') + ->with('flash.error', __('settings.authentication.providers.flash_already_linked', ['provider' => SocialAuthProvider::Oidc->label()])); + } + + if ($user->oidc_id !== $oidcId) { + $user->update(['oidc_id' => $oidcId]); + } + + return redirect()->route('app.authentication.edit') + ->with('flash.success', __('settings.authentication.providers.flash_connected', ['provider' => SocialAuthProvider::Oidc->label()])); + } + + private function loginExistingUser(User $user, string $oidcId): RedirectResponse + { + if (! $user->oidc_id) { + $user->update(['oidc_id' => $oidcId]); + } + + if (! $user->hasVerifiedEmail()) { + $user->markEmailAsVerified(); + } + + Auth::login($user, remember: true); + + // New session id on privilege change, so a session id planted before + // login cannot be reused afterwards. + request()->session()->regenerate(); + + $this->retrieveAttributionParameters(); + + if ($invite = Invite::fromId($this->retrieveInvite())) { + return redirect()->route('app.invites.show', $invite); + } + + return redirect()->route('app.home'); + } + + /** + * Whether the provider reported a group that is allowed to sign in. With no + * allow-list configured, anyone the provider lets through is welcome - the + * provider is then the only gate, which is the usual setup. + */ + private function groupsAllow(\Laravel\Socialite\Contracts\User $oidcUser): bool + { + $allowed = array_filter(array_map( + 'trim', + explode(',', (string) config('trypost.oidc_allowed_groups')) + )); + + if ($allowed === []) { + return true; + } + + $claim = (string) config('trypost.oidc_groups_claim', 'groups'); + $groups = array_map('strval', (array) data_get($oidcUser->getRaw(), $claim, [])); + + return array_intersect($groups, $allowed) !== []; + } + + /** + * Auto-join places users on a shared account, which only ever makes sense + * on a single-team install - hence the self-hosted guard, so a misplaced + * flag cannot drop strangers into someone else's account. + */ + private function autoJoinEnabled(): bool + { + return (bool) config('trypost.oidc_auto_join_enabled') + && (bool) config('trypost.self_hosted'); + } + + private function registerNewUser(\Laravel\Socialite\Contracts\User $oidcUser): RedirectResponse + { + // With auto-join on, provider group membership replaces the invite, so + // a missing invite must not be a hard stop. + $invite = $this->autoJoinEnabled() + ? Invite::fromId($this->retrieveInvite()) + : $this->resolveInviteForRegistration(); + + if ($redirect = $this->inviteEmailMismatchRedirect($invite, $oidcUser->getEmail())) { + return $redirect; + } + + $attributionParameters = $this->retrieveAttributionParameters(); + + $user = CreateUser::execute([ + 'name' => $oidcUser->getName() ?: $oidcUser->getNickname(), + 'email' => $oidcUser->getEmail(), + 'oidc_id' => $oidcUser->getId(), + 'email_verified_at' => now(), + // Both paths join an existing account, so neither wants the + // personal workspace a plain signup would create. + 'is_invite' => $invite !== null || $this->autoJoinEnabled(), + 'registration_ip' => request()->ip(), + ], $attributionParameters); + + event(new Registered($user)); + + Auth::login($user, remember: true); + + request()->session()->regenerate(); + + if ($invite) { + return redirect()->route('app.invites.show', $invite); + } + + if ($this->autoJoinEnabled()) { + $role = WorkspaceRole::tryFrom((string) config('trypost.oidc_auto_join_role')) ?? WorkspaceRole::Member; + + if (JoinOidcUserToAccount::execute($user, $role)) { + return redirect()->route('app.home'); + } + } + + return redirect()->route('app.welcome'); + } +} diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index c3b19ae05..fc697e99a 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -66,6 +66,8 @@ public function share(Request $request): array 'selfHosted' => $isSelfHosted, 'googleAuthEnabled' => SocialAuthProvider::Google->isEnabled(), 'githubAuthEnabled' => SocialAuthProvider::GitHub->isEnabled(), + 'oidcAuthEnabled' => SocialAuthProvider::Oidc->isEnabled(), + 'oidcDisplayName' => SocialAuthProvider::Oidc->label(), ]; } diff --git a/app/Models/User.php b/app/Models/User.php index 80b0446c4..d2c5cb843 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -39,6 +39,7 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma 'password', 'google_id', 'github_id', + 'oidc_id', 'account_id', 'current_workspace_id', 'email_verified_at', diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f4071b958..a8abc791d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -32,6 +32,7 @@ use App\Socialite\DiscordProvider; use App\Socialite\InstagramProvider; use App\Socialite\LinkedInPageExtendSocialite; +use App\Socialite\OidcProvider; use Carbon\CarbonImmutable; use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\VerifyEmail; @@ -190,6 +191,14 @@ protected function configureSocialite(): void return Socialite::buildProvider(InstagramProvider::class, $config); }); + // Generic OpenID Connect (login/signup) - endpoints come from the + // provider's discovery document. + Socialite::extend('oidc', function ($app) { + $config = $app['config']['services.oidc']; + + return Socialite::buildProvider(OidcProvider::class, $config); + }); + Socialite::extend('discord', function ($app) { $config = $app['config']['services.discord']; diff --git a/app/Socialite/OidcProvider.php b/app/Socialite/OidcProvider.php new file mode 100644 index 000000000..7d6885ed5 --- /dev/null +++ b/app/Socialite/OidcProvider.php @@ -0,0 +1,315 @@ + + */ + private array $tokenResponse = []; + + /** + * Verified claims of the current ID token. Some providers put group + * membership only in here and not in the userinfo response. + * + * @var array + */ + private array $idTokenClaims = []; + + /** + * The provider's discovery document. Cached per issuer because it changes + * rarely and every login would otherwise pay for the extra round-trip. + * + * @return array + */ + public function discovery(): array + { + $url = $this->discoveryUrl(); + + return Cache::remember('oidc.discovery.'.md5($url), now()->addHour(), function () use ($url): array { + $document = json_decode((string) $this->getHttpClient()->get($url, [ + RequestOptions::HEADERS => ['Accept' => 'application/json'], + ])->getBody(), true); + + if (! is_array($document) || blank($document['authorization_endpoint'] ?? null) || blank($document['token_endpoint'] ?? null)) { + throw new RuntimeException("The OIDC discovery document at {$url} is missing required endpoints."); + } + + return $document; + }); + } + + /** + * The provider's logout endpoint, or null when it does not offer one. + */ + public function endSessionEndpoint(): ?string + { + try { + $endpoint = $this->discovery()['end_session_endpoint'] ?? null; + } catch (Throwable) { + return null; + } + + return is_string($endpoint) && filled($endpoint) ? $endpoint : null; + } + + /** + * The ID token of the exchange that just happened, for use as + * `id_token_hint` on logout. + */ + public function idToken(): ?string + { + $idToken = $this->tokenResponse['id_token'] ?? null; + + return is_string($idToken) ? $idToken : null; + } + + /** + * @return array + */ + public function getScopes(): array + { + $configured = (string) config('services.oidc.scopes', ''); + + $scopes = filled($configured) + ? (array) preg_split('/[\s,]+/', trim($configured), -1, PREG_SPLIT_NO_EMPTY) + : ['profile', 'email']; + + // `openid` is what makes this an OIDC request at all, so it is never + // left to configuration. + return array_values(array_unique(['openid', ...$scopes])); + } + + protected function getAuthUrl($state): string + { + return $this->buildAuthUrlFromBase($this->discovery()['authorization_endpoint'], $state); + } + + /** + * @return array + */ + protected function getCodeFields($state = null): array + { + $nonce = Str::random(40); + + $this->request->session()->put(self::NONCE_SESSION_KEY, $nonce); + + return array_merge(parent::getCodeFields($state), ['nonce' => $nonce]); + } + + protected function getTokenUrl(): string + { + return $this->discovery()['token_endpoint']; + } + + /** + * @return array + */ + public function getAccessTokenResponse($code): array + { + $this->tokenResponse = parent::getAccessTokenResponse($code); + + $this->validateIdToken($this->tokenResponse['id_token'] ?? null); + + return $this->tokenResponse; + } + + /** + * @return array + */ + protected function getUserByToken($token): array + { + $endpoint = $this->discovery()['userinfo_endpoint'] ?? null; + + if (blank($endpoint)) { + throw new RuntimeException('The identity provider does not expose a userinfo endpoint.'); + } + + $claims = json_decode((string) $this->getHttpClient()->get($endpoint, [ + RequestOptions::HEADERS => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer '.$token, + ], + ])->getBody(), true); + + $claims = is_array($claims) ? $claims : []; + + // Claims that only the ID token carries (commonly `groups`) would + // otherwise be lost, so fill in whatever userinfo did not return. + return $claims + $this->idTokenClaims; + } + + /** + * @param array $user + */ + protected function mapUserToObject(array $user): User + { + $id = data_get($user, 'sub'); + + // Without a subject there is nothing stable to tie the account to, so + // fail instead of creating a user that can never log in again. + if (blank($id)) { + throw new RuntimeException('The identity provider did not return a subject claim.'); + } + + return (new User)->setRaw($user)->map([ + 'id' => (string) $id, + 'nickname' => data_get($user, 'preferred_username'), + 'name' => data_get($user, 'name') ?: trim((string) data_get($user, 'given_name').' '.(string) data_get($user, 'family_name')) ?: data_get($user, 'preferred_username'), + 'email' => data_get($user, 'email'), + 'avatar' => data_get($user, 'picture'), + ]); + } + + private function discoveryUrl(): string + { + $configured = (string) config('services.oidc.discovery_url', ''); + + if (blank($configured)) { + throw new RuntimeException('OIDC_DISCOVERY_URL is not configured.'); + } + + // Accept a bare issuer URL as well; the well-known path is always the + // same and pasting the issuer is the easier thing to get right. + return Str::contains($configured, '/.well-known/') + ? $configured + : rtrim($configured, '/').'/.well-known/openid-configuration'; + } + + /** + * Verifies the ID token's signature and the claims that bind it to this + * client and this login attempt. + */ + private function validateIdToken(mixed $idToken): void + { + if (! is_string($idToken) || blank($idToken)) { + throw new RuntimeException('The identity provider did not return an ID token.'); + } + + $expectedNonce = $this->request->session()->pull(self::NONCE_SESSION_KEY); + + $claims = $this->decodeIdToken($idToken); + + $issuer = $this->discovery()['issuer'] ?? null; + + if (filled($issuer) && ($claims['iss'] ?? null) !== $issuer) { + throw new RuntimeException('The ID token was issued by a different provider than configured.'); + } + + if (! in_array($this->clientId, (array) ($claims['aud'] ?? []), true)) { + throw new RuntimeException('The ID token was not issued for this client.'); + } + + if (! is_string($expectedNonce) || ! hash_equals($expectedNonce, (string) ($claims['nonce'] ?? ''))) { + throw new RuntimeException('The ID token nonce does not match this login attempt.'); + } + + $this->idTokenClaims = $claims; + } + + /** + * @return array + */ + private function decodeIdToken(string $idToken): array + { + // A little leeway so a clock a few seconds out of step does not lock + // people out. Restored afterwards because it is global to the library. + $previousLeeway = JWT::$leeway; + JWT::$leeway = 60; + + try { + try { + return $this->decodeWith($idToken, $this->signingKeys()); + } catch (Throwable) { + // A provider that just rotated its signing keys would otherwise + // lock everyone out until the cache expires, so try once more + // with freshly fetched keys before giving up. + return $this->decodeWith($idToken, $this->signingKeys(refresh: true)); + } + } catch (Throwable $e) { + throw new RuntimeException('The ID token could not be verified: '.$e->getMessage(), previous: $e); + } finally { + JWT::$leeway = $previousLeeway; + } + } + + /** + * @param array $keys + * @return array + */ + private function decodeWith(string $idToken, array $keys): array + { + return (array) json_decode(json_encode(JWT::decode($idToken, $keys)), true); + } + + /** + * @return array + */ + private function signingKeys(bool $refresh = false): array + { + $jwksUri = $this->discovery()['jwks_uri'] ?? null; + + if (blank($jwksUri)) { + throw new RuntimeException('The identity provider does not publish a JWKS, so ID tokens cannot be verified.'); + } + + $cacheKey = 'oidc.jwks.'.md5((string) $jwksUri); + + if ($refresh) { + Cache::forget($cacheKey); + } + + return Cache::remember($cacheKey, now()->addHour(), function () use ($jwksUri): array { + $jwks = json_decode((string) $this->getHttpClient()->get($jwksUri, [ + RequestOptions::HEADERS => ['Accept' => 'application/json'], + ])->getBody(), true); + + if (! is_array($jwks) || blank($jwks['keys'] ?? null)) { + throw new RuntimeException("The JWKS at {$jwksUri} is empty."); + } + + return JWK::parseKeySet($jwks); + }); + } +} diff --git a/composer.json b/composer.json index e86d17de6..cfb8537cd 100644 --- a/composer.json +++ b/composer.json @@ -41,6 +41,7 @@ "laravel/boost": "^2.5", "laravel/cashier": "^16.2", "laravel/framework": "^13.0", + "firebase/php-jwt": "^6.10|^7.0", "laravel/horizon": "^5.45", "laravel/mcp": "^0.9.1", "laravel/nightwatch": "^1.22", diff --git a/composer.lock b/composer.lock index f3dea7766..35b04a967 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ef65b49279941f3458f444863f0ce6e4", + "content-hash": "a03bfe46287c28934e6cb61eaabde232", "packages": [ { "name": "aws/aws-crt-php", diff --git a/config/services.php b/config/services.php index 4b5d5690f..927f467ab 100644 --- a/config/services.php +++ b/config/services.php @@ -75,6 +75,16 @@ 'redirect' => env('GOOGLE_AUTH_CALLBACK'), ], + // Generic OpenID Connect (used for login/signup) + 'oidc' => [ + 'client_id' => env('OIDC_CLIENT_ID'), + 'client_secret' => env('OIDC_CLIENT_SECRET'), + 'redirect' => env('OIDC_AUTH_CALLBACK'), + // Either the issuer URL or the full .well-known URL. + 'discovery_url' => env('OIDC_DISCOVERY_URL'), + 'scopes' => env('OIDC_SCOPES', 'openid profile email'), + ], + // GitHub OAuth (used for login/signup) 'github' => [ 'client_id' => env('GITHUB_CLIENT_ID'), diff --git a/config/trypost.php b/config/trypost.php index 0fdd5ee77..7f55f8082 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -157,6 +157,22 @@ 'github_auth_enabled' => env('GITHUB_AUTH_ENABLED', false), + 'oidc_auth_enabled' => env('OIDC_AUTH_ENABLED', false), + 'oidc_display_name' => env('OIDC_DISPLAY_NAME', 'SSO'), + // Ends the session at the identity provider too, so logging out really + // logs out instead of silently signing straight back in. + 'oidc_logout_enabled' => env('OIDC_LOGOUT_ENABLED', true), + // Group handling. The claim is whatever the provider puts the group names + // in; allowed_groups gates who may sign in at all. + 'oidc_groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'), + 'oidc_allowed_groups' => env('OIDC_ALLOWED_GROUPS', ''), + // Self-hosted teams usually want provider group membership to be the only + // onboarding step, so new OIDC users can be placed on the shared account + // instead of needing a separate invite each. + 'oidc_auto_join_enabled' => env('OIDC_AUTO_JOIN_ENABLED', false), + 'oidc_auto_join_role' => env('OIDC_AUTO_JOIN_ROLE', 'member'), + 'oidc_auto_join_account_id' => env('OIDC_AUTO_JOIN_ACCOUNT_ID'), + /* |-------------------------------------------------------------------------- | Social Platforms diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 87f61894d..af4fe367b 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -35,6 +35,7 @@ public function definition(): array 'password' => static::$password ??= Hash::make('password'), 'google_id' => null, 'github_id' => null, + 'oidc_id' => null, 'remember_token' => Str::random(10), 'account_id' => Account::factory(), 'current_workspace_id' => null, diff --git a/database/migrations/2026_09_14_120000_add_oidc_id_to_users_table.php b/database/migrations/2026_09_14_120000_add_oidc_id_to_users_table.php new file mode 100644 index 000000000..9f2d40222 --- /dev/null +++ b/database/migrations/2026_09_14_120000_add_oidc_id_to_users_table.php @@ -0,0 +1,24 @@ +string('oidc_id')->nullable()->unique()->after('github_id'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('oidc_id'); + }); + } +}; diff --git a/lang/de/auth.php b/lang/de/auth.php index d3d446bda..b40d6499a 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -57,6 +57,12 @@ 'google_signup' => 'Mit Google registrieren', 'github_login' => 'Mit GitHub anmelden', 'github_signup' => 'Mit GitHub registrieren', + 'oidc_login' => 'Mit :provider anmelden', + 'oidc_signup' => 'Mit :provider registrieren', + 'oidc_group_denied' => 'Dein Konto ist in keiner Gruppe, die diese Anwendung nutzen darf.', + 'oidc_email_missing' => 'Dein Anmeldedienst hat keine E-Mail-Adresse uebermittelt.', + 'oidc_email_unverified' => 'Deine E-Mail-Adresse ist bei deinem Anmeldedienst nicht bestaetigt.', + 'oidc_failed' => 'Die Anmeldung ist fehlgeschlagen. Bitte versuche es erneut.', 'github_email_unavailable' => 'Deine E-Mail-Adresse konnte nicht von GitHub abgerufen werden. Mache deine GitHub-E-Mail-Adresse öffentlich oder erteile die Berechtigung für den E-Mail-Zugriff und versuche es dann erneut.', 'login' => [ diff --git a/lang/en/auth.php b/lang/en/auth.php index 79d1b5608..5fa5366e8 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -55,6 +55,12 @@ 'google_signup' => 'Sign up with Google', 'github_login' => 'Log in with GitHub', 'github_signup' => 'Sign up with GitHub', + 'oidc_login' => 'Log in with :provider', + 'oidc_signup' => 'Sign up with :provider', + 'oidc_group_denied' => 'Your account is not in a group that may use this application.', + 'oidc_email_missing' => 'Your identity provider did not return an email address.', + 'oidc_email_unverified' => 'Your email address is not verified with your identity provider.', + 'oidc_failed' => 'Single sign-on failed. Please try again.', 'github_email_unavailable' => 'Unable to retrieve your email from GitHub. Make your GitHub email public or grant the email scope, then try again.', 'login' => [ diff --git a/resources/js/components/auth/SocialLogin.vue b/resources/js/components/auth/SocialLogin.vue index c2159938f..a682312c3 100644 --- a/resources/js/components/auth/SocialLogin.vue +++ b/resources/js/components/auth/SocialLogin.vue @@ -1,10 +1,12 @@