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
| Variable | Default | What it does |
|---|---|---|
NUXT_SSO_ENABLED | false | Turns the button on. |
NUXT_SSO_ISSUER | — | Your provider's issuer URL. |
NUXT_SSO_CLIENT_ID | — | The application you registered. |
NUXT_SSO_CLIENT_SECRET | — | Its secret. Server-side only; it never reaches a browser. |
NUXT_SSO_LABEL | Single sign-on | What the button says. |
NUXT_SSO_SCOPES | openid profile email | Scopes to request. |
NUXT_SSO_PROVISION | auto | auto creates accounts on first sign-in; existing only admits people who already have one. |
NUXT_SSO_ALLOWED_DOMAINS | — | Comma-separated e-mail domains allowed to sign in. |
NUXT_SSO_ADMIN_CLAIM | — | A claim to read the administrator role from, e.g. groups. |
NUXT_SSO_ADMIN_VALUE | — | The value in that claim that means "administrator". |
NUXT_SSO_CLAIM_SUBJECT | sub | Which field holds the provider's own id for a person. |
NUXT_SSO_CLAIM_EMAIL | email,preferred_username | Which field holds the e-mail address. |
NUXT_SSO_CLAIM_NAME | name | Which field holds the display name. |
NUXT_SSO_DOMAINS | — | E-mail domains this provider signs in, for routing. |
NUXT_SSO_PROVIDERS | — | Names of further providers — see more than one provider. |
NUXT_SSO_AUTHORIZATION_URL | — | Only for providers without discovery. |
NUXT_SSO_TOKEN_URL | — | The same. Set it together with the one above. |
NUXT_SSO_USERINFO_URL | — | The 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)
- Microsoft Entra admin centre → App registrations → New registration.
- Redirect URI: Web,
https://boards.example.com/api/auth/sso/callback. - From Overview, take the Application (client) ID and the Directory (tenant) ID.
- 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
- Google Cloud console → APIs & Services → Credentials → Create credentials → OAuth client ID → Web application.
- Authorised redirect URI:
https://boards.example.com/api/auth/sso/callback. - 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
- Admin console → Applications → Create App Integration → OIDC → Web Application.
- Sign-in redirect URI:
https://boards.example.com/api/auth/sso/callback. - 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
- Clients → Create client, type OpenID Connect, client authentication on.
- Valid redirect URI:
https://boards.example.com/api/auth/sso/callback. - 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
- Applications → Providers → Create → OAuth2/OpenID Provider, authorisation flow implicit consent (or explicit, as you prefer).
- Redirect URI:
https://boards.example.com/api/auth/sso/callback. - 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 URL | https://boards.example.com/api/auth/saml/acs |
| Binding | HTTP-POST |
| Name ID format | E-mail address |
| Metadata | https://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
| Variable | Default | What it does |
|---|---|---|
NUXT_SAML_ENABLED | false | Turns the button on. |
NUXT_SAML_ENTRY_POINT | — | The provider's SSO URL, where people are sent to sign in. |
NUXT_SAML_IDP_CERT | — | Its signing certificate. Several, comma separated, during a rollover. |
NUXT_SAML_IDP_ISSUER | — | The provider's entity id. Set it: assertions from any other issuer are then refused. |
NUXT_SAML_ENTITY_ID | NUXT_BOARDS_URL | Our entity id, and the audience assertions must name. |
NUXT_SAML_LABEL | SAML single sign-on | What the button says. |
NUXT_SAML_ATTRIBUTE_EMAIL | common spellings | Which attribute holds the address. |
NUXT_SAML_ATTRIBUTE_NAME | common spellings | Which attribute holds the display name. |
NUXT_SAML_ATTRIBUTE_SUBJECT | nameID | What the account is linked by. |
NUXT_SAML_ADMIN_ATTRIBUTE | — | An attribute to read the administrator role from, e.g. memberOf. |
NUXT_SAML_ADMIN_VALUE | — | The value in it that means "administrator". |
NUXT_SAML_PROVISION | follows OIDC | auto or existing, as above. |
NUXT_SAML_ALLOWED_DOMAINS | follows OIDC | As above. |
NUXT_SAML_WANT_RESPONSE_SIGNED | false | Also require the response around the assertion to be signed. |
NUXT_SAML_DECRYPTION_KEY | — | Private key for encrypted assertions. |
NUXT_SAML_DECRYPTION_CERT | — | Its certificate, published in our metadata. |
NUXT_SAML_ALLOW_IDP_INITIATED | false | Accept sign-ins started at the provider. |
NUXT_SAML_DOMAINS | — | E-mail domains this provider signs in, for routing. |
NUXT_SAML_CLOCK_SKEW_SECONDS | 60 | Tolerance for clocks that disagree. |
NUXT_SAML_IDENTIFIER_FORMAT | e-mail address | The Name ID format to request. |
NUXT_SAML_SIGNATURE_ALGORITHM | sha256 | |
NUXT_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT | false | Set 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:
- The button is a plain link to
/api/auth/sso/start. That endpoint mints a state, a nonce and a PKCE verifier, keeps them inhttpOnlycookies for ten minutes, and redirects to your provider. - Your provider authenticates the person and redirects back to
/api/auth/sso/callbackwith a one-time code. - 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.
- 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.
- 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:
| Message | Usually |
|---|---|
| The identity provider could not be reached | Wrong 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-in | The person is not assigned to the application, or declined consent. |
| The response came from an unexpected identity provider | The 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 instance | NUXT_SSO_CLIENT_ID does not match the application the token was minted for. |
| This sign-in has expired | More than ten minutes between clicking and returning, or cookies blocked. |
| Your identity provider did not supply an e-mail address | The email scope is not granted, or the provider uses its own field names — see plain OAuth 2.0. |
| Your e-mail domain is not allowed | NUXT_SSO_ALLOWED_DOMAINS does not include it. |
| You do not have an account here yet | NUXT_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 provider | Somebody arrived from a provider portal and NUXT_SAML_ALLOW_IDP_INITIATED is off. |
| This sign-in has already been used | A 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.