Skip to content

Enterprise Auth

c0dewhacker edited this page Apr 22, 2026 · 2 revisions

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_URL correctly in the API's environment. OIDC's redirectUri is validated against this, and SAML/OIDC callback links are rendered from it. See TLS Configuration.

How Roomer matches users

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.

Group membership from SSO

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 (OpenID Connect)

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.

Configuring OIDC in Roomer

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.

OIDC URLs

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.

Security notes

  • 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 authorize to prevent session fixation.
  • Saving a new config calls invalidateOidcCache() so the next request uses the updated values without a restart.

Troubleshooting OIDC

  • redirectUri must originate from the application URL — the value in the form doesn't start with APP_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 the email scope 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 2.0

SAML is handled via @node-saml/node-saml. Roomer consumes HTTP-POST AuthnResponse assertions and redirects via HTTP-Redirect binding for the AuthnRequest.

Configuring SAML in Roomer

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

SAML URLs

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.

Attribute mapping

Roomer extracts:

  • Email — prefers email, falls back to emailAddress, falls back to nameID if it looks like an email.
  • Display Name — tries displayName, then cn, then givenName 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.

Troubleshooting SAML

  • ?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 / Active Directory

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:

  1. Local user exists with a password hash → try local bcrypt compare.
  2. LDAP is enabled → try LDAP bind with the submitted credentials.
  3. 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).

Configuring LDAP in Roomer

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

How the bind sequence works

  1. Roomer binds to LDAP as the service account (Bind DN + Bind Credentials).
  2. It runs the configured search filter with the submitted email substituted in, under the search base.
  3. If one entry comes back, Roomer reads the user's DN, email, display name, and group attribute from that entry.
  4. A second bind is attempted as the user's DN with the password they submitted. Success = authenticated.
  5. 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.

Group membership from LDAP

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.

Troubleshooting LDAP

  • "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=true with 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 mail attribute. Test with a standalone ldapsearch using the same DN, base and filter.
  • User is authed but groups are empty — memberOf isn't returned by the directory unless explicitly requested, and some directories (non-AD) use a different attribute. Try group or isMemberOf.

LDAP directory sync

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.

Running a sync

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.

Sync-specific configuration fields

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.

Mixing providers

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.

Login page display

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.

URL fallbacks

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 2.0 provisioning

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.

Enabling SCIM

  1. Admin → Settings → Authentication → SCIM Provisioning.
  2. Click Generate token. Copy the token — it is shown once.
  3. 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.
  4. Run an on-demand sync in the IdP to verify the connection.

Set API_PUBLIC_URL in the API's environment to your public API URL so the endpoint shown in the admin panel is correct.

What SCIM covers

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.

Token management

Tokens are stored as SHA-256 hashes — the plaintext is unrecoverable after generation. To rotate:

  1. Admin → Settings → SCIM Provisioning → Revoke token.
  2. Click Generate token to issue a new one.
  3. Update the IdP configuration.

Revoking a token also disables SCIM (all requests return 401) until a new token is generated.

Clone this wiki locally