@better-forge/auth 0.1.0 → 0.1.1

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 CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 BetterStatusPage contributors
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BetterStatusPage contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
- # @better-forge/auth
2
-
3
- Users, sessions, 2FA, SSO/OIDC, roles, API tokens and sign-in routes.
4
-
5
- Status: skeleton. Depends on: `@better-forge/db`, `@better-forge/audit`.
1
+ # @better-forge/auth
2
+
3
+ Users, sessions, 2FA, SSO/OIDC, roles, API tokens and sign-in routes.
4
+
5
+ Status: skeleton. Depends on: `@better-forge/db`, `@better-forge/audit`.
@@ -1 +1 @@
1
- {"version":3,"file":"createAuth.d.ts","sourceRoot":"","sources":["../src/createAuth.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAe/C;;;GAGG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mBAuCu7E,CAAC;;;;;;;kBAA2kC,CAAC;;kBAAwB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAXxkH,qHAAqH;;;;;;;EAQxH;AAED,MAAM,MAAM,IAAI,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAA"}
1
+ {"version":3,"file":"createAuth.d.ts","sourceRoot":"","sources":["../src/createAuth.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAe/C;;;GAGG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mBAuC03E,CAAC;;;;;;;kBAAkjC,CAAC;;kBAAwB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAXl/G,qHAAqH;;;;;;;EAQxH;AAED,MAAM,MAAM,IAAI,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAA"}
@@ -2,56 +2,56 @@ import { randomBytes } from 'node:crypto';
2
2
  import bcrypt from 'bcryptjs';
3
3
  /** Users, sign-in sessions, SSO settings and API tokens. Steps run in this order. */
4
4
  export const authMigrations = [
5
- { kind: 'exec', sql: `
6
- CREATE TABLE IF NOT EXISTS users (
7
- id INTEGER PRIMARY KEY AUTOINCREMENT,
8
- email TEXT NOT NULL UNIQUE,
9
- password_hash TEXT NOT NULL,
10
- role TEXT NOT NULL DEFAULT 'admin',
11
- created_at INTEGER NOT NULL
12
- );
5
+ { kind: 'exec', sql: `
6
+ CREATE TABLE IF NOT EXISTS users (
7
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
8
+ email TEXT NOT NULL UNIQUE,
9
+ password_hash TEXT NOT NULL,
10
+ role TEXT NOT NULL DEFAULT 'admin',
11
+ created_at INTEGER NOT NULL
12
+ );
13
13
  ` },
14
- { kind: 'exec', sql: `
15
- CREATE TABLE IF NOT EXISTS oidc_settings (
16
- id INTEGER PRIMARY KEY,
17
- enabled INTEGER NOT NULL DEFAULT 0,
18
- issuer TEXT NOT NULL DEFAULT '',
19
- client_id TEXT NOT NULL DEFAULT '',
20
- client_secret TEXT NOT NULL DEFAULT '',
21
- scopes TEXT NOT NULL DEFAULT '',
22
- redirect_uri TEXT NOT NULL DEFAULT '',
23
- button_label TEXT NOT NULL DEFAULT '',
24
- allow_unverified_email INTEGER NOT NULL DEFAULT 0,
25
- disable_password_login INTEGER NOT NULL DEFAULT 0,
26
- updated_at INTEGER NOT NULL
27
- );
14
+ { kind: 'exec', sql: `
15
+ CREATE TABLE IF NOT EXISTS oidc_settings (
16
+ id INTEGER PRIMARY KEY,
17
+ enabled INTEGER NOT NULL DEFAULT 0,
18
+ issuer TEXT NOT NULL DEFAULT '',
19
+ client_id TEXT NOT NULL DEFAULT '',
20
+ client_secret TEXT NOT NULL DEFAULT '',
21
+ scopes TEXT NOT NULL DEFAULT '',
22
+ redirect_uri TEXT NOT NULL DEFAULT '',
23
+ button_label TEXT NOT NULL DEFAULT '',
24
+ allow_unverified_email INTEGER NOT NULL DEFAULT 0,
25
+ disable_password_login INTEGER NOT NULL DEFAULT 0,
26
+ updated_at INTEGER NOT NULL
27
+ );
28
28
  ` },
29
- { kind: 'exec', sql: `
30
- CREATE TABLE IF NOT EXISTS auth_sessions (
31
- id TEXT PRIMARY KEY,
32
- user_id INTEGER NOT NULL,
33
- csrf_token_hash TEXT NOT NULL,
34
- created_at INTEGER NOT NULL,
35
- last_seen_at INTEGER NOT NULL,
36
- expires_at INTEGER NOT NULL,
37
- auth_method TEXT NOT NULL DEFAULT 'password',
38
- verified_at INTEGER,
39
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
40
- );
41
- CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id);
42
- CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);
29
+ { kind: 'exec', sql: `
30
+ CREATE TABLE IF NOT EXISTS auth_sessions (
31
+ id TEXT PRIMARY KEY,
32
+ user_id INTEGER NOT NULL,
33
+ csrf_token_hash TEXT NOT NULL,
34
+ created_at INTEGER NOT NULL,
35
+ last_seen_at INTEGER NOT NULL,
36
+ expires_at INTEGER NOT NULL,
37
+ auth_method TEXT NOT NULL DEFAULT 'password',
38
+ verified_at INTEGER,
39
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
40
+ );
41
+ CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id);
42
+ CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);
43
43
  ` },
44
- { kind: 'exec', sql: `
45
- CREATE TABLE IF NOT EXISTS api_tokens (
46
- id INTEGER PRIMARY KEY AUTOINCREMENT,
47
- name TEXT NOT NULL,
48
- token_hash TEXT NOT NULL UNIQUE,
49
- prefix TEXT NOT NULL,
50
- created_at INTEGER NOT NULL,
51
- expires_at INTEGER,
52
- last_used_at INTEGER,
53
- scopes TEXT NOT NULL
54
- );
44
+ { kind: 'exec', sql: `
45
+ CREATE TABLE IF NOT EXISTS api_tokens (
46
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
47
+ name TEXT NOT NULL,
48
+ token_hash TEXT NOT NULL UNIQUE,
49
+ prefix TEXT NOT NULL,
50
+ created_at INTEGER NOT NULL,
51
+ expires_at INTEGER,
52
+ last_used_at INTEGER,
53
+ scopes TEXT NOT NULL
54
+ );
55
55
  ` },
56
56
  { kind: 'column', sql: `ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`, desc: 'users.must_change_password' },
57
57
  { kind: 'column', sql: `ALTER TABLE users ADD COLUMN totp_secret TEXT`, desc: 'users.totp_secret' },
package/package.json CHANGED
@@ -1,68 +1,68 @@
1
- {
2
- "name": "@better-forge/auth",
3
- "version": "0.1.0",
4
- "description": "Users, sessions, 2FA, SSO/OIDC, roles, API tokens and sign-in routes.",
5
- "license": "MIT",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/BElluu/Better-Forge.git",
9
- "directory": "packages/auth"
10
- },
11
- "homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/auth#readme",
12
- "bugs": {
13
- "url": "https://github.com/BElluu/Better-Forge/issues"
14
- },
15
- "publishConfig": {
16
- "access": "public"
17
- },
18
- "type": "module",
19
- "main": "./dist/index.js",
20
- "types": "./dist/index.d.ts",
21
- "exports": {
22
- ".": {
23
- "types": "./dist/index.d.ts",
24
- "import": "./dist/index.js"
25
- },
26
- "./package.json": "./package.json"
27
- },
28
- "files": [
29
- "dist",
30
- "src",
31
- "!src/**/*.test.*"
32
- ],
33
- "scripts": {
34
- "build": "tsc -p tsconfig.json",
35
- "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
36
- },
37
- "peerDependencies": {
38
- "@fastify/cookie": "^11.1.1",
39
- "@fastify/jwt": "^10.1.0",
40
- "bcryptjs": "^3.0.2",
41
- "drizzle-orm": "^0.45.2",
42
- "fastify": "^5.3.2",
43
- "openid-client": "^6.8.8",
44
- "qrcode": "^1.5.4",
45
- "@fastify/rate-limit": "^10.3.0"
46
- },
47
- "devDependencies": {
48
- "@types/node": "^26.6.2",
49
- "@types/qrcode": "^1.5.6",
50
- "@fastify/cookie": "^11.1.1",
51
- "@fastify/jwt": "^10.1.0",
52
- "bcryptjs": "^3.0.2",
53
- "drizzle-orm": "^0.45.2",
54
- "fastify": "^5.3.2",
55
- "openid-client": "^6.8.8",
56
- "qrcode": "^1.5.4",
57
- "typescript": "^5.7.3",
58
- "@fastify/rate-limit": "^10.3.0"
59
- },
60
- "dependencies": {
61
- "@better-forge/audit": "0.1.0",
62
- "@better-forge/contracts": "0.1.0",
63
- "@better-forge/db": "0.1.0"
64
- },
65
- "engines": {
66
- "node": ">=26.10.0"
67
- }
68
- }
1
+ {
2
+ "name": "@better-forge/auth",
3
+ "version": "0.1.1",
4
+ "description": "Users, sessions, 2FA, SSO/OIDC, roles, API tokens and sign-in routes.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/BElluu/Better-Forge.git",
9
+ "directory": "packages/auth"
10
+ },
11
+ "homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/auth#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/BElluu/Better-Forge/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "src",
31
+ "!src/**/*.test.*"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json",
35
+ "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
36
+ },
37
+ "peerDependencies": {
38
+ "@fastify/cookie": "^11.1.1",
39
+ "@fastify/jwt": "^10.1.0",
40
+ "bcryptjs": "^3.0.2",
41
+ "drizzle-orm": "^0.45.2",
42
+ "fastify": "^5.3.2",
43
+ "openid-client": "^6.8.8",
44
+ "qrcode": "^1.5.4",
45
+ "@fastify/rate-limit": "^10.3.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^26.6.2",
49
+ "@types/qrcode": "^1.5.6",
50
+ "@fastify/cookie": "^11.1.1",
51
+ "@fastify/jwt": "^10.1.0",
52
+ "bcryptjs": "^3.0.2",
53
+ "drizzle-orm": "^0.45.2",
54
+ "fastify": "^5.3.2",
55
+ "openid-client": "^6.8.8",
56
+ "qrcode": "^1.5.4",
57
+ "typescript": "^5.7.3",
58
+ "@fastify/rate-limit": "^10.3.0"
59
+ },
60
+ "dependencies": {
61
+ "@better-forge/audit": "0.1.1",
62
+ "@better-forge/contracts": "0.1.1",
63
+ "@better-forge/db": "0.1.1"
64
+ },
65
+ "engines": {
66
+ "node": ">=26.10.0"
67
+ }
68
+ }
@@ -1,82 +1,82 @@
1
- import type { FastifyInstance } from 'fastify'
2
- import { eq } from 'drizzle-orm'
3
- import { apiTokens } from './tables.js'
4
- import { requestIdentity } from './middleware.js'
5
- import type { AuthOptions } from './context.js'
6
- import type { ApiTokens } from './apiTokens.js'
7
- import type { IdentityConfirmation } from './identityConfirmation.js'
8
-
9
- const MAX_NAME_LENGTH = 80
10
-
11
- const MAX_EXPIRY_DAYS = 3650
12
-
13
- /** A token that nobody asked to live forever should not. */
14
- const DEFAULT_EXPIRY_DAYS = 90
15
-
16
- const DAY_MS = 24 * 60 * 60 * 1000
17
-
18
- export function createApiTokenRoutes(ctx: AuthOptions, tokens: ApiTokens, identityConfirmation: IdentityConfirmation) {
19
- const { db, audit } = ctx
20
- const hooks = ctx.hooks ?? {}
21
- const { generateApiToken, parseScopes } = tokens
22
- const { confirmIdentity } = identityConfirmation
23
-
24
- async function apiTokenRoutes(app: FastifyInstance) {
25
- type TokenRow = typeof apiTokens.$inferSelect
26
-
27
- function publicToken(row: TokenRow) {
28
- const { tokenHash: _tokenHash, scopes, ...rest } = row
29
- return { ...rest, scopes: parseScopes(scopes) }
30
- }
31
-
32
- app.get('/', async () => (await db.select().from(apiTokens)).map(publicToken))
33
-
34
- // `expiresInDays` left out means the default; `null` is the explicit "never".
35
- app.post<{ Body: { name?: unknown; scopes?: unknown; expiresInDays?: unknown; currentPassword?: unknown } }>('/', {
36
- config: { rateLimit: ctx.rateLimits.sensitiveAction },
37
- }, async (req, reply) => {
38
- const { name, scopes: requested, expiresInDays } = req.body ?? {}
39
- if (typeof name !== 'string' || !name.trim() || name.trim().length > MAX_NAME_LENGTH) {
40
- return reply.code(400).send({ error: `Name is required (up to ${MAX_NAME_LENGTH} characters)` })
41
- }
42
- const scopes = ctx.tokens.normalize(requested)
43
- if (!scopes) return reply.code(400).send({ error: 'scopes must be a list with at least one known permission' })
44
- if (expiresInDays !== undefined && expiresInDays !== null
45
- && (!Number.isInteger(expiresInDays) || (expiresInDays as number) < 1 || (expiresInDays as number) > MAX_EXPIRY_DAYS)) {
46
- return reply.code(400).send({ error: `expiresInDays must be a whole number from 1 to ${MAX_EXPIRY_DAYS}, or null for no expiry` })
47
- }
48
-
49
- const identity = requestIdentity(req)
50
- if (!await confirmIdentity(identity, req.body?.currentPassword, reply)) return reply
51
- const now = Date.now()
52
- const generated = generateApiToken()
53
- const days = expiresInDays === undefined ? DEFAULT_EXPIRY_DAYS : expiresInDays
54
- const row = (await db.insert(apiTokens).values({
55
- name: name.trim(),
56
- tokenHash: generated.hash,
57
- prefix: generated.prefix,
58
- scopes: JSON.stringify(scopes),
59
- createdAt: now,
60
- expiresAt: typeof days === 'number' ? now + days * DAY_MS : null,
61
- }).returning())[0]!
62
- await audit.write(audit.actor(identity), 'create', 'api_token', row.id, row.name,
63
- audit.snapshot({ name: row.name, permissions: scopes.join(', '), prefix: row.prefix, expiresAt: row.expiresAt }))
64
- // The only time the plaintext token leaves the server.
65
- return { ...publicToken(row), token: generated.token }
66
- })
67
-
68
- app.delete<{ Params: { id: string } }>('/:id', async (req, reply) => {
69
- const id = Number(req.params.id)
70
- const existing = Number.isSafeInteger(id) ? (await db.select().from(apiTokens).where(eq(apiTokens.id, id)))[0] : undefined
71
- if (!existing) return reply.code(404).send({ error: 'Not found' })
72
- await db.delete(apiTokens).where(eq(apiTokens.id, id))
73
- // A live event stream opened with the token must not outlast it.
74
- hooks.onSessionsRevoked?.({ sessionId: `api-token:${id}` })
75
- await audit.write(audit.actor(requestIdentity(req)), 'delete', 'api_token', id, existing.name,
76
- audit.snapshot({ name: existing.name, permissions: parseScopes(existing.scopes).join(', '), prefix: existing.prefix }))
77
- return reply.code(204).send()
78
- })
79
- }
80
-
81
- return { apiTokenRoutes }
82
- }
1
+ import type { FastifyInstance } from 'fastify'
2
+ import { eq } from 'drizzle-orm'
3
+ import { apiTokens } from './tables.js'
4
+ import { requestIdentity } from './middleware.js'
5
+ import type { AuthOptions } from './context.js'
6
+ import type { ApiTokens } from './apiTokens.js'
7
+ import type { IdentityConfirmation } from './identityConfirmation.js'
8
+
9
+ const MAX_NAME_LENGTH = 80
10
+
11
+ const MAX_EXPIRY_DAYS = 3650
12
+
13
+ /** A token that nobody asked to live forever should not. */
14
+ const DEFAULT_EXPIRY_DAYS = 90
15
+
16
+ const DAY_MS = 24 * 60 * 60 * 1000
17
+
18
+ export function createApiTokenRoutes(ctx: AuthOptions, tokens: ApiTokens, identityConfirmation: IdentityConfirmation) {
19
+ const { db, audit } = ctx
20
+ const hooks = ctx.hooks ?? {}
21
+ const { generateApiToken, parseScopes } = tokens
22
+ const { confirmIdentity } = identityConfirmation
23
+
24
+ async function apiTokenRoutes(app: FastifyInstance) {
25
+ type TokenRow = typeof apiTokens.$inferSelect
26
+
27
+ function publicToken(row: TokenRow) {
28
+ const { tokenHash: _tokenHash, scopes, ...rest } = row
29
+ return { ...rest, scopes: parseScopes(scopes) }
30
+ }
31
+
32
+ app.get('/', async () => (await db.select().from(apiTokens)).map(publicToken))
33
+
34
+ // `expiresInDays` left out means the default; `null` is the explicit "never".
35
+ app.post<{ Body: { name?: unknown; scopes?: unknown; expiresInDays?: unknown; currentPassword?: unknown } }>('/', {
36
+ config: { rateLimit: ctx.rateLimits.sensitiveAction },
37
+ }, async (req, reply) => {
38
+ const { name, scopes: requested, expiresInDays } = req.body ?? {}
39
+ if (typeof name !== 'string' || !name.trim() || name.trim().length > MAX_NAME_LENGTH) {
40
+ return reply.code(400).send({ error: `Name is required (up to ${MAX_NAME_LENGTH} characters)` })
41
+ }
42
+ const scopes = ctx.tokens.normalize(requested)
43
+ if (!scopes) return reply.code(400).send({ error: 'scopes must be a list with at least one known permission' })
44
+ if (expiresInDays !== undefined && expiresInDays !== null
45
+ && (!Number.isInteger(expiresInDays) || (expiresInDays as number) < 1 || (expiresInDays as number) > MAX_EXPIRY_DAYS)) {
46
+ return reply.code(400).send({ error: `expiresInDays must be a whole number from 1 to ${MAX_EXPIRY_DAYS}, or null for no expiry` })
47
+ }
48
+
49
+ const identity = requestIdentity(req)
50
+ if (!await confirmIdentity(identity, req.body?.currentPassword, reply)) return reply
51
+ const now = Date.now()
52
+ const generated = generateApiToken()
53
+ const days = expiresInDays === undefined ? DEFAULT_EXPIRY_DAYS : expiresInDays
54
+ const row = (await db.insert(apiTokens).values({
55
+ name: name.trim(),
56
+ tokenHash: generated.hash,
57
+ prefix: generated.prefix,
58
+ scopes: JSON.stringify(scopes),
59
+ createdAt: now,
60
+ expiresAt: typeof days === 'number' ? now + days * DAY_MS : null,
61
+ }).returning())[0]!
62
+ await audit.write(audit.actor(identity), 'create', 'api_token', row.id, row.name,
63
+ audit.snapshot({ name: row.name, permissions: scopes.join(', '), prefix: row.prefix, expiresAt: row.expiresAt }))
64
+ // The only time the plaintext token leaves the server.
65
+ return { ...publicToken(row), token: generated.token }
66
+ })
67
+
68
+ app.delete<{ Params: { id: string } }>('/:id', async (req, reply) => {
69
+ const id = Number(req.params.id)
70
+ const existing = Number.isSafeInteger(id) ? (await db.select().from(apiTokens).where(eq(apiTokens.id, id)))[0] : undefined
71
+ if (!existing) return reply.code(404).send({ error: 'Not found' })
72
+ await db.delete(apiTokens).where(eq(apiTokens.id, id))
73
+ // A live event stream opened with the token must not outlast it.
74
+ hooks.onSessionsRevoked?.({ sessionId: `api-token:${id}` })
75
+ await audit.write(audit.actor(requestIdentity(req)), 'delete', 'api_token', id, existing.name,
76
+ audit.snapshot({ name: existing.name, permissions: parseScopes(existing.scopes).join(', '), prefix: existing.prefix }))
77
+ return reply.code(204).send()
78
+ })
79
+ }
80
+
81
+ return { apiTokenRoutes }
82
+ }
package/src/apiTokens.ts CHANGED
@@ -1,62 +1,62 @@
1
- import { createHash, randomBytes } from 'node:crypto'
2
- import { eq } from 'drizzle-orm'
3
- import { apiTokens } from './tables.js'
4
- import type { AuthOptions } from './context.js'
5
-
6
- export function createApiTokens(ctx: AuthOptions) {
7
- const { db, apiTokenPrefix: API_TOKEN_PREFIX } = ctx
8
- const parseScopes = ctx.tokens.parse
9
-
10
- const LAST_USED_RESOLUTION_MS = 60_000
11
-
12
- const RATE_LIMIT_PER_MINUTE = 300
13
-
14
- const RATE_WINDOW_MS = 60_000
15
-
16
- function isApiToken(value: string): boolean {
17
- return value.startsWith(API_TOKEN_PREFIX)
18
- }
19
-
20
- function hashApiToken(token: string): string {
21
- return createHash('sha256').update(token).digest('hex')
22
- }
23
-
24
- /** 256 random bits, so a plain SHA-256 is enough to store them. The plaintext is shown once and never kept. */
25
- function generateApiToken(): { token: string; hash: string; prefix: string } {
26
- const token = `${API_TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`
27
- return { token, hash: hashApiToken(token), prefix: token.slice(0, API_TOKEN_PREFIX.length + 6) }
28
- }
29
-
30
- /** The token row when the token may be used right now: it exists and has not expired. */
31
- async function findUsableApiToken(token: string) {
32
- const row = (await db.select().from(apiTokens).where(eq(apiTokens.tokenHash, hashApiToken(token))))[0]
33
- if (!row) return null
34
- const now = Date.now()
35
- if (row.expiresAt !== null && row.expiresAt <= now) return null
36
- if (row.lastUsedAt === null || now - row.lastUsedAt >= LAST_USED_RESOLUTION_MS) {
37
- await db.update(apiTokens).set({ lastUsedAt: now }).where(eq(apiTokens.id, row.id))
38
- }
39
- return row
40
- }
41
-
42
- const windows = new Map<number, { startedAt: number; count: number }>()
43
-
44
- /** Fixed-window limit per token, so one runaway script cannot starve the API. Returns false once the budget is spent. */
45
- function consumeApiTokenBudget(tokenId: number, now = Date.now()): boolean {
46
- const current = windows.get(tokenId)
47
- if (!current || now - current.startedAt >= RATE_WINDOW_MS) {
48
- windows.set(tokenId, { startedAt: now, count: 1 })
49
- return true
50
- }
51
- current.count += 1
52
- return current.count <= RATE_LIMIT_PER_MINUTE
53
- }
54
-
55
- function resetApiTokenBudgets(): void {
56
- windows.clear()
57
- }
58
-
59
- return { parseScopes, isApiToken, hashApiToken, generateApiToken, findUsableApiToken, consumeApiTokenBudget, resetApiTokenBudgets }
60
- }
61
-
62
- export type ApiTokens = ReturnType<typeof createApiTokens>
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import { eq } from 'drizzle-orm'
3
+ import { apiTokens } from './tables.js'
4
+ import type { AuthOptions } from './context.js'
5
+
6
+ export function createApiTokens(ctx: AuthOptions) {
7
+ const { db, apiTokenPrefix: API_TOKEN_PREFIX } = ctx
8
+ const parseScopes = ctx.tokens.parse
9
+
10
+ const LAST_USED_RESOLUTION_MS = 60_000
11
+
12
+ const RATE_LIMIT_PER_MINUTE = 300
13
+
14
+ const RATE_WINDOW_MS = 60_000
15
+
16
+ function isApiToken(value: string): boolean {
17
+ return value.startsWith(API_TOKEN_PREFIX)
18
+ }
19
+
20
+ function hashApiToken(token: string): string {
21
+ return createHash('sha256').update(token).digest('hex')
22
+ }
23
+
24
+ /** 256 random bits, so a plain SHA-256 is enough to store them. The plaintext is shown once and never kept. */
25
+ function generateApiToken(): { token: string; hash: string; prefix: string } {
26
+ const token = `${API_TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`
27
+ return { token, hash: hashApiToken(token), prefix: token.slice(0, API_TOKEN_PREFIX.length + 6) }
28
+ }
29
+
30
+ /** The token row when the token may be used right now: it exists and has not expired. */
31
+ async function findUsableApiToken(token: string) {
32
+ const row = (await db.select().from(apiTokens).where(eq(apiTokens.tokenHash, hashApiToken(token))))[0]
33
+ if (!row) return null
34
+ const now = Date.now()
35
+ if (row.expiresAt !== null && row.expiresAt <= now) return null
36
+ if (row.lastUsedAt === null || now - row.lastUsedAt >= LAST_USED_RESOLUTION_MS) {
37
+ await db.update(apiTokens).set({ lastUsedAt: now }).where(eq(apiTokens.id, row.id))
38
+ }
39
+ return row
40
+ }
41
+
42
+ const windows = new Map<number, { startedAt: number; count: number }>()
43
+
44
+ /** Fixed-window limit per token, so one runaway script cannot starve the API. Returns false once the budget is spent. */
45
+ function consumeApiTokenBudget(tokenId: number, now = Date.now()): boolean {
46
+ const current = windows.get(tokenId)
47
+ if (!current || now - current.startedAt >= RATE_WINDOW_MS) {
48
+ windows.set(tokenId, { startedAt: now, count: 1 })
49
+ return true
50
+ }
51
+ current.count += 1
52
+ return current.count <= RATE_LIMIT_PER_MINUTE
53
+ }
54
+
55
+ function resetApiTokenBudgets(): void {
56
+ windows.clear()
57
+ }
58
+
59
+ return { parseScopes, isApiToken, hashApiToken, generateApiToken, findUsableApiToken, consumeApiTokenBudget, resetApiTokenBudgets }
60
+ }
61
+
62
+ export type ApiTokens = ReturnType<typeof createApiTokens>