Skip to content

Single sign-on (ADFS, OpenID Connect, SAML 2.0)

Users log in with the identity provider of the organisation, e.g. Active Directory Federation Services (ADFS), via OpenID Connect (ADFS 2016 or later) or SAML 2.0 (every ADFS version). Their roles come from their groups: in Active Directory users are members of groups, ADFS sends them as claim values, and Anvil maps these values to roles. On every login the user gets exactly the roles of their groups.

  • Unknown users with a mapped group are created at their first login. They have no password and can only log in with single sign-on: users linked to a provider who log in otherwise (e.g. by magic link) are logged out and sent to their provider, where blocks and groups apply.
  • Config\Anvil::$passwordLogin = false also turns off Shield’s magic links.
  • Users without a mapped group cannot log in.
  • Every attempt is written to the audit log.
  • The SSO routes (/sso/<provider>, callback, single logout) accept 30 requests per minute and IP address; more are answered with HTTP 429.
  1. Log in with ADFS sends the browser to ADFS (authorization code flow with PKCE, state and nonce).
  2. ADFS authenticates the user (Windows login, password, MFA of ADFS) and returns a code to /sso/<provider>/callback.
  3. Anvil exchanges the code for an ID token (with the client secret) and checks it: signature with the keys of ADFS (JWKS), issuer, audience, authorized party, lifetime, nonce. Only asymmetric signatures (RS/PS/ES) are accepted.
  4. The groups claim is mapped to roles. The user is created if needed, gets the roles and the memberships of their tenants, and is logged in.

The user is identified by the subject of ADFS (sub), never by the email address.

In AD FS Management:

  1. Application group: Application Groups → Add Application Group → Server application accessing a web API.

  2. Server application: note the Client Identifier; add the redirect URI https://<your app>/sso/adfs/callback; on the next page generate a shared secret (client secret).

  3. Web API: use the client identifier as Identifier (the ID token is issued for it). Access control policy as required, e.g. Permit everyone or Permit everyone and require MFA.

  4. Permitted scopes of the web API: openid, profile, email and allatclaims (allatclaims puts the claims of the rules into the ID token).

  5. Issuance transform rules of the web API:

    • Send LDAP Attributes as Claims: User-Principal-Name → UPN, E-Mail-Addresses → E-Mail Address, Display-Name → Name.
    • Send Group Membership as a Claim, one rule per AD group: the AD group (e.g. APP-IAM-Administrators), outgoing claim type groups (type it in), outgoing value e.g. iam-admins. Only these values reach the application, the names of internal groups stay in AD.
  6. Logout URI (for the single logout): register the logout URI of the application at the server application, in PowerShell:

    Terminal window
    Set-AdfsServerApplication -TargetIdentifier '<client identifier>' -LogoutUri 'https://<your app>/sso/adfs/logout'

    ADFS uses it twice: it returns there after a logout in Anvil, and it calls it when the user logs out of another application of the same ADFS session (see Logout). Without it ADFS ends its session but does not return to the application, and logouts elsewhere do not reach Anvil.

app/Config/Anvil.php:

public array $sso = [
'adfs' => [
'type' => 'oidc',
'label' => 'ADFS',
'issuer' => 'https://adfs.example.com/adfs',
'clientId' => '0b5c2f6e-…',
'clientSecret' => '', // from .env
'scopes' => ['openid', 'profile', 'email', 'allatclaims'],
'groupsClaim' => 'groups',
],
];

.env (never in the repository):

anvil.sso.adfs.clientSecret = "…"

Options:

Option Default Purpose
type – oidc, or saml (see SAML 2.0)
label the id Text of the button: Log in with ADFS
issuer – Issuer of ADFS, https://<host>/adfs; the discovery document is <issuer>/.well-known/openid-configuration. Must be https
clientId, clientSecret –, '' Client of the application group; without secret a public client with PKCE
tokenAuth basic Client authentication at the token endpoint: basic or post
scopes ['profile', 'email'] Requested scopes; openid is always added; allatclaims for ADFS
groupsClaim groups Claim with the groups; with the claim type Role in ADFS: role
usernameClaim upn Claim for the username of new users (part before @)
params [] Additional parameters of the authorization request, e.g. ['prompt' => 'login'] to always ask for the password
leeway 60 Seconds of clock difference accepted for the token lifetime
linkByEmail false Link to an existing local user with the same email address; off, because it lets the provider take over local accounts. Only addresses the provider vouches for are linked: OIDC email_verified, or trustEmail
trustEmail false The provider’s email addresses are verified (a directory under your control, e.g. ADFS, which sends no email_verified); also for SAML

Show the login button with Anvil’s login view in app/Config/Auth.php:

public array $views = [
'login' => '\Anvil\Views\auth\login',
'layout' => '\Anvil\Views\auth\layout',
// …
];

Anvil reads these settings from the application’s Config\Auth (Anvil\Support\ShieldConfig::auth()): Anvil’s login and TOTP views extend the layout configured there, so an own layout applies to them as well.

Config\Anvil::$passwordLogin = false removes the password form from the login page (e.g. once all users log in with ADFS). Keep a local emergency administrator (break glass) and a panel that allows password login for it.

Run the migrations (php spark migrate --all) for the tables anvil_sso_identities, anvil_role_sso_groups and anvil_sso_logouts.

When everybody logs in with ADFS, the login page with its single button is a needless step. Send users to ADFS directly:

public string $ssoAutoRedirect = 'adfs';
  • The login page is still shown with /login?local, e.g. for the local emergency administrator.
  • It is also shown with a message (after a failed login, so there is no loop between Anvil and ADFS) and right after a logout, so that ADFS does not log the user in again at once.
  • Without $ssoAutoRedirect, pages of a panel that allows only one provider (authMethods() returns e.g. ['sso:adfs'], see Restricting panels) go to that provider directly; other panels show the login page.

In the role editor every role has a field ADFS groups: the values of the groups claim (e.g. iam-admins) that grant the role. Values are compared exactly (case and spaces count). A user with several mapped groups gets all their roles, also in several tenants (roles belong to a tenant).

  • At every login the roles of the user are replaced by the mapped roles, in all tenants; memberships of tenants without a mapped role are removed. Roles assigned by hand to an SSO user are therefore removed at the next login.
  • Changing the groups of a role requires that the editor could grant all permissions of the role (and super roles only by super users): mapping a group gives the role to all members of the group.

Log out in the user menu (POST /sso/logout, with the CSRF token of the application) ends both sessions:

  1. Anvil logs the user out (audit event auth.logout).
  2. The browser is sent to the end session endpoint of ADFS with the ID token of the login as id_token_hint; ADFS ends its session (and signs the user out of other applications of the same ADFS session).
  3. ADFS returns to the logout URI /sso/<id>/logout, which shows the login page. The next Log in with ADFS asks for the credentials again.

When the user logs out of another application of the same session at the provider, the provider tells Anvil, and the Anvil session ends as well (audit event auth.sso.logout_received):

Channel How Provider
Front channel The provider loads /sso/<id>/logout?sid=…&iss=… in a hidden frame of the user’s browser. ADFS 2016 or later (logout URI above), most providers
Back channel The provider posts a signed logout token to /sso/<id>/backchannel-logout, server to server. e.g. Keycloak; register the URL as back-channel logout URL of the client
  • A front-channel logout names the session of the provider (sid of the ID token). The issuer, if sent, must be the configured one.
  • A logout token is checked like an ID token (signature with the keys of the provider, issuer, audience, lifetime) and must carry the logout event, no nonce and a jti; each token is accepted once. It ends the session sid, or without sid all sessions of the user (sub) that started before.
  • The browser of a hidden frame usually sends no session cookie. Anvil therefore records the logout (anvil_sso_logouts, the session id only as hash); every session of the provider is checked on each request to a panel and ends with its next request. The user then sees the login page with a message.
  • anvil:prune deletes recorded logouts after 30 days (or the session lifetime, if longer).
  • The back channel needs no CSRF token (it is authenticated by the signature). Exclude it in app/Config/Filters.php: 'csrf' => ['except' => ['sso/*/backchannel-logout']]. It accepts 600 requests per minute and IP.

The ID token for the logout is kept in the server side session only. After a password login, the logout stays local.

Own logout links must point to site_url('sso/logout') (or $panel->logoutUrl()); Shield’s /logout only ends the Anvil session.

Closing the browser tab does not log out: the Anvil session (and the ADFS session) last until they expire. Their lifetime is set in app/Config/Session.php (expiration, 0 ends the session with the browser) and in ADFS (web SSO lifetime, persistent SSO).

final class AdminPanel extends Panel
{
public function authMethods(): ?array
{
return ['sso:adfs']; // or ['password', 'sso:adfs']; null: all
}
}

Users who logged in with another method are logged out; with a single allowed provider they are sent to it directly.

Event Context
auth.sso.login provider, username, groups, IP
auth.sso.failed provider, reason, detail, username and groups (if known), IP
auth.logout login method
auth.sso.logout_received provider, channel (front, back), subject (back channel without session)
user.created provider, username (first login)
user.roles_changed, tenant.member_added, tenant.member_removed changes at login
role.sso_groups_changed provider, added, removed
auth.sso.throttled security event: more than 30 requests per minute and IP to the SSO routes

Reasons of auth.sso.failed:

Reason Meaning
invalid_state Missing, reused or expired state (10 minutes), e.g. a forged callback
idp_error ADFS returned an error, e.g. access_denied
provider_error Discovery, keys or token endpoint failed (network, certificate, wrong client secret)
invalid_token The ID token failed a check (signature, issuer, audience, nonce, lifetime)
no_mapped_group None of the groups of the user is mapped to a role
account_exists A local user has the email address and linkByEmail is off or the address is not verified
user_inactive The user is deactivated or banned in Anvil

Users only see a generic message; the reason is in the audit log.

  • no_mapped_group although the user is in the AD group: check the groups in the audit entry. Empty: the group rule is missing, the claim type is not groups (set groupsClaim), or the scope allatclaims is missing.
  • invalid_token with “issuer”: the issuer must be exactly the issuer of the discovery document, usually https://<host>/adfs.
  • provider_error with HTTP 401 at the token endpoint: client secret or tokenAuth wrong.
  • Clock differences: keep the servers synchronised (NTP); leeway allows 60 seconds by default.
  • Discovery document and keys are cached for one hour; a new signing key of ADFS is loaded automatically.

For ADFS versions without OpenID Connect, or when SAML is required. Roles, groups, panels, audit events and logout work as described above.

  1. Relying Party Trusts → Add Relying Party Trust → Claims aware → Import data about the relying party published online: https://<your app>/sso/<id>/metadata (Anvil publishes its metadata there: entity ID, assertion consumer service, logout endpoint, signing certificate).
  2. Access control policy as required (e.g. with MFA).
  3. Claim issuance policy:
    • Send LDAP Attributes as Claims: User-Principal-Name → UPN, E-Mail-Addresses → E-Mail Address, Display-Name → Name.
    • Transform an Incoming Claim: UPN → Name ID, format Persistent Identifier (the stable subject of the user).
    • Send Group Membership as a Claim, one rule per AD group: outgoing claim type Group, value e.g. iam-admins.
  4. The signing certificate of ADFS: Service → Certificates → Token-signing → View → Details → Copy to File (Base-64), or from the federation metadata https://<adfs>/FederationMetadata/2007-06/FederationMetadata.xml.
public array $sso = [
'adfs' => [
'type' => 'saml',
'label' => 'ADFS',
'idpEntityId' => 'http://adfs.example.com/adfs/services/trust',
'idpSsoUrl' => 'https://adfs.example.com/adfs/ls/',
'idpSloUrl' => 'https://adfs.example.com/adfs/ls/',
'idpCertificate' => WRITEPATH . '../certs/adfs-signing.cer',
// for the logout: ADFS only accepts signed logout requests
'spCertificate' => WRITEPATH . '../certs/anvil-sp.crt',
'spPrivateKey' => '', // in .env: anvil.sso.adfs.spPrivateKey (PEM) or a path
],
];
Option Default Purpose
idpEntityId – Entity ID of ADFS (Federation Service Identifier), usually http://<host>/adfs/services/trust
idpSsoUrl, idpSloUrl –, none SAML endpoint of ADFS, https://<host>/adfs/ls/; without idpSloUrl the logout stays local
idpCertificate – Token signing certificate (PEM text or file); a list (idpCertificates) during a certificate rollover of ADFS
spCertificate, spPrivateKey none Certificate and key of the application: sign authentication and logout requests (required by ADFS for the logout) and decrypt encrypted assertions
groupsAttribute http://schemas.xmlsoap.org/claims/Group Claim type with the groups; with Role: http://schemas.microsoft.com/ws/2008/06/identity/claims/role
usernameAttribute UPN claim Username of new users (part before @)
subjectAttribute NameID A claim as stable subject instead of the NameID (transient NameIDs are rejected)

Create the key pair of the application, e.g.:

Terminal window
openssl req -x509 -newkey rsa:3072 -sha256 -days 730 -nodes -subj "/CN=<your app>" \
-keyout anvil-sp.key -out anvil-sp.crt

CSRF: ADFS posts the answer to /sso/<id>/callback. Exclude it from CodeIgniter’s CSRF filter in app/Config/Filters.php:

public array $globals = [
'before' => [
'csrf' => ['except' => ['api/*', 'sso/*/callback', 'sso/*/backchannel-logout']],
],
];

The answer is protected otherwise: it must be signed by ADFS, answer the request of this browser (see below) and can be used once.

  • The assertion must be signed with the token signing certificate (SHA-256, deprecated algorithms are rejected); responses with several assertions (signature wrapping) are rejected.
  • Issuer, audience (the entity ID of the application), destination and recipient (the callback URL, taken from app.baseURL, not from the request), lifetime (NotBefore, NotOnOrAfter), and InResponseTo.
  • The answer belongs to the request of the same browser: the request is bound to a cookie (SameSite=None; Secure; HttpOnly, 10 minutes), because the cross-site POST of ADFS does not carry the session cookie. Answers without this cookie (login CSRF) or a second time (replay) are rejected. The application must therefore run on HTTPS (localhost works for development).
  • The NameID must be persistent (or subjectAttribute set).

Logout messages of ADFS must be signed (ADFS signs them); unsigned ones are rejected, so a link on another site cannot log users out. With idpSloUrl and the key of the application, Log out sends a signed logout request to ADFS. ADFS ends its session and returns to /sso/<id>/sls. A logout request of ADFS (when the user logs out of another application of the same ADFS session) at /sso/<id>/sls ends the Anvil session as well.