dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x
This commit is contained in:
commit
e6dcef9bf5
302 files changed
+75178
No files matched your search
@@ -0,0 +1,301 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user