@danainnovations/sonance-gate
v0.4.0
Published
Framework-agnostic Sonance Okta SSO gate for Vercel apps (Routing Middleware)
Downloads
563
Readme
@danainnovations/sonance-gate
Sonance Okta SSO for any web app on Vercel — one file, any framework (Next.js, Vite, Astro, SvelteKit, static HTML).
How it behaves
| Environment | Behavior | |---|---| | Vercel production | Real Okta sign-in required for every route | | Vercel preview | Legacy-open by default; internal/data-bearing apps opt into real Okta | | Local dev | Local-only Dev User adapter; no Okta credentials |
Set enforceInPreview: true for every new internal or company-data-bearing app. Existing apps retain their current preview posture until they are audited and migrated. Sessions last 12 hours; re-auth is silent while your Okta session is alive.
Install
npm install @danainnovations/sonance-gateCreate middleware.ts at your project root (NOT inside src/ or app/):
import { createGate } from '@danainnovations/sonance-gate'
export default createGate({
// Required for internal/company-data-bearing applications.
enforceInPreview: true,
// Available only while running locally; selection still enters the standard
// server-side identity path used by the app's authorization checks.
devUsers: [
{ sub: 'admin', name: 'Admin', email: '[email protected]' },
{ sub: 'manager', name: 'Manager', email: '[email protected]' },
],
// Paths reachable without sign-in. '/prefix/*' matches the whole subtree;
// a bare '/prefix' must be listed exactly (on its own) to be public.
publicPaths: ['/api/webhook/*'],
// End the Okta session at sign-out too. Needs a registered sign-out
// redirect URI first — see "Signing out" below.
oktaLogout: true,
})
export const config = {
runtime: 'nodejs',
matcher: ['/((?!_next/static|_next/image|favicon.ico|assets/).*)'],
}Next.js note: on Next.js ≤15 the framework owns root middleware.ts — compose by calling the gate first inside your existing middleware and returning its Response when defined. On Next.js 16+ use proxy.ts for framework logic; middleware.ts belongs to the gate.
Environment variables (Vercel production only)
OKTA_CLIENT_ID, OKTA_CLIENT_SECRET, OKTA_ISSUER — pushed by the Technology team from your app's Okta registration. SESSION_SECRET — any random string ≥32 chars. Nothing is needed locally.
If any are missing in an enforcing deployed environment the gate fails closed with a 500 naming the missing key names. Deployed AUTH_BYPASS, DISABLE_AUTH, and NEXT_PUBLIC_DISABLE_AUTH variables are rejected whenever present, regardless of value; they are not escape hatches.
Local Dev User Switcher
Local development needs no IdP credentials. GET /auth/dev/users returns the allowlisted devUsers; POST /auth/dev/switch with { "sub": "..." } selects one through an httpOnly local session cookie. The gate then injects that selected principal through the same server-side getUser() path used in deployment.
The adapter runs only for loopback hosts (localhost, 127.0.0.1, or ::1); a local-mode request addressed to any other host fails closed. These are test principals backed by local/isolated development data. Do not use this adapter to reach production data or treat it as a security control: local source and processes are developer-controlled. The endpoints return 404 in every deployed Vercel environment, including legacy-open previews.
Showing who's signed in
The gate serves GET /auth/me → { sub, name, email } (a fake Dev User outside production). Framework-free widget:
<div id="whoami"></div>
<script>
fetch('/auth/me').then(r => r.ok ? r.json() : null).then(user => {
if (!user) return
document.getElementById('whoami').innerHTML =
`${user.name} · <a href="/auth/signout">Sign out</a>`
})
</script>Endpoints: /auth/signin (optionally ?returnTo=/path), /auth/signout, /auth/me, /auth/callback (Okta's redirect URI — register https://<your-domain>/auth/callback).
Identity in backend code (v0.2+)
On every request the gate lets through, it injects an x-sonance-user request header (spoof-proof: any client-supplied value is stripped and replaced). Read it with the bundled helper — works in any route handler or server function:
import { getUser } from '@danainnovations/sonance-gate'
export async function POST(req: Request) {
const user = getUser(req) // { sub, name, email } | null
if (!user) return new Response('unauthenticated', { status: 401 })
// ... write rows keyed by user.email, etc.
}In local development it returns the selected dev principal, so backend code exercises the same authorization path. Legacy-open previews return the default Dev User until they are migrated; enforceInPreview: true uses the real Okta session. On publicPaths routes it returns null unless the visitor happens to have a session.
Signing out
By default /auth/signout clears the app session and forces a fresh Okta
credential prompt on the next visit, while your Okta org session for other apps
stays alive. Because that Okta session survives, some orgs re-authenticate the
browser without a visible prompt, which reads to users as "sign-out did
nothing".
oktaLogout: true ends the Okta session as well (OIDC RP-initiated logout):
/auth/signout clears the app cookies and hands the browser to Okta's
end-session endpoint, which signs the user out and returns them to your app's
root.
It is opt-in per app because it needs one thing from the Technology team first:
Add
https://<your-domain>/to Sign-out redirect URIs on this app's Okta registration.
Turn the option on only once that is registered — Okta rejects the logout otherwise. Sign-out degrades to the local-only behaviour, never an error page, whenever the Okta session cannot be ended (no session to prove, a session issued before this upgrade, a Teams-issued session, or Okta unreachable).
Microsoft Teams mode
Apps packaged as Teams tabs authenticate silently — no Okta prompt inside Teams. Enable per app:
export default createGate({
publicPaths: [],
teams: true,
})Production env additionally uses ENTRA_CLIENT_ID and ENTRA_TENANT_ID
(non-secret, pushed by the Technology team alongside the Okta keys).
teams: true is safe to deploy before the Entra registration exists. If the
Entra keys are absent, only the Teams lane degrades — the browser/Okta lane
enforces exactly as normal (no 500). An ?inTeams=true visit falls back to the
Okta redirect instead of the bootstrap page, and POST /auth/teams returns a
503 naming the missing key names (names only, no secrets). The Teams lane
activates automatically once both keys are present on the next deploy — no code
change and no strict "Entra keys before teams: true" ordering. (Missing
OKTA_*/SESSION_SECRET keys still fail the whole app closed, as before.)
How it works: the Teams manifest's contentUrl carries ?inTeams=true. For an
unauthenticated HTML request with that hint (or the __Host-sg_teams marker
cookie), the gate serves a static bootstrap page that calls
authentication.getAuthToken() (Teams JS SDK) and POSTs the Entra ID JWT to
/auth/teams. The gate validates it against Microsoft's JWKS (issuer
https://login.microsoftonline.com/{tenant}/v2.0, audience = client ID or
api://{host}/{clientId}, tenant pinned), then mints the ordinary session.
Server code stays lane-blind: getUser(request) works identically.
?inTeams=trueis a routing hint, never a bypass: outside Teams the bootstrap falls back to the normal Okta redirect. Fail closed always.- Teams-lane cookies use
SameSite=None; Secure; Partitioned(CHIPS) so they survive the cross-site iframe. Browser-lane cookies staySameSite=Lax. - Foreign-tenant users get a friendly "Sonance employees only" page (403).
- Identity: key on
email.subis lane-specific (Okta subject vs Entra object ID) — do not use it as a cross-lane key.
