Files

302 lines
11 KiB
JavaScript

import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
function digest(value) {
return createHash('sha256').update(value).digest()
}
function identifier(random = randomBytes, bytes = 32) {
return random(bytes).toString('base64url')
}
function sameDigest(value, expected) {
const actual = digest(value ?? '')
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected)
}
function sessionClient(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return Object.freeze({ ip: null, userAgent: null })
const bounded = (field, maximum) => typeof field === 'string' && field.length > 0 && field.length <= maximum
? field : null
return Object.freeze({
ip: bounded(value.ip, 128),
userAgent: bounded(value.userAgent, 512),
})
}
export class BrowserSessionStore {
constructor({
now = Date.now,
random = randomBytes,
dshAbsoluteMs = 30 * 24 * 60 * 60_000,
dshIdleMs = 7 * 24 * 60 * 60_000,
managementAbsoluteMs = 24 * 60 * 60_000,
managementIdleMs = 2 * 60 * 60_000,
activityReportMs = 5 * 60_000,
onEvent = () => {},
} = {}) {
this.now = now
this.random = random
this.policy = Object.freeze({
dsh: Object.freeze({ absoluteMs: dshAbsoluteMs, idleMs: dshIdleMs }),
management: Object.freeze({ absoluteMs: managementAbsoluteMs, idleMs: managementIdleMs }),
})
this.activityReportMs = activityReportMs
this.onEvent = onEvent
this.sessions = new Map()
}
setEventSink(onEvent) {
this.onEvent = typeof onEvent === 'function' ? onEvent : () => {}
}
emit(message, session, fields = {}) {
this.onEvent(message, {
accountId: session.accountId,
sessionId: session.sessionId,
kind: session.kind,
...fields,
})
}
prune() {
const now = this.now()
for (const [key, session] of this.sessions) {
const reason = session.expiresAt <= now
? 'absolute'
: session.lastSeenAt + session.idleMs <= now ? 'idle' : null
if (reason === null) continue
this.sessions.delete(key)
this.emit('access.session.expired', session, { reason })
}
for (const [key, session] of this.sessions) {
if (session.kind === 'management' && this.activeDshSession(session.sourceDshSessionId, session.accountId) === undefined) {
this.sessions.delete(key)
this.emit('access.session.invalidated', session, { reason: 'source-dsh-session' })
}
}
}
activeDshSession(sessionId, accountId) {
if (typeof sessionId !== 'string') return undefined
for (const session of this.sessions.values()) {
if (session.kind === 'dsh' && session.sessionId === sessionId && session.accountId === accountId) return session
}
return undefined
}
issue(kind, account, {
origin, sourceDshOrigin = null, sourceDshSessionId = null, client = null, authenticationSource = 'unknown',
} = {}) {
const policy = this.policy[kind]
if (policy === undefined) throw new TypeError('browser session kind is invalid')
if (typeof origin !== 'string' || origin.length === 0 || origin.length > 2_048) {
throw new TypeError('browser session origin is invalid')
}
this.prune()
const sourceDshSession = kind === 'management'
? this.activeDshSession(sourceDshSessionId, account.accountId)
: undefined
if (kind === 'management' && (sourceDshSession === undefined || sourceDshSession.origin !== sourceDshOrigin)) {
throw new TypeError('Management session requires an active DSH session')
}
const token = `${kind === 'dsh' ? 'dshs' : 'dshms'}_${identifier(this.random)}`
const csrfToken = `dshc_${identifier(this.random)}`
const sessionId = identifier(this.random, 16)
const createdAt = this.now()
const sourceClient = sourceDshSession === undefined ? sessionClient(client) : sourceDshSession.client
const expiresAt = Math.min(createdAt + policy.absoluteMs, sourceDshSession?.expiresAt ?? Number.POSITIVE_INFINITY)
this.sessions.set(digest(token).toString('hex'), {
sessionId,
kind,
accountId: account.accountId,
mainCredentialVersion: account.mainCredential.version,
managementAdditionalCredentialVersion: account.managementAdditionalCredential.enabled
? account.managementAdditionalCredential.version : null,
managementAccessVersion: account.managementAccess.version,
origin,
sourceDshOrigin,
sourceDshSessionId,
authenticationSource: sourceDshSession?.authenticationSource ?? authenticationSource,
client: sourceClient,
csrfDigest: digest(csrfToken),
createdAt,
lastSeenAt: createdAt,
activityReportedAt: createdAt,
expiresAt,
idleMs: policy.idleMs,
})
return Object.freeze({
token,
csrfToken,
sessionId,
expiresAt: new Date(expiresAt).toISOString(),
})
}
validate(token, kind, account, { origin, csrfToken, requireCsrf = false, touch = true } = {}) {
this.prune()
if (typeof token !== 'string' || token.length > 512 || this.policy[kind] === undefined) return undefined
const key = digest(token).toString('hex')
const session = this.sessions.get(key)
if (session === undefined || session.kind !== kind || session.accountId !== account.accountId
|| session.origin !== origin || (requireCsrf && !sameDigest(csrfToken, session.csrfDigest))) {
return undefined
}
let invalidationReason = null
if (session.mainCredentialVersion !== account.mainCredential.version) invalidationReason = 'main-credential-version'
else if (session.managementAccessVersion !== account.managementAccess.version) invalidationReason = 'management-access-version'
else if (kind === 'management'
&& session.managementAdditionalCredentialVersion !== (account.managementAdditionalCredential.enabled
? account.managementAdditionalCredential.version : null)) invalidationReason = 'management-credential-version'
if (invalidationReason !== null) {
this.sessions.delete(key)
this.emit('access.session.invalidated', session, { reason: invalidationReason })
return undefined
}
if (kind === 'management') {
const source = this.activeDshSession(session.sourceDshSessionId, account.accountId)
if (source === undefined || source.origin !== session.sourceDshOrigin
|| source.mainCredentialVersion !== account.mainCredential.version
|| source.managementAccessVersion !== account.managementAccess.version) {
this.sessions.delete(key)
this.emit('access.session.invalidated', session, { reason: 'source-dsh-session' })
return undefined
}
}
if (touch) {
const now = this.now()
session.lastSeenAt = now
if (now - session.activityReportedAt >= this.activityReportMs) {
session.activityReportedAt = now
this.emit('access.session.activity', session)
}
}
return Object.freeze({
sessionId: session.sessionId,
kind: session.kind,
accountId: session.accountId,
origin: session.origin,
sourceDshOrigin: session.sourceDshOrigin,
sourceDshSessionId: session.sourceDshSessionId,
createdAt: new Date(session.createdAt).toISOString(),
expiresAt: new Date(session.expiresAt).toISOString(),
})
}
revoke(token) {
if (typeof token !== 'string' || token.length > 512) return false
const key = digest(token).toString('hex')
const session = this.sessions.get(key)
if (!this.sessions.delete(key)) return false
if (session.kind === 'dsh') this.revokeManagementFromDsh(session.sessionId, 'source-dsh-session-revoked')
return true
}
revokeKind(kind) {
const dshSessionIds = []
let revoked = 0
for (const [key, session] of this.sessions) {
if (session.kind !== kind) continue
this.sessions.delete(key)
revoked += 1
if (kind === 'dsh') dshSessionIds.push(session.sessionId)
}
for (const sessionId of dshSessionIds) this.revokeManagementFromDsh(sessionId, 'source-dsh-session-revoked')
return revoked
}
countKind(kind) {
this.prune()
let count = 0
for (const session of this.sessions.values()) {
if (session.kind === kind) count += 1
}
return count
}
list(account, currentSessionId) {
this.prune()
const currentDshSessionId = this.sourceDshSessionId(currentSessionId) ?? currentSessionId
const managementSources = new Set([...this.sessions.values()]
.filter(session => session.kind === 'management')
.map(session => session.sourceDshSessionId))
return [...this.sessions.values()]
.filter(session => session.accountId === account.accountId
&& session.kind === 'dsh'
&& session.mainCredentialVersion === account.mainCredential.version
&& session.managementAccessVersion === account.managementAccess.version)
.map(session => Object.freeze({
sessionId: session.sessionId,
origin: session.origin,
current: session.sessionId === currentDshSessionId,
managementActive: managementSources.has(session.sessionId),
ip: session.client.ip,
userAgent: session.client.userAgent,
createdAt: new Date(session.createdAt).toISOString(),
lastSeenAt: new Date(session.lastSeenAt).toISOString(),
expiresAt: new Date(session.expiresAt).toISOString(),
}))
.sort((left, right) => right.lastSeenAt.localeCompare(left.lastSeenAt))
}
revokeDshSession(sessionId, accountId) {
if (typeof sessionId !== 'string' || typeof accountId !== 'string') return false
for (const [key, session] of this.sessions) {
if (session.kind !== 'dsh' || session.sessionId !== sessionId || session.accountId !== accountId) continue
this.sessions.delete(key)
this.revokeManagementFromDsh(session.sessionId, 'source-dsh-session-revoked')
return true
}
return false
}
revokeManagementFromDsh(sessionId, reason = 'source-dsh-session') {
let revoked = 0
for (const [key, session] of this.sessions) {
if (session.kind !== 'management' || session.sourceDshSessionId !== sessionId) continue
this.sessions.delete(key)
this.emit('access.session.invalidated', session, { reason })
revoked += 1
}
return revoked
}
sourceDshSessionId(sessionId) {
for (const session of this.sessions.values()) {
if (session.kind === 'management' && session.sessionId === sessionId) return session.sourceDshSessionId
}
return null
}
refreshDshSession(sessionId, account) {
const session = this.activeDshSession(sessionId, account.accountId)
if (session === undefined) return false
session.mainCredentialVersion = account.mainCredential.version
session.managementAccessVersion = account.managementAccess.version
this.emit('access.session.refreshed', session, { reason: 'management-access-change' })
return true
}
details(sessionId) {
this.prune()
for (const session of this.sessions.values()) {
if (session.sessionId !== sessionId) continue
return Object.freeze({
sessionId: session.sessionId,
kind: session.kind,
origin: session.origin,
sourceDshOrigin: session.sourceDshOrigin,
sourceDshSessionId: session.sourceDshSessionId,
authenticationSource: session.authenticationSource,
})
}
return undefined
}
revokeAll() {
const revoked = this.sessions.size
this.sessions.clear()
return revoked
}
}