@tillstack/auth-node
v0.5.0
Published
Node SDK for TillAuth — JWT verification + middleware
Readme
@tillstack/auth-node
Server-side TillAuth SDK for Node.js — verify TillAuth JWTs and protect your routes. Verification is offline against a cached JWKS (EdDSA), so there is no round-trip to TillAuth on the hot path.
Install
npm install @tillstack/auth-node
# or: pnpm add @tillstack/auth-nodeUsage
import { createVerifier } from '@tillstack/auth-node'
const verifier = createVerifier({
appId: 'tau_your_app_id', // the app's public id (its publishable key or app id works too)
// apiBase: 'https://auth.tilldev.dev', // default
// issuer: 'https://auth.tilldev.dev/realms/<app id>', // refuse the shared key once the app has its own
// requireMfa: true,
})
// Verify a raw token, or pull it straight from an Authorization header:
const claims = await verifier.verifyHeader(req.headers.authorization)
// claims.sub, claims.email, claims.mfa, claims.expExpress
import express from 'express'
import { createVerifier, expressMiddleware } from '@tillstack/auth-node'
const app = express()
const verifier = createVerifier({ appId: 'tau_your_app_id' })
app.use(expressMiddleware(verifier)) // sets req.user to the verified claimsFastify
import { createVerifier, fastifyHook } from '@tillstack/auth-node'
const verifier = createVerifier({ appId: 'tau_your_app_id' })
fastify.addHook('preHandler', fastifyHook(verifier)) // sets req.userWebhooks
import express from 'express'
import { verifyWebhook, TillAuthWebhookError } from '@tillstack/auth-node'
const app = express()
// Express: mount with express.raw({ type: 'application/json' }) so req.body is the raw bytes.
app.post('/webhooks/tillauth', express.raw({ type: 'application/json' }), async (req, res) => {
try {
const event = await verifyWebhook(req.body, req.get('x-tillauth-signature'), process.env.TILLAUTH_WEBHOOK_SECRET!)
// event.type, event.user_id, event.delivery_id
res.sendStatus(204)
} catch (e) {
if (e instanceof TillAuthWebhookError) return res.status(400).send(e.reason)
throw e
}
})Rejects a bad signature, a timestamp older than five minutes, and a body that was re-serialized.
Signing keys
The verifier picks the key set from the token's issuer: tokens signed with the
app's own key are checked against
https://auth.tilldev.dev/realms/<appId>/.well-known/jwks.json, which holds only
that app's keys; tokens from an app still on the shared key are checked against
https://auth.tilldev.dev/.well-known/jwks.json. Another app's tokens are
refused either way. Pin issuer to the app's issuer to refuse the shared key
entirely.
For fully air-gapped verification, pass inline public keys via publicJwks
instead of letting the SDK fetch them. A rotation publishes the new key 15
minutes before it signs and the previous key verifies for 24 hours after, so
update the inline keys when you rotate.
Internal apps
When a TillAuth internal app sits in front of your tool, the proxy forwards the
person's identity as X-TillDev-Email, X-TillDev-Role and friends, plus
X-TillDev-Assertion, a token signed for that internal app and that host only.
Verify the assertion instead of trusting the plain headers, so a request that
skipped the proxy can't claim to be someone:
import { createAccessVerifier, AccessAssertionError } from '@tillstack/auth-node'
const access = createAccessVerifier({
issuer: process.env.TILLDEV_ACCESS_ISSUER!, // shown on the internal app's page
audience: process.env.TILLDEV_ACCESS_AUDIENCE!, // its taac_… id
})
app.use(async (req, res, next) => {
try {
req.user = await access.verifyRequest(req.headers) // email, role, groups, mfa, auth_time, sub
next()
} catch (e) {
if (e instanceof AccessAssertionError) return res.status(401).send(e.code)
next(e)
}
})verifyRequest takes Node, Express or Fastify headers, or a fetch Headers, and
refuses an assertion issued for another host. It lasts 5 minutes and is reused
across that person's requests for up to 4 of them, so it always arrives with a
minute or more left; it is not a one-time token.
Docs
Full documentation: https://tilldev.dev/docs
License
Apache-2.0
