Skip to content

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: []
FieldMeaning
id1 to 32 lower-case letters, digits and hyphens. Changing it later breaks the redirect URL and unlinking by provider.
nameThe sign-in button's label. Defaults to the id.
issuerThe issuer exactly as the provider's ID tokens say it. It must be https (or http on localhost).
clientId, clientSecretThe app's client id, and its secret as a secret: reference. A literal secret is refused at start.
scopesAsked for. openid is added if missing. Default: openid, email, profile.
allowedDomainsWhen 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.
trustEmailThe provider owns these addresses. Invites then bind by email without email_verified, and allowedDomains checks the email.
jitMake a user at their first sign-in. Without it, only invited or linked users get in.
promptSent as prompt: login, consent, select_account or none.
domainHintSent as hd to Google and domain_hint to others, to skip the account picker.
rolesMaps 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/callback

Register 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.yaml

Start 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:

  1. A known identity signs its user in. A disabled user gets disabled.
  2. 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.
  3. 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, -3 and 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.
  4. 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 gets roles.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 ​

  1. 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.
  2. Copy the Application (client) ID into clientId. The issuer is https://login.microsoftonline.com/<Directory (tenant) ID>/v2.0. The multi-tenant common endpoint isn't supported.
  3. Under Certificates & secrets, add a client secret. Put its value in your secret store and reference it from clientSecret.
  4. Under Token configuration, add the optional email claim to the ID token.
  5. 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. Use claim: roles.

Notes:

  • Entra puts the app roles a user has in the roles claim 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's email. Set trustEmail: true only with allowedDomains limited 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 ​

  1. In the Okta admin console, open Applications → Create App Integration. Choose OIDC - OpenID Connect and Web Application.
  2. 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.
  3. Assign the people or groups who may sign in.
  4. 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).
  5. For roles, add a groups claim 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 with Report.
    • With a custom authorization server: under Security → API → Authorization Servers, add a claim named groups to the ID token, value type Groups, with a filter.

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 ​

  1. In the Google Cloud console, under APIs & Services → OAuth consent screen, choose the Internal user type, so only your Workspace's accounts can consent.
  2. Under Credentials, create an OAuth client ID of type Web application. Add the URL reportd logs to Authorized redirect URIs.
  3. Copy the client id and secret. The issuer is https://accounts.google.com.

Notes:

  • allowedDomains checks the hd claim, which Google sets for Workspace accounts only. A personal Gmail account has none and gets domain. The hd parameter that domainHint sends 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>/start instead, 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: []
FieldMeaning
id1 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.
nameThe sign-in button's label. Defaults to the id.
metadataURL, metadataFile, metadataThe IdP's metadata: exactly one of a URL (https, or http on a loopback host), a file, or the XML itself.
entityIDreportd's entity ID with this IdP. Default: <publicURL>/v1/auth/saml/<id>/metadata.
subjectWho signed in: nameid (the default), or attribute:<name> for an attribute that must have exactly one value.
attributesemail and name: the attributes to read them from. Unset, reportd tries the common names (below).
allowedDomainsWhen set, only these email domains sign in. The email counts only with trustEmail.
trustEmailThe IdP owns these addresses. SAML has no email_verified, so without it invites never bind and allowedDomains lets no one in.
jitMake a user at their first sign-in.
forceAuthnAsk the IdP to sign the user in again, even if they have a session there.
nameIDFormatpersistent, 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.
signRequestsSign requests (RSA-SHA256). On unless false.
allowSHA1Accept SHA-1 signatures, for an old ADFS farm. Off by default.
rolesattribute 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/acs

GET /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.3 and urn:oid:1.2.840.113549.1.9.1. When none is sent and the NameID's format is emailAddress, 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.241 and urn: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.key is a secret: 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.pem

What 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 to GET /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.yaml

Then 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.

  1. 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.
  2. 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.
  3. Under SAML Certificates, copy the App Federation Metadata Url (it ends in ?appid=<application id>) into metadataURL.
  4. 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 in http://schemas.microsoft.com/ws/2008/06/identity/claims/role.
  5. 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 source user.objectid with the format Persistent.
  • Don't set nameIDFormat for 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.mail in http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress. As for OIDC, set trustEmail: true only with allowedDomains limited to domains verified in your tenant.
  • Group claims carry object IDs, and past 150 groups Entra sends a groups.link attribute 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 ​

  1. In the Okta admin console, open Applications → Create App Integration and choose SAML 2.0.
  2. Set Single sign-on URL to the ACS URL reportd logs and Audience URI (SP Entity ID) to its entity ID.
  3. 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.
  4. Add an attribute statement email with the value user.email.
  5. For roles, add a group attribute statement named groups, with a filter such as Starts with Report. Okta sends group names.
  6. 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).
  7. 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.

  1. In the Admin console, open Apps → Web and mobile apps → Add app → Add custom SAML app.
  2. 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.
  3. 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.
  4. Under Attribute mapping, map Primary email to email. For roles, under Group membership, choose the groups (up to 75) and name the app attribute groups. 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.
  5. 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 allowedDomains to 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 ​

  1. 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 publicURL over https; otherwise download the metadata and import the file. PowerShell does the same, and keeps the trust in step with reportd's metadata:

    powershell
    Add-AdfsRelyingPartyTrust -Name "reportd" -MetadataUrl "https://reports.example.com/v1/auth/saml/adfs/metadata" -MonitoringEnabled $true -AutoUpdateEnabled $true -AccessControlPolicyName "Permit everyone"
  2. 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.

  3. Optional, and recommended: make ADFS refuse requests reportd didn't sign:

    powershell
    Set-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: true only if you can't.
  • Don't set nameIDFormat for 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.

  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.
  2. Set Home URL to <publicURL>/v1/auth/saml/<id>/start, so the account console's link starts the sign-in at reportd.
  3. For the email, add a mapper on the client's Client scopes tab, in its -dedicated scope (Add mapper → By configuration → User Property): property email, SAML attribute name email.
  4. 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 attribute saml.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:

  1. reportd starts and logs the IdP's metadata, entity ID and ACS URLs, and GET /v1/auth/providers says available: true.
  2. 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.
  3. 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.
  4. GET /v1/me shows the roles the user's groups or app roles map to. Remove them from the group and sign in again: the role is gone.
  5. With allowedDomains, a user from another domain gets domain. Without jit, an uninvited user gets no-account.
  6. reportd's log has no saml: sign-in failed. If it has, its reason says 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.

CodeMeaning
deniedThe user, or the provider's policy, refused (access_denied, or a SAML status of AuthnFailed or RequestDenied).
expiredThe 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-accountNo user has this identity or an invite for it, and the provider has no jit.
disabledThe user is disabled in reportd.
domainThe domain isn't in allowedDomains, or the email isn't verified.
failedAnything 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 ​

ActionWhen
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:

  1. reportd starts and logs the provider's redirect URL, and GET /v1/auth/providers says available: true.
  2. A user in an allowed domain signs in and returns to the page they started from.
  3. GET /v1/me shows the roles their groups or app roles map to. Remove them from the group and sign in again: the role is gone.
  4. A user outside the allowed domains gets domain. Without jit, an uninvited user gets no-account.
  5. An invited user's first sign-in binds their identity, and the invite is gone from GET /v1/users/{name}.
  6. With auth.passwords: false, only admin.username signs in with a password.
  7. The audit log has the sign-ins.