Skip to content

🔑 feat: Add Google and SAML Admin Sign-In Through a Provider Registry and Keep Deep Links Through SSO - #188

Merged
danny-avila merged 5 commits into
mainfrom
pr/redo-admin-sso-providers
Oct 28, 2026
Merged

danny-avila merged 5 commits into
mainfrom
pr/redo-admin-sso-providers

Conversation

@danny-avila

@danny-avila danny-avila commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Pull Request Template

⚠️ Before Submitting a PR, Please Review:

  • Please ensure that you have thoroughly read and understood the Contributing Docs before submitting your Pull Request.

⚠️ Documentation Updates Notice:

  • Kindly note that documentation updates are managed in this repository: librechat.ai

Summary

Admins can now sign in to the panel with Google and SAML, as well as OpenID, and SSO brings them back to the page they started from. This PR supersedes #36 by @dustinhealy and is written fresh on today's main. It reuses three ideas from that PR: the provider registry, availability read from LibreChat's GET /api/config (gated on social login and on socialLogins, with the tenant header), and carrying the deep link through the admin session.

Since v0.8.5, LibreChat has had admin OAuth routes for SAML, Google, GitHub, Discord, Facebook and Apple. Since #13832, Google admin sessions can also be refreshed. On main, though, the panel only knows OpenID. Its login page has a single OpenID button and its only callback route is /auth/openid/callback. So with Google configured, the page shows only the password form, and /auth/google/callback, where LibreChat sends Google admins back, renders the panel's 404. Every SSO sign-in also lands on /. The panel puts the deep link in a redirectTo query that LibreChat strips, and the callback always redirects to /.

Provider registry. SSO_PROVIDERS in @/constants lists OpenID, Google and SAML. Each entry gives the phrase of its button, the /api/config field with the deployer's own label, how its availability is read, and whether LibreChat can refresh its sessions. LibreChat starts each provider at /api/admin/oauth/<id> and sends the admin back to the panel's /auth/<id>/callback. GitHub, Discord, Facebook and Apple are each one entry away.

Availability. ssoProvidersFn replaces checkOpenIdFn/openIdCheckOptions and returns { providers, ssoOnly }.

  • OpenID keeps the /api/admin/oauth/openid/check probe, because only the probe shows whether LibreChat's OpenID client actually initialised.
  • Google and SAML read a single GET /api/config, sent with tenantHeaders(). Google needs googleLoginEnabled and SAML needs samlLoginEnabled. Both also need socialLoginEnabled, because LibreChat registers their admin strategies only under ALLOW_SOCIAL_LOGIN.
  • A provider that socialLogins leaves out is not offered, as on LibreChat's own sign-in page. This applies to OpenID too.
  • OpenID and SAML buttons use the deployer's openidLabel/samlLabel when /api/config has one. Otherwise they read "Sign in with SSO" and "Continue with SAML"; Google's reads "Continue with Google".
  • If /api/config can't be read, OpenID is still offered when its check answers.
  • ADMIN_SSO_ENABLED=false still offers nothing and makes no calls to LibreChat.

Start. One POST server function, ssoStartFn({ provider, redirectTo }) (Zod: the provider is one of the registry's ids), builds the PKCE challenge the way openidLoginFn did. It stores the verifier, the provider and toSafeRedirect(redirectTo) in the admin session. The browser goes to LibreChat with only code_challenge. The manual buttons and the ADMIN_SSO_ONLY auto-redirect both use it.

Exchange and callback. oauthExchangeFn takes { code, provider }. The session's tokenProvider is the provider sign-in started with, as kept in the admin session, so a code brought back to another provider's callback can't record the session under the wrong provider and send its refresh to the wrong route. The callback's provider is used only for a sign-in started before this change. It returns the stored path and then clears it from the session. It keeps main's t.AuthErrorCode failures, the verifier guard and the tenant header. One route, src/routes/auth/$provider/callback.tsx, replaces the OpenID-only file:

  • It checks the provider against the registry. An unknown provider gets the not-found page.
  • It reuses parseSsoCallbackSearch, resolveSsoCallback and SsoError, so a failure shows the panel's own wording as before.
  • After sign-in it redirects to the stored path, or /, under VITE_BASE_PATH (a new withBasePath helper, which the favicon link in __root.tsx now uses too). The router adds the base path to a to, but would send a raw href redirect as it is.

routeTree.gen.ts is regenerated, and /auth/google/callback and /auth/saml/callback are known metrics routes. A test checks that every registry provider's callback is.

Sessions. tokenProvider is now 'librechat' or a provider id. OpenID and Google sessions refresh through POST /api/admin/oauth/refresh with { refresh_token, user_id, provider }, and the rotated refresh token Google returns is kept. LibreChat has no refresh path for SAML, so a SAML session is never sent to the cookie-based /api/auth/refresh. Password sessions refresh as before.

Sign-in page. AuthCard and login.tsx render one click-ui Button per offered provider, full width under the password form. They follow the order of LibreChat's socialLogins, as LibreChat's own sign-in page does, or the registry's order when LibreChat sends no list. With ADMIN_SSO_ONLY:

  • Exactly one provider: the page goes straight to it.
  • Several providers: the page shows only their buttons, with no password form.
  • No provider resolves: the password form stays, as on main.
  • A provider that fails to start brings the password form back with "SSO redirect failed. You can sign in manually below."

The README and .env.example describe the providers and the SSO-only behaviour.

Not carried over from #36:

  • .inputValidator and string-message errors (this PR keeps .validator and t.AuthErrorCode).
  • Its own X-Tenant-Id read (tenantHeaders() from 🏨 fix: Forward the Tenant Header Through Sign-In and Word Strict-Isolation Refusals #182 is used instead).
  • Rendering error_description (main maps LibreChat's error through SSO_CALLBACK_ERRORS).
  • The note that Google sessions cannot refresh, obsolete since LibreChat #13832.
  • Dropping the OpenID check.
  • Hiding the password form behind a warning when no provider resolves.
  • A new sanitizeInternalRedirect (toSafeRedirect is reused).
  • The hand-merged routeTree.gen.ts.

Closes #30
Closes #65

Change Type

  • New feature (non-breaking change which adds functionality)

Testing

  • npx tsc --noEmit, npx eslint src/ e2e/, prettier on the changed files, bun run test:lint-rules, npx vitest run --maxWorkers=3 (3393 tests, exit 0), bun run build.
  • New unit tests:
    • Availability: each provider's flag, socialLoginEnabled off, socialLogins (including an empty list, and its order), labels from /api/config, OpenID offered on its check without the config, the tenant header on the check and the config call, ADMIN_SSO_ONLY holding only while a provider is offered, and ADMIN_SSO_ENABLED=false (also over ADMIN_SSO_ONLY=true) making no call.
    • ssoStartFn: each provider's LibreChat route, with only the challenge of the stored verifier, and the stored path. //evil.example/grants, /\evil.example, https://evil.example/, javascript:alert(1) and no path are all stored as /.
    • The exchange: a Google sign-in stored as a Google session with its refresh token, the provider sign-in started with winning over the callback's (and the callback's used for a session that kept none), the stored path returned and then cleared, and the path kept when the exchange fails.
    • Refresh: Google (and OpenID) go to /api/admin/oauth/refresh with the provider and the tenant header, and Google's rotated refresh token is persisted. SAML sends no request, and Google without a user id sends none either.
    • AuthCard: the buttons in order with labels, a click starting that provider with the page to return to, SSO-only with one provider (straight to it), several (no password form, no redirect) or none (form kept), and a failed start bringing the form back.
    • withBasePath: an in-app path and its search string under /admin (with or without a trailing slash), and unchanged without a base path.
    • Metrics: every provider's callback path; an unknown provider's path stays unknown.
    • Breaking each piece (no sanitising, a hard-coded openid token provider, a dropped stored path, Google refreshed on the cookie route, no social-login or socialLogins gate, auto-redirect with several providers, a missing metrics route) fails these tests.
  • e2e/login.spec.ts against the mock backend, which now serves /api/config, the admin OAuth start routes (302 back to the callback with a one-time code) and a PKCE-checking exchange: 33 tests pass. The new ones cover the OpenID button named by LibreChat's label, a Google button next to it, /auth/google/callback?error=auth_failed showing the error page instead of a 404, an unknown provider's callback getting the not-found page, and /grants → sign-in → "Continue with Google" → back on /grants. On a main build the Google, callback and deep-link tests fail.
  • Live, production build against LibreChat dev with Google admin OAuth configured (GOOGLE_CLIENT_ID, ALLOW_SOCIAL_LOGIN=true) and no OpenID:
    • /login shows the password form and "Continue with Google". OpenID isn't offered, since its check answers 404.
    • "Continue with Google" from /grants posts { provider: 'google', redirectTo: '/grants' }. The browser goes to LibreChat's /api/admin/oauth/google with only code_challenge, and LibreChat redirects it to accounts.google.com.
    • /auth/google/callback?code=<64 hex> shows "Authorization code has expired" (LibreChat refused the code) and ?error=auth_failed shows the identity-provider message, both instead of a 404. /auth/evil/callback shows "Page not found".
    • A Google round trip from a deep link. The browser's request to LibreChat's Google route was answered the way LibreChat answers a completed consent (302 to /auth/google/callback?code=…). The panel's server-side exchange went to a stand-in for LibreChat's exchange route, which recorded the request and answered with a real admin token. The browser went /grants → /login?redirect=%2Fgrants → callback → /grants. The SHA-256 of the verifier the panel sent matched the code_challenge the browser carried to LibreChat, and the exchange carried the panel's Origin.
    • ADMIN_SSO_ONLY=true with Google only: opening /access went straight to Google without a click and came back to /access.
    • SAML: LibreChat with SAML on a placeholder IdP and a throwaway certificate reports samlLoginEnabled: true. While registration.socialLogins was [openid, google], the page offered Google only, as LibreChat's own page does. With saml added, it offered Google and SAML. "Continue with SAML" from /grants reached LibreChat's real /api/admin/oauth/saml, which stored the challenge and redirected to the IdP with SAMLRequest and RelayState. The IdP and LibreChat's assertion step were answered as a completed sign-in, and the browser came back to /grants with PKCE verified as above.
    • ADMIN_SSO_ONLY=true with Google and SAML: only the two buttons, no password form, no redirect.
    • Password sign-in from /grants still returns to /grants.
    • Screenshots checked in light and dark mode. The Google probes recorded no console errors, page errors or failed requests.
  • Not exercised live: a real Google consent (the stack's Google client doesn't list this callback), a real signed SAML assertion, and a Google refresh against Google (covered by unit tests).

Test Configuration:

  • Bun, production build (bun run build + bun run start); e2e on vite dev with e2e/mock-backend.mjs
  • LibreChat dev, Google admin OAuth configured, OpenID off; SAML on a placeholder IdP for the SAML checks

Known limits

  • Provider setup. Admin SSO returns through LibreChat's admin callbacks, which the provider must also accept: ${DOMAIN_SERVER}/api/admin/oauth/google/callback as a Google redirect URI, ${DOMAIN_SERVER}/api/admin/oauth/saml/callback as a SAML ACS URL. The README now says so; without it the provider shows its own error page (redirect_uri_mismatch for Google) and the admin never returns to the panel.
  • Long deep links. A deep link longer than 2,048 characters isn't carried through SSO: the sign-in still starts and returns to /.
  • Button images. OPENID_IMAGE_URL/SAML_IMAGE_URL are not rendered. The panel's CSP allows only same-origin images, and a LibreChat-relative path would resolve against the panel. The deployer's label is used.
  • Behaviour changes for OpenID. With /api/config readable, the OpenID button now uses LibreChat's openidLabel ("Continue with OpenID" unless OPENID_BUTTON_LABEL is set) instead of "Sign in with SSO". A deployment whose socialLogins leaves out openid no longer gets an OpenID button. The default list includes it.
  • Older LibreChat. Google refresh needs LibreChat with #13832 (v0.8.8). Before that, LibreChat issues no Google refresh token, so a Google session ends when its token expires. The OpenID refresh body now also names provider: 'openid'; LibreChat ignored the field before #13832 and accepts it since.
  • A slow exchange leaves the callback page blank until LibreChat answers, as on main; the callback route has no pending state yet.
  • ⏳ fix: Restore Login Card Width and Show SSO Loading State #123 also edits AuthCard, for the card width and the SSO loading state; its replacement is built on top of this change.

Checklist

  • My code adheres to this project's style guidelines
  • I have performed a self-review of my own code
  • I have commented in any complex areas of my code
  • I have made pertinent documentation changes
  • My changes do not introduce new warnings
  • I have written tests demonstrating that my changes are effective or that my feature works
  • Local unit tests pass with my changes

… and Keep Deep Links Through SSO

The sign-in page offers one button per SSO provider LibreChat has for admins,
from a registry in src/constants (SSO_PROVIDERS: OpenID, Google, SAML).
ssoProvidersFn replaces checkOpenIdFn and returns { providers, ssoOnly }.
OpenID still needs its /api/admin/oauth/openid/check probe. Google and SAML
come from one GET /api/config sent with the request's tenant, and count only
with socialLoginEnabled, since LibreChat registers their admin strategies under
ALLOW_SOCIAL_LOGIN alone. A provider left out of socialLogins is not offered,
as on LibreChat's own sign-in page. ADMIN_SSO_ENABLED=false still offers none.
OpenID and SAML buttons take the deployer's label from /api/config.

One POST ssoStartFn ({ provider, redirectTo }) builds the PKCE challenge and
keeps the verifier and the sanitized in-app path (toSafeRedirect) in the admin
session, instead of the redirectTo query LibreChat strips. oauthExchangeFn takes
{ code, provider }, stores the provider as tokenProvider, and returns and then
forgets the path. One /auth/$provider/callback route replaces the OpenID-only
one: an unknown provider is not found, a failure shows SsoError, and a sign-in
returns to the page it started from. Both callback paths are known metrics
routes.

Google sessions refresh through POST /api/admin/oauth/refresh with
provider: 'google' and keep the rotated refresh token. SAML sessions, which
LibreChat cannot renew, are never sent to the cookie-based /api/auth/refresh.

ADMIN_SSO_ONLY auto-redirects only when exactly one provider is offered, hides
the password form while any is, and brings the form back when a provider cannot
start or none resolves.

Supersedes #36 by @dustinhealy, reusing its provider registry, its
/api/config-based availability with the social-login and socialLogins gates,
and its idea of carrying the deep link through the admin session.

Closes #30
Closes #65
@danny-avila

Copy link
Copy Markdown
Contributor Author

@codex review

Pass 1. Head: fdf93c2

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T09:52:48.041798Z 0268a74 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fdf93c2177

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/server/auth.ts Outdated
Comment thread src/utils/auth.ts Outdated
@danny-avila

Copy link
Copy Markdown
Contributor Author

@codex review

Pass 2. Head: 9182b12

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 9182b125d0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danny-avila

Copy link
Copy Markdown
Contributor Author

@codex review

Pass 3. Head: f7357a9

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f7357a9dc9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/routes/auth/$provider/callback.tsx Outdated
@danny-avila

Copy link
Copy Markdown
Contributor Author

@codex review

Pass 4. Head: 0268a74

1 similar comment
@danny-avila

Copy link
Copy Markdown
Contributor Author

@codex review

Pass 4. Head: 0268a74

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: 0268a7480c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danny-avila
danny-avila merged commit e5836df into main Oct 28, 2026
3 checks passed
danny-avila added a commit that referenced this pull request Oct 28, 2026
… the Provider Loads (#196)

* 🔑 feat: Add Google and SAML Admin Sign-In Through a Provider Registry and Keep Deep Links Through SSO

The sign-in page offers one button per SSO provider LibreChat has for admins,
from a registry in src/constants (SSO_PROVIDERS: OpenID, Google, SAML).
ssoProvidersFn replaces checkOpenIdFn and returns { providers, ssoOnly }.
OpenID still needs its /api/admin/oauth/openid/check probe. Google and SAML
come from one GET /api/config sent with the request's tenant, and count only
with socialLoginEnabled, since LibreChat registers their admin strategies under
ALLOW_SOCIAL_LOGIN alone. A provider left out of socialLogins is not offered,
as on LibreChat's own sign-in page. ADMIN_SSO_ENABLED=false still offers none.
OpenID and SAML buttons take the deployer's label from /api/config.

One POST ssoStartFn ({ provider, redirectTo }) builds the PKCE challenge and
keeps the verifier and the sanitized in-app path (toSafeRedirect) in the admin
session, instead of the redirectTo query LibreChat strips. oauthExchangeFn takes
{ code, provider }, stores the provider as tokenProvider, and returns and then
forgets the path. One /auth/$provider/callback route replaces the OpenID-only
one: an unknown provider is not found, a failure shows SsoError, and a sign-in
returns to the page it started from. Both callback paths are known metrics
routes.

Google sessions refresh through POST /api/admin/oauth/refresh with
provider: 'google' and keep the rotated refresh token. SAML sessions, which
LibreChat cannot renew, are never sent to the cookie-based /api/auth/refresh.

ADMIN_SSO_ONLY auto-redirects only when exactly one provider is offered, hides
the password form while any is, and brings the form back when a provider cannot
start or none resolves.

Supersedes #36 by @dustinhealy, reusing its provider registry, its
/api/config-based availability with the social-login and socialLogins gates,
and its idea of carrying the deep link through the admin session.

Closes #30
Closes #65

* 🔗 fix: Bind the SSO Session to the Provider That Started It and Follow the socialLogins Order

* 🧪 test: Expect the SSO Buttons in LibreChat's socialLogins Order

* ⏳ fix: Restore the Sign-In Card Width and Keep SSO Buttons Busy Until the Provider Loads

click-ui 0.9.1's unlayered `.panel { width: var(--panel-width, auto) }`
outranks Tailwind's layered `w-full`, so the sign-in card and the SSO-only
redirect card took their content's width: 304px and 311px at 1440 instead of
max-w-md's 448px. Both Panels now take Panel's `fillWidth` (an inline
`--panel-width: 100%`) and keep max-w-md.

startSso reset its busy state in a `finally` that ran right after
`window.location.href = authUrl`, so the button went back to its idle label,
enabled, while the browser was still loading the provider, and could be
clicked again. Only a start that fails gives the button back now: an error
result, a request that throws, or a result with an empty URL (assigning it
would reload /login). The button takes click-ui's `loading` (shimmer,
disabled, aria-busy); the other providers stay disabled.

On an SSO-only page with one provider, that state starts as the provider, so
the redirect card shows from the first render and ends the same way. A
`pageshow` with `persisted` (the browser restoring the page from
its back-forward cache, Back from the provider) clears it: the button is idle
again, and an SSO-only page offers its provider's button instead of a spinner
that never ends.

Supersedes #123 by @dustinhealy, whose fillWidth, loading and pageshow
approach this follows. Depends on #188.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Authenticating to LibreChat using SAML Support Google OAuth as an SSO provider (in addition to OpenID)

1 participant