Single sign-on: OIDC, SAML, DiscourseConnect and LTI

Embed
Preview of the card
<a href="https://forumulate.com/guides/sso-and-lti"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/3d4115ef-3a3c-4daf-ad24-cbee8a44ad5f/social-card.svg?v=3e7ae33eec8b" alt="Single sign-on: OIDC, SAML, DiscourseConnect and LTI" width="600" height="315"></a>

Four integrations let another system vouch for your members: an OpenID Connect provider, a SAML 2.0 identity provider, DiscourseConnect (the single sign-on protocol Discourse popularised) and LTI 1.3 (the standard a learning management system uses to launch a course tool). Each is configured per organization and needs the org.manage_sso capability, and none of them returns a stored secret when you read its configuration back. For sign-in to the reader surfaces of a space, see Reader sign-in methods instead.

Every flow below ends the same way when it succeeds: the email the provider vouched for is matched to an account (one is created if none exists), that account is given a membership in the organization with the configuration's defaultRole if it has none, and a session cookie is set. Where a configuration has an allowedDomain, an email outside that domain is refused with a 403.

The public addresses in this guide are served on the API origin (https://forumulate.com/api on ours).

OpenID Connect

Save the provider with PUT /orgs/{orgId}/sso: clientId is required, clientSecret is required the first time and kept when you leave it out later, and a generic oidc provider also needs an issuer URL (GET /orgs/{orgId}/sso/providers lists the named presets that do not). enabled defaults to true on the first save.

Register https://forumulate.com/api/auth/sso/callback as the redirect URI at your provider. Sign-in starts with POST /auth/sso/{orgId}/start, which answers { "authUrl": ... }, the provider's authorization address with a signed state and a PKCE challenge; send the browser there. The provider returns the browser to GET /auth/sso/callback, which verifies the state (it expires after ten minutes), exchanges the code, signs the member in and redirects to the organization in the console. A disabled or missing configuration is a 404.

SAML 2.0

Save the identity provider with PUT /orgs/{orgId}/saml: idpEntryPoint (an http(s) URL) and, the first time, idpCert, the IdP's signing certificate. enabled defaults to true on the first save.

Give your IdP the service-provider metadata at GET /auth/saml/{orgId}/metadata. Its entity ID is that same metadata URL, and its assertion consumer service is POST /auth/saml/{orgId}/acs, where the IdP posts the signed SAMLResponse. A member starts at GET /auth/saml/{orgId}/login, which redirects to your IdP with a SAML request. The response must be signed and addressed to this organization's entity ID, and the same response is accepted only once; anything else is a 401. The email comes from the assertion's email attribute, or the NameID when there is none. On success the member lands on the organization in the console.

DiscourseConnect

DiscourseConnect has two modes, set with mode:

Mode

Meaning

provider

Forumulate is the identity source. Another app sends your users here to sign in. Default.

consumer

Forumulate delegates sign-in to an external provider at providerUrl.

PUT /orgs/{orgId}/discourse-connect/config
{
  "mode": "consumer",
  "secret": "a-long-random-shared-secret",
  "providerUrl": "https://sso.yourcompany.com/discourse",
  "allowedDomain": "yourcompany.com",
  "defaultRole": "MEMBER",
  "enabled": true
}

The shared secret signs every exchange and must be at least 10 characters. It is stored encrypted and is write-only: leave it out (or send a mask) on a later PUT and the stored one is kept. A save with no secret ever stored is a 422. consumer mode also needs providerUrl, an http(s) URL, or it is a 422. Nothing takes effect until enabled is true; it defaults to false. A flow for the mode you did not configure is a 409.

GET /orgs/{orgId}/discourse-connect/config returns the same fields plus configured and providerEndpoint, the URL an external client uses as its sso_url in provider mode. The secret is not returned.

In provider mode the external client redirects a signed-in user to GET /orgs/{orgId}/discourse-connect/provider?sso=...&sig=... (needs only the org.read capability, since it signs the caller's own identity). The response is a redirect to the client's return_sso_url with the signed identity, and the admin and moderator flags follow the user's role in the organization.

In consumer mode a reader starts at GET /discourse-connect/{orgId}/start, optionally with ?return=/a/path, and is redirected to providerUrl with a signed payload. The provider's signed return is handled at GET /discourse-connect/{orgId}/callback, with sso and sig. The callback checks the signature and the nonce (ten minutes, single use), applies allowedDomain and defaultRole, signs them in and redirects to the return path. The return path must be a path on the host they started from, never a URL, so a reader who signed in from your own docs domain lands back on the page they were reading; with no return path they land on the organization in the console.

LTI 1.3

Register your LMS as a platform and a course can launch straight into Forumulate with the learner already identified, no second account.

PUT /orgs/{orgId}/lti
{
  "issuer": "https://lms.example.edu",
  "clientId": "the-client-id-the-lms-gave-you",
  "authLoginUrl": "https://lms.example.edu/auth",
  "jwksUrl": "https://lms.example.edu/.well-known/jwks.json",
  "deploymentId": "1",
  "allowedDomain": "example.edu",
  "defaultRole": "MEMBER",
  "enabled": true
}

issuer, clientId, authLoginUrl and jwksUrl are required; the URLs must be http(s). deploymentId and allowedDomain may be null. enabled defaults to true on the first save.

Give the LMS two addresses. The login initiation URL is /lti/{orgId}/login, which the LMS may call as GET /lti/{orgId}/login or POST /lti/{orgId}/login with login_hint and lti_message_hint; we redirect to its authLoginUrl with a signed state and a nonce. The redirect (launch) URL is POST /lti/{orgId}/launch, where the LMS posts the signed id_token and the state. We verify the token against jwksUrl (issuer and audience), check the nonce (ten minutes, single use), refuse a token for a different deploymentId when you set one, and accept only an LtiResourceLinkRequest; an unverifiable launch is rejected. The learner is signed in and sent to the launch's target link when it is on the Forumulate web origin, otherwise to the organization in the console.

GET /orgs/{orgId}/lti answers { enabled, issuer, clientId, configured }. The OIDC, SAML and LTI settings calls require an admin-scoped token when you use a token rather than a session, because each one changes who can sign in.

In the API

Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.

Discourse Connect Admin

Operation

Request

What it does

Shortcuts

DiscourseConnectAdmin_getConfig

GET /orgs/{orgId}/discourse-connect/config

Get the config in an organization

client.sso.discourseConnect.getConfig()

DiscourseConnectAdmin_setConfig

PUT /orgs/{orgId}/discourse-connect/config

Replace the config in an organization

client.sso.discourseConnect.setConfig()

DiscourseConnectAdmin_provider

GET /orgs/{orgId}/discourse-connect/provider

Get the provider in an organization (discourse connect)

client.sso.discourseConnect.provider()

Saml Admin

Operation

Request

What it does

Shortcuts

SamlAdmin_get

GET /orgs/{orgId}/saml

Get the SAML in an organization

client.sso.saml.get()

SamlAdmin_set

PUT /orgs/{orgId}/saml

Replace the SAML in an organization

client.sso.saml.set()

Sso Admin

Operation

Request

What it does

Shortcuts

SsoAdmin_get

GET /orgs/{orgId}/sso

Get the SSO in an organization

client.sso.get()

SsoAdmin_set

PUT /orgs/{orgId}/sso

Replace the SSO in an organization

client.sso.set()

SsoAdmin_providers

GET /orgs/{orgId}/sso/providers

List providers in an organization (SSO)

client.sso.providers()

The Forumulate teamUpdated