@samveys/auth
Dari packages/auth/README.md di repository framework.
Halaman ini belum diterjemahkan. Kamu sedang membaca versi bahasa Inggris.
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
// 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.
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
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).
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:
- checks
state(constant time) and that the sign-in was started in this session, less than 10 minutes ago, and not used before; - exchanges the code at
token_endpoint, sending thecode_verifier; - verifies the ID token signature with the provider's JWKS (RS256 or ES256 only;
noneand HMAC are refused), theniss,aud(withazpwhen there are several),exp,iat,nbf(60 s clock skew) andnonce; - 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.
| Provider | issuer | Notes |
|---|---|---|
| Google Workspace | https://accounts.google.com | Restrict by hosted domain in findOrCreateFromClaims (claims.hd) |
| Microsoft Entra ID | https://login.microsoftonline.com/<tenant-id>/v2.0 | Use the tenant id, not common: common returns a different issuer per tenant and fails the exact-match check |
| Keycloak | https://<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.