@demystify/id-verify
v0.4.0
Published
Verify Demystify ID (OIDC) tokens: JWKS verification with the correct audience per token kind, claim extraction (demystify_id, demystify_org_id, roles, products) and RBAC guards.
Readme
@demystify/id-verify
Verify Demystify ID tokens in any product of the One Suite. JWKS verification with the right audience per
token kind, claim extraction (demystify_id, demystify_org_id, roles, products) and RBAC guards.
Full integration guide: docs/47 — Client Integration
npm install @demystify/id-verifyThe one thing to get right
A Demystify ID login gives you two tokens, and they have different audiences:
| Token | aud | What it's for |
|---|---|---|
| ID token | your client_id | the token you get at your /auth/callback; use it to create your own session |
| Access token | https://api.demystifysystem.com | the bearer you send to the Demystify core API |
Verifying an access token against your own client_id fails every time — and it fails at runtime, in
production, not at build time. So this package never asks you which audience to use:
import { createDemystifyVerifier } from "@demystify/id-verify";
const demystify = createDemystifyVerifier({ clientId: process.env.DEMYSTIFY_CLIENT_ID! });
const identity = await demystify.verifyIdToken(idTokenFromCallback); // aud = your client_id
const caller = await demystify.verifyAccessToken(bearerToken); // aud = the suite APIWhat you get back
identity.demystifyId // sub → core idp.identities.id — the person, stable across every product
identity.demystifyOrgId // org_id → core access.orgs.id — the business; scope YOUR rls by this
identity.roles // ["finocket.ledger.read", …] — <product>.<module>.<action>, org-scoped
identity.products // ["finocket", "asher"]
identity.email
identity.raw // every verified claim, frozen
identity.hasRole("finocket.ledger.read"); // exact match — a prefix is not a grant
identity.requireRole("finocket.ledger.write"); // throws a 403-shaped DemystifyAuthError
identity.requireSameOrg(body.orgId); // throws 403 if a payload names another tenantTenant scope always comes from the verified claim — never from a URL, a path parameter or a cookie.
Errors
Every failure is a DemystifyAuthError carrying the status your API should return. Messages are deliberately
coarse: a caller never learns why a token failed, because that is a probing oracle.
| code | status |
|---|---|
| missing_token, invalid_token, missing_claims | 401 |
| forbidden | 403 |
| config | 500 |
import { extractBearerToken, isDemystifyAuthError } from "@demystify/id-verify";
try {
const caller = await demystify.verifyAccessToken(extractBearerToken(request));
caller.requireRole("finocket.ledger.write");
} catch (error) {
if (isDemystifyAuthError(error)) return new Response(null, { status: error.status });
throw error;
}Testing without a network call
@demystify/id-verify/testing mints Demystify-shaped tokens against a throwaway key pair, so your own test
suite needs no issuer, no JWKS fetch and no credential.
import { createTestIssuer } from "@demystify/id-verify/testing";
import { createDemystifyVerifier } from "@demystify/id-verify";
const demystify = await createTestIssuer({ clientId: "finocket" });
const verifier = createDemystifyVerifier(demystify.config);
const token = await demystify.mintIdToken({ sub: userId, orgId, roles: ["finocket.ledger.read"] });
await verifier.verifyIdToken(token);
await demystify.mintAccessToken({ sub: userId, orgId }); // aud = the suite resource
await demystify.mintUntrusted({ sub: userId, orgId }); // signed with a key you do NOT trustConfiguration
| Option | Default |
|---|---|
| clientId | required — your registered client_id, and the ID token audience |
| issuer | https://id.demystifysystem.com |
| jwksUri | <issuer>/.well-known/jwks.json |
| clockToleranceSeconds | 60 |
| keys | fetched from jwksUri; supply directly only in tests |
The JWKS is fetched once per process and cached by jose.
