Shared auth library for the Upland Workshop app suite. Replaces nine copies of
lib/auth.ts that had drifted across the suite into security gaps.
See upland-odin/upland-auth-philosophy.md
for the model this encodes.
In a consumer app's package.json:
"dependencies": {
"@upland/auth": "github:flinthillsdesign/upland-auth#v0.2.0"
}Pin to a tag (#v0.2.0) so updates are deliberate.
- One frozen JWT contract, enforced at the verify boundary
(not by every gate):
{sub, role, name}for humans,{sub, aud: "service"}for service tokens. A token mixing both shapes is rejected atverifyJWT. - Algorithm pinned to HS256 on sign and verify — a token signed with HS384/HS512 (even with the right secret) is rejected.
- Fail-closed app-access gate — no
user_app_accessrow → 403, no exceptions. - Defensive permission reads — missing column / null JSON / missing key →
false. - Memoized permission parsing — multiple flag reads on the same access row parse the JSON once (WeakMap-keyed, GC-safe).
- Parallel storage calls —
checkAccessrunsgetUserandgetAccessviaPromise.allso staff requests pay the longer of the two roundtrips, not the sum. - Live revocation honored — every check honors
token_invalid_before; malformed dates fail closed (not open). - Generic 403 reasons — the app slug is omitted from the "no access" response so a probing caller can't enumerate which apps a token lacks access to.
- Storage-agnostic — apps implement one
AuthStorageinterface (getUser+getAccess); the lib never touches the DB itself. - Compatibility shims —
hasRole(auth, ...roles)andisTeam(auth)match the names existing consumers already use, so adoption is mostly an import-path change.
import { getAuth, checkAccess, type AuthStorage } from "@upland/auth"
import { authStorage } from "./lib/auth-storage"
const storage: AuthStorage = {
getUser: (sub) => authStorage.getUser(sub),
getAccess: (sub, app) => authStorage.getAppAccess(sub, app),
}
const auth = getAuth(event.headers)
const decision = await checkAccess(auth, { app: "scheduler", storage })
if (!decision.ok) {
return json(decision.status, { error: decision.reason })
}
// decision.auth is the verified AuthPayload (HumanPayload | ServicePayload)Routes that need to accept cross-app service calls:
const decision = await checkAccess(auth, {
app: "inquiries",
storage,
allowService: true,
})Superadmin-only routes:
import { checkSuperadmin } from "@upland/auth"
const decision = await checkSuperadmin(auth, { storage })Narrowing the union manually (the type predicates were dropped because oxfmt strips them — use the structural check):
if ("aud" in decision.auth) {
// decision.auth is ServicePayload — has aud, sub, iat
} else {
// decision.auth is HumanPayload — has role, name, sub, iat
}| Export | Purpose |
|---|---|
createJWT({sub, role, name}) |
Mint a human JWT (7d, HS256) |
createServiceToken(callerName) |
Mint a service JWT (5m, HS256, aud:"service") |
verifyJWT(token) |
Verify; returns AuthPayload | null; enforces algorithm, iat, sub, and full payload shape |
getAuth(headers) |
Extract + verify Bearer token; case-insensitive header lookup; accepts Bearer/bearer; handles array-valued headers |
| Export | Returns |
|---|---|
isSuperadmin(auth) |
true iff auth is a superadmin human token |
isStaff(auth) |
true iff auth is a staff human token (superadmins are NOT staff) |
isService(auth) |
true iff auth is a service token (aud:"service") |
isTeam(auth) |
true iff auth is any human (superadmin OR staff) |
hasRole(auth, ...roles) |
compatibility shim — matches old auth.hasRole signature |
| Export | Purpose |
|---|---|
getPermission(access, flag) |
Defensive single-flag read (missing → false) |
readPermissions(access) |
Bulk read returning all literal-true flags (use when checking 2+ flags) |
setPermission(access, flag, value) |
Returns the new JSON string to persist |
| Export | Purpose |
|---|---|
checkAccess(auth, opts) |
Fail-closed composite gate. opts: {app, storage, allowService?} |
checkSuperadmin(auth, opts) |
Superadmin-only gate. opts: {storage, allowService?} |
Both return a Decision:
type Decision =
| { ok: true, auth: AuthPayload }
| { ok: false, status: 401 | 403, reason: string }| Export | Async/sync | Purpose |
|---|---|---|
hashPassword(plain) |
async | Returns Promise<string> |
verifyPassword(plain, hash) |
async | Returns Promise<boolean> |
hashPasswordSync(plain) |
sync | Blocks the event loop ~80ms; use only in single-shot scripts |
verifyPasswordSync(plain, hash) |
sync | Same constraint |
Async is the default for any new code. Long-lived processes (Express dev wrapper)
serializing concurrent logins on *Sync will block other requests.
import type {
AuthPayload, // HumanPayload | ServicePayload (discriminated by `aud`)
HumanPayload,
ServicePayload,
UserRecord,
AppAccessRow,
AuthStorage,
HttpHeaders,
Role,
Decision,
DecisionOk,
DecisionFail,
CheckOptions,
SuperadminOptions,
} from "@upland/auth"
import { SERVICE_AUD, SERVICE_SUB_PREFIX } from "@upland/auth"JWT_SECRET— shared across all consumer apps. Lazy-read on every call so rotation works without process restart. Rotate-rarely-and-redeploy per the runbook in the philosophy doc. In production, the lib warns once if:- The secret is the dev fallback (
"dev-secret-change-me"), or - The secret is shorter than 32 chars (almost certainly a placeholder).
- The secret is the dev fallback (
- Capability / client-view tokens — scheduler's
createShortLivedJWT/ project-token pattern is not in this lib (it sits outside the human/service model per the philosophy doc). Scheduler should keep a tiny local wrapper aroundjsonwebtokenfor that path until a future v0.x adds first-class capability-token support.
npm install
npm test # tsc --noEmit + node --test (60 tests)
npm run build # tsc → dist/
npm run lintInternal — All Rights Reserved.