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/LICENSE +15 -0
- package/README.md +269 -0
- package/dist/ability.js +47 -0
- package/dist/index.js +629 -0
- package/dist/migrations/0001_initial.js +28 -0
- package/package.json +65 -0
- package/src/ability.client.ts +77 -0
- package/src/account.ts +42 -0
- package/src/confirm.ts +54 -0
- package/src/cookie.ts +34 -0
- package/src/csrf.ts +80 -0
- package/src/guard.ts +96 -0
- package/src/index.ts +32 -0
- package/src/limit.ts +38 -0
- package/src/password.ts +12 -0
- package/src/reset.ts +40 -0
- package/src/secret.ts +18 -0
- package/src/session.ts +150 -0
- package/src/store.ts +13 -0
- package/src/token.ts +77 -0
- package/src/types.ts +78 -0
- package/src/verify.ts +44 -0
- package/src/wares.ts +101 -0
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
|
+
}
|
package/src/password.ts
ADDED
|
@@ -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
|
+
}
|