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/src/session.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
2
|
+
import { sql } from 'ajo-kit/database'
|
|
3
|
+
import { db } from './store'
|
|
4
|
+
|
|
5
|
+
const minute = 60 * 1000
|
|
6
|
+
const day = 24 * 60 * 60 * 1000
|
|
7
|
+
const idle = 30 * minute
|
|
8
|
+
const pace = 5 * minute
|
|
9
|
+
|
|
10
|
+
type Row = {
|
|
11
|
+
id: string
|
|
12
|
+
user: number
|
|
13
|
+
expiry: string
|
|
14
|
+
last: string | null
|
|
15
|
+
created: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stamp(now = Date.now()): string {
|
|
19
|
+
return new Date(now).toISOString()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function time(value: string | null): number {
|
|
23
|
+
const parsed = value ? Date.parse(value) : Number.NaN
|
|
24
|
+
|
|
25
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function age(session: Row, now: number): number {
|
|
29
|
+
return now - time(session.last ?? session.created)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function expired(session: Row, now: number): boolean {
|
|
33
|
+
return time(session.expiry) <= now || age(session, now) > idle
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stale(session: Row, now: number): boolean {
|
|
37
|
+
return age(session, now) > pace
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cutoff(now = Date.now()): string {
|
|
41
|
+
return stamp(now - idle)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function drop(id: string) {
|
|
45
|
+
return db()
|
|
46
|
+
.deleteFrom('sessions')
|
|
47
|
+
.where('id', '=', id)
|
|
48
|
+
.execute()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function mark(id: string, last = stamp()) {
|
|
52
|
+
return db()
|
|
53
|
+
.updateTable('sessions')
|
|
54
|
+
.set({ last })
|
|
55
|
+
.where('id', '=', id)
|
|
56
|
+
.execute()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Generates a random plaintext credential value. */
|
|
60
|
+
export function generate(): string {
|
|
61
|
+
return randomBytes(32).toString('base64url')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Hashes a plaintext session credential for database storage. */
|
|
65
|
+
export function hash(plain: string): string {
|
|
66
|
+
return createHash('sha256').update(plain).digest('hex')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Creates a cookie session and returns its plaintext credential. */
|
|
70
|
+
export const create = async (
|
|
71
|
+
user: number,
|
|
72
|
+
remember = false,
|
|
73
|
+
ip?: string,
|
|
74
|
+
agent?: string
|
|
75
|
+
) => {
|
|
76
|
+
|
|
77
|
+
const plain = generate()
|
|
78
|
+
const id = hash(plain)
|
|
79
|
+
const lifetime = remember ? 365 : 30
|
|
80
|
+
const now = Date.now()
|
|
81
|
+
const expiry = stamp(now + lifetime * day)
|
|
82
|
+
const last = stamp(now)
|
|
83
|
+
|
|
84
|
+
await db().insertInto('sessions').values({
|
|
85
|
+
id,
|
|
86
|
+
user,
|
|
87
|
+
expiry,
|
|
88
|
+
ip: ip ?? null,
|
|
89
|
+
agent: agent ?? null,
|
|
90
|
+
last
|
|
91
|
+
}).execute()
|
|
92
|
+
|
|
93
|
+
return plain
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolves a session credential, enforcing expiry and throttled activity touch.
|
|
98
|
+
*
|
|
99
|
+
* Pass `activity = false` for background checks that must not renew activity.
|
|
100
|
+
*/
|
|
101
|
+
export const validate = async (plain: string, activity = true) => {
|
|
102
|
+
|
|
103
|
+
const id = hash(plain)
|
|
104
|
+
|
|
105
|
+
const session = await db()
|
|
106
|
+
.selectFrom('sessions')
|
|
107
|
+
.select(['id', 'user', 'expiry', 'last', 'created'])
|
|
108
|
+
.where('id', '=', id)
|
|
109
|
+
.executeTakeFirst() as Row | undefined
|
|
110
|
+
|
|
111
|
+
if (!session) return null
|
|
112
|
+
|
|
113
|
+
const now = Date.now()
|
|
114
|
+
|
|
115
|
+
if (expired(session, now)) {
|
|
116
|
+
await drop(id)
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (activity && stale(session, now)) {
|
|
121
|
+
const last = stamp(now)
|
|
122
|
+
|
|
123
|
+
await mark(id, last)
|
|
124
|
+
|
|
125
|
+
return { ...session, last }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return session
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Deletes the session matching a plaintext credential. */
|
|
132
|
+
export const remove = (plain: string) =>
|
|
133
|
+
drop(hash(plain))
|
|
134
|
+
|
|
135
|
+
/** Updates the last-seen timestamp for a session credential. */
|
|
136
|
+
export const touch = (plain: string) =>
|
|
137
|
+
mark(hash(plain))
|
|
138
|
+
|
|
139
|
+
/** Deletes sessions past their absolute or idle timeout. */
|
|
140
|
+
export const prune = () => {
|
|
141
|
+
const now = Date.now()
|
|
142
|
+
|
|
143
|
+
return db()
|
|
144
|
+
.deleteFrom('sessions')
|
|
145
|
+
.where(eb => eb.or([
|
|
146
|
+
eb('expiry', '<=', stamp(now)),
|
|
147
|
+
sql<boolean>`unixepoch(coalesce(last, created)) <= unixepoch(${cutoff(now)})`,
|
|
148
|
+
]))
|
|
149
|
+
.execute()
|
|
150
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Kysely } from 'ajo-kit/database'
|
|
2
|
+
import type { Auth } from './types'
|
|
3
|
+
|
|
4
|
+
let accessor: (() => Kysely<Auth>) | null = null
|
|
5
|
+
|
|
6
|
+
/** Sets the Kysely accessor used by auth helpers. */
|
|
7
|
+
export const configure = (fn: () => Kysely<any>) => { accessor = fn }
|
|
8
|
+
|
|
9
|
+
/** Returns the configured auth Kysely instance or throws if missing. */
|
|
10
|
+
export const db = () => {
|
|
11
|
+
if (!accessor) throw new Error('Auth database not configured. Call configure() from ajo-kit-auth.')
|
|
12
|
+
return accessor()
|
|
13
|
+
}
|
package/src/token.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { db } from './store'
|
|
3
|
+
import { generate } from './session'
|
|
4
|
+
import type { Ability } from './ability.client'
|
|
5
|
+
|
|
6
|
+
const hash = (plain: string) => createHash('sha256').update(plain).digest('hex')
|
|
7
|
+
|
|
8
|
+
/** Creates an API token and returns its plaintext credential once. */
|
|
9
|
+
export async function create(
|
|
10
|
+
user: number,
|
|
11
|
+
name: string,
|
|
12
|
+
abilities: Ability[],
|
|
13
|
+
ttl: number | null = 90 * 24 * 60 * 60 * 1000 // 90 días default
|
|
14
|
+
) {
|
|
15
|
+
|
|
16
|
+
const plain = generate()
|
|
17
|
+
const id = hash(plain)
|
|
18
|
+
const expiry = ttl ? new Date(Date.now() + ttl).toISOString() : null
|
|
19
|
+
|
|
20
|
+
await db().insertInto('tokens').values({
|
|
21
|
+
id,
|
|
22
|
+
user,
|
|
23
|
+
name,
|
|
24
|
+
abilities: JSON.stringify(abilities),
|
|
25
|
+
last: null,
|
|
26
|
+
expiry
|
|
27
|
+
}).execute()
|
|
28
|
+
|
|
29
|
+
return plain
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Resolves a plaintext API token to its stored bearer identity. */
|
|
33
|
+
export async function validate(plain: string) {
|
|
34
|
+
|
|
35
|
+
const id = hash(plain)
|
|
36
|
+
|
|
37
|
+
const token = await db()
|
|
38
|
+
.selectFrom('tokens')
|
|
39
|
+
.select(['id', 'user', 'abilities', 'expiry'])
|
|
40
|
+
.where('id', '=', id)
|
|
41
|
+
.executeTakeFirst()
|
|
42
|
+
|
|
43
|
+
if (!token) return null
|
|
44
|
+
|
|
45
|
+
if (token.expiry && new Date(token.expiry) < new Date()) {
|
|
46
|
+
await db().deleteFrom('tokens').where('id', '=', id).execute()
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await db().updateTable('tokens')
|
|
51
|
+
.set({ last: new Date().toISOString() })
|
|
52
|
+
.where('id', '=', id)
|
|
53
|
+
.execute()
|
|
54
|
+
|
|
55
|
+
return { ...token, abilities: JSON.parse(token.abilities) as Ability[] }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Deletes the API token matching a plaintext credential. */
|
|
59
|
+
export const revoke = (plain: string) =>
|
|
60
|
+
db().deleteFrom('tokens').where('id', '=', hash(plain)).execute()
|
|
61
|
+
|
|
62
|
+
/** Deletes every API token owned by a user. */
|
|
63
|
+
export const purge = (user: number) =>
|
|
64
|
+
db().deleteFrom('tokens').where('user', '=', user).execute()
|
|
65
|
+
|
|
66
|
+
/** Lists stored API tokens for a user without plaintext secrets. */
|
|
67
|
+
export const list = (user: number) =>
|
|
68
|
+
db().selectFrom('tokens')
|
|
69
|
+
.select(['id', 'name', 'abilities', 'last', 'expiry', 'created'])
|
|
70
|
+
.where('user', '=', user)
|
|
71
|
+
.execute()
|
|
72
|
+
|
|
73
|
+
/** Deletes expired API tokens. */
|
|
74
|
+
export const prune = () =>
|
|
75
|
+
db().deleteFrom('tokens')
|
|
76
|
+
.where('expiry', '<', new Date().toISOString())
|
|
77
|
+
.execute()
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Generated, Selectable, Insertable } from 'ajo-kit/database'
|
|
2
|
+
|
|
3
|
+
/** users table shape. */
|
|
4
|
+
export interface Users {
|
|
5
|
+
id: Generated<number>
|
|
6
|
+
name: Generated<string>
|
|
7
|
+
email: string
|
|
8
|
+
password: string | null
|
|
9
|
+
verified: string | null
|
|
10
|
+
created: Generated<string>
|
|
11
|
+
updated: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** sessions table shape. */
|
|
15
|
+
export interface Sessions {
|
|
16
|
+
id: string
|
|
17
|
+
user: number
|
|
18
|
+
expiry: string
|
|
19
|
+
ip: string | null
|
|
20
|
+
agent: string | null
|
|
21
|
+
last: string | null
|
|
22
|
+
created: Generated<string>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** roles table shape. */
|
|
26
|
+
export interface Roles {
|
|
27
|
+
id: number
|
|
28
|
+
name: string
|
|
29
|
+
abilities: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** members table shape linking users to roles. */
|
|
33
|
+
export interface Members {
|
|
34
|
+
user: number
|
|
35
|
+
role: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** tokens table shape for bearer API tokens. */
|
|
39
|
+
export interface Tokens {
|
|
40
|
+
id: string
|
|
41
|
+
user: number
|
|
42
|
+
name: string
|
|
43
|
+
abilities: string
|
|
44
|
+
last: string | null
|
|
45
|
+
expiry: string | null
|
|
46
|
+
created: Generated<string>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** resets table shape for password reset tokens. */
|
|
50
|
+
export interface Resets {
|
|
51
|
+
id: string
|
|
52
|
+
user: number
|
|
53
|
+
expiry: string
|
|
54
|
+
created: Generated<string>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Schema parcial — solo las tablas de auth
|
|
58
|
+
|
|
59
|
+
/** Database schema fragment owned by ajo-kit-auth. */
|
|
60
|
+
export interface Auth {
|
|
61
|
+
users: Users
|
|
62
|
+
sessions: Sessions
|
|
63
|
+
roles: Roles
|
|
64
|
+
members: Members
|
|
65
|
+
tokens: Tokens
|
|
66
|
+
resets: Resets
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Selected auth user row. */
|
|
70
|
+
export type User = Selectable<Users>
|
|
71
|
+
/** Insertable auth user row. */
|
|
72
|
+
export type New = Insertable<Users>
|
|
73
|
+
/** Selected auth session row. */
|
|
74
|
+
export type Session = Selectable<Sessions>
|
|
75
|
+
/** Selected auth token row. */
|
|
76
|
+
export type Token = Selectable<Tokens>
|
|
77
|
+
/** Built-in role names used by the demo auth schema. */
|
|
78
|
+
export type Role = 'admin' | 'user'
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import * as secret from './secret'
|
|
3
|
+
|
|
4
|
+
const hours = 24
|
|
5
|
+
|
|
6
|
+
/** Signs a user id into a time-limited email verification signature. */
|
|
7
|
+
export function sign(user: number): string {
|
|
8
|
+
|
|
9
|
+
const expiry = Date.now() + hours * 60 * 60 * 1000
|
|
10
|
+
const data = `${user}:${expiry}`
|
|
11
|
+
const sig = createHmac('sha256', secret.value()).update(data).digest('hex')
|
|
12
|
+
|
|
13
|
+
return Buffer.from(`${data}:${sig}`).toString('base64url')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Validates an email verification signature and returns its user id. */
|
|
17
|
+
export function validate(signature: string): number | null {
|
|
18
|
+
|
|
19
|
+
const key = secret.value()
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
|
|
23
|
+
const decoded = Buffer.from(signature, 'base64url').toString()
|
|
24
|
+
const [user, expiry, sig] = decoded.split(':')
|
|
25
|
+
|
|
26
|
+
if (Date.now() > Number(expiry)) return null
|
|
27
|
+
|
|
28
|
+
const expected = createHmac('sha256', key).update(`${user}:${expiry}`).digest('hex')
|
|
29
|
+
const actual = Buffer.from(sig, 'hex')
|
|
30
|
+
const wanted = Buffer.from(expected, 'hex')
|
|
31
|
+
|
|
32
|
+
if (!timingSafeEqual(actual, wanted)) return null
|
|
33
|
+
|
|
34
|
+
return Number(user)
|
|
35
|
+
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Builds an absolute email verification URL for a user. */
|
|
42
|
+
export function url(user: number, base: string): string {
|
|
43
|
+
return `${base}/verify/${sign(user)}`
|
|
44
|
+
}
|
package/src/wares.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Middleware, Request } from 'ajo-kit'
|
|
2
|
+
import { Forbidden, api } from 'ajo-kit'
|
|
3
|
+
import { read, clear } from './cookie'
|
|
4
|
+
import { validate } from './session'
|
|
5
|
+
import { validate as bearer } from './token'
|
|
6
|
+
import { verify as valid } from './csrf'
|
|
7
|
+
import { db } from './store'
|
|
8
|
+
import { grants } from './account'
|
|
9
|
+
import { merge } from './ability.client'
|
|
10
|
+
|
|
11
|
+
async function resolve(id: number) {
|
|
12
|
+
|
|
13
|
+
const user = await db()
|
|
14
|
+
.selectFrom('users')
|
|
15
|
+
.select(['id', 'name', 'email', 'verified'])
|
|
16
|
+
.where('id', '=', id)
|
|
17
|
+
.executeTakeFirst()
|
|
18
|
+
|
|
19
|
+
if (!user) return null
|
|
20
|
+
|
|
21
|
+
const roles = await grants(user.id)
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
...user,
|
|
25
|
+
roles: roles.map(r => r.name),
|
|
26
|
+
abilities: merge(...roles.map(r => r.abilities)),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Resolve = typeof resolve
|
|
31
|
+
|
|
32
|
+
const reset = (req: Request) => {
|
|
33
|
+
delete req.user
|
|
34
|
+
delete req.session
|
|
35
|
+
delete req.token
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Authenticates requests from bearer API tokens or session cookies. */
|
|
39
|
+
export function session(lookup?: Resolve): Middleware {
|
|
40
|
+
|
|
41
|
+
const find = lookup ?? resolve
|
|
42
|
+
|
|
43
|
+
return async (req, res, next) => {
|
|
44
|
+
|
|
45
|
+
reset(req)
|
|
46
|
+
|
|
47
|
+
// 1. Explicit bearer token for API/Mobile/CLI
|
|
48
|
+
|
|
49
|
+
const auth = req.headers.authorization
|
|
50
|
+
|
|
51
|
+
if (api(req) && auth?.startsWith('Bearer ')) {
|
|
52
|
+
|
|
53
|
+
const authz = await bearer(auth.slice(7))
|
|
54
|
+
|
|
55
|
+
if (authz) {
|
|
56
|
+
const user = await find(authz.user)
|
|
57
|
+
if (user) {
|
|
58
|
+
req.user = user
|
|
59
|
+
req.token = { id: authz.id, abilities: authz.abilities }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return next()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2. Cookie session (SPA/Web)
|
|
67
|
+
|
|
68
|
+
const cookie = read(req)
|
|
69
|
+
|
|
70
|
+
if (cookie) {
|
|
71
|
+
|
|
72
|
+
const valid = await validate(cookie, req.headers.accept !== 'text/event-stream')
|
|
73
|
+
|
|
74
|
+
if (valid) {
|
|
75
|
+
const user = await find(valid.user)
|
|
76
|
+
if (user) {
|
|
77
|
+
req.user = user
|
|
78
|
+
req.session = { id: valid.id }
|
|
79
|
+
return next()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
clear(res)
|
|
84
|
+
return next()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
next()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Rejects unsafe cookie-auth requests without CSRF proof. */
|
|
92
|
+
export const csrf: Middleware = (req, _, next) => {
|
|
93
|
+
|
|
94
|
+
if (req.token) return next()
|
|
95
|
+
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next()
|
|
96
|
+
if (api(req) && !req.user) return next()
|
|
97
|
+
|
|
98
|
+
if (!valid(req)) throw new Forbidden('Invalid CSRF token')
|
|
99
|
+
|
|
100
|
+
next()
|
|
101
|
+
}
|