← All posts

SSO for Rocket.Chat and Seafile — keep the users, skip the reset apocalypse

September 2026 · DevOps

We already had Rocket.Chat with real users and real history. The goal was a small portal + Authentik IdP + Seafile CE stack: one login for chat and files, no Rocket.Chat data loss, roles stay authoritative in RC. That is not “stand up Keycloak and hope.” It is migrate identities carefully, then teach each app to trust the same OIDC provider.

Tested with:
- Rocket.Chat: 8.8
- Authentik: 2026.8.2
- Seafile CE: 11.0 (seafileltd/seafile-mc:11.0-latest)
- Deployment: Podman
- Reverse proxy / ingress: Cloudflare Tunnel + nginx

Version pins matter here. The Capital-A Custom OAuth behavior and the Authentik InbuiltBackend monkey-patch are observed on these builds, not eternal product laws.

The non-negotiables

  • RC Mongo is sacred. Backup first. OAuth is additive.
  • Rocket.Chat remains authoritative for roles. Custom OAuth role mapping is not available on Rocket.Chat Community. We therefore keep Rocket.Chat authoritative for roles and do not map Authentik groups to RC roles. Verified with user-role snapshots before and after first SSO login. Separately: RC docs say that with Merge Roles from SSO disabled, incoming SSO roles can replace existing roles — counterintuitive, so leave role mapping off and snapshot anyway.
  • Same passwords on day one. People should type the Rocket.Chat password into Authentik and succeed — no mass “reset your password” email.
  • Seafile CE on NFS for blobs; OAuth (not Pro-only SAML) for SSO.

Step 1 — Export Rocket.Chat people, create Authentik people

Dump the boring fields you need for an internal Authentik user: username, email, display name, optionally roles for Authentik-side grouping (engineering, admins). We used a JSONL export (~19 users in our case) and created matching Authentik users / groups.

Conceptually:

# Pseudocode — one line per RC user → Authentik internal user
for row in read_jsonl("rc-users.jsonl"):
    ensure_user(
        username=row["username"],
        email=row["email"],
        name=row["name"],
        groups=map_groups(row.get("roles")),
    )

Usernames and emails should match what Rocket.Chat will use when it merges on first SSO login. Mismatch here = duplicate accounts later. Painful. Avoid.

Step 2 — Password hashes: Meteor is not Django

For the bcrypt-backed Rocket.Chat accounts in this deployment, Meteor stored bcrypt over the SHA-256 hex digest of the password (not a normal bcrypt of the plaintext):

bcrypt( sha256_hex(utf8_password) )

Meteor can use Argon2 on newer setups — do not assume every RC user is $2b$… forever. Inspect the password field on your Rocket.Chat user records before designing the migration.

A raw Meteor string like $2b$10$… is not a Django-formatted password hash by itself. Django picks the verifier from an algorithm prefix. BCryptPasswordHasher expects storage shaped like:

bcrypt$$2b$10$…

So the import step is: wrap / convert each Meteor bcrypt value into Django’s bcrypt$… representation, then store that on the Authentik user. (Authentik 2026.5+ can import pre-hashed Django passwords via blueprints/API — the value still has to be a valid Django hash string.)

Enable BCryptPasswordHasher in Authentik PASSWORD_HASHERS, then authenticate with the Meteor pre-hash and explicitly upgrade on first success:

# Trimmed idea — verify RC passwords, then migrate off Meteor storage
meteor = hashlib.sha256(password.encode("utf-8")).hexdigest()

if user.password and check_password(meteor, user.password):
    # Legacy Rocket.Chat password succeeded.
    # Migrate this Authentik account to Authentik's native hasher.
    user.set_password(password)
    user.save(update_fields=["password"])
    return user

if user.check_password(password):
    return user

A bare check_password(meteor, user.password) only verifies — it does not rehash. Authentik’s User.check_password() can upgrade when it verifies through the model API; our Meteor path must call set_password ourselves if we want migration to be real.

We monkey-patched Authentik’s InbuiltBackend.authenticate (plus an AppConfig ready() hook) so portal / Authentik login accepts the same password people already use in chat. Meteor compatibility is temporary: first successful Authentik login converts that account to a native Authentik hash. Changing the password in Authentik still does not change Rocket.Chat’s hash (and vice versa) until everyone lives on SSO only.

Step 3 — OIDC applications in Authentik

Create separate OAuth2/OIDC applications (and clients) for at least:

AppWho redirects back
Portal/auth/callback (or your portal path)
Seafile/oauth/callback/ on the Seafile URL
Rocket.Chat/_oauth/Authentik (name must match the Custom OAuth service)

Critical Authentik flow wiring (easy to get wrong):

  • authentication_flow = default authentication flow (login)
  • authorization_flow = provider authorization (consent / implicit consent) — not the login flow again

If authorization_flow points at login, Authentik never returns ?code= to the client. You will debug “redirect URI fine, still broken” for an hour.

Redirect URIs must be the public HTTPS URLs once Cloudflare Tunnel (or whatever) is in front — LAN URLs only help LAN tests. Prefer keeping authorize / token / userinfo under the same canonical Authentik origin unless you have measured issuer, Host, and TLS behavior for a split topology.

Step 4 — Rocket.Chat Custom OAuth (and the capital-A trap)

Wire Custom OAuth to Authentik’s OIDC endpoints (/application/o/authorize/, /token/, /userinfo/), scopes openid email profile, map username / email / name fields, show a button label like Authentik SSO.

Settings that mattered for us:

  • merge_users=true — link SSO identity to existing RC user
  • No IdP → RC role mapping — keep Rocket.Chat authoritative; snapshot roles before/after first SSO login
  • show_button=true

Gotcha (observed on Rocket.Chat 8.8): Rocket.Chat capitalizes the custom service name when it rebuilds meteor_accounts_loginServiceConfiguration. If you store settings under Accounts_OAuth_Custom-authentik (lowercase) but the UI service is Authentik, restart wipes the button because the canonical keys are Accounts_OAuth_Custom-Authentik-…. Put the full config on the Capital-A keys; disable the lowercase twin. Verify the SSO button survives podman restart rocketchat.

When you are ready for IdP-only login: turn off form login / registration in RC (Accounts_ShowFormLogin=false, registration disabled) and keep the Authentik button.

Step 5 — Seafile CE OAuth

Seafile CE speaks OAuth without needing Pro SAML. In seahub_settings.py (conceptually):

ENABLE_OAUTH = True
OAUTH_CLIENT_ID = "…"
OAUTH_CLIENT_SECRET = "…"
OAUTH_REDIRECT_URL = "https://files.example.org/oauth/callback/"
OAUTH_AUTHORIZATION_URL = "https://auth.example.org/application/o/authorize/"
OAUTH_TOKEN_URL = "https://auth.example.org/application/o/token/"
OAUTH_USER_INFO_URL = "https://auth.example.org/application/o/userinfo/"
OAUTH_SCOPE = ["openid", "profile", "email"]
OAUTH_ATTRIBUTE_MAP = {
    "sub": (True, "uid"),           # stable external id (Seafile 11+)
    "name": (False, "name"),
    "email": (False, "contact_email"),
}

Seafile 11+ treats uid as the external identity key and recommends a stable provider identifier (sub) rather than making email the permanent identity. Our first LAN cut used email as the key; for a clean public write-up, prefer the sub → uid map above.

Match SERVICE_URL / file server URLs to the same public hostname users actually hit. Keep authorize/token/userinfo on the public Authentik origin unless you intentionally validate an internal-only path in your setup.

Quotas and groups are a separate policy layer; SSO only proves who you are.

Step 6 — Portal as the front door

Thin portal: OIDC login against Authentik, then tiles / embeds for Chat, Files, AI. Same client family as above. Once CF Tunnel publishes auth., files., portal. (and chat already public), update every redirect URI and restart the portal.

Iframing Rocket.Chat introduces cookie, CSP/frame-ancestors, and cross-origin concerns. Serving the portal and chat behind the same origin (for example chat hostname + /portal/ path) can eliminate a surprising amount of that complexity — portal.example.org and chat.example.org can already be same-site while remaining different origins.

What “done” feels like

  1. User opens portal → Authentik → lands with session.
  2. Chat shows Authentik SSO; first click merges into existing RC user; messages and roles intact.
  3. Seafile “Login with …” uses the same Authentik user; library appears under that identity.
  4. Optional: RC form login off; Authentik is the only password story humans care about.

Sharp edges worth tattooing on your runbook

  • Hashes ≠ plaintext migration. Wrap Meteor bcrypt as Django bcrypt$…, verify via sha256_hex then bcrypt, then set_password on first success.
  • RC Custom OAuth name casing must match settings _id prefix after restart (verify on your RC version).
  • Authentik authorization_flow must be the provider authorization flow.
  • Password change in Authentik does not sync back to Rocket.Chat until you retire local RC passwords.
  • Backup before OAuth. Additive config is still config; roles snapshots (before / after) prove you did not clobber anyone.

One IdP is the easy part. The migration is the work: identities, password schemes, and three slightly different OAuth clients that all agree who “alice” is.