diff --git a/.env.example b/.env.example index b1ff91de7..0a3a33ea6 100644 --- a/.env.example +++ b/.env.example @@ -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 ". +# 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= 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/Auth/ReleaseAccountOwnership.php b/app/Actions/Auth/ReleaseAccountOwnership.php new file mode 100644 index 000000000..beacb2b7a --- /dev/null +++ b/app/Actions/Auth/ReleaseAccountOwnership.php @@ -0,0 +1,42 @@ +account; + + if (! $account || blank($account->owner_id)) { + return false; + } + + $account->update(['owner_id' => null]); + + return true; + } +} diff --git a/app/Actions/Auth/SyncOidcWorkspaceRole.php b/app/Actions/Auth/SyncOidcWorkspaceRole.php new file mode 100644 index 000000000..4e8ab42b9 --- /dev/null +++ b/app/Actions/Auth/SyncOidcWorkspaceRole.php @@ -0,0 +1,65 @@ + $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 + */ + private static function configuredAdminGroups(): array + { + return array_values(array_filter(array_map( + 'trim', + explode(',', (string) config('trypost.oidc_admin_groups')) + ))); + } +} 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..290bbfe8e 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -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 { @@ -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); } } diff --git a/app/Http/Controllers/Auth/OidcController.php b/app/Http/Controllers/Auth/OidcController.php new file mode 100644 index 000000000..32ed26421 --- /dev/null +++ b/app/Http/Controllers/Auth/OidcController.php @@ -0,0 +1,274 @@ +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'), + ]); + } + + 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())->first(); + + if (! $user) { + // Matching on the email address is how someone with a local account + // moves over to SSO. An address the provider has not verified must + // not be able to do that, or anyone able to sign up there with + // someone else's address could walk into their account. Signing in + // is still fine - it just creates a separate account. + $byEmail = User::where('email', $oidcUser->getEmail())->first(); + + if ($byEmail && data_get($oidcUser->getRaw(), 'email_verified') === false) { + return redirect()->route('login')->withErrors([ + 'email' => __('auth.oidc_email_unverified'), + ]); + } + + $user = $byEmail; + } + + if ($user) { + return $this->loginExistingUser($user, $oidcUser->getId(), $this->groupsOf($oidcUser)); + } + + return $this->registerNewUser($oidcUser, $this->groupsOf($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()])); + } + + /** + * @param array $groups + */ + private function loginExistingUser(User $user, string $oidcId, array $groups = []): RedirectResponse + { + if (! $user->oidc_id) { + $user->update(['oidc_id' => $oidcId]); + } + + // Roles follow the provider on every sign-in, so revoking admin there + // takes effect here without anyone touching the application. + ReleaseAccountOwnership::execute($user); + SyncOidcWorkspaceRole::execute($user->fresh(), $groups); + + 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'); + } + + /** + * Group names as reported by the provider. + * + * @return array + */ + private function groupsOf(\Laravel\Socialite\Contracts\User $oidcUser): array + { + $claim = (string) config('trypost.oidc_groups_claim', 'groups'); + $value = data_get($oidcUser->getRaw(), $claim, []); + + // Most providers send an array, some a single space- or + // comma-separated string. Both have to work, or group handling + // silently does nothing on half the providers out there. + if (is_string($value)) { + $value = preg_split('/[\s,]+/', trim($value), -1, PREG_SPLIT_NO_EMPTY) ?: []; + } + + return array_values(array_map('strval', array_filter((array) $value, 'is_scalar'))); + } + + /** + * 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; + } + + return array_intersect($this->groupsOf($oidcUser), $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'); + } + + /** + * @param array $groups + */ + private function registerNewUser(\Laravel\Socialite\Contracts\User $oidcUser, array $groups = []): 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)) { + SyncOidcWorkspaceRole::execute($user->fresh(), $groups); + + return redirect()->route('app.home'); + } + + // Nothing to join yet: this is the first user on a fresh instance, + // and the account they were just given has no workspace because + // auto-join suppressed it. Give them the one an ordinary signup + // would have created, or they land in an application with nowhere + // to work - and nobody can ever join them either. + CreateWorkspace::execute($user, ['name' => $user->name."'s Workspace"]); + ReleaseAccountOwnership::execute($user); + } + + return redirect()->route('app.welcome'); + } +} diff --git a/app/Http/Middleware/App/EnsurePasswordLoginEnabled.php b/app/Http/Middleware/App/EnsurePasswordLoginEnabled.php new file mode 100644 index 000000000..bbc5c7a8a --- /dev/null +++ b/app/Http/Middleware/App/EnsurePasswordLoginEnabled.php @@ -0,0 +1,27 @@ + $isSelfHosted, 'googleAuthEnabled' => SocialAuthProvider::Google->isEnabled(), 'githubAuthEnabled' => SocialAuthProvider::GitHub->isEnabled(), + 'oidcAuthEnabled' => SocialAuthProvider::Oidc->isEnabled(), + 'oidcDisplayName' => SocialAuthProvider::Oidc->label(), + 'passwordLoginEnabled' => LoginMethods::passwordEnabled(), ]; } 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..fb783ff4c --- /dev/null +++ b/app/Socialite/OidcProvider.php @@ -0,0 +1,320 @@ + + */ + 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); + } + + // Cache the raw document rather than the parsed key set: parsed keys + // hold OpenSSL key objects, which no cache store can serialize. + // Parsing again on every login is cheap by comparison. + $jwks = Cache::remember($cacheKey, now()->addHour(), function () use ($jwksUri): array { + $document = json_decode((string) $this->getHttpClient()->get($jwksUri, [ + RequestOptions::HEADERS => ['Accept' => 'application/json'], + ])->getBody(), true); + + if (! is_array($document) || blank($document['keys'] ?? null)) { + throw new RuntimeException("The JWKS at {$jwksUri} is empty."); + } + + return $document; + }); + + return JWK::parseKeySet($jwks); + } +} diff --git a/app/Support/Auth/LoginMethods.php b/app/Support/Auth/LoginMethods.php new file mode 100644 index 000000000..a91ca4c76 --- /dev/null +++ b/app/Support/Auth/LoginMethods.php @@ -0,0 +1,42 @@ +isEnabled()) { + return true; + } + } + + return false; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 5bdd356a7..0c78a6b83 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Http\Middleware\Api\LoadWorkspaceFromToken; +use App\Http\Middleware\App\EnsurePasswordLoginEnabled; use App\Http\Middleware\App\EnsureRegistrationEnabled; use App\Http\Middleware\App\HandleInertiaRequests; use App\Http\Middleware\App\SetLocale; @@ -37,6 +38,7 @@ $middleware->alias([ 'workspace.token' => LoadWorkspaceFromToken::class, 'registration.enabled' => EnsureRegistrationEnabled::class, + 'password.login.enabled' => EnsurePasswordLoginEnabled::class, ]); $middleware->preventRequestForgery(except: [ diff --git a/composer.json b/composer.json index e86d17de6..0418ac690 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": "^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..a295fa39e 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": "5e8e8d47a119032b01c3ee780251bdf1", "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 aed84cd6b..cd621991d 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -157,6 +157,39 @@ 'github_auth_enabled' => env('GITHUB_AUTH_ENABLED', false), + // Instances behind an identity provider usually want the local password + // form gone. Ignored while no other provider is configured, so a single + // variable can never lock everybody out. + 'password_login_enabled' => env('PASSWORD_LOGIN_ENABLED', true), + + '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), + // Where the provider sends the browser after logout. Leave empty unless + // the exact same URI is registered with the provider - a mismatch makes + // providers reject the logout entirely. + 'oidc_post_logout_redirect_uri' => env('OIDC_POST_LOGOUT_REDIRECT_URI'), + // 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'), + // Groups whose members administer the workspace. Set this and the role of + // every OIDC user follows the provider on each sign-in, which is what lets + // an instance run without a standing local admin account. + 'oidc_admin_groups' => env('OIDC_ADMIN_GROUPS', ''), + // Hand the account over to group management entirely by clearing its + // owner. Ownership outranks the workspace role, so whoever holds it sits + // outside the group system for good. + 'oidc_release_ownership' => env('OIDC_RELEASE_OWNERSHIP', false), + '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/ar/auth.php b/lang/ar/auth.php index 947e75271..e0467b745 100644 --- a/lang/ar/auth.php +++ b/lang/ar/auth.php @@ -55,11 +55,18 @@ 'google_signup' => 'التسجيل عبر Google', 'github_login' => 'تسجيل الدخول عبر GitHub', 'github_signup' => 'التسجيل عبر GitHub', + 'oidc_login' => 'تسجيل الدخول عبر :provider', + 'oidc_signup' => 'التسجيل عبر :provider', + 'oidc_group_denied' => 'حسابك ليس ضمن أي مجموعة مسموح لها باستخدام هذا التطبيق.', + 'oidc_email_missing' => 'لم يرسل مزوّد الهوية الخاص بك أي عنوان بريد إلكتروني.', + 'oidc_email_unverified' => 'عنوان بريدك الإلكتروني غير موثّق لدى مزوّد الهوية الخاص بك.', + 'oidc_failed' => 'فشل تسجيل الدخول الموحّد. يرجى المحاولة مرة أخرى.', 'github_email_unavailable' => 'تعذر جلب بريدك الإلكتروني من GitHub. اجعل بريدك على GitHub عامًا أو امنح نطاق الوصول إلى البريد، ثم حاول مرة أخرى.', 'login' => [ 'title' => 'تسجيل الدخول إلى حسابك', 'description' => 'أدخل بريدك الإلكتروني وكلمة المرور أدناه لتسجيل الدخول', + 'description_without_password' => 'سجّل الدخول باستخدام حساب مؤسستك للمتابعة', 'page_title' => 'تسجيل الدخول', 'email' => 'البريد الإلكتروني', 'password' => 'كلمة المرور', diff --git a/lang/de/auth.php b/lang/de/auth.php index d3d446bda..af6aab867 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -57,11 +57,18 @@ '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 übermittelt.', + 'oidc_email_unverified' => 'Deine E-Mail-Adresse ist bei deinem Anmeldedienst nicht bestätigt.', + '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' => [ 'title' => 'Melde dich bei deinem Konto an', 'description' => 'Gib unten deine E-Mail-Adresse und dein Passwort ein, um dich anzumelden', + 'description_without_password' => 'Melde dich mit deinem Organisationskonto an, um fortzufahren', 'page_title' => 'Anmelden', 'email' => 'E-Mail-Adresse', 'password' => 'Passwort', diff --git a/lang/el/auth.php b/lang/el/auth.php index d828a59e5..46f50da67 100644 --- a/lang/el/auth.php +++ b/lang/el/auth.php @@ -55,11 +55,18 @@ 'google_signup' => 'Εγγραφή με Google', 'github_login' => 'Σύνδεση με GitHub', 'github_signup' => 'Εγγραφή με GitHub', + 'oidc_login' => 'Σύνδεση με :provider', + 'oidc_signup' => 'Εγγραφή με :provider', + 'oidc_group_denied' => 'Ο λογαριασμός σας δεν ανήκει σε ομάδα που επιτρέπεται να χρησιμοποιεί αυτήν την εφαρμογή.', + 'oidc_email_missing' => 'Ο πάροχος ταυτότητάς σας δεν επέστρεψε διεύθυνση email.', + 'oidc_email_unverified' => 'Η διεύθυνση email σας δεν είναι επαληθευμένη στον πάροχο ταυτότητάς σας.', + 'oidc_failed' => 'Η σύνδεση απέτυχε. Δοκιμάστε ξανά.', 'github_email_unavailable' => 'Δεν ήταν δυνατή η ανάκτηση του email σας από το GitHub. Κάντε δημόσιο το email σας στο GitHub ή παραχωρήστε το scope email και δοκιμάστε ξανά.', 'login' => [ 'title' => 'Συνδεθείτε στον λογαριασμό σας', 'description' => 'Εισάγετε το email και τον κωδικό πρόσβασής σας παρακάτω για να συνδεθείτε', + 'description_without_password' => 'Συνδεθείτε με τον λογαριασμό του οργανισμού σας για να συνεχίσετε', 'page_title' => 'Σύνδεση', 'email' => 'Διεύθυνση email', 'password' => 'Κωδικός πρόσβασης', diff --git a/lang/en/auth.php b/lang/en/auth.php index 79d1b5608..217369c4d 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -55,11 +55,18 @@ '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' => [ 'title' => 'Log in to your account', 'description' => 'Enter your email and password below to log in', + 'description_without_password' => 'Sign in with your organisation account to continue', 'page_title' => 'Log in', 'email' => 'Email address', 'password' => 'Password', diff --git a/lang/es/auth.php b/lang/es/auth.php index 967d37d6b..aaafc40c3 100644 --- a/lang/es/auth.php +++ b/lang/es/auth.php @@ -43,11 +43,18 @@ 'google_signup' => 'Registrarse con Google', 'github_login' => 'Iniciar sesión con GitHub', 'github_signup' => 'Registrarse con GitHub', + 'oidc_login' => 'Iniciar sesión con :provider', + 'oidc_signup' => 'Registrarse con :provider', + 'oidc_group_denied' => 'Tu cuenta no pertenece a ningún grupo autorizado a usar esta aplicación.', + 'oidc_email_missing' => 'Tu proveedor de identidad no ha devuelto ninguna dirección de correo.', + 'oidc_email_unverified' => 'Tu dirección de correo no está verificada en tu proveedor de identidad.', + 'oidc_failed' => 'El inicio de sesión ha fallado. Inténtalo de nuevo.', 'github_email_unavailable' => 'No fue posible obtener tu correo de GitHub. Haz tu correo público en GitHub o concede el permiso de correo y vuelve a intentar.', 'login' => [ 'title' => 'Inicia sesión en tu cuenta', 'description' => 'Introduce tu correo y contraseña para iniciar sesión', + 'description_without_password' => 'Inicia sesión con la cuenta de tu organización para continuar', 'page_title' => 'Iniciar sesión', 'email' => 'Correo electrónico', 'password' => 'Contraseña', diff --git a/lang/fr/auth.php b/lang/fr/auth.php index 512e78a2e..0334b851e 100644 --- a/lang/fr/auth.php +++ b/lang/fr/auth.php @@ -55,11 +55,18 @@ 'google_signup' => 'S\'inscrire avec Google', 'github_login' => 'Se connecter avec GitHub', 'github_signup' => 'S\'inscrire avec GitHub', + 'oidc_login' => 'Se connecter avec :provider', + 'oidc_signup' => 'S\'inscrire avec :provider', + 'oidc_group_denied' => 'Votre compte n\'appartient à aucun groupe autorisé à utiliser cette application.', + 'oidc_email_missing' => 'Votre fournisseur d\'identité n\'a pas transmis d\'adresse e-mail.', + 'oidc_email_unverified' => 'Votre adresse e-mail n\'est pas vérifiée auprès de votre fournisseur d\'identité.', + 'oidc_failed' => 'La connexion a échoué. Veuillez réessayer.', 'github_email_unavailable' => 'Impossible de récupérer votre e-mail depuis GitHub. Rendez votre e-mail GitHub public ou accordez l\'autorisation d\'accès à l\'e-mail, puis réessayez.', 'login' => [ 'title' => 'Connectez-vous à votre compte', 'description' => 'Saisissez votre e-mail et votre mot de passe ci-dessous pour vous connecter', + 'description_without_password' => 'Connectez-vous avec le compte de votre organisation pour continuer', 'page_title' => 'Connexion', 'email' => 'Adresse e-mail', 'password' => 'Mot de passe', diff --git a/lang/it/auth.php b/lang/it/auth.php index 3677f507e..c35a85c3c 100644 --- a/lang/it/auth.php +++ b/lang/it/auth.php @@ -55,11 +55,18 @@ 'google_signup' => 'Registrati con Google', 'github_login' => 'Accedi con GitHub', 'github_signup' => 'Registrati con GitHub', + 'oidc_login' => 'Accedi con :provider', + 'oidc_signup' => 'Registrati con :provider', + 'oidc_group_denied' => 'Il tuo account non appartiene a nessun gruppo autorizzato a usare questa applicazione.', + 'oidc_email_missing' => 'Il tuo provider di identità non ha restituito alcun indirizzo email.', + 'oidc_email_unverified' => 'Il tuo indirizzo email non è verificato presso il tuo provider di identità.', + 'oidc_failed' => 'Accesso non riuscito. Riprova.', 'github_email_unavailable' => 'Impossibile recuperare la tua email da GitHub. Rendi pubblica la tua email GitHub o concedi l\'ambito email, poi riprova.', 'login' => [ 'title' => 'Accedi al tuo account', 'description' => 'Inserisci la tua email e la password qui sotto per accedere', + 'description_without_password' => 'Accedi con l\'account della tua organizzazione per continuare', 'page_title' => 'Accedi', 'email' => 'Indirizzo email', 'password' => 'Password', diff --git a/lang/ja/auth.php b/lang/ja/auth.php index 53acf65ae..35e4dfd37 100644 --- a/lang/ja/auth.php +++ b/lang/ja/auth.php @@ -55,11 +55,18 @@ 'google_signup' => 'Google で登録', 'github_login' => 'GitHub でログイン', 'github_signup' => 'GitHub で登録', + 'oidc_login' => ':provider でログイン', + 'oidc_signup' => ':provider で登録', + 'oidc_group_denied' => 'お使いのアカウントは、このアプリケーションを利用できるグループに所属していません。', + 'oidc_email_missing' => 'ID プロバイダーからメールアドレスが返されませんでした。', + 'oidc_email_unverified' => 'ID プロバイダーでメールアドレスが確認されていません。', + 'oidc_failed' => 'シングルサインオンに失敗しました。もう一度お試しください。', 'github_email_unavailable' => 'GitHub からメールアドレスを取得できませんでした。GitHub のメールアドレスを公開するか、email スコープを許可してから、もう一度お試しください。', 'login' => [ 'title' => 'アカウントにログイン', 'description' => 'ログインするにはメールアドレスとパスワードを入力してください', + 'description_without_password' => '続行するには組織のアカウントでログインしてください', 'page_title' => 'ログイン', 'email' => 'メールアドレス', 'password' => 'パスワード', diff --git a/lang/ko/auth.php b/lang/ko/auth.php index 796ef6d45..bd3a0cc53 100644 --- a/lang/ko/auth.php +++ b/lang/ko/auth.php @@ -55,11 +55,18 @@ 'google_signup' => 'Google로 가입하기', 'github_login' => 'GitHub으로 로그인', 'github_signup' => 'GitHub으로 가입하기', + 'oidc_login' => ':provider로 로그인', + 'oidc_signup' => ':provider로 가입하기', + 'oidc_group_denied' => '이 애플리케이션을 사용할 수 있는 그룹에 계정이 속해 있지 않습니다.', + 'oidc_email_missing' => 'ID 공급자가 이메일 주소를 반환하지 않았습니다.', + 'oidc_email_unverified' => 'ID 공급자에서 이메일 주소가 확인되지 않았습니다.', + 'oidc_failed' => '싱글 사인온에 실패했습니다. 다시 시도해 주세요.', 'github_email_unavailable' => 'GitHub에서 이메일을 가져올 수 없습니다. GitHub 이메일을 공개로 설정하거나 이메일 권한을 부여한 후 다시 시도하세요.', 'login' => [ 'title' => '계정에 로그인', 'description' => '로그인하려면 아래에 이메일과 비밀번호를 입력하세요', + 'description_without_password' => '계속하려면 조직 계정으로 로그인하세요', 'page_title' => '로그인', 'email' => '이메일 주소', 'password' => '비밀번호', diff --git a/lang/nl/auth.php b/lang/nl/auth.php index 03fb7b664..39c2a3d0e 100644 --- a/lang/nl/auth.php +++ b/lang/nl/auth.php @@ -55,11 +55,18 @@ 'google_signup' => 'Aanmelden met Google', 'github_login' => 'Inloggen met GitHub', 'github_signup' => 'Aanmelden met GitHub', + 'oidc_login' => 'Inloggen met :provider', + 'oidc_signup' => 'Aanmelden met :provider', + 'oidc_group_denied' => 'Je account zit niet in een groep die deze applicatie mag gebruiken.', + 'oidc_email_missing' => 'Je identiteitsprovider heeft geen e-mailadres doorgegeven.', + 'oidc_email_unverified' => 'Je e-mailadres is niet geverifieerd bij je identiteitsprovider.', + 'oidc_failed' => 'Inloggen is mislukt. Probeer het opnieuw.', 'github_email_unavailable' => 'Kan je e-mailadres niet ophalen van GitHub. Maak je GitHub-e-mailadres openbaar of verleen de e-mailscope en probeer het opnieuw.', 'login' => [ 'title' => 'Log in op je account', 'description' => 'Voer hieronder je e-mailadres en wachtwoord in om in te loggen', + 'description_without_password' => 'Log in met je organisatieaccount om verder te gaan', 'page_title' => 'Inloggen', 'email' => 'E-mailadres', 'password' => 'Wachtwoord', diff --git a/lang/pl/auth.php b/lang/pl/auth.php index b486f4c14..6190e0a05 100644 --- a/lang/pl/auth.php +++ b/lang/pl/auth.php @@ -55,11 +55,18 @@ 'google_signup' => 'Zarejestruj się przez Google', 'github_login' => 'Zaloguj się przez GitHub', 'github_signup' => 'Zarejestruj się przez GitHub', + 'oidc_login' => 'Zaloguj się przez :provider', + 'oidc_signup' => 'Zarejestruj się przez :provider', + 'oidc_group_denied' => 'Twoje konto nie należy do grupy uprawnionej do korzystania z tej aplikacji.', + 'oidc_email_missing' => 'Twój dostawca tożsamości nie przekazał adresu e-mail.', + 'oidc_email_unverified' => 'Twój adres e-mail nie został zweryfikowany u dostawcy tożsamości.', + 'oidc_failed' => 'Logowanie nie powiodło się. Spróbuj ponownie.', 'github_email_unavailable' => 'Nie udało się pobrać Twojego adresu e-mail z GitHuba. Ustaw swój adres e-mail w GitHubie jako publiczny lub przyznaj uprawnienie do e-maila, a następnie spróbuj ponownie.', 'login' => [ 'title' => 'Zaloguj się na swoje konto', 'description' => 'Wprowadź poniżej swój e-mail i hasło, aby się zalogować', + 'description_without_password' => 'Zaloguj się kontem swojej organizacji, aby kontynuować', 'page_title' => 'Zaloguj się', 'email' => 'Adres e-mail', 'password' => 'Hasło', diff --git a/lang/pt-BR/auth.php b/lang/pt-BR/auth.php index 3548fd741..63e15a010 100644 --- a/lang/pt-BR/auth.php +++ b/lang/pt-BR/auth.php @@ -55,11 +55,18 @@ 'google_signup' => 'Cadastrar com Google', 'github_login' => 'Entrar com GitHub', 'github_signup' => 'Cadastrar com GitHub', + 'oidc_login' => 'Entrar com :provider', + 'oidc_signup' => 'Cadastrar com :provider', + 'oidc_group_denied' => 'Sua conta não está em um grupo autorizado a usar este aplicativo.', + 'oidc_email_missing' => 'Seu provedor de identidade não retornou um endereço de email.', + 'oidc_email_unverified' => 'Seu endereço de email não está verificado no seu provedor de identidade.', + 'oidc_failed' => 'Falha no login único. Tente novamente.', 'github_email_unavailable' => 'Não foi possível obter seu e-mail do GitHub. Torne seu e-mail público ou conceda a permissão de e-mail e tente novamente.', 'login' => [ 'title' => 'Entrar na sua conta', 'description' => 'Digite seu email e senha abaixo para entrar', + 'description_without_password' => 'Entre com a conta da sua organização para continuar', 'page_title' => 'Entrar', 'email' => 'Endereço de email', 'password' => 'Senha', diff --git a/lang/ru/auth.php b/lang/ru/auth.php index a50349475..5acb1e3bb 100644 --- a/lang/ru/auth.php +++ b/lang/ru/auth.php @@ -55,11 +55,18 @@ 'google_signup' => 'Зарегистрироваться через Google', 'github_login' => 'Войти через GitHub', 'github_signup' => 'Зарегистрироваться через GitHub', + 'oidc_login' => 'Войти через :provider', + 'oidc_signup' => 'Зарегистрироваться через :provider', + 'oidc_group_denied' => 'Ваша учётная запись не входит в группу, которой разрешено пользоваться этим приложением.', + 'oidc_email_missing' => 'Ваш поставщик учётных данных не передал email.', + 'oidc_email_unverified' => 'Ваш email не подтверждён у поставщика учётных данных.', + 'oidc_failed' => 'Не удалось выполнить вход. Попробуйте ещё раз.', 'github_email_unavailable' => 'Не удалось получить ваш email из GitHub. Сделайте email в GitHub публичным или предоставьте доступ к email, затем попробуйте снова.', 'login' => [ 'title' => 'Войдите в свой аккаунт', 'description' => 'Введите email и пароль, чтобы войти', + 'description_without_password' => 'Войдите с учётной записью вашей организации, чтобы продолжить', 'page_title' => 'Вход', 'email' => 'Адрес email', 'password' => 'Пароль', diff --git a/lang/tr/auth.php b/lang/tr/auth.php index a8d3d81c4..63f016985 100644 --- a/lang/tr/auth.php +++ b/lang/tr/auth.php @@ -57,11 +57,18 @@ 'google_signup' => 'Google ile kayıt ol', 'github_login' => 'GitHub ile giriş yap', 'github_signup' => 'GitHub ile kayıt ol', + 'oidc_login' => ':provider ile giriş yap', + 'oidc_signup' => ':provider ile kayıt ol', + 'oidc_group_denied' => 'Hesabınız bu uygulamayı kullanabilecek bir grupta değil.', + 'oidc_email_missing' => 'Kimlik sağlayıcınız bir e-posta adresi döndürmedi.', + 'oidc_email_unverified' => 'E-posta adresiniz kimlik sağlayıcınızda doğrulanmamış.', + 'oidc_failed' => 'Çoklu oturum açma başarısız oldu. Lütfen tekrar deneyin.', 'github_email_unavailable' => 'GitHub\'dan e-postanız alınamadı. GitHub e-postanızı herkese açık yapın veya e-posta iznini verin, ardından tekrar deneyin.', 'login' => [ 'title' => 'Hesabınıza giriş yapın', 'description' => 'Giriş yapmak için e-posta ve parolanızı aşağıya girin', + 'description_without_password' => 'Devam etmek için kurum hesabınızla giriş yapın', 'page_title' => 'Giriş yap', 'email' => 'E-posta adresi', 'password' => 'Parola', diff --git a/lang/uk/auth.php b/lang/uk/auth.php index 8aa200bb1..070d9479c 100644 --- a/lang/uk/auth.php +++ b/lang/uk/auth.php @@ -55,11 +55,18 @@ 'google_signup' => 'Зареєструватися через Google', 'github_login' => 'Увійти через GitHub', 'github_signup' => 'Зареєструватися через GitHub', + 'oidc_login' => 'Увійти через :provider', + 'oidc_signup' => 'Зареєструватися через :provider', + 'oidc_group_denied' => 'Ваш обліковий запис не належить до групи, якій дозволено користуватися цим застосунком.', + 'oidc_email_missing' => 'Ваш постачальник ідентифікації не передав email.', + 'oidc_email_unverified' => 'Ваш email не підтверджено в постачальника ідентифікації.', + 'oidc_failed' => 'Не вдалося увійти. Спробуйте ще раз.', 'github_email_unavailable' => 'Не вдалося отримати ваш email з GitHub. Зробіть email публічним або надайте доступ до email, потім спробуйте ще раз.', 'login' => [ 'title' => 'Увійдіть до облікового запису', 'description' => 'Введіть email і пароль нижче, щоб увійти', + 'description_without_password' => 'Увійдіть за допомогою облікового запису вашої організації, щоб продовжити', 'page_title' => 'Вхід', 'email' => 'Адреса email', 'password' => 'Пароль', diff --git a/lang/zh/auth.php b/lang/zh/auth.php index ce799a35c..1c84e27b3 100644 --- a/lang/zh/auth.php +++ b/lang/zh/auth.php @@ -55,11 +55,18 @@ 'google_signup' => '使用 Google 注册', 'github_login' => '使用 GitHub 登录', 'github_signup' => '使用 GitHub 注册', + 'oidc_login' => '使用 :provider 登录', + 'oidc_signup' => '使用 :provider 注册', + 'oidc_group_denied' => '你的账户不属于任何可以使用此应用的群组。', + 'oidc_email_missing' => '你的身份提供商未返回邮箱地址。', + 'oidc_email_unverified' => '你的邮箱地址尚未在身份提供商处验证。', + 'oidc_failed' => '单点登录失败,请重试。', 'github_email_unavailable' => '无法从 GitHub 获取你的邮箱。请将你的 GitHub 邮箱设为公开,或授予邮箱权限后重试。', 'login' => [ 'title' => '登录你的账户', 'description' => '请在下方输入你的邮箱和密码以登录', + 'description_without_password' => '请使用你的组织账户登录以继续', 'page_title' => '登录', 'email' => '邮箱地址', 'password' => '密码', 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 @@