@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/src/setupRoutes.ts
CHANGED
|
@@ -1,100 +1,100 @@
|
|
|
1
|
-
import type { FastifyInstance, FastifyReply } from 'fastify'
|
|
2
|
-
import bcrypt from 'bcryptjs'
|
|
3
|
-
import { eq } from 'drizzle-orm'
|
|
4
|
-
import type { SqliteDatabase } from '@better-forge/db'
|
|
5
|
-
import { users } from './tables.js'
|
|
6
|
-
|
|
7
|
-
export interface SetupRoutesOptions<Identity extends { sessionId?: string }> {
|
|
8
|
-
db: SqliteDatabase
|
|
9
|
-
/** Runs `work` so that all of it is written, or none of it. */
|
|
10
|
-
withTransaction: <T>(work: () => Promise<T>) => Promise<T>
|
|
11
|
-
isSetupComplete: () => boolean
|
|
12
|
-
/** Records that setup is done. Called as the last step inside the transaction: if it throws, the administrator is not kept. */
|
|
13
|
-
writeSetupComplete: () => void
|
|
14
|
-
/** Takes the record back, when the transaction fails to commit after it was written. */
|
|
15
|
-
removeSetupMarker: () => void
|
|
16
|
-
/** Opens the database and brings its tables up to date; called once the request has been checked. */
|
|
17
|
-
prepareDatabase: () => void
|
|
18
|
-
/** Signs the new administrator in. */
|
|
19
|
-
createSession: (app: FastifyInstance, reply: FastifyReply, user: typeof users.$inferSelect) => Promise<Identity>
|
|
20
|
-
/** Rate limit of `POST /complete` (the config the rate limit plugin understands). */
|
|
21
|
-
rateLimit: Record<string, unknown>
|
|
22
|
-
/** Writes whatever else the application needs on first run, inside the same transaction as the administrator. */
|
|
23
|
-
onSetup?: () => Promise<void>
|
|
24
|
-
/** Starts the work that runs only once the application is set up. */
|
|
25
|
-
startBackgroundServices?: () => void
|
|
26
|
-
/** Lets setup run although accounts already exist, for an installation that is filled with accounts before its first run. Checked on each request. */
|
|
27
|
-
allowExistingUsers?: () => boolean
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* The first-run routes: whether the application still needs setup, and the one request that creates the administrator
|
|
32
|
-
* and finishes it. Setup is refused once it is done, and only one attempt runs at a time.
|
|
33
|
-
*/
|
|
34
|
-
export function createSetupRoutes<Identity extends { sessionId?: string }>(options: SetupRoutesOptions<Identity>) {
|
|
35
|
-
const { db } = options
|
|
36
|
-
let inProgress = false
|
|
37
|
-
|
|
38
|
-
return async function setupRoutes(app: FastifyInstance, overrides: { startBackgroundServices?: () => void } = {}) {
|
|
39
|
-
app.get('/status', async () => ({ needsSetup: !options.isSetupComplete() }))
|
|
40
|
-
|
|
41
|
-
app.post<{ Body: { email: string; password: string } }>('/complete', {
|
|
42
|
-
config: { rateLimit: options.rateLimit },
|
|
43
|
-
}, async (req, reply) => {
|
|
44
|
-
if (options.isSetupComplete()) {
|
|
45
|
-
return reply.code(409).send({ error: 'Setup already completed' })
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const { email, password } = req.body ?? {}
|
|
49
|
-
if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
|
|
50
|
-
return reply.code(400).send({ error: 'Email and password are required' })
|
|
51
|
-
}
|
|
52
|
-
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
53
|
-
return reply.code(400).send({ error: 'A valid email address is required' })
|
|
54
|
-
}
|
|
55
|
-
if (password.length < 8 || password.length > 128) {
|
|
56
|
-
return reply.code(400).send({ error: 'Password must be between 8 and 128 characters' })
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (inProgress) {
|
|
60
|
-
return reply.code(409).send({ error: 'Setup is already in progress' })
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
inProgress = true
|
|
64
|
-
try {
|
|
65
|
-
// Initialize DB and run migrations now that we have a confirmed DB type
|
|
66
|
-
options.prepareDatabase()
|
|
67
|
-
|
|
68
|
-
const hash = await bcrypt.hash(password, 10)
|
|
69
|
-
let setupMarkerWritten = false
|
|
70
|
-
const user = await options.withTransaction(async () => {
|
|
71
|
-
// The marker can be missing while the data is not (a lost volume, a restore). Accounts that already
|
|
72
|
-
// exist mean setup was done: never create or overwrite an administrator over them.
|
|
73
|
-
if (!options.allowExistingUsers?.() && (await db.select({ id: users.id }).from(users).limit(1)).length > 0) return null
|
|
74
|
-
|
|
75
|
-
await db.insert(users).values({ email, passwordHash: hash, role: 'admin', createdAt: Date.now() })
|
|
76
|
-
|
|
77
|
-
await options.onSetup?.()
|
|
78
|
-
|
|
79
|
-
const admin = (await db.select().from(users).where(eq(users.email, email)))[0]!
|
|
80
|
-
// Last step: if writing the marker fails the admin insert rolls back with it.
|
|
81
|
-
options.writeSetupComplete()
|
|
82
|
-
setupMarkerWritten = true
|
|
83
|
-
return admin
|
|
84
|
-
}).catch((error: unknown) => {
|
|
85
|
-
// COMMIT itself failed after the marker was written — undo it so setup can be retried.
|
|
86
|
-
if (setupMarkerWritten) options.removeSetupMarker()
|
|
87
|
-
throw error
|
|
88
|
-
})
|
|
89
|
-
if (!user) return reply.code(409).send({ error: 'Setup already completed' })
|
|
90
|
-
const startServices = overrides.startBackgroundServices ?? options.startBackgroundServices
|
|
91
|
-
startServices?.()
|
|
92
|
-
const identity = await options.createSession(app, reply, user)
|
|
93
|
-
const { sessionId: _sessionId, ...safeIdentity } = identity
|
|
94
|
-
return safeIdentity
|
|
95
|
-
} finally {
|
|
96
|
-
inProgress = false
|
|
97
|
-
}
|
|
98
|
-
})
|
|
99
|
-
}
|
|
100
|
-
}
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify'
|
|
2
|
+
import bcrypt from 'bcryptjs'
|
|
3
|
+
import { eq } from 'drizzle-orm'
|
|
4
|
+
import type { SqliteDatabase } from '@better-forge/db'
|
|
5
|
+
import { users } from './tables.js'
|
|
6
|
+
|
|
7
|
+
export interface SetupRoutesOptions<Identity extends { sessionId?: string }> {
|
|
8
|
+
db: SqliteDatabase
|
|
9
|
+
/** Runs `work` so that all of it is written, or none of it. */
|
|
10
|
+
withTransaction: <T>(work: () => Promise<T>) => Promise<T>
|
|
11
|
+
isSetupComplete: () => boolean
|
|
12
|
+
/** Records that setup is done. Called as the last step inside the transaction: if it throws, the administrator is not kept. */
|
|
13
|
+
writeSetupComplete: () => void
|
|
14
|
+
/** Takes the record back, when the transaction fails to commit after it was written. */
|
|
15
|
+
removeSetupMarker: () => void
|
|
16
|
+
/** Opens the database and brings its tables up to date; called once the request has been checked. */
|
|
17
|
+
prepareDatabase: () => void
|
|
18
|
+
/** Signs the new administrator in. */
|
|
19
|
+
createSession: (app: FastifyInstance, reply: FastifyReply, user: typeof users.$inferSelect) => Promise<Identity>
|
|
20
|
+
/** Rate limit of `POST /complete` (the config the rate limit plugin understands). */
|
|
21
|
+
rateLimit: Record<string, unknown>
|
|
22
|
+
/** Writes whatever else the application needs on first run, inside the same transaction as the administrator. */
|
|
23
|
+
onSetup?: () => Promise<void>
|
|
24
|
+
/** Starts the work that runs only once the application is set up. */
|
|
25
|
+
startBackgroundServices?: () => void
|
|
26
|
+
/** Lets setup run although accounts already exist, for an installation that is filled with accounts before its first run. Checked on each request. */
|
|
27
|
+
allowExistingUsers?: () => boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The first-run routes: whether the application still needs setup, and the one request that creates the administrator
|
|
32
|
+
* and finishes it. Setup is refused once it is done, and only one attempt runs at a time.
|
|
33
|
+
*/
|
|
34
|
+
export function createSetupRoutes<Identity extends { sessionId?: string }>(options: SetupRoutesOptions<Identity>) {
|
|
35
|
+
const { db } = options
|
|
36
|
+
let inProgress = false
|
|
37
|
+
|
|
38
|
+
return async function setupRoutes(app: FastifyInstance, overrides: { startBackgroundServices?: () => void } = {}) {
|
|
39
|
+
app.get('/status', async () => ({ needsSetup: !options.isSetupComplete() }))
|
|
40
|
+
|
|
41
|
+
app.post<{ Body: { email: string; password: string } }>('/complete', {
|
|
42
|
+
config: { rateLimit: options.rateLimit },
|
|
43
|
+
}, async (req, reply) => {
|
|
44
|
+
if (options.isSetupComplete()) {
|
|
45
|
+
return reply.code(409).send({ error: 'Setup already completed' })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { email, password } = req.body ?? {}
|
|
49
|
+
if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
|
|
50
|
+
return reply.code(400).send({ error: 'Email and password are required' })
|
|
51
|
+
}
|
|
52
|
+
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
53
|
+
return reply.code(400).send({ error: 'A valid email address is required' })
|
|
54
|
+
}
|
|
55
|
+
if (password.length < 8 || password.length > 128) {
|
|
56
|
+
return reply.code(400).send({ error: 'Password must be between 8 and 128 characters' })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (inProgress) {
|
|
60
|
+
return reply.code(409).send({ error: 'Setup is already in progress' })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
inProgress = true
|
|
64
|
+
try {
|
|
65
|
+
// Initialize DB and run migrations now that we have a confirmed DB type
|
|
66
|
+
options.prepareDatabase()
|
|
67
|
+
|
|
68
|
+
const hash = await bcrypt.hash(password, 10)
|
|
69
|
+
let setupMarkerWritten = false
|
|
70
|
+
const user = await options.withTransaction(async () => {
|
|
71
|
+
// The marker can be missing while the data is not (a lost volume, a restore). Accounts that already
|
|
72
|
+
// exist mean setup was done: never create or overwrite an administrator over them.
|
|
73
|
+
if (!options.allowExistingUsers?.() && (await db.select({ id: users.id }).from(users).limit(1)).length > 0) return null
|
|
74
|
+
|
|
75
|
+
await db.insert(users).values({ email, passwordHash: hash, role: 'admin', createdAt: Date.now() })
|
|
76
|
+
|
|
77
|
+
await options.onSetup?.()
|
|
78
|
+
|
|
79
|
+
const admin = (await db.select().from(users).where(eq(users.email, email)))[0]!
|
|
80
|
+
// Last step: if writing the marker fails the admin insert rolls back with it.
|
|
81
|
+
options.writeSetupComplete()
|
|
82
|
+
setupMarkerWritten = true
|
|
83
|
+
return admin
|
|
84
|
+
}).catch((error: unknown) => {
|
|
85
|
+
// COMMIT itself failed after the marker was written — undo it so setup can be retried.
|
|
86
|
+
if (setupMarkerWritten) options.removeSetupMarker()
|
|
87
|
+
throw error
|
|
88
|
+
})
|
|
89
|
+
if (!user) return reply.code(409).send({ error: 'Setup already completed' })
|
|
90
|
+
const startServices = overrides.startBackgroundServices ?? options.startBackgroundServices
|
|
91
|
+
startServices?.()
|
|
92
|
+
const identity = await options.createSession(app, reply, user)
|
|
93
|
+
const { sessionId: _sessionId, ...safeIdentity } = identity
|
|
94
|
+
return safeIdentity
|
|
95
|
+
} finally {
|
|
96
|
+
inProgress = false
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/signInAudit.ts
CHANGED
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
import type { AuthOptions } from './context.js'
|
|
2
|
-
|
|
3
|
-
/** How the user tried to sign in, as recorded in `diff.method` of 'sign_in' audit entries. */
|
|
4
|
-
export type SignInMethod = 'password' | 'sso'
|
|
5
|
-
|
|
6
|
-
/** Why a password, or the authentication-code step of any sign-in, was refused. SSO refusals use the OIDC codes. */
|
|
7
|
-
export type CredentialDenialCode =
|
|
8
|
-
| 'password_login_disabled'
|
|
9
|
-
| 'no_matching_account'
|
|
10
|
-
| 'wrong_password'
|
|
11
|
-
| 'two_factor_expired'
|
|
12
|
-
| 'invalid_two_factor_code'
|
|
13
|
-
|
|
14
|
-
export interface SignInDenial {
|
|
15
|
-
code: string
|
|
16
|
-
reason: string
|
|
17
|
-
email?: string | undefined
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function createSignInAudit(ctx: AuthOptions) {
|
|
21
|
-
const { audit } = ctx
|
|
22
|
-
|
|
23
|
-
/** Records a successful sign-in; `twoFactor` says whether an authentication code was part of it. */
|
|
24
|
-
async function auditSignIn(user: { id: number; email: string }, method: SignInMethod, twoFactor: boolean): Promise<void> {
|
|
25
|
-
await audit.write({ userId: user.id, userEmail: user.email }, 'allow', 'sign_in', user.id, user.email, { method, twoFactor })
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Records a refused sign-in. Nobody is signed in, so the actor is user 0 with the email that was entered or that
|
|
30
|
-
* the provider asserted, when there was one. The user only ever sees a generic message; this holds the reason.
|
|
31
|
-
*/
|
|
32
|
-
async function auditSignInDenial(method: SignInMethod, denial: SignInDenial, extra: Record<string, unknown> = {}): Promise<void> {
|
|
33
|
-
await audit.write(
|
|
34
|
-
{ userId: 0, userEmail: denial.email ?? `unknown (${method === 'sso' ? 'SSO' : 'password'})` },
|
|
35
|
-
'deny', 'sign_in', null, denial.email ?? (method === 'sso' ? 'SSO sign-in' : 'Password sign-in'),
|
|
36
|
-
{ method, code: denial.code, reason: denial.reason, ...extra },
|
|
37
|
-
)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return { auditSignIn, auditSignInDenial }
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export type SignInAudit = ReturnType<typeof createSignInAudit>
|
|
1
|
+
import type { AuthOptions } from './context.js'
|
|
2
|
+
|
|
3
|
+
/** How the user tried to sign in, as recorded in `diff.method` of 'sign_in' audit entries. */
|
|
4
|
+
export type SignInMethod = 'password' | 'sso'
|
|
5
|
+
|
|
6
|
+
/** Why a password, or the authentication-code step of any sign-in, was refused. SSO refusals use the OIDC codes. */
|
|
7
|
+
export type CredentialDenialCode =
|
|
8
|
+
| 'password_login_disabled'
|
|
9
|
+
| 'no_matching_account'
|
|
10
|
+
| 'wrong_password'
|
|
11
|
+
| 'two_factor_expired'
|
|
12
|
+
| 'invalid_two_factor_code'
|
|
13
|
+
|
|
14
|
+
export interface SignInDenial {
|
|
15
|
+
code: string
|
|
16
|
+
reason: string
|
|
17
|
+
email?: string | undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createSignInAudit(ctx: AuthOptions) {
|
|
21
|
+
const { audit } = ctx
|
|
22
|
+
|
|
23
|
+
/** Records a successful sign-in; `twoFactor` says whether an authentication code was part of it. */
|
|
24
|
+
async function auditSignIn(user: { id: number; email: string }, method: SignInMethod, twoFactor: boolean): Promise<void> {
|
|
25
|
+
await audit.write({ userId: user.id, userEmail: user.email }, 'allow', 'sign_in', user.id, user.email, { method, twoFactor })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Records a refused sign-in. Nobody is signed in, so the actor is user 0 with the email that was entered or that
|
|
30
|
+
* the provider asserted, when there was one. The user only ever sees a generic message; this holds the reason.
|
|
31
|
+
*/
|
|
32
|
+
async function auditSignInDenial(method: SignInMethod, denial: SignInDenial, extra: Record<string, unknown> = {}): Promise<void> {
|
|
33
|
+
await audit.write(
|
|
34
|
+
{ userId: 0, userEmail: denial.email ?? `unknown (${method === 'sso' ? 'SSO' : 'password'})` },
|
|
35
|
+
'deny', 'sign_in', null, denial.email ?? (method === 'sso' ? 'SSO sign-in' : 'Password sign-in'),
|
|
36
|
+
{ method, code: denial.code, reason: denial.reason, ...extra },
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { auditSignIn, auditSignInDenial }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type SignInAudit = ReturnType<typeof createSignInAudit>
|
package/src/tables.ts
CHANGED
|
@@ -1,59 +1,59 @@
|
|
|
1
|
-
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
|
|
2
|
-
|
|
3
|
-
export const users = sqliteTable('users', {
|
|
4
|
-
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
5
|
-
email: text('email').notNull().unique(),
|
|
6
|
-
passwordHash: text('password_hash').notNull(),
|
|
7
|
-
role: text('role').notNull().default('admin'),
|
|
8
|
-
createdAt: integer('created_at').notNull(),
|
|
9
|
-
// Additive columns (added via ALTER TABLE, must stay at end for sqlite-proxy position mapping)
|
|
10
|
-
mustChangePassword: integer('must_change_password').notNull().default(0),
|
|
11
|
-
totpSecret: text('totp_secret'),
|
|
12
|
-
totpEnabled: integer('totp_enabled').notNull().default(0),
|
|
13
|
-
totpRecoveryCodes: text('totp_recovery_codes'),
|
|
14
|
-
// SSO identity bound on the first OIDC sign-in; later sign-ins match on it instead of the email.
|
|
15
|
-
oidcIssuer: text('oidc_issuer'),
|
|
16
|
-
oidcSubject: text('oidc_subject'),
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
export const authSessions = sqliteTable('auth_sessions', {
|
|
20
|
-
id: text('id').primaryKey(),
|
|
21
|
-
userId: integer('user_id').notNull(),
|
|
22
|
-
csrfTokenHash: text('csrf_token_hash').notNull(),
|
|
23
|
-
createdAt: integer('created_at').notNull(),
|
|
24
|
-
lastSeenAt: integer('last_seen_at').notNull(),
|
|
25
|
-
expiresAt: integer('expires_at').notNull(),
|
|
26
|
-
// 'password' or 'oidc'. An SSO session never asks for the temporary password to be changed.
|
|
27
|
-
authMethod: text('auth_method').notNull().default('password'),
|
|
28
|
-
// When the user last proved who they are in this session: the sign-in, or a later SSO confirmation.
|
|
29
|
-
verifiedAt: integer('verified_at'),
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
/** Long-lived bearer credentials for scripts and CI. Only the SHA-256 of the token is stored; revoking deletes the row. */
|
|
33
|
-
export const apiTokens = sqliteTable('api_tokens', {
|
|
34
|
-
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
35
|
-
name: text('name').notNull(),
|
|
36
|
-
tokenHash: text('token_hash').notNull().unique(),
|
|
37
|
-
/** Leading characters of the token, so it can be recognised in the list. */
|
|
38
|
-
prefix: text('prefix').notNull(),
|
|
39
|
-
createdAt: integer('created_at').notNull(),
|
|
40
|
-
expiresAt: integer('expires_at'),
|
|
41
|
-
lastUsedAt: integer('last_used_at'),
|
|
42
|
-
/** JSON list of what the token may do, e.g. `["notes:write","tags:read"]` (the permissions are defined by the application). */
|
|
43
|
-
scopes: text('scopes').notNull(),
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
export const oidcSettings = sqliteTable('oidc_settings', {
|
|
47
|
-
id: integer('id').primaryKey(),
|
|
48
|
-
enabled: integer('enabled').notNull().default(0),
|
|
49
|
-
issuer: text('issuer').notNull().default(''),
|
|
50
|
-
clientId: text('client_id').notNull().default(''),
|
|
51
|
-
/** Stored encrypted by the application's secret box; empty when no secret is set. */
|
|
52
|
-
clientSecret: text('client_secret').notNull().default(''),
|
|
53
|
-
scopes: text('scopes').notNull().default(''),
|
|
54
|
-
redirectUri: text('redirect_uri').notNull().default(''),
|
|
55
|
-
buttonLabel: text('button_label').notNull().default(''),
|
|
56
|
-
allowUnverifiedEmail: integer('allow_unverified_email').notNull().default(0),
|
|
57
|
-
disablePasswordLogin: integer('disable_password_login').notNull().default(0),
|
|
58
|
-
updatedAt: integer('updated_at').notNull(),
|
|
59
|
-
})
|
|
1
|
+
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
|
|
3
|
+
export const users = sqliteTable('users', {
|
|
4
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
5
|
+
email: text('email').notNull().unique(),
|
|
6
|
+
passwordHash: text('password_hash').notNull(),
|
|
7
|
+
role: text('role').notNull().default('admin'),
|
|
8
|
+
createdAt: integer('created_at').notNull(),
|
|
9
|
+
// Additive columns (added via ALTER TABLE, must stay at end for sqlite-proxy position mapping)
|
|
10
|
+
mustChangePassword: integer('must_change_password').notNull().default(0),
|
|
11
|
+
totpSecret: text('totp_secret'),
|
|
12
|
+
totpEnabled: integer('totp_enabled').notNull().default(0),
|
|
13
|
+
totpRecoveryCodes: text('totp_recovery_codes'),
|
|
14
|
+
// SSO identity bound on the first OIDC sign-in; later sign-ins match on it instead of the email.
|
|
15
|
+
oidcIssuer: text('oidc_issuer'),
|
|
16
|
+
oidcSubject: text('oidc_subject'),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export const authSessions = sqliteTable('auth_sessions', {
|
|
20
|
+
id: text('id').primaryKey(),
|
|
21
|
+
userId: integer('user_id').notNull(),
|
|
22
|
+
csrfTokenHash: text('csrf_token_hash').notNull(),
|
|
23
|
+
createdAt: integer('created_at').notNull(),
|
|
24
|
+
lastSeenAt: integer('last_seen_at').notNull(),
|
|
25
|
+
expiresAt: integer('expires_at').notNull(),
|
|
26
|
+
// 'password' or 'oidc'. An SSO session never asks for the temporary password to be changed.
|
|
27
|
+
authMethod: text('auth_method').notNull().default('password'),
|
|
28
|
+
// When the user last proved who they are in this session: the sign-in, or a later SSO confirmation.
|
|
29
|
+
verifiedAt: integer('verified_at'),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/** Long-lived bearer credentials for scripts and CI. Only the SHA-256 of the token is stored; revoking deletes the row. */
|
|
33
|
+
export const apiTokens = sqliteTable('api_tokens', {
|
|
34
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
35
|
+
name: text('name').notNull(),
|
|
36
|
+
tokenHash: text('token_hash').notNull().unique(),
|
|
37
|
+
/** Leading characters of the token, so it can be recognised in the list. */
|
|
38
|
+
prefix: text('prefix').notNull(),
|
|
39
|
+
createdAt: integer('created_at').notNull(),
|
|
40
|
+
expiresAt: integer('expires_at'),
|
|
41
|
+
lastUsedAt: integer('last_used_at'),
|
|
42
|
+
/** JSON list of what the token may do, e.g. `["notes:write","tags:read"]` (the permissions are defined by the application). */
|
|
43
|
+
scopes: text('scopes').notNull(),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
export const oidcSettings = sqliteTable('oidc_settings', {
|
|
47
|
+
id: integer('id').primaryKey(),
|
|
48
|
+
enabled: integer('enabled').notNull().default(0),
|
|
49
|
+
issuer: text('issuer').notNull().default(''),
|
|
50
|
+
clientId: text('client_id').notNull().default(''),
|
|
51
|
+
/** Stored encrypted by the application's secret box; empty when no secret is set. */
|
|
52
|
+
clientSecret: text('client_secret').notNull().default(''),
|
|
53
|
+
scopes: text('scopes').notNull().default(''),
|
|
54
|
+
redirectUri: text('redirect_uri').notNull().default(''),
|
|
55
|
+
buttonLabel: text('button_label').notNull().default(''),
|
|
56
|
+
allowUnverifiedEmail: integer('allow_unverified_email').notNull().default(0),
|
|
57
|
+
disablePasswordLogin: integer('disable_password_login').notNull().default(0),
|
|
58
|
+
updatedAt: integer('updated_at').notNull(),
|
|
59
|
+
})
|
package/src/tokenCatalogue.ts
CHANGED
|
@@ -1,42 +1,42 @@
|
|
|
1
|
-
import type { TokenPreset, TokenResource } from '@better-forge/contracts'
|
|
2
|
-
import { VAULT_USE_SCOPE, normalizeScopes } from '@better-forge/contracts'
|
|
3
|
-
|
|
4
|
-
export interface TokenCatalogue {
|
|
5
|
-
/** Every permission a token can hold, in the order they are listed: `<part>:read`, `<part>:write`, then the extra ones. */
|
|
6
|
-
readonly scopes: readonly string[]
|
|
7
|
-
readonly resources: readonly TokenResource[]
|
|
8
|
-
readonly presets: readonly TokenPreset[]
|
|
9
|
-
/** A clean list of known permissions (reading added wherever writing is granted), or null if one is unknown or none is given. */
|
|
10
|
-
normalize(input: unknown): string[] | null
|
|
11
|
-
/** The permissions stored with a token. Anything that is not a known permission is dropped. */
|
|
12
|
-
parse(stored: string): string[]
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* The permissions an API token can be given. A part is a group of routes (`belongsTo(scope, part)` ties routes to
|
|
17
|
-
* it); `extraScopes` are single permissions that belong to no part, such as the one to use vault secrets.
|
|
18
|
-
*/
|
|
19
|
-
export function createTokenCatalogue(options: {
|
|
20
|
-
resources: readonly TokenResource[]
|
|
21
|
-
extraScopes?: readonly string[]
|
|
22
|
-
presets?: (scopes: readonly string[]) => readonly TokenPreset[]
|
|
23
|
-
}): TokenCatalogue {
|
|
24
|
-
const scopes = [
|
|
25
|
-
...options.resources.flatMap((resource) => resource.levels.map((level) => `${resource.key}:${level}`)),
|
|
26
|
-
...(options.extraScopes ?? [VAULT_USE_SCOPE]),
|
|
27
|
-
]
|
|
28
|
-
return {
|
|
29
|
-
scopes,
|
|
30
|
-
resources: options.resources,
|
|
31
|
-
presets: options.presets?.(scopes) ?? [],
|
|
32
|
-
normalize: (input) => normalizeScopes(input, scopes),
|
|
33
|
-
parse(stored) {
|
|
34
|
-
try {
|
|
35
|
-
const parsed: unknown = JSON.parse(stored)
|
|
36
|
-
return Array.isArray(parsed) ? parsed.filter((scope): scope is string => typeof scope === 'string' && scopes.includes(scope)) : []
|
|
37
|
-
} catch {
|
|
38
|
-
return []
|
|
39
|
-
}
|
|
40
|
-
},
|
|
41
|
-
}
|
|
42
|
-
}
|
|
1
|
+
import type { TokenPreset, TokenResource } from '@better-forge/contracts'
|
|
2
|
+
import { VAULT_USE_SCOPE, normalizeScopes } from '@better-forge/contracts'
|
|
3
|
+
|
|
4
|
+
export interface TokenCatalogue {
|
|
5
|
+
/** Every permission a token can hold, in the order they are listed: `<part>:read`, `<part>:write`, then the extra ones. */
|
|
6
|
+
readonly scopes: readonly string[]
|
|
7
|
+
readonly resources: readonly TokenResource[]
|
|
8
|
+
readonly presets: readonly TokenPreset[]
|
|
9
|
+
/** A clean list of known permissions (reading added wherever writing is granted), or null if one is unknown or none is given. */
|
|
10
|
+
normalize(input: unknown): string[] | null
|
|
11
|
+
/** The permissions stored with a token. Anything that is not a known permission is dropped. */
|
|
12
|
+
parse(stored: string): string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The permissions an API token can be given. A part is a group of routes (`belongsTo(scope, part)` ties routes to
|
|
17
|
+
* it); `extraScopes` are single permissions that belong to no part, such as the one to use vault secrets.
|
|
18
|
+
*/
|
|
19
|
+
export function createTokenCatalogue(options: {
|
|
20
|
+
resources: readonly TokenResource[]
|
|
21
|
+
extraScopes?: readonly string[]
|
|
22
|
+
presets?: (scopes: readonly string[]) => readonly TokenPreset[]
|
|
23
|
+
}): TokenCatalogue {
|
|
24
|
+
const scopes = [
|
|
25
|
+
...options.resources.flatMap((resource) => resource.levels.map((level) => `${resource.key}:${level}`)),
|
|
26
|
+
...(options.extraScopes ?? [VAULT_USE_SCOPE]),
|
|
27
|
+
]
|
|
28
|
+
return {
|
|
29
|
+
scopes,
|
|
30
|
+
resources: options.resources,
|
|
31
|
+
presets: options.presets?.(scopes) ?? [],
|
|
32
|
+
normalize: (input) => normalizeScopes(input, scopes),
|
|
33
|
+
parse(stored) {
|
|
34
|
+
try {
|
|
35
|
+
const parsed: unknown = JSON.parse(stored)
|
|
36
|
+
return Array.isArray(parsed) ? parsed.filter((scope): scope is string => typeof scope === 'string' && scopes.includes(scope)) : []
|
|
37
|
+
} catch {
|
|
38
|
+
return []
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
}
|