-
Notifications
You must be signed in to change notification settings - Fork 1
Enterprise Auth
Out of the box, Roomer uses local passwords stored as bcrypt hashes. For enterprise deployments you can plug in any combination of:
- OIDC (OpenID Connect) — for Okta, Auth0, Keycloak, Azure AD / Entra ID, Google Workspace, etc.
- SAML 2.0 — for ADFS, Azure AD (SAML app), OneLogin, Shibboleth, Ping.
- LDAP / Active Directory — direct bind, for on-prem directories.
All three are configured through the admin UI (Admin → Settings →
Authentication) and stored in the AuthConfig table — no .env rebuilds
required. Enabling any provider leaves the local login intact; the login
page shows whatever is enabled.
Before you start — set
APP_URLcorrectly in the API's environment. OIDC'sredirectUriis validated against this, and SAML/OIDC callback links are rendered from it. See TLS Configuration.
For every SSO provider, Roomer resolves the upstream identity to a local
User row by email. On first login, the user is created with
provider = OIDC/SAML/LDAP; on subsequent logins the displayName is kept
in sync with the upstream value.
If the account is marked BLOCKED in Roomer, the SSO flow redirects back to
/login?error=account_blocked. SSO does not override Roomer's account
status.
All three providers support pushing group memberships into Roomer's access groups at login. You configure a list of mappings:
[ { idpGroup: "engineering", roomerGroupId: "<id of the Engineering group>" }, … ]
At each login the user's Roomer group memberships are reconciled — they are added to mapped groups they now belong to, and removed from mapped groups they no longer belong to. Non-mapped upstream groups are ignored, and group memberships you set manually on a non-mapped group in Roomer are preserved.
See Users Groups and Permissions for what groups actually gate.
OIDC uses a standard Authorization Code flow with PKCE-style state + nonce.
The library underneath is openid-client
which handles discovery automatically — you only need the issuer URL.
Admin → Settings → Authentication → OIDC:
| Field | Required | What it is |
|---|---|---|
| Issuer URL | ✓ | Discovery endpoint root. Roomer hits {issuer}/.well-known/openid-configuration. Example: https://login.example.com or https://example.auth0.com. |
| Client ID | ✓ | OAuth client id from your IdP. |
| Client Secret | ✓ (first time) | OAuth client secret. Only required on the first save; leave blank on subsequent edits to keep the existing secret. |
| Redirect URI | ✓ | Must start with APP_URL. Typically https://roomer.example.com/api/v1/auth/oidc/callback. Register the same URL in your IdP. |
| Scope | Space-separated scopes. Defaults to openid profile email. Add groups if your IdP exposes them that way. |
|
| Label | Button text on the login page ("Sign in with Okta"). | |
| Groups Claim Name | Claim that contains the user's groups in the ID token/userinfo. Defaults to groups. |
|
| Group Mappings | Array of { idpGroup, roomerGroupId } — maps upstream group names to Roomer access groups. |
| Endpoint | Purpose |
|---|---|
GET /api/v1/auth/oidc/authorize |
Login button target. Redirects the user to the IdP with generated state + nonce stored in a short-lived session. |
GET /api/v1/auth/oidc/callback |
IdP redirect target. Validates state/nonce, calls userinfo, upserts the user, issues a JWT cookie, redirects to /bookings. |
- State + nonce are mandatory; callbacks that don't match reject with
oidc_callback_failed. - The short-lived session used to hold state/nonce is regenerated on
authorizeto prevent session fixation. - Saving a new config calls
invalidateOidcCache()so the next request uses the updated values without a restart.
-
redirectUri must originate from the application URL— the value in the form doesn't start withAPP_URL. Fix one or the other so they share the same origin. -
Login ends at
?error=oidc_no_email— the IdP isn't returning an email in userinfo. Add theemailscope or configure email mapping at the IdP. - Login works but group mappings don't apply — check Groups Claim Name matches what your IdP puts in the ID token. Some IdPs require enabling a "groups" assertion; others expose it at a non-standard claim name.
SAML is handled via @node-saml/node-saml.
Roomer consumes HTTP-POST AuthnResponse assertions and redirects via
HTTP-Redirect binding for the AuthnRequest.
Admin → Settings → Authentication → SAML:
| Field | Required | What it is |
|---|---|---|
| Entry Point | ✓ | IdP SSO URL. e.g. https://login.example.com/sso/saml2. |
| Issuer | SP entity ID. If blank, APP_URL is used. |
|
| Certificate | ✓ | IdP signing certificate, PEM-encoded. Paste the whole block including -----BEGIN CERTIFICATE----- headers. |
| Callback URL | ✓ | Where the IdP POSTs the assertion. Set to https://roomer.example.com/api/v1/auth/saml/callback and register the same URL in your IdP as the ACS. |
| Signature Algorithm |
sha1, sha256, sha512. Defaults to library default (sha256). |
|
| Label | Button text on the login page. | |
| Group Attribute | SAML attribute that holds groups — e.g. http://schemas.xmlsoap.org/claims/Group (ADFS) or groups (generic). |
|
| Group Mappings | Array of { idpGroup, roomerGroupId }. |
|
| Want Authn Response Signed | Require the AuthnResponse wrapper to be signed (defaults to the library default). | |
| Want Assertions Signed | Require individual assertions to be signed. | |
| Allow Clock Skew Ms | Tolerance for NotBefore/NotOnOrAfter. Max 300000 (5 min). |
| Endpoint | Purpose |
|---|---|
GET /api/v1/auth/saml/authorize |
Login button target. Builds an AuthnRequest and redirects the user to the IdP. |
POST /api/v1/auth/saml/callback |
IdP ACS. Validates the assertion, extracts email/displayName/groups, upserts the user, issues a JWT cookie. |
Roomer extracts:
-
Email — prefers
email, falls back toemailAddress, falls back tonameIDif it looks like an email. -
Display Name — tries
displayName, thencn, thengivenName surname, then the local part of the email. - Groups — reads the attribute you configured in Group Attribute.
Most IdPs need an explicit mapping to be configured on the IdP side to emit
these in the assertion. Check the raw assertion in your browser's devtools
(Network → POST /saml/callback → Request → SAMLResponse base64-decoded)
if you're debugging.
-
?error=saml_no_email— the IdP is sending an assertion but without an email claim. Add it on the IdP side. -
?error=saml_callback_failed— assertion validation failed. Common causes: wrong certificate pasted, clock skew over the allowed tolerance, signature algorithm mismatch. - Groups not applied — verify Group Attribute matches what the IdP actually emits (exact URN or attribute name), and that each mapped upstream group matches case-sensitively.
LDAP is "username + password, but the directory is upstream". The user types their corporate credentials into Roomer's normal login form; Roomer attempts LDAP bind before falling back to any local password.
The order of attempts for a given login POST is:
- Local user exists with a password hash → try local bcrypt compare.
- LDAP is enabled → try LDAP bind with the submitted credentials.
-
Neither succeeds →
401 Unauthorized.
This order means: existing local users keep working even after LDAP is turned
on; new users from LDAP are auto-provisioned on first successful bind (with
provider = LDAP, passwordHash = null).
Admin → Settings → Authentication → LDAP:
| Field | Required | What it is |
|---|---|---|
| URL | ✓ |
ldap://dc1.example.com:389 or ldaps://...:636. |
| Bind DN | ✓ | Service account that can search the directory. e.g. CN=RoomerSvc,OU=Service,DC=example,DC=com. |
| Bind Credentials | ✓ (first time) | Service account password. Blank on later edits keeps the existing value. |
| Search Base | ✓ | Where to look for users. e.g. OU=Users,DC=example,DC=com. |
| Search Filter | LDAP filter template. Defaults to (mail={{email}}). {{email}} is replaced with the (RFC 4515-escaped) submitted email — safe from injection. Use (&(objectClass=user)(mail={{email}})) for AD. |
|
| Display Name Attribute | LDAP attribute to read the display name from. Defaults to displayName. |
|
| Email Attribute | Defaults to mail. |
|
| TLS Enabled | Use TLS (ldaps://). |
|
| TLS Reject Unauthorized | Whether to validate the LDAP server's cert. Default true. Only disable for a known self-signed dev LDAP. |
|
| Group Attribute | Where to read groups. Defaults to memberOf. Values are the full DN of each group (AD's convention). |
|
| Group Mappings | Array of { idpGroup, roomerGroupId }. idpGroup is matched against the literal LDAP value (typically a DN). |
- Roomer binds to LDAP as the service account (Bind DN + Bind Credentials).
- It runs the configured search filter with the submitted email substituted in, under the search base.
- If one entry comes back, Roomer reads the user's DN, email, display name, and group attribute from that entry.
- A second bind is attempted as the user's DN with the password they submitted. Success = authenticated.
- Roomer upserts the local user row, reconciles group memberships, and issues the JWT cookie.
The separation (admin bind → search → user bind) means the user's password never touches the service account, and the service account never learns the user's DN at bind time.
The memberOf attribute on each user entry is a list of DNs. For mapping,
use the full DN in the idpGroup field:
idpGroup: CN=Engineering,OU=Groups,DC=example,DC=com
roomerGroupId: <id of the Engineering group>
If you have nested groups you need resolved to a flat list, most AD deployments
return memberOf with LDAP_MATCHING_RULE_IN_CHAIN filters via a different
attribute — configure Group Attribute to that attribute name instead.
-
"Invalid email or password" for an LDAP user that exists — enable debug
logging on the API and watch for the search return. Common: the filter
doesn't match (wrong attribute, wrong base), or the TLS cert is untrusted
(
tlsRejectUnauthorized=truewith a self-signed cert). -
Search returns 0 entries — the service account doesn't have permission
to read from the search base, or the user's email on the search filter
doesn't match the
mailattribute. Test with a standaloneldapsearchusing the same DN, base and filter. -
User is authed but groups are empty —
memberOfisn't returned by the directory unless explicitly requested, and some directories (non-AD) use a different attribute. TrygrouporisMemberOf.
In addition to authenticating individual logins, Roomer can bulk-sync your entire LDAP/AD directory into local user accounts on demand. This is useful for pre-populating users before they first log in and for deactivating accounts removed from the directory.
From Admin → Settings → Authentication → LDAP, click Sync now. The
button calls POST /api/v1/settings/auth-config/ldap/sync (SUPER_ADMIN
only). The response contains a result object:
{ "data": { "created": 12, "updated": 45, "deactivated": 3, "skipped": 2, "errors": [] } }| Field | Meaning |
|---|---|
created |
New users provisioned from LDAP entries. |
updated |
Existing users whose displayName or accountStatus was refreshed. |
deactivated |
Users blocked because deactivateMissing is enabled and they were absent from the sync results. |
skipped |
Entries with no email value — ignored silently. |
errors |
Per-entry errors (DN + message) that didn't stop the overall sync. |
These fields are part of the LDAP config stored in Admin → Settings → Authentication → LDAP and extend the login-only config above.
| Field | Default | Notes |
|---|---|---|
| Sync Base DN | Same as Search Base | DN to use as the root of the sync search. Set to a broader base if your sync scope differs from your login search base. |
| Sync Filter | (objectClass=person) |
LDAP filter applied during sync. Use (&(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))) to restrict AD sync to enabled accounts. |
| Sync Scope | sub |
sub — full subtree search; one — one level below Sync Base only. |
| Deactivate missing | off | When enabled, any LDAP-provider user in Roomer who is absent from the sync results is marked BLOCKED. Useful for reflecting terminations automatically. Only acts when the sync returns at least one entry, to guard against accidental mass-deactivation from a misconfigured filter. |
Group mappings configured under LDAP → Group Mappings are applied during sync exactly as they are at login time — users are added to or removed from mapped Roomer groups based on their LDAP group attribute values.
You can enable any combination. In practice:
- Pure SSO, no local passwords: turn off new-user local signup (there is no signup UI — admins create users), disable the admin seed's default password on first use by changing it, and the only login path for real users is the SSO button. LDAP alone is fine too.
-
SSO + break-glass local admin: standard. The seeded admin
(
admin@roomer.local) keeps a local password so you can still get in if SSO is broken.
There is no limit on the number of enabled providers. How they are presented on the login page depends on the Login Display settings — see the section below. With the default "show provider selector" on, the login page shows all enabled providers together (credential form + a button per SSO provider). With it off, users go directly to the configured default.
Once you have providers configured, control how the login page presents them from Admin → Settings → Authentication → Login Display:
| Setting | What it does |
|---|---|
| Default provider | The provider to use when users arrive. When the selector is hidden and the default is oidc or saml, users are automatically redirected to the IdP without ever seeing the login form. |
| Show provider selector | When on (the default), the login page shows all enabled providers together — credential form plus a button per SSO provider. When off, users go straight to the default provider. |
Force a specific provider regardless of the default by appending
?login_provider= to the login URL:
| URL | Effect |
|---|---|
/login?login_provider=local |
Local password form, regardless of default. |
/login?login_provider=ldap |
LDAP credential form. |
/login?login_provider=oidc |
Redirect directly to the OIDC IdP. |
/login?login_provider=saml |
Redirect directly to the SAML IdP. |
This is useful for break-glass access — bookmark
/login?login_provider=local as a local admin fallback so you can always
log in even when SSO is the default (or broken).
SCIM (System for Cross-domain Identity Management) lets an upstream identity provider — Okta, Azure AD / Entra ID, OneLogin, and others — push user and group changes into Roomer automatically: create accounts, update display names, deactivate users, and manage group memberships without any manual steps.
- Admin → Settings → Authentication → SCIM Provisioning.
- Click Generate token. Copy the token — it is shown once.
- Configure your IdP with:
-
Base URL:
{API_PUBLIC_URL}/scim/v2(shown in the settings panel). - Authentication: Bearer token — paste the value you just copied.
-
Base URL:
- Run an on-demand sync in the IdP to verify the connection.
Set
API_PUBLIC_URLin the API's environment to your public API URL so the endpoint shown in the admin panel is correct.
| Operation | Endpoint |
|---|---|
| List / filter users | GET /scim/v2/Users |
| Create user |
POST /scim/v2/Users — auto-provisions the user (provider = OIDC) |
| Update user (full replace) | PUT /scim/v2/Users/:id |
| Update user (partial) |
PATCH /scim/v2/Users/:id — supports active, displayName, emails
|
| Deprovision user |
DELETE /scim/v2/Users/:id — marks the user BLOCKED, does not delete the record (returns 204) |
| List / filter groups | GET /scim/v2/Groups |
| Create group |
POST /scim/v2/Groups — creates a Roomer access group |
| Patch group |
PATCH /scim/v2/Groups/:id — add/remove members, rename (returns 204) |
| Delete group |
DELETE /scim/v2/Groups/:id — deletes the Roomer group |
Discovery endpoints (ServiceProviderConfig, ResourceTypes, Schemas)
are also available for IdPs that need them.
Limitations: list/filter requests return a maximum of 200 results. SCIM
bulk operations (/Bulk) and sort (sortBy/sortOrder) are not implemented.
Supported filter attributes are userName, emails.value, externalId, and
displayName for users; displayName and externalId for groups.
Tokens are stored as SHA-256 hashes — the plaintext is unrecoverable after generation. To rotate:
- Admin → Settings → SCIM Provisioning → Revoke token.
- Click Generate token to issue a new one.
- Update the IdP configuration.
Revoking a token also disables SCIM (all requests return 401) until a new
token is generated.
Install & Run
- Getting Started
- Development Setup
- Production Deployment
- Kubernetes Deployment
- Backup and Recovery
- TLS Configuration
- Configuration Reference
Using Roomer
- Buildings and Floors
- Zones and Assets
- Users, Groups and Permissions
- Booking and Queue
- Bulk CSV Import
- Enterprise Auth
- Email Notifications
- Webhooks
- Reports and Leases
Developer