@decionis/presence-auth
v0.2.0
Published
Server-side Presence auth hooks for Auth0, Clerk, Okta, Entra, PingOne, and Supabase: risk-based step-up and assurance that replaces always-on MFA, over the Presence /v1/check API.
Readme
Presence Auth Hooks
packages/presence-auth (npm: @decionis/presence-auth) is the E-commerce & auth channel from
the ecosystem roadmap (docs/27): server-side hooks that gate a login or
sensitive action on a Presence disposition — the MFA-replacement motion. It is a thin adapter over
POST /v1/check (docs/32); the full contract is docs/37 in the repository.
Model
The sign-in page runs the Presence widget, which mints a browser Session Token. Your
auth hook holds the tenant API secret (server-side only), passes that token to POST /v1/check,
and enforces the answer:
| /v1/check | Decision | Auth outcome |
| ----------------------- | --------- | --------------------------------- |
| PASS | allow | Login proceeds — no second factor |
| CHALLENGE / STEP_UP | step_up | Require a second factor |
| DENY | deny | Block the login |
It fails closed: a missing token or an unreachable Presence API yields step_up, so an outage
hardens login rather than locking users out. Presence decides; the hook only reacts.
Auth0 — post-login Action
// Auth0 Action: store the tenant secret as the PRESENCE_API_SECRET Action Secret.
const { createPresenceAction } = require("@decionis/presence-auth/auth0");
exports.onExecutePostLogin = createPresenceAction({
apiHost: "https://presence.decionis.com",
apiSecret: event.secrets.PRESENCE_API_SECRET,
});createPresenceAction(config) returns an onExecutePostLogin(event, api) handler. It reads the
Session Token from event.request.body.presence_token (override with tokenFrom), derives
accountAgeDays from event.user.created_at, then drives the Action API: allow returns, deny
calls api.access.deny("presence_denied"), and step_up calls api.multifactor.enable(provider)
(default "any", override with mfaProvider). A login with no Session Token requires MFA.
Clerk — backend guard
Clerk has no server hook that halts a session mid-flow, so the guard returns a decision your own
backend enforces (in clerkMiddleware, a server action, or a route handler after auth()):
import { createPresenceGuard } from "@decionis/presence-auth/clerk";
const guard = createPresenceGuard({
apiHost: "https://presence.decionis.com",
apiSecret: process.env.PRESENCE_API_SECRET!,
});
const { decision, requiredProof } = await guard({
token: formData.get("presence_token")?.toString(),
intent: "payment.checkout",
context: { amount: 5000, currency: "USD" },
});
if (decision === "deny") throw new Response("Forbidden", { status: 403 });
if (decision === "step_up") return redirectToStepUp(requiredProof);Surface
| Export | Entry | Contract |
| ---------------------- | ------------------------------- | --------------------------------------------------------------------------------- |
| createPresenceAction | @decionis/presence-auth/auth0 | Auth0 onExecutePostLogin(event, api) handler driving allow / deny / MFA |
| createPresenceGuard | @decionis/presence-auth/clerk | guard({ token, intent?, context? }) → { decision, disposition, requiredProof? } |
| createOktaInlineHook | @decionis/presence-auth/okta | Okta Token Inline Hook responder: proceed+claim / error deny / step-up |
| createEntraTokenHook | @decionis/presence-auth/entra | Entra onTokenIssuanceStart extension: annotate presence_* claims (see below) |
| createPingOneHook | @decionis/presence-auth/ping | PingOne DaVinci HTTP-connector decision endpoint { decision, disposition } |
| PresenceCheckClient | @decionis/presence-auth | Provider-neutral POST /v1/check client (fail-closed) for a bespoke integration |
| decisionFor | @decionis/presence-auth | Disposition → allow / step_up / deny mapping |
Enterprise IdP inline hooks
Beyond the consumer Auth0/Clerk hooks, the package ships inline-hook handlers for the enterprise
IdPs (docs/39): the IdP calls a Presence-provided handler
during a privileged auth event, and the handler delegates to /v1/check. Each is framework-agnostic
((request) => { status, body }), verifies a configured shared secret, and fails closed:
import { createOktaInlineHook } from "@decionis/presence-auth/okta";
const hook = createOktaInlineHook({ apiHost, apiSecret: process.env.PRESENCE_API_SECRET!, secret });
const { status, body } = await hook({ headers: req.headers, body: req.body });Mapping is faithful to each platform: Okta Token Inline Hooks deny via an error (no MFA
command, so step-up is deny or annotate); Entra onTokenIssuanceStart can only annotate
tokens (presence_verified + presence_disposition claims — Conditional Access blocks); PingOne
returns a decision a DaVinci flow branches on. Distinct from the platform-side
Okta connector (docs/17), which runs inside Presence.
Boundaries
The tenant secret is server-side only — never shipped to a browser; the browser holds just the
short-lived Session Token. The disposition is authoritative and computed by the Presence API from
adverse-only evidence, so a fabricated "clean" client gains nothing. check fails closed on transport
error, non-2xx, malformed body, unrecognized disposition, or timeout (timeoutMs, default 4s).
Verification
Vitest (Node), against mocked fetch: pnpm --filter @decionis/presence-auth test — request shape
and tenant-secret header, every disposition, all fail-closed paths (including abort), the Auth0 Action
outcomes, the Clerk guard's decision mapping, and each IdP inline hook (bad-secret 401, per-provider
response mapping, fail-closed missing token).
Distribution
Lockstep 0.x npm line (docs/23) with subpath exports (.,
./auth0, ./clerk, ./okta, ./entra, ./ping) and no runtime dependencies — Node's global
fetch only. Consumer Shopify/Firebase listings and the enterprise Salesforce/ServiceNow plugins are
the planned follow-on for these channels.
