Skip to main content

Sign-in and single sign-on

Let users sign in with your company's single sign-on (OpenID Connect: Keycloak, Entra ID, Okta, Google...), next to LDAP or local accounts, and decide who gets an account. Accounts, admins and tokens are in User Management.

Set up single sign-on​

QA-Board is an OpenID Connect confidential client, using the Authorization Code flow with PKCE: the backend talks to your identity provider (IdP), and browsers never see its tokens.

  1. Ask your IdP's admins for a client, with:

    FieldValue
    ProtocolOpenID Connect
    Client typeConfidential (the server keeps a secret)
    Grant typesAuthorization Code only, with PKCE (S256)
    Redirect URIhttps://qaboard.example.com/api/v1/auth/oidc/callback: your QABOARD_URL, exactly, then /api/v1/auth/oidc/callback
    Scopes / claimsopenid profile email: sub, preferred_username, email, name, and groups if you can

    They give you back the issuer (e.g. https://keycloak.example.com/realms/company), a client id and a client secret.

  2. Set them on the server, then deploy again (deployments/deploy.py <env file> up recreates the backend, without downtime; with the Helm chart, the client secret goes in secret, the others in config):

    .env, next to docker-compose.yml
    QABOARD_URL=https://qaboard.example.com
    QABOARD_OIDC_ISSUER=https://keycloak.example.com/realms/company
    QABOARD_OIDC_CLIENT_ID=qaboard
    QABOARD_OIDC_CLIENT_SECRET=...
  3. Check it from the server. It tells you what's wrong, in the order setups break:

    docker compose exec backend qaboard-admin check-sign-in
    Single sign-on (sso)
    OK discovery: The server reaches https://keycloak.example.com/realms/company/.well-known/openid-configuration
    OK issuer: The discovery document's issuer is https://keycloak.example.com/realms/company
    OK signing keys: 2 signing keys
    OK client id: set
    OK client secret: set
    FAIL redirect URI: The IdP refuses https://qaboard.example.com/api/v1/auth/oidc/callback. It accepts
    http://qaboard.example.com/api/v1/auth/oidc/callback: register https://qaboard.example.com/api/v1/auth/oidc/callback, or set the provider's redirect URI.

    Admin → Sign-in → Test runs the same checks.

  4. Sign in: the sign-in page now shows a Single sign-on button first, and the password form below it.

Users who already have an account (LDAP, local) don't get a new one by default: see Existing accounts.

Every OIDC environment variable
VariableDefaultWhat it does
QABOARD_OIDC_ISSUERemptyThe IdP's issuer. Empty: no single sign-on (removing it is the rollback)
QABOARD_OIDC_CLIENT_IDThe client id
QABOARD_OIDC_CLIENT_SECRETThe client secret
QABOARD_OIDC_SCOPESopenid profile emailAdd groups if your IdP needs it to send the groups
QABOARD_OIDC_BUTTON_LABELSingle sign-onThe button's label, e.g. Sign in with Okta
QABOARD_OIDC_MATCH_USERSnoneHow the first SSO sign-in finds existing accounts: none, username, email (see Existing accounts)
QABOARD_OIDC_USERNAME_CLAIMpreferred_usernameThe claim with the user name, e.g. samaccountname with Active Directory
QABOARD_OIDC_EMAIL_CLAIMemail
QABOARD_OIDC_NAME_CLAIMname
QABOARD_OIDC_GROUPS_CLAIMgroupsRecorded in the audit log at each sign-in. Group rules can read any claim
QABOARD_OIDC_REFRESH_GROUPSfalsetrue reads users' claims again every day, with refresh tokens (needs QABOARD_ENCRYPTION_KEY, see groups)
QABOARD_OIDC_REDIRECT_URI$QABOARD_URL/api/v1/auth/oidc/callbackOnly if the IdP has another URI registered for QA-Board

Several providers​

Admins add more OpenID Connect providers in Admin → Sign-in: say, a partner's IdP next to yours. Each has its own redirect URI, $QABOARD_URL/api/v1/auth/oidc/<id>/callback, shown in the panel. Its secret is either:

  • the name of an environment variable that holds it (nothing secret in the database), or
  • stored encrypted in the database: set QABOARD_ENCRYPTION_KEY on the server first, to any long random text (openssl rand -base64 32). Keep it: without it, the stored secrets can't be read.

Providers from the environment are read-only in the panel. Users link more sign-in methods to their account in Settings → Sign-in methods.

Existing accounts​

At their first SSO sign-in, users are recognized by the provider's id for them (sub). For users who already have an account, the provider's match_users decides:

  • none (the default): a new account, unless they link the provider from their existing account in Settings → Sign-in methods.
  • username: the account with the same user name. Use it when the IdP is federated to the same directory as LDAP: the user names are the same people. Set the claim with the directory's user names, e.g. QABOARD_OIDC_USERNAME_CLAIM=samaccountname.
  • email: the account with the same email, if the IdP says it's verified (email_verified).

With LDAP and its service account (QABOARD_LDAP_BIND_DN), SSO sign-ins matched by user name also refresh the user's directory attributes, which the rules of QABOARD_LOGIN_RESTRICTED_YAML can use.

Decide who gets an account​

auth.access_policy, in Admin → Settings:

  • open (the default): anyone a sign-in method accepts gets an account.
  • domains: users whose verified email is in auth.allowed_email_domains (e.g. example.com) get one. The others wait for an admin. Verified emails are those of LDAP and SAML, and those single sign-on says are verified (email_verified): not the emails of local sign-up.
  • approval: everyone waits for an admin.

Users who wait can sign in, and see a page saying so, where they write why they need access. Admins get an email (when QA-Board sends emails), and a message on auth.access_request_webhook if you set it: a Slack, Teams or Mattermost incoming webhook (QA-Board posts {"text": "..."}; the panel and the audit log only show its host, since its URL is a credential). Admins approve or deny them in Admin → Users; users get an email either way.

The policy applies to new accounts. To stop someone who already has one, disable them in Admin → Users.

Sessions​

Browser sessions are stored on the server:

  • They end after sessions.idle_days (30) without activity, and after sessions.max_days (180) in any case.
  • Users see theirs in Settings → Sessions and can end any of them. Signing out ends the session.
  • Admins end all of a user's sessions from the user's page. Disabling a user ends them too.

Users signed in before the upgrade stay signed in: their browser's older cookie becomes a stored session at its next request. Not when the user's sessions ended since (they signed out, they or an admin ended their sessions, or they were disabled), since the cookie could be a copy, nor sessions.max_days after the upgrade: they sign in again.

Only single sign-on​

The sign-in page shows the password form for LDAP and local accounts. To send users straight to your IdP, require signing in (auth.require_sign_in), and turn on auth.sso_auto_redirect: with a single SSO provider, the sign-in page redirects to it. /login?local=1 still shows the password form, for admins with a local account (qaboard-admin create-admin admin --password), in case the IdP is down.

LDAP over TLS​

Use ldaps://ldap.example.com:636 in QABOARD_LDAP_HOST, or set QABOARD_LDAP_START_TLS=true to upgrade a plain connection. If your directory's certificate comes from an internal CA, give it to OpenLDAP with LDAPTLS_CACERT=/path/to/ca.pem in the backend's environment. qaboard-admin check-sign-in --provider ldap --user alice checks the service account, and looks a user up. Empty passwords are refused before they reach the directory: LDAP servers often accept them as anonymous binds.

Rate limits and proxies​

QA-Board's nginx allows 30 sign-in attempts per minute per client, with bursts: sign-in, sign-up, qa auth login codes and SSO redirects. Beyond, it answers 429. It counts by the address it sees: behind a load balancer or an ingress, that's the proxy's, so all users share the limit.

The audit log records users' IPs. If there are proxies in front of QA-Board's nginx (a Kubernetes ingress, a load balancer), set QABOARD_TRUSTED_PROXY_HOPS to how many: 1 for one ingress. With 0 (the default), the IP is the address nginx sees. Don't set it without proxies: clients could then choose the IP that gets recorded.

Troubleshooting​

When an SSO sign-in fails, users see a short message and a reference: the audit event in Admin → Audit (search for it) has the details, and so does the backend's log ([sign-in] ...).

Users seeWhat happenedWhat to do
We couldn't reach the sign-in serviceThe backend can't reach the IdPRun qaboard-admin check-sign-in. Check the proxy (NO_PROXY for an internal IdP) and the CA certificates of the backend's image (CA_CERT_URL)
Your sign-in expiredThe handshake is lost: another tab, more than 10 minutes, or a different hostnameUsers must come back to the hostname they left from: QABOARD_URL must be the one users use, and the redirect URI must be on it
The sign-in service didn't sign you inThe IdP sent an error (e.g. access_denied)The audit event has the IdP's message
Signing in isn't set up correctlyWrong issuer, audience, signature, a missing claim...The audit event says which. A missing sub or user name claim: ask for the claim mapper
Your account can't use QA-BoardQABOARD_LOGIN_RESTRICTED_YAML's rules refuse the user
Your account is disabledAn admin disabled it
It's another account'sLinking a provider whose account is already linked to another userSign in as that user and unlink it, or check the users' identities

Keycloak's "Invalid parameter: redirect_uri" means the redirect URI isn't registered: check-sign-in says which near-misses (http/https, a trailing slash) the IdP accepts. In Keycloak, scheme, host, port and path must all match.