An identity provider (IdP) is a service that allows users to authenticate using external accounts, such as Apple ID, Facebook, or other OIDC compatible IdP. By integrating with an identity provider, you can enable your users to sign in to your application using their existing accounts, rather than requiring them to create a new account specifically for your application.

To use an identity provider in Contember, you will need to configure the identity provider in the system and then provide a way for users to initiate the authentication process. This can typically be done by providing a login button or link that redirects the user to the identity provider's authentication page.

Once the user has authenticated with the identity provider, they will be redirected back to your application, where Contember will handle the rest of the authentication process. If the user's identity can be successfully verified, they will be logged in to your application.

IdP configuration

Provider types

Contember ships three IdP types, selected by the type argument of addIDP:

typeProviderConfiguration
oidcAny OpenID Connect–compliant provider (Keycloak, Microsoft Entra, Auth0, Apereo CAS, …)OIDC configuration
facebookFacebook LoginFacebook configuration
appleSign in with AppleApple configuration

All three are built on the same OIDC machinery and accept a common subset of the OIDC configurationscope (claims), responseType, and additionalAuthorizedParties — differing mainly in the credential fields and how the issuer is discovered. The richer OIDC options (claimMapping, fetchUserInfo, returnOIDCResult, tokenEndpointAuthMethod, and revalidation) are honored only by type: "oidc" — Facebook and Apple silently ignore them. The walkthroughs below use oidc.

Adding new IdP

To add a new identity provider (IdP) in Contember, you will need to use the addIDP mutation provided by the tenant API. This mutation allows you to specify the details of the identity provider you want to add, including its type, configuration, and options.

Example how to use the addIDP mutation to add a new OIDC identity provider:

mutation {
	addIDP(
		identityProvider: "oidc-provider",
		type: "oidc",
		configuration: {
			url: "https://oidc-provider.com/.well-known/openid-configuration",
			clientId: "YOUR_CLIENT_ID",
			clientSecret: "YOUR_CLIENT_SECRET",
			responseType: "code",
			claims: "openid email"
		},
		options: {
			autoSignUp: true,
			exclusive: false
		}
	) {
		ok
		error {
			code
			developerMessage
		}
	}
}
claims vs scope

Note that claims in configuration are actually "scopes" in OIDC terminology. This will be fixed in a future version.

In this example, the identityProvider field is set to "oidc-provider", which is a custom slug that you can use to identify the identity provider in your application. The type field is set to "oidc" to indicate that this is an OIDC identity provider.

The configuration field should include the URL of the provider's OpenID Connect configuration, as well as the client ID and client secret provided by the provider. The responseType and claims fields are optional.

The options field allows you to specify additional options for the identity provider, such as whether to automatically sign up users who don't already have an account and whether to allow only this identity provider for authentication.

Options

OptionDefaultNotes
autoSignUpfalseCreate a new account for an unknown e-mail instead of failing.
exclusivefalseOnly this IdP may authenticate the matched accounts; disables linking by e-mail.
initReturnsConfigfalseinitSignInIDP returns the raw provider config to the client.
requireVerifiedEmailfalse(since 2.2) For a non-exclusive provider, only auto-link to / sign in an existing local account by e-mail when the provider asserts the e-mail is verified. Guards against account takeover via an unverified provider claim. See e-mail verification.
assumeEmailVerifiedfalse(since 2.2) Treat this provider's e-mails as verified even when it sends no email_verified claim, so requireVerifiedEmail can stay on for a trusted IdP that never emits the claim. Waives the verified-email gate — use only for a provider you fully trust to vouch for its accounts. See e-mail verification.

If the "ok" in response is false, you will find details in error, possible error codes are following: ALREADY_EXISTS, UNKNOWN_TYPE, INVALID_CONFIGURATION

OIDC configuration

The configuration object passed to addIDP / updateIDP is provider-specific. For type: "oidc" the following fields are supported — only url is strictly required, the rest fall back to the defaults below:

FieldDefaultNotes
urlRequired. The provider's OpenID Connect discovery URL (…/.well-known/openid-configuration), used for issuer discovery.
clientIdOAuth client ID issued by the provider.
clientSecretOAuth client secret. Never returned by the API and redacted in the audit log.
scopeopenid emailSpace-separated scopes requested at authorization. When re-validation uses refresh, offline_access is appended automatically.
claimsDeprecated alias for scope — despite the name these are OIDC scopes, not claims. Prefer scope.
responseTypecodeOIDC response_type. One of code, code id_token, code id_token token, code token, id_token, id_token token, none.
idTokenSignedResponseAlgRS256Expected signing algorithm of the ID token.
tokenEndpointAuthMethodclient_secret_basic(since 2.2) How the client authenticates to the token endpoint. See Token endpoint authentication.
fetchUserInfofalseAfter the token exchange, also call the userinfo endpoint and merge its claims (userinfo wins over the ID token). Needed when the provider only returns some claims from userinfo.
returnOIDCResultfalseExpose the provider's raw token-exchange response in signInIDP's result.idpResponse (access token, raw claims, …).
additionalAuthorizedParties[]Extra azp values accepted in the ID token besides the configured clientId.
timeout5000HTTP timeout (ms) for calls to the provider.
claimMapping(since 2.2) Remap provider claims onto Contember's identity fields. See Claim mapping.
revalidation(since 2.2) Continuous session re-validation. See Session re-validation.

Token endpoint authentication

Available since 2.2

tokenEndpointAuthMethod controls how the client proves its identity when exchanging the authorization code for tokens. The default is client_secret_basic (the secret in an HTTP Basic header). Some providers — notably certain Apereo CAS registrations — only accept the secret in the request body, which requires client_secret_post:

mutation {
  updateIDP(
    identityProvider: "cas-provider",
    mergeConfiguration: true,
    configuration: { tokenEndpointAuthMethod: "client_secret_post" }
  ) { ok error { code developerMessage } }
}

Supported values: client_secret_basic, client_secret_post, client_secret_jwt, private_key_jwt, tls_client_auth, self_signed_tls_client_auth, none.

Claim mapping

Available since 2.2

By default Contember derives the identity from the standard OIDC claims: sub for the stable external identifier, email for the e-mail, name for the display name. A provider whose claims don't follow these defaults can be remapped via claimMapping — without a per-provider code change.

FieldDefaultNotes
externalIdentifiersubClaim used as the stable federation key persisted per identity and matched on every subsequent sign-in. Must resolve to a scalar (string/number); a non-scalar or empty value is rejected at sign-in (see the caution below).
emailemailClaim used as the e-mail address. A non-string value is ignored (treated as absent).
namenameClaim used as the display name. A non-string value is ignored.
attributesKeyKey of a nested object whose properties are lifted to the top level before mapping. For providers that nest their claims — notably Apereo CAS userinfo, which returns them under attributes.

Each mapping value is a claim name, with dot-paths into nested objects supported (e.g. user.id, address.region).

Mapping the external identifier safely

externalIdentifier is the key that ties an IdP account to a Contember identity on every sign-in, so it must be stable and unique per user.

  • It must resolve to a scalar. A claim that resolves to an object/array, or to an empty string, is rejected at sign-in (IDP_VALIDATION_FAILED) — mapping such a value through would otherwise collapse multiple users onto a single key (account takeover). When the mapped claim is simply absent, Contember falls back to the signed sub.
  • Claims lifted via attributesKey (or fetched from userinfo) are not covered by the ID-token signature. A signed ID-token claim always wins over an attributes-level claim of the same name, but if you map externalIdentifier to a claim that only exists in userinfo / attributes, the federation key is no longer anchored to the signed subject. Prefer a signed claim where possible.
Example: Apereo CAS

Apereo CAS exposes user attributes nested under attributes in its userinfo response and, in many deployments, expects the client secret in the request body. The two new options combine to consume it without any code change — fetch userinfo, unwrap attributes, map the stable oid to the external identifier, and post the secret:

mutation {
  addIDP(
    identityProvider: "cas-provider",
    type: "oidc",
    configuration: {
      url: "https://cas.example.edu/cas/oidc/.well-known/openid-configuration",
      clientId: "YOUR_CLIENT_ID",
      clientSecret: "YOUR_CLIENT_SECRET",
      scope: "openid email profile",
      tokenEndpointAuthMethod: "client_secret_post",
      fetchUserInfo: true,
      claimMapping: {
        attributesKey: "attributes",
        externalIdentifier: "oid",
        email: "email",
        name: "name"
      }
    },
    options: { autoSignUp: true }
  ) { ok error { code developerMessage } }
}

Facebook configuration

type: "facebook" uses Contember's built-in Facebook endpoints; you supply the credentials from your Facebook app. It honors the common OIDC options (scope, responseType, additionalAuthorizedParties) but not the oidc-only ones (claimMapping, fetchUserInfo, returnOIDCResult, tokenEndpointAuthMethod, revalidation).

FieldDefaultNotes
clientIdRequired. Facebook app ID.
clientSecretRequired. Facebook app secret. Redacted in the audit log and never returned by identityProviders.
urlRequired by the shared schema — but the Facebook issuer is built in, so the value is not used for discovery. Set it to https://www.facebook.com/.well-known/openid-configuration/.

Besides the standard redirect/callback flow, signInIDP also accepts a Facebook JS SDK response in data instead of a callback url:

data: {
  authResponse: {
    accessToken: "…",      # from FB.login()
    signedRequest: "…"
  }
}

Contember verifies the signedRequest HMAC with the app secret and then fetches id,email,name from the Graph API.

Apple configuration

type: "apple" uses Apple's fixed discovery endpoint and builds the client secret as a short-lived JWT signed with your Apple private key — so there is no clientSecret or url field. Like Facebook, it honors the common OIDC options (scope, responseType, additionalAuthorizedParties) but not the oidc-only ones (claimMapping, fetchUserInfo, returnOIDCResult, tokenEndpointAuthMethod, revalidation).

FieldDefaultNotes
clientIdRequired. Your Apple Services ID (the OAuth client identifier).
teamIdRequired. Apple Developer Team ID — becomes the client-secret JWT issuer.
keyIdRequired. Key ID of the Apple sign-in key — becomes the JWT kid.
privateKeyRequired. The .p8 private key (PKCS#8 PEM). Redacted in the audit log and never returned by identityProviders.

Contember signs an ES256 client-secret JWT (aud: appleid.apple.com, 10-minute lifetime) per request, so no static client secret is stored.

Updating existing IdP

To update an existing identity provider (IdP) in Contember, you will need to use the updateIDP mutation. This mutation allows you to specify the updated details of the identity provider you want to update, including its configuration and options.

Example how to use the updateIDP mutation to update an existing OIDC identity provider:

mutation {
	updateIDP(
		identityProvider: "oidc-provider",
		configuration: {
			url: "https://new-oidc-provider.com/.well-known/openid-configuration",
			clientId: "NEW_CLIENT_ID",
			clientSecret: "NEW_CLIENT_SECRET",
			responseType: "code",
			claims: "openid email profile"
		},
		options: {
			autoSignUp: false,
			exclusive: false
		}
	) {
		ok
		error {
			code
			developerMessage
		}
	}
}

In this example, the identityProvider field is set to "oidc-provider", which is the custom slug that you used to identify the identity provider when you added it to Contember.

The configuration field should include the updated details for the identity provider, such as the URL of the provider's OpenID Connect configuration, the client ID and client secret provided by the provider, and any optional parameters such as responseType and claims.

The options field allows you to specify updated options for the identity provider, such as whether to automatically sign up users who don't already have an account and whether to allow only this identity provider for authentication.

If the "ok" in response is false, you will find details in error, possible error codes are following: NOT_FOUND, INVALID_CONFIGURATION

configuration is replaced wholesale by default

By default updateIDP replaces the entire configuration object — any field you don't pass is dropped. To change a single field while keeping the rest, pass mergeConfiguration: true: each key you provide is merged into the stored configuration, and a key set to null is removed. Omitting configuration entirely leaves it untouched (handy when you only want to change options). The merge is recursive: a nested object (such as claimMapping or revalidation) is deep-merged key-by-key, so updating one of its fields preserves the rest, and a key set to null removes it at any level. An array value (such as the A09 rules list) is replaced as a whole, never merged element-wise.

Temporarily disabling and enabling an IdP

In Contember, you can enable or disable an identity provider (IdP) using the enableIDP and disableIDP mutations. These mutations allow you to control whether an identity provider is available.

To disable an identity provider, you can use the disableIDP mutation like this:

mutation {
	disableIDP(identityProvider: "oidc-provider") {
		ok
		error {
			code
			developerMessage
		}
	}
}

In this example, the identityProvider field is set to "oidc-provider", which is the custom slug that you used to identify the identity provider when you added it to Contember.

When you execute the disableIDP mutation, it will return a response indicating whether the operation was successful. If the operation was not successful, the ok field will be set to false and the error field will contain details about the error that occurred. Possible error code is NOT_FOUND.

To enable a previously disabled identity provider, you can use the enableIDP mutation like this:

mutation {
	enableIDP(identityProvider: "oidc-provider") {
		ok
		error {
			code
			developerMessage
		}
	}
}

The enableIDP mutation works in a similar way to the disableIDP mutation, with the identityProvider field specifying the custom slug of the identity provider you want to enable.

When you execute the disableIDP mutation, it will return a response indicating whether the operation was successful. If the operation was not successful, the ok field will be set to false and the error field will contain details about the error that occurred. Possible error code is NOT_FOUND.

Listing configured IdPs

The identityProviders query returns every configured provider — enabled or disabled. It requires the idp:list permission (granted to PROJECT_ADMIN and SUPER_ADMIN by default), so it is meant for administrative tooling, not the public login screen.

query {
  identityProviders {
    slug
    type
    disabledAt
    configuration
    options {
      autoSignUp
      exclusive
      initReturnsConfig
      requireVerifiedEmail
      assumeEmailVerified
    }
  }
}

disabledAt is null for an active provider and carries the timestamp of the last disableIDP otherwise. The returned configuration has its secrets stripped — OIDC/Facebook clientSecret and Apple privateKey are never exposed — so it is safe to surface in an admin UI.

IdP authentication

A two-step exchange: initSignInIDP produces the redirect URL and any state your client must remember, the user authenticates at the provider, and signInIDP consumes the callback URL to mint a session.

Both mutations carry their provider-specific arguments inside a data: Json field. Older redirectUrl, idpResponse, and sessionData top-level arguments still work but are deprecated and should not be used in new integrations.

Step 1 — initiate

mutation {
  initSignInIDP(
    identityProvider: "oidc-provider",
    data: { redirectUrl: "https://my-app.dev/finish-auth" }
  ) {
    ok
    error { code developerMessage }
    result {
      authUrl
      sessionData
      idpConfiguration
    }
  }
}

The identityProvider field is the slug used when the IdP was added. data.redirectUrl is where the provider should send the user after authentication. The exact data shape is provider-specific — for oidc, facebook and apple the keys are redirectUrl (required) and an optional responseMode (the OAuth response_mode, e.g. query or form_post).

The response carries:

  • authUrl — redirect the browser here.
  • sessionData — opaque blob you must keep alongside the browser session (carries state, nonce, code verifier, …). Pass it back into signInIDP verbatim.
  • idpConfiguration — only set when the IDP was created with initReturnsConfig: true; useful for clients that need to drive the auth flow themselves.

Error codes: PROVIDER_NOT_FOUND, IDP_VALIDATION_FAILED.

Step 2 — complete

Once the IDP redirects back to your redirectUrl, take the full callback URL (with ?code=…&state=…) and submit it together with the sessionData from step 1:

mutation {
  signInIDP(
    identityProvider: "oidc-provider",
    data: {
      url: "https://my-app.dev/finish-auth?code=ABC123&state=XYZ789",
      sessionData: { nonce: "123456", state: "XYZ789" },
      redirectUrl: "https://my-app.dev/finish-auth"
    },
    expiration: 60,
    options: { trustForwardedClientInfo: true }  # optional, see proxy-trust.md
  ) {
    ok
    error { code developerMessage }
    result {
      token
      person { id email }
      idpResponse
    }
  }
}

data.url is the full callback URL the IDP sent the user to. data.sessionData is the blob returned by initSignInIDP. data.redirectUrl must match what was passed to initSignInIDP.

The response result.token is the session API key — store it client-side and use it as the Bearer token for subsequent requests. idpResponse exposes the provider's raw token-exchange response when you need access tokens / userinfo claims.

{
  "ok": true,
  "error": null,
  "result": {
    "token": "XXX",
    "person": { "id": "user-uuid", "email": "[email protected]" }
  }
}

Error codes: INVALID_IDP_RESPONSE, IDP_VALIDATION_FAILED, PERSON_NOT_FOUND, PERSON_DISABLED, PERSON_ALREADY_EXISTS.

Personal IdP connections

Available since 2.2

Once a person has signed in (or linked an account) through an IdP, the federation is stored as a connection on that person — one record per link. A person can be connected to several providers, and may even hold more than one connection to the same provider. A signed-in person can list and disconnect their own connections; administrators can inspect another person's.

Listing your connections

The connections hang off the Person type, so the calling person reads their own through me. It is always visible to the person themselves — no special permission is required:

query {
  me {
    person {
      identityProviders {
        id
        createdAt
        externalIdentifier
        identityProvider {
          slug
          type
          disabledAt
        }
      }
    }
  }
}

id is the connection id (not the provider) — pass it to disconnectMyIdentityProvider to remove a specific link. externalIdentifier is the stable federation key (the provider's sub, see claim mapping). The nested identityProvider is a public view of the provider — slug, type and disabledAt only, never its configuration or secrets. person is null for a non-person caller (e.g. an API key), which therefore has no connections to show.

Viewing another person's connections

The same field is reachable on any Person, e.g. through personById:

query {
  personById(id: "person-uuid") {
    identityProviders {
      id
      externalIdentifier
      identityProvider { slug }
    }
  }
}

For a person other than the caller this requires the person:viewIdp permission against the target's roles — SUPER_ADMIN sees everyone; PROJECT_ADMIN sees members whose roles fall within their allowed-input roles, the same scoping as session visibility. When the viewer lacks visibility the field returns an empty list rather than erroring, so listing many people does not abort on a single forbidden target.

Disconnecting a connection

disconnectMyIdentityProvider(id) removes one of the calling person's own connections, addressed by the connection id from the listing above:

mutation {
  disconnectMyIdentityProvider(id: "connection-uuid") {
    ok
    error { code developerMessage }
  }
}

To guard against accidental lock-out, a connection cannot be removed while it is the person's last remaining sign-in method — that is, they have no password, passwordless sign-in is not usable for them under the tenant policy, and no other enabled IdP connection remains. A connection to a disabled provider does not count as a usable fallback, since it cannot be used to sign in.

codeMeaning
NOT_FOUNDThe caller has no connection with that id.
NOT_A_PERSONThe caller is not a person (e.g. an API key) and so has no connections to disconnect.
LAST_AUTH_METHODRemoving it would leave the person with no way to sign in. Set up a password or another sign-in method first.

A successful disconnect is recorded in the audit log as idp_disconnect.

Session re-validation

Available since 2.2

By default the IdP is consulted only at sign-in. After that the Contember session lives on its own and is simply re-prolonged on every request — so if the user is de-provisioned at the IdP, or their IdP session is revoked, Contember keeps the session valid until it expires on its own.

For OIDC providers you can opt in to continuous re-validation: the session is bound to the IdP session at sign-in (the token set is stored encrypted) and periodically re-checked against the IdP on the verify path. If the IdP says the session is gone, the Contember session is revoked.

It is off by default — without it, IdP sessions behave exactly as before. Enable it per IdP under configuration.revalidation:

mutation {
  updateIDP(
    identityProvider: "oidc-provider",
    mergeConfiguration: true,
    configuration: {
      revalidation: {
        enabled: true,
        method: "refresh",
        softRefreshThreshold: 0.5,
        minInterval: "10 seconds",
        mode: "auto"
      }
    }
  ) { ok error { code developerMessage } }
}

Cadence is driven by the access-token lifetime

Re-validation does not run on a fixed interval. It follows the lifetime of the access token the IdP issued (expires_at), which is exactly the provider's statement of "trust this until X":

  • Fresh — while the token is well within its lifetime, nothing happens (no IdP call).
  • Soft — once the token passes softRefreshThreshold of its lifetime (default 0.5, i.e. half-expired), a background refresh is triggered. It runs after the response, so the request keeps zero added latency, and the token is renewed before it dies. A revocation discovered here is applied by disabling the api_key, so it takes effect on a subsequent request — the request that triggered the refresh is still served.
  • Expired — once the token has actually expired, the refresh becomes blocking: the request waits for the IdP, and a revoked grant fails it immediately.

Setting mode: "blocking" makes every re-validation synchronous (zero revocation lag, at the cost of latency on the re-validation tick) — useful for regulated deployments. A burst of requests past the threshold triggers only one refresh (minInterval single-flight floor).

Revocation latency in auto mode

In the default auto/background mode the revocation is not guaranteed on the very next request:

  • the revoke writes disabled_at to the primary, but the verify path reads the api_key from a (possibly lagging) read replica, so a revoked session keeps authorizing until replication catches up;
  • the minInterval single-flight floor suppresses re-discovery in the meantime.

So in the soft window the worst-case exposure is roughly (1 − softRefreshThreshold) of the remaining token lifetime plus a round-trip and replica lag — bounded by the token expiry, after which the refresh becomes blocking. If you need near-immediate logout, use mode: "blocking" and/or short access-token lifetimes.

Bounded fail-open on a transient failure

A transient IdP/DB failure during a blocking refresh fails open (the session is kept) and spends the claim, so the next attempt is throttled by minInterval. This means an already-expired token can keep being served for up to one minInterval while the IdP is unreachable — an intentional anti-hammer / availability trade-off, not a revocation. These fail-opens are audited (see Audit).

Re-validation methods

methodHow it re-checksNotes
refresh (default)OIDC refresh-token grantNeeds a refresh token, so offline_access is requested automatically. Rotates the token and advances the lifetime on each refresh.
userinfoCalls the userinfo endpoint with the stored access tokenNo refresh token, so the stored access token is never rotated. Once it passes the soft threshold the session re-probes the IdP on (at most) every minInterval, indefinitely, until the IdP rejects the token (HTTP 401 → revoked).
introspectionRFC 7662 token introspectionSame cadence as userinfo — the token is not rotated, so the session keeps introspecting every minInterval until the IdP reports it inactive.
Note

With userinfo/introspection the session does not self-expire: because no token is rotated, re-validation keeps probing the IdP at the minInterval cadence for the life of the session. Choose a minInterval that the IdP can sustain, or prefer refresh where the IdP issues refresh tokens.

A definitive IdP rejection (invalid_grant, inactive token) revokes the session and disables its api_key. Transient failures (network, IdP unavailable) fail open — the session is kept and retried later, so IdP downtime does not log everyone out. Disabling the IdP itself also revokes its sessions on the next check.

Configuration reference

FieldDefaultNotes
enabledfalseMaster switch. false = pre-2.2 behaviour (no session stored, no re-validation).
methodrefreshrefresh | userinfo | introspection.
softRefreshThreshold0.5Fraction (0–1) of the token lifetime after which the proactive background refresh starts.
minInterval"10 seconds"Single-flight floor between re-validation attempts (Postgres interval).
fallbackInterval"5 minutes"Throttle used when the IdP returns no token expiry (Postgres interval).
modeautoauto (background before expiry, blocking once expired) | blocking (always synchronous).
onFailurerevokeAction on a revoked grant.
Encryption key required

Refresh tokens are stored encrypted at rest, so method: "refresh" needs a configured encryption key (CONTEMBER_ENCRYPTION_KEY). Without one, a token-bearing session is simply not persisted — the session falls back to plain behaviour with no re-validation rather than storing secrets in plaintext or failing sign-in.

Single Logout

Available since 2.2

When a session was federated through an OIDC provider, signing out can also terminate the session at the IdP (OpenID Connect Single Logout), so the user is not silently re-authenticated by a still-live SSO session. Both flows build on the federated session captured by session re-validation, so they take effect only when configuration.revalidation.enabled is set (that is when the IdP sid is stored at sign-in).

RP-initiated logout (front-channel)

signOut returns a logoutUrl when the session was federated and the IdP advertises an end_session_endpoint:

mutation { signOut { ok logoutUrl } }

Redirect the browser to logoutUrl to finish the logout at the IdP. It is null for plain/password sessions and for IdPs without an end_session_endpoint (where only a local logout happens).

If you use the bundled admin client, this redirect is opt-in so a federated logout does not unexpectedly bounce existing apps through the IdP. Enable it per logout with the idpLogout flag — <LogoutTrigger idpLogout> or useLogout()({ idpLogout: true }); without it the client performs a local-only logout and redirects to / as before.

Set where the IdP returns the browser afterwards with configuration.postLogoutRedirectUri — it must be registered as a post-logout redirect URI at the IdP:

{
  "url": "https://idp.example.com",
  "clientId": "",
  "clientSecret": "",
  "postLogoutRedirectUri": "https://app.example.com/logged-out"
}

Back-channel logout

For IdP-initiated logout — the IdP terminates the SSO session and notifies Contember directly — Contember exposes a public receiver endpoint:

POST /oidc/backchannel-logout?provider=<slug>
Content-Type: application/x-www-form-urlencoded

logout_token=<signed JWT>

Register this URL as the provider's back-channel logout URI at the IdP, putting the Contember provider slug in the ?provider= query parameter (the IdP knows only which client it is calling, not the Contember provider record). The endpoint is unauthenticated but acts only on a logout_token whose signature, issuer, audience and freshness validate against that provider: a token targeting a sid revokes that session; a sub-only token revokes all of that subject's federated sessions. It answers 200 on success, 400 for an invalid token, 404 for an unknown provider slug, and 501 when the provider does not support back-channel logout.

Membership mapping

Available since 2.2

A membership mapping turns the IdP's response into Contember project memberships on sign-in, so project authorization follows the IdP instead of being maintained by hand. It is off by default — without it, sign-in grants nothing. Configure it per IdP under configuration.claimMapping:

mutation {
  updateIDP(
    identityProvider: "oidc-provider",
    mergeConfiguration: true,
    configuration: {
      claimMapping: {
        syncPolicy: "always",
        unmatched: "keep",
        rules: [
          # grant a plain membership to everyone in the `Editors` IdP group
          { claim: "groups", contains: "Editors", grantMembership: { project: "blog", role: "editor" } },
          # grant a project membership, with membership variables, to the Sales department
          {
            claim: "department",
            equals: "Sales",
            grantMembership: { project: "crm", role: "salesperson", variables: [{ name: "region", values: ["eu"] }] }
          }
        ]
      }
    }
  ) { ok error { code developerMessage } }
}

A mapping grants project memberships only — never global/tenant roles. Global roles are privilege-bearing and granting them from external claims would be a privilege-escalation path, so they are not mappable; a config that tries to (a grantRoles on a rule) is rejected with INVALID_CONFIGURATION. A project membership stays within what an IdP configurer can already grant directly: configuring claimMapping requires IDP_ADD / IDP_UPDATE, held only by a global project_admin / super_admin who can already grant any project role directly. Note the mapping is bounded only by the target project's ACL — it can grant any role defined there and set arbitrary membership-variable values (subject to map / allow); it is not additionally constrained by a per-project admin.tenant.manage role/variable allowlist, so restricting that does not limit what a mapping can grant. Hand out global roles explicitly instead.

Shared claimMapping key

On an oidc provider, configuration.claimMapping is also used for the identity-field remap (email / name / externalIdentifier / attributesKey). The two coexist in the same object — membership mapping only reads the rules field, the identity remap only reads its own fields. A claimMapping with no rules is treated as "no membership mapping" (the remap-only case), not an error. Because updateIDP with mergeConfiguration: true deep-merges nested config objects (see Updating existing IdP), updating the A09 rules preserves the OIDC identity-remap fields (email, name, externalIdentifier, attributesKey) in the same claimMapping object, and vice versa — send only the fields you want to change (and a field as null to remove it). Note that rules is an array, so it is replaced as a whole, not merged element-wise.

Each rule inspects one claim from the IdP response and, when it matches, grants its grantMembership. Rules are independent and additive — every matching rule contributes, a non-matching rule is skipped. The grantMembership.role must exist in the target project's ACL schema, and each membership variable it sets must be a variable defined for that role — both validated at addIDP/updateIDP time (mirroring a direct add-member call). A project that doesn't exist yet is skipped at validation time — the grant is simply inert at sign-in until the project and role exist; note this means a mapping authored before its project exists is not re-validated when that project later appears. Equally, a rule targeting a project that does not exist (or was deleted) at sign-in time is silently skipped and is not reflected in the idp_role_mapped audit delta. The claims are whatever the provider returns (for OIDC, the userinfo/ID-token claims), so target a claim the IdP asserts — typically groups or roles — not a value the end user can edit (see the caution below). Note that userinfo claims are only merged into the claim surface at sign-in when fetchUserInfo: true (default false); a rule matching a claim the IdP returns only from userinfo (often groups/roles) then silently never fires — and because the rule is still well-formed, it produces no idp_role_mapping_failed marker, just a no-op. Set fetchUserInfo: true if your rules target userinfo-only claims. Only OIDC-shaped providers (oidc, apple) expose arbitrary claims for rule matching; Facebook's signed-request sign-in surfaces only the identity fields (email / name / externalIdentifier), so a rule matching on groups/roles never fires for a Facebook IdP. A rule's claim — like a variable's from.claim — is resolved by exact name first, so a claim whose name literally contains dots (URL-namespaced, e.g. Auth0's https://example.com/roles) is addressable as-is; only when no claim of that exact name exists is it treated as a dot-path into nested objects (e.g. Keycloak's realm_access.roles).

Matching

ConditionMatches when
equalsthe claim equals the value — for an array claim, when the value is one of its elements
containsfor an array claim, when the value is an element; for a string claim, when it is a substring
neitherthe claim is present and non-empty — an explicit null, the boolean false, an empty array [] (user is in no groups), and an empty string "" do not match (but a numeric 0 does count as present, unlike false — so don't use a 0/1 numeric flag as a presence gate); useful for a flag-style claim

A single rule may set at most one of equals or contains; a config that sets both is rejected at addIDP/updateIDP time with INVALID_CONFIGURATION (if one is nonetheless planted out-of-band, equals wins and contains is ignored). An empty-string equals/contains is likewise rejected — an empty contains would substring-match every string value of the claim (granting to every user of the IdP); to match on the claim's mere presence, omit both.

An unknown or misspelled key on a rule, a grantMembership, a membership variable, or a from source is rejected at addIDP/updateIDP with INVALID_CONFIGURATION — so a typo like equal for equals (which would otherwise silently turn the rule into a match-everyone grant) or grantMembershp for grantMembership fails loudly instead of being silently dropped. The lone exception is the map lookup table, whose keys are arbitrary raw claim values by design.

Matching is type-strict: equals, contains, and a variable's from.where compare values with no coercion, so a string equals: "42" does not match a numeric claim 42 — match a numeric claim with a numeric value (equals: 42). This differs from claim-derived membership-variable values, which are stringified (see the Numeric claim values note).

syncPolicy and unmatched

FieldDefaultNotes
syncPolicyalwaysalways re-applies the mapping on every sign-in — and, when session re-validation is enabled, again on each OIDC refresh that returns fresh claims, so a long-lived session's memberships track the IdP's current claims without a full re-login. Refresh-time resync depends on the re-validation method delivering claims: refresh reconstructs the same claim surface sign-in builds — the refreshed ID-token claims plus a userinfo merge when fetchUserInfo: trueuserinfo returns its endpoint's claims, and introspection returns none (no resync). Nested claims (attributesKey) and the identity-field mapping are applied on the refresh path exactly as at sign-in, so providers that nest their claims (e.g. Apereo CAS) resync correctly. Because method: "refresh" rebuilds the complete surface, a refresh reconciles exactly like a sign-in: grants are added/updated and, under unmatched: "remove", memberships the mapping no longer grants are revoked — at the membership-variable level too, where a claim-derived variable is replaced with the IdP's current values, so it can both widen and narrow. The one exception is a partial surface — method: "userinfo" (no ID-token claims) or a refresh whose fetchUserInfo call failed — which stays additive-only: grants are refreshed but nothing is removed, so a rule keyed on a claim absent from the partial surface can't strip a membership the IdP still asserts; those removals are deferred to a full sign-in. One caveat the engine cannot detect: some IdPs release certain claims only at authentication and omit them from a refreshed ID token — the surface is then structurally complete but semantically slimmer, and under unmatched: "remove" a rule keyed on such a claim would revoke the membership on refresh. If your IdP does this, key rules on claims it also asserts on refresh, enable fetchUserInfo: true (userinfo is re-fetched on refresh), or use unmatched: "keep". sticky applies the mapping only when the account is first created by auto-sign-up, then never touches it again (refresh never re-applies a sticky mapping).
unmatchedkeepkeep only ever adds grants. remove makes the IdP the source of truth: a membership the mapping no longer grants is revoked — and so is a membership the mapping still grants but whose declared variable resolves to no values, since an empty membership variable is not a valid grant (the whole role is removed rather than left scoped to nothing — clearing the variable to an empty set would instead, for a condition variable, widen it to allow-all). Revocation is bounded to the mapping's own vocabulary — the (project, role) pairs named by some current rule; memberships and roles managed outside the mapping are never touched. Caveat: because the vocabulary is built from the current rules, removing a variable from a rule, deleting a rule, removing the whole claimMapping, or disconnecting the IdP takes the affected grant out of vocabulary — so a previously-granted membership (or a previously-written variable value) is not retroactively revoked on subsequent sign-ins. To revoke, keep the rule but stop matching it, or use the direct member API.

Membership variables

A grantMembership may also set membership variables — the per-membership values that parameterize row-level ACL. The simplest form is constant values:

grantMembership: { project: "crm", role: "salesperson", variables: [{ name: "region", values: ["eu"] }] }

To derive a variable's values from a claim instead, give it a from source and (typically) a map. A common case: the IdP returns language codes and you store locale ids.

variables: [
  {
    name: "locale",
    # read + shape the claim into a flat list of raw values
    from: { claim: "profile.languages", split: "," },
    # translate each raw value (e.g. "cs", "en") into the value(s) actually stored
    map: { cs: ["<cs-locale-id>"], en: ["<en-locale-id>"] }
  }
]

from shapes the claim into a flat list of raw string values (combine the options as needed):

FieldEffect
claimclaim to read: the exact name first (a URL-namespaced claim like https://example.com/roles works as-is), then as a dot-path into the response (profile.languages descends into nested objects)
splitsplit each string value on this delimiter ("cs,en"["cs","en"]; OIDC-style "cs en" with split: " ") — a scalar string claim and every string element of an array claim; a no-op for non-string values; each resulting part is trimmed and empty parts are dropped ("cs, en"["cs","en"]; "cs,,en"["cs","en"])
pickfor an array of objects claim, take this dot-path field from each element ([{ code, role }] → the codes)
wherefor an array of objects claim, keep only elements whose dot-path field equals equals; requires pick — using where without pick is rejected at addIDP/updateIDP time with INVALID_CONFIGURATION
Numeric claim values

Claim values read for a membership variable (the from extraction) are coerced to strings via JavaScript String(), so numeric claims work — but only within the safe-integer range (±2^53 − 1). Configure exact identifiers (org/tenant ids) as string claims at the IdP rather than numbers to avoid precision loss. (Rule matchingequals / contains / where — is type-strict and does not coerce; see Matching.)

The raw values are then turned into the values actually stored:

FieldEffect
maplookup table from a raw value to the value(s) to store; a raw value absent from the map is dropped…
passthrough…unless passthrough: true, which stores an unmapped raw value verbatim (off by default — see the caution)
allowoptional allowlist applied to the claim-derived values (constant values are never filtered)

A variable's constant values and its claim-derived values are unioned. A variable that resolves to no values is not applied under unmatched: "keep" (the default), and the membership is kept as-is. Under unmatched: "remove", a declared variable that resolves to no values revokes the whole membership — an empty membership variable is not a valid grant (see unmatched).

The one exception is the project admin role, which carries project-admin privilege from its role name alone (independent of its variables). A grant for admin whose required (non-fallback) variable resolves to no values is dropped rather than kept — otherwise the empty variable would be filtered out and the bare admin membership would confer unscoped project-admin. The drop is fail-closed (the grant simply doesn't apply) and is recorded with the idp_role_mapping_failed audit marker. To grant admin, either set its required variables from claims that are reliably present, or grant the unrestricted admin (a role with no required variables) deliberately.

Membership mapping grants real privilege from external input

A matching rule grants a project membership directly, so a careless mapping is an access-control risk:

  • Target IdP-asserted claims only. Map groups/roles, not user-editable fields like name or email. A contains rule on a user-controlled claim (e.g. { claim: "email", contains: "admin" }) can be satisfied by an attacker who sets that value at the IdP.
  • No global/tenant roles. A mapping can only grant project memberships; super_admin, project_creator, project_admin and the like are never grantable this way. A grantRoles on a rule is rejected with INVALID_CONFIGURATION.
  • No predefined variables. A mapping cannot set a predefined membership variable (identityID / personID) — the runtime binds those to the signed-in identity. Attempting to set one is rejected with INVALID_CONFIGURATION, like grantRoles.
  • unmatched: "remove" with syncPolicy: "always" re-evaluates on every login, so a change in the IdP's claims (or an IdP outage that drops a claim) can revoke a user's memberships at the next sign-in. (Refresh-time resync never removes — only a full sign-in does.) Keep high-value memberships out of a remove mapping's vocabulary, or grant them outside the mapping.
  • passthrough puts raw external claim values into row-level ACL. Membership variables feed content-API row filters, so a raw claim value passed through becomes a row-level access grant. Keep passthrough off and translate values through map (or bound them with allow) unless the variable legitimately holds claim values rather than opaque ids. A condition-type membership variable is stricter still: its stored value is parsed as an ACL condition, so a claim-derived value is an arbitrary-filter injection vector — addIDP/updateIDP reject a condition variable whose value comes from a claim unless it is bounded by an allow allowlist, and reject passthrough on it outright. Furthermore, all values that can end up in a condition variable are validated as parseable ACL conditions at config time: this includes constant values, every entry in map (its output values), and every entry in allow — a malformed condition in any of these is rejected at addIDP/updateIDP time with INVALID_CONFIGURATION, so it can never be silently stored.

Failure handling differs by path. On a sign-in, a malformed stored config is skipped so sign-in still succeeds (fail-open), recorded in the audit log as idp_role_mapping_failed; a transient apply error (e.g. a database error) instead fails the sign-in — it rolls back the whole transaction so no partial set of grants is ever committed. A third case sits between them: a well-formed config whose individual rule fails apply-time re-validation against the project's current ACL (the role/variable drifted since the mapping was authored, or the target project's row exists but its ACL is momentarily unresolvable) has that rule dropped — the remaining safe rules still apply and sign-in succeeds, with the drop recorded as idp_role_mapping_failed (a partial-apply fail-open, not a whole-mapping skip). On a session refresh the apply runs in its own transaction and is fully fail-open: a malformed config or a transient apply error is swallowed (the session is kept) and recorded as idp_role_mapping_failed. When a mapping actually changes an identity's memberships, the sync is recorded as idp_role_mapped.

Audit

Available since 2.2

Every IdP management call is recorded in the audit log:

MutationAudit typeevent_data
addIDPidp_create{identityProvider, type, configurationKeys, options} — only the names of the configuration keys, never the secret values
updateIDPidp_update{before, after} with configuration collapsed the same way
disableIDPidp_disable{identityProvider}
enableIDPidp_enable{identityProvider}
disconnectMyIdentityProvideridp_disconnect{id, slug} — the removed connection id and the provider slug

Sign-in attempts via IdP are recorded as idp_login. When a membership mapping changes an identity's project memberships (on sign-in or on a session refresh), that sync is recorded separately as idp_role_mapped with event_data: {before, after, syncPolicy, unmatched} (each of before/after is {memberships}) — only when grants actually changed, never on a no-op. Note that the before/after membership snapshots include membership-variable values for every role in the mapping's vocabulary — both claim-derived values (when a variable uses passthrough or allow) and any variable managed outside the mapping on that same role (e.g. an admin-set condition filter), since the snapshot is bounded per (project, role), not per variable name. All of those values are therefore persisted in person_auth_log.event_data and are readable via system:viewAuthLog. By contrast, idp_role_mapping_failed deliberately carries no claim values. A mapping that was configured but failed and was skipped (fail-open) is recorded as idp_role_mapping_failed.

When session re-validation revokes a session, it is recorded as idp_session_revoked with event_data: {reason} (e.g. invalid_grant, inactive, idp_disabled); a token rotation is recorded as idp_session_revalidated.

Single Logout adds two more (since 2.2): signing out a federated session that produced an RP-initiated logout URL is recorded as idp_logout_initiated (event_data: {all}), and an IdP-initiated back-channel logout as idp_backchannel_logout (event_data: {sid, sub, revokedCount}). The back-channel audit write is best-effort — it never fails the logout — so a missing entry means observability was lost, not that the logout did not happen.

A fail-open — re-validation could not be performed but the session was kept — is recorded as idp_session_revalidation_failed (with success: true, since it is not a security failure) and an error_code:

error_codeMeaning
revalidation_errorThe IdP could not be reached / returned a transient error; the session was kept and will be retried. Throttled to one entry per minInterval per session, so a prolonged IdP outage surfaces here rather than silently.
config_invalidThe stored IdP configuration could not be parsed at re-validation time; the session was kept.
encryption_disabledRe-validation is enabled on the IdP but no encryption key is configured, so at sign-in the token-bearing session was not stored — the session degraded to a plain, non-revalidated one. Recorded once per such sign-in.

Alerting on idp_session_revalidation_failed lets you distinguish "sessions are being re-validated" from "re-validation has been silently failing open".