AuthZ Client¶
SentinelAuthz is the browser auth client for authz mode. It manages dual tokens: an IdP token (identity, from Google/EntraID) and a Sentinel authz token (authorization).
Setup¶
import { SentinelAuthz, IdpConfigs } from '@sentinel-auth/js'
const authz = new SentinelAuthz({
sentinelUrl: 'http://localhost:9003',
mintEndpoint: '/api/auth/mint', // YOUR backend route — must not be Sentinel
idps: { google: IdpConfigs.google('your-google-client-id') },
})
| Option | Type | Default | Description |
|---|---|---|---|
sentinelUrl |
string |
required | Base URL of the Sentinel service. Used only for discovery (listing workspaces for an IdP token). |
mintEndpoint |
string |
required | URL of your backend's mint route. The browser calls here (not Sentinel directly) to exchange IdP token + workspace_id for an authz token. Your backend forwards to Sentinel's /authz/resolve with X-Service-Key. See AuthZ Mode Security. |
idps |
Record<string, IdpConfig> |
{} |
IdP configs keyed by provider name |
redirectUri |
string |
${origin}/auth/callback |
OAuth redirect URI |
storage |
AuthzTokenStore |
AuthzMemoryStore |
Token storage backend |
autoRefresh |
boolean |
true |
Refresh authz token before expiry |
refreshBuffer |
number |
30 |
Seconds before expiry to trigger refresh |
Backend mint route¶
The mintEndpoint must accept {idp_token, provider, workspace_id, nonce?} and return the same shape as Sentinel's /authz/resolve. FastAPI example:
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import uuid
from your_app.sentinel_instance import sentinel # your Sentinel SDK instance
router = APIRouter()
class MintRequest(BaseModel):
idp_token: str
provider: str
workspace_id: uuid.UUID
nonce: str | None = None
@router.post("/api/auth/mint")
async def mint_authz_token(body: MintRequest):
try:
return await sentinel.authz.resolve(
idp_token=body.idp_token,
provider=body.provider,
workspace_id=body.workspace_id,
nonce=body.nonce,
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
Add the route to sentinel.protect(app, exclude_paths=[...]) — it's called before the user has an authz token.
Next.js Route Handler:
// app/api/auth/mint/route.ts
import { NextResponse } from 'next/server'
export async function POST(req: Request) {
const body = await req.json()
const r = await fetch(`${process.env.SENTINEL_URL}/authz/resolve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Service-Key': process.env.SENTINEL_SERVICE_KEY!, // server-side only
},
body: JSON.stringify(body),
})
return NextResponse.json(await r.json(), { status: r.status })
}
Built-in IdP helpers: IdpConfigs.google(clientId), IdpConfigs.entraId(clientId, tenantId). Pass a custom IdpConfig object for other providers.
How it works¶
1. authz.login('google') -> redirect to Google
2. Google redirects back with #id_token=...
3. authz.handleCallback() -> extract token, verify nonce
4. authz.resolve(idpToken, provider) -> POST /authz/resolve, get workspaces
5. authz.selectWorkspace(...) -> POST /authz/resolve with workspace_id
6. Both tokens stored, auto-refresh scheduled
Methods¶
login(provider)¶
Redirect to the IdP's authorization page. Provider must be configured in idps.
handleCallback()¶
Interpret the IdP response in the URL hash after a redirect. Verifies the nonce. Returns a discriminated result (or null if there is no IdP response in the URL):
const cb = authz.handleCallback()
// | { status: 'success', idpToken, provider, returnTo } -> proceed to resolve/selectWorkspace
// | { status: 'silent_failed', error, provider, returnTo } -> silentLogin() couldn't complete; fall back to login()
// | null -> not a callback
Throws on a genuine OAuth error or a nonce mismatch (possible replay). returnTo is the same-origin path the user was on before a silentLogin() redirect (or null). Accepts an optional pre-captured hash, handleCallback(hash), for React StrictMode.
resolve(idpToken, provider)¶
Validate IdP token with Sentinel, discover workspaces.
const result = await authz.resolve(idpToken, 'google')
// result.user -> { id, email, name }
// result.workspaces -> [{ id, name, slug, role }]
selectWorkspace(idpToken, provider, workspaceId)¶
Exchange IdP token for a Sentinel authz token scoped to a workspace. POSTs to the configured mintEndpoint on your backend (not Sentinel). Propagates sessionStorage.sentinel_authz_nonce automatically for replay protection.
getAuthState() / getUser() / isAuthenticated / needsReauth¶
Auth state is derived from both tokens. A valid authz token alone is not enough — the memory-only IdP token (gone after a reload) is also required to authenticate a request. getAuthState() reports this honestly so the app never renders "logged in" while every request 401s:
authz.getAuthState()
// 'authenticated' -> authz token + IdP token present; requests work
// 'needs_reauth' -> authz token survived (e.g. reload) but IdP token is gone
// 'unauthenticated' -> no usable authz token
const user = authz.getUser() // non-null ONLY when authenticated
if (authz.isAuthenticated) { /* ... */ } // === getAuthState() === 'authenticated'
if (authz.needsReauth) { authz.silentLogin() } // re-auth after reload (see below)
silentLogin(provider?) / consumeReturnTo()¶
Recover from needs_reauth (e.g. after a page reload) without a full manual login. silentLogin() does a top-level prompt=none redirect to the IdP using the stored provider; with a live IdP session it bounces straight back with a fresh id_token (usually no UI). Returns false if it no-ops (no provider known, or an attempt is already in flight — there is a built-in loop guard). A full-page redirect is used deliberately over a hidden iframe, which third-party-cookie rules make unreliable.
if (authz.needsReauth) authz.silentLogin() // → resolves on the callback
// on the callback, handleCallback() returns 'silent_failed' if interaction is needed
const returnTo = authz.consumeReturnTo() // same-origin path to restore, or null
getHeaders()¶
fetch / fetchJson¶
Inject dual-token headers. On 401, refresh and retry once.
const res = await authz.fetch('/api/notes')
const notes = await authz.fetchJson<Note[]>('/api/notes')
onAuthStateChange(cb) / logout() / destroy()¶
const unsub = authz.onAuthStateChange((user) => { /* ... */ })
authz.logout() // clear tokens, notify listeners
authz.destroy() // clean up timers
Token storage¶
| Backend | Persistence |
|---|---|
AuthzMemoryStore (default) |
Lost on page refresh |
AuthzLocalStorageStore |
Authz token + metadata persist; IdP token stays in memory only |
import { SentinelAuthz, AuthzLocalStorageStore } from '@sentinel-auth/js'
const authz = new SentinelAuthz({
sentinelUrl: '...', storage: new AuthzLocalStorageStore(),
})
IdP token is not persisted
AuthzLocalStorageStore deliberately keeps the IdP token (which is long-lived and trust-critical — a Google ID token lasts ~1h and authenticates on every request) in instance memory only. It does not survive a page reload. This reduces the blast radius of XSS: an attacker who reads localStorage does not get the IdP token, only the short-lived (~5 min) authz token.
Trade-off: after a page reload the SDK has no IdP token. getAuthState() then reports needs_reauth (and isAuthenticated is false, getUser() is null) — so the app shows login instead of a broken "zombie" page that 401s on every request. Call silentLogin() to re-auth seamlessly via the IdP's existing session, or front your frontend with a backend route that sets an HttpOnly cookie holding the tokens server-side for true persistent sessions.
handleCallback() nonce enforcement¶
handleCallback() requires that sentinel_authz_nonce exists in sessionStorage. If it doesn't (e.g. the callback was opened in a new tab that did not initiate the login), the SDK throws:
This prevents a login-CSRF where an attacker links a victim to .../auth/callback#id_token=<attacker_token> and silently establishes the attacker's identity in the victim's app.
Complete example¶
import { SentinelAuthz, IdpConfigs, AuthzLocalStorageStore } from '@sentinel-auth/js'
const authz = new SentinelAuthz({
sentinelUrl: 'http://localhost:9003',
mintEndpoint: '/api/auth/mint',
idps: { google: IdpConfigs.google('your-client-id') },
storage: new AuthzLocalStorageStore(),
})
// Login page
authz.login('google')
// Callback page (/auth/callback)
const cb = authz.handleCallback()
if (cb?.status === 'success') {
const result = await authz.resolve(cb.idpToken, cb.provider)
if (result.workspaces?.length === 1) {
await authz.selectWorkspace(cb.idpToken, cb.provider, result.workspaces[0].id)
window.location.href = cb.returnTo ?? '/dashboard'
}
} else if (cb?.status === 'silent_failed') {
authz.login('google') // silent re-auth needs interaction → fall back to interactive
}
// Protected page boot: recover a reloaded session seamlessly
if (authz.needsReauth) authz.silentLogin()
// After auth
const notes = await authz.fetchJson<Note[]>('/api/notes')
AuthZ vs Proxy mode¶
AuthZ (SentinelAuthz) |
Proxy (SentinelAuth) |
|
|---|---|---|
| IdP interaction | You configure IdPs, SDK redirects | Sentinel manages redirect flow |
| Tokens stored | IdP token + authz token | Access + refresh token |
| Headers sent | Authorization + X-Authz-Token |
Authorization only |
| PKCE | Not needed (implicit flow) | Generated by SDK |