Appearance
Signing in with SSO (OIDC and SAML)
reportd signs people in through OpenID Connect providers: company SSO such as Microsoft Entra ID, Okta and Google Workspace, or any other provider that does discovery. It also signs them in through SAML 2.0 identity providers, such as ADFS (SAML). Passwords keep working unless you turn them off.
SSO needs a licence with
sso(licence.md). An install that has never had one starts with providers configured, but asks for a licence first: setup won't finish without one, and an install with users opens only the Licence dialog until one is installed (licence.md). After a lapse, providers keep signing in the people they already sign in, but bring no one new: JIT makes no user, no invite binds and no identity is linked, and the sign-in page says a licence is needed. Mapped roles that would make a designer past the licence's designer seats are dropped at sign-in (licence.seat-refused).
How it works
- The studio's and the viewer's sign-in pages show a button per provider, above the password form. The button goes to
/v1/auth/oidc/<id>/start, which sends the browser to the provider. - The provider sends the browser back to
<publicURL>/v1/auth/oidc/<id>/callback. reportd checks the answer and the ID token, signs the user in with the usual 24-hour session cookie, and returns to the page they started from. - reportd keeps no tokens from the provider. A user disabled at the provider can't sign in again, but a session they already have lasts up to a day. Disable them in reportd too to end it at once.
- A user is identified by the provider's issuer and subject (
sub), never by email.
Config
Every provider needs publicURL, because its redirect URL is made from it. reportd refuses to start without it.
yaml
publicURL: https://reports.example.com
secrets:
providers: [env]
auth:
passwords: true
oidc:
providers:
- id: entra # [a-z0-9-]; in URLs and the audit log
name: Contoso # the button's label
issuer: https://login.microsoftonline.com/<tenant-id>/v2.0
clientId: <application id>
clientSecret: secret:env:REPORTD_OIDC_ENTRA_SECRET
scopes: [openid, email, profile]
allowedDomains: [contoso.com]
trustEmail: true
jit: true
roles:
claim: roles
rules:
- value: ReportDesigners
role: designer
environments: [dev, test]
- value: ReportPublishers
role: publisher
environments: ["*"]
default: []| Field | Meaning |
|---|---|
id | 1 to 32 lower-case letters, digits and hyphens. Changing it later breaks the redirect URL and unlinking by provider. |
name | The sign-in button's label. Defaults to the id. |
issuer | The issuer exactly as the provider's ID tokens say it. It must be https (or http on localhost). |
clientId, clientSecret | The app's client id, and its secret as a secret: reference. A literal secret is refused at start. |
scopes | Asked for. openid is added if missing. Default: openid, email, profile. |
allowedDomains | When set, only these domains sign in: for Google, the hd claim; for others, the email's domain, when the email is verified or trustEmail is set. |
trustEmail | The provider owns these addresses. Invites then bind by email without email_verified, and allowedDomains checks the email. |
jit | Make a user at their first sign-in. Without it, only invited or linked users get in. |
prompt | Sent as prompt: login, consent, select_account or none. |
domainHint | Sent as hd to Google and domain_hint to others, to skip the account picker. |
roles | Maps a claim's values to roles. See Roles. |
At start reportd checks the ids, issuers, secrets, roles and environments. It logs each provider's redirect URL:
INFO oidc: provider configured; register its redirect URL with it provider=entra redirectURL=https://reports.example.com/v1/auth/oidc/entra/callbackRegister that URL with the provider. reportd never takes it from the request's Host.
reportd fetches each provider's discovery document in the background. Until it answers, GET /v1/auth/providers says available: false, and the sign-in pages can show it as not available yet. reportd keeps trying, every 5 seconds at first and every 5 minutes at most.
On a workstation, publicURL is http://localhost:43123, or the Vite dev server's origin with pnpm dev, which forwards /v1. Open reportd at that same origin: the sign-in's cookie is set for the host you start from, and the provider sends the browser back to publicURL.
Trying it on a workstation
tools/mock-oidc is a provider for trying sign-in without a real tenant. It listens on 127.0.0.1:43141 only and shows a page where you pick who signs in: their subject, email, groups and Google-style hd. A few ready-made people have the groups designers and publishers, and one has an email in another domain.
Add a provider that points at it:
yaml
publicURL: http://127.0.0.1:43123
secrets:
providers: [env]
auth:
oidc:
providers:
- id: mock
name: Mock IdP
issuer: http://127.0.0.1:43141
clientId: reportd
clientSecret: secret:env:REPORTD_OIDC_MOCK_SECRET
jit: true
roles:
claim: groups
rules:
- { value: designers, role: designer, environments: ["*"] }
- { value: publishers, role: publisher, environments: ["*"] }Set REPORTD_OIDC_MOCK_SECRET to any value for both, and start the tool with reportd's config, from which it takes the client id and secret:
sh
go run ./tools/mock-oidc -config config.yamlStart the tool before reportd, or wait for reportd's next discovery attempt. Then open http://127.0.0.1:43123 and use the "Sign in with Mock IdP" button. -client-id and -secret-file (or REPORTD_OIDC_SECRET) give the client without a config.
Users
When someone signs in, reportd finds their user like this:
- A known identity signs its user in. A disabled user gets
disabled. - An invite binds the identity to its user, once. The invite is for a provider and an email. The email must match the token's (ignoring case), and must be verified, or the provider must have
trustEmail. - With
jit, reportd makes a user named after the email, lower-cased, with any character a username can't have turned into-. If the name is taken, it adds-2,-3and so on. A name never links to an existing user. The new user has no password and no roles of their own; the role rules give them theirs. - Anything else gets
no-account.
An administrator invites someone when creating the user, in place of a password:
sh
curl -X POST https://reports.example.com/v1/users -H "Authorization: Bearer $TOKEN" \
-d '{"username":"ada","invite":{"provider":"entra","email":"ada@contoso.com"},
"assignments":[{"role":"designer","environments":["dev"]}]}'PATCH /v1/users/{name} with invites replaces a user's invites, for an existing user. GET /v1/users/{name} shows their identities, invites and mapped roles. DELETE /v1/users/{name}/identities/{provider} unlinks one.
A signed-in user can link another identity: POST /v1/me/identities/{provider} answers the provider's URL for the page to open, and the provider's answer links the identity to them. DELETE /v1/me/identities/{provider} unlinks it, unless it is their only way to sign in.
A user without a password can't sign in with one or change one. An administrator may give them one, as for anyone.
Roles
A user has two sets of roles:
- Granted in reportd by an administrator (
assignments). - Mapped from the provider (
mapped): at every sign-in, the role rules turn the claim's values into roles. When no rule matches, the user getsroles.default. These replace the mapped roles of the last sign-in, so leaving an IdP group drops its role at the next sign-in. Only the IdP changes them.
The claim may be a string or a list of strings. A missing claim has no values.
A sign-in that would take user.manage from the last user who has it keeps their old mapped roles and logs a warning, so a change at the IdP can't lock everyone out.
Passwords and break-glass
auth.passwords: false turns password sign-in off for everyone except admin.username. POST /v1/login then answers 403 passwords-off for anyone else, and the sign-in pages show only the provider buttons, with a small "Administrator sign-in" link that shows the password form.
The administrator reportd seeds (admin.username) can always use a password, so you can get back in when the IdP is down or misconfigured. Give it a secret: password on a server. Its sign-ins are audited as user.sign-in admin (password, break-glass) while passwords are off, and failed attempts are throttled as usual.
A password alone is a weak way in for the account that can do everything, so give the break-glass administrator a second factor (mfa.md): auth.mfa: admins makes it set one up, and a passkey is best. Its sign-ins are then audited as (password, break-glass, passkey) and so on. Keep its recovery codes with its password. If it loses both its factor and its codes, stop reportd and run reportd mfa reset <admin.username>.
People who sign in through a provider are never asked for reportd's second factor. Set up MFA at the provider.
Microsoft Entra ID
- In the Entra admin centre, open App registrations and add a registration. Choose Accounts in this organizational directory only. Under Redirect URI, choose Web and enter the URL reportd logs.
- Copy the Application (client) ID into
clientId. The issuer ishttps://login.microsoftonline.com/<Directory (tenant) ID>/v2.0. The multi-tenantcommonendpoint isn't supported. - Under Certificates & secrets, add a client secret. Put its value in your secret store and reference it from
clientSecret. - Under Token configuration, add the optional
emailclaim to the ID token. - For roles, under App roles, add a role per reportd role, such as
ReportDesigners, with allowed member types Users/Groups. Assign people or groups to them in Enterprise applications → Users and groups. Useclaim: roles.
Notes:
- Entra puts the app roles a user has in the
rolesclaim of the ID token. Rules should use app roles, not groups: group claims are object GUIDs, and past 200 groups Entra sends a link (_claim_names) instead of the list. reportd treats that as no values and logs it. - Entra doesn't send
email_verified, and tenant administrators can change a user'semail. SettrustEmail: trueonly withallowedDomainslimited to domains verified in your tenant. reportd uses the email only to name new users and match invites, never to identify anyone.
yaml
- id: entra
name: Contoso
issuer: https://login.microsoftonline.com/<tenant-id>/v2.0
clientId: <application id>
clientSecret: secret:env:REPORTD_OIDC_ENTRA_SECRET
allowedDomains: [contoso.com]
trustEmail: true
jit: true
roles:
claim: roles
rules:
- { value: ReportDesigners, role: designer, environments: [dev, test] }
- { value: ReportPublishers, role: publisher, environments: ["*"] }Okta
- In the Okta admin console, open Applications → Create App Integration. Choose OIDC - OpenID Connect and Web Application.
- Under Sign-in redirect URIs, enter the URL reportd logs. Client authentication is Client secret. You may tick Require PKCE as additional verification; reportd always sends PKCE.
- Assign the people or groups who may sign in.
- Copy the client id and secret. The issuer is your org's URL (
https://<org>.okta.com), or a custom authorization server's (https://<org>.okta.com/oauth2/default). - For roles, add a
groupsclaim to the ID token:- With the org authorization server: on the app's Sign On tab, under OpenID Connect ID Token, set Groups claim type to Filter, the name to
groups, and a filter such as Starts withReport. - With a custom authorization server: under Security → API → Authorization Servers, add a claim named
groupsto the ID token, value type Groups, with a filter.
- With the org authorization server: on the app's Sign On tab, under OpenID Connect ID Token, set Groups claim type to Filter, the name to
Okta sends email_verified, so trustEmail isn't needed.
yaml
- id: okta
name: Okta
issuer: https://<org>.okta.com
clientId: <client id>
clientSecret: secret:env:REPORTD_OIDC_OKTA_SECRET
allowedDomains: [example.com]
jit: true
roles:
claim: groups
rules:
- { value: ReportDesigners, role: designer, environments: [dev, test] }
- { value: ReportPublishers, role: publisher, environments: ["*"] }Google Workspace
- In the Google Cloud console, under APIs & Services → OAuth consent screen, choose the Internal user type, so only your Workspace's accounts can consent.
- Under Credentials, create an OAuth client ID of type Web application. Add the URL reportd logs to Authorized redirect URIs.
- Copy the client id and secret. The issuer is
https://accounts.google.com.
Notes:
allowedDomainschecks thehdclaim, which Google sets for Workspace accounts only. A personal Gmail account has none and getsdomain. Thehdparameter thatdomainHintsends is only a hint to the account picker; the claim is what counts.- Google puts no groups in the ID token, so role rules can't match. Give roles with
roles.default, or let an administrator grant them.
yaml
- id: google
name: Google
issuer: https://accounts.google.com
clientId: <client id>.apps.googleusercontent.com
clientSecret: secret:env:REPORTD_OIDC_GOOGLE_SECRET
allowedDomains: [example.com]
domainHint: example.com
jit: true
roles:
default:
- { role: viewer, environments: ["*"] }SAML
reportd also signs people in through SAML 2.0 identity providers (IdPs), for companies whose IT team offers SAML but not OIDC; ADFS is the usual case. Accounts, invites, jit, mapped roles, break-glass, the sign-in buttons, the error codes and the audit work as for OIDC, above.
How SAML sign-in works
- The sign-in pages show a button per IdP, beside the OIDC ones. It goes to
/v1/auth/saml/<id>/start, which sends the browser to the IdP with a signed request. - The IdP's page posts its answer to reportd's assertion consumer service (ACS),
<publicURL>/v1/auth/saml/<id>/acs. reportd checks it, signs the user in with the usual session cookie and returns to the page they started from. - Only sign-ins that start at reportd work. An IdP's app dashboard (Entra's My Apps, the Okta dashboard, Google's app launcher) can't post an answer nobody asked for; point its tile at
<publicURL>/v1/auth/saml/<id>/startinstead, as a bookmark app or with the app's "sign-on URL" or "start URL" setting. - Signing out ends the reportd session only. There is no single logout.
- A user is identified by the IdP's entity ID and their NameID, or an attribute you name, never by email. A transient NameID changes at every sign-in, so it is refused.
The flow cookie that carries a sign-in to the ACS is SameSite=None; Secure, because the IdP's answer is a cross-site POST. So publicURL must be https, or http://localhost on a workstation. Chrome, Edge and Firefox keep a Secure cookie from http://localhost, but not from http://127.0.0.1, and Safari not from either.
SAML config
yaml
publicURL: https://reports.example.com
secrets:
providers: [file]
dir: /etc/reportd/secrets
auth:
saml:
key: secret:file:saml-sp.pem # optional; see "The SP key"
providers:
- id: adfs # [a-z0-9-]; unique across auth.oidc and auth.saml
name: Contoso # the button's label
metadataURL: https://adfs.contoso.com/FederationMetadata/2007-06/FederationMetadata.xml
subject: nameid # or attribute:<name>
allowedDomains: [contoso.com]
trustEmail: true
jit: true
roles:
attribute: http://schemas.microsoft.com/ws/2008/06/identity/claims/role
rules:
- { value: ReportDesigners, role: designer, environments: [dev, test] }
- { value: ReportPublishers, role: publisher, environments: ["*"] }
default: []| Field | Meaning |
|---|---|
id | 1 to 32 lower-case letters, digits and hyphens, unique across OIDC and SAML providers. It is in reportd's entity ID and ACS URL, so changing it means setting the IdP up again. |
name | The sign-in button's label. Defaults to the id. |
metadataURL, metadataFile, metadata | The IdP's metadata: exactly one of a URL (https, or http on a loopback host), a file, or the XML itself. |
entityID | reportd's entity ID with this IdP. Default: <publicURL>/v1/auth/saml/<id>/metadata. |
subject | Who signed in: nameid (the default), or attribute:<name> for an attribute that must have exactly one value. |
attributes | email and name: the attributes to read them from. Unset, reportd tries the common names (below). |
allowedDomains | When set, only these email domains sign in. The email counts only with trustEmail. |
trustEmail | The IdP owns these addresses. SAML has no email_verified, so without it invites never bind and allowedDomains lets no one in. |
jit | Make a user at their first sign-in. |
forceAuthn | Ask the IdP to sign the user in again, even if they have a session there. |
nameIDFormat | persistent, emailAddress or unspecified: ask the IdP for that NameID format. Unset (the default), the request asks for none, and the IdP sends the NameID it is set up to send. Set it only when the IdP needs one, as Okta does with signed requests on. |
signRequests | Sign requests (RSA-SHA256). On unless false. |
allowSHA1 | Accept SHA-1 signatures, for an old ADFS farm. Off by default. |
roles | attribute holds the values the rules match; default is for a user no rule matches. As for OIDC's roles. |
At start reportd checks the ids, publicURL, the key, metadata in a file or the config, the subject, the roles and environments. It logs, for each IdP, what to give it:
INFO saml: provider configured; give the IdP its metadata URL, or its entity ID and ACS URL provider=adfs metadataURL=https://reports.example.com/v1/auth/saml/adfs/metadata entityID=https://reports.example.com/v1/auth/saml/adfs/metadata acsURL=https://reports.example.com/v1/auth/saml/adfs/acsGET /v1/auth/saml/<id>/metadata is reportd's metadata for that IdP: the entity ID, the ACS (HTTP-POST), the SP certificate for signing and encryption, whether requests are signed, that assertions must be, and the persistent NameID format. Most IdPs import it from the URL or a downloaded copy.
reportd reads metadata from a URL in the background and again every 12 hours, and a file every 12 hours too, keeping the last good copy when a read fails. Until it has read it once, GET /v1/auth/providers says available: false and the button shows "not available yet". Every signing certificate the metadata lists is accepted, so an IdP that publishes its next certificate early rolls over without a gap. The metadata's own signature isn't checked: its trust comes from TLS, or from you, for a file.
The attributes read by default are, in order:
- email:
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress,email,mail,emailAddress,Email,urn:oid:0.9.2342.19200300.100.1.3andurn:oid:1.2.840.113549.1.9.1. When none is sent and the NameID's format isemailAddress, the NameID is the email. - name:
http://schemas.microsoft.com/identity/claims/displayname,http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name,displayName,name,cn,urn:oid:2.16.840.1.113730.3.1.241andurn:oid:2.5.4.3.
Attribute names compare exactly, case included.
The SP key
reportd signs its requests and decrypts encrypted assertions with one key pair for all SAML IdPs. Keycloak refuses unsigned requests by default, and ADFS encrypts assertions to the certificate in reportd's metadata.
auth.saml.keyis asecret:reference to a PEM file with an RSA private key of at least 2048 bits and its certificate.- Without it, reportd makes an RSA-3072 key with a self-signed certificate valid for 10 years, keeps it in
dataDir/saml-sp.pem(mode 0600) and logs that. Backups leave the file out (backup.md).
IdPs pin the certificate; they don't check its dates or issuer. Replacing the key means importing reportd's metadata into every IdP again, so plan it as an outage. To make a key yourself:
sh
openssl req -x509 -newkey rsa:3072 -nodes -days 3650 -subj "/CN=reportd SAML" -keyout key.pem -out cert.pem
cat key.pem cert.pem > /etc/reportd/secrets/saml-sp.pem && chmod 600 /etc/reportd/secrets/saml-sp.pemWhat reportd checks
A response must answer the request reportd sent from this browser, hold exactly one assertion, and be signed, the response or the assertion, by a certificate from the IdP's metadata, with SHA-256 or stronger (SHA-1 only with allowSHA1). Its destination, recipient and audience must be reportd's, its issuer the IdP's entity ID, and its times current, with three minutes of clock skew. An assertion is used once. Encrypted assertions are decrypted with the SP key: AES-CBC or AES-GCM, with the key wrapped by RSA-OAEP (both the 2001 rsa-oaep-mgf1p and XML Encryption 1.1's rsa-oaep). RSA PKCS#1 v1.5 key transport is refused. An IdP's certificate must also be within its own dates.
SAML users and roles
As for OIDC, with these differences:
- The identity's issuer is the IdP's entity ID.
GET /v1/users/{name}shows it. - An invite binds by email only with
trustEmail, since SAML can't say an email is verified. - The role rules match the values of
roles.attribute. Group values are what the IdP sends: Keycloak sends full paths (/designers) unless "Full group path" is off, Google Workspace sends group names, and Entra sends group object IDs (use app roles instead). - People who sign in through a SAML IdP are never asked for reportd's second factor. Set up MFA at the IdP.
- A signed-in user links a SAML identity with
POST /v1/me/identities/{provider}, as for OIDC. The IdP's answer comes back to the ACS, which can't see the browser's session (a cross-site POST doesn't carry it), so it hands the identity toGET /v1/auth/saml/<id>/link, which links it to the signed-in user.
Trying SAML on a workstation
tools/mock-saml is an IdP for trying SAML sign-in without a real one. It listens on 127.0.0.1:43142 only and shows a page where you pick who signs in (their NameID, email, name and groups) and how the response is made: signed or not, SHA-1, encrypted, a transient NameID, or refused. A few ready-made people have the groups designers and publishers, and one has an email in another domain.
Add an IdP that points at it. publicURL must be http://localhost, not 127.0.0.1 (see How SAML sign-in works):
yaml
publicURL: http://localhost:43123
auth:
saml:
providers:
- id: mock-saml
name: Mock SAML IdP
metadataURL: http://127.0.0.1:43142/metadata
trustEmail: true
jit: true
roles:
attribute: groups
rules:
- { value: designers, role: designer, environments: ["*"] }
- { value: publishers, role: publisher, environments: ["*"] }Start the tool with reportd's config, from which it finds reportd's SP metadata (at publicURL), to check request signatures and encrypt assertions:
sh
go run ./tools/mock-saml -config config.yamlThen open http://localhost:43123 in Chrome, Edge or Firefox and use the "Sign in with Mock SAML IdP" button. The tool keeps its key in your user cache folder (-key names another file), so reportd's copy of its metadata stays good when you restart it. If it makes a new key, restart reportd, which reads the metadata at start. Without -config it still answers, but checks no signatures and encrypts nothing.
SAML with Microsoft Entra ID
Use SAML with Entra when an app must be a SAML enterprise application; otherwise OIDC is simpler.
- In the Entra admin centre, open Enterprise applications → New application → Create your own application, choose Integrate any other application you don't find in the gallery, and then Single sign-on → SAML.
- Under Basic SAML Configuration, use Upload metadata file with reportd's metadata (
/v1/auth/saml/<id>/metadata), or enter the Identifier (Entity ID) and Reply URL reportd logs. Set Sign on URL to<publicURL>/v1/auth/saml/<id>/start, so the My Apps tile starts the sign-in at reportd. - Under SAML Certificates, copy the App Federation Metadata Url (it ends in
?appid=<application id>) intometadataURL. - For roles, add app roles to the app's registration (App registrations → App roles, member types Users/Groups), such as
ReportDesigners, and assign people or groups to them under Users and groups. Entra sends them inhttp://schemas.microsoft.com/ws/2008/06/identity/claims/role. - Optional, and recommended: under Verification certificates, upload reportd's certificate and tick Require verification certificates, so Entra accepts only requests reportd signed. Entra then also stops IdP-initiated sign-in and its own Test button.
Notes:
- Entra's default NameID is the user's UPN, which can change. Use the object ID instead: either
subject: attribute:http://schemas.microsoft.com/identity/claims/objectidentifier, which Entra always sends, as below, or set Attributes & Claims → Unique User Identifier to the sourceuser.objectidwith the format Persistent. - Don't set
nameIDFormatfor Entra: when a request names a format, Entra sends a pairwise ID of its own instead of the configured one. - Entra sends the email from
user.mailinhttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress. As for OIDC, settrustEmail: trueonly withallowedDomainslimited to domains verified in your tenant. - Group claims carry object IDs, and past 150 groups Entra sends a
groups.linkattribute instead of the list. reportd treats that as no groups and logs it. Use app roles, or choose Groups assigned to the application in the group claim. - Entra signs the assertion with SHA-256. Token encryption is optional (it needs Entra ID P1 or P2): upload reportd's certificate under Token encryption and activate it.
yaml
- id: entra-saml
name: Contoso
metadataURL: https://login.microsoftonline.com/<tenant-id>/federationmetadata/2007-06/federationmetadata.xml?appid=<application id>
subject: attribute:http://schemas.microsoft.com/identity/claims/objectidentifier
allowedDomains: [contoso.com]
trustEmail: true
jit: true
roles:
attribute: http://schemas.microsoft.com/ws/2008/06/identity/claims/role
rules:
- { value: ReportDesigners, role: designer, environments: [dev, test] }
- { value: ReportPublishers, role: publisher, environments: ["*"] }SAML with Okta
- In the Okta admin console, open Applications → Create App Integration and choose SAML 2.0.
- Set Single sign-on URL to the ACS URL reportd logs and Audience URI (SP Entity ID) to its entity ID.
- Set Name ID format to Persistent and Application username to Custom with
user.getInternalProperty("id"), the Okta user ID. Okta's NameID is always the application username, so the format alone changes nothing; the default username is the Okta username, which can change. Users already assigned keep their old application username until it is updated. - Add an attribute statement
emailwith the valueuser.email. - For roles, add a group attribute statement named
groups, with a filter such as Starts withReport. Okta sends group names. - Assign the people or groups who may sign in, and copy the app's metadata URL from its Sign On tab (
https://<org>.okta.com/app/<app id>/sso/saml/metadata). - Optional: to have Okta check reportd's signatures, upload reportd's certificate as the Signature Certificate and tick Signed Requests. Okta then needs a NameIDPolicy in the request, so set
nameIDFormat: persistent. Okta then also takes the ACS URL from the request.
Okta signs the response and the assertion with SHA-256 by default. Set trustEmail only if Okta's profile emails are ones your organisation manages, for example from your directory, and limit allowedDomains.
yaml
- id: okta-saml
name: Okta
metadataURL: https://<org>.okta.com/app/<app id>/sso/saml/metadata
allowedDomains: [example.com]
trustEmail: true
jit: true
roles:
attribute: groups
rules:
- { value: ReportDesigners, role: designer, environments: [dev, test] }
- { value: ReportPublishers, role: publisher, environments: ["*"] }SAML with Google Workspace
Unlike Google's OIDC, Google's SAML can send groups, so roles can be mapped.
- In the Admin console, open Apps → Web and mobile apps → Add app → Add custom SAML app.
- On Google Identity Provider details, download the metadata (Option 2). Google has no metadata URL, so use
metadataFile. When Google renews its certificate, download the file again; reportd reads it again every 12 hours. - Under Service provider details, set ACS URL and Entity ID to the ones reportd logs, and Start URL to
<publicURL>/v1/auth/saml/<id>/start. Name ID format may stay as it is; Name ID is Basic Information → Primary email. Signed response may stay off: Google then signs the assertion, which is enough. - Under Attribute mapping, map Primary email to
email. For roles, under Group membership, choose the groups (up to 75) and name the app attributegroups. Google sends the names of the chosen groups the user belongs to, directly or through another group. A group that is renamed must be chosen again. - Turn the app ON for the organisational units that may sign in.
Notes:
- The NameID is the user's primary email, the only stable choice Google offers, and it isn't stable: an address can be renamed and later given to someone else, who would then sign in as the old user. Limit
allowedDomainsto your domains, and disable or delete the reportd user when you reuse an address. - Google can't check signed requests (it has no field for reportd's certificate) and ignores the signature.
yaml
- id: google-saml
name: Google
metadataFile: /etc/reportd/google-saml-metadata.xml
allowedDomains: [example.com]
trustEmail: true
jit: true
roles:
attribute: groups
rules:
- { value: Report designers, role: designer, environments: [dev, test] }
- { value: Report publishers, role: publisher, environments: ["*"] }SAML with ADFS
In AD FS Management, open Relying Party Trusts → Add Relying Party Trust, choose Claims aware, and Import data about the relying party published online from reportd's metadata URL. That needs ADFS to reach
publicURLover https; otherwise download the metadata and import the file. PowerShell does the same, and keeps the trust in step with reportd's metadata:powershellAdd-AdfsRelyingPartyTrust -Name "reportd" -MetadataUrl "https://reports.example.com/v1/auth/saml/adfs/metadata" -MonitoringEnabled $true -AutoUpdateEnabled $true -AccessControlPolicyName "Permit everyone"Add claim issuance rules. First a persistent NameID from the account's
objectGUID, which never changes (two custom rules):c:[Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname", Issuer == "AD AUTHORITY"] => add(store = "Active Directory", types = ("urn:reportd:objectguid"), query = ";objectGUID;{0}", param = c.Value); c:[Type == "urn:reportd:objectguid"] => issue(Type = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier", Value = c.Value, Properties["http://schemas.xmlsoap.org/ws/2005/05/identity/claimproperties/format"] = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent");Then a Send LDAP Attributes as Claims rule: E-Mail-Addresses to E-Mail Address, Display-Name to Name, and Token-Groups - Unqualified Names to Role.
Optional, and recommended: make ADFS refuse requests reportd didn't sign:
powershellSet-AdfsRelyingPartyTrust -TargetName "reportd" -SignedSamlRequestsRequired $true
Notes:
- ADFS encrypts assertions to the certificate in reportd's metadata (AES-256-CBC, with the key wrapped by
rsa-oaep-mgf1p), and reportd decrypts them. - ADFS signs with SHA-256 unless the trust's Secure hash algorithm is SHA-1, as on old farms. Change it to SHA-256; set
allowSHA1: trueonly if you can't. - Don't set
nameIDFormatfor ADFS: a request for a format the claim rules don't issue fails. - The metadata URL is
https://<adfs host>/FederationMetadata/2007-06/FederationMetadata.xml. ADFS lists its next token-signing certificate there before it switches, and reportd accepts every certificate listed, so automatic rollover works.
yaml
- id: adfs
name: Contoso
metadataURL: https://adfs.contoso.com/FederationMetadata/2007-06/FederationMetadata.xml
allowedDomains: [contoso.com]
trustEmail: true
jit: true
roles:
attribute: http://schemas.microsoft.com/ws/2008/06/identity/claims/role
rules:
- { value: ReportDesigners, role: designer, environments: [dev, test] }
- { value: ReportPublishers, role: publisher, environments: ["*"] }SAML with Keycloak
This recipe was checked against Keycloak 26.8.1.
- In the realm, open Clients → Import client and import reportd's metadata (download it from
/v1/auth/saml/<id>/metadata). The client's ID is reportd's entity ID. The import takes reportd's certificates, so the client checks reportd's request signatures (Client signature required), signs the response and the assertion with RSA-SHA256, encrypts the assertion, and sends a persistent NameID. - Set Home URL to
<publicURL>/v1/auth/saml/<id>/start, so the account console's link starts the sign-in at reportd. - For the email, add a mapper on the client's Client scopes tab, in its
-dedicatedscope (Add mapper → By configuration → User Property): propertyemail, SAML attribute nameemail. - For roles, add a Group list mapper with the SAML attribute name
groups. With Full group path on (the default), values are paths such as/designers, so the rules match those; turn it off to match plain names. Only the groups a user is directly in are sent.
Notes:
- The persistent NameID is
G-and a UUID that Keycloak makes per client and keeps on the user (in the attributesaml.persistent.name.id.for.<client>), so it stays the same. Deleting that attribute, or the client, gives everyone new identities. - The issuer, and so the identity's issuer in reportd, is the realm's URL,
https://<host>/realms/<realm>. Set Keycloak's hostname (KC_HOSTNAME) before the first sign-in: a changed hostname is a changed issuer, and everyone gets new users. - Keycloak encrypts with AES-256-GCM and XML Encryption 1.1's RSA-OAEP (SHA-256 and MGF1-SHA-256), whatever reportd's metadata lists; reportd decrypts both.
- Keycloak's metadata URL is
https://<host>/realms/<realm>/protocol/saml/descriptor.
yaml
- id: keycloak
name: Keycloak
metadataURL: https://sso.example.com/realms/<realm>/protocol/saml/descriptor
trustEmail: true
jit: true
roles:
attribute: groups
rules:
- { value: /designers, role: designer, environments: [dev, test] }
- { value: /publishers, role: publisher, environments: ["*"] }Checking a SAML IdP
Before relying on an IdP, check by hand:
- reportd starts and logs the IdP's metadata, entity ID and ACS URLs, and
GET /v1/auth/providerssaysavailable: true. - A user signs in from the button and returns to the page they started from. The IdP's dashboard tile, pointed at
start, does the same. GET /v1/users/{name}shows the identity with the IdP's entity ID and a NameID (or attribute) that doesn't change: sign in again from another browser and the same user is found.GET /v1/meshows the roles the user's groups or app roles map to. Remove them from the group and sign in again: the role is gone.- With
allowedDomains, a user from another domain getsdomain. Withoutjit, an uninvited user getsno-account. - reportd's log has no
saml: sign-in failed. If it has, itsreasonsays which check failed.
When a sign-in fails
The browser goes to the sign-in page with signInError set to a code. The reason is in reportd's log (oidc: sign-in failed or saml: sign-in failed), never on the page.
| Code | Meaning |
|---|---|
denied | The user, or the provider's policy, refused (access_denied, or a SAML status of AuthnFailed or RequestDenied). |
expired | The sign-in took more than 10 minutes, reportd restarted during it, the answer was used already, or a SAML IdP posted an answer reportd didn't ask for (IdP-initiated). |
no-account | No user has this identity or an invite for it, and the provider has no jit. |
disabled | The user is disabled in reportd. |
domain | The domain isn't in allowedDomains, or the email isn't verified. |
failed | Anything else: a bad answer, token or assertion, the provider unavailable, or too many failed sign-ins from this address. |
Failed callbacks and ACS posts count with failed password sign-ins: twenty from one address in 15 minutes block that address until the window passes.
Audit
| Action | When |
|---|---|
user.sign-in <name> (oidc:<provider>) | A sign-in through a provider, or (saml:<provider>) through a SAML IdP. Password sign-ins are (password), or (password, break-glass) while passwords are off, with the second factor added when there is one (mfa.md). |
user.sign-in-failed <provider> <code> | A failed sign-in through a provider. Failed passwords stay in the throttle counts and the access log. |
user.sign-out <name> (session <id>) | A sign-out, or a session its user ended in the Account dialog; (by <admin>) when an administrator ended it (state.md). |
user.create <name> (oidc:<provider>) | A user made at their first sign-in, or (saml:<provider>). |
user.invite <name> <provider> | An administrator invited a user. |
user.identity-link <name> <provider> | An invite bound an identity, or a user linked one. |
user.identity-unlink <name> <provider> | A user or an administrator unlinked one. |
user.roles-mapped <name> | The role rules gave other roles than at the last sign-in. |
Checking a provider
Before relying on a provider, check by hand:
- reportd starts and logs the provider's redirect URL, and
GET /v1/auth/providerssaysavailable: true. - A user in an allowed domain signs in and returns to the page they started from.
GET /v1/meshows the roles their groups or app roles map to. Remove them from the group and sign in again: the role is gone.- A user outside the allowed domains gets
domain. Withoutjit, an uninvited user getsno-account. - An invited user's first sign-in binds their identity, and the invite is gone from
GET /v1/users/{name}. - With
auth.passwords: false, onlyadmin.usernamesigns in with a password. - The audit log has the sign-ins.