@incorta/auth
v1.10.0
Published
Authenticate Incorta users — OAuth 2.0/OIDC against Incorta's built-in authorization server: a server-side handler for Node.js apps with React, Express and TanStack Start integrations, and a headless client for native, desktop and extension apps.
Keywords
Readme
@incorta/auth
Authenticate Incorta users in any Node.js app via Incorta's built-in OAuth 2.0/OIDC authorization server. See the repository root README for the full guide; this is the API surface.
To read Incorta with the session this package holds — schemas, tables,
columns, rows, all filtered to the signed-in user — add
@incorta/sdk, released from this repository at the same
version.
Entry points
| Import | What's in it |
| ----------------------- | ------------------------------------------------------------------- |
| @incorta/auth | createIncortaAuth, registerClient, getClient, updateClientRedirectUris, deleteClient, types (IncortaUser, AuthSession, …) |
| @incorta/auth/react | IncortaAuthProvider, useAuth, useUser, SignedIn, SignedOut, Protect, RedirectToLogin |
| @incorta/auth/express | incortaAuth(auth) middleware, requireAuth(auth, options?) |
| @incorta/auth/node | toNodeHandler(auth), toWebRequest, sendWebResponse, getNodeSession |
| @incorta/auth/client | createIncortaClient, memoryStore, IncortaAuthError, types (TokenStore, StoredTokens, …) — no server, for native and extension apps |
createIncortaAuth(config?)
Returns { handler, getSession, config }:
handler(request: Request): Promise<Response>— serves, underconfig.basePath(default/auth):GET /login?redirect_to=— 302 into the Incorta authorize flow (or straight back when already signed in), with a PKCE S256 challenge. Sets a sealed state+nonce+verifier cookie (10 min).GET /callback— validates state, exchanges the code with the PKCE verifier (as a public client —client_idin the body — or withclient_secret_basicwhen a secret is configured), verifies the ID token against the tenant JWKS (issuer, audience, nonce), seals the session cookie, 302 to the original page. Failures 302 to{redirect_to}?incorta_auth_error={code}.GET /session—{ user, session }or{ user: null }. Refreshes the access token (rotating refresh token) when <60s from expiry; slides the session cookie past its half-life.GET|POST /logout— clears the session cookie (GET redirects: toredirect_towhen given, else to/signed-out).GET /signed-out— ungated "you're signed out" page with a sign-in link; the reactsignOut()default destination. Landing anywhere gated would silently re-login via SSO while the Incorta session lives.GET /token— the raw Incorta access token; only withexposeAccessToken: true, otherwise 404.
getSession(request): Promise<AuthSession | null>— read-only session lookup for server code (user,accessToken,accessTokenExpiresAt,expiresAt).
Config falls back to INCORTA_URL, INCORTA_TENANT, INCORTA_CLIENT_ID,
INCORTA_AUTH_SECRET, INCORTA_APP_URL, INCORTA_CLIENT_SECRET (optional —
only a confidential client has one; Incorta registers apps as public
clients), and
INCORTA_INTERNAL_URL (split horizon: server-side discovery/token/JWKS calls
use it when the public incortaUrl is not routable from the app — e.g.
http://host.docker.internal:8080/incorta from a local container; the
authorize redirect and issuer validation stay on the public URL).
App access policy (appAccess / INCORTA_APP_ACCESS)
Who may use the app, enforced by the whole-app gate:
public— anyone with the URL, signed in or not. The gate is inert for app routes; the auth routes stay mounted, so signing in remains possible and sessions become optional (handlesession == null).authenticated(default) — any signed-in Incorta user. Exactly the pre-policy behavior, and what an absent/unknownINCORTA_APP_ACCESSmeans (unknown values warn and fall back here — never topublic).catalog— signed in AND at least VIEW access to this app in the Incorta catalog. The SDK asksGET {incortaUrl}/apps-catalog/checkwith the session's own access token; Incorta resolves which app the token belongs to from the OAuth client registration's(incorta-app:<id>)name suffix (written once, server-side, at registration), so the endpoint can only ever answer about this app. Verdicts (allow and deny) are cached 30s per user; apps not registered in the catalog stay open to authenticated users (legacy-open). When Incorta cannot answer, the gate fails CLOSED with a distinct 503 "temporarily unavailable" page (failures are not cached).
Enforcement surfaces:
auth.gate(request): Promise<Response | null>— the whole-app gate: serve theResponsewhen non-null (auth routes, login redirects, the 403 access-denied page — which names the signed-in user and offers a switch-account logout — or the 503 page), continue to your app onnull.@incorta/auth/express:appGate(auth)middleware isauth.gatefor Express/Connect.requireAuth(auth)always demands a session regardless of policy (an explicit route guard is the author's promise), and undercatalogadditionally requires VIEW, attaching the verdict asreq.incortaAuth.appAccess.auth.getAppAccess(session): Promise<AppAccess>— the cached verdict (registered,accessbitmask,canView,canEdit,allowed) for permission-aware UX in any mode.
GET {basePath}/refresh?redirect_to= supports the catalog gate: it refreshes
an expired access token through a browser round trip (persisting the rotated
refresh-token cookie) and bounces back — the gate sends navigations there
instead of calling Incorta with a token it knows is expired.
Session model
The cookie is an encrypted JWT (JWE dir/A256GCM, key derived from
secret), HttpOnly, SameSite=Lax, Secure on HTTPS. Payload: verified user
claims (sub, name, email, roles, tenant) + the Incorta access/refresh
tokens. Nothing is stored server-side.
Embedded (cross-site iframe) mode: when the app runs inside an iframe on a
DIFFERENT site, Lax cookies are never sent — the login flow loops forever.
Set cookies: { sameSite: "none", partitioned: true } (or env
INCORTA_COOKIE_SAMESITE=none + INCORTA_COOKIE_PARTITIONED=1): the auth
cookies become SameSite=None; Secure; Partitioned (CHIPS), which modern
Chrome allows in third-party iframes with a per-embedder cookie jar. none
forces Secure (accepted on trustworthy plain-http origins like
*.localhost); top-level usage keeps working with the same setting.
@incorta/auth/client (native, desktop and extension apps)
A headless sign-in client for apps with no server of their own: mobile (React Native / Expo), desktop (Electron) and browser extensions. It runs the authorization-code flow with PKCE (S256 only) in the system browser (RFC 8252), so Incorta's own login page decides password, LDAP or SSO. Public clients only: there is no client secret option.
import { createIncortaClient } from "@incorta/auth/client";
const incorta = createIncortaClient({
incortaUrl: "https://acme.cloud.incorta.com/incorta",
tenant: "demo",
clientId, // a PUBLIC client registered with Incorta
redirectUri: "incorta.chat://oauth/callback",
// Open the URL in the system browser; resolve with the URL Incorta
// redirected to. Reject when the user cancels.
openAuthSession: (url, redirectUri) => openInSystemBrowser(url, redirectUri),
store: secureStore, // optional; defaults to memoryStore()
});
const user = await incorta.signIn();
const token = await incorta.getAccessToken(); // { accessToken, expiresAt } | null
incorta.onSignedOut(() => showSignIn());
await incorta.signOut();signIn()sendsstate,nonceand an S256 challenge; accepts only a callback toredirectUri(scheme, host and path) carrying the samestate, before it looks aterrororcode; exchanges the code with the verifier (client_idin the body); verifies the ID token (signature, issuer, audience, nonce, expiry); stores user and tokens.getAccessToken({ forceRefresh? })returns the stored token, refreshing first when under 60 s remain or when forced (after a 401). Concurrent callers share one refresh. A refused refresh (invalid_refresh_token,invalid_grant, …) clears the store and firesonSignedOut. When Incorta is unreachable the session is kept: an automatic caller gets the current token while it is still valid, a forced caller gets the error.signOut()clears the store and cancels a sign-in or refresh in flight: neither can write tokens back afterwards, and a sign-in whose browser step was still open does not exchange its code. It tellsonSignedOutlisteners only when someone was signed in, even if the store fails to clear (the same holds for a refused refresh). Incorta has no revocation endpoint; the refresh token expires on its own (24 h).- A second
signIn()while one is running returns the same promise. WhenopenAuthSessionnever settles (a desktop loopback the user abandoned),signOut()lets a newsignIn()start; the abandoned one rejects withsign_in_cancelledif itsopenAuthSessionever settles. Errors fromopenAuthSession(the user cancelled) and from theTokenStorereach the caller as they are. Failures of the sign-in itself are anIncortaAuthErrorwhosecodeis one ofsign_in_cancelled,invalid_callback,redirect_mismatch,state_mismatch,missing_code, an OAuth error Incorta sent back, or a token-endpoint or verification code. TokenStoreis{ get, set, clear }, sync or async, holding oneStoredTokensobject (issuer, client id, user, access token, refresh token: a few KB). A record from another issuer or client counts as signed out, soget()must return every fieldset()was given. Back it with the platform's secure storage (Keychain/Keystore, ElectronsafeStorage,chrome.storage.session), neverlocalStorage.expo-secure-storewarns above 2048 bytes on iOS: keep the refresh token there and the rest in memory or inexpo-file-systemencrypted by a key held in SecureStore, or split the fields across keys. Give each store one client: refresh tokens rotate, so two clients refreshing from one store sign each other out. In an extension, keep the client in the background worker and have other pages ask it for tokens.- Every call (discovery, token, JWKS) goes through
fetchwhen given, so an Electron renderer can route them through its main process. Endpoints and keys are cached per Incorta issuer for the process, so a second client for the same Incorta in one process uses the first one'sfetchfor them: give every client of one Incorta the samefetch.
Runtime needs: fetch, URL with searchParams, TextEncoder, atob/btoa,
structuredClone, AbortSignal.timeout, crypto.getRandomValues, and crypto.subtle with
digest("SHA-256"), importKey("jwk") for RSA keys and
verify("RSASSA-PKCS1-v1_5") (RS256). Browsers, extension pages and Electron
have them all. React Native needs polyfills for crypto (e.g.
react-native-quick-crypto, which needs a dev build rather than Expo Go),
URL (react-native-url-polyfill), structuredClone and AbortSignal.timeout
where Hermes lacks them (checked on Expo SDK 54 / React Native 0.81). The token and discovery calls are cross-origin fetches to Incorta: an
extension needs host_permissions for the Incorta URL.
CLI
incorta-auth register --url http://localhost:8080/incorta --tenant demo \
--name "My App" --redirect https://myapp.example.com/auth/callback [--json]
incorta-auth show --url ... --tenant ... --client <id> --rat <token> [--json]
incorta-auth update-redirects --url ... --tenant ... --client <id> --rat <token> \
--redirect <url> [--redirect ...] [--replace] [--json]
incorta-auth delete --url ... --tenant ... --client <id> --rat <token>register creates the OAuth client via RFC 7591 dynamic registration and
prints the .env entries (keep the registration access token). show and
update-redirects manage the registered client's allowed callback URLs —
adding by default, replacing the whole set with --replace. delete removes
the registration (new logins stop immediately; issued sessions live until
they expire).
Testing
pnpm test runs the suite against an in-process replica of Incorta's OAuth
endpoints (test/mock-incorta.ts) — full login/callback/refresh/logout flows
over real HTTP sockets, no Incorta needed.
