@ogcio/api-auth
v6.0.0
Published
Fastify plugin that verifies [Logto](https://logto.io/) JWTs, checks route permissions against token scopes, and decorates the Fastify instance with auth helpers.
Maintainers
Keywords
Readme
@ogcio/api-auth
Fastify plugin that verifies Logto JWTs, checks route permissions against token scopes, and decorates the Fastify instance with auth helpers.
Installation
pnpm add @ogcio/api-auth fastify fastify-plugin @ogcio/shared-errorsOptional dependencies (required only when piiHasher is provided):
pnpm add @ogcio/pii-utils @aws-sdk/client-secrets-managerPlugin registration
import Fastify from "fastify";
import apiAuthPlugin from "@ogcio/api-auth";
const app = Fastify({ logger: true });
await app.register(apiAuthPlugin, {
jwkEndpoint: "https://your-logto-tenant.logto.app/oidc/jwks",
oidcEndpoint: "https://your-logto-tenant.logto.app/oidc",
});Options
| Option | Type | Required | Description |
|---|---|---|---|
| jwkEndpoint | string | ✓ | URL of the Logto JWKS endpoint |
| oidcEndpoint | string | ✓ | Logto OIDC issuer URL |
| getLocalJwksFn | () => JSONWebKeySet \| undefined | — | Returns a locally cached JWKS to avoid round trips |
| storeLocalJwkSetFn | (jwks: JSONWebKeySet) => Promise<void> | — | Persists a freshly fetched JWKS |
| piiHasher | PiiHasher | — | When provided, computes request.userData.pseudoUser using HMAC hashing of the user ID |
Decorators
After registration the plugin adds the following to the Fastify instance and request objects.
fastify.checkPermissions(request, reply, permissions, matchConfig?)
Verifies the bearer token, checks that the token scopes satisfy the required permissions, and stores the resolved user data on request.userData. Throws 401 when the authorization header is missing or the token is unverifiable, and 403 when permissions are insufficient.
app.get("/payments", async (req, reply) => {
await app.checkPermissions(req, reply, ["payments:payment:create"]);
// req.userData is now populated
return { userId: req.userData!.userId };
});
// AND-match (all permissions must be present)
app.delete("/payments/:id", async (req, reply) => {
await app.checkPermissions(req, reply, ["payments:payment:read", "payments:payment:delete"], {
method: "AND",
});
return { ok: true };
});request.ensureUserIsSet()
Synchronous guard that returns request.userData as non-nullable ExtractedUserData, or throws 401 when auth has not yet been performed for the request. Use it as a compact assertion after checkPermissions or getScopes.
app.get("/profile", async (req, reply) => {
await app.checkPermissions(req, reply, ["profile:read"]);
const user = req.ensureUserIsSet(); // ExtractedUserData — non-nullable
return { userId: user.userId };
});request.ensureIsPublicServant()
Calls ensureUserIsSet() first, then asserts the user belongs to an organisation (i.e. the JWT audience is an org URN). Returns { organisationId: string } on success, or throws 403.
app.get("/admin/report", async (req, reply) => {
await app.checkPermissions(req, reply, ["report:read"]);
const { organisationId } = req.ensureIsPublicServant();
return { organisationId };
});request.ensureIsCitizen()
Calls ensureUserIsSet() first, then asserts the user is not associated with any organisation. Throws 403 when userData.organizationId is set.
app.get("/citizen/profile", async (req, reply) => {
await app.checkPermissions(req, reply, ["profile:read"]);
req.ensureIsCitizen();
return { userId: req.userData!.userId };
});request.getScopes()
Extracts and normalizes the token scopes without enforcing any route permissions. Safe to call from middleware or routes that need to inspect scopes before deciding whether to gate access. Returns:
{ scopes: string[] }— on a valid, verifiable bearer token{ invalidAuthorizationHeader: true }— when the header is missing, malformed, or the token cannot be verified
Populates request.userData as a side-effect when verification succeeds (same behavior as checkPermissions).
app.get("/me/scopes", async (req) => {
const result = await req.getScopes();
if ("invalidAuthorizationHeader" in result) {
return { authenticated: false };
}
return { scopes: result.scopes };
});request.userData
Populated by checkPermissions and getScopes after a successful auth flow.
interface ExtractedUserData {
userId: string;
accessToken: string;
isM2MApplication: boolean;
scopes: string[]; // normalized string[] from JWT scope claim
organizationId?: string; // set when the token audience is an org URN
signInMethod?: string;
pseudoUser?: { // only when piiHasher is configured
id: string;
version: string;
};
}Starts as undefined before any auth decorator runs. Within a single request, calling getScopes() then checkPermissions() with the same bearer token reuses the already-extracted userData without re-verifying the JWT.
Optional: PII pseudonymisation
Pass a piiHasher instance (compatible with @ogcio/pii-utils PiiHasher) to have both checkPermissions and getScopes compute a pseudonymous identifier for the user. Requires @aws-sdk/client-secrets-manager to be installed.
import { PiiHasher } from "@ogcio/pii-utils";
const piiHasher = new PiiHasher({ ... });
await piiHasher.warmup();
await app.register(apiAuthPlugin, {
jwkEndpoint: "...",
oidcEndpoint: "...",
piiHasher,
});
// After checkPermissions / getScopes:
// req.userData.pseudoUser === { id: "<hmac-hash>", version: "v1" }Low-level helpers
These stateless functions are exported for use outside of a Fastify request lifecycle.
import { checkPermissions, getScopes, getPermissions } from "@ogcio/api-auth";
// Verify JWT and check permissions (throws on failure)
const userData = await checkPermissions("Bearer <token>", config, ["payments:payment:create"]);
// Return normalized scopes without enforcing permissions
const scopes = await getScopes("Bearer <token>", config);
// @deprecated — alias for getScopes
const scopes = await getPermissions("Bearer <token>", config);Helper: ensureUserCanAccessUser
Asserts that the logged-in user may access the requested user's data (either by matching userId or by having an organizationId).
import { ensureUserCanAccessUser } from "@ogcio/api-auth";
app.get("/users/:userId/profile", async (req) => {
await app.checkPermissions(req, reply, ["profile:read"]);
const accessingUser = ensureUserCanAccessUser(req.userData, req.params.userId);
// ...
});