Skip to content

Single Sign-On (SAML) ​

Overview ​

Workspace owners on a plan that includes SSO can connect a SAML identity provider (Microsoft Entra ID, Google Workspace, Okta or any other SAML provider) so members sign in with their company account. The frontend never handles SAML itself: the API exposes the service-provider values, syncs the IdP metadata, runs a connection test and records sign-in activity; collage-admin drives that through a four-step setup wizard, a summary page, an SSO tile on the login page and a 428 re-check in the fetch plugin.

Shipped in PR #110 (branch sunil/enterprise-sso-frontend, merged 2026-10-26), with follow-ups in PR #112 and PR #114.

Two flags on the workspace object from /user (accessibleWorkspaces[], type Workspace in app/types/layout.ts) control everything:

FlagChecked byEffect when false
is_owneruseHelpers().canManageSsoSettings(workspace)can-access-sso-settings raises a 401 fatal error; the settings menu hides Single Sign-On
sso_alloweduseHelpers().isSsoAllowed(workspace)The SSO page shows an upgrade state (showUpgrade) and loads nothing; the settings menu hides Single Sign-On; the members page gets ssoAllowed = false

Both helpers compare with === true, so a missing flag counts as false.

File Structure ​

FileRole
app/pages/[workspace_id]/workspace-settings/sso/index.vueSummary page (ROUTE_WORKSPACE_SETTINGS_SSO, workspace_id-workspace-settings-sso)
app/pages/[workspace_id]/workspace-settings/sso/setup.vueSetup wizard and edit mode (ROUTE_WORKSPACE_SETTINGS_SSO_SETUP, workspace_id-workspace-settings-sso-setup)
app/composables/core/pages/useSsoSettingsPage.tsPage state shared by both pages: load, view resolution, sync polling, test, save, activity
app/composables/core/pages/useSsoProviderDetails.tsActive provider, brand-token substitution, SP values, copy-to-clipboard
app/composables/core/pages/useSsoProviderChoice.tsRemembers the chosen provider per workspace and user in localStorage
app/composables/api/useSsoApi.tsAPI calls and redirect URL builders
app/stores/sso.tsuseSsoStore: connection cache keyed by workspace ID
app/constants/sso.tsSSO_SETUP_STEP_COUNT, SSO_SETUP_STEPS, SSO_BRAND_TOKEN, SSO_PROVIDERS
app/types/sso.tsAll SSO types
app/middleware/can-access-sso-settings.tsRoute guard
app/components/org-settings/Sso*.vueSsoPageHeader, SsoSetupStepper, SsoProviderLogo, SsoGuideDrawer, SsoGuideFlow, SsoActivityPanel (Components)
app/assets/scss/components/_sso-settings.scssPage styles
app/pages/index.vue, app/pages/social-login.vueSSO tile, work-email step, callback and blocked screen
app/plugins/fetch.ts428 SSO re-check redirect
app/utils/redirect.tsisSafeRedirectUrl

Both pages use general-settings-layout with middleware ['auth-check', 'check-workspace', 'can-access-sso-settings', 'check-if-suspended'], and both routes are in SIDEBAR_MINI_TARGET_ROUTES (useSidebarMiniState), so the sidebar collapses to mini on them.

API Contract ​

All calls go through $api and are wrapped in useApiRequestState().track.

FunctionMethod and pathBody / queryReturns
resolveSso(email)POST auth/sso/resolve{ email }SsoResolveResponse: url_slug, workspace_name, provider, redirect_url
getConnection(workspaceId)GET sso/connection?workspace_id=SsoConnection
saveConnection(payload)POST sso/connectionSsoConnectionUpdatePayload: workspace_id, optional metadata_url, allowed_domains, enabled, show_on_custom_domainSsoConnection
syncConnection(workspaceId, metadataUrl)POST sso/sync{ workspace_id, metadata_url }SsoSyncResponse: status, optional connection
syncConnectionFile(workspaceId, file)POST sso/syncFormData: workspace_id, metadata_fileSsoSyncResponse
testConnection(workspaceId)POST sso/test{ workspace_id }SsoTestResponse: ok, message, certificate_expires_at
getActivity(params)GET sso/activity?workspace_id=&page=&per_page=page default 1, per_page default 20SsoActivityResponse: data, current_page, last_page, total

Two helpers build the browser redirect to the IdP; neither calls the API:

  • buildRedirectUrl(urlSlug, returnHost, returnPath) returns <apiBaseUrl without trailing slashes>/auth/sso/<encodeURIComponent(urlSlug)>/redirect?return_host=<host>&return_path=<path>.
  • withReturnParams(url, returnHost, returnPath) parses url against returnHost, sets return_host and return_path, and returns the string, or null if the URL cannot be parsed.

Example (placeholder values):

text
https://api.example.test/api/auth/sso/acme/redirect?return_host=https%3A%2F%2Fapp.example.test&return_path=%2F

SsoConnection ​

FieldTypeNotes
providerstring
enabledbooleanThe Status switch
is_readybooleanTrue once metadata has synced; unlocks the Status switch and step 2 Continue
metadata_url, metadata_source, metadata_file_name, metadata_uploaded_atmetadata_source is 'url', 'file' or null
idp_entity_id, idp_sso_urlstring | nullFrom the synced metadata
certificate_count, certificate_expires_atcertificate_expires_at is refreshed from a test response
allowed_domainsstring[]Email domains that may sign in
email_attribute, stable_id_attributestring | null
default_rolestringRole given to accounts created on first sign-in
show_on_custom_domainbooleanAlso show the SSO tile on the workspace's custom domain
last_synced_at, last_sync_status, last_sync_messagelast_sync_status: never, queued, running, ok, failed
recheck_days, stale_daysnumberUI falls back to 7 and 30
sp{ acs_url, entity_id, metadata_url }Values the admin pastes into the IdP
custom_domain{ url, domain_status, verified } | null
stats{ sso_members, auto_created, blocked_7d, stale_30d }Summary stat boxes

Sign-in Flow ​

check-branding now returns an sso object (SsoBranding: enabled, url_slug), which pages/index.vue stores as ssoBranding and useBrand() exposes as brand.sso (null on Collage defaults). The SSO tile shows when the host is the primary domain or ssoBranding.enabled === true. Google, Microsoft and passkey stay primary-domain only, so a white-label custom domain with SSO enabled shows only the SSO tile. With four tiles visible the row gets the tiles-4 class.

The IdP returns to /social-login. With ?access_token the page follows the same path as Google and Microsoft (apply session, fetch user, nextStep()), showing a lock icon and the passkey spinner colour #6473FF. Without a token it reads ?reason= and a base64 ?message= (decoded as UTF-8 via TextDecoder). For SSO with a decodable message it stays on a "We could not sign you in" screen with the message and a Back to login button, because a snackbar would be gone before the user read why they were blocked. Every failure is tracked as user login-failed with method and reason (default unauthorized).

The email regex is ^[^\s@]+@[^\s@]+\.[^\s@]+$.

428 re-check during a session ​

When any API call returns 428 (status or body code) on the client with data.redirect_url, fetch.ts sets window.location.href to that URL so the IdP can re-verify the member, and throws an error with isSsoRecheck: true. The URL must pass isSafeRedirectUrl(url, window.location.origin), which only accepts http: and https: after resolving relative URLs. Any other value falls through to the normal error rules. See Plugins.

Settings Pages ​

Access and menu entry ​

useAccountSettingsLeftMenu adds a Single Sign-On entry at index 7 of generalSettings, visible only when canManageSsoSettings(workspace) && isSsoAllowed(workspace). Unlike the other entries it does not depend on hasGeneralSettingsAccess. Its matchRoutes are both SSO routes, and while on either one the menu link keeps the current route name and query, so clicking the active entry from setup?edit=domains does not jump to the summary. On workspace switch, an SSO route stays on ROUTE_WORKSPACE_SETTINGS_SSO if the new workspace passes both checks; otherwise it takes the normal settings redirect.

Load and view resolution ​

useSsoSettingsPage(view) with view 'summary' or 'setup':

  1. Waits for the workspace from accessibleWorkspaces. If isSsoAllowed is false it stops and the page shows the upgrade state.
  2. If useSsoStore().connectionFor(workspaceId) has a cached connection it applies it immediately and refreshes in the background (refreshConnection). The refresh is applied only if the form is not dirty and no sync is running. Otherwise it calls getConnection with a loading state.
  3. On the first applied connection it decides where the user belongs. A connection is configured when is_ready === true and allowed_domains is not empty.
ViewConfigured?edit=Result
summarynoreplace-navigate to setup
summaryyesstay
setupyesdomains or statusstay in edit mode on step 3 or 4
setupyesnone or invalidreplace-navigate to summary
setupnoanydrop the query; start at step 3 if is_ready, else step 1

While a redirect is pending, contentLoading stays true so the wrong page never flashes. On the summary, a later connection whose configured state flips re-runs this table.

The tab is in the URL: ?tab=activity selects Sign-in activity (tab 1); Connection (tab 0) removes the key. Tab changes use replace navigation.

Setup wizard ​

SSO_SETUP_STEPS: 1 Identity provider, 2 Connect, 3 Who can sign in, 4 Test and go live (SSO_SETUP_STEP_COUNT = 4).

StepLeave rule (canLeaveStep)Blocked message
1 Identity provideralways
2 Connectconnection.is_ready === trueContinue unlocks after a successful sync.
3 Who can sign inat least one allowed domainAdd at least one domain to continue.
4 Test and go livea test has run this session (tested)Run the connection test to enable Save.

The stepper only lets users jump back to steps they have reached (maxVisitedStep) and is locked in edit mode. On step 4 the primary button reads Save and go live when Status is on, else Save; it saves only if the form is dirty, then goes to the summary. Step 4 also shows a summary sentence built from the allowed domains, provider short name, default_role and recheck_days.

Step 1 lists SSO_PROVIDERS: microsoft (Microsoft Entra ID, gives a metadata URL), google (Google Workspace, gives a file), okta (Okta, URL) and other (Other SAML provider, either). The choice is stored in localStorage under sso-provider-choice:<workspaceId>:<userId>; an unknown stored ID falls back to the first provider. Arrow-key focus wraps around the list (focusProviderAt). Provider texts contain {brand} (SSO_BRAND_TOKEN), replaced with Collage on unbranded hosts, the brand name on white-label hosts, or the app when a branded host has no name. Each provider has a step-by-step guide opened in SsoGuideDrawer, titled Connect <appName> and <provider name>.

Step 2 shows the SP values to paste into the IdP (for Microsoft: Identifier (Entity ID) from sp.entity_id and Reply URL (Assertion Consumer Service URL) from sp.acs_url), then accepts the IdP metadata.

Metadata rules:

  • URL: required unless a file is chosen or a metadata source already exists; must parse as a URL with protocol https: (Enter an https URL).
  • File: must end in .xml (Choose the XML metadata file your identity provider gave you) and be at most 2 MB (That file is larger than 2 MB). Drag-and-drop and file input both go through acceptMetadataFile.
  • canSync is true when not already syncing and either a file is chosen or a valid URL is entered. A file takes priority over the URL and is cleared after upload.

Metadata sync ​

A poll that throws stops polling with reason status_check. Polls never overlap (pollInFlight). A settled poll result is applied to the form only when the form is not dirty; otherwise it only updates the cache, so typed domains are not overwritten. The sync alert is hidden when its message equals last_sync_message already shown on the page, to avoid saying the same thing twice. Polling stops on unmount. Starting a sync resets tested, so a re-sync always needs a new test before Save.

The sync status line reads Synced <MMM D, h:mm a> for ok with a usable date, the API message (or Last sync failed) for failed, and Not synced yet, Sync queued or Sync running otherwise.

Allowed domains ​

addDomain normalizes the input before validating, so admins can paste an email, URL or host:

  1. trim and lowercase
  2. strip a scheme (https://)
  3. cut at the first /, ? or #
  4. keep what follows the last @
  5. drop a :port
  6. strip trailing dots

Examples: https://Acme.com/login becomes acme.com; [email protected] becomes acme.com.

The result must match ^(?!-)[a-z0-9-]+(\.[a-z0-9-]+)+$ (case-insensitive). Errors: Enter a domain, Enter a valid domain, for example acme.com, This domain is already on the list.

Test and save ​

runTest calls POST sso/test and shows message as a success or error alert depending on ok; a returned certificate_expires_at updates the cached connection. tested becomes true only when the request succeeds (even if ok is false).

The Status switch (enabled) is disabled until is_ready, with the tooltip Sync the identity provider metadata first. The switch unlocks once the connection is ready.

canSave requires: not saving, form dirty (JSON compare of enabled, show_on_custom_domain, trimmed metadata_url, allowed_domains against the last applied connection), no metadata URL error, and at least one domain. metadata_url is sent only when non-empty. After a save the activity list is marked stale (and reloaded if its tab is open) and the snackbar shows the API message or Single sign-on settings saved..

Summary page and edit mode ​

The summary shows four stat boxes (Members using SSO, Created by first sign-in, Blocked, last <recheck_days> days, No sign-in in <stale_days> days), the provider, allowed domains (first domain plus +N more), Status, and the registered values (SP values, <appName> metadata if not already listed, then either IdP metadata file or IdP metadata URL). The header pill is Live or Connected · off.

Edit buttons open setup?edit=domains (step 3) or setup?edit=status (step 4). In edit mode, Cancel returns to the summary, Save saves if dirty and returns.

Sign-in activity ​

Loaded the first time the activity tab opens, 20 rows per page (ACTIVITY_PER_PAGE), appended on scroll via loadMoreActivity while current_page < last_page. Concurrent loads are dropped. Outcomes: ok/signed_in Signed in, blocked Blocked, system System.

Members List ​

MemberUser gained sign_in_method, sso_provider and sso_last_verified_at. The members page shows a Sign-in method column on every tab: Password unless sign_in_method === 'sso', then Microsoft SSO, Google SSO, Okta SSO by sso_provider (case-insensitive), else SSO. Last Login (active users only) is parsed as UTC and shown as MMM D, YYYY h:mm a, with a tooltip. useMembersPage exposes ssoAllowed for the current workspace. The list request now builds its query with URLSearchParams, so search text with & or # no longer breaks the URL (16a2e85f).

Analytics ​

ConstantEventProperties
WORKSPACE_SSO_METADATA_SYNCEDworkspace sso-metadata-syncedsource: url or file
WORKSPACE_SSO_SYNC_FAILEDworkspace sso-sync-failedsource, reason: failed, timeout, request, status_check; status for request errors
WORKSPACE_SSO_TESTEDworkspace sso-testedok; status on request error
WORKSPACE_SSO_SETTINGS_UPDATEDworkspace sso-settings-updatedallowed_domains (count), metadata_source, show_on_custom_domain
WORKSPACE_SSO_TOGGLEDworkspace sso-toggledenabled; sent only when Status changed in that save

Edge Cases ​

  • Plan removed later: the route guard only checks is_owner; an owner without sso_allowed reaches the page and sees the upgrade state, not an error.
  • Cache and redirects: a cached connection is applied before the network call, so a configured workspace goes straight to the summary without a spinner.
  • Unsafe redirect URLs: both the login resolve flow and the 428 handler refuse anything that is not http/https.
  • Branded hosts without a name: guide text says the app instead of an empty string.
  • Copy feedback: copying shows <label> copied to clipboard and swaps to a check icon for 1.4 s (COPIED_RESET_MS); a clipboard failure shows Failed to copy.