SSO / SAML
| Direction | masks is | See |
|---|---|---|
| Sign in to masks with Google, Okta, Entra, or a SAML identity provider | the service provider | Providers |
| Sign in to a SAML-only app, such as a wiki, with masks | the identity provider | SAML applications |
| Keep masks’ people in sync with a directory | the SCIM service | Provisioning |
Providers
Section titled “Providers”A provider is another service people sign in with, over OpenID Connect, OAuth 2.0, or SAML 2.0. masks links the account there to an account in masks. That link is a connected account.
A provider stands in for the password. A person’s sign-in policy still asks for a second factor when it requires one.
Provider roles
Section titled “Provider roles”| Role | Creates accounts | Updates the profile |
|---|---|---|
credential |
No. It signs in only the account it is connected to. | No |
delegate |
Yes, for a new person. | Name, photo, and address, on every sign-in |
Use delegate when the company directory decides who works there. A credential provider that
confirms an existing account’s address connects to that account only after it signs in another
way.
Trusted email addresses
Section titled “Trusted email addresses”masks tells apps email_verified: true, so it trusts a provider’s address only when:
- the address is in one of the provider’s email domains, or
- the provider has
trusts_emailon and reports the address as confirmed.
Turn on trusts_email only for a provider that confirms addresses. The Google, Apple, GitHub,
GitLab, Slack, LinkedIn, and Discord presets turn it on.
- An address the provider reports as unconfirmed stays unconfirmed, even in a listed domain.
- SAML has no confirmation field, so a SAML provider is trusted only for its listed domains.
- For an untrusted address, a
delegateprovider creates no account and no invitation is accepted. - A person with no address at all still gets an account, with no address.
Where a provider is offered
Section titled “Where a provider is offered”The client’s sign-in policy decides:
first_factorsmust includeprovider.providerslists which ones. Unset means all.
A signed-in person can connect any offered provider from their account page, after signing in within the last fifteen minutes. masks refuses an account there that is already connected to someone else.
Sending a domain to its provider
Section titled “Sending a domain to its provider”Someone who types an address at a company’s domain can go straight to that company’s provider, with no password prompt. masks does this only for a domain the tenant has proven it controls.
- In
/manage, open Settings, then Domains, enter the domain, and choose the provider. - Publish the TXT record masks shows, such as
_masks-challenge.acme.examplewith the valuemasks-verification=…. - Choose Check now, or wait for the hourly check.
Once the domain is proven, an address there starts the provider’s sign-in, even when the provider belongs to an organization the app did not name. An address at any other domain, and a nickname, sign in the usual way.
- A domain can be proven by one tenant at a time. Another tenant’s claim to it is refused.
- A proven record that stops answering is released after seven days, and people sign in the usual way again.
- A sign-in policy without the provider factor sends nobody to a provider.
Presets
Section titled “Presets”A preset fills in every setting except the credentials.
| Preset | Protocol | Asks for |
|---|---|---|
| OpenID Connect | ||
| Microsoft | OpenID Connect | directory (tenant) ID |
| Apple | OpenID Connect | team ID, key ID, private key |
| GitHub | OAuth 2.0 | |
| GitLab | OpenID Connect | domain, gitlab.com by default |
| Slack | OpenID Connect | |
| OpenID Connect | ||
| Discord | OAuth 2.0 | |
| OAuth 2.0 | ||
| X | OAuth 2.0 | |
| Okta, Auth0 | OpenID Connect | domain |
| Keycloak | OpenID Connect | domain, realm |
| Microsoft Entra, Okta, Google Workspace | SAML 2.0 | metadata |
| Notion | MCP | nothing; masks registers itself |
| OpenID Connect, OAuth 2.0, SAML 2.0, MCP server | everything |
An MCP provider signs nobody in. Apps use it through a delegation.
Provider protocols
Section titled “Provider protocols”OpenID Connect
Section titled “OpenID Connect”masks checks the id_token’s signature against the issuer’s keys, and its issuer, audience, and
nonce against the request. The request uses PKCE. The subject is sub, or oid for a Microsoft
directory.
OAuth 2.0
Section titled “OAuth 2.0”With no id_token, masks reads the provider’s userinfo endpoint. subject_claim and claims map
each claim to a path in the response, such as data.id for X or id for GitHub. The request uses
PKCE, and masks discards the provider’s tokens once it has the profile.
An emails URL lists confirmed addresses separately from the profile, as GitHub does. masks uses the primary confirmed address from it, and ignores an address that appears only in the public profile.
SAML 2.0
Section titled “SAML 2.0”Give the identity provider two values from the provider’s page in /manage:
| Assertion consumer service URL | https://<tenant>/login/provider/<key>/callback |
| Service provider entity ID | https://<tenant>/login/provider/<key>/metadata, which also serves masks’ metadata |
Then give masks the identity provider’s metadata, as a URL or pasted in. A URL is fetched again nightly, so a rotated certificate is picked up.
masks trusts an assertion only when it:
- is signed by a certificate in the identity provider’s metadata,
- answers the request this browser sent (
InResponseTo), - names this service provider as audience and recipient,
- names the identity provider’s entity ID as issuer,
- is within its validity window, with one minute of clock drift.
masks refuses unsolicited, identity-provider-initiated responses, and marks each request used, so a response cannot be replayed. Ask for a persistent name ID. A transient one changes on every sign-in, so each sign-in looks like a new person.
Apple’s client secret is a short-lived ES256 token that masks signs with the provider’s .p8
private key, so the provider stores a team ID, a key ID, and that key. Apple sends the person’s
name only on their first sign-in, and masks keeps it.
Posted callbacks
Section titled “Posted callbacks”Apple and SAML post back across sites, and browsers do not send a SameSite=Lax cookie with a
cross-site POST. masks holds the posted parameters for five minutes under a one-time handle and
redirects to the same URL as a GET, which sends the cookie.
SAML applications
Section titled “SAML applications”masks is the identity provider for apps that sign people in only over SAML 2.0. The app sends an
AuthnRequest, masks signs the person in under their
sign-in policy, and masks posts a signed assertion back.
What to give the application
Section titled “What to give the application”| Metadata | https://demo.masks.example/saml/metadata |
| Entity ID | The same URL. |
| Single sign-on | https://demo.masks.example/saml/sso, HTTP-Redirect or HTTP-POST. |
| Certificate | In the metadata, one per tenant signing key. |
Most apps need only the metadata URL. After a key rotation, the app must read it again. The retiring key’s certificate stays in the metadata during the overlap.
Registering one
Section titled “Registering one”- In
/manage, open Clients → Add client → SAML app. - Paste the app’s metadata.
- Click Read it. masks fills in its entity ID, HTTP-POST assertion consumer services, and request-signing certificate.
A SAML app is an approved client with no grants and no secret. It cannot use /authorize or
/token.
| Setting | |
|---|---|
| Entity ID | The Issuer its AuthnRequest names. |
| Assertion consumer services | The only URLs masks posts to. |
| Names people by | A persistent identifier (pairwise if the client is), or their email. |
| Refuse unsigned requests | Check every request against the app’s certificate. |
| Let people start from masks | Answer /saml/initiate/<client_id> without a request. |
What is checked
Section titled “What is checked”masks shows an error and posts nothing when a request:
- names an
Issuerwith no registered app, - has an
AssertionConsumerServiceURLnot registered, or a binding other than HTTP-POST, - asks for a NameID format masks does not issue,
- is more than eight minutes old, more than three minutes in the future, or has another
Destination, - was already received, or repeats an
ID, - fails a required signature check (SHA-256 or stronger), or carries two signatures,
- repeats a parameter, or is larger than 64 KB,
- declares a document type, which is how XML external entities get in.
| Request or outcome | Response |
|---|---|
ForceAuthn |
The person signs in again. |
IsPassive |
NoPassive, with no page shown. |
| Suspended, declined, or stopped by the policy | RequestDenied, in place of an assertion. |
What is asserted
Section titled “What is asserted”The response and the assertion are both signed with RSA-SHA256. The assertion is addressed to the
app’s entity ID, confirmed for its assertion consumer service and the request ID, valid for five
minutes, names the session in SessionIndex, and names https://refeds.org/profile/mfa when a
second factor was used.
Attributes use the claim names email, name, given_name, family_name, and
preferred_username. samlAttributes maps them to an app’s own names. masks releases only what the person’s
openid profile email consent covers, and an email only once it is confirmed, as attribute or
NameID. The posting page’s content security policy allows only its own script.
Single logout, encrypted assertions, the artifact binding, and choosing an assertion consumer service by index are not supported.
Provisioning
Section titled “Provisioning”A directory such as Entra ID, Okta, Google Workspace, or JumpCloud keeps masks’ people in sync over SCIM 2.0 (RFC 7643, RFC 7644). It adds, renames, and suspends people in masks as it does in its own directory.
Each tenant’s base URL is its issuer followed by /scim/v2:
https://demo.masks.example/scim/v2Connecting a directory
Section titled “Connecting a directory”- In
/manage, open Settings → Provisioning and issue a token. - Label it after the directory.
- Choose a lifetime, up to one year.
- Copy it. masks shows it once.
- In the directory, enter the base URL and the token as a bearer token.
The token list shows each token’s last use. A revoked token is refused.
A client can also provision. It must be approved and allowed masks:scim, request its own token
with client_credentials, and name the base URL as its
resource.
What is provisioned
Section titled “What is provisioned”/Users supports reads, filters, creation, PUT, PATCH, and deletion.
| SCIM | masks |
|---|---|
id |
The actor’s uuid. |
userName |
The nickname, or the email when it is an address. |
externalId |
Stored and filterable. It is unique in the tenant, or in the organization for a directory for one organization. |
name, displayName |
The name claims. |
emails |
The primary email, treated as confirmed. |
phoneNumbers, photos, profileUrl, locale, timezone |
The matching claims. |
active |
Whether the person is suspended. |
password |
Set, if the directory sends one. |
A provisioned person holds the standard scopes and has no password unless one was sent. They sign in through a provider that trusts the same email, or by accepting an invitation.
| Filters | |
|---|---|
| Operators | eq, ne, co, sw, ew, pr, gt, ge, lt, le |
| Joins | Up to four comparisons with and. |
| Attributes | userName, externalId, emails.value, displayName, name.givenName, name.familyName, active, the meta dates |
eq only |
userName, active |
| Case | externalId compares exactly. The other attributes ignore case. |
Every response has a weak ETag, and an If-Match naming an older version is refused.
/ServiceProviderConfig, /ResourceTypes, and /Schemas describe the rest. /Groups answers
only a directory for one organization. Bulk operations and sorting are not
supported.
Suspending
Section titled “Suspending”active: false, or Suspend in /manage, suspends a person. Every session ends and every token
stops working, refresh tokens included. Sign-in is refused after the first factor, so entering a
name alone does not reveal the suspension. active: true restores them. DELETE removes them.
What a directory cannot do
Section titled “What a directory cannot do”A provisioning token changes many accounts, so it cannot:
- set the password or email of anyone holding a
masks:scope, - suspend or delete the last active holder of
masks:manage, - change anyone’s scopes.
Every change is recorded in the person’s activity, marked as SCIM.
A directory for one organization
Section titled “A directory for one organization”A provisioning token can belong to one organization, so a
customer’s directory manages that customer’s people and nobody else. A token for every account, and a
client that may carry masks:scim, can come only from an owner, since either reaches managers too.
| Request | What happens |
|---|---|
| List or read users | Only the organization’s members. Anyone else is not found. |
| Create a user | A new account that joins as member. Its email must be at a domain proven for one of the organization’s providers, and is then treated as confirmed. Any other address is refused with 400 before masks looks for an account holding it. A user without an email is allowed. |
| Replace or patch a user | Allowed only for an account this directory created that belongs to no other organization and manages nothing. Anyone else is refused with 403, so an owner who adds an existing account cannot then take it over through the directory. |
| Delete a user | Removes them from the organization and revokes its tokens. The account stays. |
The organization’s directory keeps its own externalId for each member, so two organizations’
directories can use the same value. A userName, email, or externalId that is already taken is
refused with 409 and the same uniqueness detail every time, so the answer does not say who holds
it or where. An actor’s page in /manage shows the externalId each organization’s directory holds for
them.
Roles as groups
Section titled “Roles as groups”A directory for one organization manages roles through /Groups. Each of the organization’s roles
is one group. Its displayName is the role, its id is the organization’s uuid and the role
joined by a dot, and its members are the people who have accepted that role.
| Request | What happens |
|---|---|
| List or read groups | Every role, owner and member included. Filter with displayName eq or id eq. excludedAttributes=members leaves members out. |
| Add a member | Gives them that role in place of the one they held. |
| Remove a member | Moves them back to member. Removing from member changes nothing. They stay in the organization. |
Replace members with PUT or a replace operation |
Adds everyone named and moves everyone else back to member. People this directory did not create keep their role. For member, it only adds. |
| Create a group | Adds a role with that name. An existing role is a conflict. |
| Delete a group | Removes the role once nobody holds it. owner and member cannot be deleted. |
| Rename a group | Refused. |
A group takes only people this directory created who belong to no other organization and manage
nothing. Naming anyone else is refused with 400, and the answer is the same whether or not the
account exists. Removing the last owner is refused with 409. A role change revokes the person’s
access tokens for the organization and is recorded in its activity, marked as SCIM.
A token for every account lists no groups and cannot change them.