feat: allow SP initiated SAML (#12447)

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Shivam Mishra
2025-09-23 18:29:16 +05:30
committed by GitHub
parent 5c5abc24e3
commit 2e108653ae
11 changed files with 337 additions and 4 deletions

View File

@@ -22,6 +22,20 @@
},
"FORGOT_PASSWORD": "Forgot your password?",
"CREATE_NEW_ACCOUNT": "Create a new account",
"SUBMIT": "Login"
"SUBMIT": "Login",
"SAML": {
"LABEL": "Log in via SSO",
"TITLE": "Initiate Single Sign-on (SSO)",
"SUBTITLE": "Enter your work email to access your organization",
"BACK_TO_LOGIN": "Login via Password",
"WORK_EMAIL": {
"LABEL": "Work Email",
"PLACEHOLDER": "Enter your work email"
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
}
}
}
}

View File

@@ -1,3 +1,5 @@
import { parseBoolean } from '@chatwoot/utils';
const {
API_CHANNEL_NAME: apiChannelName,
API_CHANNEL_THUMBNAIL: apiChannelThumbnail,
@@ -15,6 +17,7 @@ const {
LOGO: logo,
LOGO_DARK: logoDark,
PRIVACY_URL: privacyURL,
IS_ENTERPRISE: isEnterprise,
TERMS_URL: termsURL,
WIDGET_BRAND_URL: widgetBrandURL,
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
@@ -30,8 +33,8 @@ const state = {
chatwootInboxToken,
deploymentEnv,
createNewAccountFromDashboard,
directUploadsEnabled: directUploadsEnabled === 'true',
disableUserProfileUpdate: disableUserProfileUpdate === 'true',
directUploadsEnabled: parseBoolean(directUploadsEnabled),
disableUserProfileUpdate: parseBoolean(disableUserProfileUpdate),
displayManifest,
gitSha,
hCaptchaSiteKey,
@@ -42,6 +45,7 @@ const state = {
privacyURL,
termsURL,
widgetBrandURL,
isEnterprise: parseBoolean(isEnterprise),
};
export const getters = {

View File

@@ -55,6 +55,7 @@ const model = defineModel({
<input
v-bind="$attrs"
v-model="model"
:name="name"
:type="type"
class="block w-full border-none rounded-md shadow-sm bg-n-alpha-black2 appearance-none outline outline-1 focus:outline focus:outline-1 text-n-slate-12 placeholder:text-n-slate-10 sm:text-sm sm:leading-6 px-3 py-3"
:class="{

View File

@@ -41,7 +41,14 @@ export const validateRouteAccess = (to, next, chatwootConfig = {}) => {
to.meta &&
to.meta.requireSignupEnabled;
if (!to.name || isAnInalidSignupNavigation) {
// Disable navigation to SAML login if enterprise is not enabled
// SAML route has an attribute (requireEnterprise) in it's definition
const isEnterpriseOnlyPath =
chatwootConfig.isEnterprise !== 'true' &&
to.meta &&
to.meta.requireEnterprise;
if (!to.name || isAnInalidSignupNavigation || isEnterpriseOnlyPath) {
next(frontendURL('login'));
return;
}

View File

@@ -85,6 +85,9 @@ export default {
showSignupLink() {
return parseBoolean(window.chatwootConfig.signupEnabled);
},
showSamlLogin() {
return this.globalConfig.isEnterprise;
},
},
created() {
if (this.ssoAuthToken) {
@@ -302,5 +305,13 @@ export default {
<Spinner color-scheme="primary" size="" />
</div>
</section>
<div v-if="showSamlLogin" class="mt-6 text-center">
<router-link
to="/app/login/sso"
class="inline-flex items-center text-sm font-medium text-n-brand hover:text-n-brand-dark"
>
{{ $t('LOGIN.SAML.LABEL') }}
</router-link>
</div>
</main>
</template>

View File

@@ -0,0 +1,102 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore } from 'vuex';
import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n';
// components
import FormInput from '../../components/Form/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const store = useStore();
const { t } = useI18n();
const credentials = ref({
email: '',
});
const loginApi = ref({
showLoading: false,
hasErrored: false,
});
const validations = {
credentials: {
email: {
required,
email,
},
},
};
const v$ = useVuelidate(validations, { credentials });
const globalConfig = computed(() => store.getters['globalConfig/get']);
const csrfToken = ref('');
onMounted(() => {
csrfToken.value =
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') || '';
});
</script>
<template>
<main
class="flex flex-col w-full min-h-screen py-20 bg-n-brand/5 dark:bg-n-background sm:px-6 lg:px-8"
>
<section class="max-w-5xl mx-auto">
<img
:src="globalConfig.logo"
:alt="globalConfig.installationName"
class="block w-auto h-8 mx-auto dark:hidden"
/>
<img
v-if="globalConfig.logoDark"
:src="globalConfig.logoDark"
:alt="globalConfig.installationName"
class="hidden w-auto h-8 mx-auto dark:block"
/>
<h2 class="mt-6 text-3xl font-medium text-center text-n-slate-12">
{{ t('LOGIN.SAML.TITLE') }}
</h2>
</section>
<section
class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
:class="{
'animate-wiggle': loginApi.hasErrored,
}"
>
<form class="space-y-5" method="POST" action="/api/v1/auth/saml_login">
<input type="hidden" name="authenticity_token" :value="csrfToken" I />
<FormInput
v-model="credentials.email"
name="email"
type="text"
:tabindex="1"
required
:label="t('LOGIN.SAML.WORK_EMAIL.LABEL')"
:placeholder="t('LOGIN.SAML.WORK_EMAIL.PLACEHOLDER')"
:has-error="v$.credentials.email.$error"
@input="v$.credentials.email.$touch"
/>
<NextButton
lg
type="submit"
class="w-full"
:tabindex="2"
:label="t('LOGIN.SAML.SUBMIT')"
:disabled="loginApi.showLoading"
:is-loading="loginApi.showLoading"
/>
</form>
</section>
<p class="mt-6 text-sm text-center text-n-slate-11">
<router-link to="/app/login" class="text-link text-n-brand">
{{ t('LOGIN.SAML.BACK_TO_LOGIN') }}
</router-link>
</p>
</main>
</template>

View File

@@ -1,6 +1,7 @@
import { frontendURL } from 'dashboard/helper/URLHelper';
import Login from './login/Index.vue';
import SamlLogin from './login/Saml.vue';
import Signup from './auth/signup/Index.vue';
import ResetPassword from './auth/reset/password/Index.vue';
import Confirmation from './auth/confirmation/Index.vue';
@@ -20,6 +21,12 @@ export default [
authError: route.query.error,
}),
},
{
path: frontendURL('login/sso'),
name: 'sso_login',
component: SamlLogin,
meta: { requireEnterprise: true },
},
{
path: frontendURL('auth/signup'),
name: 'auth_signup',

View File

@@ -36,6 +36,10 @@ en:
success: 'Channel reauthorized successfully'
not_required: 'Reauthorization is not required for this inbox'
invalid_channel: 'Invalid channel type for reauthorization'
auth:
saml:
invalid_email: 'Please enter a valid email address'
authentication_failed: 'Authentication failed. Please check your credentials and try again.'
messages:
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
reset_password_failure: Uh ho! We could not find any user with the specified email.

View File

@@ -325,6 +325,9 @@ Rails.application.routes.draw do
resources :webhooks, only: [:create]
end
# Frontend API endpoint to trigger SAML authentication flow
post 'auth/saml_login', to: 'auth#saml_login'
resource :profile, only: [:show, :update] do
delete :avatar, on: :collection
member do

View File

@@ -0,0 +1,49 @@
class Api::V1::AuthController < Api::BaseController
skip_before_action :authenticate_user!, only: [:saml_login]
before_action :find_user_and_account, only: [:saml_login]
def saml_login
return if @account.nil?
saml_initiation_url = "/auth/saml?account_id=#{@account.id}"
redirect_to saml_initiation_url, status: :temporary_redirect
end
private
def find_user_and_account
return unless validate_email_presence
find_saml_enabled_account
end
def validate_email_presence
@email = params[:email]&.downcase&.strip
return true if @email.present?
render json: { error: I18n.t('auth.saml.invalid_email') }, status: :bad_request
false
end
def find_saml_enabled_account
user = User.from_email(@email)
return render_saml_error unless user
account_user = find_account_with_saml(user)
return render_saml_error unless account_user
@account = account_user.account
end
def find_account_with_saml(user)
user.account_users
.joins(account: :saml_settings)
.where.not(saml_settings: { sso_url: [nil, ''] })
.where.not(saml_settings: { certificate: [nil, ''] })
.find { |account_user| account_user.account.feature_enabled?('saml') }
end
def render_saml_error
render json: { error: I18n.t('auth.saml.authentication_failed') }, status: :unauthorized
end
end

View File

@@ -0,0 +1,131 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Api::V1::Auth', type: :request do
let(:account) { create(:account) }
let(:user) { create(:user, email: 'user@example.com') }
before do
account.enable_features('saml')
account.save!
end
def json_response
JSON.parse(response.body, symbolize_names: true)
end
describe 'POST /api/v1/auth/saml_login' do
context 'when email is blank' do
it 'returns bad request' do
post '/api/v1/auth/saml_login', params: { email: '' }
expect(response).to have_http_status(:bad_request)
end
end
context 'when email is nil' do
it 'returns bad request' do
post '/api/v1/auth/saml_login', params: {}
expect(response).to have_http_status(:bad_request)
end
end
context 'when user does not exist' do
it 'returns unauthorized with generic message' do
post '/api/v1/auth/saml_login', params: { email: 'nonexistent@example.com' }
expect(response).to have_http_status(:unauthorized)
end
end
context 'when user exists but has no SAML enabled accounts' do
before do
create(:account_user, user: user, account: account)
end
it 'returns unauthorized' do
post '/api/v1/auth/saml_login', params: { email: user.email }
expect(response).to have_http_status(:unauthorized)
end
end
context 'when user has account without SAML feature enabled' do
let(:saml_settings) { create(:account_saml_settings, account: account) }
before do
saml_settings
create(:account_user, user: user, account: account)
account.disable_features('saml')
account.save!
end
it 'returns unauthorized' do
post '/api/v1/auth/saml_login', params: { email: user.email }
expect(response).to have_http_status(:unauthorized)
end
end
context 'when user has valid SAML configuration' do
let(:saml_settings) do
create(:account_saml_settings, account: account)
end
before do
saml_settings
create(:account_user, user: user, account: account)
end
it 'redirects to SAML initiation URL' do
post '/api/v1/auth/saml_login', params: { email: user.email }
expect(response).to have_http_status(:temporary_redirect)
expect(response.location).to include("/auth/saml?account_id=#{account.id}")
end
it 'handles email case insensitivity' do
post '/api/v1/auth/saml_login', params: { email: user.email.upcase }
expect(response).to have_http_status(:temporary_redirect)
expect(response.location).to include("/auth/saml?account_id=#{account.id}")
end
it 'strips whitespace from email' do
post '/api/v1/auth/saml_login', params: { email: " #{user.email} " }
expect(response).to have_http_status(:temporary_redirect)
expect(response.location).to include("/auth/saml?account_id=#{account.id}")
end
end
context 'when user has multiple accounts with SAML' do
let(:account2) { create(:account) }
let(:saml_settings1) do
create(:account_saml_settings, account: account)
end
let(:saml_settings2) do
create(:account_saml_settings, account: account2)
end
before do
account2.enable_features('saml')
account2.save!
saml_settings1
saml_settings2
create(:account_user, user: user, account: account)
create(:account_user, user: user, account: account2)
end
it 'redirects to the first SAML enabled account' do
post '/api/v1/auth/saml_login', params: { email: user.email }
expect(response).to have_http_status(:temporary_redirect)
returned_account_id = response.location.match(/account_id=(\d+)/)[1].to_i
expect([account.id, account2.id]).to include(returned_account_id)
end
end
end
end