@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/oidcConfig.ts
CHANGED
|
@@ -1,96 +1,96 @@
|
|
|
1
|
-
export const DEFAULT_OIDC_SCOPES = 'openid email profile'
|
|
2
|
-
export const DEFAULT_OIDC_BUTTON_LABEL = 'Sign in with SSO'
|
|
3
|
-
|
|
4
|
-
export interface OidcConfig {
|
|
5
|
-
issuer: string
|
|
6
|
-
clientId: string
|
|
7
|
-
clientSecret: string
|
|
8
|
-
scopes: string
|
|
9
|
-
redirectUri: string
|
|
10
|
-
buttonLabel: string
|
|
11
|
-
allowUnverifiedEmail: boolean
|
|
12
|
-
disablePasswordLogin: boolean
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Raw, unvalidated OIDC settings from either source (environment or the database). */
|
|
16
|
-
export interface OidcSettingsInput {
|
|
17
|
-
issuer?: string | undefined
|
|
18
|
-
clientId?: string | undefined
|
|
19
|
-
clientSecret?: string | undefined
|
|
20
|
-
scopes?: string | undefined
|
|
21
|
-
redirectUri?: string | undefined
|
|
22
|
-
buttonLabel?: string | undefined
|
|
23
|
-
allowUnverifiedEmail?: boolean | undefined
|
|
24
|
-
disablePasswordLogin?: boolean | undefined
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function flag(value: string | undefined): boolean {
|
|
28
|
-
return ['1', 'true', 'yes'].includes((value ?? '').trim().toLowerCase())
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function isHttpUrl(value: string): boolean {
|
|
32
|
-
try {
|
|
33
|
-
const url = new URL(value)
|
|
34
|
-
return url.protocol === 'https:' || url.protocol === 'http:'
|
|
35
|
-
} catch {
|
|
36
|
-
return false
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Settings validation and the environment variables, for an application whose public address is `publicUrl(env)` and
|
|
42
|
-
* whose provider redirects back to `callbackPath`.
|
|
43
|
-
*/
|
|
44
|
-
export function createOidcConfigResolver(options: { publicUrl: (env: NodeJS.ProcessEnv) => string; callbackPath: string }) {
|
|
45
|
-
/** Callback URL derived from the public URL, or '' when that is not set. */
|
|
46
|
-
function defaultOidcRedirectUri(env: NodeJS.ProcessEnv = process.env): string {
|
|
47
|
-
const publicUrl = options.publicUrl(env)
|
|
48
|
-
return publicUrl ? `${publicUrl}${options.callbackPath}` : ''
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Validates raw settings and fills defaults. Returns null when they do not describe a usable setup. */
|
|
52
|
-
function buildOidcConfig(input: OidcSettingsInput, env: NodeJS.ProcessEnv = process.env): OidcConfig | null {
|
|
53
|
-
const issuer = (input.issuer ?? '').trim()
|
|
54
|
-
const clientId = (input.clientId ?? '').trim()
|
|
55
|
-
if (!issuer || !clientId || !isHttpUrl(issuer)) return null
|
|
56
|
-
const redirectUri = (input.redirectUri ?? '').trim() || defaultOidcRedirectUri(env)
|
|
57
|
-
if (!redirectUri || !isHttpUrl(redirectUri)) return null
|
|
58
|
-
return {
|
|
59
|
-
issuer,
|
|
60
|
-
clientId,
|
|
61
|
-
clientSecret: (input.clientSecret ?? '').trim(),
|
|
62
|
-
scopes: (input.scopes ?? '').trim() || DEFAULT_OIDC_SCOPES,
|
|
63
|
-
redirectUri,
|
|
64
|
-
buttonLabel: (input.buttonLabel ?? '').trim() || DEFAULT_OIDC_BUTTON_LABEL,
|
|
65
|
-
allowUnverifiedEmail: input.allowUnverifiedEmail === true,
|
|
66
|
-
disablePasswordLogin: input.disablePasswordLogin === true,
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* OIDC settings from environment variables, which override anything saved in the admin UI
|
|
72
|
-
* (for IaC deployments). Returns null when OIDC_ISSUER / OIDC_CLIENT_ID are not both set.
|
|
73
|
-
*/
|
|
74
|
-
function resolveOidcConfigFromEnv(env: NodeJS.ProcessEnv = process.env): OidcConfig | null {
|
|
75
|
-
if (!(env['OIDC_ISSUER'] ?? '').trim() || !(env['OIDC_CLIENT_ID'] ?? '').trim()) return null
|
|
76
|
-
const config = buildOidcConfig({
|
|
77
|
-
issuer: env['OIDC_ISSUER'],
|
|
78
|
-
clientId: env['OIDC_CLIENT_ID'],
|
|
79
|
-
clientSecret: env['OIDC_CLIENT_SECRET'],
|
|
80
|
-
scopes: env['OIDC_SCOPES'],
|
|
81
|
-
redirectUri: env['OIDC_REDIRECT_URI'],
|
|
82
|
-
buttonLabel: env['OIDC_BUTTON_LABEL'],
|
|
83
|
-
allowUnverifiedEmail: flag(env['OIDC_ALLOW_UNVERIFIED_EMAIL']),
|
|
84
|
-
disablePasswordLogin: flag(env['OIDC_DISABLE_PASSWORD_LOGIN']),
|
|
85
|
-
}, env)
|
|
86
|
-
if (!config) console.warn('OIDC_* environment variables are set but incomplete or invalid (issuer, client id and a redirect URI or PUBLIC_URL are required) — OIDC sign-in is disabled')
|
|
87
|
-
return config
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return { defaultOidcRedirectUri, buildOidcConfig, resolveOidcConfigFromEnv }
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Password sign-in can only be turned off while OIDC is usable, so nobody is locked out by a typo. */
|
|
94
|
-
export function passwordLoginEnabled(config: OidcConfig | null): boolean {
|
|
95
|
-
return !(config && config.disablePasswordLogin)
|
|
96
|
-
}
|
|
1
|
+
export const DEFAULT_OIDC_SCOPES = 'openid email profile'
|
|
2
|
+
export const DEFAULT_OIDC_BUTTON_LABEL = 'Sign in with SSO'
|
|
3
|
+
|
|
4
|
+
export interface OidcConfig {
|
|
5
|
+
issuer: string
|
|
6
|
+
clientId: string
|
|
7
|
+
clientSecret: string
|
|
8
|
+
scopes: string
|
|
9
|
+
redirectUri: string
|
|
10
|
+
buttonLabel: string
|
|
11
|
+
allowUnverifiedEmail: boolean
|
|
12
|
+
disablePasswordLogin: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Raw, unvalidated OIDC settings from either source (environment or the database). */
|
|
16
|
+
export interface OidcSettingsInput {
|
|
17
|
+
issuer?: string | undefined
|
|
18
|
+
clientId?: string | undefined
|
|
19
|
+
clientSecret?: string | undefined
|
|
20
|
+
scopes?: string | undefined
|
|
21
|
+
redirectUri?: string | undefined
|
|
22
|
+
buttonLabel?: string | undefined
|
|
23
|
+
allowUnverifiedEmail?: boolean | undefined
|
|
24
|
+
disablePasswordLogin?: boolean | undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function flag(value: string | undefined): boolean {
|
|
28
|
+
return ['1', 'true', 'yes'].includes((value ?? '').trim().toLowerCase())
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isHttpUrl(value: string): boolean {
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL(value)
|
|
34
|
+
return url.protocol === 'https:' || url.protocol === 'http:'
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Settings validation and the environment variables, for an application whose public address is `publicUrl(env)` and
|
|
42
|
+
* whose provider redirects back to `callbackPath`.
|
|
43
|
+
*/
|
|
44
|
+
export function createOidcConfigResolver(options: { publicUrl: (env: NodeJS.ProcessEnv) => string; callbackPath: string }) {
|
|
45
|
+
/** Callback URL derived from the public URL, or '' when that is not set. */
|
|
46
|
+
function defaultOidcRedirectUri(env: NodeJS.ProcessEnv = process.env): string {
|
|
47
|
+
const publicUrl = options.publicUrl(env)
|
|
48
|
+
return publicUrl ? `${publicUrl}${options.callbackPath}` : ''
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Validates raw settings and fills defaults. Returns null when they do not describe a usable setup. */
|
|
52
|
+
function buildOidcConfig(input: OidcSettingsInput, env: NodeJS.ProcessEnv = process.env): OidcConfig | null {
|
|
53
|
+
const issuer = (input.issuer ?? '').trim()
|
|
54
|
+
const clientId = (input.clientId ?? '').trim()
|
|
55
|
+
if (!issuer || !clientId || !isHttpUrl(issuer)) return null
|
|
56
|
+
const redirectUri = (input.redirectUri ?? '').trim() || defaultOidcRedirectUri(env)
|
|
57
|
+
if (!redirectUri || !isHttpUrl(redirectUri)) return null
|
|
58
|
+
return {
|
|
59
|
+
issuer,
|
|
60
|
+
clientId,
|
|
61
|
+
clientSecret: (input.clientSecret ?? '').trim(),
|
|
62
|
+
scopes: (input.scopes ?? '').trim() || DEFAULT_OIDC_SCOPES,
|
|
63
|
+
redirectUri,
|
|
64
|
+
buttonLabel: (input.buttonLabel ?? '').trim() || DEFAULT_OIDC_BUTTON_LABEL,
|
|
65
|
+
allowUnverifiedEmail: input.allowUnverifiedEmail === true,
|
|
66
|
+
disablePasswordLogin: input.disablePasswordLogin === true,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* OIDC settings from environment variables, which override anything saved in the admin UI
|
|
72
|
+
* (for IaC deployments). Returns null when OIDC_ISSUER / OIDC_CLIENT_ID are not both set.
|
|
73
|
+
*/
|
|
74
|
+
function resolveOidcConfigFromEnv(env: NodeJS.ProcessEnv = process.env): OidcConfig | null {
|
|
75
|
+
if (!(env['OIDC_ISSUER'] ?? '').trim() || !(env['OIDC_CLIENT_ID'] ?? '').trim()) return null
|
|
76
|
+
const config = buildOidcConfig({
|
|
77
|
+
issuer: env['OIDC_ISSUER'],
|
|
78
|
+
clientId: env['OIDC_CLIENT_ID'],
|
|
79
|
+
clientSecret: env['OIDC_CLIENT_SECRET'],
|
|
80
|
+
scopes: env['OIDC_SCOPES'],
|
|
81
|
+
redirectUri: env['OIDC_REDIRECT_URI'],
|
|
82
|
+
buttonLabel: env['OIDC_BUTTON_LABEL'],
|
|
83
|
+
allowUnverifiedEmail: flag(env['OIDC_ALLOW_UNVERIFIED_EMAIL']),
|
|
84
|
+
disablePasswordLogin: flag(env['OIDC_DISABLE_PASSWORD_LOGIN']),
|
|
85
|
+
}, env)
|
|
86
|
+
if (!config) console.warn('OIDC_* environment variables are set but incomplete or invalid (issuer, client id and a redirect URI or PUBLIC_URL are required) — OIDC sign-in is disabled')
|
|
87
|
+
return config
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { defaultOidcRedirectUri, buildOidcConfig, resolveOidcConfigFromEnv }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Password sign-in can only be turned off while OIDC is usable, so nobody is locked out by a typo. */
|
|
94
|
+
export function passwordLoginEnabled(config: OidcConfig | null): boolean {
|
|
95
|
+
return !(config && config.disablePasswordLogin)
|
|
96
|
+
}
|
package/src/oidcSettings.ts
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
import { eq } from 'drizzle-orm'
|
|
2
|
-
import { oidcSettings } from './tables.js'
|
|
3
|
-
import type { AuthOptions } from './context.js'
|
|
4
|
-
import type { OidcConfig, createOidcConfigResolver } from './oidcConfig.js'
|
|
5
|
-
|
|
6
|
-
export type OidcSettingsRow = typeof oidcSettings.$inferSelect
|
|
7
|
-
|
|
8
|
-
export function createOidcSettings(ctx: AuthOptions, resolver: ReturnType<typeof createOidcConfigResolver>) {
|
|
9
|
-
const { db, secretBox } = ctx
|
|
10
|
-
const { buildOidcConfig, resolveOidcConfigFromEnv } = resolver
|
|
11
|
-
|
|
12
|
-
async function getOidcSettingsRow(): Promise<OidcSettingsRow | null> {
|
|
13
|
-
return (await db.select().from(oidcSettings).where(eq(oidcSettings.id, 1)))[0] ?? null
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function oidcConfigFromRow(row: OidcSettingsRow): OidcConfig | null {
|
|
17
|
-
return buildOidcConfig({
|
|
18
|
-
issuer: row.issuer,
|
|
19
|
-
clientId: row.clientId,
|
|
20
|
-
clientSecret: row.clientSecret ? secretBox.decrypt(row.clientSecret) : '',
|
|
21
|
-
scopes: row.scopes,
|
|
22
|
-
redirectUri: row.redirectUri,
|
|
23
|
-
buttonLabel: row.buttonLabel,
|
|
24
|
-
allowUnverifiedEmail: !!row.allowUnverifiedEmail,
|
|
25
|
-
disablePasswordLogin: !!row.disablePasswordLogin,
|
|
26
|
-
})
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** OIDC_FORCE_PASSWORD_LOGIN=true is the break-glass switch when the IdP is down and password sign-in was disabled. */
|
|
30
|
-
function withPasswordOverride(config: OidcConfig): OidcConfig {
|
|
31
|
-
return ['1', 'true', 'yes'].includes((process.env['OIDC_FORCE_PASSWORD_LOGIN'] ?? '').trim().toLowerCase())
|
|
32
|
-
? { ...config, disablePasswordLogin: false }
|
|
33
|
-
: config
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* The OIDC configuration in effect: environment variables win (deployment-managed), otherwise the
|
|
38
|
-
* settings saved in the admin UI when enabled. Read per request, so saving takes effect without a restart.
|
|
39
|
-
*/
|
|
40
|
-
async function getOidcConfig(): Promise<{ config: OidcConfig | null; source: 'env' | 'database' | 'none' }> {
|
|
41
|
-
const fromEnv = resolveOidcConfigFromEnv()
|
|
42
|
-
if (fromEnv) return { config: withPasswordOverride(fromEnv), source: 'env' }
|
|
43
|
-
const row = await getOidcSettingsRow()
|
|
44
|
-
if (!row?.enabled) return { config: null, source: 'none' }
|
|
45
|
-
const config = oidcConfigFromRow(row)
|
|
46
|
-
return { config: config && withPasswordOverride(config), source: config ? 'database' : 'none' }
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Settings a test sign-in runs against: the environment, or the saved settings even while SSO is switched off. */
|
|
50
|
-
async function getOidcTestConfig(): Promise<OidcConfig | null> {
|
|
51
|
-
const fromEnv = resolveOidcConfigFromEnv()
|
|
52
|
-
if (fromEnv) return fromEnv
|
|
53
|
-
const row = await getOidcSettingsRow()
|
|
54
|
-
return row ? oidcConfigFromRow(row) : null
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return { getOidcSettingsRow, oidcConfigFromRow, getOidcConfig, getOidcTestConfig }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export type OidcSettings = ReturnType<typeof createOidcSettings>
|
|
1
|
+
import { eq } from 'drizzle-orm'
|
|
2
|
+
import { oidcSettings } from './tables.js'
|
|
3
|
+
import type { AuthOptions } from './context.js'
|
|
4
|
+
import type { OidcConfig, createOidcConfigResolver } from './oidcConfig.js'
|
|
5
|
+
|
|
6
|
+
export type OidcSettingsRow = typeof oidcSettings.$inferSelect
|
|
7
|
+
|
|
8
|
+
export function createOidcSettings(ctx: AuthOptions, resolver: ReturnType<typeof createOidcConfigResolver>) {
|
|
9
|
+
const { db, secretBox } = ctx
|
|
10
|
+
const { buildOidcConfig, resolveOidcConfigFromEnv } = resolver
|
|
11
|
+
|
|
12
|
+
async function getOidcSettingsRow(): Promise<OidcSettingsRow | null> {
|
|
13
|
+
return (await db.select().from(oidcSettings).where(eq(oidcSettings.id, 1)))[0] ?? null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function oidcConfigFromRow(row: OidcSettingsRow): OidcConfig | null {
|
|
17
|
+
return buildOidcConfig({
|
|
18
|
+
issuer: row.issuer,
|
|
19
|
+
clientId: row.clientId,
|
|
20
|
+
clientSecret: row.clientSecret ? secretBox.decrypt(row.clientSecret) : '',
|
|
21
|
+
scopes: row.scopes,
|
|
22
|
+
redirectUri: row.redirectUri,
|
|
23
|
+
buttonLabel: row.buttonLabel,
|
|
24
|
+
allowUnverifiedEmail: !!row.allowUnverifiedEmail,
|
|
25
|
+
disablePasswordLogin: !!row.disablePasswordLogin,
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** OIDC_FORCE_PASSWORD_LOGIN=true is the break-glass switch when the IdP is down and password sign-in was disabled. */
|
|
30
|
+
function withPasswordOverride(config: OidcConfig): OidcConfig {
|
|
31
|
+
return ['1', 'true', 'yes'].includes((process.env['OIDC_FORCE_PASSWORD_LOGIN'] ?? '').trim().toLowerCase())
|
|
32
|
+
? { ...config, disablePasswordLogin: false }
|
|
33
|
+
: config
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The OIDC configuration in effect: environment variables win (deployment-managed), otherwise the
|
|
38
|
+
* settings saved in the admin UI when enabled. Read per request, so saving takes effect without a restart.
|
|
39
|
+
*/
|
|
40
|
+
async function getOidcConfig(): Promise<{ config: OidcConfig | null; source: 'env' | 'database' | 'none' }> {
|
|
41
|
+
const fromEnv = resolveOidcConfigFromEnv()
|
|
42
|
+
if (fromEnv) return { config: withPasswordOverride(fromEnv), source: 'env' }
|
|
43
|
+
const row = await getOidcSettingsRow()
|
|
44
|
+
if (!row?.enabled) return { config: null, source: 'none' }
|
|
45
|
+
const config = oidcConfigFromRow(row)
|
|
46
|
+
return { config: config && withPasswordOverride(config), source: config ? 'database' : 'none' }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Settings a test sign-in runs against: the environment, or the saved settings even while SSO is switched off. */
|
|
50
|
+
async function getOidcTestConfig(): Promise<OidcConfig | null> {
|
|
51
|
+
const fromEnv = resolveOidcConfigFromEnv()
|
|
52
|
+
if (fromEnv) return fromEnv
|
|
53
|
+
const row = await getOidcSettingsRow()
|
|
54
|
+
return row ? oidcConfigFromRow(row) : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { getOidcSettingsRow, oidcConfigFromRow, getOidcConfig, getOidcTestConfig }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type OidcSettings = ReturnType<typeof createOidcSettings>
|
|
@@ -1,165 +1,165 @@
|
|
|
1
|
-
import type { FastifyInstance, FastifyReply } from 'fastify'
|
|
2
|
-
import { oidcSettings } from './tables.js'
|
|
3
|
-
import { requestIdentity } from './middleware.js'
|
|
4
|
-
import type { AuthOptions } from './context.js'
|
|
5
|
-
import type { IdentityConfirmation } from './identityConfirmation.js'
|
|
6
|
-
import type { Oidc } from './oidc.js'
|
|
7
|
-
import type { OidcSettings, OidcSettingsRow } from './oidcSettings.js'
|
|
8
|
-
import type { OidcConfig, createOidcConfigResolver } from './oidcConfig.js'
|
|
9
|
-
|
|
10
|
-
interface SettingsBody {
|
|
11
|
-
enabled?: boolean
|
|
12
|
-
issuer?: string
|
|
13
|
-
clientId?: string
|
|
14
|
-
/** Non-empty replaces the stored secret; omitted or empty keeps it, unless the issuer or client ID changed. */
|
|
15
|
-
clientSecret?: string
|
|
16
|
-
scopes?: string
|
|
17
|
-
redirectUri?: string
|
|
18
|
-
buttonLabel?: string
|
|
19
|
-
allowUnverifiedEmail?: boolean
|
|
20
|
-
disablePasswordLogin?: boolean
|
|
21
|
-
currentPassword?: string
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function createOidcSettingsRoutes(ctx: AuthOptions, parts: {
|
|
25
|
-
identityConfirmation: IdentityConfirmation
|
|
26
|
-
oidc: Oidc
|
|
27
|
-
oidcSettings: OidcSettings
|
|
28
|
-
resolver: ReturnType<typeof createOidcConfigResolver>
|
|
29
|
-
startOidcFlow: (app: FastifyInstance, reply: FastifyReply, cfg: OidcConfig, extra: { purpose?: 'oidc-flow' | 'oidc-test' | 'oidc-confirm'; adminUserId?: number }) => Promise<unknown>
|
|
30
|
-
}) {
|
|
31
|
-
const { db, audit, secretBox } = ctx
|
|
32
|
-
const { confirmIdentity } = parts.identityConfirmation
|
|
33
|
-
const { readOidcTestResult, storeOidcTestResult, testOidcDiscovery } = parts.oidc
|
|
34
|
-
const { getOidcConfig, getOidcSettingsRow, getOidcTestConfig, oidcConfigFromRow } = parts.oidcSettings
|
|
35
|
-
const { buildOidcConfig, defaultOidcRedirectUri } = parts.resolver
|
|
36
|
-
const { startOidcFlow } = parts
|
|
37
|
-
|
|
38
|
-
function publicView(row: OidcSettingsRow | null) {
|
|
39
|
-
return {
|
|
40
|
-
enabled: !!row?.enabled,
|
|
41
|
-
issuer: row?.issuer ?? '',
|
|
42
|
-
clientId: row?.clientId ?? '',
|
|
43
|
-
hasClientSecret: !!row?.clientSecret,
|
|
44
|
-
scopes: row?.scopes ?? '',
|
|
45
|
-
redirectUri: row?.redirectUri ?? '',
|
|
46
|
-
buttonLabel: row?.buttonLabel ?? '',
|
|
47
|
-
allowUnverifiedEmail: !!row?.allowUnverifiedEmail,
|
|
48
|
-
disablePasswordLogin: !!row?.disablePasswordLogin,
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Fields that appear in the audit diff; the secret is only recorded as changed, never its value. */
|
|
53
|
-
function auditView(row: OidcSettingsRow | null, secretChanged = false) {
|
|
54
|
-
const { hasClientSecret: _has, ...rest } = publicView(row)
|
|
55
|
-
return { ...rest, clientSecretChanged: secretChanged }
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
async function discoveryError(config: OidcConfig): Promise<string | null> {
|
|
59
|
-
try {
|
|
60
|
-
await testOidcDiscovery(config)
|
|
61
|
-
return null
|
|
62
|
-
} catch (error) {
|
|
63
|
-
return error instanceof Error ? error.message : 'Discovery failed'
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function oidcSettingsRoutes(app: FastifyInstance) {
|
|
68
|
-
app.get('/', async () => {
|
|
69
|
-
const [row, active] = await Promise.all([getOidcSettingsRow(), getOidcConfig()])
|
|
70
|
-
return {
|
|
71
|
-
source: active.source,
|
|
72
|
-
envManaged: active.source === 'env',
|
|
73
|
-
defaultRedirectUri: defaultOidcRedirectUri(),
|
|
74
|
-
settings: publicView(row),
|
|
75
|
-
}
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
app.put<{ Body: SettingsBody }>('/', { config: { rateLimit: ctx.rateLimits.sensitiveAction } }, async (req, reply) => {
|
|
79
|
-
if ((await getOidcConfig()).source === 'env') {
|
|
80
|
-
return reply.code(409).send({ error: 'OIDC is configured through environment variables and cannot be changed here' })
|
|
81
|
-
}
|
|
82
|
-
const body = req.body ?? {}
|
|
83
|
-
const identity = requestIdentity(req)
|
|
84
|
-
if (!await confirmIdentity(identity, body.currentPassword, reply)) return reply
|
|
85
|
-
|
|
86
|
-
const previous = await getOidcSettingsRow()
|
|
87
|
-
const secretChanged = !!body.clientSecret?.trim()
|
|
88
|
-
// The stored secret is sent to the issuer's token endpoint, so it must not follow a changed target.
|
|
89
|
-
if (previous?.clientSecret && !secretChanged && (
|
|
90
|
-
(body.issuer ?? '').trim() !== previous.issuer || (body.clientId ?? '').trim() !== previous.clientId
|
|
91
|
-
)) {
|
|
92
|
-
return reply.code(400).send({ error: 'Re-enter the client secret when changing the issuer URL or client ID' })
|
|
93
|
-
}
|
|
94
|
-
const clientSecret = secretChanged ? secretBox.encrypt(body.clientSecret!.trim()) : previous?.clientSecret ?? ''
|
|
95
|
-
const next: OidcSettingsRow = {
|
|
96
|
-
id: 1,
|
|
97
|
-
enabled: body.enabled ? 1 : 0,
|
|
98
|
-
issuer: (body.issuer ?? '').trim(),
|
|
99
|
-
clientId: (body.clientId ?? '').trim(),
|
|
100
|
-
clientSecret,
|
|
101
|
-
scopes: (body.scopes ?? '').trim(),
|
|
102
|
-
redirectUri: (body.redirectUri ?? '').trim(),
|
|
103
|
-
buttonLabel: (body.buttonLabel ?? '').trim(),
|
|
104
|
-
allowUnverifiedEmail: body.allowUnverifiedEmail ? 1 : 0,
|
|
105
|
-
disablePasswordLogin: body.disablePasswordLogin ? 1 : 0,
|
|
106
|
-
updatedAt: Date.now(),
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (next.enabled) {
|
|
110
|
-
const config = oidcConfigFromRow(next)
|
|
111
|
-
if (!config) {
|
|
112
|
-
return reply.code(400).send({ error: 'Issuer URL, client ID and a redirect URI (or PUBLIC_URL) are required and must be valid http(s) URLs' })
|
|
113
|
-
}
|
|
114
|
-
// Never persist a setup that could lock everyone out: with password login off, the IdP must be reachable.
|
|
115
|
-
if (config.disablePasswordLogin) {
|
|
116
|
-
const failure = await discoveryError(config)
|
|
117
|
-
if (failure) return reply.code(400).send({ error: `Password sign-in can only be disabled after OIDC discovery succeeds: ${failure}` })
|
|
118
|
-
}
|
|
119
|
-
} else {
|
|
120
|
-
next.disablePasswordLogin = 0
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
await db.insert(oidcSettings).values(next).onConflictDoUpdate({ target: oidcSettings.id, set: { ...next } })
|
|
124
|
-
await audit.write(audit.actor(identity), previous ? 'update' : 'create', 'oidc_settings', 1, 'OIDC sign-in',
|
|
125
|
-
audit.diffObjects(auditView(previous), auditView(next, secretChanged)))
|
|
126
|
-
return publicView(next)
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
// Opened in a new tab: runs the real sign-in with the saved settings and shows the outcome instead of signing in.
|
|
130
|
-
app.get('/test-sign-in', { config: { rateLimit: ctx.rateLimits.sensitiveAction } }, async (req, reply) => {
|
|
131
|
-
const cfg = await getOidcTestConfig()
|
|
132
|
-
if (!cfg) return reply.code(400).send({ error: 'Save a valid issuer URL, client ID and redirect URI first' })
|
|
133
|
-
try {
|
|
134
|
-
return await startOidcFlow(app, reply, cfg, { purpose: 'oidc-test', adminUserId: requestIdentity(req).userId })
|
|
135
|
-
} catch (error) {
|
|
136
|
-
const result = {
|
|
137
|
-
issuer: cfg.issuer, testedAt: Date.now(), claims: null, outcome: 'deny' as const, user: null,
|
|
138
|
-
denial: { code: 'discovery_failed' as const, reason: `OIDC discovery against the issuer failed: ${error instanceof Error ? error.message : String(error)}` },
|
|
139
|
-
}
|
|
140
|
-
return reply.redirect(`${ctx.urls.ssoTest}?result=${storeOidcTestResult(requestIdentity(req).userId, result)}`)
|
|
141
|
-
}
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
app.get<{ Params: { id: string } }>('/test-sign-in/:id', async (req, reply) => {
|
|
145
|
-
const result = readOidcTestResult(req.params.id, requestIdentity(req).userId)
|
|
146
|
-
if (!result) return reply.code(404).send({ error: 'The test result has expired. Run the test sign-in again.' })
|
|
147
|
-
return result
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
app.post<{ Body: SettingsBody }>('/test', { config: { rateLimit: ctx.rateLimits.sensitiveAction } }, async (req, reply) => {
|
|
151
|
-
const body = req.body ?? {}
|
|
152
|
-
const config = buildOidcConfig({
|
|
153
|
-
issuer: body.issuer,
|
|
154
|
-
clientId: body.clientId,
|
|
155
|
-
redirectUri: body.redirectUri,
|
|
156
|
-
})
|
|
157
|
-
if (!config) return reply.code(400).send({ error: 'Enter a valid issuer URL, client ID and a redirect URI (or set PUBLIC_URL)' })
|
|
158
|
-
const failure = await discoveryError(config)
|
|
159
|
-
if (failure) return reply.code(400).send({ error: `Discovery failed: ${failure}` })
|
|
160
|
-
return { ok: true, ...(await testOidcDiscovery(config)) }
|
|
161
|
-
})
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return { oidcSettingsRoutes }
|
|
165
|
-
}
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify'
|
|
2
|
+
import { oidcSettings } from './tables.js'
|
|
3
|
+
import { requestIdentity } from './middleware.js'
|
|
4
|
+
import type { AuthOptions } from './context.js'
|
|
5
|
+
import type { IdentityConfirmation } from './identityConfirmation.js'
|
|
6
|
+
import type { Oidc } from './oidc.js'
|
|
7
|
+
import type { OidcSettings, OidcSettingsRow } from './oidcSettings.js'
|
|
8
|
+
import type { OidcConfig, createOidcConfigResolver } from './oidcConfig.js'
|
|
9
|
+
|
|
10
|
+
interface SettingsBody {
|
|
11
|
+
enabled?: boolean
|
|
12
|
+
issuer?: string
|
|
13
|
+
clientId?: string
|
|
14
|
+
/** Non-empty replaces the stored secret; omitted or empty keeps it, unless the issuer or client ID changed. */
|
|
15
|
+
clientSecret?: string
|
|
16
|
+
scopes?: string
|
|
17
|
+
redirectUri?: string
|
|
18
|
+
buttonLabel?: string
|
|
19
|
+
allowUnverifiedEmail?: boolean
|
|
20
|
+
disablePasswordLogin?: boolean
|
|
21
|
+
currentPassword?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createOidcSettingsRoutes(ctx: AuthOptions, parts: {
|
|
25
|
+
identityConfirmation: IdentityConfirmation
|
|
26
|
+
oidc: Oidc
|
|
27
|
+
oidcSettings: OidcSettings
|
|
28
|
+
resolver: ReturnType<typeof createOidcConfigResolver>
|
|
29
|
+
startOidcFlow: (app: FastifyInstance, reply: FastifyReply, cfg: OidcConfig, extra: { purpose?: 'oidc-flow' | 'oidc-test' | 'oidc-confirm'; adminUserId?: number }) => Promise<unknown>
|
|
30
|
+
}) {
|
|
31
|
+
const { db, audit, secretBox } = ctx
|
|
32
|
+
const { confirmIdentity } = parts.identityConfirmation
|
|
33
|
+
const { readOidcTestResult, storeOidcTestResult, testOidcDiscovery } = parts.oidc
|
|
34
|
+
const { getOidcConfig, getOidcSettingsRow, getOidcTestConfig, oidcConfigFromRow } = parts.oidcSettings
|
|
35
|
+
const { buildOidcConfig, defaultOidcRedirectUri } = parts.resolver
|
|
36
|
+
const { startOidcFlow } = parts
|
|
37
|
+
|
|
38
|
+
function publicView(row: OidcSettingsRow | null) {
|
|
39
|
+
return {
|
|
40
|
+
enabled: !!row?.enabled,
|
|
41
|
+
issuer: row?.issuer ?? '',
|
|
42
|
+
clientId: row?.clientId ?? '',
|
|
43
|
+
hasClientSecret: !!row?.clientSecret,
|
|
44
|
+
scopes: row?.scopes ?? '',
|
|
45
|
+
redirectUri: row?.redirectUri ?? '',
|
|
46
|
+
buttonLabel: row?.buttonLabel ?? '',
|
|
47
|
+
allowUnverifiedEmail: !!row?.allowUnverifiedEmail,
|
|
48
|
+
disablePasswordLogin: !!row?.disablePasswordLogin,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Fields that appear in the audit diff; the secret is only recorded as changed, never its value. */
|
|
53
|
+
function auditView(row: OidcSettingsRow | null, secretChanged = false) {
|
|
54
|
+
const { hasClientSecret: _has, ...rest } = publicView(row)
|
|
55
|
+
return { ...rest, clientSecretChanged: secretChanged }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function discoveryError(config: OidcConfig): Promise<string | null> {
|
|
59
|
+
try {
|
|
60
|
+
await testOidcDiscovery(config)
|
|
61
|
+
return null
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return error instanceof Error ? error.message : 'Discovery failed'
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function oidcSettingsRoutes(app: FastifyInstance) {
|
|
68
|
+
app.get('/', async () => {
|
|
69
|
+
const [row, active] = await Promise.all([getOidcSettingsRow(), getOidcConfig()])
|
|
70
|
+
return {
|
|
71
|
+
source: active.source,
|
|
72
|
+
envManaged: active.source === 'env',
|
|
73
|
+
defaultRedirectUri: defaultOidcRedirectUri(),
|
|
74
|
+
settings: publicView(row),
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
app.put<{ Body: SettingsBody }>('/', { config: { rateLimit: ctx.rateLimits.sensitiveAction } }, async (req, reply) => {
|
|
79
|
+
if ((await getOidcConfig()).source === 'env') {
|
|
80
|
+
return reply.code(409).send({ error: 'OIDC is configured through environment variables and cannot be changed here' })
|
|
81
|
+
}
|
|
82
|
+
const body = req.body ?? {}
|
|
83
|
+
const identity = requestIdentity(req)
|
|
84
|
+
if (!await confirmIdentity(identity, body.currentPassword, reply)) return reply
|
|
85
|
+
|
|
86
|
+
const previous = await getOidcSettingsRow()
|
|
87
|
+
const secretChanged = !!body.clientSecret?.trim()
|
|
88
|
+
// The stored secret is sent to the issuer's token endpoint, so it must not follow a changed target.
|
|
89
|
+
if (previous?.clientSecret && !secretChanged && (
|
|
90
|
+
(body.issuer ?? '').trim() !== previous.issuer || (body.clientId ?? '').trim() !== previous.clientId
|
|
91
|
+
)) {
|
|
92
|
+
return reply.code(400).send({ error: 'Re-enter the client secret when changing the issuer URL or client ID' })
|
|
93
|
+
}
|
|
94
|
+
const clientSecret = secretChanged ? secretBox.encrypt(body.clientSecret!.trim()) : previous?.clientSecret ?? ''
|
|
95
|
+
const next: OidcSettingsRow = {
|
|
96
|
+
id: 1,
|
|
97
|
+
enabled: body.enabled ? 1 : 0,
|
|
98
|
+
issuer: (body.issuer ?? '').trim(),
|
|
99
|
+
clientId: (body.clientId ?? '').trim(),
|
|
100
|
+
clientSecret,
|
|
101
|
+
scopes: (body.scopes ?? '').trim(),
|
|
102
|
+
redirectUri: (body.redirectUri ?? '').trim(),
|
|
103
|
+
buttonLabel: (body.buttonLabel ?? '').trim(),
|
|
104
|
+
allowUnverifiedEmail: body.allowUnverifiedEmail ? 1 : 0,
|
|
105
|
+
disablePasswordLogin: body.disablePasswordLogin ? 1 : 0,
|
|
106
|
+
updatedAt: Date.now(),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (next.enabled) {
|
|
110
|
+
const config = oidcConfigFromRow(next)
|
|
111
|
+
if (!config) {
|
|
112
|
+
return reply.code(400).send({ error: 'Issuer URL, client ID and a redirect URI (or PUBLIC_URL) are required and must be valid http(s) URLs' })
|
|
113
|
+
}
|
|
114
|
+
// Never persist a setup that could lock everyone out: with password login off, the IdP must be reachable.
|
|
115
|
+
if (config.disablePasswordLogin) {
|
|
116
|
+
const failure = await discoveryError(config)
|
|
117
|
+
if (failure) return reply.code(400).send({ error: `Password sign-in can only be disabled after OIDC discovery succeeds: ${failure}` })
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
next.disablePasswordLogin = 0
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await db.insert(oidcSettings).values(next).onConflictDoUpdate({ target: oidcSettings.id, set: { ...next } })
|
|
124
|
+
await audit.write(audit.actor(identity), previous ? 'update' : 'create', 'oidc_settings', 1, 'OIDC sign-in',
|
|
125
|
+
audit.diffObjects(auditView(previous), auditView(next, secretChanged)))
|
|
126
|
+
return publicView(next)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// Opened in a new tab: runs the real sign-in with the saved settings and shows the outcome instead of signing in.
|
|
130
|
+
app.get('/test-sign-in', { config: { rateLimit: ctx.rateLimits.sensitiveAction } }, async (req, reply) => {
|
|
131
|
+
const cfg = await getOidcTestConfig()
|
|
132
|
+
if (!cfg) return reply.code(400).send({ error: 'Save a valid issuer URL, client ID and redirect URI first' })
|
|
133
|
+
try {
|
|
134
|
+
return await startOidcFlow(app, reply, cfg, { purpose: 'oidc-test', adminUserId: requestIdentity(req).userId })
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const result = {
|
|
137
|
+
issuer: cfg.issuer, testedAt: Date.now(), claims: null, outcome: 'deny' as const, user: null,
|
|
138
|
+
denial: { code: 'discovery_failed' as const, reason: `OIDC discovery against the issuer failed: ${error instanceof Error ? error.message : String(error)}` },
|
|
139
|
+
}
|
|
140
|
+
return reply.redirect(`${ctx.urls.ssoTest}?result=${storeOidcTestResult(requestIdentity(req).userId, result)}`)
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
app.get<{ Params: { id: string } }>('/test-sign-in/:id', async (req, reply) => {
|
|
145
|
+
const result = readOidcTestResult(req.params.id, requestIdentity(req).userId)
|
|
146
|
+
if (!result) return reply.code(404).send({ error: 'The test result has expired. Run the test sign-in again.' })
|
|
147
|
+
return result
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
app.post<{ Body: SettingsBody }>('/test', { config: { rateLimit: ctx.rateLimits.sensitiveAction } }, async (req, reply) => {
|
|
151
|
+
const body = req.body ?? {}
|
|
152
|
+
const config = buildOidcConfig({
|
|
153
|
+
issuer: body.issuer,
|
|
154
|
+
clientId: body.clientId,
|
|
155
|
+
redirectUri: body.redirectUri,
|
|
156
|
+
})
|
|
157
|
+
if (!config) return reply.code(400).send({ error: 'Enter a valid issuer URL, client ID and a redirect URI (or set PUBLIC_URL)' })
|
|
158
|
+
const failure = await discoveryError(config)
|
|
159
|
+
if (failure) return reply.code(400).send({ error: `Discovery failed: ${failure}` })
|
|
160
|
+
return { ok: true, ...(await testOidcDiscovery(config)) }
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { oidcSettingsRoutes }
|
|
165
|
+
}
|