@pixygon/auth
v1.6.0
Published
Shared authentication package for all Pixygon applications
Maintainers
Readme
@pixygon/auth
The shared account spine for every Pixygon web app — one Pixygon account,
one token store, one set of branded forms. 16 repos depend on it, second
only to @pixygon/analytics.
It talks to the central Pixygon API (https://api.pixygon.com/v1): register,
verify, login, refresh, password recovery, /users/me. The Unity counterpart
is com.pixygon.passport (related system, deliberately separate package).
Retrofitting it into an existing app (≈20 minutes)
Written for an agent. Do these in order.
1. Install
npm i @pixygon/auth@^1.6.0>= 1.4.7 sends X-App-Id on every request (server-side per-app attribution
for emails and Discord notices); 1.5.0 returns a soft-verification
session on register, so a new user is logged in immediately instead of being
stranded on a "check your email" screen; 1.6.0 adds the opt-in
SSO client
(nothing changes unless you set config.sso).
2. Know your ids
| Field | Value |
|---|---|
| appId | the MongoDB ObjectId from this repo's .pixygon.json |
| appName | human name — appears in verification/recovery emails and Discord |
| baseUrl | https://api.pixygon.com/v1 (with an env override) |
appId does two jobs: it namespaces the localStorage token keys
(<appId>_access_token, …) and it is sent as X-App-Id so the server knows
which app the request came from. Use the ObjectId, not a slug — a slug
still isolates tokens but the server can't attribute it to a project.
⚠ Changing appId later logs everyone out (the storage keys move).
⚠ One exception: an app that deliberately shares the hub's session (e.g.
PixygonAdmin) sets appId to the pixygon.io project
65481fec93ee5f866ade917f. Only do that on purpose.
3. Mount the provider
The estate pattern is a thin local wrapper (src/providers/AuthProvider.tsx)
that owns the config and re-exports the hooks, so the rest of the app never
imports the package directly:
// src/providers/AuthProvider.tsx — copied from Tastebud
import { AuthProvider as PixygonAuthProvider, useAuth, useUser } from '@pixygon/auth'
import type { ReactNode } from 'react'
const AUTH_CONFIG = {
baseUrl: import.meta.env.VITE_API_URL || 'https://api.pixygon.com/v1',
appId: '69ee9b3f7a116f8ba90d0890', // from .pixygon.json
appName: 'Tastebud',
recoveryBaseUrl: `${window.location.origin}/reset-password`,
}
export function AuthProvider({ children }: { children: ReactNode }) {
return <PixygonAuthProvider config={AUTH_CONFIG}>{children}</PixygonAuthProvider>
}
export { useAuth, useUser }Mount it outside the router and outside every other pearl provider — subscription, social and savedata all read the token from it:
<AuthProvider>
<PixygonPlusProvider> {/* @pixygon/subscription */}
<SocialProvider> {/* @pixygon/social */}
<SaveDataProvider> {/* @pixygon/savedata */}
<BrowserRouter><App /></BrowserRouter>
</SaveDataProvider>
</SocialProvider>
</PixygonPlusProvider>
</AuthProvider>4. Use it
import { useAuth, useUser, useToken, useRequireAuth } from '@pixygon/auth'
const { login, register, logout, isAuthenticated, isLoading, error } = useAuth()
const { userName, email, subscriptionTier } = useUser()
const { accessToken, getAccessToken } = useToken() // pass getAccessToken to the other pearls
// protected route
const { isAuthorized } = useRequireAuth({ roles: ['admin'], onUnauthorized: () => nav('/login') })Or drop in the branded forms instead of writing your own:
import { PixygonAuth } from '@pixygon/auth/components'
<PixygonAuth mode="login" theme="dark" onSuccess={() => nav('/')} onModeChange={(m) => nav(`/auth/${m}`)} />Modes: login | register | verify | forgot-password | recover-password.
For recover-password, pass the email + code from the recovery link, and
set recoveryBaseUrl in the config to the route that renders it.
5. Verify it worked
node node_modules/@pixygon/auth/verify.mjs
# or:
pearl verify authStatic check — installed version, <AuthProvider> mounted, non-placeholder
appId that agrees with .pixygon.json, baseUrl pointing at the real
central API (localhost with no fallback fails). Exit 0 pass, 1 fail.
What verify cannot prove: that logging in works. It never touches the network. Auth is money-adjacent, so after any auth change also run the live end-to-end smoke against the real API — import-level tests have missed a prod-breaking register bug before:
API=https://api.pixygon.com/v1
U=smoke$RANDOM
# 1. register (1.5.0+ returns a session token straight away)
TOK=$(curl -s -X POST $API/auth/register -H 'Content-Type: application/json' \
-d "{\"userName\":\"$U\",\"email\":\"[email protected]\",\"password\":\"Passw0rd!23\"}" \
| node -pe 'JSON.parse(require("fs").readFileSync(0)).token')
# 2. use the token on an authenticated endpoint
curl -s $API/users/me -H "Authorization: Bearer $TOK" | head -c 200Single sign-on (optional) — log in once, be logged in everywhere
Every Pixygon app already shares one identity (the same accounts, the same
/v1/auth). What it lacked was one session: tokens live in localStorage
keyed <appId>_access_token, which is per-origin by browser design, and most
apps sit on their own apex (pixiel.ai, lonnlyst.no, kikortet.no …).
SSO closes that gap with an authorization-code + PKCE top-level redirect
against the central auth origin. Contract:
Dyson/docs/SSO_DESIGN.md.
GET {baseUrl}/auth/sso/authorize ?app&redirect_uri&state&code_challenge[&prompt=none]
-> 302 back to redirect_uri?code=<one-time>&state=<state>
POST {baseUrl}/auth/sso/token { code, code_verifier, app }
-> { token, refreshToken, expiresIn, user } (same shape as /auth/login)
GET {baseUrl}/auth/sso/logout ?redirect_uri&state (TOP-LEVEL navigation)
-> 302 back to redirect_uri, central session revokedEntirely opt-in. An app that doesn't set config.sso behaves exactly as it
did before — same storage keys, same hooks, same everything. Upgrading the
package changes nothing on its own.
⚠ Server status: the
/auth/sso/*endpoints are being built in parallel against this same contract. Until they are live,startLogin()will redirect to a 404. Ship the client wiring behind a flag if that matters to you.
1. Turn it on
One key in the config you already have:
const AUTH_CONFIG = {
baseUrl: import.meta.env.VITE_API_URL || 'https://api.pixygon.com/v1',
appId: '69ee9b3f7a116f8ba90d0890', // from .pixygon.json
appName: 'Tastebud',
sso: true, // <- that's the whole retrofit
}sso: true means: expose useSso(), and auto-finish an SSO callback when
the app loads with ?code in the URL. Tune it with an object instead:
sso: {
autoComplete: true, // finish ?code callbacks on mount (default true)
redirectUri: undefined, // default: current origin + pathname
cleanUrl: true, // strip code/state from the address bar (default true)
}The redirect_uri must be on the server's allow-list (same ownership rule as
CORS), so a new domain needs registering server-side before it will work.
2. Send people to it
import { useSso, useAuth } from '@pixygon/auth'
function LoginButton() {
const { startLogin, status, error } = useSso()
return (
<button onClick={() => startLogin()} disabled={status === 'starting'}>
Continue with Pixygon
</button>
)
}startLogin() navigates away — treat every line after it as unreachable.
Keep your password form: SSO is an additional door, not a replacement.
3. (Optional) the silent check for returning visitors
To answer "is this person already logged in centrally?" without a click:
function SilentSsoProbe() {
const { isAuthenticated, isLoading } = useAuth()
const { enabled, trySilentLogin } = useSso()
useEffect(() => {
if (enabled && !isLoading && !isAuthenticated) trySilentLogin()
}, [enabled, isLoading, isAuthenticated, trySilentLogin])
return null
}trySilentLogin() uses prompt=none: the auth origin either bounces back with
a code (invisible, fast) or with error=login_required. It runs at most
once per tab per app — the package marks the probe in sessionStorage, which
is what stops a server that keeps answering login_required from becoming an
infinite redirect loop. Don't hand-roll this with startLogin({ prompt: 'none' })
in an effect; that is exactly the loop.
What actually happens
First visit, never logged in anywhere
startLogin() → authorize → no central session → the auth origin shows its
login → user signs in → central pxg_sso cookie is set → 302 back with ?code
→ the provider exchanges it → tokens land in <appId>_access_token → useAuth()
reports authenticated. The URL is cleaned; your app never sees a token in it.
Returning visit, already signed in at another Pixygon app
trySilentLogin() (or startLogin()) → authorize sees the cookie → 302 straight
back with ?code → exchanged on mount → signed in with no form and no click.
Two fast redirects, no password.
Returning visit, no central session, prompt=none
302 back with error=login_required → useSso().status === 'login_required' →
render your normal login. Not an error, not a throw.
Any callback that doesn't match
Wrong/absent state, a code with no request pending in this tab, a stale
request (>10 min): nothing is exchanged, the stash is dropped, the URL is
cleaned, and you get { status: 'error' }. completeSsoLogin never throws.
Honest limits
- No silent cross-domain check without a top-level redirect. Hidden iframes
need third-party cookies, which browsers block by default.
prompt=noneis fast and invisible, but it is still a real navigation — the page unloads and comes back. Don't call it mid-form. - Logout is not a kill switch.
useSso().logout()ends the central session (no more silent grants) and clears this app's tokens. Other apps that already hold a token keep working until it expires (max 7d access) unless they log out themselves. Anyone promising "log out everywhere, instantly" is promising something this design does not deliver. - Logout is a page navigation, not a fetch.
pxg_ssoisSameSite=Laxand the auth origin is cross-site for every app (a different registrable domain even from*.pixygon.io), so the cookie is NOT sent on a cross-site fetch — a POST would arrive without it and revoke nothing while looking like it worked.ssoLogout()therefore clears local tokens first and then navigates top-level to the auth origin, where the cookie is first-party and IS sent. Expect the page to leave; the returnedcentral: 'navigating'says exactly that rather than claiming a confirmed revocation it cannot observe. - Secure context required. PKCE uses
crypto.subtle, which does not exist on plainhttp://origins (localhost is fine). On an insecure originstartLogin()setsstatus: 'error'with a message saying so. - sessionStorage required. The verifier must survive the redirect without leaking to other tabs. If storage is blocked (some private modes), SSO fails closed and the password form still works.
- Changing
appIdstill logs everyone out — the storage keys move. SSO doesn't change that.
Verify it worked
There is no static check for this — verify.mjs reports adoption but cannot
prove a redirect flow works. Drive it once, by hand:
- Click your SSO button. The address bar goes to
…/v1/auth/sso/authorize?app=<your ObjectId>&redirect_uri=<your origin+path>&state=…&code_challenge=….code_verifiermust NOT be in that URL — only the challenge. - In DevTools → Application → Session Storage, there is one
pxg_sso_pending_<appId>entry while the redirect is in flight. - After landing back: the address bar has no
code/state(they were stripped viahistory.replaceState, so they're not in history or the Referer either), and Local Storage has<appId>_access_token,<appId>_refresh_token,<appId>_user,<appId>_expires_at— the exact same keys a password login writes. - Network tab: exactly one
POST /auth/sso/token, body{ code, code_verifier, app }, response identical in shape to/auth/login. - Reload. You stay logged in, and there is no second token exchange.
- Negative test: paste
?code=x&state=yonto your app URL by hand. Nothing is exchanged, the params are stripped,useSso().status === 'error'.
Imperative (non-React) use
import { startSsoLogin, completeSsoLogin, ssoLogout, hasSsoCallback } from '@pixygon/auth'
if (hasSsoCallback()) {
const result = await completeSsoLogin({ appId, baseUrl }) // never throws
// 'success' | 'login_required' | 'error' | 'no_callback'
}
await startSsoLogin({ appId, baseUrl, prompt: 'none' })
await ssoLogout({ baseUrl, appId })API
| Export | Use |
|---|---|
| <AuthProvider config> | the mount; everything else needs it |
| useAuth() | user, status, isAuthenticated, isLoading, error, login, register, logout, verify, forgotPassword, recoverPassword, changePassword, hasRole |
| useUser() | user, userName, email, role, subscriptionTier, totalTokens |
| useToken() | accessToken, refreshToken, getAccessToken, refreshTokens — this is what you hand to social/savedata/subscription |
| useAuthStatus() | isIdle, isLoading, isAuthenticated, isUnauthenticated, isVerifying |
| useRequireAuth({roles, onUnauthorized}) | route guards |
| useProfileSync() | keeps the cached user fresh |
| useSso() | enabled, status, error, startLogin, trySilentLogin, completeLogin, logout — central SSO (opt-in via config.sso) |
| startSsoLogin, completeSsoLogin, ssoLogout, trySilentSsoLogin, hasSsoCallback | the same flow without React |
| generateCodeVerifier, createCodeChallenge, generateState | PKCE primitives (Web Crypto, no deps) |
| PixygonAuth, LoginForm, RegisterForm, VerifyForm, ForgotPasswordForm, RecoverPasswordForm | @pixygon/auth/components |
| createAuthApiClient, createTokenStorage | non-React / imperative use |
AuthConfig
{
baseUrl: string // REQUIRED — https://api.pixygon.com/v1
appId: string // REQUIRED — .pixygon.json ObjectId
appName?: string // shown in emails + Discord
language?: 'en' | 'no' // auth UI + email language
recoveryBaseUrl?: string // where the recovery link lands
autoRefresh?: boolean // default true
refreshThreshold?: number // seconds before expiry (default 300)
onLogin?, onLogout?, onTokenRefresh?, onError?
storage?: AuthStorage // default localStorage
theme?: AuthTheme
debug?: boolean
sso?: boolean | { // central SSO — omitted = off, nothing changes
autoComplete?: boolean // finish ?code callbacks on mount (default true)
redirectUri?: string // default: current origin + pathname
cleanUrl?: boolean // strip code/state afterwards (default true)
}
}Gotchas
- Hooks outside the provider silently no-op —
isAuthenticatedstaysfalseforever. If login "does nothing", check the provider is above the component in the tree. appIdis a storage namespace. Changing it, or using two different values in one app, splits the session in two.- Don't confuse it with the other pearls'
appId.@pixygon/subscriptionand@pixygon/socialtake their ownappId(a lowercase app slug liketastebud) — those are unrelated to the auth one and are usually declared in the same file. - Register returns a token in 1.5.0+ only. On older versions the app must send the user through the verify screen before it has a session.
- SSO is off unless you ask for it. No
config.ssomeans no redirect handling, no new storage, no behaviour change.useSso()still works — it reportsenabled: falseand its actions are no-ops, so a button can render behind that flag without ceremony. - Redux apps need a small sync component (see below) — the package is the source of truth, Redux is a mirror.
function AuthReduxSync({ children }) {
const { user, accessToken, isAuthenticated } = useAuth()
const dispatch = useDispatch()
useEffect(() => {
if (isAuthenticated && user && accessToken) dispatch(setLogin({ user, token: accessToken }))
else if (!isAuthenticated) dispatch(setLogout())
}, [isAuthenticated, user, accessToken, dispatch])
return children
}Publishing
npm run build && npm publish --access publicApps consume a change here only once it is published — 1.6.0 (the SSO
client) is committed but not yet on npm, so npm i @pixygon/auth@^1.6.0 in a
consuming repo will resolve to the last published version until someone with a
token runs the two commands above.
MIT — Pixygon
