Repository navigation
🔑 feat: Add Google and SAML Admin Sign-In Through a Provider Registry and Keep Deep Links Through SSO - #188
Conversation
… 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
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
…w the socialLogins Order
|
Codex Review: Didn't find any major issues. Can't wait for the next one! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
There was a problem hiding this comment.
💡 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".
1 similar comment
|
Codex Review: Didn't find any major issues. Another round soon, please! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
… 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.
Pull Request Template
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'sGET /api/config(gated on social login and onsocialLogins, 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 aredirectToquery that LibreChat strips, and the callback always redirects to/.Provider registry.
SSO_PROVIDERSin@/constantslists OpenID, Google and SAML. Each entry gives the phrase of its button, the/api/configfield 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.
ssoProvidersFnreplacescheckOpenIdFn/openIdCheckOptionsand returns{ providers, ssoOnly }./api/admin/oauth/openid/checkprobe, because only the probe shows whether LibreChat's OpenID client actually initialised.GET /api/config, sent withtenantHeaders(). Google needsgoogleLoginEnabledand SAML needssamlLoginEnabled. Both also needsocialLoginEnabled, because LibreChat registers their admin strategies only underALLOW_SOCIAL_LOGIN.socialLoginsleaves out is not offered, as on LibreChat's own sign-in page. This applies to OpenID too.openidLabel/samlLabelwhen/api/confighas one. Otherwise they read "Sign in with SSO" and "Continue with SAML"; Google's reads "Continue with Google"./api/configcan't be read, OpenID is still offered when its check answers.ADMIN_SSO_ENABLED=falsestill 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 wayopenidLoginFndid. It stores the verifier, the provider andtoSafeRedirect(redirectTo)in the admin session. The browser goes to LibreChat with onlycode_challenge. The manual buttons and theADMIN_SSO_ONLYauto-redirect both use it.Exchange and callback.
oauthExchangeFntakes{ code, provider }. The session'stokenProvideris 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'sprovideris used only for a sign-in started before this change. It returns the stored path and then clears it from the session. It keepsmain'st.AuthErrorCodefailures, the verifier guard and the tenant header. One route,src/routes/auth/$provider/callback.tsx, replaces the OpenID-only file:parseSsoCallbackSearch,resolveSsoCallbackandSsoError, so a failure shows the panel's own wording as before./, underVITE_BASE_PATH(a newwithBasePathhelper, which the favicon link in__root.tsxnow uses too). The router adds the base path to ato, but would send a rawhrefredirect as it is.routeTree.gen.tsis regenerated, and/auth/google/callbackand/auth/saml/callbackare known metrics routes. A test checks that every registry provider's callback is.Sessions.
tokenProvideris now'librechat'or a provider id. OpenID and Google sessions refresh throughPOST /api/admin/oauth/refreshwith{ 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.
AuthCardandlogin.tsxrender one click-uiButtonper offered provider, full width under the password form. They follow the order of LibreChat'ssocialLogins, as LibreChat's own sign-in page does, or the registry's order when LibreChat sends no list. WithADMIN_SSO_ONLY:main.The README and
.env.exampledescribe the providers and the SSO-only behaviour.Not carried over from #36:
.inputValidatorand string-message errors (this PR keeps.validatorandt.AuthErrorCode).X-Tenant-Idread (tenantHeaders()from 🏨 fix: Forward the Tenant Header Through Sign-In and Word Strict-Isolation Refusals #182 is used instead).error_description(mainmaps LibreChat'serrorthroughSSO_CALLBACK_ERRORS).sanitizeInternalRedirect(toSafeRedirectis reused).routeTree.gen.ts.Closes #30
Closes #65
Change Type
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.socialLoginEnabledoff,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_ONLYholding only while a provider is offered, andADMIN_SSO_ENABLED=false(also overADMIN_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/./api/admin/oauth/refreshwith 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.unknown.openidtoken provider, a dropped stored path, Google refreshed on the cookie route, no social-login orsocialLoginsgate, auto-redirect with several providers, a missing metrics route) fails these tests.e2e/login.spec.tsagainst 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_failedshowing 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 amainbuild the Google, callback and deep-link tests fail.devwith Google admin OAuth configured (GOOGLE_CLIENT_ID,ALLOW_SOCIAL_LOGIN=true) and no OpenID:/loginshows the password form and "Continue with Google". OpenID isn't offered, since its check answers 404./grantsposts{ provider: 'google', redirectTo: '/grants' }. The browser goes to LibreChat's/api/admin/oauth/googlewith onlycode_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_failedshows the identity-provider message, both instead of a 404./auth/evil/callbackshows "Page not found"./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 thecode_challengethe browser carried to LibreChat, and the exchange carried the panel'sOrigin.ADMIN_SSO_ONLY=truewith Google only: opening/accesswent straight to Google without a click and came back to/access.samlLoginEnabled: true. Whileregistration.socialLoginswas[openid, google], the page offered Google only, as LibreChat's own page does. Withsamladded, it offered Google and SAML. "Continue with SAML" from/grantsreached LibreChat's real/api/admin/oauth/saml, which stored the challenge and redirected to the IdP withSAMLRequestandRelayState. The IdP and LibreChat's assertion step were answered as a completed sign-in, and the browser came back to/grantswith PKCE verified as above.ADMIN_SSO_ONLY=truewith Google and SAML: only the two buttons, no password form, no redirect./grantsstill returns to/grants.Test Configuration:
bun run build+bun run start); e2e onvite devwithe2e/mock-backend.mjsdev, Google admin OAuth configured, OpenID off; SAML on a placeholder IdP for the SAML checksKnown limits
${DOMAIN_SERVER}/api/admin/oauth/google/callbackas a Google redirect URI,${DOMAIN_SERVER}/api/admin/oauth/saml/callbackas a SAML ACS URL. The README now says so; without it the provider shows its own error page (redirect_uri_mismatchfor Google) and the admin never returns to the panel./.OPENID_IMAGE_URL/SAML_IMAGE_URLare 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./api/configreadable, the OpenID button now uses LibreChat'sopenidLabel("Continue with OpenID" unlessOPENID_BUTTON_LABELis set) instead of "Sign in with SSO". A deployment whosesocialLoginsleaves outopenidno longer gets an OpenID button. The default list includes it.provider: 'openid'; LibreChat ignored the field before #13832 and accepts it since.main; the callback route has no pending state yet.AuthCard, for the card width and the SSO loading state; its replacement is built on top of this change.Checklist