@rjyspl/phoenix-sso-react
v0.1.2
Published
React SDK for Yukthi Single Sign-On — provider, hooks, and components to add SSO to any app without hand-rolling the popup/postMessage handshake.
Readme
@rjyspl/phoenix-sso-react
React SDK for adding SSO to any app by installing and importing — no need to hand-roll the popup/postMessage handshake against your SSO service yourself. Works with any SSO service that speaks this package's popup/redirect + postMessage protocol (see Wire protocol below).
Two ways to use it, depending on whether your app already has its own session/token system:
- Framework-free primitives — for apps that mint and own their own session (their own JWTs, their own refresh logic). Use just the popup/redirect handshake and SSO logout call; you keep everything else.
<SSOProvider>+ hooks/components — for apps that don't want to build any auth state machine at all. The provider owns "am I authenticated" for you, backed by a live check against the SSO service.
Install
Published to the public npm registry under the @rjyspl scope — no auth needed to install:
npm install @rjyspl/phoenix-sso-reactFramework-free primitives
If your app already has its own auth context, token storage, and refresh logic, you likely only need these — no <SSOProvider>, no new state machine:
import { openLoginPopup, redirectToLogin, ssoLogout, PopupBlockedError } from '@rjyspl/phoenix-sso-react';
async function loginWithSso() {
try {
// Opens the popup, resolves once the SSO service posts back success.
await openLoginPopup('https://sso.example.com', 'my-app');
} catch (err) {
if (err instanceof PopupBlockedError) {
// Fall back to a full-page redirect instead of a popup. The SSO
// service redirects back to `returnTo` (default: current URL) once
// login completes — detect that on your side (e.g. a query param you
// add to returnTo) and skip straight to the backend exchange below.
redirectToLogin('https://sso.example.com', 'my-app');
return; // page is navigating away
}
throw err;
}
// Your own backend exchanges the now-set SSO cookie for your app's token.
await myApi.post('/auth/login', {}, { withCredentials: true });
}
async function logout() {
await myApi.post('/auth/logout'); // your own backend session
await ssoLogout('https://sso.example.com'); // invalidates the SSO session
}checkSilentAuth(ssoUrl, appId) is also available if you want to silently check for an existing SSO session (via a hidden iframe) without a popup — resolves with the session payload, rejects if there's no active session.
<SSOProvider> + hooks (for apps with no auth system of their own)
import { SSOProvider, SignedIn, SignedOut, SignInButton, useSSOAuth } from '@rjyspl/phoenix-sso-react';
function App() {
return (
<SSOProvider
ssoUrl="https://sso.example.com"
appId="my-app"
onAuthenticated={() => {
// Optional: exchange the SSO session for your own app's tokens
// against your own backend, if you have one.
return myApi.post('/auth/login', {}, { withCredentials: true });
}}
onSignOut={() => {
myApi.post('/auth/logout');
}}
>
<SignedOut>
<SignInButton>Sign in with SSO</SignInButton>
</SignedOut>
<SignedIn>
<Dashboard />
</SignedIn>
</SSOProvider>
);
}A blocked popup during signIn() falls back to a full-page redirect automatically — no extra handling needed. On the way back, the provider's normal mount-time silent check picks up the new session, so nothing special has to happen at the return URL.
Protecting a route
ProtectedRoute is router-agnostic — pass your own router's redirect element as fallback:
import { ProtectedRoute } from '@rjyspl/phoenix-sso-react';
import { Navigate } from 'react-router-dom';
<ProtectedRoute fallback={<Navigate to="/login" replace />}>
<Dashboard />
</ProtectedRoute>The useSSOAuth() hook
const { status, error, signIn, signOut } = useSSOAuth();
// status: 'checking' | 'authenticated' | 'unauthenticated'signIn()— opens the SSO login popup (falling back to a redirect if blocked), resolves once authenticated (after youronAuthenticatedcallback resolves).signOut()— runs youronSignOutcallback, invalidates the SSO service's session, and clears local status. Resolves even if the SSO logout request itself fails (local state is always reset — check console warnings for failures).
What this package does and doesn't do
Does: the SSO handshake — popup window management with an automatic full-page-redirect fallback when popups are blocked, the silent (iframe) session check, SSO-side logout, postMessage/origin validation against ssoUrl.
Doesn't: mint, store, or refresh your app's own tokens. Every app's backend contract for that is different, so it's handled through onAuthenticated/onSignOut callbacks (or left entirely to your own code, for the framework-free primitives) instead of being baked into the package.
Wire protocol
This package is a client. Any SSO service can work with it as long as it implements this contract — nothing here is tied to a particular backend.
Popup / redirect login — the package navigates a popup (or, on the blocked-popup fallback, the whole page) to:
GET {ssoUrl}/login?mode=popup&app={appId}&origin={encoded current origin} # popup mode
GET {ssoUrl}/login?app={appId}&redirect_uri={encoded return URL} # redirect mode (no "mode" param)- Popup mode (
mode=popuppresent): after authentication,window.opener.postMessage(...)back to the popup's opener, targeting the exactoriginquery param value:{ type: 'SSO_AUTH_SUCCESS' } { type: 'SSO_AUTH_FAILED', message?: string } - Redirect mode (
redirect_uripresent, nomode): after authentication,window.location.href = redirect_uri. NopostMessageinvolved.
Silent auth — loaded in a hidden iframe to check for an existing session without a popup:
GET {ssoUrl}/silent-auth?app={appId}&origin={encoded current origin}Should silently check the session and postMessage back to the parent, targeting origin:
{ type: 'SSO_AUTH_SUCCESS', payload?: unknown }
{ type: 'SSO_AUTH_FAILED', message?: string }
{ type: 'SSO_SESSION_EXPIRED', message?: string }Logout:
DELETE {ssoUrl}/auth/logout (credentials: 'include')Should invalidate the session and respond with a 2xx status.
Security requirement: the package only ever accepts a postMessage whose event.origin exactly matches ssoUrl's origin. Your SSO service must validate the origin query param it receives just as strictly before trusting it as a postMessage target — never post back to an unvalidated origin.
API Reference
Every exported member, in full. ssoUrl throughout is the base URL of the hosted SSO service (e.g. https://sso.example.com), and appId is whatever identifier your app registers with it (e.g. "chat").
<SSOProvider>
The state-managing provider for the hooks/components tier. Wrap your app (or the part of it that needs auth) once.
<SSOProvider
ssoUrl="https://sso.example.com"
appId="my-app"
silentAuthTimeoutMs={5000}
skipInitialCheck={false}
onAuthenticated={() => myApi.post('/auth/login', {}, { withCredentials: true })}
onSignOut={() => myApi.post('/auth/logout')}
>
{children}
</SSOProvider>| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| ssoUrl | string | yes | Base URL of the SSO service. |
| appId | string | yes | Identifier for this app, sent to the SSO service. |
| children | ReactNode | yes | — |
| onAuthenticated | () => void \| Promise<void> | no | Runs after the SSO handshake succeeds (popup sign-in or the mount-time silent check). status only becomes 'authenticated' once this resolves. Typically where you exchange the SSO session for your own app's token. |
| onSignOut | () => void \| Promise<void> | no | Runs when signOut() is called, before the SSO session is invalidated and before local status resets. Typically your own backend logout call plus any local cleanup (query cache, sockets, etc). If it throws, the error is logged (console.warn) but sign-out still proceeds. |
| silentAuthTimeoutMs | number | no | Timeout for the mount-time silent (iframe) session check. Default 5000. |
| skipInitialCheck | boolean | no | Skip the automatic silent check on mount. Default false. If true, status starts as 'unauthenticated' instead of 'checking'. |
useSSOAuth()
Consumes the auth state. Throws if called outside an <SSOProvider>.
const { status, error, signIn, signOut } = useSSOAuth();Returns an SSOAuthContextValue:
| Field | Type | Description |
| --- | --- | --- |
| status | SSOStatus | 'checking' | 'authenticated' | 'unauthenticated'. |
| error | string \| null | The last signIn() failure message, if any. Cleared on the next signIn()/signOut() call. |
| signIn | () => Promise<void> | Opens the SSO login popup. On a blocked popup, falls back to a full-page redirect automatically (the promise then never resolves, since the page is navigating away). Resolves once onAuthenticated has completed and status is 'authenticated'. Rejects (and sets error) on any other failure — wrong credentials, user closed the popup, etc. |
| signOut | () => Promise<void> | Runs onSignOut, invalidates the SSO service's session, resets status to 'unauthenticated'. Always resolves — a failed SSO logout request is logged, not thrown, since local state should still clear either way. |
<SignInButton>
A <button> that calls signIn() on click. Renders inside an <SSOProvider>.
<SignInButton>Sign in with SSO</SignInButton>
<SignInButton className="my-button-class" onClick={trackEvent} />Props: SignInButtonProps = every native <button> prop except type (fixed to "button"), plus optional children (default: "Sign in"). Your own onClick, if provided, runs before signIn() is triggered.
<SignedIn> / <SignedOut>
Conditionally render children based on status.
<SignedIn>{/* rendered only when status === 'authenticated' */}</SignedIn>
<SignedOut>{/* rendered only when status === 'unauthenticated' */}</SignedOut>Both take a single prop: children: ReactNode. Neither renders anything while status === 'checking'.
<ProtectedRoute>
Gates children behind auth status. Deliberately router-agnostic — no assumption about React Router, TanStack Router, or anything else.
import { Navigate } from 'react-router-dom';
<ProtectedRoute
fallback={<Navigate to="/login" replace />}
loading={<Spinner />}
>
<Dashboard />
</ProtectedRoute>| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| children | ReactNode | yes | Rendered when status === 'authenticated'. |
| fallback | ReactNode | yes | Rendered when status === 'unauthenticated'. Pass your own router's redirect element, a login prompt, etc. |
| loading | ReactNode | no | Rendered while status === 'checking'. Default: null. |
openLoginPopup(ssoUrl, appId, options?)
Opens the SSO login popup and waits for the result.
import { openLoginPopup, PopupBlockedError } from '@rjyspl/phoenix-sso-react';
try {
await openLoginPopup('https://sso.example.com', 'my-app', { width: 480, height: 620 });
// popup succeeded — SSO cookie is now set
} catch (err) {
if (err instanceof PopupBlockedError) { /* see redirectToLogin below */ }
// otherwise: user closed the popup, or SSO auth failed (wrong credentials, etc)
}- Returns:
Promise<void>— resolves once the SSO service posts backSSO_AUTH_SUCCESS. - Rejects with:
PopupBlockedError— the browser blockedwindow.open().Error('Login popup was closed before completing authentication.')— the user closed it.Error(<message from the SSO service>)— the SSO service posted backSSO_AUTH_FAILED.
options.width/options.height— popup dimensions in pixels. Default480×620.
Only one popup can be in flight at a time — calling this again cancels any previous attempt.
cancelLoginPopup()
Cancels any in-flight popup and removes its listeners/timers. () => void. Rarely needed directly — <SSOProvider> and the redirect fallback call it internally.
PopupBlockedError
class PopupBlockedError extends Error {}Thrown specifically (and only) when the popup was blocked — use instanceof to branch on this case distinctly from "user closed it" or "auth failed," rather than matching on .message text.
checkSilentAuth(ssoUrl, appId, timeoutMs?)
Checks for an existing SSO session via a hidden iframe, without opening a popup. This is what <SSOProvider> runs on mount.
import { checkSilentAuth } from '@rjyspl/phoenix-sso-react';
try {
const payload = await checkSilentAuth('https://sso.example.com', 'my-app', 5000);
// an SSO session already exists
} catch {
// no active session (or the check timed out)
}- Returns:
Promise<unknown>— resolves with whatever payload the SSO service's silent-auth page sends back (session info, ornull). - Rejects on no active session, an explicit session-expired signal, or timeout (default
5000ms).
cancelSilentAuth()
Cancels any in-flight silent check and removes the hidden iframe/listeners/timer. () => void.
ssoLogout(ssoUrl)
Calls the SSO service's own logout endpoint to invalidate its session cookie.
import { ssoLogout } from '@rjyspl/phoenix-sso-react';
await ssoLogout('https://sso.example.com');- Returns:
Promise<void>. - Throws on a non-2xx response or network failure — same as the axios-based call this typically replaces, so an existing
try/catcharound your old logout call keeps working unchanged.
redirectToLogin(ssoUrl, appId, options?)
Navigates the whole page to the SSO login screen instead of opening a popup — the fallback for PopupBlockedError.
import { redirectToLogin } from '@rjyspl/phoenix-sso-react';
redirectToLogin('https://sso.example.com', 'my-app', {
returnTo: `${window.location.origin}/login?sso_redirect=1`,
});- Returns:
void— the browser navigates away; there's nothing to await. options.returnTo— where the SSO service sends the browser back after login. Defaults towindow.location.href.- On the way back, there's no popup and no
postMessage— you need your own signal for "I just came back from an SSO redirect" (e.g. a query param you add toreturnTo, as above) so you know to skip straight to your backend token exchange instead of trying to sign in again. If you're using<SSOProvider>instead of the raw primitives, you don't need to do any of this — the provider's mount-time silent check picks up the new session automatically.
buildLoginRedirectUrl(ssoUrl, appId, returnTo)
The pure URL-builder redirectToLogin uses internally, exposed in case you want to construct the URL yourself (e.g. for a plain <a href> instead of a JS-triggered navigation).
buildLoginRedirectUrl('https://sso.example.com', 'my-app', 'https://my-app.com/login')
// => 'https://sso.example.com/login?app=my-app&redirect_uri=https%3A%2F%2Fmy-app.com%2Flogin'- Returns:
string.
Types
| Type | Shape |
| --- | --- |
| SSOStatus | 'checking' \| 'authenticated' \| 'unauthenticated' |
| SSOConfig | { ssoUrl: string; appId: string; silentAuthTimeoutMs?: number } |
| SSOAuthContextValue | See useSSOAuth() above. |
| SsoMessageType | 'SSO_AUTH_SUCCESS' \| 'SSO_AUTH_FAILED' \| 'SSO_SESSION_EXPIRED' \| 'SSO_LOGOUT' — the wire protocol's postMessage types. |
| SsoMessage | { type: SsoMessageType; message?: string; payload?: unknown } |
| OpenLoginPopupOptions | { width?: number; height?: number } |
| RedirectToLoginOptions | { returnTo?: string } |
| SignInButtonProps | Native <button> props (minus type) plus { children?: ReactNode }. |
| ProtectedRouteProps | { children: ReactNode; fallback: ReactNode; loading?: ReactNode } |
| SSOProviderProps | SSOConfig & { children: ReactNode; onAuthenticated?; onSignOut?; skipInitialCheck?: boolean } |
License
GPL-3.0-only
