@better-forge/auth 0.0.0-stage → 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 -0
- package/README.md +4 -2
- package/dist/apiTokenRoutes.d.ts +8 -0
- package/dist/apiTokenRoutes.d.ts.map +1 -0
- package/dist/apiTokenRoutes.js +67 -0
- package/dist/apiTokenRoutes.js.map +1 -0
- package/dist/apiTokens.d.ts +25 -0
- package/dist/apiTokens.d.ts.map +1 -0
- package/dist/apiTokens.js +50 -0
- package/dist/apiTokens.js.map +1 -0
- package/dist/authRoutes.d.ts +37 -0
- package/dist/authRoutes.d.ts.map +1 -0
- package/dist/authRoutes.js +403 -0
- package/dist/authRoutes.js.map +1 -0
- package/dist/context.d.ts +98 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +2 -0
- package/dist/context.js.map +1 -0
- package/dist/createAuth.d.ts +146 -0
- package/dist/createAuth.d.ts.map +1 -0
- package/dist/createAuth.js +52 -0
- package/dist/createAuth.js.map +1 -0
- package/dist/identityConfirmation.d.ts +13 -0
- package/dist/identityConfirmation.d.ts.map +1 -0
- package/dist/identityConfirmation.js +37 -0
- package/dist/identityConfirmation.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware.d.ts +41 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +100 -0
- package/dist/middleware.js.map +1 -0
- package/dist/migrations.d.ts +4 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +77 -0
- package/dist/migrations.js.map +1 -0
- package/dist/module.d.ts +8 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +5 -0
- package/dist/module.js.map +1 -0
- package/dist/oidc.d.ts +85 -0
- package/dist/oidc.d.ts.map +1 -0
- package/dist/oidc.js +213 -0
- package/dist/oidc.js.map +1 -0
- package/dist/oidcConfig.d.ts +39 -0
- package/dist/oidcConfig.d.ts.map +1 -0
- package/dist/oidcConfig.js +72 -0
- package/dist/oidcConfig.js.map +1 -0
- package/dist/oidcSettings.d.ts +15 -0
- package/dist/oidcSettings.d.ts.map +1 -0
- package/dist/oidcSettings.js +51 -0
- package/dist/oidcSettings.js.map +1 -0
- package/dist/oidcSettingsRoutes.d.ts +19 -0
- package/dist/oidcSettingsRoutes.d.ts.map +1 -0
- package/dist/oidcSettingsRoutes.js +133 -0
- package/dist/oidcSettingsRoutes.js.map +1 -0
- package/dist/roles.d.ts +25 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/roles.js +44 -0
- package/dist/roles.js.map +1 -0
- package/dist/routeScope.d.ts +34 -0
- package/dist/routeScope.d.ts.map +1 -0
- package/dist/routeScope.js +32 -0
- package/dist/routeScope.js.map +1 -0
- package/dist/sessions.d.ts +46 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +159 -0
- package/dist/sessions.js.map +1 -0
- package/dist/setupRoutes.d.ts +37 -0
- package/dist/setupRoutes.d.ts.map +1 -0
- package/dist/setupRoutes.js +70 -0
- package/dist/setupRoutes.js.map +1 -0
- package/dist/signInAudit.d.ts +19 -0
- package/dist/signInAudit.d.ts.map +1 -0
- package/dist/signInAudit.js +16 -0
- package/dist/signInAudit.js.map +1 -0
- package/dist/tables.d.ts +716 -0
- package/dist/tables.d.ts.map +1 -0
- package/dist/tables.js +56 -0
- package/dist/tables.js.map +1 -0
- package/dist/tokenCatalogue.d.ts +21 -0
- package/dist/tokenCatalogue.d.ts.map +1 -0
- package/dist/tokenCatalogue.js +27 -0
- package/dist/tokenCatalogue.js.map +1 -0
- package/dist/totp.d.ts +12 -0
- package/dist/totp.d.ts.map +1 -0
- package/dist/totp.js +93 -0
- package/dist/totp.js.map +1 -0
- package/dist/twoFactor.d.ts +10 -0
- package/dist/twoFactor.d.ts.map +1 -0
- package/dist/twoFactor.js +43 -0
- package/dist/twoFactor.js.map +1 -0
- package/dist/userRoutes.d.ts +13 -0
- package/dist/userRoutes.d.ts.map +1 -0
- package/dist/userRoutes.js +135 -0
- package/dist/userRoutes.js.map +1 -0
- package/package.json +66 -4
- package/src/apiTokenRoutes.ts +82 -0
- package/src/apiTokens.ts +62 -0
- package/src/authRoutes.ts +452 -0
- package/src/context.ts +85 -0
- package/src/createAuth.ts +58 -0
- package/src/identityConfirmation.ts +50 -0
- package/src/index.ts +17 -0
- package/src/middleware.ts +123 -0
- package/src/migrations.ts +77 -0
- package/src/module.ts +6 -0
- package/src/oidc.ts +291 -0
- package/src/oidcConfig.ts +96 -0
- package/src/oidcSettings.ts +60 -0
- package/src/oidcSettingsRoutes.ts +165 -0
- package/src/roles.ts +60 -0
- package/src/routeScope.ts +50 -0
- package/src/sessions.ts +209 -0
- package/src/setupRoutes.ts +100 -0
- package/src/signInAudit.ts +43 -0
- package/src/tables.ts +59 -0
- package/src/tokenCatalogue.ts +42 -0
- package/src/totp.ts +96 -0
- package/src/twoFactor.ts +49 -0
- package/src/userRoutes.ts +144 -0
|
@@ -0,0 +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
|
+
}
|
package/src/apiTokens.ts
ADDED
|
@@ -0,0 +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>
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import type { FastifyInstance, FastifyReply } from 'fastify'
|
|
3
|
+
import bcrypt from 'bcryptjs'
|
|
4
|
+
import QRCode from 'qrcode'
|
|
5
|
+
import { eq } from 'drizzle-orm'
|
|
6
|
+
import { users } from './tables.js'
|
|
7
|
+
import { generateRecoveryCodes, generateTotpSecret, hashRecoveryCode, totpUri, verifyTotp } from './totp.js'
|
|
8
|
+
import { ALLOW_PENDING_PASSWORD_CHANGE, requestIdentity } from './middleware.js'
|
|
9
|
+
import { passwordLoginEnabled, type OidcConfig } from './oidcConfig.js'
|
|
10
|
+
import type { AuthOptions, SignInOutcome } from './context.js'
|
|
11
|
+
import type { AuthIdentity, AuthMethod, Sessions } from './sessions.js'
|
|
12
|
+
import type { Middleware } from './middleware.js'
|
|
13
|
+
import type { TwoFactor } from './twoFactor.js'
|
|
14
|
+
import type { IdentityConfirmation } from './identityConfirmation.js'
|
|
15
|
+
import type { CredentialDenialCode, SignInAudit, SignInMethod } from './signInAudit.js'
|
|
16
|
+
import type { OidcSettings } from './oidcSettings.js'
|
|
17
|
+
import { OIDC_CONFIRMATION_MAX_AGE_SECONDS, type Oidc, type OidcDecision, type OidcDenialCode, type OidcFlowState } from './oidc.js'
|
|
18
|
+
|
|
19
|
+
interface OidcFlowClaims extends OidcFlowState {
|
|
20
|
+
/**
|
|
21
|
+
* 'oidc-flow' signs a user in; 'oidc-test' only reports the outcome to the administrator who started it;
|
|
22
|
+
* 'oidc-confirm' re-authenticates the user of an existing SSO session before a sensitive action.
|
|
23
|
+
*/
|
|
24
|
+
purpose?: 'oidc-flow' | 'oidc-test' | 'oidc-confirm'
|
|
25
|
+
adminUserId?: number
|
|
26
|
+
/** 'oidc-confirm': the session to mark as confirmed and its user. The callback has no session cookie (SameSite=Strict). */
|
|
27
|
+
sessionId?: string
|
|
28
|
+
userId?: number
|
|
29
|
+
/** 'oidc-flow' started from a screen that named a `returnTo` target: the application's `signInRedirect` hook decides where it ends. */
|
|
30
|
+
returnTo?: string | undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface TwoFactorChallenge {
|
|
34
|
+
purpose?: string
|
|
35
|
+
userId?: number
|
|
36
|
+
authMethod?: AuthMethod
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createSignInRoutes(ctx: AuthOptions, parts: {
|
|
40
|
+
sessions: Sessions
|
|
41
|
+
middleware: Middleware
|
|
42
|
+
twoFactor: TwoFactor
|
|
43
|
+
identityConfirmation: IdentityConfirmation
|
|
44
|
+
signInAudit: SignInAudit
|
|
45
|
+
oidcSettings: OidcSettings
|
|
46
|
+
oidc: Oidc
|
|
47
|
+
}) {
|
|
48
|
+
const { db, audit, secretBox, cookies, urls, basePath, totpIssuer } = ctx
|
|
49
|
+
const returnTargets = ctx.returnTargets ?? []
|
|
50
|
+
const hooks = ctx.hooks ?? {}
|
|
51
|
+
const { requireAuth } = parts.middleware
|
|
52
|
+
const { clearAuthCookies, createAuthSession, revokeSession, revokeUserSessions } = parts.sessions
|
|
53
|
+
const { verifySecondFactor } = parts.twoFactor
|
|
54
|
+
const { confirmIdentity, markSessionVerified } = parts.identityConfirmation
|
|
55
|
+
const { auditSignIn, auditSignInDenial } = parts.signInAudit
|
|
56
|
+
const { getOidcConfig, getOidcTestConfig } = parts.oidcSettings
|
|
57
|
+
const { auditOidcDenial, beginOidcLogin, evaluateOidcClaims, exchangeOidcCode, linkOidcIdentity, provisionSsoUser, runOidcTest, storeOidcTestResult } = parts.oidc
|
|
58
|
+
const OIDC_FLOW_COOKIE = cookies.oidcFlow
|
|
59
|
+
const OIDC_FLOW_PATH = `${basePath}/oidc`
|
|
60
|
+
const OIDC_FAILED_REDIRECT = `${urls.signIn}?error=oidc_failed`
|
|
61
|
+
const SSO_CONFIRM_PAGE = urls.ssoConfirm
|
|
62
|
+
const TWO_FACTOR_CHALLENGE_COOKIE = cookies.twoFactorChallenge
|
|
63
|
+
const TWO_FACTOR_CHALLENGE_PATH = `${basePath}/2fa`
|
|
64
|
+
|
|
65
|
+
const OIDC_FLOW_SECONDS = 10 * 60
|
|
66
|
+
|
|
67
|
+
const TWO_FACTOR_CHALLENGE_SECONDS = 5 * 60
|
|
68
|
+
|
|
69
|
+
// Denials about the identity itself show "no account matches"; the rest show the generic failure.
|
|
70
|
+
const ACCOUNT_DENIALS = new Set<OidcDenialCode>(['no_email_claim', 'email_not_verified', 'no_matching_account', 'subject_mismatch'])
|
|
71
|
+
|
|
72
|
+
const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, 1000)
|
|
73
|
+
|
|
74
|
+
/** Redirects to the provider, keeping the PKCE verifier, state and nonce in a short-lived signed cookie. */
|
|
75
|
+
async function startOidcFlow(
|
|
76
|
+
app: FastifyInstance, reply: FastifyReply, cfg: OidcConfig,
|
|
77
|
+
extra: Pick<OidcFlowClaims, 'purpose' | 'adminUserId' | 'sessionId' | 'userId' | 'returnTo'>,
|
|
78
|
+
) {
|
|
79
|
+
const { url, flow } = await beginOidcLogin(cfg, { confirm: extra.purpose === 'oidc-confirm' })
|
|
80
|
+
reply.setCookie(OIDC_FLOW_COOKIE, app.jwt.sign({ ...extra, ...flow }, { expiresIn: OIDC_FLOW_SECONDS }), {
|
|
81
|
+
path: OIDC_FLOW_PATH,
|
|
82
|
+
httpOnly: true,
|
|
83
|
+
secure: ctx.secureCookies(),
|
|
84
|
+
// Lax, not Strict: the IdP redirects back from another site and Strict cookies are not sent then.
|
|
85
|
+
sameSite: 'lax',
|
|
86
|
+
maxAge: OIDC_FLOW_SECONDS,
|
|
87
|
+
})
|
|
88
|
+
return reply.redirect(url)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function callbackUrlFor(cfg: OidcConfig, query: URLSearchParams): URL {
|
|
92
|
+
const url = new URL(cfg.redirectUri)
|
|
93
|
+
url.search = query.toString()
|
|
94
|
+
return url
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function publicSession(identity: AuthIdentity) {
|
|
98
|
+
const { sessionId: _sessionId, ...safe } = identity
|
|
99
|
+
return safe
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function verifyPassword(user: typeof users.$inferSelect, password: string): Promise<boolean> {
|
|
103
|
+
return bcrypt.compare(password, user.passwordHash)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Records a refused password sign-in, or a refused 2FA step of either kind of sign-in. */
|
|
107
|
+
function denySignIn(method: SignInMethod, code: CredentialDenialCode, reason: string, email?: string) {
|
|
108
|
+
return auditSignInDenial(method, { code, reason, email })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Makes a pending temporary password unusable once its user signed in through SSO: they did not need it, and the
|
|
113
|
+
* administrator who handed it out still knows it. They can set a password of their own later in Settings.
|
|
114
|
+
*/
|
|
115
|
+
async function revokeTemporaryPassword(user: typeof users.$inferSelect): Promise<typeof users.$inferSelect> {
|
|
116
|
+
if (!user.mustChangePassword) return user
|
|
117
|
+
const passwordHash = await bcrypt.hash(randomBytes(32).toString('base64url'), 10)
|
|
118
|
+
await db.update(users).set({ passwordHash, mustChangePassword: 0 }).where(eq(users.id, user.id))
|
|
119
|
+
await audit.write({ userId: user.id, userEmail: user.email }, 'update', 'user-security', user.id, user.email, { temporaryPasswordRevoked: true })
|
|
120
|
+
return { ...user, passwordHash, mustChangePassword: 0 }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Where a finished or failed sign-in goes. The application may send it elsewhere (a `returnTo` target it listed, or
|
|
125
|
+
* a role that belongs on another page); otherwise it ends at `urls.home`, and errors show on `urls.signIn`.
|
|
126
|
+
*/
|
|
127
|
+
function signInRedirect(flow: Pick<OidcFlowClaims, 'returnTo'>, outcome: SignInOutcome): string {
|
|
128
|
+
const custom = hooks.signInRedirect?.(flow, outcome)
|
|
129
|
+
if (custom) return custom
|
|
130
|
+
if (outcome === 'two-factor') return `${urls.signIn}?two-factor=sso`
|
|
131
|
+
if ('error' in outcome) return `${urls.signIn}?error=${outcome.error}`
|
|
132
|
+
return urls.home
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Ends the SSO confirmation popup; the page tells the opening window through a BroadcastChannel. */
|
|
136
|
+
function confirmRedirect(reply: FastifyReply, ok: boolean) {
|
|
137
|
+
return reply.redirect(`${SSO_CONFIRM_PAGE}?status=${ok ? 'ok' : 'failed'}`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function finishLogin(app: FastifyInstance, reply: FastifyReply, user: typeof users.$inferSelect, authMethod: AuthMethod = 'password') {
|
|
141
|
+
const identity = await createAuthSession(app, reply, user, authMethod)
|
|
142
|
+
return publicSession(identity)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function authRoutes(app: FastifyInstance) {
|
|
146
|
+
app.get('/config', async () => {
|
|
147
|
+
const { config } = await getOidcConfig()
|
|
148
|
+
// `loginUrl` starts SSO on the redirect URI's host: the flow cookie is only sent back to the host that set it,
|
|
149
|
+
// so starting on another one (127.0.0.1 vs localhost, an internal name vs PUBLIC_URL) would lose it.
|
|
150
|
+
return {
|
|
151
|
+
passwordLogin: passwordLoginEnabled(config),
|
|
152
|
+
oidc: config ? { label: config.buttonLabel, loginUrl: `${new URL(config.redirectUri).origin}${OIDC_FLOW_PATH}/login` } : null,
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
app.get<{ Querystring: { returnTo?: string } }>('/oidc/login', { config: { rateLimit: ctx.rateLimits.signIn } }, async (req, reply) => {
|
|
157
|
+
const { config: cfg } = await getOidcConfig()
|
|
158
|
+
if (!cfg) return reply.code(404).send({ error: 'OIDC is not configured' })
|
|
159
|
+
const returnTo = req.query.returnTo
|
|
160
|
+
const flow: Pick<OidcFlowClaims, 'purpose' | 'returnTo'> = returnTo && returnTargets.includes(returnTo)
|
|
161
|
+
? { purpose: 'oidc-flow', returnTo }
|
|
162
|
+
: { purpose: 'oidc-flow' }
|
|
163
|
+
try {
|
|
164
|
+
return await startOidcFlow(app, reply, cfg, flow)
|
|
165
|
+
} catch (error) {
|
|
166
|
+
req.log.error({ err: error }, 'OIDC discovery failed')
|
|
167
|
+
await auditOidcDenial(cfg, { code: 'discovery_failed', reason: `OIDC discovery against the issuer failed: ${errorText(error)}` })
|
|
168
|
+
return reply.redirect(signInRedirect(flow, { error: 'oidc_failed' }))
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// Opened in a popup by the admin UI when a sensitive action needs an SSO session to be confirmed.
|
|
173
|
+
app.get('/oidc/confirm', { preHandler: requireAuth, config: { rateLimit: ctx.rateLimits.signIn } }, async (req, reply) => {
|
|
174
|
+
const identity = requestIdentity(req)
|
|
175
|
+
const { config: cfg } = await getOidcConfig()
|
|
176
|
+
if (identity.authMethod !== 'oidc' || !cfg) return confirmRedirect(reply, false)
|
|
177
|
+
try {
|
|
178
|
+
return await startOidcFlow(app, reply, cfg, { purpose: 'oidc-confirm', sessionId: identity.sessionId, userId: identity.userId })
|
|
179
|
+
} catch (error) {
|
|
180
|
+
req.log.error({ err: error }, 'OIDC discovery failed')
|
|
181
|
+
await auditOidcDenial(cfg, { code: 'discovery_failed', reason: `OIDC discovery against the issuer failed: ${errorText(error)}` })
|
|
182
|
+
return confirmRedirect(reply, false)
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
app.get('/oidc/callback', { config: { rateLimit: ctx.rateLimits.signIn } }, async (req, reply) => {
|
|
187
|
+
const token = req.cookies[OIDC_FLOW_COOKIE]
|
|
188
|
+
reply.clearCookie(OIDC_FLOW_COOKIE, { path: OIDC_FLOW_PATH })
|
|
189
|
+
const query = new URL(req.url, 'http://localhost').searchParams
|
|
190
|
+
let flow: OidcFlowClaims | null = null
|
|
191
|
+
try {
|
|
192
|
+
if (token) flow = app.jwt.verify<OidcFlowClaims>(token)
|
|
193
|
+
} catch { /* expired or tampered: handled as missing */ }
|
|
194
|
+
|
|
195
|
+
if (flow?.purpose === 'oidc-test' && Number.isInteger(flow.adminUserId)) {
|
|
196
|
+
const cfg = await getOidcTestConfig()
|
|
197
|
+
if (!cfg) return reply.code(404).send({ error: 'OIDC is not configured' })
|
|
198
|
+
const result = await runOidcTest(cfg, callbackUrlFor(cfg, query), flow)
|
|
199
|
+
return reply.redirect(`${ctx.urls.ssoTest}?result=${storeOidcTestResult(flow.adminUserId!, result)}`)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const { config: cfg } = await getOidcConfig()
|
|
203
|
+
if (!cfg) return reply.code(404).send({ error: 'OIDC is not configured' })
|
|
204
|
+
|
|
205
|
+
if (flow?.purpose === 'oidc-confirm') {
|
|
206
|
+
if (!flow.sessionId || !Number.isInteger(flow.userId)) return confirmRedirect(reply, false)
|
|
207
|
+
try {
|
|
208
|
+
const exchanged = await exchangeOidcCode(cfg, callbackUrlFor(cfg, query), flow, OIDC_CONFIRMATION_MAX_AGE_SECONDS)
|
|
209
|
+
const decision = exchanged.denial ? { user: null, denial: exchanged.denial } as const : await evaluateOidcClaims(cfg, exchanged.claims)
|
|
210
|
+
if (!decision.user || decision.user.id !== flow.userId) {
|
|
211
|
+
const denial = decision.denial ?? {
|
|
212
|
+
code: 'confirmation_mismatch' as const,
|
|
213
|
+
reason: `The confirmation signed in as ${decision.user!.email}, not as the user of the session being confirmed.`,
|
|
214
|
+
email: decision.user!.email,
|
|
215
|
+
}
|
|
216
|
+
req.log.warn({ code: denial.code, reason: denial.reason }, 'OIDC confirmation refused')
|
|
217
|
+
await auditOidcDenial(cfg, denial)
|
|
218
|
+
return confirmRedirect(reply, false)
|
|
219
|
+
}
|
|
220
|
+
if (decision.link) await linkOidcIdentity(cfg, decision.user, decision.subject)
|
|
221
|
+
return confirmRedirect(reply, await markSessionVerified(flow.sessionId, flow.userId!))
|
|
222
|
+
} catch (error) {
|
|
223
|
+
req.log.warn({ err: error }, 'OIDC confirmation failed')
|
|
224
|
+
await auditOidcDenial(cfg, { code: 'token_exchange_failed', reason: `The confirmation could not be completed: ${errorText(error)}` })
|
|
225
|
+
return confirmRedirect(reply, false)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (flow?.purpose !== 'oidc-flow') {
|
|
230
|
+
// Only audited for what looks like a real provider redirect: anyone can open the callback URL.
|
|
231
|
+
if (query.has('state')) {
|
|
232
|
+
await auditOidcDenial(cfg, {
|
|
233
|
+
code: 'flow_expired',
|
|
234
|
+
reason: 'The sign-in session cookie was missing or expired. The sign-in took longer than 10 minutes, '
|
|
235
|
+
+ 'or the redirect URI points to a different host than the one the sign-in started on (for example localhost vs 127.0.0.1).',
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
return reply.redirect(OIDC_FAILED_REDIRECT)
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const exchanged = await exchangeOidcCode(cfg, callbackUrlFor(cfg, query), flow)
|
|
242
|
+
const decision: OidcDecision = exchanged.denial ? { user: null, denial: exchanged.denial } : await evaluateOidcClaims(cfg, exchanged.claims)
|
|
243
|
+
let user: typeof users.$inferSelect
|
|
244
|
+
if (decision.user) {
|
|
245
|
+
user = decision.user
|
|
246
|
+
if (decision.link) await linkOidcIdentity(cfg, user, decision.subject)
|
|
247
|
+
} else if (decision.provision) {
|
|
248
|
+
user = await provisionSsoUser(cfg, decision.provision)
|
|
249
|
+
} else {
|
|
250
|
+
const { denial } = decision
|
|
251
|
+
req.log.warn({ code: denial.code, reason: denial.reason }, 'OIDC sign-in refused')
|
|
252
|
+
await auditOidcDenial(cfg, denial)
|
|
253
|
+
return reply.redirect(signInRedirect(flow, { error: ACCOUNT_DENIALS.has(denial.code) ? 'oidc_no_account' : 'oidc_failed' }))
|
|
254
|
+
}
|
|
255
|
+
// The IdP replaces the password. A user who enabled 2FA here still has to enter their code.
|
|
256
|
+
if (user.totpEnabled) {
|
|
257
|
+
const challenge: TwoFactorChallenge = { purpose: 'two-factor-login', userId: user.id, authMethod: 'oidc' }
|
|
258
|
+
reply.setCookie(TWO_FACTOR_CHALLENGE_COOKIE, app.jwt.sign(challenge, { expiresIn: TWO_FACTOR_CHALLENGE_SECONDS }), {
|
|
259
|
+
path: TWO_FACTOR_CHALLENGE_PATH,
|
|
260
|
+
httpOnly: true,
|
|
261
|
+
secure: ctx.secureCookies(),
|
|
262
|
+
sameSite: 'strict',
|
|
263
|
+
maxAge: TWO_FACTOR_CHALLENGE_SECONDS,
|
|
264
|
+
})
|
|
265
|
+
return reply.redirect(signInRedirect(flow, 'two-factor'))
|
|
266
|
+
}
|
|
267
|
+
const identity = await createAuthSession(app, reply, await revokeTemporaryPassword(user), 'oidc')
|
|
268
|
+
await auditSignIn(user, 'sso', false)
|
|
269
|
+
return reply.redirect(signInRedirect(flow, identity))
|
|
270
|
+
} catch (error) {
|
|
271
|
+
req.log.warn({ err: error }, 'OIDC callback failed')
|
|
272
|
+
await auditOidcDenial(cfg, { code: 'token_exchange_failed', reason: `The sign-in could not be completed: ${errorText(error)}` })
|
|
273
|
+
return reply.redirect(signInRedirect(flow, { error: 'oidc_failed' }))
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
app.post<{ Body: { email: string; password: string } }>('/login', {
|
|
278
|
+
config: { rateLimit: ctx.rateLimits.signIn },
|
|
279
|
+
}, async (req, reply) => {
|
|
280
|
+
const { email, password } = req.body
|
|
281
|
+
const typedEmail = typeof email === 'string' && email.trim() ? email.trim().slice(0, 320) : undefined
|
|
282
|
+
if (!passwordLoginEnabled((await getOidcConfig()).config)) {
|
|
283
|
+
await denySignIn('password', 'password_login_disabled', 'Password sign-in is disabled; only single sign-on is allowed.', typedEmail)
|
|
284
|
+
return reply.code(403).send({ error: 'Password sign-in is disabled' })
|
|
285
|
+
}
|
|
286
|
+
const user = (await db.select().from(users).where(eq(users.email, email)))[0]
|
|
287
|
+
if (!user) {
|
|
288
|
+
await denySignIn('password', 'no_matching_account', `No user has the email ${typedEmail ?? '(none entered)'}.`, typedEmail)
|
|
289
|
+
return reply.code(401).send({ error: 'Invalid credentials' })
|
|
290
|
+
}
|
|
291
|
+
if (!await verifyPassword(user, password)) {
|
|
292
|
+
await denySignIn('password', 'wrong_password', 'The password is incorrect.', user.email)
|
|
293
|
+
return reply.code(401).send({ error: 'Invalid credentials' })
|
|
294
|
+
}
|
|
295
|
+
if (user.totpEnabled) {
|
|
296
|
+
const challengeToken = app.jwt.sign({ purpose: 'two-factor-login', userId: user.id }, { expiresIn: '5m' })
|
|
297
|
+
return { requiresTwoFactor: true, challengeToken }
|
|
298
|
+
}
|
|
299
|
+
const session = await finishLogin(app, reply, user)
|
|
300
|
+
await auditSignIn(user, 'password', false)
|
|
301
|
+
return session
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
// A password sign-in sends its challenge in the body; an SSO sign-in left it in a cookie at the callback.
|
|
305
|
+
app.post<{ Body: { challengeToken?: string; code: string } }>('/2fa/verify', {
|
|
306
|
+
config: { rateLimit: ctx.rateLimits.signIn },
|
|
307
|
+
}, async (req, reply) => {
|
|
308
|
+
const token = req.body.challengeToken || req.cookies[TWO_FACTOR_CHALLENGE_COOKIE]
|
|
309
|
+
// Where the challenge came from says which sign-in it continues, even when it can no longer be read.
|
|
310
|
+
const method: SignInMethod = req.body.challengeToken ? 'password' : 'sso'
|
|
311
|
+
let challenge: TwoFactorChallenge
|
|
312
|
+
try { challenge = app.jwt.verify(token ?? '') }
|
|
313
|
+
catch {
|
|
314
|
+
await denySignIn(method, 'two_factor_expired', 'The authentication code was entered more than 5 minutes after the first sign-in step, or the challenge was missing or invalid.')
|
|
315
|
+
return reply.code(401).send({ error: 'Two-factor challenge expired' })
|
|
316
|
+
}
|
|
317
|
+
if (challenge.purpose !== 'two-factor-login' || !Number.isInteger(challenge.userId)) {
|
|
318
|
+
return reply.code(401).send({ error: 'Invalid two-factor challenge' })
|
|
319
|
+
}
|
|
320
|
+
const found = (await db.select().from(users).where(eq(users.id, challenge.userId!)))[0]
|
|
321
|
+
if (!found || !await verifySecondFactor(found, req.body.code)) {
|
|
322
|
+
if (found) await denySignIn(method, 'invalid_two_factor_code', 'The authentication or recovery code is incorrect.', found.email)
|
|
323
|
+
return reply.code(401).send({ error: 'Invalid authentication code' })
|
|
324
|
+
}
|
|
325
|
+
reply.clearCookie(TWO_FACTOR_CHALLENGE_COOKIE, { path: TWO_FACTOR_CHALLENGE_PATH })
|
|
326
|
+
const authMethod: AuthMethod = challenge.authMethod === 'oidc' ? 'oidc' : 'password'
|
|
327
|
+
const user = authMethod === 'oidc' ? await revokeTemporaryPassword(found) : found
|
|
328
|
+
const session = await finishLogin(app, reply, user, authMethod)
|
|
329
|
+
await auditSignIn(user, authMethod === 'oidc' ? 'sso' : 'password', true)
|
|
330
|
+
return session
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
app.get('/session', { preHandler: requireAuth, config: ALLOW_PENDING_PASSWORD_CHANGE }, async (req) => {
|
|
334
|
+
return publicSession(requestIdentity(req))
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
app.post('/logout', { preHandler: requireAuth, config: ALLOW_PENDING_PASSWORD_CHANGE }, async (req, reply) => {
|
|
338
|
+
const identity = requestIdentity(req)
|
|
339
|
+
await revokeSession(identity.sessionId)
|
|
340
|
+
clearAuthCookies(reply)
|
|
341
|
+
return reply.code(204).send()
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
app.post('/logout-all', { preHandler: requireAuth, config: ALLOW_PENDING_PASSWORD_CHANGE }, async (req, reply) => {
|
|
345
|
+
const identity = requestIdentity(req)
|
|
346
|
+
await revokeUserSessions(identity.userId)
|
|
347
|
+
clearAuthCookies(reply)
|
|
348
|
+
return reply.code(204).send()
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
app.post<{ Body: { newPassword: string; currentPassword?: string } }>('/change-password', {
|
|
352
|
+
preHandler: requireAuth,
|
|
353
|
+
config: ALLOW_PENDING_PASSWORD_CHANGE,
|
|
354
|
+
}, async (req, reply) => {
|
|
355
|
+
const { newPassword, currentPassword } = req.body
|
|
356
|
+
if (!newPassword || newPassword.length < 8 || newPassword.length > 128) {
|
|
357
|
+
return reply.code(400).send({ error: 'Password must be between 8 and 128 characters' })
|
|
358
|
+
}
|
|
359
|
+
const identity = requestIdentity(req)
|
|
360
|
+
const user = (await db.select().from(users).where(eq(users.id, identity.userId)))[0]
|
|
361
|
+
if (!user) return reply.code(404).send({ error: 'User not found' })
|
|
362
|
+
|
|
363
|
+
// The session, not the user row: an SSO session never has to replace a temporary password, so it confirms too.
|
|
364
|
+
if (!identity.mustChangePassword) {
|
|
365
|
+
if (identity.authMethod === 'password' && !currentPassword) return reply.code(400).send({ error: 'Current password is required' })
|
|
366
|
+
if (!await confirmIdentity(identity, currentPassword, reply)) return reply
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const passwordHash = await bcrypt.hash(newPassword, 10)
|
|
370
|
+
await db.update(users).set({ passwordHash, mustChangePassword: 0 }).where(eq(users.id, user.id))
|
|
371
|
+
await audit.write(
|
|
372
|
+
{ userId: identity.userId, userEmail: identity.email },
|
|
373
|
+
'update', 'user-security', identity.userId, identity.email,
|
|
374
|
+
{ passwordChanged: true },
|
|
375
|
+
)
|
|
376
|
+
await revokeUserSessions(user.id)
|
|
377
|
+
const updated = { ...user, passwordHash, mustChangePassword: 0 }
|
|
378
|
+
// An SSO user setting a password stays in an SSO session.
|
|
379
|
+
return finishLogin(app, reply, updated, identity.authMethod)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
app.post<{ Body: { currentPassword?: string } }>('/2fa/setup', { preHandler: requireAuth }, async (req, reply) => {
|
|
383
|
+
const identity = requestIdentity(req)
|
|
384
|
+
if (!await confirmIdentity(identity, req.body?.currentPassword, reply)) return reply
|
|
385
|
+
const user = (await db.select().from(users).where(eq(users.id, identity.userId)))[0]
|
|
386
|
+
if (!user) return reply.code(404).send({ error: 'User not found' })
|
|
387
|
+
if (user.totpEnabled) return reply.code(409).send({ error: 'Two-factor authentication is already enabled' })
|
|
388
|
+
const secret = generateTotpSecret()
|
|
389
|
+
const uri = totpUri(secret, user.email, totpIssuer)
|
|
390
|
+
const setupToken = app.jwt.sign({
|
|
391
|
+
purpose: 'two-factor-setup',
|
|
392
|
+
userId: user.id,
|
|
393
|
+
encryptedSecret: secretBox.encrypt(secret),
|
|
394
|
+
}, { expiresIn: '10m' })
|
|
395
|
+
const qrDataUrl = await QRCode.toDataURL(uri, {
|
|
396
|
+
errorCorrectionLevel: 'M',
|
|
397
|
+
margin: 1,
|
|
398
|
+
width: 240,
|
|
399
|
+
color: { dark: '#111827', light: '#ffffff' },
|
|
400
|
+
})
|
|
401
|
+
return { secret, uri, qrDataUrl, setupToken }
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
app.post<{ Body: { setupToken: string; code: string } }>('/2fa/enable', { preHandler: requireAuth }, async (req, reply) => {
|
|
405
|
+
const identity = requestIdentity(req)
|
|
406
|
+
let setup: { purpose?: string; userId?: number; encryptedSecret?: string }
|
|
407
|
+
try { setup = app.jwt.verify(req.body.setupToken) }
|
|
408
|
+
catch { return reply.code(400).send({ error: 'Two-factor setup expired' }) }
|
|
409
|
+
if (setup.purpose !== 'two-factor-setup' || setup.userId !== identity.userId || !setup.encryptedSecret) {
|
|
410
|
+
return reply.code(400).send({ error: 'Invalid two-factor setup' })
|
|
411
|
+
}
|
|
412
|
+
const secret = secretBox.decrypt(setup.encryptedSecret)
|
|
413
|
+
if (!verifyTotp(secret, req.body.code)) {
|
|
414
|
+
return reply.code(400).send({ error: 'Invalid authentication code' })
|
|
415
|
+
}
|
|
416
|
+
const recoveryCodes = generateRecoveryCodes()
|
|
417
|
+
await db.update(users).set({
|
|
418
|
+
totpSecret: secretBox.encrypt(secret),
|
|
419
|
+
totpEnabled: 1,
|
|
420
|
+
totpRecoveryCodes: JSON.stringify(recoveryCodes.map(hashRecoveryCode)),
|
|
421
|
+
}).where(eq(users.id, identity.userId))
|
|
422
|
+
await revokeUserSessions(identity.userId, identity.sessionId)
|
|
423
|
+
await audit.write(
|
|
424
|
+
{ userId: identity.userId, userEmail: identity.email },
|
|
425
|
+
'update', 'user-security', identity.userId, identity.email,
|
|
426
|
+
{ twoFactorEnabled: { from: false, to: true } },
|
|
427
|
+
)
|
|
428
|
+
return { recoveryCodes }
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
app.post<{ Body: { currentPassword?: string; code: string } }>('/2fa/disable', { preHandler: requireAuth }, async (req, reply) => {
|
|
432
|
+
const identity = requestIdentity(req)
|
|
433
|
+
if (!await confirmIdentity(identity, req.body?.currentPassword, reply)) return reply
|
|
434
|
+
const user = (await db.select().from(users).where(eq(users.id, identity.userId)))[0]
|
|
435
|
+
if (!user) return reply.code(404).send({ error: 'User not found' })
|
|
436
|
+
if (!user.totpEnabled) return reply.code(409).send({ error: 'Two-factor authentication is not enabled' })
|
|
437
|
+
if (!await verifySecondFactor(user, req.body.code)) {
|
|
438
|
+
return reply.code(400).send({ error: 'Invalid authentication code' })
|
|
439
|
+
}
|
|
440
|
+
await db.update(users).set({ totpSecret: null, totpEnabled: 0, totpRecoveryCodes: null }).where(eq(users.id, user.id))
|
|
441
|
+
await revokeUserSessions(user.id, identity.sessionId)
|
|
442
|
+
await audit.write(
|
|
443
|
+
{ userId: identity.userId, userEmail: identity.email },
|
|
444
|
+
'update', 'user-security', identity.userId, identity.email,
|
|
445
|
+
{ twoFactorEnabled: { from: true, to: false } },
|
|
446
|
+
)
|
|
447
|
+
return { twoFactorEnabled: false }
|
|
448
|
+
})
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return { startOidcFlow, authRoutes }
|
|
452
|
+
}
|