triostack-suite-auth
v1.7.0
Published
Shared JWT signing/verification and Express auth middleware for Trio-Suite backend microservices
Downloads
46
Maintainers
Readme
triostack-suite-auth
Shared JWT signing/verification and Express auth middleware for Trio-Suite backend microservices. Every service shares one JWT_SECRET, so a token issued by one service verifies in every other -- this package exists so that logic isn't copy-pasted per service.
Install
npm install triostack-suite-authUsage
const { signToken, verifyToken, AUTH_TYPES, requireAuthType, requireSuperAdmin, requireAnyAuth, requireInternalToken } = require("triostack-suite-auth");
// Issue a token (JWT_SECRET read from process.env.JWT_SECRET by default)
const token = signToken({ userId, tenantId, organizationId, authType: AUTH_TYPES.USER }, { expiresIn: "8h" });
// Require a specific authType -- sets req.auth to the decoded payload
router.get("/me/software-access", requireAuthType(AUTH_TYPES.USER), controller.mySoftwareAccess);
router.get("/me/tenant-software-access", requireAuthType(AUTH_TYPES.TENANT), controller.tenantSoftwareAccess);
// Super admin also requires isSuperAdmin === true on the token
router.get("/admin/tenants", requireSuperAdmin(), controller.list);
// Any authenticated session, no authType restriction
router.get("/me", requireAnyAuth(), controller.me);
// Internal service-to-service token (constant-time compare), not a JWT --
// each service names its own env var, so pass a getter
router.use("/internal/permissions", requireInternalToken(() => process.env.IDENTITY_INTERNAL_API_TOKEN));
// Permission check -- must run AFTER a middleware that sets req.auth. A
// "tenant" session is granted every permission automatically (no lookup);
// a "user" session is checked for real via your resolver, since this
// package has no DB connection of its own -- see examples/requirePermission.example.js
// for both an in-process DB lookup and a cross-service internal-API lookup.
router.delete(
"/leads/:id",
requireAuthType(AUTH_TYPES.USER),
requirePermission("crm.lead.delete", async (req) => {
const { userId, organizationId } = req.auth;
// ... your own DB query or internal API call, returns string[] of allowed codes
return findAllowedPermissionCodesForUser(userId, organizationId, req.params.softwareId);
}),
controller.deleteLead
);Cross-module (wildcard) permission codes
A permission code is "<scope>.<action...>", e.g. "crm.product.add". A role can be granted a wildcard-scoped code, "*.product.add", which satisfies any required code with the same suffix -- "crm.product.add", "inventory.product.add", "invoicing.product.add", etc. -- without granting one row per module. This is a real, storable string (permission codes are plain VARCHAR), not a query-time pattern -- *.product.add must actually exist as a permissions.code row and be linked via role_permissions, same as any other code.
requirePermission already matches wildcards automatically (it calls hasPermission internally). Use permissionCodeMatches/hasPermission directly wherever you check codes outside of requirePermission (e.g. inside an internal check-bulk endpoint):
const { permissionCodeMatches, hasPermission } = require("triostack-suite-auth");
permissionCodeMatches("*.product.add", "crm.product.add"); // true
permissionCodeMatches("crm.product.add", "inventory.product.add"); // false -- scoped grants never cross modules
hasPermission(["crm.lead.view", "*.product.add"], "invoicing.product.add"); // trueA bare "*" (no suffix) never matches anything, and "*" is only meaningful as the scope -- "crm.*" is not treated as "every action in crm."
Checking multiple permissions (requirePermissions)
For a route that needs more than one code -- either "must have all of these" or "must have at least one of these" -- use requirePermissions instead of stacking multiple requirePermission calls (which would call your resolver once per call). Same tenant-bypass/fail-closed rules as requirePermission, resolver called at most once:
const { requirePermissions } = require("triostack-suite-auth");
// AND (default): needs BOTH codes
router.post(
"/leads/:id/assign",
requireAuthType(AUTH_TYPES.USER),
requirePermissions(["crm.lead.view", "crm.lead.assign"], resolveAllowedCodesForUser),
controller.assignLead
);
// OR: needs AT LEAST ONE
router.get(
"/leads/:id",
requireAuthType(AUTH_TYPES.USER),
requirePermissions(["crm.lead.view", "crm.lead.viewAll"], resolveAllowedCodesForUser, { mode: "any" }),
controller.getLead
);requirePermissions is array-based only -- if the required code(s) depend on the request itself (e.g. a route shared across CRM/Inventory/Invoicing where the module comes from req.params), wrap it in your own middleware that builds the array first, same as you would with requirePermission:
router.post("/:softwareCode/products", requireAnyAuth(), (req, res, next) =>
requirePermissions([`${req.params.softwareCode}.product.add`], resolveAllowedCodesForUser)(req, res, next)
);Subscription check (requireValidSubscription)
requirePermission/requirePermissions answer "what is this session allowed to do" -- they are NOT a subscription check, and a tenant session is never exempt from needing a real subscription (tenant sessions get every permission for free, never a free subscription -- those are separate rules). requireValidSubscription is the third gate: it runs for every authType that reaches it, no bypass for anyone, since "does this organization actually pay for this software" applies identically regardless of who's asking.
const { requireAnyAuth, requireValidSubscription, requirePermissions } = require("triostack-suite-auth");
// Your own check -- typically subscriptionValidation.service.js's
// validateSubscription (in-service) or a call to the internal
// tenant-access/validate endpoint (cross-service). organizationId is
// sourced differently per session: a "user" token carries it directly
// (req.auth.organizationId); a "tenant" token does NOT (see
// generateTenantToken) -- resolve it from wherever your app tracks it for
// a tenant session (query param, body, frontend-selected org state, ...).
const resolveSubscriptionValid = async (req) => {
const { authType, tenantId } = req.auth;
const organizationId = authType === "user" ? req.auth.organizationId : req.query.organizationId;
const check = await subscriptionValidation.validateSubscription(tenantId, organizationId, req.params.softwareId);
return check.allowed;
};
router.post(
"/:softwareId/leads/:id/assign",
requireAnyAuth(), // 1. who are you
requireValidSubscription(resolveSubscriptionValid), // 2. does your org pay for this software (checked for EVERY authType)
requirePermissions(["crm.lead.view", "crm.lead.assign"], resolveAllowedCodesForUser), // 3. what can you do
controller.assignLead
);If resolveIsValid returns false, the request is denied with 403. If it throws (subscription-service unreachable, DB down), the response is 503, not 403 -- an unreachable dependency is a different failure mode than a real, confirmed denial, and must not be reported as one.
Requiring ANY of several software subscriptions, by name (resolveSubscribedToAnyOf)
For a route that should pass if the organization is subscribed to any one of several software products -- named, not by numeric id -- resolveSubscribedToAnyOf builds a resolveIsValid for requireValidSubscription without you hand-rolling the OR loop:
const { requireAnyAuth, requireValidSubscription, resolveSubscribedToAnyOf, requirePermissions } = require("triostack-suite-auth");
// Your own lookups -- this package has no DB connection, so YOU supply
// how a software name resolves to an id, and how one id's subscription is
// actually checked.
const getSoftwareIdByName = async (name) => {
const result = await pool.query(`SELECT id FROM software WHERE LOWER(name) = LOWER($1)`, [name]);
return result.rows[0]?.id ?? null;
};
const isSubscriptionValid = async (req, softwareId) => {
const { tenantId } = req.auth;
const organizationId = defaultResolveOrganizationId(req); // or your own lookup
const check = await subscriptionValidation.validateSubscription(tenantId, organizationId, softwareId);
return check.allowed;
};
router.post(
"/:softwareId/leads/:id/assign",
requireAnyAuth(),
requireValidSubscription(
resolveSubscribedToAnyOf(["CRM", "Payroll"], { getSoftwareIdByName, isSubscriptionValid })
),
requirePermissions(["crm.lead.view", "crm.lead.assign"], resolveAllowedCodesForUser),
controller.assignLead
);- A name with no matching software (typo, renamed module) is treated as "not subscribed" for that name, not a crash -- the OR still checks the remaining names.
- By default (
failureMode: "allSettled"), one name's check throwing (e.g. that specific lookup is unreachable) does not sink the whole OR if another name's check confirmstrue. Pass{ failureMode: "all" }to instead fail the whole check -- and thereforerequireValidSubscription's503-- the instant any one check throws. defaultResolveOrganizationId(req)(also exported) is the same "user token has organizationId directly, tenant token doesn't" fallback described above, exported standalone sinceisSubscriptionValidimplementations need it too.
Design notes
signToken/verifyTokendefault toprocess.env.JWT_SECRET; pass{ secret }to override.requireAuthType/requireSuperAdmin/requireAnyAuth/requirePermission/requirePermissions/requireValidSubscriptionare Express middleware factories -- call them (requireAuthType(AUTH_TYPES.USER)), don't pass the function reference directly.- These are the same security boundary the per-service middleware they replace already was: real, not just a UX gate. Frontend route guards are a separate, non-authoritative layer (see
@triostack/authintriosuite-ui-service) -- don't confuse the two. requirePermission/requirePermissionsfail closed: if your resolver throws (DB down, internal API unreachable), the request is denied with 500, never silently allowed.requireValidSubscriptionfails closed too, but with 503 (service unavailable) instead of 500/403, since it's specifically about an unreachable dependency, not a confirmed denial.requirePermissionsnever calls your resolver more than once per request, regardless of how many codes are being checked or which mode is used.requirePermission/requirePermissionsskip the resolver entirely for a"tenant"session (full permission access).requireValidSubscriptiondoes NOT skip tenant sessions -- subscription validity is checked for every authType, no exceptions. PutrequireValidSubscriptionbeforerequirePermission/requirePermissionson a route so an unsubscribed organization is rejected before permissions are even considered.
