Single Sign-On

LokalBoards signs people in against your own identity provider — Entra ID, Google Workspace, Okta, Keycloak, Authentik, Auth0, Zitadel, ADFS, Shibboleth, or anything else that speaks OpenID Connect or SAML 2.0. One button on the sign-in page, no password to keep here, and joiners and leavers handled where you already handle them.

It is part of the MIT licence like everything else. There is no edition to buy.

What it does

  • One button, your wording. "Sign in with Contoso", if that is what you call it.
  • Accounts on first use. Somebody signing in for the first time gets an account here, named and addressed from what your provider says about them.
  • Existing accounts are joined, not duplicated. Anyone already using the instance keeps their boards, their cards and their comments the first time they arrive through the provider.
  • Administrators from your directory. Optional: a group or role claim decides who administers this instance, checked on every sign-in.
  • Passwords still work. Adding SSO takes nothing away: the password form stays on the sign-in page, existing passwords keep working, and administrators can still create accounts with a password from the Users page. Useful for a break-glass account, or for people your directory does not cover.

Which protocol? If your provider offers both, choose OpenID Connect: less to configure, no certificates to rotate. Choose SAML when that is what your provider speaks — which in practice means a good deal of higher education, public administration and anything bought before about 2015. Both can be switched on at once, and each gets its own button; an account is the same account either way.

What you need

  • An OpenID Connect or SAML 2.0 provider you can register an application with.
  • LokalBoards reachable over HTTPS at a fixed address. The provider redirects browsers back to it, so it has to be an address your provider will accept.

The redirect URI

Register exactly this address with your provider, with your own domain in front:

https://boards.example.com/api/auth/sso/callback

It has to match to the character — a trailing slash, http instead of https, or www. in one place and not the other is enough for a provider to refuse the sign-in. LokalBoards builds it from NUXT_BOARDS_URL, so set that to the address people actually use.

Configuration

Four variables are the minimum:

NUXT_SSO_ENABLED=true
NUXT_SSO_ISSUER=https://login.example.com/realms/company
NUXT_SSO_CLIENT_ID=lokalboards
NUXT_SSO_CLIENT_SECRET=the-secret-your-provider-gave-you

Everything else — where to send people, where to exchange the code, where to ask for a profile — is read from your provider's discovery document at <issuer>/.well-known/openid-configuration. If that URL returns JSON in your browser, the issuer is right.

All the settings

VariableDefaultWhat it does
NUXT_SSO_ENABLEDfalseTurns the button on.
NUXT_SSO_ISSUERYour provider's issuer URL.
NUXT_SSO_CLIENT_IDThe application you registered.
NUXT_SSO_CLIENT_SECRETIts secret. Server-side only; it never reaches a browser.
NUXT_SSO_LABELSingle sign-onWhat the button says.
NUXT_SSO_SCOPESopenid profile emailScopes to request.
NUXT_SSO_PROVISIONautoauto creates accounts on first sign-in; existing only admits people who already have one.
NUXT_SSO_ALLOWED_DOMAINSComma-separated e-mail domains allowed to sign in.
NUXT_SSO_ADMIN_CLAIMA claim to read the administrator role from, e.g. groups.
NUXT_SSO_ADMIN_VALUEThe value in that claim that means "administrator".
NUXT_SSO_CLAIM_SUBJECTsubWhich field holds the provider's own id for a person.
NUXT_SSO_CLAIM_EMAILemail,preferred_usernameWhich field holds the e-mail address.
NUXT_SSO_CLAIM_NAMEnameWhich field holds the display name.
NUXT_SSO_DOMAINSE-mail domains this provider signs in, for routing.
NUXT_SSO_PROVIDERSNames of further providers — see more than one provider.
NUXT_SSO_AUTHORIZATION_URLOnly for providers without discovery.
NUXT_SSO_TOKEN_URLThe same. Set it together with the one above.
NUXT_SSO_USERINFO_URLThe same, optional.

A half-configured instance behaves as if SSO were off: no button, and both endpoints answer 404. A button that leads to an error page is worse than no button.

Your provider

The four values are the same everywhere. What differs is where to find them.

Microsoft Entra ID (Azure AD)

  1. Microsoft Entra admin centre → App registrations → New registration.
  2. Redirect URI: Web, https://boards.example.com/api/auth/sso/callback.
  3. From Overview, take the Application (client) ID and the Directory (tenant) ID.
  4. Certificates & secrets → New client secret. Copy the value now; it is shown once.
NUXT_SSO_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
NUXT_SSO_CLIENT_ID=<application-id>
NUXT_SSO_CLIENT_SECRET=<secret-value>
NUXT_SSO_LABEL=Sign in with Microsoft

For administrators from a group, add a groups claim under Token configuration and use the group's object id:

NUXT_SSO_ADMIN_CLAIM=groups
NUXT_SSO_ADMIN_VALUE=00000000-1111-2222-3333-444444444444

Google Workspace

  1. Google Cloud console → APIs & Services → Credentials → Create credentials → OAuth client ID → Web application.
  2. Authorised redirect URI: https://boards.example.com/api/auth/sso/callback.
  3. Under OAuth consent screen, set the publishing status to Internal so only your own organisation can use it.
NUXT_SSO_ISSUER=https://accounts.google.com
NUXT_SSO_CLIENT_ID=<id>.apps.googleusercontent.com
NUXT_SSO_CLIENT_SECRET=<secret>
NUXT_SSO_LABEL=Sign in with Google
NUXT_SSO_ALLOWED_DOMAINS=example.com

Google does not send group memberships in the ID token, so leave the admin claim unset and manage administrators here.

Okta

  1. Admin console → Applications → Create App Integration → OIDC → Web Application.
  2. Sign-in redirect URI: https://boards.example.com/api/auth/sso/callback.
  3. Assign the application to the people or groups who should have access.
NUXT_SSO_ISSUER=https://example.okta.com/oauth2/default
NUXT_SSO_CLIENT_ID=<client-id>
NUXT_SSO_CLIENT_SECRET=<client-secret>
NUXT_SSO_LABEL=Sign in with Okta

Add a groups claim to the ID token in the authorisation server's claims settings if you want administrators from a group.

Keycloak

  1. Clients → Create client, type OpenID Connect, client authentication on.
  2. Valid redirect URI: https://boards.example.com/api/auth/sso/callback.
  3. The secret is under Credentials.
NUXT_SSO_ISSUER=https://sso.example.com/realms/company
NUXT_SSO_CLIENT_ID=lokalboards
NUXT_SSO_CLIENT_SECRET=<secret>

For administrators, add a group membership mapper named groups to the client's dedicated scope, then set NUXT_SSO_ADMIN_CLAIM=groups and NUXT_SSO_ADMIN_VALUE=/lokalboards-admins. Keycloak sends group paths with a leading slash — use exactly what appears in the token.

Authentik

  1. Applications → Providers → Create → OAuth2/OpenID Provider, authorisation flow implicit consent (or explicit, as you prefer).
  2. Redirect URI: https://boards.example.com/api/auth/sso/callback.
  3. Create an Application bound to that provider so it appears in the user portal.
NUXT_SSO_ISSUER=https://auth.example.com/application/o/lokalboards/
NUXT_SSO_CLIENT_ID=<client-id>
NUXT_SSO_CLIENT_SECRET=<client-secret>

Auth0

NUXT_SSO_ISSUER=https://example.eu.auth0.com/
NUXT_SSO_CLIENT_ID=<client-id>
NUXT_SSO_CLIENT_SECRET=<client-secret>

Allowed callback URL: https://boards.example.com/api/auth/sso/callback.

Anything else

Any provider with a discovery document works with the four basic variables. One without can be configured by hand:

NUXT_SSO_AUTHORIZATION_URL=https://sso.example.com/oauth2/authorize
NUXT_SSO_TOKEN_URL=https://sso.example.com/oauth2/token
NUXT_SSO_USERINFO_URL=https://sso.example.com/oauth2/userinfo
NUXT_SSO_ISSUER=https://sso.example.com

The issuer is still needed: it is what the tokens are checked against.

Plain OAuth 2.0, and providers that name things differently

Not everything is OpenID Connect. A plain OAuth 2.0 provider issues no ID token and answers its profile endpoint with whatever field names it likes — GitHub sends id and login, not sub and name. Three settings say where to look:

NUXT_SSO_CLAIM_SUBJECT=id
NUXT_SSO_CLAIM_EMAIL=email
NUXT_SSO_CLAIM_NAME=login

Several candidates, comma separated and tried in order, for providers that leave their first choice empty rather than absent:

NUXT_SSO_CLAIM_EMAIL=email,primary_email,mail

Nested fields, with dots, for providers that wrap everything:

NUXT_SSO_CLAIM_NAME=data.attributes.display_name

The subject is what an account is linked by, so choose a field the provider will never change — a numeric id rather than a username somebody can edit.

Without a mapping, a provider that sends none of the standard names is refused rather than guessed at: better a clear "no e-mail address" than an account keyed to the wrong thing.

One caution before wiring up a provider this way. LokalBoards links a first-time sign-in to any existing account with the same e-mail address, which is only safe if the provider vouches for that address. Check that its profile endpoint returns a verified one. GitHub, for instance, needs the user:email scope and can still answer with a null address when somebody keeps theirs private.

SAML 2.0

Everything above is OpenID Connect. If your provider speaks SAML instead, this section replaces the configuration — the rest of the page (who gets in, existing accounts, administrators) applies just the same.

Configuration

NUXT_SAML_ENABLED=true
NUXT_SAML_ENTRY_POINT=https://idp.example.com/idp/profile/SAML2/Redirect/SSO
NUXT_SAML_IDP_CERT=MIIDdzCCAl+gAwIBAgIEb...
NUXT_SAML_LABEL=Sign in with the university

NUXT_SAML_IDP_CERT is the provider's signing certificate, which you will find in its metadata as <X509Certificate>. Paste it with or without the -----BEGIN CERTIFICATE----- lines; whitespace does not matter. During a certificate rollover, give both separated by a comma and each is accepted until the old one is retired.

What to give your provider

Entity ID (audience)https://boards.example.com
ACS / Reply URLhttps://boards.example.com/api/auth/saml/acs
BindingHTTP-POST
Name ID formatE-mail address
Metadatahttps://boards.example.com/api/auth/saml/metadata

Most providers can be pointed at that metadata URL and will configure themselves from it. The entity ID defaults to NUXT_BOARDS_URL; set NUXT_SAML_ENTITY_ID if your provider needs a different one.

All the SAML settings

VariableDefaultWhat it does
NUXT_SAML_ENABLEDfalseTurns the button on.
NUXT_SAML_ENTRY_POINTThe provider's SSO URL, where people are sent to sign in.
NUXT_SAML_IDP_CERTIts signing certificate. Several, comma separated, during a rollover.
NUXT_SAML_IDP_ISSUERThe provider's entity id. Set it: assertions from any other issuer are then refused.
NUXT_SAML_ENTITY_IDNUXT_BOARDS_URLOur entity id, and the audience assertions must name.
NUXT_SAML_LABELSAML single sign-onWhat the button says.
NUXT_SAML_ATTRIBUTE_EMAILcommon spellingsWhich attribute holds the address.
NUXT_SAML_ATTRIBUTE_NAMEcommon spellingsWhich attribute holds the display name.
NUXT_SAML_ATTRIBUTE_SUBJECTnameIDWhat the account is linked by.
NUXT_SAML_ADMIN_ATTRIBUTEAn attribute to read the administrator role from, e.g. memberOf.
NUXT_SAML_ADMIN_VALUEThe value in it that means "administrator".
NUXT_SAML_PROVISIONfollows OIDCauto or existing, as above.
NUXT_SAML_ALLOWED_DOMAINSfollows OIDCAs above.
NUXT_SAML_WANT_RESPONSE_SIGNEDfalseAlso require the response around the assertion to be signed.
NUXT_SAML_DECRYPTION_KEYPrivate key for encrypted assertions.
NUXT_SAML_DECRYPTION_CERTIts certificate, published in our metadata.
NUXT_SAML_ALLOW_IDP_INITIATEDfalseAccept sign-ins started at the provider.
NUXT_SAML_DOMAINSE-mail domains this provider signs in, for routing.
NUXT_SAML_CLOCK_SKEW_SECONDS60Tolerance for clocks that disagree.
NUXT_SAML_IDENTIFIER_FORMATe-mail addressThe Name ID format to request.
NUXT_SAML_SIGNATURE_ALGORITHMsha256
NUXT_SAML_DISABLE_REQUESTED_AUTHN_CONTEXTfalseSet for providers that refuse a requested authentication context (ADFS often does).

Attributes

SAML attribute names are rarely friendly. An address might arrive as email, mail, or urn:oid:0.9.2342.19200300.100.1.3; Microsoft sends http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress. All of those are recognised without configuration, along with the usual spellings for a display name. Where your provider uses something else, say so:

NUXT_SAML_ATTRIBUTE_EMAIL=urn:oid:0.9.2342.19200300.100.1.3
NUXT_SAML_ATTRIBUTE_NAME=urn:oid:2.16.840.1.113730.3.1.241
NUXT_SAML_ADMIN_ATTRIBUTE=memberOf
NUXT_SAML_ADMIN_VALUE=cn=lokalboards-admins,ou=groups,dc=example,dc=com

If the address is only in the Name ID and there is no attribute for it, nothing needs configuring — the Name ID is used when no attribute carries an address.

Providers

Entra ID: Enterprise applications → New application → Create your own → Integrate any other application, then Single sign-on → SAML. Identifier and Reply URL from the table above. Take the Login URL and the Certificate (Base64) from the SAML Certificates section.

Okta: Applications → Create App Integration → SAML 2.0. Single sign-on URL is the ACS; Audience URI is the entity id. The certificate is under View SAML setup instructions.

Keycloak: Clients → Create client → SAML. Client ID is our entity id, Valid redirect URI the ACS. Set Name ID format to email and turn Force POST binding on. The realm's certificate is under Realm settings → Keys → RS256 → Certificate.

ADFS: add a Relying Party Trust with the metadata URL, then add claim rules mapping LDAP attributes to E-Mail-Address and Name. ADFS is the usual reason to set NUXT_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT=true.

Shibboleth, SimpleSAMLphp, PingFederate, OneLogin: register the metadata URL and release an e-mail attribute. Nothing else is special.

What is checked, and what is not

Every assertion must be signed, and the signature must cover the assertion that is then read — that is the defence against signature wrapping, the family of attacks that has produced authentication bypasses in SAML libraries for fifteen years. On top of that: the audience must be this instance, the destination must be the ACS URL, the conditions window must be current (with the configured skew), and the issuer must match NUXT_SAML_IDP_ISSUER when one is set. Anything that fails is refused outright — there is no partial acceptance.

The signature checking itself is @node-saml/node-saml, the library the Node SAML ecosystem has hardened over many years. XML signature verification is the one part of this that should not be written from scratch for the sake of one fewer dependency.

Encrypted assertions

Some providers encrypt assertions as a matter of policy. Give LokalBoards a key pair of its own, and put the certificate where the provider can find it — it is published in our metadata, so a provider pointed at the metadata URL picks it up by itself:

openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
  -keyout saml-decrypt.key -out saml-decrypt.crt -subj "/CN=boards.example.com"
NUXT_SAML_DECRYPTION_KEY=-----BEGIN PRIVATE KEY-----\nMIIEvg...\n-----END PRIVATE KEY-----
NUXT_SAML_DECRYPTION_CERT=MIIDdzCCAl+gAwIBAgIEb...

Both accept the PEM armour or the bare base64, and \n escapes survive a Docker environment file. Assertions are decrypted before anything else is looked at; an unencrypted one still works, so switching this on does not break a provider mid-change.

Sign-in started at your provider

Somebody clicking your application's tile in Entra's My Apps or Okta's dashboard arrives here with an assertion nobody asked for. That is refused by default and enabled with:

NUXT_SAML_ALLOW_IDP_INITIATED=true

The default is off deliberately. An assertion we did not request is a bearer token with nothing tying it to the browser presenting it, so when this is on, each assertion may be used exactly once — its identifier is recorded as it is consumed and a second attempt is refused. RelayState is treated as untrusted too: anything that is not a path on this instance is ignored and the person lands on the dashboard, so a crafted response cannot turn this into an open redirect.

Still not implemented: single logout (SLO). Signing out of LokalBoards ends the session here and leaves the session at your provider alone.

More than one provider

One provider is the common case and needs nothing beyond what is above. An instance that needs several — a company with a partner directory, a merger, or a migration where the old and the new provider both have to work for a while — names them:

NUXT_SSO_PROVIDERS=acme,partner

NUXT_SSO_ACME_ISSUER=https://login.microsoftonline.com/<tenant>/v2.0
NUXT_SSO_ACME_CLIENT_ID=...
NUXT_SSO_ACME_CLIENT_SECRET=...
NUXT_SSO_ACME_LABEL=Acme Corp

NUXT_SSO_PARTNER_ISSUER=https://partner.example.com/oidc
NUXT_SSO_PARTNER_CLIENT_ID=...
NUXT_SSO_PARTNER_CLIENT_SECRET=...
NUXT_SSO_PARTNER_LABEL=Partner Ltd

Every setting that exists on its own exists per provider under that provider's name: NUXT_SSO_ACME_SCOPES, NUXT_SSO_PARTNER_PROVISION, and so on. Anything not set for a provider falls back to the instance-wide value, so a shared policy is written once.

SAML providers are named the same way with NUXT_SAML_PROVIDERS, and the two lists are independent — an instance can run two OpenID Connect providers and a SAML one at the same time. Each SAML provider gets its own reply URL, which is what you register at that provider's end:

https://boards.example.com/api/auth/saml/acs?provider=university
https://boards.example.com/api/auth/saml/metadata?provider=university

Naming a provider switches it on. The unprefixed settings still work and are a provider called sso (or saml) alongside the named ones.

Sending people to the right one

With several providers, a row of buttons asks everyone to know which of their organisation's names is on theirs. Tell each provider which e-mail domains it signs in, and typing an address is enough:

NUXT_SSO_ACME_DOMAINS=acme.example,acme.co.uk
NUXT_SSO_PARTNER_DOMAINS=partner.example
NUXT_SAML_UNIVERSITY_DOMAINS=uni.example

As somebody types their address, the provider that claims that domain is offered first and filled in; the others stay below. Subdomains count, and the most specific match wins, so a subsidiary's own provider beats the parent's catch-all.

This is only about routing. It refuses nobody — that is what NUXT_SSO_ALLOWED_DOMAINS is for — and the lookup says nothing about whether an account exists, so it cannot be used to find out who uses this instance.

Who gets in

Two questions, and they are separate.

Who your provider lets through is your provider's business — assign the application to a group, or to everyone, in its own admin interface. That is the control you already have and the one to prefer.

Who LokalBoards accepts is this:

# Only these e-mail domains, whatever your provider allows
NUXT_SSO_ALLOWED_DOMAINS=example.com,subsidiary.example.com

# And/or: no new accounts, only people who already have one here
NUXT_SSO_PROVISION=existing

With existing, somebody your provider is happy to authenticate but who has no account here is turned away with "You do not have an account here yet". Create their account first — by hand, or by inviting them to a board — and the next sign-in finds it.

Administrators from your directory

NUXT_SSO_ADMIN_CLAIM=groups
NUXT_SSO_ADMIN_VALUE=lokalboards-admins

On every sign-in, the claim is read and the role is set from it — both ways. Someone added to the group becomes an administrator here at their next sign-in; someone removed from it stops being one. The claim may be a single value or a list; a list matches if any entry does.

Leave both unset and roles are managed here instead, on the Users page. It is one or the other: an instance that reads the claim will overwrite whatever you set by hand at the next sign-in.

Existing accounts

The first time somebody signs in through the provider, LokalBoards looks for an account with the same e-mail address. If it finds one, the two are linked: same account, same boards, same history, now reachable through SSO as well.

That linking is what makes SSO adoptable — a team that has been using the instance for a year does not want to start again — and it is safe because the address comes from the provider you configured, over a channel authenticated with your client secret. It is never an address somebody typed into a form.

Their password keeps working, and so does the password form. There is no switch that makes SSO the only way in, and there does not need to be: an account created by SSO has no password at all — nothing was ever set for it — so for everyone who arrived through the provider, the provider already is the only way in. Accounts that had a password keep it until an administrator changes it.

Turning public signup off is a separate thing and does not affect any of this: it stops strangers signing themselves up, and leaves the password form, existing passwords and administrator-created accounts exactly as they were.

Turning it off

Set NUXT_SSO_ENABLED=false and restart. The button disappears, both endpoints answer 404, and every account stays exactly as it was — the links to your provider simply go unused. Nobody is locked out who has a password; anybody who only ever used SSO needs one setting up.

How the sign-in works

Worth knowing if you are reviewing this before deploying it:

  1. The button is a plain link to /api/auth/sso/start. That endpoint mints a state, a nonce and a PKCE verifier, keeps them in httpOnly cookies for ten minutes, and redirects to your provider.
  2. Your provider authenticates the person and redirects back to /api/auth/sso/callback with a one-time code.
  3. The callback checks the state, then exchanges the code from the server, authenticating with the client secret and the PKCE verifier. No token ever reaches the browser.
  4. The ID token's claims are checked: the issuer is the one configured, the audience is this client, it has not expired, and the nonce is the one from step 1.
  5. The account is found or created, a session is issued, and the browser lands on the dashboard.

The ID token's signature is not verified, which OpenID Connect Core allows (§3.1.3.7) when the token came directly from the token endpoint over TLS to a client that authenticated itself — the channel is the proof, and everything a signature would not cover is checked anyway.

When it does not work

Every failure sends the visitor back to the sign-in page with a message. What each one means:

MessageUsually
The identity provider could not be reachedWrong NUXT_SSO_ISSUER, or no route from the server to it. Check <issuer>/.well-known/openid-configuration from the server itself.
Your identity provider refused the sign-inThe person is not assigned to the application, or declined consent.
The response came from an unexpected identity providerThe issuer in the token does not match NUXT_SSO_ISSUER — often a trailing slash, or Entra's v2.0 suffix missing.
The response was not issued for this instanceNUXT_SSO_CLIENT_ID does not match the application the token was minted for.
This sign-in has expiredMore than ten minutes between clicking and returning, or cookies blocked.
Your identity provider did not supply an e-mail addressThe email scope is not granted, or the provider uses its own field names — see plain OAuth 2.0.
Your e-mail domain is not allowedNUXT_SSO_ALLOWED_DOMAINS does not include it.
You do not have an account here yetNUXT_SSO_PROVISION=existing (or the SAML equivalent) and nobody has that address here.
The sign-in could not be verified (SAML)The assertion failed a check: wrong signing certificate, wrong audience, an expired window, or clocks too far apart. The server log names which.
The response came from an unexpected identity provider (SAML)The assertion's issuer does not match NUXT_SAML_IDP_ISSUER.
This instance does not accept sign-ins started at your identity providerSomebody arrived from a provider portal and NUXT_SAML_ALLOW_IDP_INITIATED is off.
This sign-in has already been usedA provider-initiated assertion was presented twice. Start again from the sign-in page.

A provider that rejects the request before showing a sign-in page — usually redirect_uri_mismatch — never reaches LokalBoards at all. Compare the URI registered with your provider against NUXT_BOARDS_URL plus /api/auth/sso/callback, character by character.

Server logs carry the detail for anything unexpected; the browser is told only what is useful to the person in front of it.