Skip to content

flinthillsdesign/upland-auth

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@upland/auth

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.

Install

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.

What it gives you

  • 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 at verifyJWT.
  • 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_access row → 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 callscheckAccess runs getUser and getAccess via Promise.all so 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 AuthStorage interface (getUser + getAccess); the lib never touches the DB itself.
  • Compatibility shimshasRole(auth, ...roles) and isTeam(auth) match the names existing consumers already use, so adoption is mostly an import-path change.

Quick example

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
}

API

Tokens

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

Roles

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

Permissions

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

Gates

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 }

Passwords

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.

Types & constants

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"

Required env vars

  • 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).

What this lib doesn't do (yet)

  • 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 around jsonwebtoken for that path until a future v0.x adds first-class capability-token support.

Develop

npm install
npm test       # tsc --noEmit + node --test (60 tests)
npm run build  # tsc → dist/
npm run lint

License

Internal — All Rights Reserved.

About

Shared auth library for the Upland Workshop app suite — one frozen JWT contract, fail-closed gates, defensive permission reads. See upland-odin/upland-auth-philosophy.md.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors