Skip to content
TDACorpIDENTITYSign in

Decision client and framework adapters

The SDK client that asks identity whether a person may do something, its caching, and the Next.js, Hono and Express adapters built on it.

On this page

Status: Available

The decision client and the Next.js, Hono and Express adapters are Available. Your product's service client must hold a DPoP key and be linked to your product, and your product must have an active installation in the organization being asked about. Identity answers only for your product's own namespace. The client's resource and relation writes, searches and feature lookup call endpoints that are off until the authz_write_api and authz_search_api flags are on; both flags default OFF, and an endpoint that is off makes the call throw NotAvailableError. The event stream and the delegation subject token are also off by default (see below). Workload tokens and a session-revoked listener are In development.

Identity's tokens carry no roles. A product asks the decision API whether a person may perform an action. The decision client is the SDK's wrapper for that call, and the adapters put it in front of a route.

The client#

createIdentity is imported from the SDK's root entry.

import { createIdentity } from '@tdacorp/identity-sdk'const identity = createIdentity({  issuer: 'https://identity.tdacorp.in',  clientId,  ns: 'eidos',  audience: 'https://api.example.com',})
OptionDefaultMeaning
issuer, clientIdrequiredThe issuer, and your product's client.
nsrequiredYour product's permission namespace.
audienceclientIdThe aud your tokens carry.
authzBaseUrlthe issuerWhere the decision API is.
algorithms, jwks, requireConfirmation, refuseActorTokens, clockToleranceSeconds, clockas for resource-server verificationToken verification options. The same limits apply: only EdDSA and ES256, and a tolerance of 0 to 300 seconds, both checked when the client is created.
defaultTenantnoneThe organization when the token has no tid.
cache'memory''memory', 'none', a DecisionCacheOptions object or a DecisionCache.
onStaleTokennoneCalled with the old token when identity hints the token's authorization version is behind. Return a fresh token to retry once.
eventsfalseOpens a stream of invalidation events per organization the client checks, so a change to a person's authority drops their cached answers at once. Needs a cache and headers. See Caching.
fetch, headers, requestTimeoutMsglobal fetch, none, 5000The transport, the caller credential for every decision call (see below), and the timeout in milliseconds.

Methods#

MethodReturns
verify(token)The verified access token, or throws TokenVerificationError.
check(token, action, resource?, { tenant? })A CheckResult. Never throws for a failure to decide.
isAllowed(token, action, resource?)true only for ALLOW.
authorize(token, action, resource?)The verified token, or throws ForbiddenError.
checkBatch(token, items, { tenant? })One CheckResult per { action, resource? } item, in order.
effective(token, { tenant? })The permissions the person holds in your namespace. Throws IdentityRequestError on failure.
close()Stops the event streams and their timers. Does nothing without events.

The data methods below write to or search your product's resource graph. They throw IdentityRequestError on a failed call and NotAvailableError when identity answers 404, which is what a flag that is off looks like, so a product can degrade. Each request body is checked locally and an unknown field is refused.

MethodWhat it does
resources.upsert({ tenant, resources })Creates or updates resources (type, external_id, optional parent and owner_sub). Needs authz_write_api. Returns the refs and the organization's rv.
relations.write({ tenant, writes?, deletes? })Writes and deletes relation tuples whose subject is a person or a set of another resource ({ resource, relation }). Needs authz_write_api. Returns the counts and rv.
listAccessible({ tenant, subject, action, type })An async iterator of the resource refs the person may act on. Follows paging to the end. Needs authz_search_api.
subjectsFor({ tenant, resource, action })An async iterator of the people who may act on one resource. Needs authz_search_api, and the installation must have explanations enabled.
actionsFor({ tenant, subject, resource? })An async iterator of the permission codes a person may use on one resource, or the tenant-level codes when resource is absent. Needs authz_search_api.
hasFeature(tenant, code)Whether the organization's plan includes a feature of your product. Reused for 30 seconds at most, and dropped when the organization's policy version changes. Needs authz_search_api.

A write or search request carries at most 500 items; the client splits a larger list into several requests, one after another. They are not atomic across requests: if a later one fails, the earlier ones stay applied and the error says so.

token is a string, which the client verifies first, or an already verified token.

Results#

A CheckResult has decision (ALLOW, DENY or INDETERMINATE), reasonCode, vector (av, pv, pvt, and rv when identity sends it), evaluatedAt, ttlHint, hints and source.

source says where the answer came from: pdp (identity), cache, last_known_good, or sdk (the client refused without asking). An SDK-made refusal uses one of these reason codes.

reasonCodeMeaning
invalid_tokenThe token failed verification. A DENY.
indeterminate_sdk_transportThe decision API could not be reached or answered with an error.
indeterminate_sdk_malformed_responseThe reply was not a decision.
indeterminate_sdk_requestThe action or resource is not well formed, a wildcard was used, or no organization could be found.
indeterminate_sdk_item_failedOne item in a batch could not be evaluated.

Only ALLOW permits. The client never produces an ALLOW that identity did not send.

Caching#

Identity decides how long an answer may be reused (ttlHint). The default in-memory cache stores only ALLOW answers from identity, for ttlHint seconds, and never when the hint is 0. When identity sets hints.last_known_good, a prior ALLOW for the same question, at most 300 seconds old and with unchanged av, pv and pvt, may be used instead of a failure. The cache is per process and holds 10,000 entries by default.

Read-your-writes is automatic. The client remembers the highest rv (the organization's resource version) it has seen, from a write result or a token, and sends it as min_rv with the next check. If identity has not caught up it answers indeterminate_consistency; the client retries once after a short pause and otherwise returns that INDETERMINATE, never an ALLOW. An answer about a resource is cached under the rv it was decided at, so a write moves it to a new key.

With events: true the cache also follows identity's invalidation stream: a change to a person's authority drops that person's cached answers at once, and a cached answer is served only while the stream vouches for the organization's policy version. After a disconnect longer than 30 seconds, answers with a ttlHint of 60 or less are no longer served. A stream that cannot connect makes the cache miss; it never fails a call.

A token that carries an act claim (a delegated agent) is sent to identity whole as the subject_token, so the decision follows its delegation chain and agent_action, and its answers are cached per token. Identity reads the token only while the pdp_subject_token flag is on, and it defaults OFF. When identity refuses that form, the client falls back to the plain request, which carries no delegation: an agent token is then INDETERMINATE, but an impersonation token decides with the person's full authority, so set refuseActorTokens unless you intend that.

The adapters#

Each adapter does the same work in front of a route: verify the token, check proof of possession for a bound token, resolve the resource, ask check, and refuse or let the request through. Each is its own subpath.

FrameworkImportUse
Next.js@tdacorp/identity-sdk/nextpepMiddleware(options) for middleware, pepRoute(options)(handler) for a route handler.
Hono@tdacorp/identity-sdk/honopep(options) as middleware. Sets c.get('pep').
Express@tdacorp/identity-sdk/expresspep(options) as middleware. Sets req.pep.
import { pepRoute } from '@tdacorp/identity-sdk/next'export const GET = pepRoute({  identity,  action: 'eidos.document.read',  resource: (request, context) => 'eidos:document:q3-plan',  proofOfPossession,})(async (request, context, allowed) => Response.json({ caller: allowed.subject.subject }))

Options#

OptionMeaning
identityThe client above.
actionThe permission code to check.
resourceOptional. Returns the resource reference for this request.
tenantOptional organization override.
proofOfPossessionA hook that proves a DPoP-bound token. Without it a bound token is refused. Build it with createDpopProofOfPossession({ publicOrigin, replay }).
requireAuthenticationOptional. { acrValues?, maxAgeS? }: refuse a token that is too weakly or too long ago authenticated.
onDecisionCalled with every result, for logging.

Refusals#

A refusal is application/problem+json with title, status, detail and reason_code. It is 401 when the token failed verification or proof, with a WWW-Authenticate header that offers Bearer and DPoP. An adapter option requireAuthentication ({ acrValues?, maxAgeS? }, RFC 9470) also answers 401 when the token verified but its acr is too weak or its auth_time too old: the challenge is insufficient_user_authentication with the required acr_values and max_age, and the client steps up with rp.stepUpUrl (Relying-party module). It is 403 for every other refusal. A failure to decide is a refusal, never an allow.

Authenticating to the decision API#

Identity authenticates the caller of the decision API, not the person: your product's service client, with its own DPoP-bound access token and a fresh DPoP proof for every request. The headers option is a function that receives { method, url, nonce? } for each attempt and returns the request headers, so it can sign a proof for exactly that request. pdpCredential builds it from a service client.

import { createIdentity } from '@tdacorp/identity-sdk'import { createServiceClient, pdpCredential } from '@tdacorp/identity-sdk/service'const service = createServiceClient({  issuer,  clientId,  key: { kid: 'k1', privateKey: privateJwk },  dpop,})const identity = createIdentity({  issuer,  clientId,  ns: 'eidos',  headers: pdpCredential(service),})

pdpCredential uses the service client's own token (audience is the client itself), never an exchanged one. The service client must hold a DPoP key, because identity refuses an unbound token on this API. The returned headers are secrets: do not log them.

Security notes#

  • Fail closed. INDETERMINATE and every indeterminate_sdk_* code mean refuse.
  • Enforce where the resource is. The adapter is your product's enforcement point; identity is the decision point.
  • Do not cache your own answers. Use the client's cache, which honours ttlHint and the version vector.

Verified against commit 5d85077