@samveys/auth

Version
1.0.2Nov 5, 2026
Install
pnpm add @samveys/auth

From packages/auth/README.md in the framework repository.

Authentication guards, OIDC sign-in and policies for Samveys. No runtime dependencies: tokens and ID tokens are handled with node:crypto.

A guard answers "who is calling?" in the auth middleware. A policy answers "may they do this to that?" where you call authorize() or wrap a handler with guarded(). Everything fails closed: no user is SVS_E093 (401), a refusal is SVS_E094 (403), a failed OIDC sign-in is SVS_E095 (400).

1. Install

ts
// samveys.config.ts
import { auth, ormUserProvider, sessionGuard, tokenGuard } from '@samveys/auth'
import { database } from '@samveys/database'
import { http } from '@samveys/http'
import { session } from '@samveys/session'
import { User } from './app/models/user.ts'
import dbConfig from './config/database.ts'

const provider = ormUserProvider(User) // User.find(id)

export default {
  plugins: [
    http(),
    database(dbConfig),
    session(),
    auth({ guards: { web: sessionGuard({ provider }), api: tokenGuard({ provider }) }, default: 'web' }),
  ],
}

The plugin registers auth (the default guard) and auth:<name> for each guard, and ctx.auth = { user, guard, token?, can(ability) }. Use use: ['session', 'auth'] for the web guard and use: ['auth:api'] for the API guard. Reading ctx.auth without the middleware is SVS_E093.

A UserProvider is { findById(id), findByCredentials?(input), findOrCreateFromClaims?(claims) }.

2. API tokens

createToken(user, { name, abilities, expiresAt? }) returns { plainText, token }. plainText is svs_<id>.<secret> and is shown once.

The table samveys_api_tokens(id, user_id, name, token_hash, abilities, last_used_at, expires_at, created_at) stores only a SHA-256 of the secret. SHA-256 is correct here and Argon2 would be wrong: a token is 256 random bits, so there is nothing to brute-force, and the hash is checked on every API request. The hash is compared with timingSafeEqual. Missing, malformed, unknown, tampered, expired, revoked and orphaned tokens all give the same 401.

ts
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { auth, requireAbility, tokenGuard } from '@samveys/auth'
import { database, defineDatabaseConfig, sqlite } from '@samveys/database'
import { http } from '@samveys/http'
import { createTestApp } from '@samveys/testing'

test('a token needs the ability the route requires', async () => {
  const users = new Map([[1, { id: 1 }]])
  const tokens = tokenGuard({ provider: { findById: (id) => Promise.resolve(users.get(Number(id))) } })
  const config = defineDatabaseConfig({ default: 'main', connections: { main: sqlite({ file: ':memory:' }) } })

  await using app = await createTestApp({
    plugins: [http(), database(config), auth({ guards: { api: tokens }, default: 'api' })],
    routes: (router) => {
      router.post('/servers/:id', { use: ['auth', requireAbility('server:update')] }, () => ({ updated: true }))
    },
  })

  const readOnly = await tokens.createToken({ id: 1 }, { name: 'read', abilities: ['server:read'] })
  const deployer = await tokens.createToken({ id: 1 }, { name: 'deploy', abilities: ['server:update'] })

  const refused = await app.http.post('/servers/1', { headers: { authorization: `Bearer ${readOnly.plainText}` } })
  refused.assertProblem(403, 'SVS_E094')
  const allowed = await app.http.post('/servers/1', { headers: { authorization: `Bearer ${deployer.plainText}` } })
  assert.equal(allowed.status, 200)
  const anonymous = await app.http.post('/servers/1')
  anonymous.assertProblem(401, 'SVS_E093')
})

'*' grants every ability. revokeToken(id) and revokeAllTokens(user) delete tokens.

3. Browser sign-in

sessionGuard({ provider, abilities? }) keeps the user id in the session. In a login handler call guard.login(ctx, user): it stores the id and calls ctx.session.regenerate(), so a session id planted before login is useless afterwards. guard.logout(ctx) invalidates the session. A session whose user was deleted counts as signed out.

4. Policies

ts
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { auth, authorize, definePolicy, guarded, tokenGuard } from '@samveys/auth'
import { database, defineDatabaseConfig, sqlite } from '@samveys/database'
import { http } from '@samveys/http'
import { createTestApp } from '@samveys/testing'

interface Post { readonly id: number; readonly userId: number }
interface Member { readonly id: number }

const postPolicy = definePolicy({
  update: (user: Member, post: Post) => user.id === post.userId,
})
const posts = new Map<string, Post>([['10', { id: 10, userId: 1 }]])

test('guarded() checks the policy right before the handler', async () => {
  const tokens = tokenGuard({ provider: { findById: (id) => Promise.resolve({ id: Number(id) }) } })
  const config = defineDatabaseConfig({ default: 'main', connections: { main: sqlite({ file: ':memory:' }) } })

  await using app = await createTestApp({
    plugins: [http(), database(config), auth({ guards: { api: tokens }, default: 'api' })],
    routes: (router) => {
      router.put('/posts/:id', { use: ['auth'] }, guarded(postPolicy, 'update', (ctx) => posts.get(ctx.params.id), (_ctx, post) => ({ updated: post?.id })))
      // Imperative form, for when the check depends on more than one resource:
      router.delete('/posts/:id', { use: ['auth'] }, async (ctx) => {
        await authorize(ctx, postPolicy, 'update', posts.get(ctx.params.id))
        return { deleted: true }
      })
    },
  })

  const owner = await tokens.createToken({ id: 1 }, { name: 'owner', abilities: [] })
  const other = await tokens.createToken({ id: 2 }, { name: 'other', abilities: [] })
  assert.equal((await app.http.put('/posts/10', { headers: { authorization: `Bearer ${owner.plainText}` } })).status, 200)
  ;(await app.http.put('/posts/10', { headers: { authorization: `Bearer ${other.plainText}` } })).assertProblem(403, 'SVS_E094')
})

A rule returns exactly true to allow. An unknown action, any other return value, and a rule that throws all deny. guarded() calls authOf(ctx) first, so a route that forgot the auth middleware answers 401 instead of running the handler: the check does not depend on middleware being configured correctly.

5. OIDC sign-in

oidcGuard({ issuer, clientId, clientSecret?, redirectUri, scopes?, provider }) implements the authorization code flow with PKCE (S256).

ts
import { oidcGuard } from '@samveys/auth'

const sso = oidcGuard({
  issuer: 'https://accounts.google.com',
  clientId: process.env.OIDC_CLIENT_ID!,
  clientSecret: process.env.OIDC_CLIENT_SECRET,
  redirectUri: 'https://app.example.com/auth/callback',
  provider: {
    findById: (id) => users.find(id),
    findOrCreateFromClaims: (claims) => users.upsertBySubject(claims.sub, { email: claims.email }),
  },
})

router.get('/auth/login', { use: ['session'] }, (ctx) => sso.redirectToProvider(ctx))
router.get('/auth/callback', { use: ['session'] }, async (ctx) => {
  await sso.handleCallback(ctx) // signs in with a NEW session id
  return Response.redirect('https://app.example.com/', 302)
})

redirectToProvider stores a random state, nonce and PKCE verifier in the session and redirects. handleCallback then:

  1. checks state (constant time) and that the sign-in was started in this session, less than 10 minutes ago, and not used before;
  2. exchanges the code at token_endpoint, sending the code_verifier;
  3. verifies the ID token signature with the provider's JWKS (RS256 or ES256 only; none and HMAC are refused), then iss, aud (with azp when there are several), exp, iat, nbf (60 s clock skew) and nonce;
  4. calls provider.findOrCreateFromClaims(claims) and logs in through the session guard.

Every failure is SVS_E095 and the client sees only "sign-in failed"; the reason is in the error's meta.reason for logs. The discovery document is cached for an hour and checked to name exactly the configured issuer. Endpoints must be https (http only on loopback, for tests). Calls to the provider have a timeout (10 s) and do not follow redirects.

Provider configuration

No provider-specific code: only issuer differs. Which provider the organization uses is still open.

ProviderissuerNotes
Google Workspacehttps://accounts.google.comRestrict by hosted domain in findOrCreateFromClaims (claims.hd)
Microsoft Entra IDhttps://login.microsoftonline.com/<tenant-id>/v2.0Use the tenant id, not common: common returns a different issuer per tenant and fails the exact-match check
Keycloakhttps://<host>/realms/<realm>

Register redirectUri exactly as given with the provider.

Tests without a real provider

packages/auth/test/oidc-provider.ts is a small in-process OIDC provider on node:http (discovery, /authorize, /token with a PKCE check, /jwks, RS256 signing) that can issue deliberately bad tokens. The suite runs the full flow against it and always closes the server in finally.