@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/context.ts
CHANGED
|
@@ -1,85 +1,85 @@
|
|
|
1
|
-
import type { DatabaseSync } from 'node:sqlite'
|
|
2
|
-
// Loaded for the request and reply properties they add (`cookies`, `jwt`, `user`, route `rateLimit` config).
|
|
3
|
-
import type {} from '@fastify/cookie'
|
|
4
|
-
import type {} from '@fastify/jwt'
|
|
5
|
-
import type {} from '@fastify/rate-limit'
|
|
6
|
-
import type { Audit } from '@better-forge/audit'
|
|
7
|
-
import type { SqliteDatabase } from '@better-forge/db'
|
|
8
|
-
import type { RoleRegistry } from './roles.js'
|
|
9
|
-
import type { TokenCatalogue } from './tokenCatalogue.js'
|
|
10
|
-
|
|
11
|
-
/** Which sessions were ended, so the application can close what they had open (for example live connections). */
|
|
12
|
-
export type SessionsRevoked =
|
|
13
|
-
| { sessionId: string }
|
|
14
|
-
| { userId: number; exceptSessionId?: string | undefined }
|
|
15
|
-
|
|
16
|
-
export interface UnknownSsoUser {
|
|
17
|
-
email: string
|
|
18
|
-
subject: string
|
|
19
|
-
issuer: string
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export type SignInOutcome = { role: string } | { error: string } | 'two-factor'
|
|
23
|
-
|
|
24
|
-
export interface AuthHooks {
|
|
25
|
-
/** Called after sessions were deleted, whatever the reason (sign-out, password reset, role change, ...). */
|
|
26
|
-
onSessionsRevoked?: (match: SessionsRevoked) => void
|
|
27
|
-
/**
|
|
28
|
-
* An SSO sign-in succeeded at the provider but no account matches. Return the role of an account to create for
|
|
29
|
-
* this person, or null to refuse. Only called when the provider's identity is verified; without this hook nobody
|
|
30
|
-
* is created.
|
|
31
|
-
*/
|
|
32
|
-
resolveUnknownSsoUser?: (user: UnknownSsoUser) => Promise<{ role: string } | null>
|
|
33
|
-
/**
|
|
34
|
-
* Where a sign-in goes next, for one that started with a `returnTo` target the application listed in
|
|
35
|
-
* `returnTargets`, or for a role that belongs somewhere else than `urls.home`. Return undefined for the default.
|
|
36
|
-
*/
|
|
37
|
-
signInRedirect?: (flow: { returnTo?: string | undefined }, outcome: SignInOutcome) => string | undefined
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** A `@fastify/rate-limit` route configuration. Passed through untouched, so a group id can be shared with other routes. */
|
|
41
|
-
export interface RateLimitConfig {
|
|
42
|
-
groupId?: string
|
|
43
|
-
max: number
|
|
44
|
-
timeWindow: string | number
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Everything the auth module needs from the application. All of it is passed in: the module reads no globals, so
|
|
49
|
-
* two applications (or two tests) can each have their own.
|
|
50
|
-
*/
|
|
51
|
-
export interface AuthOptions {
|
|
52
|
-
/** Database handle and the raw connection (for transactions). Both are used lazily, so they may be opened after this is created. */
|
|
53
|
-
db: SqliteDatabase
|
|
54
|
-
sqlite: DatabaseSync
|
|
55
|
-
audit: Pick<Audit, 'write' | 'actor' | 'diffObjects' | 'snapshot'>
|
|
56
|
-
roles: RoleRegistry
|
|
57
|
-
tokens: TokenCatalogue
|
|
58
|
-
/** Encrypts and decrypts secrets the module stores (the SSO client secret, authenticator secrets). */
|
|
59
|
-
secretBox: { encrypt(plain: string): string; decrypt(stored: string): string }
|
|
60
|
-
cookies: { session: string; csrf: string; oidcFlow: string; twoFactorChallenge: string }
|
|
61
|
-
/** Prefix that tells an API token from a session token in a Bearer header. */
|
|
62
|
-
apiTokenPrefix: string
|
|
63
|
-
/** Name an authenticator app shows next to the account. */
|
|
64
|
-
totpIssuer: string
|
|
65
|
-
/** Where the sign-in routes are mounted. The cookie paths and the SSO callback derive from it. */
|
|
66
|
-
basePath: string
|
|
67
|
-
urls: {
|
|
68
|
-
/** Where a failed sign-in sends the browser, e.g. `/admin/login`. */
|
|
69
|
-
signIn: string
|
|
70
|
-
/** Where a finished sign-in goes by default. */
|
|
71
|
-
home: string
|
|
72
|
-
/** The page that closes the SSO confirmation popup. */
|
|
73
|
-
ssoConfirm: string
|
|
74
|
-
/** The page that shows the result of an SSO test. */
|
|
75
|
-
ssoTest: string
|
|
76
|
-
}
|
|
77
|
-
/** The address the application is reached at, from the environment; '' when unknown. */
|
|
78
|
-
publicUrl: (env: NodeJS.ProcessEnv) => string
|
|
79
|
-
/** Whether cookies are marked Secure. Evaluated on every response. */
|
|
80
|
-
secureCookies: () => boolean
|
|
81
|
-
rateLimits: { signIn: RateLimitConfig; sensitiveAction: RateLimitConfig }
|
|
82
|
-
/** `returnTo` values a sign-in may start with; the application's `signInRedirect` hook turns them into a destination. */
|
|
83
|
-
returnTargets?: readonly string[]
|
|
84
|
-
hooks?: AuthHooks
|
|
85
|
-
}
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite'
|
|
2
|
+
// Loaded for the request and reply properties they add (`cookies`, `jwt`, `user`, route `rateLimit` config).
|
|
3
|
+
import type {} from '@fastify/cookie'
|
|
4
|
+
import type {} from '@fastify/jwt'
|
|
5
|
+
import type {} from '@fastify/rate-limit'
|
|
6
|
+
import type { Audit } from '@better-forge/audit'
|
|
7
|
+
import type { SqliteDatabase } from '@better-forge/db'
|
|
8
|
+
import type { RoleRegistry } from './roles.js'
|
|
9
|
+
import type { TokenCatalogue } from './tokenCatalogue.js'
|
|
10
|
+
|
|
11
|
+
/** Which sessions were ended, so the application can close what they had open (for example live connections). */
|
|
12
|
+
export type SessionsRevoked =
|
|
13
|
+
| { sessionId: string }
|
|
14
|
+
| { userId: number; exceptSessionId?: string | undefined }
|
|
15
|
+
|
|
16
|
+
export interface UnknownSsoUser {
|
|
17
|
+
email: string
|
|
18
|
+
subject: string
|
|
19
|
+
issuer: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type SignInOutcome = { role: string } | { error: string } | 'two-factor'
|
|
23
|
+
|
|
24
|
+
export interface AuthHooks {
|
|
25
|
+
/** Called after sessions were deleted, whatever the reason (sign-out, password reset, role change, ...). */
|
|
26
|
+
onSessionsRevoked?: (match: SessionsRevoked) => void
|
|
27
|
+
/**
|
|
28
|
+
* An SSO sign-in succeeded at the provider but no account matches. Return the role of an account to create for
|
|
29
|
+
* this person, or null to refuse. Only called when the provider's identity is verified; without this hook nobody
|
|
30
|
+
* is created.
|
|
31
|
+
*/
|
|
32
|
+
resolveUnknownSsoUser?: (user: UnknownSsoUser) => Promise<{ role: string } | null>
|
|
33
|
+
/**
|
|
34
|
+
* Where a sign-in goes next, for one that started with a `returnTo` target the application listed in
|
|
35
|
+
* `returnTargets`, or for a role that belongs somewhere else than `urls.home`. Return undefined for the default.
|
|
36
|
+
*/
|
|
37
|
+
signInRedirect?: (flow: { returnTo?: string | undefined }, outcome: SignInOutcome) => string | undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A `@fastify/rate-limit` route configuration. Passed through untouched, so a group id can be shared with other routes. */
|
|
41
|
+
export interface RateLimitConfig {
|
|
42
|
+
groupId?: string
|
|
43
|
+
max: number
|
|
44
|
+
timeWindow: string | number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Everything the auth module needs from the application. All of it is passed in: the module reads no globals, so
|
|
49
|
+
* two applications (or two tests) can each have their own.
|
|
50
|
+
*/
|
|
51
|
+
export interface AuthOptions {
|
|
52
|
+
/** Database handle and the raw connection (for transactions). Both are used lazily, so they may be opened after this is created. */
|
|
53
|
+
db: SqliteDatabase
|
|
54
|
+
sqlite: DatabaseSync
|
|
55
|
+
audit: Pick<Audit, 'write' | 'actor' | 'diffObjects' | 'snapshot'>
|
|
56
|
+
roles: RoleRegistry
|
|
57
|
+
tokens: TokenCatalogue
|
|
58
|
+
/** Encrypts and decrypts secrets the module stores (the SSO client secret, authenticator secrets). */
|
|
59
|
+
secretBox: { encrypt(plain: string): string; decrypt(stored: string): string }
|
|
60
|
+
cookies: { session: string; csrf: string; oidcFlow: string; twoFactorChallenge: string }
|
|
61
|
+
/** Prefix that tells an API token from a session token in a Bearer header. */
|
|
62
|
+
apiTokenPrefix: string
|
|
63
|
+
/** Name an authenticator app shows next to the account. */
|
|
64
|
+
totpIssuer: string
|
|
65
|
+
/** Where the sign-in routes are mounted. The cookie paths and the SSO callback derive from it. */
|
|
66
|
+
basePath: string
|
|
67
|
+
urls: {
|
|
68
|
+
/** Where a failed sign-in sends the browser, e.g. `/admin/login`. */
|
|
69
|
+
signIn: string
|
|
70
|
+
/** Where a finished sign-in goes by default. */
|
|
71
|
+
home: string
|
|
72
|
+
/** The page that closes the SSO confirmation popup. */
|
|
73
|
+
ssoConfirm: string
|
|
74
|
+
/** The page that shows the result of an SSO test. */
|
|
75
|
+
ssoTest: string
|
|
76
|
+
}
|
|
77
|
+
/** The address the application is reached at, from the environment; '' when unknown. */
|
|
78
|
+
publicUrl: (env: NodeJS.ProcessEnv) => string
|
|
79
|
+
/** Whether cookies are marked Secure. Evaluated on every response. */
|
|
80
|
+
secureCookies: () => boolean
|
|
81
|
+
rateLimits: { signIn: RateLimitConfig; sensitiveAction: RateLimitConfig }
|
|
82
|
+
/** `returnTo` values a sign-in may start with; the application's `signInRedirect` hook turns them into a destination. */
|
|
83
|
+
returnTargets?: readonly string[]
|
|
84
|
+
hooks?: AuthHooks
|
|
85
|
+
}
|
package/src/createAuth.ts
CHANGED
|
@@ -1,58 +1,58 @@
|
|
|
1
|
-
import type { AuthOptions } from './context.js'
|
|
2
|
-
import { createApiTokenRoutes } from './apiTokenRoutes.js'
|
|
3
|
-
import { createApiTokens } from './apiTokens.js'
|
|
4
|
-
import { createIdentityConfirmation } from './identityConfirmation.js'
|
|
5
|
-
import { createMiddleware } from './middleware.js'
|
|
6
|
-
import { createOidc } from './oidc.js'
|
|
7
|
-
import { createOidcConfigResolver } from './oidcConfig.js'
|
|
8
|
-
import { createOidcSettings } from './oidcSettings.js'
|
|
9
|
-
import { createOidcSettingsRoutes } from './oidcSettingsRoutes.js'
|
|
10
|
-
import { createSessions } from './sessions.js'
|
|
11
|
-
import { createSignInAudit } from './signInAudit.js'
|
|
12
|
-
import { createSignInRoutes } from './authRoutes.js'
|
|
13
|
-
import { createTwoFactor } from './twoFactor.js'
|
|
14
|
-
import { createUserRoutes } from './userRoutes.js'
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Accounts, sessions, two-factor sign-in, single sign-on, API tokens and the routes that manage them. Everything is
|
|
18
|
-
* built from `options` and kept in the returned closures; nothing is shared between two calls.
|
|
19
|
-
*/
|
|
20
|
-
export function createAuth(options: AuthOptions) {
|
|
21
|
-
options.roles.freeze()
|
|
22
|
-
|
|
23
|
-
const apiTokens = createApiTokens(options)
|
|
24
|
-
const sessions = createSessions(options, apiTokens)
|
|
25
|
-
const middleware = createMiddleware(sessions, apiTokens)
|
|
26
|
-
const identityConfirmation = createIdentityConfirmation(options)
|
|
27
|
-
const twoFactor = createTwoFactor(options)
|
|
28
|
-
const signInAudit = createSignInAudit(options)
|
|
29
|
-
const oidcConfig = createOidcConfigResolver({ publicUrl: options.publicUrl, callbackPath: `${options.basePath}/oidc/callback` })
|
|
30
|
-
const oidcSettings = createOidcSettings(options, oidcConfig)
|
|
31
|
-
const oidc = createOidc(options, signInAudit)
|
|
32
|
-
|
|
33
|
-
const signIn = createSignInRoutes(options, { sessions, middleware, twoFactor, identityConfirmation, signInAudit, oidcSettings, oidc })
|
|
34
|
-
const users = createUserRoutes(options, { sessions, twoFactor, identityConfirmation })
|
|
35
|
-
const oidcSettingsRoutes = createOidcSettingsRoutes(options, { identityConfirmation, oidc, oidcSettings, resolver: oidcConfig, startOidcFlow: signIn.startOidcFlow })
|
|
36
|
-
const tokenRoutes = createApiTokenRoutes(options, apiTokens, identityConfirmation)
|
|
37
|
-
|
|
38
|
-
return {
|
|
39
|
-
sessions,
|
|
40
|
-
middleware,
|
|
41
|
-
identityConfirmation,
|
|
42
|
-
twoFactor,
|
|
43
|
-
signInAudit,
|
|
44
|
-
oidcConfig,
|
|
45
|
-
oidcSettings,
|
|
46
|
-
oidc,
|
|
47
|
-
apiTokens,
|
|
48
|
-
/** Plain plugins (not wrapped for encapsulation), so the hooks of the scope they are registered in apply to them. */
|
|
49
|
-
routes: {
|
|
50
|
-
signIn: signIn.authRoutes,
|
|
51
|
-
users: users.userRoutes,
|
|
52
|
-
oidcSettings: oidcSettingsRoutes.oidcSettingsRoutes,
|
|
53
|
-
apiTokens: tokenRoutes.apiTokenRoutes,
|
|
54
|
-
},
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export type Auth = ReturnType<typeof createAuth>
|
|
1
|
+
import type { AuthOptions } from './context.js'
|
|
2
|
+
import { createApiTokenRoutes } from './apiTokenRoutes.js'
|
|
3
|
+
import { createApiTokens } from './apiTokens.js'
|
|
4
|
+
import { createIdentityConfirmation } from './identityConfirmation.js'
|
|
5
|
+
import { createMiddleware } from './middleware.js'
|
|
6
|
+
import { createOidc } from './oidc.js'
|
|
7
|
+
import { createOidcConfigResolver } from './oidcConfig.js'
|
|
8
|
+
import { createOidcSettings } from './oidcSettings.js'
|
|
9
|
+
import { createOidcSettingsRoutes } from './oidcSettingsRoutes.js'
|
|
10
|
+
import { createSessions } from './sessions.js'
|
|
11
|
+
import { createSignInAudit } from './signInAudit.js'
|
|
12
|
+
import { createSignInRoutes } from './authRoutes.js'
|
|
13
|
+
import { createTwoFactor } from './twoFactor.js'
|
|
14
|
+
import { createUserRoutes } from './userRoutes.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Accounts, sessions, two-factor sign-in, single sign-on, API tokens and the routes that manage them. Everything is
|
|
18
|
+
* built from `options` and kept in the returned closures; nothing is shared between two calls.
|
|
19
|
+
*/
|
|
20
|
+
export function createAuth(options: AuthOptions) {
|
|
21
|
+
options.roles.freeze()
|
|
22
|
+
|
|
23
|
+
const apiTokens = createApiTokens(options)
|
|
24
|
+
const sessions = createSessions(options, apiTokens)
|
|
25
|
+
const middleware = createMiddleware(sessions, apiTokens)
|
|
26
|
+
const identityConfirmation = createIdentityConfirmation(options)
|
|
27
|
+
const twoFactor = createTwoFactor(options)
|
|
28
|
+
const signInAudit = createSignInAudit(options)
|
|
29
|
+
const oidcConfig = createOidcConfigResolver({ publicUrl: options.publicUrl, callbackPath: `${options.basePath}/oidc/callback` })
|
|
30
|
+
const oidcSettings = createOidcSettings(options, oidcConfig)
|
|
31
|
+
const oidc = createOidc(options, signInAudit)
|
|
32
|
+
|
|
33
|
+
const signIn = createSignInRoutes(options, { sessions, middleware, twoFactor, identityConfirmation, signInAudit, oidcSettings, oidc })
|
|
34
|
+
const users = createUserRoutes(options, { sessions, twoFactor, identityConfirmation })
|
|
35
|
+
const oidcSettingsRoutes = createOidcSettingsRoutes(options, { identityConfirmation, oidc, oidcSettings, resolver: oidcConfig, startOidcFlow: signIn.startOidcFlow })
|
|
36
|
+
const tokenRoutes = createApiTokenRoutes(options, apiTokens, identityConfirmation)
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
sessions,
|
|
40
|
+
middleware,
|
|
41
|
+
identityConfirmation,
|
|
42
|
+
twoFactor,
|
|
43
|
+
signInAudit,
|
|
44
|
+
oidcConfig,
|
|
45
|
+
oidcSettings,
|
|
46
|
+
oidc,
|
|
47
|
+
apiTokens,
|
|
48
|
+
/** Plain plugins (not wrapped for encapsulation), so the hooks of the scope they are registered in apply to them. */
|
|
49
|
+
routes: {
|
|
50
|
+
signIn: signIn.authRoutes,
|
|
51
|
+
users: users.userRoutes,
|
|
52
|
+
oidcSettings: oidcSettingsRoutes.oidcSettingsRoutes,
|
|
53
|
+
apiTokens: tokenRoutes.apiTokenRoutes,
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type Auth = ReturnType<typeof createAuth>
|
|
@@ -1,50 +1,50 @@
|
|
|
1
|
-
import bcrypt from 'bcryptjs'
|
|
2
|
-
import { and, eq, gt } from 'drizzle-orm'
|
|
3
|
-
import type { FastifyReply } from 'fastify'
|
|
4
|
-
import { authSessions, users } from './tables.js'
|
|
5
|
-
import type { AuthOptions } from './context.js'
|
|
6
|
-
import type { AuthIdentity } from './sessions.js'
|
|
7
|
-
|
|
8
|
-
/** Error code telling the admin UI to confirm the user through the identity provider, then retry. */
|
|
9
|
-
export const SSO_CONFIRMATION_REQUIRED = 'SSO_CONFIRMATION_REQUIRED'
|
|
10
|
-
|
|
11
|
-
/** How long an SSO sign-in or SSO confirmation covers sensitive actions in that session. */
|
|
12
|
-
export const SSO_CONFIRMATION_WINDOW_MS = 10 * 60 * 1000
|
|
13
|
-
|
|
14
|
-
export function createIdentityConfirmation(ctx: AuthOptions) {
|
|
15
|
-
const { db } = ctx
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Sensitive actions are confirmed the way the session signed in: a password session with the current
|
|
19
|
-
* password, an SSO session with a recent sign-in at the identity provider (the sign-in itself, or a
|
|
20
|
-
* confirmation popup started from the UI). Returns true, or sends the refusal and returns false.
|
|
21
|
-
*/
|
|
22
|
-
async function confirmIdentity(identity: AuthIdentity, currentPassword: unknown, reply: FastifyReply): Promise<boolean> {
|
|
23
|
-
if (identity.authMethod === 'oidc') {
|
|
24
|
-
const session = (await db.select().from(authSessions).where(eq(authSessions.id, identity.sessionId)))[0]
|
|
25
|
-
const verifiedAt = session?.verifiedAt ?? session?.createdAt ?? 0
|
|
26
|
-
if (Date.now() - verifiedAt <= SSO_CONFIRMATION_WINDOW_MS) return true
|
|
27
|
-
await reply.code(403).send({ error: 'Confirm your identity with single sign-on to continue', code: SSO_CONFIRMATION_REQUIRED })
|
|
28
|
-
return false
|
|
29
|
-
}
|
|
30
|
-
const user = (await db.select().from(users).where(eq(users.id, identity.userId)))[0]
|
|
31
|
-
if (user && typeof currentPassword === 'string' && currentPassword && await bcrypt.compare(currentPassword, user.passwordHash)) return true
|
|
32
|
-
await reply.code(400).send({ error: 'Current password is incorrect' })
|
|
33
|
-
return false
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Records a successful SSO confirmation on a live SSO session of that user. False when there is no such session. */
|
|
37
|
-
async function markSessionVerified(sessionId: string, userId: number): Promise<boolean> {
|
|
38
|
-
const updated = await db.update(authSessions).set({ verifiedAt: Date.now() }).where(and(
|
|
39
|
-
eq(authSessions.id, sessionId),
|
|
40
|
-
eq(authSessions.userId, userId),
|
|
41
|
-
eq(authSessions.authMethod, 'oidc'),
|
|
42
|
-
gt(authSessions.expiresAt, Date.now()),
|
|
43
|
-
)).returning({ id: authSessions.id })
|
|
44
|
-
return updated.length > 0
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return { confirmIdentity, markSessionVerified }
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export type IdentityConfirmation = ReturnType<typeof createIdentityConfirmation>
|
|
1
|
+
import bcrypt from 'bcryptjs'
|
|
2
|
+
import { and, eq, gt } from 'drizzle-orm'
|
|
3
|
+
import type { FastifyReply } from 'fastify'
|
|
4
|
+
import { authSessions, users } from './tables.js'
|
|
5
|
+
import type { AuthOptions } from './context.js'
|
|
6
|
+
import type { AuthIdentity } from './sessions.js'
|
|
7
|
+
|
|
8
|
+
/** Error code telling the admin UI to confirm the user through the identity provider, then retry. */
|
|
9
|
+
export const SSO_CONFIRMATION_REQUIRED = 'SSO_CONFIRMATION_REQUIRED'
|
|
10
|
+
|
|
11
|
+
/** How long an SSO sign-in or SSO confirmation covers sensitive actions in that session. */
|
|
12
|
+
export const SSO_CONFIRMATION_WINDOW_MS = 10 * 60 * 1000
|
|
13
|
+
|
|
14
|
+
export function createIdentityConfirmation(ctx: AuthOptions) {
|
|
15
|
+
const { db } = ctx
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Sensitive actions are confirmed the way the session signed in: a password session with the current
|
|
19
|
+
* password, an SSO session with a recent sign-in at the identity provider (the sign-in itself, or a
|
|
20
|
+
* confirmation popup started from the UI). Returns true, or sends the refusal and returns false.
|
|
21
|
+
*/
|
|
22
|
+
async function confirmIdentity(identity: AuthIdentity, currentPassword: unknown, reply: FastifyReply): Promise<boolean> {
|
|
23
|
+
if (identity.authMethod === 'oidc') {
|
|
24
|
+
const session = (await db.select().from(authSessions).where(eq(authSessions.id, identity.sessionId)))[0]
|
|
25
|
+
const verifiedAt = session?.verifiedAt ?? session?.createdAt ?? 0
|
|
26
|
+
if (Date.now() - verifiedAt <= SSO_CONFIRMATION_WINDOW_MS) return true
|
|
27
|
+
await reply.code(403).send({ error: 'Confirm your identity with single sign-on to continue', code: SSO_CONFIRMATION_REQUIRED })
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
const user = (await db.select().from(users).where(eq(users.id, identity.userId)))[0]
|
|
31
|
+
if (user && typeof currentPassword === 'string' && currentPassword && await bcrypt.compare(currentPassword, user.passwordHash)) return true
|
|
32
|
+
await reply.code(400).send({ error: 'Current password is incorrect' })
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Records a successful SSO confirmation on a live SSO session of that user. False when there is no such session. */
|
|
37
|
+
async function markSessionVerified(sessionId: string, userId: number): Promise<boolean> {
|
|
38
|
+
const updated = await db.update(authSessions).set({ verifiedAt: Date.now() }).where(and(
|
|
39
|
+
eq(authSessions.id, sessionId),
|
|
40
|
+
eq(authSessions.userId, userId),
|
|
41
|
+
eq(authSessions.authMethod, 'oidc'),
|
|
42
|
+
gt(authSessions.expiresAt, Date.now()),
|
|
43
|
+
)).returning({ id: authSessions.id })
|
|
44
|
+
return updated.length > 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { confirmIdentity, markSessionVerified }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type IdentityConfirmation = ReturnType<typeof createIdentityConfirmation>
|
package/src/index.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
export { createAuth, type Auth } from './createAuth.js'
|
|
2
|
-
export { authDbModule } from './module.js'
|
|
3
|
-
export { apiTokens, authSessions, oidcSettings, users } from './tables.js'
|
|
4
|
-
export { acceptApiTokens, belongsTo, registerAdminApi, type AdminRouteGroup } from './routeScope.js'
|
|
5
|
-
export { createSetupRoutes, type SetupRoutesOptions } from './setupRoutes.js'
|
|
6
|
-
export { ADMIN_ROLE, createRoleRegistry, type RoleRegistry } from './roles.js'
|
|
7
|
-
export { createTokenCatalogue, type TokenCatalogue } from './tokenCatalogue.js'
|
|
8
|
-
export type { AuthHooks, AuthOptions, RateLimitConfig, SessionsRevoked, SignInOutcome, UnknownSsoUser } from './context.js'
|
|
9
|
-
export { ALLOW_PENDING_PASSWORD_CHANGE, PASSWORD_CHANGE_REQUIRED, requestIdentity } from './middleware.js'
|
|
10
|
-
export { API_TOKEN_NOT_ACCEPTED, type AuthenticateOptions, type AuthIdentity, type AuthMethod } from './sessions.js'
|
|
11
|
-
export { SSO_CONFIRMATION_REQUIRED, SSO_CONFIRMATION_WINDOW_MS } from './identityConfirmation.js'
|
|
12
|
-
export { checkEmailVerified, OIDC_CONFIRMATION_MAX_AGE_SECONDS } from './oidc.js'
|
|
13
|
-
export type { OidcDecision, OidcDenial, OidcDenialCode, OidcFlowState, OidcTestResult, SsoProvisioning } from './oidc.js'
|
|
14
|
-
export type { OidcSettingsRow } from './oidcSettings.js'
|
|
15
|
-
export { createOidcConfigResolver, DEFAULT_OIDC_BUTTON_LABEL, DEFAULT_OIDC_SCOPES, isHttpUrl, passwordLoginEnabled, type OidcConfig, type OidcSettingsInput } from './oidcConfig.js'
|
|
16
|
-
export { consumeRecoveryCode, generateRecoveryCodes, generateTotpCode, generateTotpSecret, hashRecoveryCode, totpUri, verifyTotp } from './totp.js'
|
|
17
|
-
export type { CredentialDenialCode, SignInDenial, SignInMethod } from './signInAudit.js'
|
|
1
|
+
export { createAuth, type Auth } from './createAuth.js'
|
|
2
|
+
export { authDbModule } from './module.js'
|
|
3
|
+
export { apiTokens, authSessions, oidcSettings, users } from './tables.js'
|
|
4
|
+
export { acceptApiTokens, belongsTo, registerAdminApi, type AdminRouteGroup } from './routeScope.js'
|
|
5
|
+
export { createSetupRoutes, type SetupRoutesOptions } from './setupRoutes.js'
|
|
6
|
+
export { ADMIN_ROLE, createRoleRegistry, type RoleRegistry } from './roles.js'
|
|
7
|
+
export { createTokenCatalogue, type TokenCatalogue } from './tokenCatalogue.js'
|
|
8
|
+
export type { AuthHooks, AuthOptions, RateLimitConfig, SessionsRevoked, SignInOutcome, UnknownSsoUser } from './context.js'
|
|
9
|
+
export { ALLOW_PENDING_PASSWORD_CHANGE, PASSWORD_CHANGE_REQUIRED, requestIdentity } from './middleware.js'
|
|
10
|
+
export { API_TOKEN_NOT_ACCEPTED, type AuthenticateOptions, type AuthIdentity, type AuthMethod } from './sessions.js'
|
|
11
|
+
export { SSO_CONFIRMATION_REQUIRED, SSO_CONFIRMATION_WINDOW_MS } from './identityConfirmation.js'
|
|
12
|
+
export { checkEmailVerified, OIDC_CONFIRMATION_MAX_AGE_SECONDS } from './oidc.js'
|
|
13
|
+
export type { OidcDecision, OidcDenial, OidcDenialCode, OidcFlowState, OidcTestResult, SsoProvisioning } from './oidc.js'
|
|
14
|
+
export type { OidcSettingsRow } from './oidcSettings.js'
|
|
15
|
+
export { createOidcConfigResolver, DEFAULT_OIDC_BUTTON_LABEL, DEFAULT_OIDC_SCOPES, isHttpUrl, passwordLoginEnabled, type OidcConfig, type OidcSettingsInput } from './oidcConfig.js'
|
|
16
|
+
export { consumeRecoveryCode, generateRecoveryCodes, generateTotpCode, generateTotpSecret, hashRecoveryCode, totpUri, verifyTotp } from './totp.js'
|
|
17
|
+
export type { CredentialDenialCode, SignInDenial, SignInMethod } from './signInAudit.js'
|