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 = falsealso 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.
How a login works
Section titled “How a login works”- Log in with ADFS sends the browser to ADFS (authorization code flow
with PKCE,
stateandnonce). - ADFS authenticates the user (Windows login, password, MFA of ADFS) and
returns a code to
/sso/<provider>/callback. - 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.
- 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.
Setting up ADFS
Section titled “Setting up ADFS”In AD FS Management:
-
Application group: Application Groups → Add Application Group → Server application accessing a web API.
-
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). -
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.
-
Permitted scopes of the web API:
openid,profile,emailandallatclaims(allatclaimsputs the claims of the rules into the ID token). -
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 typegroups(type it in), outgoing value e.g.iam-admins. Only these values reach the application, the names of internal groups stay in AD.
- Send LDAP Attributes as Claims:
-
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.
Configuring Anvil
Section titled “Configuring 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.
Going to the provider directly
Section titled “Going to the provider directly”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.
Mapping groups to roles
Section titled “Mapping groups to roles”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.
Logout
Section titled “Logout”Log out in the user menu (POST /sso/logout, with the CSRF token of the
application) ends both sessions:
- Anvil logs the user out (audit event
auth.logout). - 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). - 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.
Logout in another application
Section titled “Logout in another application”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 (
sidof 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 sessionsid, or withoutsidall 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:prunedeletes 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).
Restricting panels
Section titled “Restricting panels”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.
Audit events
Section titled “Audit events”| 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.
Troubleshooting
Section titled “Troubleshooting”no_mapped_groupalthough the user is in the AD group: check the groups in the audit entry. Empty: the group rule is missing, the claim type is notgroups(setgroupsClaim), or the scopeallatclaimsis missing.invalid_tokenwith “issuer”: the issuer must be exactly theissuerof the discovery document, usuallyhttps://<host>/adfs.provider_errorwith HTTP 401 at the token endpoint: client secret ortokenAuthwrong.- Clock differences: keep the servers synchronised (NTP);
leewayallows 60 seconds by default. - Discovery document and keys are cached for one hour; a new signing key of ADFS is loaded automatically.
SAML 2.0
Section titled “SAML 2.0”For ADFS versions without OpenID Connect, or when SAML is required. Roles, groups, panels, audit events and logout work as described above.
Setting up ADFS (relying party trust)
Section titled “Setting up ADFS (relying party trust)”- 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). - Access control policy as required (e.g. with MFA).
- 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.
- Send LDAP Attributes as Claims:
- 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.
Configuring Anvil
Section titled “Configuring Anvil”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.:
openssl req -x509 -newkey rsa:3072 -sha256 -days 730 -nodes -subj "/CN=<your app>" \ -keyout anvil-sp.key -out anvil-sp.crtCSRF: 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.
What is checked
Section titled “What is checked”- 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), andInResponseTo. - 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 (localhostworks for development). - The NameID must be persistent (or
subjectAttributeset).
Logout
Section titled “Logout”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.