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/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
+ }