diff --git a/README.md b/README.md index 7597eede..6060b2cd 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ - ๐Ÿ‘ฅ **Segmentation** Create dynamic lists to target users matching any event or user based criteria in real time. - ๐Ÿ“ฃ **Campaigns** Build campaigns that target specific lists of users and go out at pre-defined times. - ๐Ÿ”— **Integrations** Connect Lunogram to your applications using our easy to use SDKs and APIs. -- ๐Ÿ”’ **Secure** OpenID Connect single sign-on, configured like any other login driver, no add-ons. SAML is not supported. +- ๐Ÿ”’ **Secure** OpenID Connect and SAML single sign-on, configured like any other login driver, no add-ons. - ๐Ÿ“ฆ **Open Source** Easy to setup and get running in your own cloud. ## ๐Ÿš€ Deployment @@ -152,13 +152,15 @@ the address, not by holding the link. Invitations expire after 48 hours. See ### Signing in with your company's identity provider -Point the deployment at your own OpenID Connect providers โ€” Okta, Entra ID, -Google Workspace, Keycloak, Auth0 โ€” and your staff sign in there. It is a driver -like the others: add `oidc` to `AUTH_DRIVER` and configure it. One provider is -configured from the environment; several are declared in the configuration file. +Point the deployment at your own identity providers โ€” Okta, Entra ID, Google +Workspace, Keycloak, Auth0, ADFS, Shibboleth โ€” and your staff sign in there. +Both protocols are drivers like the others: add `oidc` or `saml` to +`AUTH_DRIVER` and configure it. One provider is configured from the environment; +several are declared in the configuration file. -SAML is deliberately not supported, and is not planned. OpenID Connect is what -the platform speaks. +Prefer OpenID Connect where your provider offers both. It has discovery, so a +key rotation is picked up on its own, and it carries an `email_verified` claim +the platform can act on. SAML is there for the directories that only speak SAML. #### 1. Register the application with your identity provider @@ -265,6 +267,153 @@ editable claim such as `preferred_username` or `upn` leaves addresses unverified โ€” and therefore never linked to an existing account โ€” until `AUTH_OIDC_EMAIL_VERIFIED_CLAIM` names the claim that attests them. +### Signing in with SAML + +Use this where your identity provider does not offer OpenID Connect. Everything +after the proof is identical: a SAML login lands an admin in exactly the same +place, and an admin may hold both identities at once. + +#### 1. Register this deployment with your identity provider + +Create a SAML 2.0 application. The two values it asks for are published at: + +``` +https:///api/auth/saml/default/metadata +``` + +Most providers accept that URL directly. For one that wants the fields typed in, +they are the entity id (`AUTH_SAML_SP_ENTITY_ID`, defaulting to that metadata +URL) and the assertion consumer service URL: + +``` +https:///api/auth/saml/default/acs +``` + +Both derive from `PUBLIC_URL` and the provider's id โ€” `default` for the +single-provider form โ€” and never from anything in a request. A second provider +registers `.../api/auth/saml//acs`. + +Ask the provider to send a **persistent** NameID or an email address. A +transient NameID names a session rather than a person, so it is refused: taken +as an identity it would provision a new admin on every sign-in. + +`PUBLIC_URL` must be `https`. The cookie that ties a login to the browser that +started it has to be `SameSite=None` to survive the cross-site form POST your +provider answers with, and browsers refuse a `SameSite=None` cookie that is not +`Secure`. A plaintext deployment is refused at startup rather than run without +that binding. + +#### 2. Configure the driver + +``` +AUTH_DRIVER=basic,saml +AUTH_SAML_ENTITY_ID=http://www.okta.com/exk... +AUTH_SAML_METADATA_URL=https://example.okta.com/app/exk.../sso/saml/metadata +``` + +`AUTH_SAML_ENTITY_ID` is what your provider stamps as the `Issuer` of every +response, compared exactly. It is an opaque URI โ€” Entra publishes an `https` +URL, others publish a `urn:` โ€” so it is never parsed or normalised. + +`AUTH_SAML_METADATA_URL` is re-read as it expires, so a certificate rotation is +picked up without a restart. Where the deployment cannot reach it โ€” an egress +policy, or a provider that only offers a file โ€” give the two fields instead: + +``` +AUTH_SAML_SSO_URL=https://example.okta.com/app/exk.../sso/saml +AUTH_SAML_CERTIFICATE="$(cat idp.pem)" +``` + +Set the metadata URL **or** that pair, not both. Either way the sign-on endpoint +is held to the deployment's outbound policy, which refuses plaintext, so +`AUTH_SAML_SSO_URL` must be `https`. `AUTH_SAML_CERTIFICATE` holds the provider's +signing certificates as PEM; paste both during a rotation. Anything after the +last certificate is refused rather than ignored, so a bundle that was truncated +on its way into the environment is not read as a bundle of one. + +The rest have defaults worth knowing about: + +| Variable | Default | What it is | +| --- | --- | --- | +| `AUTH_SAML_EMAIL_ATTRIBUTE` | the common claim URIs, then the NameID when its format is an address | Which attribute carries the address | +| `AUTH_SAML_GIVEN_NAME_ATTRIBUTE` | the common claim URIs | Which attribute carries the first name | +| `AUTH_SAML_FAMILY_NAME_ATTRIBUTE` | the common claim URIs | Which attribute carries the last name | +| `AUTH_SAML_NAME_ID_FORMAT` | whatever the provider is configured for | What the request asks for | +| `AUTH_SAML_TRUST_EMAIL` | `true` | Whether an address this provider asserts may link to an existing account | +| `AUTH_SAML_SIGN_REQUESTS` | signed whenever a key pair is configured | Whether outgoing requests are signed | + +SAML has no standard attribute names, so the address is looked for under the +WS-Federation claim URI Entra and ADFS send, the X.500 object identifier +Shibboleth sends, and the bare words most others send. A directory using +something else names it explicitly โ€” and a name you set is used on its own, so a +typo reads as a missing address rather than quietly finding a different one. + +#### Signed requests and encrypted assertions + +Providers that require a signed `AuthnRequest`, or that encrypt their +assertions, need this deployment to hold a key pair: + +``` +AUTH_SAML_SP_CERTIFICATE="$(cat sp.pem)" +AUTH_SAML_SP_PRIVATE_KEY="$(cat sp.key)" +``` + +A self-signed pair is what these are; they authenticate this deployment to your +provider and nothing else. Both or neither โ€” one on its own is refused at +startup. They are published in the metadata above, so registering the metadata +URL is all your provider needs. + +Do not reuse `AUTH_CONSOLE_SIGNING_KEY` here. That key mints this deployment's +sessions; this one is handed to third parties. + +#### More than one provider + +Declared in the configuration file, exactly as the OpenID Connect ones are: + +```yaml +auth: + drivers: [basic, saml] + saml: + sp_entity_id: https://console.example.com/saml + sp_certificate: ${SAML_SP_CERTIFICATE} + sp_private_key: ${SAML_SP_PRIVATE_KEY} + providers: + - id: staff + name: Staff directory + entity_id: http://www.okta.com/exk... + metadata_url: https://example.okta.com/app/exk.../sso/saml/metadata + - id: contractors + name: Contractors + entity_id: urn:partner:idp + sso_url: https://idp.partner.example/sso + certificate: ${PARTNER_IDP_CERTIFICATE} + allowed_domains: [partner.example] +``` + +`sp_entity_id`, `sp_certificate` and `sp_private_key` belong to the deployment +rather than to a provider: you are one service provider however many directories +you federate with. Set `AUTH_SAML_*` **or** `auth.saml.providers` for the +providers themselves, not both. + +`allowed_domains` means what it does for OpenID Connect, and matters more here. + +#### What SAML does not carry + +There is no `email_verified` in SAML. Nothing an assertion can carry attests an +address the way an OpenID Connect claim does, so the attestation is yours: by +configuring a provider you are saying it is authoritative for the addresses it +asserts, which is true of a corporate directory. That is why `trust_email` +defaults to `true`, and why `allowed_domains` is the thing to reach for when it +should only speak for some addresses. Set `trust_email: false` for a directory +whose users can edit their own address, and logins through it will only ever +reach an account it provisioned itself. + +Two more things are deliberately absent. Identity-provider-initiated sign-on โ€” +the tile in your provider's dashboard โ€” is refused: an unsolicited assertion +answers no request this deployment issued, so the RelayState, the browser +binding and `InResponseTo` all have nothing to check. Start from the login page +instead. Single logout is not implemented; signing out ends the session here. + For full documentation on the platform and more information on deployment, check out our docs. **[Explore the Docs ยป](https://docs.lunogram.com)** diff --git a/console/public/locales/en.json b/console/public/locales/en.json index 752d30dc..ca871012 100644 --- a/console/public/locales/en.json +++ b/console/public/locales/en.json @@ -1121,7 +1121,7 @@ "rule_step_visit_this_step": "this step", "rule_step_visit_times": "times", "rule_step_visit_hint": "The visit in progress is counted, so a user standing on this step for the third time compares against 3.", - "auth_driver_oidc": "Continue with single sign-on", + "auth_driver_sso": "Continue with single sign-on", "sso_instructions": "You will be sent to your organization's identity provider to sign in.", "sso_continue": "Continue with single sign-on", "sso_error_expired": "That sign-in request has expired or was already answered in another browser. Please start again.", @@ -1129,5 +1129,6 @@ "sso_error_email": "Your identity provider returned no email address.", "sso_error_failed": "Single sign-on failed. Please try again.", "sso_choose_provider": "Choose how you would like to sign in.", - "sso_error_domain": "Your identity provider returned an address it is not configured to sign in." + "sso_error_domain": "Your identity provider returned an address it is not configured to sign in.", + "sso_error_transient": "Your identity provider returned a temporary identifier rather than a lasting one, so there is no account to sign in to. Ask your administrator to send a persistent NameID or an email address." } diff --git a/console/src/api.ts b/console/src/api.ts index 9f93f0df..9fad2f14 100644 --- a/console/src/api.ts +++ b/console/src/api.ts @@ -42,6 +42,7 @@ import type { SubjectOrganizationCreateParams, SubjectOrganizationUpdateParams, SubscriptionUpdateParams, + SsoDriver, Template, TemplateCreateParams, TemplateUpdateParams, @@ -223,16 +224,21 @@ const api = { changePassword: async (current_password: string, password: string) => { await client.post("/admin/profile/password", { current_password, password }) }, - ssoProviders: async () => + // Providers are listed per protocol, because the two are separate + // drivers a deployment enables independently and each answers 404 when + // its own is off. + ssoProviders: async (driver: SsoDriver) => await client - .get>("/auth/oidc/providers") + .get>(`/auth/${driver}/providers`) .then((r) => r.data), // ssoStart is a full-page navigation rather than an XHR: the browser has // to follow the redirect to the identity provider and come back with a - // session cookie. - ssoStart: (provider: string, redirect: string) => { + // session cookie. A SAML provider that only takes the HTTP-POST binding + // answers with a self-submitting form instead of a redirect, which the + // same navigation renders. + ssoStart: (driver: SsoDriver, provider: string, redirect: string) => { const base = env.api.baseURL.replace(/\/$/, "") - window.location.href = `${base}/auth/oidc/${encodeURIComponent(provider)}/start?r=${encodeURIComponent(redirect)}` + window.location.href = `${base}/auth/${driver}/${encodeURIComponent(provider)}/start?r=${encodeURIComponent(redirect)}` }, clerkAuth: async (token: string) => { await client.post( diff --git a/console/src/oapi/management.generated.ts b/console/src/oapi/management.generated.ts index 18758fe0..8460f276 100644 --- a/console/src/oapi/management.generated.ts +++ b/console/src/oapi/management.generated.ts @@ -2750,6 +2750,87 @@ export interface paths { patch?: never; trace?: never; }; + "/api/auth/saml/providers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List the deployment's SAML providers + * @description The SAML providers the login page may offer, in the order the operator declared them. These are the deployment's own providers; there is no other tenant whose existence could leak, so this needs no credential. + */ + get: operations["listSAMLProviders"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/saml/{provider}/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Begin a SAML login + * @description Sends the browser to the named identity provider, having stored the AuthnRequest ID server-side under a short lifetime and set a binding cookie that ties the request to this browser. Answers 302 when the provider takes the HTTP-Redirect binding, and 200 with a self-submitting form when it only takes HTTP-POST. The assertion consumer service URL handed to the provider derives from the deployment's public URL and the provider's id, and is never taken from a request parameter. + */ + get: operations["startSAMLLogin"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/saml/{provider}/acs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Complete a SAML login + * @description The assertion consumer service. Redeems the RelayState, which is single-use and deleted as it is read and must have been issued for this provider; requires the binding cookie to match the one the login was started with; and proves the response against the provider's signing certificates, its entity id, the InResponseTo of the request this deployment issued, the destination, the audience and the assertion's own validity window. The assertion ID is recorded so the same assertion can never be accepted twice. Unsolicited (identity-provider-initiated) responses are refused. The browser is then redirected into the console, with or without a session. + * Both form fields are supplied by the identity provider rather than by a caller, and neither is marked required: a response missing either is refused by the handler, which answers the navigation with a redirect the person can read rather than a problem document they cannot. RelayState is absent on exactly the unsolicited responses this deployment declines to accept. + */ + post: operations["completeSAMLLogin"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/saml/{provider}/metadata": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * This deployment's SAML service provider metadata + * @description The metadata an operator registers with their identity provider: this deployment's entity id, its assertion consumer service URL, and the public half of its signing certificate. It carries no secret. + */ + get: operations["getSAMLMetadata"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -5615,6 +5696,12 @@ export interface components { /** @description What the login page calls it */ name: string; }; + SAMLProvider: { + /** @description Names the provider in its login URLs */ + id: string; + /** @description What the login page calls it */ + name: string; + }; }; responses: { /** @description Error response */ @@ -5792,6 +5879,8 @@ export interface components { IncludeDeleted: boolean; /** @description The single sign-on provider */ OIDCProviderID: string; + /** @description The SAML single sign-on provider */ + SAMLProviderID: string; }; requestBodies: never; headers: never; @@ -10957,4 +11046,112 @@ export interface operations { default: components["responses"]["Error"]; }; }; + listSAMLProviders: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The configured providers */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SAMLProvider"][]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + startSAMLLogin: { + parameters: { + query?: { + /** @description Where the console should land once the session exists. Reduced to a same-site path. */ + r?: string; + }; + header?: never; + path: { + /** @description The SAML single sign-on provider */ + provider: components["parameters"]["SAMLProviderID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A self-submitting form carrying the request over the HTTP-POST binding */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/html": string; + }; + }; + /** @description Redirect to the identity provider */ + 302: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Error"]; + }; + }; + completeSAMLLogin: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The SAML single sign-on provider */ + provider: components["parameters"]["SAMLProviderID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/x-www-form-urlencoded": { + SAMLResponse?: string; + RelayState?: string; + }; + }; + }; + responses: { + /** @description Redirect into the console */ + 302: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Error"]; + }; + }; + getSAMLMetadata: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The SAML single sign-on provider */ + provider: components["parameters"]["SAMLProviderID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The service provider metadata */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/samlmetadata+xml": string; + }; + }; + default: components["responses"]["Error"]; + }; + }; } diff --git a/console/src/types.ts b/console/src/types.ts index a59b5a75..9d0ed445 100644 --- a/console/src/types.ts +++ b/console/src/types.ts @@ -322,12 +322,18 @@ export interface SearchResult { export type AuditFields = "created_at" | "updated_at" | "deleted_at" -export type AuthDriver = "basic" | "clerk" | "oidc" +export type AuthDriver = "basic" | "clerk" | "oidc" | "saml" + +// SsoDriver is the subset of drivers a login starts by navigating away to an +// identity provider. They share the login page's provider list and its failure +// reasons; what differs is the protocol behind the endpoints. +export type SsoDriver = "oidc" | "saml" export const AUTH_DRIVERS = { BASIC: "basic" as const, CLERK: "clerk" as const, OIDC: "oidc" as const, + SAML: "saml" as const, } export const organizationRoles = ["member", "admin", "owner"] as const diff --git a/console/src/views/auth/Login.tsx b/console/src/views/auth/Login.tsx index 478025bf..1f986c13 100644 --- a/console/src/views/auth/Login.tsx +++ b/console/src/views/auth/Login.tsx @@ -8,7 +8,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { loginSchema } from "@/validation/auth/login" import api from "../../api" -import { type AuthDriver, AUTH_DRIVERS } from "../../types" +import { type AuthDriver, type SsoDriver, AUTH_DRIVERS } from "../../types" import { validateRedirect } from "@/lib/validate-redirect" import AuthCard from "./AuthCard" import PasswordField from "./PasswordField" @@ -39,20 +39,56 @@ const SSO_ERRORS: Record = { denied: "sso_error_denied", domain: "sso_error_domain", email: "sso_error_email", + transient: "sso_error_transient", exchange: "sso_error_failed", failed: "sso_error_failed", } -const SUPPORTED_DRIVERS: AuthDriver[] = [AUTH_DRIVERS.BASIC, AUTH_DRIVERS.CLERK, AUTH_DRIVERS.OIDC] +const SUPPORTED_DRIVERS: AuthDriver[] = [ + AUTH_DRIVERS.BASIC, + AUTH_DRIVERS.CLERK, + AUTH_DRIVERS.OIDC, + AUTH_DRIVERS.SAML, +] + +// The drivers that start a login by navigating away to an identity provider. +const SSO_DRIVERS: SsoDriver[] = [AUTH_DRIVERS.OIDC, AUTH_DRIVERS.SAML] + +// A deployment may enable both protocols, but "sign in with OpenID Connect" and +// "sign in with SAML" is not a choice anybody wants to make: which protocol a +// directory speaks is the operator's problem, not the problem of the person +// signing in. The two drivers therefore collapse into one choice on the method +// picker, and the providers behind them into one list of buttons that carry +// their own protocol. +const SSO_CHOICE = "sso" as const + +type DriverChoice = AuthDriver | typeof SSO_CHOICE + +interface SsoProvider { + id: string + name: string + driver: SsoDriver +} + +// driverChoices replaces however many single sign-on drivers are enabled with +// the single collapsed choice, keeping the operator's declared order otherwise. +function driverChoices(drivers: AuthDriver[]): DriverChoice[] { + const choices: DriverChoice[] = [] + for (const driver of drivers) { + const choice: DriverChoice = SSO_DRIVERS.includes(driver as SsoDriver) ? SSO_CHOICE : driver + if (!choices.includes(choice)) choices.push(choice) + } + return choices +} export default function Login() { const { t } = useTranslation() const [searchParams] = useSearchParams() const [drivers, setDrivers] = useState() - const [selectedDriver, setSelectedDriver] = useState() + const [selectedDriver, setSelectedDriver] = useState() const [error, setError] = useState() const [isSubmitting, setIsSubmitting] = useState(false) - const [ssoProviders, setSsoProviders] = useState>() + const [ssoProviders, setSsoProviders] = useState() const redirect = validateRedirect(searchParams.get("r")) const form = useForm({ @@ -63,7 +99,7 @@ export default function Login() { }, }) - const handleSelectDriver = useCallback((driver: AuthDriver) => { + const handleSelectDriver = useCallback((driver: DriverChoice) => { setSelectedDriver(driver) setError(undefined) }, []) @@ -99,13 +135,14 @@ export default function Login() { SUPPORTED_DRIVERS.includes(driver), ) setDrivers(supportedDrivers) + const choices = driverChoices(supportedDrivers) // Selected directly rather than through handleSelectDriver, // which clears the error. On a deployment offering only single // sign-on this runs right after a failed callback set one, and // clearing it would leave the person looking at the button they // just came back from with no explanation. - if (supportedDrivers.length === 1) { - setSelectedDriver(supportedDrivers[0]) + if (choices.length === 1) { + setSelectedDriver(choices[0]) } }) .catch((err) => { @@ -114,14 +151,27 @@ export default function Login() { }) }, [t]) - // Fetched only once the deployment says it offers the driver, so a - // deployment without single sign-on makes no call that would 404. + // Fetched only for the protocols the deployment says it offers, so a + // deployment without single sign-on makes no call that would 404, and one + // with only SAML never asks for OpenID Connect providers. Iterated in the + // operator's declared order rather than SSO_DRIVERS', because that order is + // what the buttons end up in and driverChoices already honours it. useEffect(() => { - if (!drivers?.includes(AUTH_DRIVERS.OIDC)) return + if (!drivers) return + const enabled = drivers.filter((driver): driver is SsoDriver => + SSO_DRIVERS.includes(driver as SsoDriver), + ) + if (enabled.length === 0) return - api.auth - .ssoProviders() - .then(setSsoProviders) + Promise.all( + enabled.map(async (driver) => + (await api.auth.ssoProviders(driver)).map((provider) => ({ + ...provider, + driver, + })), + ), + ) + .then((lists) => setSsoProviders(lists.flat())) .catch((err) => { console.error("Failed to fetch sso providers:", err) // Leaving this undefined would spin the loader forever behind @@ -153,7 +203,7 @@ export default function Login() { - {drivers.length > 1 && ( + {driverChoices(drivers).length > 1 && ( ))} @@ -217,15 +268,19 @@ export default function Login() { )} + {/* Ids are unique within a protocol and not across them: + each driver's single-provider form calls itself + "default", so a deployment offering both would collide + on a bare id. */} {ssoProviders?.map((provider) => ( ))} - {drivers.length > 1 && ( + {choices.length > 1 && (