302 lines
11 KiB
JavaScript
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
|
|
}
|
|
}
|