1283 lines
64 KiB
JavaScript
1283 lines
64 KiB
JavaScript
import { createServer } from 'node:http'
|
|
import { createHash, randomBytes } from 'node:crypto'
|
|
import { timingSafeEqual } from 'node:crypto'
|
|
import { AuthenticationLimiter } from './rate-limiter.mjs'
|
|
import { AccessError, accessErrorBody } from './errors.mjs'
|
|
import { normalizePassword, normalizeUsername, verifyCredential } from './credentials.mjs'
|
|
import { BrowserSessionStore } from './sessions.mjs'
|
|
import { ManagementExchangeStore } from './exchanges.mjs'
|
|
import { CapabilityStore } from './capabilities.mjs'
|
|
import { ManagementTransitionStore } from './transitions.mjs'
|
|
import { detectRuntimeCapabilities } from './runtime-capabilities.mjs'
|
|
import { TotpFlowStore, TotpRetryLimiter, totpUri, verifyTotpCode } from './totp.mjs'
|
|
|
|
const MAX_BODY_BYTES = 64 * 1024
|
|
const RECOVERY_OPERATIONS = new Map([
|
|
['/v1/recovery/set-username', 'set-username'],
|
|
['/v1/recovery/reset-access', 'reset-access'],
|
|
['/v1/recovery/reset-password', 'reset-password'],
|
|
['/v1/recovery/reset-management-password', 'reset-management-password'],
|
|
['/v1/recovery/disable-management-password', 'disable-management-password'],
|
|
['/v1/recovery/generate-key', 'generate-key'],
|
|
['/v1/recovery/clear-retry', 'clear-retry'],
|
|
])
|
|
const ACCESS_FAILURE_EVENTS = new Map([
|
|
['/v1/initialize', 'access.initialization.failed'],
|
|
['/v1/dsh/initialize', 'access.initialization.failed'],
|
|
['/v1/dsh/reset-authentication', 'access.authentication-reset.failed'],
|
|
])
|
|
function identifier() { return randomBytes(32).toString('base64url') }
|
|
function isLoopbackHostname(hostname) {
|
|
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
const parts = hostname.split('.')
|
|
return parts.length === 4 && parts[0] === '127'
|
|
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
}
|
|
function digest(value) { return createHash('sha256').update(value).digest('base64url') }
|
|
|
|
function accountTotp(account) {
|
|
return account?.totp ?? { enabled: false, version: 0, secret: null, changedAt: null }
|
|
}
|
|
|
|
function authenticationSource(value) {
|
|
if (typeof value?.authenticationSource === 'string'
|
|
&& /^[A-Za-z0-9:_-]{1,128}$/.test(value.authenticationSource)) return value.authenticationSource
|
|
const ip = value?.client?.ip
|
|
return typeof ip === 'string' && ip.length > 0 && ip.length <= 128
|
|
? `ip:${digest(ip)}` : 'unknown'
|
|
}
|
|
|
|
function enforceAuthenticationRetry(retry) {
|
|
if (retry?.retryAfterSeconds <= 0) return
|
|
throw new AccessError(
|
|
'AUTHENTICATION_RETRY_REQUIRED',
|
|
`authentication retry is available in ${retry.retryAfterSeconds} seconds`,
|
|
429,
|
|
{ retryAfterSeconds: retry.retryAfterSeconds },
|
|
)
|
|
}
|
|
|
|
function releaseAuthentication(admission, authenticated) {
|
|
const retry = admission.release(authenticated)
|
|
if (!authenticated) enforceAuthenticationRetry(retry)
|
|
}
|
|
|
|
function normalizeManagementAccess(mode, entry) {
|
|
if (!['compat', 'isolated'].includes(mode)) throw new AccessError('ACCESS_MODE_INVALID', 'management access mode is invalid')
|
|
if (mode === 'compat') return null
|
|
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)
|
|
|| !['local-only', 'public'].includes(entry.kind)) {
|
|
throw new AccessError('ACCESS_ENTRY_INVALID', 'isolated Management entry is invalid')
|
|
}
|
|
if (entry.kind === 'local-only') {
|
|
try {
|
|
const origin = entry.managementLocalOrigin
|
|
const parsed = new URL(origin)
|
|
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin !== origin
|
|
|| !isLoopbackHostname(parsed.hostname)) throw new Error('not loopback')
|
|
return { kind: 'local-only', managementLocalOrigin: parsed.origin }
|
|
} catch { throw new AccessError('ACCESS_ENTRY_INVALID', 'local Management origin is invalid') }
|
|
}
|
|
try {
|
|
const parsed = new URL(entry.managementPublicOrigin)
|
|
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin === 'null'
|
|
|| parsed.username !== '' || parsed.password !== '' || parsed.pathname !== '/'
|
|
|| parsed.search !== '' || parsed.hash !== '') throw new Error('invalid origin')
|
|
return { kind: 'public', managementPublicOrigin: parsed.origin }
|
|
} catch {
|
|
throw new AccessError('ACCESS_ENTRY_INVALID', 'isolated Management entry is invalid')
|
|
}
|
|
}
|
|
|
|
function sameToken(left, right) {
|
|
const a = Buffer.from(left ?? '')
|
|
const b = Buffer.from(right ?? '')
|
|
return a.byteLength === b.byteLength && a.byteLength > 0 && timingSafeEqual(a, b)
|
|
}
|
|
|
|
function send(response, status, value, headers = {}) {
|
|
response.writeHead(status, {
|
|
'cache-control': 'no-store',
|
|
'content-type': 'application/json; charset=utf-8',
|
|
...headers,
|
|
})
|
|
response.end(`${JSON.stringify(value)}\n`)
|
|
}
|
|
|
|
async function body(request) {
|
|
const chunks = []
|
|
let size = 0
|
|
for await (const chunk of request) {
|
|
size += chunk.byteLength
|
|
if (size > MAX_BODY_BYTES) throw new AccessError('REQUEST_TOO_LARGE', 'access request is too large', 413)
|
|
chunks.push(chunk)
|
|
}
|
|
if (size === 0) return {}
|
|
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) }
|
|
catch { throw new AccessError('REQUEST_INVALID', 'access request is invalid') }
|
|
}
|
|
|
|
function publicAccount(account) {
|
|
if (account === undefined) return null
|
|
return {
|
|
accountId: account.accountId,
|
|
username: account.username,
|
|
revision: account.revision,
|
|
mainCredentialVersion: account.mainCredential.version,
|
|
managementAdditionalCredential: {
|
|
enabled: account.managementAdditionalCredential.enabled,
|
|
version: account.managementAdditionalCredential.version,
|
|
},
|
|
totp: {
|
|
enabled: accountTotp(account).enabled,
|
|
version: accountTotp(account).version,
|
|
},
|
|
managementAccess: { ...account.managementAccess },
|
|
}
|
|
}
|
|
|
|
export class AccessService {
|
|
constructor({
|
|
store,
|
|
classificationToken,
|
|
limiter = new AuthenticationLimiter(),
|
|
sessions = null,
|
|
exchanges = new ManagementExchangeStore(),
|
|
capabilities = new CapabilityStore(),
|
|
transitions,
|
|
totpFlows,
|
|
totpLimiter,
|
|
verifyTotp = verifyTotpCode,
|
|
verify = verifyCredential,
|
|
report = async () => {},
|
|
now = () => Date.now(),
|
|
runtimeCapabilities = detectRuntimeCapabilities,
|
|
authenticationEpoch = identifier(),
|
|
}) {
|
|
this.store = store
|
|
this.classificationToken = classificationToken
|
|
this.limiter = limiter
|
|
this.sessions = sessions ?? new BrowserSessionStore()
|
|
this.exchanges = exchanges
|
|
this.capabilities = capabilities
|
|
this.transitions = transitions ?? new ManagementTransitionStore({ now })
|
|
this.totpFlows = totpFlows ?? new TotpFlowStore({ now })
|
|
this.totpLimiter = totpLimiter ?? new TotpRetryLimiter({ now })
|
|
this.verifyTotp = verifyTotp
|
|
this.verify = verify
|
|
this.report = report
|
|
this.sessions.setEventSink?.((message, fields) => {
|
|
void Promise.resolve(this.report(message, fields)).catch(() => {})
|
|
})
|
|
this.now = now
|
|
this.runtimeCapabilities = runtimeCapabilities
|
|
this.authenticationEpoch = authenticationEpoch
|
|
this.authenticationReset = null
|
|
this.transition = Promise.resolve()
|
|
}
|
|
|
|
authenticationContext(current) {
|
|
const revision = current.account?.revision ?? current.initialization?.instanceId ?? current.state
|
|
return `${this.authenticationEpoch}.${revision}`
|
|
}
|
|
|
|
requireAuthenticationContext(value, current) {
|
|
if (!sameToken(value.authenticationContext, this.authenticationContext(current))) {
|
|
throw new AccessError('AUTHENTICATION_CONTEXT_STALE', 'authentication context is stale', 409)
|
|
}
|
|
}
|
|
|
|
serialized(operation) {
|
|
const pending = this.transition.then(operation, operation)
|
|
this.transition = pending.catch(() => {})
|
|
return pending
|
|
}
|
|
|
|
reconcileRuntimePolicy() {
|
|
return this.serialized(async () => {
|
|
const [current, runtimeCapabilities] = await Promise.all([
|
|
this.store.state(),
|
|
this.runtimeCapabilities(),
|
|
])
|
|
if (current.state !== 'initialized' || current.account === undefined
|
|
|| runtimeCapabilities.dshRootCapabilityEffective !== true
|
|
|| current.account.managementAccess.mode === 'compat') {
|
|
return { changed: false, account: publicAccount(current.account), ...runtimeCapabilities }
|
|
}
|
|
const changedAt = new Date(this.now()).toISOString()
|
|
const account = {
|
|
...current.account,
|
|
revision: identifier(),
|
|
updatedAt: changedAt,
|
|
managementAccess: {
|
|
mode: 'compat',
|
|
version: current.account.managementAccess.version + 1,
|
|
isolatedEntry: null,
|
|
dshPublicOrigin: null,
|
|
changedAt,
|
|
},
|
|
}
|
|
const next = await this.store.replaceAccount(account, current.account.revision)
|
|
const revokedSessions = this.sessions.revokeAll()
|
|
this.exchanges.clear?.()
|
|
await this.report('access.management-origin.reconciled', {
|
|
mode: 'compat', reason: 'dsh-root-capability', revokedSessions,
|
|
})
|
|
return { changed: true, account: publicAccount(next), ...runtimeCapabilities }
|
|
})
|
|
}
|
|
|
|
async status() {
|
|
const current = await this.store.state()
|
|
const capabilities = await this.runtimeCapabilities()
|
|
return {
|
|
componentReady: true,
|
|
state: current.state,
|
|
instanceId: current.initialization?.instanceId ?? null,
|
|
authenticationContext: this.authenticationContext(current),
|
|
account: publicAccount(current.account),
|
|
...capabilities,
|
|
}
|
|
}
|
|
|
|
classify(value) {
|
|
if (!sameToken(value.token, this.classificationToken)) {
|
|
throw new AccessError('CLASSIFICATION_FORBIDDEN', 'classification is forbidden', 403)
|
|
}
|
|
return this.serialized(async () => {
|
|
const classified = await this.store.classify(value.evidence)
|
|
await this.report('access.classification.completed', { state: classified.initialization.state })
|
|
return this.status()
|
|
})
|
|
}
|
|
|
|
initialize(value) {
|
|
return this.serialized(async () => {
|
|
const account = await this.store.initialize(value)
|
|
await this.report('access.initialization.completed', { accountId: account.accountId })
|
|
return this.status()
|
|
})
|
|
}
|
|
|
|
initializeDsh(value) {
|
|
return this.serialized(async () => {
|
|
const account = await this.store.initialize(value)
|
|
const session = this.sessions.issue('dsh', account, {
|
|
origin: value.origin,
|
|
client: value.client,
|
|
authenticationSource: authenticationSource(value),
|
|
})
|
|
await this.report('access.initialization.completed', { accountId: account.accountId })
|
|
await this.report('access.session.created', { accountId: account.accountId, kind: 'dsh' })
|
|
return { state: 'initialized', account: publicAccount(account), session }
|
|
})
|
|
}
|
|
|
|
generateAuthenticationResetKey() {
|
|
return this.serialized(async () => {
|
|
const current = await this.store.state()
|
|
if (!['migration-required', 'recovery-required'].includes(current.state)) {
|
|
throw new AccessError('AUTHENTICATION_RESET_UNAVAILABLE', 'administrator authentication reset is unavailable', 409)
|
|
}
|
|
const key = `dshak_${identifier()}`
|
|
this.authenticationReset = { digest: digest(key), expiresAt: this.now() + 10 * 60 * 1000 }
|
|
await this.report('access.authentication-reset-key.generated', { state: current.state })
|
|
return { key, expiresAt: new Date(this.authenticationReset.expiresAt).toISOString() }
|
|
})
|
|
}
|
|
|
|
resetDshAuthentication(value) {
|
|
return this.serialized(async () => {
|
|
const setup = this.authenticationReset
|
|
if (setup === null || setup.expiresAt <= this.now() || typeof value.setupKey !== 'string'
|
|
|| !sameToken(setup.digest, digest(value.setupKey))) {
|
|
if (setup !== null && setup.expiresAt <= this.now()) this.authenticationReset = null
|
|
throw new AccessError('AUTHENTICATION_RESET_KEY_INVALID', 'authentication reset key is invalid or expired', 401)
|
|
}
|
|
const current = await this.store.state()
|
|
let account
|
|
if (current.state === 'migration-required') account = await this.store.migrate(value)
|
|
else if (current.state === 'recovery-required') account = await this.store.recover(value)
|
|
else throw new AccessError('AUTHENTICATION_RESET_UNAVAILABLE', 'administrator authentication reset is unavailable', 409)
|
|
this.authenticationReset = null
|
|
const session = this.sessions.issue('dsh', account, {
|
|
origin: value.origin,
|
|
client: value.client,
|
|
authenticationSource: authenticationSource(value),
|
|
})
|
|
await this.report('access.authentication-reset.completed', { accountId: account.accountId, previousState: current.state })
|
|
return { state: 'initialized', account: publicAccount(account), session }
|
|
})
|
|
}
|
|
|
|
async authenticate(value, suppliedCurrent) {
|
|
const current = suppliedCurrent ?? await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const admission = this.limiter.enter(current.account.accountId, authenticationSource(value))
|
|
let authenticated = false
|
|
try {
|
|
let usernameMatches = false
|
|
try { usernameMatches = normalizeUsername(value.username) === current.account.username } catch {}
|
|
const passwordMatches = await this.verify(value.password, current.account.mainCredential)
|
|
authenticated = usernameMatches && passwordMatches
|
|
if (!authenticated) {
|
|
await this.report('access.authentication.failed', { accountId: current.account.accountId, level: 'warning' })
|
|
throw new AccessError('AUTHENTICATION_FAILED', 'username or password is incorrect', 401)
|
|
}
|
|
await this.report('access.authentication.succeeded', { accountId: current.account.accountId })
|
|
return {
|
|
authenticated: true,
|
|
accountId: current.account.accountId,
|
|
accountRevision: current.account.revision,
|
|
mainCredentialVersion: current.account.mainCredential.version,
|
|
managementAccessVersion: current.account.managementAccess.version,
|
|
}
|
|
} finally { releaseAuthentication(admission, authenticated) }
|
|
}
|
|
|
|
async verifyFreshAuthentication(account, mainPassword, sourceId = 'unknown') {
|
|
const mainMatches = typeof mainPassword === 'string'
|
|
&& await this.verify(mainPassword, account.mainCredential)
|
|
if (!mainMatches) {
|
|
await this.report('access.fresh-authentication.failed', { accountId: account.accountId, level: 'warning' })
|
|
throw new AccessError('FRESH_AUTH_FAILED', 'current administrator credentials are incorrect', 401)
|
|
}
|
|
await this.report('access.fresh-authentication.succeeded', { accountId: account.accountId })
|
|
}
|
|
|
|
|
|
async loginDsh(value) {
|
|
const current = await this.store.state()
|
|
this.requireAuthenticationContext(value, current)
|
|
const authenticated = await this.authenticate(value, current)
|
|
if (accountTotp(current.account).enabled) {
|
|
const challenge = this.totpFlows.createLogin(current.account, {
|
|
origin: value.origin,
|
|
client: value.client,
|
|
authenticationSource: authenticationSource(value),
|
|
})
|
|
return { ...authenticated, totpRequired: true, challenge }
|
|
}
|
|
const session = this.sessions.issue('dsh', current.account, {
|
|
origin: value.origin,
|
|
client: value.client,
|
|
authenticationSource: authenticationSource(value),
|
|
})
|
|
await this.report('access.session.created', { accountId: authenticated.accountId, kind: 'dsh' })
|
|
return { ...authenticated, session }
|
|
}
|
|
|
|
async verifyTotpAuthentication(account, code, source, secret = accountTotp(account).secret) {
|
|
const currentRetry = this.totpLimiter.retry(account.accountId, source)
|
|
if (currentRetry.retryAfterSeconds > 0) {
|
|
throw new AccessError(
|
|
currentRetry.kind === 'rate' ? 'TOTP_RATE_LIMITED' : 'TOTP_RETRY_REQUIRED',
|
|
currentRetry.kind === 'rate'
|
|
? 'too many two-factor authentication attempts'
|
|
: 'two-factor authentication retry is required',
|
|
429,
|
|
{ retryAfterSeconds: currentRetry.retryAfterSeconds },
|
|
)
|
|
}
|
|
const valid = this.verifyTotp(code, secret, { now: this.now() })
|
|
if (!valid) {
|
|
const retry = this.totpLimiter.fail(account.accountId, source)
|
|
await this.report('access.totp.failed', { accountId: account.accountId, level: 'warning' })
|
|
if (retry.retryAfterSeconds > 0) {
|
|
throw new AccessError(
|
|
retry.kind === 'rate' ? 'TOTP_RATE_LIMITED' : 'TOTP_RETRY_REQUIRED',
|
|
retry.kind === 'rate'
|
|
? 'too many two-factor authentication attempts'
|
|
: 'two-factor authentication retry is required',
|
|
429,
|
|
{ retryAfterSeconds: retry.retryAfterSeconds },
|
|
)
|
|
}
|
|
throw new AccessError('TOTP_INVALID', 'two-factor authentication code is invalid', 401)
|
|
}
|
|
this.totpLimiter.succeed(account.accountId, source)
|
|
await this.report('access.totp.succeeded', { accountId: account.accountId })
|
|
}
|
|
|
|
async completeDshTotp(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined || !accountTotp(current.account).enabled) {
|
|
throw new AccessError('TOTP_LOGIN_INVALID', 'two-factor authentication login is invalid or expired', 401)
|
|
}
|
|
this.requireAuthenticationContext(value, current)
|
|
const pending = this.totpFlows.login(value.challengeToken, current.account)
|
|
if (pending === undefined || pending.value.origin !== value.origin
|
|
|| pending.value.authenticationSource !== authenticationSource(value)) {
|
|
throw new AccessError('TOTP_LOGIN_INVALID', 'two-factor authentication login is invalid or expired', 401)
|
|
}
|
|
await this.verifyTotpAuthentication(
|
|
current.account,
|
|
value.code,
|
|
pending.value.authenticationSource,
|
|
)
|
|
this.totpFlows.consumeLogin(pending.key)
|
|
const session = this.sessions.issue('dsh', current.account, {
|
|
origin: pending.value.origin,
|
|
client: pending.value.client,
|
|
authenticationSource: pending.value.authenticationSource,
|
|
})
|
|
await this.report('access.session.created', { accountId: current.account.accountId, kind: 'dsh' })
|
|
return { authenticated: true, session }
|
|
}
|
|
|
|
async validateSession(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) return { authenticated: false }
|
|
const session = this.sessions.validate(value.token, value.kind, current.account, {
|
|
origin: value.origin,
|
|
csrfToken: value.csrfToken,
|
|
requireCsrf: value.requireCsrf === true,
|
|
})
|
|
return session === undefined ? { authenticated: false } : { authenticated: true, session }
|
|
}
|
|
|
|
async logout(value) {
|
|
const revoked = this.sessions.revoke(value.token)
|
|
if (revoked) await this.report('access.session.revoked', { kind: value.kind ?? null })
|
|
return { authenticated: false }
|
|
}
|
|
|
|
async logoutDshBrowser(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
if (!['management', 'all'].includes(value.scope)) {
|
|
throw new AccessError('SESSION_ACTION_INVALID', 'session action is invalid')
|
|
}
|
|
const session = this.sessions.validate(value.dshToken, 'dsh', current.account, {
|
|
origin: value.dshOrigin,
|
|
touch: false,
|
|
})
|
|
if (session === undefined) throw new AccessError('SESSION_INVALID', 'DSH session is invalid', 401)
|
|
let managementRevoked = this.sessions.revokeManagementFromDsh(session.sessionId)
|
|
if (typeof value.managementToken === 'string' && this.sessions.revoke(value.managementToken)) {
|
|
managementRevoked += 1
|
|
}
|
|
const dshRevoked = value.scope === 'all' && this.sessions.revoke(value.dshToken)
|
|
await this.report('access.browser-sessions.logged-out', {
|
|
scope: value.scope,
|
|
dshRevoked,
|
|
managementRevoked,
|
|
})
|
|
return { authenticated: value.scope !== 'all', dshRevoked, managementRevoked }
|
|
}
|
|
|
|
async managementResult(account, { origin, sourceDshOrigin, sourceDshSessionId }) {
|
|
if (account.managementAdditionalCredential.enabled) {
|
|
return { pending: this.exchanges.createPending(account, { targetOrigin: origin, sourceDshOrigin, sourceDshSessionId }) }
|
|
}
|
|
const session = this.sessions.issue('management', account, { origin, sourceDshOrigin, sourceDshSessionId })
|
|
await this.report('access.session.created', { accountId: account.accountId, kind: 'management' })
|
|
return { session }
|
|
}
|
|
|
|
async createManagementHandoff(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const session = this.sessions.validate(value.dshToken, 'dsh', current.account, { origin: value.dshOrigin })
|
|
if (session === undefined) throw new AccessError('SESSION_INVALID', 'DSH session is invalid', 401)
|
|
const handoff = this.exchanges.createHandoff(session, current.account, value.targetOrigin)
|
|
await this.report('access.handoff.created', { accountId: current.account.accountId })
|
|
return { handoff }
|
|
}
|
|
|
|
async consumeManagementHandoff(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const handoff = this.exchanges.consumeHandoff(value.token, current.account, value.origin)
|
|
if (handoff === undefined) throw new AccessError('HANDOFF_INVALID', 'management handoff is invalid or expired', 401)
|
|
const source = this.sessions.details(handoff.dshSessionId)
|
|
if (source?.kind !== 'dsh' || source.origin !== handoff.sourceDshOrigin) {
|
|
throw new AccessError('HANDOFF_INVALID', 'management handoff is invalid or expired', 401)
|
|
}
|
|
await this.report('access.handoff.consumed', { accountId: current.account.accountId })
|
|
return this.managementResult(current.account, {
|
|
origin: value.origin,
|
|
sourceDshOrigin: handoff.sourceDshOrigin,
|
|
sourceDshSessionId: handoff.dshSessionId,
|
|
})
|
|
}
|
|
|
|
async completeManagementLogin(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('PENDING_LOGIN_INVALID', 'management login is invalid or expired', 401)
|
|
}
|
|
this.requireAuthenticationContext(value, current)
|
|
if (!current.account.managementAdditionalCredential.enabled) {
|
|
throw new AccessError('PENDING_LOGIN_INVALID', 'management login is invalid or expired', 401)
|
|
}
|
|
const source = this.sessions.details(
|
|
this.exchanges.peekPendingSource(value.pendingToken, current.account, value.origin) ?? '',
|
|
)
|
|
const sourceId = source?.authenticationSource ?? 'unknown'
|
|
this.limiter.checkRetry(current.account.accountId, sourceId, 'management')
|
|
const pending = this.exchanges.inspectPending(value.pendingToken, current.account, value.origin)
|
|
if (pending === undefined) throw new AccessError('PENDING_LOGIN_INVALID', 'management login is invalid or expired', 401)
|
|
const validatedSource = this.sessions.details(pending.value.sourceDshSessionId)
|
|
if (validatedSource?.kind !== 'dsh' || validatedSource.origin !== pending.value.sourceDshOrigin) {
|
|
this.exchanges.consumePending(pending.key)
|
|
throw new AccessError('PENDING_LOGIN_INVALID', 'management login is invalid or expired', 401)
|
|
}
|
|
const admission = this.limiter.enter(current.account.accountId, sourceId, 'management')
|
|
let valid = false
|
|
try {
|
|
valid = await this.verify(value.password, current.account.managementAdditionalCredential.verifier)
|
|
if (!valid) {
|
|
await this.report('access.authentication.failed', {
|
|
accountId: current.account.accountId, kind: 'management', level: 'warning',
|
|
})
|
|
throw new AccessError('AUTHENTICATION_FAILED', 'username or password is incorrect', 401)
|
|
}
|
|
await this.report('access.authentication.succeeded', {
|
|
accountId: current.account.accountId, kind: 'management',
|
|
})
|
|
} finally { releaseAuthentication(admission, valid) }
|
|
this.exchanges.consumePending(pending.key)
|
|
const session = this.sessions.issue('management', current.account, {
|
|
origin: value.origin,
|
|
sourceDshOrigin: pending.value.sourceDshOrigin,
|
|
sourceDshSessionId: pending.value.sourceDshSessionId,
|
|
})
|
|
await this.report('access.session.created', { accountId: current.account.accountId, kind: 'management' })
|
|
return { session }
|
|
}
|
|
|
|
async issueCapability(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const session = this.sessions.validate(value.managementToken, 'management', current.account, {
|
|
origin: value.origin,
|
|
csrfToken: value.csrfToken,
|
|
requireCsrf: value.requireCsrf === true,
|
|
touch: false,
|
|
})
|
|
if (session === undefined) throw new AccessError('SESSION_INVALID', 'Management session is invalid', 401)
|
|
return { capability: this.capabilities.issue(session, current.account, value) }
|
|
}
|
|
|
|
async issuePluginCapability(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const session = this.sessions.validate(value.dshToken, 'dsh', current.account, {
|
|
origin: value.origin,
|
|
csrfToken: value.csrfToken,
|
|
requireCsrf: value.requireCsrf === true,
|
|
touch: false,
|
|
})
|
|
if (session === undefined) throw new AccessError('SESSION_INVALID', 'DSH session is invalid', 401)
|
|
return {
|
|
capability: this.capabilities.issue(session, current.account, {
|
|
audience: 'plugin', method: value.method, target: value.target,
|
|
}),
|
|
}
|
|
}
|
|
|
|
async consumeCapability(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const capability = this.capabilities.consume(value.token, current.account, value)
|
|
if (capability === undefined) throw new AccessError('CAPABILITY_INVALID', 'Management capability is invalid or expired', 401)
|
|
return { authorized: true, capability }
|
|
}
|
|
|
|
consumeInternalCapability(value, account) {
|
|
const authorization = this.capabilities.consume(value.internalCapability, account, {
|
|
audience: 'management', method: value.method, target: value.target,
|
|
})
|
|
if (authorization === undefined) throw new AccessError('CAPABILITY_INVALID', 'Management capability is invalid', 401)
|
|
return authorization
|
|
}
|
|
|
|
async authenticationSettings(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const authorization = this.consumeInternalCapability(value, current.account)
|
|
const capabilities = await this.runtimeCapabilities()
|
|
return {
|
|
account: publicAccount(current.account),
|
|
sessions: this.sessions.list(current.account, authorization.sessionId),
|
|
...capabilities,
|
|
}
|
|
}
|
|
|
|
async beginTotpEnrollment(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const authorization = this.consumeInternalCapability(value, current.account)
|
|
if (accountTotp(current.account).enabled) {
|
|
throw new AccessError('TOTP_ALREADY_ENABLED', 'two-factor authentication is already enabled', 409)
|
|
}
|
|
const username = value.username === undefined ? current.account.username : normalizeUsername(value.username)
|
|
const enrollment = this.totpFlows.createEnrollment(current.account, authorization.sessionId)
|
|
await this.report('access.totp.enrollment.created', { accountId: current.account.accountId })
|
|
return {
|
|
enrollmentToken: enrollment.token,
|
|
secret: enrollment.secret,
|
|
uri: totpUri({ secret: enrollment.secret, username }),
|
|
expiresAt: enrollment.expiresAt,
|
|
}
|
|
}
|
|
|
|
async cancelTotpEnrollment(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const authorization = this.consumeInternalCapability(value, current.account)
|
|
const canceled = this.totpFlows.cancelEnrollment(value.enrollmentToken, current.account, authorization.sessionId)
|
|
if (canceled) await this.report('access.totp.enrollment.canceled', { accountId: current.account.accountId })
|
|
return { canceled }
|
|
}
|
|
|
|
async confirmTotpEnrollment(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const authorization = this.consumeInternalCapability(value, current.account)
|
|
const enrollment = this.totpFlows.enrollment(value.enrollmentToken, current.account, authorization.sessionId)
|
|
if (enrollment === undefined) {
|
|
throw new AccessError('TOTP_ENROLLMENT_INVALID', 'two-factor authentication enrollment is invalid or expired', 401)
|
|
}
|
|
const managementSession = this.sessions.details(authorization.sessionId)
|
|
const sourceSession = this.sessions.details(managementSession?.sourceDshSessionId)
|
|
await this.verifyTotpAuthentication(
|
|
current.account,
|
|
value.totpCode,
|
|
sourceSession?.authenticationSource ?? 'unknown',
|
|
enrollment.value.secret,
|
|
)
|
|
if (!this.totpFlows.confirmEnrollment(enrollment.key)) {
|
|
throw new AccessError('TOTP_ENROLLMENT_INVALID', 'two-factor authentication enrollment is invalid or expired', 401)
|
|
}
|
|
await this.report('access.totp.enrollment.confirmed', { accountId: current.account.accountId })
|
|
return { confirmed: true, expiresAt: new Date(enrollment.value.expiresAt).toISOString() }
|
|
}
|
|
|
|
async beginTotpDisableConfirmation(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const authorization = this.consumeInternalCapability(value, current.account)
|
|
if (!accountTotp(current.account).enabled) {
|
|
throw new AccessError('TOTP_NOT_ENABLED', 'two-factor authentication is not enabled', 409)
|
|
}
|
|
const confirmation = this.totpFlows.createDisableConfirmation(current.account, authorization.sessionId)
|
|
await this.report('access.totp.disable-confirmation.created', { accountId: current.account.accountId })
|
|
return { confirmationToken: confirmation.token, expiresAt: confirmation.expiresAt }
|
|
}
|
|
|
|
async cancelTotpDisableConfirmation(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const authorization = this.consumeInternalCapability(value, current.account)
|
|
const canceled = this.totpFlows.cancelDisableConfirmation(
|
|
value.confirmationToken,
|
|
current.account,
|
|
authorization.sessionId,
|
|
)
|
|
if (canceled) await this.report('access.totp.disable-confirmation.canceled', { accountId: current.account.accountId })
|
|
return { canceled }
|
|
}
|
|
|
|
async confirmTotpDisable(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const authorization = this.consumeInternalCapability(value, current.account)
|
|
const confirmation = this.totpFlows.disableConfirmation(
|
|
value.confirmationToken,
|
|
current.account,
|
|
authorization.sessionId,
|
|
)
|
|
if (confirmation === undefined) {
|
|
throw new AccessError('TOTP_DISABLE_CONFIRMATION_INVALID', 'two-factor disable confirmation is invalid or expired', 401)
|
|
}
|
|
const managementSession = this.sessions.details(authorization.sessionId)
|
|
const sourceSession = this.sessions.details(managementSession?.sourceDshSessionId)
|
|
await this.verifyTotpAuthentication(
|
|
current.account,
|
|
value.totpCode,
|
|
sourceSession?.authenticationSource ?? 'unknown',
|
|
)
|
|
if (!this.totpFlows.confirmDisable(confirmation.key)) {
|
|
throw new AccessError('TOTP_DISABLE_CONFIRMATION_INVALID', 'two-factor disable confirmation is invalid or expired', 401)
|
|
}
|
|
await this.report('access.totp.disable-confirmation.confirmed', { accountId: current.account.accountId })
|
|
return { confirmed: true, expiresAt: new Date(confirmation.value.expiresAt).toISOString() }
|
|
}
|
|
|
|
async revokeBrowserSessions(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const authorization = this.consumeInternalCapability(value, current.account)
|
|
if (typeof value.sessionId !== 'string' || value.sessionId.length === 0 || value.sessionId.length > 128) {
|
|
throw new AccessError('SESSION_ACTION_INVALID', 'session action is invalid')
|
|
}
|
|
const currentDshSessionId = this.sessions.sourceDshSessionId(authorization.sessionId)
|
|
const revoked = this.sessions.revokeDshSession(value.sessionId, current.account.accountId)
|
|
if (!revoked) throw new AccessError('SESSION_NOT_FOUND', 'browser session was not found', 404)
|
|
const currentSessionRevoked = value.sessionId === currentDshSessionId
|
|
await this.report('access.sessions.revoked', { sessionId: value.sessionId, currentSessionRevoked })
|
|
return { revoked: 1, currentSessionRevoked }
|
|
}
|
|
|
|
async recoveryStatus() {
|
|
const current = await this.store.state()
|
|
return {
|
|
state: current.state,
|
|
authenticationRetry: current.account === undefined
|
|
? null : this.limiter.status(current.account.accountId),
|
|
account: publicAccount(current.account),
|
|
}
|
|
}
|
|
|
|
async clearAuthenticationRetry(value = {}) {
|
|
const current = await this.store.state()
|
|
if (current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator account is unavailable', 409)
|
|
}
|
|
const scope = value.scope ?? 'all'
|
|
const credential = value.credential ?? 'all'
|
|
if (!['all', 'global'].includes(scope)) {
|
|
throw new AccessError('REQUEST_INVALID', 'authentication retry clear scope is invalid')
|
|
}
|
|
if (!['all', 'main', 'management', 'totp'].includes(credential)) {
|
|
throw new AccessError('REQUEST_INVALID', 'authentication retry credential is invalid')
|
|
}
|
|
let result
|
|
if (credential === 'totp') {
|
|
result = this.totpLimiter.clearDailyLimits(current.account.accountId, { globalOnly: scope === 'global' })
|
|
}
|
|
else if (credential === 'all') {
|
|
const passwordResult = scope === 'global'
|
|
? this.limiter.clearGlobal('all')
|
|
: this.limiter.clear(current.account.accountId, 'all')
|
|
const totpResult = this.totpLimiter.clearDailyLimits(current.account.accountId, { globalOnly: scope === 'global' })
|
|
result = { ...passwordResult, twoFactorDailyCleared: totpResult.cleared }
|
|
} else {
|
|
result = scope === 'global'
|
|
? this.limiter.clearGlobal(credential)
|
|
: this.limiter.clear(current.account.accountId, credential)
|
|
}
|
|
await this.report('access.authentication-retry.cleared', {
|
|
accountId: current.account.accountId,
|
|
scope,
|
|
credential,
|
|
cleared: result.cleared,
|
|
})
|
|
return { status: 'cleared', scope, credential, ...result }
|
|
}
|
|
|
|
async clearRecoverySessions(value = {}) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator account is unavailable', 409)
|
|
}
|
|
if (value.managementOnly !== undefined && typeof value.managementOnly !== 'boolean') {
|
|
throw new AccessError('REQUEST_INVALID', 'session clear scope is invalid')
|
|
}
|
|
const managementOnly = value.managementOnly === true
|
|
const revoked = managementOnly ? this.sessions.revokeKind('management') : this.sessions.revokeAll()
|
|
this.exchanges.clear?.()
|
|
await this.report('access.sessions.cleared', { managementOnly, revoked })
|
|
return { status: 'cleared', scope: managementOnly ? 'management' : 'all', revoked }
|
|
}
|
|
|
|
async authenticationRetryStatus(value = {}) {
|
|
const current = await this.store.state()
|
|
if (current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator account is unavailable', 409)
|
|
}
|
|
if (value.credential !== 'totp' || typeof value.source !== 'string'
|
|
|| value.source.length === 0 || value.source.length > 128) {
|
|
throw new AccessError('REQUEST_INVALID', 'authentication retry status request is invalid')
|
|
}
|
|
return this.totpLimiter.retry(current.account.accountId, value.source)
|
|
}
|
|
|
|
async replaceRecoveryAccount(value, operation, { revoke = 'none', auditOperation } = {}) {
|
|
const current = await this.store.state()
|
|
if (current.account === undefined) throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator account is unavailable', 409)
|
|
if (value.revision !== current.account.revision) throw new AccessError('REVISION_CONFLICT', 'account revision changed', 409)
|
|
const account = { ...current.account, revision: identifier(), updatedAt: new Date().toISOString() }
|
|
await operation(account, current.account)
|
|
const next = await this.store.replaceAccount(account, current.account.revision)
|
|
const managementSessionsBefore = this.sessions.countKind?.('management') ?? 0
|
|
const allSessionsRevoked = revoke === 'all'
|
|
const managementSessionsRevoked = revoke === 'management'
|
|
? this.sessions.revokeKind('management')
|
|
: allSessionsRevoked ? managementSessionsBefore : 0
|
|
if (allSessionsRevoked) this.sessions.revokeAll()
|
|
if (allSessionsRevoked) this.exchanges.clear?.()
|
|
const result = {
|
|
account: publicAccount(next),
|
|
currentManagementSessionRevoked: managementSessionsRevoked > 0,
|
|
managementSessionsRevoked,
|
|
allSessionsRevoked,
|
|
}
|
|
await this.report('access.recovery-account.changed', {
|
|
operation: auditOperation,
|
|
managementSessionsRevoked,
|
|
allSessionsRevoked,
|
|
})
|
|
return result
|
|
}
|
|
|
|
async setRecoveryUsername(value) {
|
|
const normalized = normalizeUsername(value.username)
|
|
return this.replaceRecoveryAccount(value, async account => { account.username = normalized }, {
|
|
auditOperation: 'set-username',
|
|
})
|
|
}
|
|
|
|
async resetRecoveryPassword(value) {
|
|
const verifier = await this.store.createVerifier(value.password)
|
|
return this.replaceRecoveryAccount(value, async account => {
|
|
account.mainCredential = { ...verifier, version: account.mainCredential.version + 1 }
|
|
}, { revoke: 'all', auditOperation: 'reset-password' })
|
|
}
|
|
|
|
async resetRecoveryAccess(value) {
|
|
const action = value.managementPasswordAction
|
|
if (!['preserve', 'disable', 'reset'].includes(action)) {
|
|
throw new AccessError('REQUEST_INVALID', 'management password action is invalid')
|
|
}
|
|
const totpAction = value.totpAction ?? 'preserve'
|
|
if (!['preserve', 'disable'].includes(totpAction)) {
|
|
throw new AccessError('REQUEST_INVALID', 'two-factor action is invalid')
|
|
}
|
|
const username = value.username === undefined ? null : normalizeUsername(value.username)
|
|
const mainPassword = value.password === undefined ? null : normalizePassword(value.password)
|
|
const managementPassword = action === 'reset' ? normalizePassword(value.managementPassword) : null
|
|
const currentTotpEnabled = accountTotp((await this.store.state()).account).enabled
|
|
if (username === null && mainPassword === null && action === 'preserve'
|
|
&& (totpAction === 'preserve' || !currentTotpEnabled)) {
|
|
throw new AccessError('REQUEST_INVALID', 'no access changes were selected')
|
|
}
|
|
if (managementPassword !== null && mainPassword !== null && managementPassword === mainPassword) {
|
|
throw new AccessError('PASSWORDS_MUST_DIFFER', 'main and Management console passwords must differ')
|
|
}
|
|
const revoke = mainPassword !== null ? 'all'
|
|
: action === 'reset' ? 'management'
|
|
: action === 'disable' ? 'none'
|
|
: 'none'
|
|
return this.replaceRecoveryAccount(value, async (account, current) => {
|
|
if (managementPassword !== null && mainPassword === null
|
|
&& await verifyCredential(managementPassword, current.mainCredential)) {
|
|
throw new AccessError('PASSWORDS_MUST_DIFFER', 'main and Management console passwords must differ')
|
|
}
|
|
if (username !== null) account.username = username
|
|
if (mainPassword !== null) {
|
|
const verifier = await this.store.createVerifier(mainPassword)
|
|
account.mainCredential = { ...verifier, version: account.mainCredential.version + 1 }
|
|
}
|
|
if (action !== 'preserve') {
|
|
const version = account.managementAdditionalCredential.version + 1
|
|
account.managementAdditionalCredential = action === 'disable'
|
|
? { enabled: false, version, verifier: null, changedAt: new Date().toISOString() }
|
|
: {
|
|
enabled: true,
|
|
version,
|
|
verifier: { ...await this.store.createVerifier(managementPassword), version },
|
|
changedAt: new Date().toISOString(),
|
|
}
|
|
}
|
|
if (totpAction === 'disable' && accountTotp(current).enabled) {
|
|
account.totp = {
|
|
enabled: false,
|
|
version: accountTotp(current).version + 1,
|
|
secret: null,
|
|
changedAt: new Date().toISOString(),
|
|
}
|
|
}
|
|
}, { revoke, auditOperation: 'reset-access' })
|
|
}
|
|
|
|
async resetRecoveryManagementPassword(value) {
|
|
const verifier = await this.store.createVerifier(value.password)
|
|
return this.replaceRecoveryAccount(value, async account => {
|
|
account.managementAdditionalCredential = {
|
|
enabled: true,
|
|
version: account.managementAdditionalCredential.version + 1,
|
|
verifier: { ...verifier, version: account.managementAdditionalCredential.version + 1 },
|
|
changedAt: new Date().toISOString(),
|
|
}
|
|
}, { revoke: 'management', auditOperation: 'reset-management-password' })
|
|
}
|
|
|
|
async disableRecoveryManagementPassword(value) {
|
|
return this.replaceRecoveryAccount(value, async account => {
|
|
account.managementAdditionalCredential = {
|
|
enabled: false,
|
|
version: account.managementAdditionalCredential.version + 1,
|
|
verifier: null,
|
|
changedAt: new Date().toISOString(),
|
|
}
|
|
}, { revoke: 'none', auditOperation: 'disable-management-password' })
|
|
}
|
|
|
|
async createManagementTransition(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const authorization = this.consumeInternalCapability(value, current.account)
|
|
const session = this.sessions.details(authorization.sessionId)
|
|
if (session?.kind !== 'management') throw new AccessError('SESSION_INVALID', 'Management session is invalid', 401)
|
|
const runtimeCapabilities = await this.runtimeCapabilities()
|
|
if (runtimeCapabilities.dshRootCapabilityEffective === true
|
|
&& value.mode !== current.account.managementAccess.mode) {
|
|
throw new AccessError('ACCESS_MODE_LOCKED', 'Management access mode cannot change while DSH Root capability is enabled', 409)
|
|
}
|
|
const isolatedEntry = normalizeManagementAccess(value.mode, value.mode === 'isolated'
|
|
&& value.isolatedEntry?.kind === 'local-only'
|
|
? { ...value.isolatedEntry, managementLocalOrigin: value.isolatedEntry.managementLocalOrigin ?? value.candidateOrigin }
|
|
: value.isolatedEntry)
|
|
const candidateOrigin = value.mode === 'isolated'
|
|
? (() => {
|
|
if (isolatedEntry.kind === 'public') return isolatedEntry.managementPublicOrigin
|
|
try {
|
|
const parsed = new URL(value.candidateOrigin)
|
|
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin !== value.candidateOrigin
|
|
|| !isLoopbackHostname(parsed.hostname)) throw new Error('not loopback')
|
|
return parsed.origin
|
|
} catch { throw new AccessError('ACCESS_ENTRY_INVALID', 'local Management origin is invalid') }
|
|
})()
|
|
: null
|
|
const transition = this.transitions.create({
|
|
account: current.account,
|
|
instanceId: current.initialization.instanceId,
|
|
sessionId: authorization.sessionId,
|
|
sourceOrigin: session.origin,
|
|
sourceDshOrigin: session.sourceDshOrigin,
|
|
sourceDshSessionId: session.sourceDshSessionId,
|
|
mode: value.mode,
|
|
isolatedEntry,
|
|
candidateOrigin,
|
|
})
|
|
await this.report('access.management-transition.created', { mode: value.mode })
|
|
return { transition, instanceId: current.initialization.instanceId }
|
|
}
|
|
|
|
async probeManagementTransition(value) {
|
|
const current = await this.store.state()
|
|
const result = this.transitions.probe({
|
|
...value,
|
|
instanceId: current.initialization?.instanceId,
|
|
})
|
|
if (result === undefined) throw new AccessError('TRANSITION_PROBE_INVALID', 'Management origin probe is invalid or expired', 401)
|
|
await this.report('access.management-transition.probed')
|
|
return result
|
|
}
|
|
|
|
async commitManagementTransition(value) {
|
|
return this.serialized(async () => {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const authorization = this.consumeInternalCapability(value, current.account)
|
|
const transitionValue = {
|
|
transitionId: value.transitionId,
|
|
proof: value.proof,
|
|
account: current.account,
|
|
sessionId: authorization.sessionId,
|
|
}
|
|
const transition = this.transitions.consume(transitionValue)
|
|
if (transition === undefined) throw new AccessError('TRANSITION_INVALID', 'Management origin transition is invalid or expired', 409)
|
|
const sourceDshSession = this.sessions.details(transition.sourceDshSessionId)
|
|
if (sourceDshSession?.kind !== 'dsh' || sourceDshSession.origin !== transition.sourceDshOrigin) {
|
|
throw new AccessError('SESSION_INVALID', 'source DSH session is invalid', 401)
|
|
}
|
|
const targetOrigin = transition.mode === 'isolated'
|
|
? transition.candidateOrigin
|
|
: transition.sourceDshOrigin
|
|
if (typeof targetOrigin !== 'string' || targetOrigin.length === 0) {
|
|
throw new AccessError('TRANSITION_TARGET_UNAVAILABLE', 'Management transition target is unavailable', 409)
|
|
}
|
|
const account = {
|
|
...current.account,
|
|
revision: identifier(),
|
|
updatedAt: new Date().toISOString(),
|
|
managementAccess: {
|
|
mode: transition.mode,
|
|
version: current.account.managementAccess.version + 1,
|
|
isolatedEntry: transition.isolatedEntry,
|
|
dshPublicOrigin: transition.mode === 'isolated' ? transition.sourceDshOrigin : null,
|
|
changedAt: new Date().toISOString(),
|
|
},
|
|
}
|
|
const next = await this.store.replaceAccount(account, current.account.revision)
|
|
this.sessions.refreshDshSession(transition.sourceDshSessionId, next)
|
|
const continuation = this.transitions.createContinuation({
|
|
account: next,
|
|
targetOrigin,
|
|
sourceDshOrigin: transition.sourceDshOrigin,
|
|
sourceDshSessionId: transition.sourceDshSessionId,
|
|
})
|
|
if (continuation === null) throw new AccessError('TRANSITION_TARGET_UNAVAILABLE', 'Management transition continuation is unavailable', 409)
|
|
this.sessions.revokeKind('management')
|
|
this.exchanges.clear?.()
|
|
await this.report('access.management-origin.changed', { mode: transition.mode })
|
|
return {
|
|
account: publicAccount(next),
|
|
continuation,
|
|
targetOrigin,
|
|
loginOrigin: transition.mode === 'isolated' ? transition.candidateOrigin : transition.sourceDshOrigin,
|
|
}
|
|
})
|
|
}
|
|
|
|
async consumeManagementContinuation(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const continuation = this.transitions.consumeContinuation({
|
|
token: value.token, account: current.account, targetOrigin: value.origin,
|
|
})
|
|
if (continuation === undefined) throw new AccessError('CONTINUATION_INVALID', 'Management continuation is invalid or expired', 401)
|
|
const source = this.sessions.details(continuation.sourceDshSessionId)
|
|
if (source?.kind !== 'dsh' || source.origin !== continuation.sourceDshOrigin) {
|
|
throw new AccessError('CONTINUATION_INVALID', 'Management continuation is invalid or expired', 401)
|
|
}
|
|
const session = this.sessions.issue('management', current.account, {
|
|
origin: value.origin,
|
|
sourceDshOrigin: continuation.sourceDshOrigin,
|
|
sourceDshSessionId: continuation.sourceDshSessionId,
|
|
})
|
|
await this.report('access.management-continuation.consumed')
|
|
return { session }
|
|
}
|
|
|
|
async updateAuthenticationSettings(value) {
|
|
const current = await this.store.state()
|
|
if (current.state !== 'initialized' || current.account === undefined) {
|
|
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
|
|
}
|
|
const authorization = this.consumeInternalCapability(value, current.account)
|
|
const account = { ...current.account, revision: identifier(), updatedAt: new Date().toISOString() }
|
|
const usernameChanged = value.username !== undefined && normalizeUsername(value.username) !== current.account.username
|
|
const mainPasswordChanged = value.password !== undefined
|
|
const additionalChanged = value.additionalPassword !== undefined
|
|
|| (value.additionalEnabled !== undefined
|
|
&& value.additionalEnabled !== current.account.managementAdditionalCredential.enabled)
|
|
if (value.totpEnabled !== undefined && typeof value.totpEnabled !== 'boolean') {
|
|
throw new AccessError('REQUEST_INVALID', 'two-factor authentication setting is invalid')
|
|
}
|
|
const totpChanged = value.totpEnabled !== undefined
|
|
&& value.totpEnabled !== accountTotp(current.account).enabled
|
|
if (value.mode !== undefined || value.isolatedEntry !== undefined) {
|
|
throw new AccessError('TRANSITION_REQUIRED', 'Management access changes require a verified transition', 409)
|
|
}
|
|
if (!usernameChanged && !mainPasswordChanged && !additionalChanged && !totpChanged) {
|
|
return {
|
|
account: publicAccount(current.account),
|
|
changed: false,
|
|
currentManagementSessionRevoked: false,
|
|
managementSessionsRevoked: 0,
|
|
allSessionsRevoked: false,
|
|
}
|
|
}
|
|
const managementSession = this.sessions.details(authorization.sessionId)
|
|
const sourceSession = this.sessions.details(managementSession?.sourceDshSessionId)
|
|
const authenticationSourceId = sourceSession?.authenticationSource ?? 'unknown'
|
|
if (mainPasswordChanged || additionalChanged || totpChanged) {
|
|
await this.verifyFreshAuthentication(
|
|
current.account,
|
|
value.currentPassword,
|
|
authenticationSourceId,
|
|
)
|
|
}
|
|
let enrollment
|
|
let disableConfirmation
|
|
if (totpChanged && value.totpEnabled === true) {
|
|
enrollment = this.totpFlows.enrollment(value.totpEnrollmentToken, current.account, authorization.sessionId)
|
|
if (enrollment === undefined) {
|
|
throw new AccessError('TOTP_ENROLLMENT_INVALID', 'two-factor authentication enrollment is invalid or expired', 401)
|
|
}
|
|
if (enrollment.value.confirmed !== true) {
|
|
throw new AccessError('TOTP_ENROLLMENT_UNCONFIRMED', 'two-factor authentication enrollment is not confirmed', 409)
|
|
}
|
|
} else if (totpChanged) {
|
|
disableConfirmation = this.totpFlows.disableConfirmation(
|
|
value.totpDisableConfirmationToken,
|
|
current.account,
|
|
authorization.sessionId,
|
|
)
|
|
if (disableConfirmation === undefined) {
|
|
throw new AccessError('TOTP_DISABLE_CONFIRMATION_INVALID', 'two-factor disable confirmation is invalid or expired', 401)
|
|
}
|
|
if (disableConfirmation.value.confirmed !== true) {
|
|
throw new AccessError('TOTP_DISABLE_CONFIRMATION_UNCONFIRMED', 'two-factor disable confirmation is not confirmed', 409)
|
|
}
|
|
}
|
|
if (value.username !== undefined) account.username = normalizeUsername(value.username)
|
|
if (value.password !== undefined) {
|
|
if (current.account.managementAdditionalCredential.enabled
|
|
&& value.additionalPassword === undefined
|
|
&& await this.verify(value.password, current.account.managementAdditionalCredential.verifier)) {
|
|
throw new AccessError('PASSWORDS_MUST_DIFFER', 'main and Management console passwords must differ')
|
|
}
|
|
const verifier = await this.store.createVerifier(value.password)
|
|
account.mainCredential = { ...verifier, version: current.account.mainCredential.version + 1 }
|
|
}
|
|
if (value.additionalPassword !== undefined || value.additionalEnabled !== undefined) {
|
|
const enabled = value.additionalEnabled === true
|
|
if (!enabled) {
|
|
account.managementAdditionalCredential = {
|
|
enabled: false,
|
|
version: current.account.managementAdditionalCredential.version + 1,
|
|
verifier: null,
|
|
changedAt: new Date().toISOString(),
|
|
}
|
|
} else if (value.additionalPassword !== undefined) {
|
|
if (typeof value.additionalPassword !== 'string') throw new AccessError('PASSWORD_REQUIRED', 'Management console password is required')
|
|
const equalsMain = value.password !== undefined
|
|
? normalizePassword(value.additionalPassword) === normalizePassword(value.password)
|
|
: await this.verify(value.additionalPassword, current.account.mainCredential)
|
|
if (equalsMain) throw new AccessError('PASSWORDS_MUST_DIFFER', 'main and Management console passwords must differ')
|
|
const verifier = await this.store.createVerifier(value.additionalPassword)
|
|
account.managementAdditionalCredential = {
|
|
enabled: true,
|
|
version: current.account.managementAdditionalCredential.version + 1,
|
|
verifier: { ...verifier, version: current.account.managementAdditionalCredential.version + 1 },
|
|
changedAt: new Date().toISOString(),
|
|
}
|
|
} else if (!current.account.managementAdditionalCredential.enabled) {
|
|
throw new AccessError('PASSWORD_REQUIRED', 'Management console password is required')
|
|
}
|
|
}
|
|
if (totpChanged) {
|
|
const version = accountTotp(current.account).version + 1
|
|
account.totp = value.totpEnabled
|
|
? { enabled: true, version, secret: enrollment.value.secret, changedAt: new Date().toISOString() }
|
|
: { enabled: false, version, secret: null, changedAt: new Date().toISOString() }
|
|
}
|
|
const next = await this.store.replaceAccount(account, current.account.revision)
|
|
if (enrollment !== undefined) this.totpFlows.consumeEnrollment(enrollment.key)
|
|
if (disableConfirmation !== undefined) this.totpFlows.consumeDisableConfirmation(disableConfirmation.key)
|
|
const allSessionsRevoked = mainPasswordChanged
|
|
const revokedSessionCount = allSessionsRevoked
|
|
? this.sessions.revokeAll()
|
|
: additionalChanged ? this.sessions.revokeKind('management') : 0
|
|
this.exchanges.clear?.()
|
|
if (allSessionsRevoked) this.totpFlows.clear()
|
|
await this.report('access.authentication.settings.changed', { usernameChanged })
|
|
return {
|
|
account: publicAccount(next),
|
|
changed: true,
|
|
currentManagementSessionRevoked: allSessionsRevoked || additionalChanged,
|
|
managementSessionsRevoked: allSessionsRevoked || additionalChanged ? revokedSessionCount : 0,
|
|
allSessionsRevoked,
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createAccessHttpServer({ service, surface = 'access' }) {
|
|
return createServer((request, response) => {
|
|
void (async () => {
|
|
const pathname = new URL(request.url ?? '/', 'http://access.internal').pathname
|
|
if (request.method === 'GET' && pathname === '/v1/status') return send(response, 200, await service.status())
|
|
if (surface === 'recovery') {
|
|
const value = await body(request)
|
|
if (request.method === 'GET' && pathname === '/v1/recovery/status') return send(response, 200, await service.recoveryStatus())
|
|
if (request.method === 'POST' && pathname === '/v1/recovery/set-username') return send(response, 200, await service.setRecoveryUsername(value))
|
|
if (request.method === 'POST' && pathname === '/v1/recovery/reset-access') return send(response, 200, await service.resetRecoveryAccess(value))
|
|
if (request.method === 'POST' && pathname === '/v1/recovery/reset-password') return send(response, 200, await service.resetRecoveryPassword(value))
|
|
if (request.method === 'POST' && pathname === '/v1/recovery/reset-management-password') return send(response, 200, await service.resetRecoveryManagementPassword(value))
|
|
if (request.method === 'POST' && pathname === '/v1/recovery/disable-management-password') return send(response, 200, await service.disableRecoveryManagementPassword(value))
|
|
if (request.method === 'POST' && pathname === '/v1/recovery/generate-key') return send(response, 201, await service.generateAuthenticationResetKey())
|
|
if (request.method === 'POST' && pathname === '/v1/recovery/clear-retry') return send(response, 200, await service.clearAuthenticationRetry(value))
|
|
if (request.method === 'POST' && pathname === '/v1/recovery/clear-sessions') return send(response, 200, await service.clearRecoverySessions(value))
|
|
return send(response, 404, { error: 'not found', code: 'NOT_FOUND' })
|
|
}
|
|
const value = await body(request)
|
|
if (request.method === 'POST' && pathname === '/v1/classify') return send(response, 200, await service.classify(value))
|
|
if (request.method === 'POST' && pathname === '/v1/initialize') return send(response, 201, await service.initialize(value))
|
|
if (request.method === 'POST' && pathname === '/v1/authenticate') return send(response, 200, await service.authenticate(value))
|
|
if (request.method === 'POST' && pathname === '/v1/dsh/initialize') return send(response, 201, await service.initializeDsh(value))
|
|
if (request.method === 'POST' && pathname === '/v1/dsh/reset-authentication') return send(response, 201, await service.resetDshAuthentication(value))
|
|
if (request.method === 'POST' && pathname === '/v1/dsh/login') return send(response, 200, await service.loginDsh(value))
|
|
if (request.method === 'POST' && pathname === '/v1/dsh/totp/complete') return send(response, 200, await service.completeDshTotp(value))
|
|
if (request.method === 'POST' && pathname === '/v1/authentication-retry/status') return send(response, 200, await service.authenticationRetryStatus(value))
|
|
if (request.method === 'POST' && pathname === '/v1/sessions/validate') return send(response, 200, await service.validateSession(value))
|
|
if (request.method === 'POST' && pathname === '/v1/sessions/logout') return send(response, 200, await service.logout(value))
|
|
if (request.method === 'POST' && pathname === '/v1/dsh/browser-logout') return send(response, 200, await service.logoutDshBrowser(value))
|
|
if (request.method === 'POST' && pathname === '/v1/management/handoffs') return send(response, 201, await service.createManagementHandoff(value))
|
|
if (request.method === 'POST' && pathname === '/v1/management/handoffs/consume') return send(response, 200, await service.consumeManagementHandoff(value))
|
|
if (request.method === 'POST' && pathname === '/v1/management/pending/complete') return send(response, 200, await service.completeManagementLogin(value))
|
|
if (request.method === 'POST' && pathname === '/v1/capabilities') return send(response, 201, await service.issueCapability(value))
|
|
if (request.method === 'POST' && pathname === '/v1/dsh/capabilities') return send(response, 201, await service.issuePluginCapability(value))
|
|
if (request.method === 'POST' && pathname === '/v1/capabilities/consume') return send(response, 200, await service.consumeCapability(value))
|
|
if (request.method === 'POST' && pathname === '/v1/management/settings') return send(response, 200, await service.authenticationSettings(value))
|
|
if (request.method === 'POST' && pathname === '/v1/management/totp/enrollments') return send(response, 201, await service.beginTotpEnrollment(value))
|
|
if (request.method === 'POST' && pathname === '/v1/management/totp/enrollments/confirm') return send(response, 200, await service.confirmTotpEnrollment(value))
|
|
if (request.method === 'POST' && pathname === '/v1/management/totp/enrollments/cancel') return send(response, 200, await service.cancelTotpEnrollment(value))
|
|
if (request.method === 'POST' && pathname === '/v1/management/totp/disable-confirmations') return send(response, 201, await service.beginTotpDisableConfirmation(value))
|
|
if (request.method === 'POST' && pathname === '/v1/management/totp/disable-confirmations/confirm') return send(response, 200, await service.confirmTotpDisable(value))
|
|
if (request.method === 'POST' && pathname === '/v1/management/totp/disable-confirmations/cancel') return send(response, 200, await service.cancelTotpDisableConfirmation(value))
|
|
if (request.method === 'POST' && pathname === '/v1/management/sessions/revoke') return send(response, 200, await service.revokeBrowserSessions(value))
|
|
if (request.method === 'POST' && pathname === '/v1/management/transitions') return send(response, 201, await service.createManagementTransition(value))
|
|
if (request.method === 'POST' && pathname === '/v1/management/transitions/probe') return send(response, 200, await service.probeManagementTransition(value))
|
|
if (request.method === 'POST' && pathname === '/v1/management/transitions/commit') return send(response, 200, await service.commitManagementTransition(value))
|
|
if (request.method === 'POST' && pathname === '/v1/management/continuations/consume') return send(response, 200, await service.consumeManagementContinuation(value))
|
|
if (request.method === 'POST' && pathname === '/v1/management/auth-settings') return send(response, 200, await service.updateAuthenticationSettings(value))
|
|
send(response, 404, { error: 'not found', code: 'NOT_FOUND' })
|
|
})().catch(async error => {
|
|
const status = error instanceof AccessError ? error.statusCode : 500
|
|
const failedPathname = new URL(request.url ?? '/', 'http://access.internal').pathname
|
|
const recoveryOperation = surface === 'recovery' && request.method === 'POST'
|
|
? RECOVERY_OPERATIONS.get(failedPathname) : undefined
|
|
if (recoveryOperation !== undefined) {
|
|
await service.report('access.recovery-operation.failed', {
|
|
operation: recoveryOperation,
|
|
code: error instanceof AccessError ? error.code : 'INTERNAL_ERROR',
|
|
})
|
|
}
|
|
const accessFailureEvent = surface === 'access' && request.method === 'POST'
|
|
? ACCESS_FAILURE_EVENTS.get(failedPathname) : undefined
|
|
if (accessFailureEvent !== undefined) {
|
|
await service.report(accessFailureEvent, {
|
|
code: error instanceof AccessError ? error.code : 'INTERNAL_ERROR',
|
|
})
|
|
}
|
|
if (!(error instanceof AccessError)) await service.report('access.request.failed', {
|
|
error, method: request.method ?? null, pathname: request.url ?? null,
|
|
})
|
|
send(response, status, accessErrorBody(error), Number.isInteger(error?.details?.retryAfterSeconds)
|
|
? { 'retry-after': String(error.details.retryAfterSeconds) } : {})
|
|
})
|
|
})
|
|
}
|