PPM

@pixygon/auth

Shared authentication package for all Pixygon applications

Install

# .npmrc
@pixygon:registry=https://ppm.pixygon.io/

npm install @pixygon/auth@1.7.1

Versions

VersionPublishedIntegrity
1.7.1 latest2026-09-07sha512-fjPNXvkW3/1xZEk…
1.7.0 2026-09-06sha512-yyUwfGO8iVmq71J…
1.6.2 2026-09-06sha512-gCZ1AaC73/xPQi+…
1.6.1 2026-09-03sha512-23gQG2+tHYppilB…
1.6.0 2026-08-08sha512-VGFOxmF7PNRLK9p…
1.5.0 2026-07-24sha512-nADe7/koy2cFPrK…
1.4.6 2026-04-26sha512-xQx95RC6Gbu343k…
1.4.5 2026-04-26sha512-Ecs9ovn2fGWRYZu…
1.4.4 2026-04-26sha512-Gb/0SHIE5NKFQmQ…
1.4.3 2026-04-25sha512-cuQ0Z+sFgNvOXAT…
1.4.2 2026-04-24sha512-ywc04IOpVkBNkct…
1.4.1 2026-04-24sha512-y+AQ2lWcnZmyYl4…
1.4.0 2026-04-24sha512-Ic2zTVTQdtoTcU6…
1.3.2 2026-04-24sha512-UsRBpElnJ8sY0Pb…
1.3.1 2026-04-24sha512-yaWPG30Ev44E5DY…
1.3.0 2026-04-24sha512-nM8W/ihWcQOyxIx…
1.2.1 2026-04-24sha512-rzs7iF3KxNwXO/C…
1.2.0 2026-04-14sha512-DIIwZVg0WnbPAmD…
1.1.0 2026-04-08sha512-e5JWPlcUUaTOc7R…
1.0.0 2026-01-30sha512-4GxahxtcGtNvuIw…

@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

FieldValue
appIdthe MongoDB ObjectId from this repo's .pixygon.json
appNamehuman name — appears in verification/recovery emails and Discord
baseUrlhttps://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 auth

Static 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\":\"$U@example.com\",\"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 200

Single 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 revoked

Entirely 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

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:

  1. 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_verifier must NOT be in that URL — only the challenge.
  2. In DevTools → Application → Session Storage, there is one pxg_sso_pending_<appId> entry while the redirect is in flight.
  3. After landing back: the address bar has no code/state (they were stripped via history.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.
  4. Network tab: exactly one POST /auth/sso/token, body { code, code_verifier, app }, response identical in shape to /auth/login.
  5. Reload. You stay logged in, and there is no second token exchange.
  6. Negative test: paste ?code=x&state=y onto 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

ExportUse
<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, hasSsoCallbackthe same flow without React
generateCodeVerifier, createCodeChallenge, generateStatePKCE primitives (Web Crypto, no deps)
PixygonAuth, LoginForm, RegisterForm, VerifyForm, ForgotPasswordForm, RecoverPasswordForm@pixygon/auth/components
createAuthApiClient, createTokenStoragenon-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

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 public

Apps 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

All packages · packument JSON