@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 +21 -21
- package/README.md +5 -5
- package/dist/createAuth.d.ts.map +1 -1
- package/dist/migrations.js +47 -47
- package/package.json +68 -68
- package/src/apiTokenRoutes.ts +82 -82
- package/src/apiTokens.ts +62 -62
- package/src/authRoutes.ts +452 -452
- package/src/context.ts +85 -85
- package/src/createAuth.ts +58 -58
- package/src/identityConfirmation.ts +50 -50
- package/src/index.ts +17 -17
- package/src/middleware.ts +123 -123
- package/src/migrations.ts +77 -77
- package/src/module.ts +6 -6
- package/src/oidc.ts +291 -291
- package/src/oidcConfig.ts +96 -96
- package/src/oidcSettings.ts +60 -60
- package/src/oidcSettingsRoutes.ts +165 -165
- package/src/roles.ts +60 -60
- package/src/routeScope.ts +50 -50
- package/src/sessions.ts +209 -209
- package/src/setupRoutes.ts +100 -100
- package/src/signInAudit.ts +43 -43
- package/src/tables.ts +59 -59
- package/src/tokenCatalogue.ts +42 -42
- package/src/totp.ts +96 -96
- package/src/twoFactor.ts +49 -49
- package/src/userRoutes.ts +144 -144
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`.
|
package/dist/createAuth.d.ts.map
CHANGED
|
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
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"}
|
package/dist/migrations.js
CHANGED
|
@@ -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.
|
|
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.
|
|
62
|
-
"@better-forge/contracts": "0.1.
|
|
63
|
-
"@better-forge/db": "0.1.
|
|
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
|
+
}
|
package/src/apiTokenRoutes.ts
CHANGED
|
@@ -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>
|