@authlink/policy-client
v1.0.1
Published
Official client SDK for requesting policy decisions and effective permissions from the Authlink Policy Decision Point
Maintainers
Readme
🔒 Security stance — read this first
Browser-side checks are UX gating only. This client asks the Policy Decision Point (PDP) what the current user may do so your UI can hide buttons, disable forms, and guard routes — it does not, and cannot, enforce anything. A user with devtools can bypass every check in this package. Enforcement happens at the API policy enforcement points (PEPs) that consume the same policies server-side; the UI's job is to agree with the API, not to replace it.
Everything here fails closed: an unloaded snapshot, a transport error, a
Deny, a NotApplicable, and an obligation contract the client does not
understand all read as denied. If something goes wrong, the UI under-promises
and the API remains the judge.
🚀 Getting Started
Install
npm install @authlink/policy-clientQuickstart
Wire the client to the tokens you already get from @authlink/client (the
Authlink identity SDK). getAccessToken is called once per request, so a
refreshed token is picked up automatically; getSubjectId supplies the
subject (the token's sub claim) so requests can omit it:
import { IdentityProviderClient } from "@authlink/client";
import { PolicyClient, createPolicyContext } from "@authlink/policy-client";
const identity = new IdentityProviderClient("https://identity.example.com");
let tokens: { accessToken: string; sub: string; expiresAt: number } | null = null;
async function getAccessToken(): Promise<string> {
if (tokens !== null && Date.now() < tokens.expiresAt - 30_000)
return tokens.accessToken;
// Silent authorization + code exchange (PKCE handled by @authlink/client).
// Request the Policy API's audience on the token exchange; the authority is
// appended automatically, so one token serves identity and policy calls.
const response = await identity.authorizeSilently({
clientId,
redirectUri,
scope: "openid"
});
const exchanged = await identity.token({
grantType: "authorization_code",
clientId,
redirectUri,
code: response.code!,
state: response.state,
audience: "https://policy.example.com"
});
const claims = JSON.parse(atob(exchanged.accessToken.split(".")[1]!));
tokens = {
accessToken: exchanged.accessToken,
sub: claims.sub,
expiresAt: (claims.exp as number) * 1000
};
return tokens.accessToken;
}
const client = new PolicyClient({
baseUrl: "https://policy.example.com",
getAccessToken,
getSubjectId: () => {
if (tokens === null) throw new Error("not signed in");
return tokens.sub;
}
});Then load a permission snapshot once and gate the UI:
const permissions = createPolicyContext(client, { refreshIntervalMs: 5 * 60 * 1000 });
await permissions.load();
// Synchronous — no network per check:
if (permissions.can("document:update")) {
showEditButton();
}
permissions.canAll(["document:read", "document:update"]);And gate an attribute-dependent action with a live decision:
import { isAllowed, explainDenial, requiresReason } from "@authlink/policy-client";
const result = await permissions.check("document:delete", {
resourceType: "document",
resourceAttributes: { "owner-id": document.ownerId }
});
if (isAllowed(result)) {
if (requiresReason(result.value)) {
const reason = await promptForJustification();
await api.deleteDocument(document.id, { reason });
} else {
await api.deleteDocument(document.id);
}
} else {
tooltip.show(explainDenial(result)); // deny reason, "no policy permits", or error detail
}🧭 Snapshot (can) vs live (check)
| | can / canAny / canAll | check / checkMany |
|---|---|---|
| Answers | "Does the user hold this permission?" | "May the user do this to this resource?" |
| Source | Effective-permissions snapshot, loaded once | POST /api/decide per (cached) request |
| Cost | Synchronous, no network | Async; 30s TTL cache + in-flight coalescing |
| Use for | Menus, buttons, route guards — the bulk of gating | Row/record-level actions that depend on resource attributes, purpose, or context |
| Freshness | refresh() / refreshIntervalMs (stale-while-refreshing) | TTL expiry, invalidate(), or automatic clear when a snapshot lands |
Rule of thumb: reach for the snapshot first; use check only when the answer
depends on which resource (ownership, tenant, classification) — that is what
the resource attributes are for. checkMany evaluates per-row checks in one
round-trip via the batch endpoint (POST /api/decisions), falling back to
parallel single decisions when the endpoint is not deployed.
📜 Obligations in the UI
An Allow decision may carry obligations — requirements the enforcing PEP
must honour. In the browser they translate to UX:
RequireReason—requiresReason(response)tells a form to collect a justification before submitting; the API PEP rejects the operation without one.MaskProperties—maskedProperties(response)returns the parsed selector list (ssn,bank_details.iban, prefix wildcards likebank_details.*) for cosmetic field hiding. The API response is already masked server-side; hiding the fields client-side keeps the UI honest about what it never received.RequireAudit— enforced entirely server-side; nothing to do in the UI.RowFilter— compiled and enforced by the API PEP; result sets arrive pre-filtered.
Before trusting any of the above, ask hasUnsupportedObligations(response):
it flags unknown obligation kinds, unsupported grammar versions, and
unparseable expressions. When it returns true, degrade safely (treat as denied
or hide the affected view) — partially honouring obligations is what the
fail-closed rule exists to prevent.
⚠️ Error handling
Expected failures never throw. Every call returns
PolicyResult<T> = { ok: true; value } | { ok: false; error } with
error.code one of validation (HTTP 400, problem details parsed onto
error.problem), unauthorized (401 or the token provider failed),
forbidden (403), server (5xx, unexpected statuses, malformed bodies), or
unavailable (network failure or the 3-second timeout). isAllowed treats
them all as denied.
🖼 Framework packages
- Vue 3 —
@authlink/policy-client-vue:providePolicyContext+usePolicyPermissions/usePolicyDecisioncomposables. - React 18/19 —
@authlink/policy-client-react:PolicyContextProvider+usePolicyPermissions/usePolicyDecisionhooks.
Both are thin reactive wrappers over this package's PolicyContext.
🛠 Features
- Policy decisions (permit/deny) against the Authlink Policy Decision Point
- Effective permission queries
- Typed error results instead of thrown exceptions (fail-closed friendly)
- Fail-closed decision guards and obligation helpers (
isAllowed,explainDenial,requiresReason,maskedProperties,hasUnsupportedObligations) - Permission snapshot (
createPolicyContext) with synchronouscan/canAny/canAll, stale-while-refreshing updates, and change subscriptions - Live per-resource decisions (
check/checkMany) with a TTL + LRU decision cache and in-flight coalescing - Token-provider auth, request-default merging, and AbortController timeouts
- Type-safe APIs mirroring the PDP wire contracts
- Framework-agnostic, zero dependencies
- Dual ESM + CJS builds with bundled type declarations
🧪 Development
This package is a member of the npm workspace at the repository root:
npm install # at the repository root
npm run build
npm run test
npm run lint
npm run check:package📝 License
MIT
