ajo-kit-auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "ajo-kit-auth",
3
+ "version": "0.1.0",
4
+ "description": "Authentication and authorization for ajo-kit applications",
5
+ "type": "module",
6
+ "files": [
7
+ "dist",
8
+ "src",
9
+ "README.md"
10
+ ],
11
+ "kit": {
12
+ "alias": "auth",
13
+ "serverOnly": true,
14
+ "migrations": "./dist/migrations/"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "types": "./src/index.ts",
19
+ "default": "./dist/index.js",
20
+ "import": "./dist/index.js"
21
+ },
22
+ "./ability": {
23
+ "types": "./src/ability.client.ts",
24
+ "default": "./dist/ability.js",
25
+ "import": "./dist/ability.js"
26
+ }
27
+ },
28
+ "dependencies": {
29
+ "argon2": "^0.44.0"
30
+ },
31
+ "devDependencies": {
32
+ "ajo-kit": "^0.1.0"
33
+ },
34
+ "peerDependencies": {
35
+ "ajo-kit": "^0.1.0"
36
+ },
37
+ "engines": {
38
+ "node": ">=22.18.0"
39
+ },
40
+ "keywords": [
41
+ "ajo",
42
+ "authentication",
43
+ "authorization",
44
+ "security",
45
+ "sqlite"
46
+ ],
47
+ "author": "Cristian Falcone",
48
+ "license": "ISC",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/cristianfalcone/ajo-kit.git",
52
+ "directory": "packages/ajo-kit-auth"
53
+ },
54
+ "bugs": {
55
+ "url": "https://github.com/cristianfalcone/ajo-kit/issues"
56
+ },
57
+ "homepage": "https://github.com/cristianfalcone/ajo-kit/tree/main/packages/ajo-kit-auth#readme",
58
+ "publishConfig": {
59
+ "access": "public"
60
+ },
61
+ "scripts": {
62
+ "build": "pnpm -w exec tsx scripts/package-build.ts ajo-kit-auth",
63
+ "test": "pnpm -w exec vitest run packages/ajo-kit-auth/tests"
64
+ }
65
+ }
@@ -0,0 +1,77 @@
1
+ /** Ability string used by account grants and bearer API tokens. */
2
+ export type Ability = string
3
+
4
+ const full = '*'
5
+ const wildcarded = (ability: Ability) => ability === full
6
+ ? null
7
+ : ability.endsWith(':*')
8
+ ? ability.slice(0, -2)
9
+ : null
10
+
11
+ /** Returns true when grants include the required ability. */
12
+ export function can(grants: readonly Ability[] | undefined, required: Ability): boolean {
13
+
14
+ if (!grants) return false
15
+ if (grants.includes(full)) return true
16
+ if (grants.includes(required)) return true
17
+
18
+ const [resource] = required.split(':')
19
+
20
+ return grants.includes(`${resource}:*`)
21
+ }
22
+
23
+ /** Returns true when grants include every required ability. */
24
+ export const all = (grants: readonly Ability[], required: readonly Ability[]) =>
25
+ required.every(ability => can(grants, ability))
26
+
27
+ /** Removes duplicate and redundant grants while preserving stable order. */
28
+ export function compact(grants: readonly Ability[]): Ability[] {
29
+
30
+ const unique = [...new Set(grants)]
31
+
32
+ if (unique.includes(full)) return [full]
33
+
34
+ const resources = new Set(unique.map(wildcarded).filter(resource => resource !== null))
35
+
36
+ return unique.filter(grant => {
37
+ const resource = wildcarded(grant)
38
+ if (resource) return true
39
+
40
+ return !resources.has(grant.split(':')[0])
41
+ })
42
+ }
43
+
44
+ /** Merges ability grant sets with duplicate and wildcard compaction. */
45
+ export const merge = (...sets: (readonly Ability[])[]): Ability[] =>
46
+ compact(sets.flat())
47
+
48
+ /** Returns the effective grants allowed by both grant sets. */
49
+ export function intersect(left: readonly Ability[], right: readonly Ability[]): Ability[] {
50
+
51
+ const grants: Ability[] = []
52
+
53
+ for (const a of compact(left)) {
54
+ for (const b of compact(right)) {
55
+ const grant = overlap(a, b)
56
+ if (grant) grants.push(grant)
57
+ }
58
+ }
59
+
60
+ return compact(grants)
61
+ }
62
+
63
+ function overlap(left: Ability, right: Ability): Ability | null {
64
+
65
+ if (left === full) return right
66
+ if (right === full) return left
67
+ if (left === right) return left
68
+
69
+ const l = wildcarded(left)
70
+ const r = wildcarded(right)
71
+
72
+ if (l && r) return l === r ? left : null
73
+ if (l && can([left], right)) return right
74
+ if (r && can([right], left)) return left
75
+
76
+ return null
77
+ }
package/src/account.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { merge, type Ability } from './ability.client'
2
+ import { db } from './store'
3
+ import type { Role } from './types'
4
+
5
+ /** Parsed ability bundle assigned through one user role. */
6
+ export type Grant = {
7
+ name: Role
8
+ abilities: Ability[]
9
+ }
10
+
11
+ function parse(value: string): Ability[] {
12
+ try {
13
+ const abilities = JSON.parse(value)
14
+
15
+ return Array.isArray(abilities) && abilities.every(ability => typeof ability === 'string')
16
+ ? abilities
17
+ : []
18
+ } catch {
19
+ return []
20
+ }
21
+ }
22
+
23
+ /** Loads role ability bundles for an auth user. */
24
+ export async function grants(user: number): Promise<Grant[]> {
25
+ const roles = await db()
26
+ .selectFrom('members')
27
+ .innerJoin('roles', 'roles.id', 'members.role')
28
+ .select(['roles.name', 'roles.abilities'])
29
+ .where('members.user', '=', user)
30
+ .orderBy('roles.id')
31
+ .execute()
32
+
33
+ return roles.map(role => ({
34
+ name: role.name as Role,
35
+ abilities: parse(role.abilities),
36
+ }))
37
+ }
38
+
39
+ /** Resolves the effective account abilities from all assigned roles. */
40
+ export async function abilities(user: number): Promise<Ability[]> {
41
+ return merge(...(await grants(user)).map(role => role.abilities))
42
+ }
package/src/confirm.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { Request } from 'ajo-kit'
2
+
3
+ const stamps = new Map<string, number>()
4
+
5
+ const key = (user: number, kind: 'session' | 'token', id: string) => `${kind}:${user}:${id}`
6
+ const segment = (user: number) => `:${user}:`
7
+
8
+ /** Returns the current session/token confirmation key for a request. */
9
+ export function credential(req: Request): string | null {
10
+ if (!req.user) return null
11
+ if (req.token) return key(req.user.id, 'token', req.token.id)
12
+ if (req.session) return key(req.user.id, 'session', req.session.id)
13
+ return null
14
+ }
15
+
16
+ /** Stamps the current credential as recently password-confirmed. */
17
+ export function stamp(req: Request): boolean {
18
+ const id = credential(req)
19
+ if (!id) return false
20
+ stamps.set(id, Date.now())
21
+ return true
22
+ }
23
+
24
+ /** Returns true when the current credential was confirmed recently. */
25
+ export function check(req: Request, window = 180_000): boolean {
26
+ const id = credential(req)
27
+ if (!id) return false
28
+ const at = stamps.get(id)
29
+ if (!at) return false
30
+ return Date.now() - at < window
31
+ }
32
+
33
+ /** Clears the confirmation stamp for the current credential. */
34
+ export function clear(req: Request): void {
35
+ const id = credential(req)
36
+ if (id) stamps.delete(id)
37
+ }
38
+
39
+ /** Clears the confirmation stamp for a specific session. */
40
+ export function clearSession(user: number, id: string): void {
41
+ stamps.delete(key(user, 'session', id))
42
+ }
43
+
44
+ /** Clears the confirmation stamp for a specific bearer token. */
45
+ export function clearToken(user: number, id: string): void {
46
+ stamps.delete(key(user, 'token', id))
47
+ }
48
+
49
+ /** Clears every confirmation stamp for a user. */
50
+ export function clearUser(user: number): void {
51
+ for (const id of stamps.keys()) {
52
+ if (id.includes(segment(user))) stamps.delete(id)
53
+ }
54
+ }
package/src/cookie.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { Request, Response } from 'ajo-kit'
2
+
3
+ const name = 'session'
4
+ const secure = () => process.env.NODE_ENV === 'production' ? '; Secure' : ''
5
+ const base = () => `HttpOnly; SameSite=Lax; Path=/${secure()}`
6
+
7
+ /** Reads one cookie by exact name and rejects duplicates. */
8
+ export const parse = (header: string | undefined, key: string) => {
9
+ let value: string | undefined
10
+
11
+ for (const part of header?.split(';') ?? []) {
12
+ const trimmed = part.trim()
13
+ const index = trimmed.indexOf('=')
14
+ if (index === -1) continue
15
+ if (trimmed.slice(0, index) !== key) continue
16
+ if (value !== undefined) return
17
+ value = trimmed.slice(index + 1)
18
+ }
19
+
20
+ return value
21
+ }
22
+
23
+ /** Reads the session cookie from a request. */
24
+ export const read = (req: Request) => parse(req.headers.cookie, name)
25
+
26
+ /** Writes the session cookie to a response. */
27
+ export const write = (res: Response, value: string, remember = false) => {
28
+ const age = remember ? 31536000 : 2592000
29
+ res.setHeader('Set-Cookie', `${name}=${value}; ${base()}; Max-Age=${age}`)
30
+ }
31
+
32
+ /** Clears the session cookie on a response. */
33
+ export const clear = (res: Response) =>
34
+ res.setHeader('Set-Cookie', `${name}=; ${base()}; Max-Age=0`)
package/src/csrf.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto'
2
+ import { origin, type Request, type Response } from 'ajo-kit'
3
+ import { generate } from './session'
4
+ import { parse } from './cookie'
5
+ import * as secret from './secret'
6
+
7
+ const NAME = 'XSRF-TOKEN'
8
+ const secure = () => process.env.NODE_ENV === 'production' ? '; Secure' : ''
9
+
10
+ const sign = (session: string, token: string) =>
11
+ createHmac('sha256', secret.value()).update(`${session}:${token}`).digest('hex')
12
+
13
+ const equal = (left: string, right: string) => {
14
+ const a = Buffer.from(left, 'hex')
15
+ const b = Buffer.from(right, 'hex')
16
+
17
+ return a.length === b.length && timingSafeEqual(a, b)
18
+ }
19
+
20
+ const seal = (session: string) => {
21
+ const token = generate()
22
+ return `${token}.${sign(session, token)}`
23
+ }
24
+
25
+ const signed = (req: Request, token: string) => {
26
+ if (!req.session) return false
27
+
28
+ const parts = token.split('.')
29
+ if (parts.length !== 2) return false
30
+
31
+ const [plain, mac] = parts
32
+ if (!plain || !mac) return false
33
+
34
+ return equal(mac, sign(req.session.id, plain))
35
+ }
36
+
37
+ /** Sets a readable session-bound XSRF cookie and returns its token. */
38
+ export function set(req: Request, res: Response) {
39
+ if (!req.session) throw new Error('CSRF token requires a session')
40
+
41
+ const token = seal(req.session.id)
42
+ res.setHeader('Set-Cookie', `${NAME}=${token}; Path=/; SameSite=Lax${secure()}`)
43
+ return token
44
+ }
45
+
46
+ /** Validates signed session-bound CSRF or same-origin proof. */
47
+ export function verify(req: Request): boolean {
48
+
49
+ // 1. Check signed double-submit token
50
+
51
+ const cookie = parse(req.headers.cookie, NAME)
52
+ const header = req.headers['x-xsrf-token'] as string | undefined
53
+
54
+ if (cookie && cookie === header && signed(req, header)) return true
55
+
56
+ // 2. Check same-origin (Origin or Referer matches host)
57
+
58
+ const source = req.headers.origin
59
+ const referer = req.headers.referer
60
+
61
+ if (!source && !referer) return false
62
+
63
+ const base = origin(req)
64
+
65
+ if (source) {
66
+ try {
67
+ const url = new URL(source)
68
+ if (url.origin === base) return true
69
+ } catch {}
70
+ }
71
+
72
+ if (referer) {
73
+ try {
74
+ const url = new URL(referer)
75
+ if (url.origin === base) return true
76
+ } catch {}
77
+ }
78
+
79
+ return false
80
+ }
package/src/guard.ts ADDED
@@ -0,0 +1,96 @@
1
+ import type { Middleware, Request, Response } from 'ajo-kit'
2
+ import { Denied, Forbidden, Failure, ajax } from 'ajo-kit'
3
+ import { can } from './ability.client'
4
+ import { check as confirm, credential } from './confirm'
5
+ import { db } from './store'
6
+
7
+ /** Redirects HTML requests or returns a JSON redirect envelope for AJAX. */
8
+ export const redirect = (to: string | ((req: Request) => string)): Middleware => (req, res) => {
9
+
10
+ const target = typeof to === 'function' ? to(req) : to
11
+ const json = ajax(req)
12
+
13
+ res.writeHead(
14
+ json ? 200 : 302,
15
+ json
16
+ ? { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }
17
+ : { Location: target }
18
+ )
19
+ res.end(json ? JSON.stringify({ redirect: target }) : undefined)
20
+ }
21
+
22
+ /** Runs middleware only when a request condition matches. */
23
+ export const when = (
24
+ condition: (req: Request, res: Response) => boolean,
25
+ middleware: Middleware,
26
+ otherwise?: Middleware
27
+ ): Middleware => (req, res, next) => {
28
+ if (condition(req, res)) return middleware(req, res, next)
29
+ if (otherwise) return otherwise(req, res, next)
30
+ next()
31
+ }
32
+
33
+ /** Requires an authenticated request user. */
34
+ export const auth = (): Middleware => (req, _, next) => {
35
+ if (!req.user) throw new Denied()
36
+ next()
37
+ }
38
+
39
+ /** Redirects guests away from protected browser routes. */
40
+ export const protect = (to = '/login') => when(req => !req.user, redirect(to))
41
+ /** Redirects authenticated users away from guest-only routes. */
42
+ export const guest = (to = '/dashboard') => when(req => !!req.user, redirect(to))
43
+
44
+ /** Throws when the current account or bearer token lacks required abilities. */
45
+ export function authorize(req: Request, ...required: string[]) {
46
+
47
+ if (!req.user) throw new Denied()
48
+
49
+ const abilities = req.user.abilities ?? []
50
+ const missing = required.find(ability => !can(abilities, ability))
51
+ ?? (req.token ? required.find(ability => !can(req.token!.abilities, ability)) : undefined)
52
+
53
+ if (missing) throw new Forbidden(`Missing ability: ${missing}`)
54
+ }
55
+
56
+ /** Middleware variant of authorize() for route stacks. */
57
+ export const ability = (...required: string[]): Middleware => (req, _, next) => {
58
+ authorize(req, ...required)
59
+ next()
60
+ }
61
+
62
+ /** Requires recent password confirmation for the current credential. */
63
+ export const confirmed = (window?: number): Middleware => (req, res, next) => {
64
+
65
+ if (!req.user || !credential(req)) throw new Denied()
66
+
67
+ if (!confirm(req, window)) {
68
+ const back = encodeURIComponent(req.originalUrl)
69
+ return redirect(`/confirm?redirect=${back}`)(req, res, next)
70
+ }
71
+
72
+ next()
73
+ }
74
+
75
+ /** Requires the authenticated user to have a verified timestamp. */
76
+ export const verified = (): Middleware => async (req, res, next) => {
77
+
78
+ if (!req.user) throw new Denied()
79
+
80
+ const user = await db()
81
+ .selectFrom('users')
82
+ .select(['verified'])
83
+ .where('id', '=', req.user.id)
84
+ .executeTakeFirst()
85
+
86
+ if (!user?.verified) {
87
+
88
+ if (ajax(req)) {
89
+ throw new Failure(403, 'Email verification required')
90
+ }
91
+
92
+ return redirect('/verify')(req, res, next)
93
+ }
94
+
95
+ next()
96
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ /** Route guards and imperative authorization helpers. */
2
+ export { auth, protect, guest, ability, confirmed, verified, redirect, when, authorize } from './guard'
3
+ /** Ability matching and set helpers. */
4
+ export { all, can, compact, intersect, merge, type Ability } from './ability.client'
5
+ /** Account role grant helpers. */
6
+ export * as account from './account'
7
+ /** Configures the auth package database accessor. */
8
+ export { configure } from './store'
9
+ /** Password confirmation stamp helpers. */
10
+ export * as confirm from './confirm'
11
+ /** Session cookie helpers. */
12
+ export * as cookie from './cookie'
13
+ /** CSRF token and origin verification helpers. */
14
+ export * as csrf from './csrf'
15
+ /** Guard helper namespace. */
16
+ export * as guard from './guard'
17
+ /** In-memory rate limit helpers. */
18
+ export * as limit from './limit'
19
+ /** Password hashing and verification helpers. */
20
+ export * as password from './password'
21
+ /** Password reset token helpers. */
22
+ export * as reset from './reset'
23
+ /** Cookie session lifecycle helpers. */
24
+ export * as session from './session'
25
+ /** API token lifecycle helpers. */
26
+ export * as token from './token'
27
+ /** Email verification signature helpers. */
28
+ export * as verify from './verify'
29
+ /** Auth and CSRF middleware namespace. */
30
+ export * as wares from './wares'
31
+ /** Auth table and row types. */
32
+ export type { User, New, Session, Token, Role, Auth } from './types'
package/src/limit.ts ADDED
@@ -0,0 +1,38 @@
1
+ interface Attempt {
2
+ count: number
3
+ reset: number
4
+ }
5
+
6
+ const store = new Map<string, Attempt>()
7
+
8
+ /** Returns true when a key is still under the hit limit. */
9
+ export function check(key: string, max = 5): boolean {
10
+ const entry = store.get(key)
11
+ if (!entry || Date.now() > entry.reset) return true
12
+ return entry.count < max
13
+ }
14
+
15
+ /** Records one hit for a key within the current window. */
16
+ export function hit(key: string, window = 60_000): void {
17
+
18
+ const entry = store.get(key)
19
+ const now = Date.now()
20
+
21
+ if (!entry || now > entry.reset) {
22
+ store.set(key, { count: 1, reset: now + window })
23
+ } else {
24
+ entry.count++
25
+ }
26
+ }
27
+
28
+ /** Clears all hits for a key. */
29
+ export function clear(key: string): void {
30
+ store.delete(key)
31
+ }
32
+
33
+ /** Returns remaining hits before a key reaches the limit. */
34
+ export function remaining(key: string, max = 5): number {
35
+ const entry = store.get(key)
36
+ if (!entry || Date.now() > entry.reset) return max
37
+ return Math.max(0, max - entry.count)
38
+ }
@@ -0,0 +1,12 @@
1
+ import { createRequire } from 'node:module'
2
+
3
+ const require = createRequire(import.meta.resolve('ajo-kit-auth'))
4
+ const argon2 = require('argon2') as typeof import('argon2')
5
+
6
+ const options = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 }
7
+
8
+ /** Hashes a plaintext password with Argon2id. */
9
+ export const hash = (plain: string) => argon2.hash(plain, options)
10
+
11
+ /** Verifies a plaintext password against an Argon2 hash. */
12
+ export const verify = (plain: string, hashed: string) => argon2.verify(hashed, plain)
package/src/reset.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { generate } from './session'
3
+ import { db } from './store'
4
+
5
+ const hash = (plain: string) => createHash('sha256').update(plain).digest('hex')
6
+ const hours = 1
7
+
8
+ /** Creates a password reset token and returns its plaintext value. */
9
+ export async function create(user: number): Promise<string> {
10
+
11
+ await db().deleteFrom('resets').where('user', '=', user).execute()
12
+
13
+ const plain = generate()
14
+ const id = hash(plain)
15
+ const expiry = new Date(Date.now() + hours * 60 * 60 * 1000).toISOString()
16
+
17
+ await db().insertInto('resets').values({ id, user, expiry }).execute()
18
+
19
+ return plain
20
+ }
21
+
22
+ /** Resolves a plaintext reset token to its user id when active. */
23
+ export async function validate(plain: string): Promise<number | null> {
24
+
25
+ const id = hash(plain)
26
+ const reset = await db()
27
+ .selectFrom('resets')
28
+ .select(['user', 'expiry'])
29
+ .where('id', '=', id)
30
+ .executeTakeFirst()
31
+
32
+ if (!reset || new Date(reset.expiry) < new Date()) return null
33
+
34
+ return reset.user
35
+ }
36
+
37
+ /** Deletes expired password reset tokens. */
38
+ export function prune() {
39
+ return db().deleteFrom('resets').where('expiry', '<', new Date().toISOString()).execute()
40
+ }
package/src/secret.ts ADDED
@@ -0,0 +1,18 @@
1
+ const fallback = 'change-in-production'
2
+ const minimum = 32
3
+ const samples = new Set([fallback, 'your-secret-key'])
4
+
5
+ const production = () => process.env.NODE_ENV === 'production'
6
+
7
+ /** Returns the app signing secret and fails closed in production. */
8
+ export const value = () => {
9
+ const secret = process.env.APP_SECRET?.trim() ? process.env.APP_SECRET : undefined
10
+
11
+ if (production() && (!secret || secret.length < minimum || samples.has(secret))) {
12
+ const message = 'APP_SECRET must be set to a strong production secret'
13
+ console.error(`[security] ${message}`)
14
+ throw new Error(message)
15
+ }
16
+
17
+ return secret ?? fallback
18
+ }