1
/** Browser-session authentication for the Host Connection carrier. */3
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'4
import { credentialKey } from '@deepseek-ai/dsh-credentials'5
import type { CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials'6
import type {7
ConnectionIndexRequest,8
ConnectionIndexResponse,9
ConnectionTrustRequest,10
} from './rpc.ts'12
const AUTH_RECORD_KEY = credentialKey('client-connection', 'browser-session')13
const DAY_MILLISECONDS = 24 * 60 * 60 * 100014
const SECRET_BYTES = 3215
const TOKEN_QUERY = 'token'16
const COOKIE_PREFIX = 'dsh-auth-'17
const COOKIE_PAYLOAD_VERSION = 118
const STORED_SECRET_VERSION = 119
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/20
const PROCESS_LAUNCH_TOKENS = new WeakMap<object, string>()22
interface StoredSecretPayload {23
readonly version: typeof STORED_SECRET_VERSION24
readonly secret: string25
}27
interface BrowserCookiePayload {28
readonly version: typeof COOKIE_PAYLOAD_VERSION29
readonly authority: string30
readonly issuedAt: number31
readonly expiresAt: number32
}34
function isRecord(value: unknown): value is Record<string, unknown> {35
return typeof value === 'object' && value !== null && !Array.isArray(value)36
}38
function encodeBase64Url(value: Uint8Array): string {39
return Buffer.from(value).toString('base64')40
.replaceAll('+', '-')41
.replaceAll('/', '_')42
.replace(/=+$/u, '')43
}45
function decodeBase64Url(value: string): Buffer | undefined {46
if (!BASE64URL_PATTERN.test(value) || value.length % 4 === 1) return undefined47
const padding = '='.repeat((4 - value.length % 4) % 4)48
const decoded = Buffer.from(value.replaceAll('-', '+').replaceAll('_', '/') + padding, 'base64')49
return encodeBase64Url(decoded) === value ? decoded : undefined50
}52
function processLaunchToken(owner: object): string {53
const existing = PROCESS_LAUNCH_TOKENS.get(owner)54
if (existing !== undefined) return existing55
const created = encodeBase64Url(randomBytes(SECRET_BYTES))56
PROCESS_LAUNCH_TOKENS.set(owner, created)57
return created58
}60
function header(61
headers: ConnectionTrustRequest['headers'],62
name: string,63
): string | undefined {64
if (headers instanceof Headers) return headers.get(name) ?? undefined65
const value = headers[name]66
return typeof value === 'string' ? value : undefined67
}69
/** Canonical request authority used as the cookie name and signed audience. */70
function requestAuthority(headers: ConnectionTrustRequest['headers']): string | undefined {71
const host = header(headers, 'host')72
if (host === undefined) return undefined73
try {74
return new URL(`http://${host}`).host75
} catch {76
return undefined77
}78
}80
function canonicalSecret(value: unknown): Buffer | undefined {81
if (typeof value !== 'string') return undefined82
const decoded = decodeBase64Url(value)83
if (decoded === undefined || decoded.byteLength !== SECRET_BYTES) return undefined84
return decoded85
}87
function storedSecret(record: CredentialRecord | undefined): Buffer | undefined {88
if (record === undefined) return undefined89
if (record.kind !== 'grant' || !isRecord(record.payload)90
|| record.payload.version !== STORED_SECRET_VERSION) {91
throw new Error('client-connection: browser-session credential record has an unsupported format')92
}93
const secret = canonicalSecret(record.payload.secret)94
if (secret === undefined) {95
throw new Error('client-connection: browser-session credential record has an invalid secret')96
}97
return secret98
}100
function tokenMatches(actual: string, expected: string): boolean {101
const actualBytes = Buffer.from(actual, 'utf8')102
const expectedBytes = Buffer.from(expected, 'utf8')103
return actualBytes.byteLength === expectedBytes.byteLength && timingSafeEqual(actualBytes, expectedBytes)104
}106
function cookieName(authority: string): string {107
return COOKIE_PREFIX + encodeBase64Url(createHash('sha256').update(authority).digest())108
}110
/** Read the exact generated cookie without implementing general Cookie decoding. */111
function cookieValue(headerValue: string, name: string): string | undefined {112
for (const segment of headerValue.split(';')) {113
const at = segment.indexOf('=')114
if (at === -1 || segment.slice(0, at).trim() !== name) continue115
return segment.slice(at + 1).trim()116
}117
return undefined118
}120
/** Serialize the fixed browser-session attributes; generated names and values are cookie-safe base64url. */121
function sessionCookie(name: string, value: string, expiresAt: number, maxAgeSeconds: number): string {122
return `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict`123
}125
function signature(secret: Buffer, body: string): Buffer {126
return createHmac('sha256', secret).update(body).digest()127
}129
function encodeCookie(payload: BrowserCookiePayload, secret: Buffer): string {130
const body = encodeBase64Url(Buffer.from(JSON.stringify(payload), 'utf8'))131
return `v1.${body}.${encodeBase64Url(signature(secret, body))}`132
}134
function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | undefined {135
const parts = value.split('.')136
const [version, body, encodedSignature] = parts137
if (parts.length !== 3 || version !== 'v1' || body === undefined || encodedSignature === undefined) {138
return undefined139
}140
const actualSignature = decodeBase64Url(encodedSignature)141
if (actualSignature === undefined) return undefined142
const expectedSignature = signature(secret, body)143
if (actualSignature.byteLength !== expectedSignature.byteLength144
|| !timingSafeEqual(actualSignature, expectedSignature)) return undefined145
let decoded: unknown146
try {147
const bodyBytes = decodeBase64Url(body)148
if (bodyBytes === undefined) return undefined149
decoded = JSON.parse(bodyBytes.toString('utf8'))150
} catch {151
return undefined152
}153
if (!isRecord(decoded)154
|| decoded.version !== COOKIE_PAYLOAD_VERSION155
|| typeof decoded.authority !== 'string'156
|| !Number.isSafeInteger(decoded.issuedAt)157
|| !Number.isSafeInteger(decoded.expiresAt)) return undefined158
return decoded as unknown as BrowserCookiePayload159
}161
async function initializeSecret(credentials: CredentialProvider): Promise<Buffer> {162
const generated: StoredSecretPayload = {163
version: STORED_SECRET_VERSION,164
secret: encodeBase64Url(randomBytes(SECRET_BYTES)),165
}166
const record = await credentials.modifyRecord(AUTH_RECORD_KEY, (current) => {167
if (current !== undefined) {168
storedSecret(current)169
return Promise.resolve(undefined)170
}171
return Promise.resolve({ kind: 'grant', payload: generated })172
})173
const secret = storedSecret(record)174
if (secret === undefined) {175
throw new Error('client-connection: browser-session credential record was not created')176
}177
return secret178
}180
/**181
* Process launch-token exchange and persistent signed-cookie verification.182
* Connection loads the credential provider's signing secret during activation183
* and retains it for synchronous request authentication.184
*/185
export class BrowserAuth {186
private readonly launchToken: string187
private readonly maxAgeMilliseconds: number189
private constructor(190
processOwner: object,191
private readonly secret: Buffer,192
maxAgeDays: number,193
) {194
this.launchToken = processLaunchToken(processOwner)195
this.maxAgeMilliseconds = maxAgeDays * DAY_MILLISECONDS196
if (!Number.isSafeInteger(this.maxAgeMilliseconds)197
|| !Number.isSafeInteger(Date.now() + this.maxAgeMilliseconds)) {198
throw new Error('client-connection: cookieMaxAgeDays exceeds the safe timestamp range')199
}200
}202
/**203
* Initialize browser authentication and create its durable signing secret204
* when this Harness home has none.205
* @param processOwner - root application context retaining one token across Connection reloads.206
* @param credentials - persistent credential provider for the Web profile.207
* @param maxAgeDays - positive absolute browser-cookie lifetime in days.208
* @returns initialized authentication owner with the process owner's launch token.209
*/210
static async create(211
processOwner: object,212
credentials: CredentialProvider,213
maxAgeDays: number,214
): Promise<BrowserAuth> {215
return new BrowserAuth(processOwner, await initializeSecret(credentials), maxAgeDays)216
}218
/**219
* Add this process's launch token to the caller's application URL.220
* @param baseUrl - clean browser URL whose authority and mount are preserved.221
* @returns the same URL carrying the process token as its sole authentication input.222
*/223
authenticatedUrl(baseUrl: string): string {224
const url = new URL(baseUrl)225
url.searchParams.set(TOKEN_QUERY, this.launchToken)226
return url.href227
}229
/**230
* Authenticate an index request. A valid root query token mints the cookie231
* and redirects to the directory-relative clean `./`; a valid cookie lets232
* the caller serve the index; every other request receives the same minimal233
* 401 response.234
* @param req - incoming root or configured-index request.235
* @param res - response owned when this method returns false.236
* @returns true only when the caller may serve index.html.237
*/238
authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse): boolean {239
/* v8 ignore next -- node:http always supplies url on server requests. */240
const url = new URL(req.url ?? '/', 'http://dsh.invalid')241
const tokens = url.searchParams.getAll(TOKEN_QUERY)242
if (tokens.length > 0) {243
const authority = requestAuthority(req.headers)244
if (req.method === 'GET' && url.pathname === '/' && tokens.length === 1245
&& authority !== undefined && tokenMatches(tokens.join(''), this.launchToken)) {246
const issuedAt = Date.now()247
const expiresAt = issuedAt + this.maxAgeMilliseconds248
const value = encodeCookie({249
version: COOKIE_PAYLOAD_VERSION,250
authority,251
issuedAt,252
expiresAt,253
}, this.secret)254
res.writeHead(303, {255
'cache-control': 'no-store',256
'location': './',257
'referrer-policy': 'no-referrer',258
'set-cookie': sessionCookie(259
cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1000),260
),261
})262
res.end()263
return false264
}265
if (req.method === 'GET' && url.pathname === '/' && this.isAuthenticated(req)) {266
res.writeHead(303, {267
'cache-control': 'no-store',268
'location': './',269
'referrer-policy': 'no-referrer',270
})271
res.end()272
return false273
}274
this.writeUnauthorized(req, res)275
return false276
}277
if (this.isAuthenticated(req)) return true278
this.writeUnauthorized(req, res)279
return false280
}282
/**283
* Verify the authority-bound browser cookie on a Host request.284
* @param request - request headers carrying Host and Cookie.285
* @returns true only for an unexpired cookie signed by this activation's loaded secret.286
*/287
isAuthenticated(request: ConnectionTrustRequest): boolean {288
const authority = requestAuthority(request.headers)289
const rawCookie = header(request.headers, 'cookie')290
if (authority === undefined || rawCookie === undefined) return false291
const value = cookieValue(rawCookie, cookieName(authority))292
if (value === undefined) return false293
const payload = decodeCookie(value, this.secret)294
if (payload === undefined || payload.authority !== authority) return false295
const now = Date.now()296
return payload.issuedAt <= now297
&& payload.expiresAt > now298
&& payload.expiresAt > payload.issuedAt299
&& payload.expiresAt - payload.issuedAt <= this.maxAgeMilliseconds300
}302
private writeUnauthorized(req: ConnectionIndexRequest, res: ConnectionIndexResponse): void {303
res.writeHead(401, {304
'cache-control': 'no-store',305
'content-type': 'text/plain; charset=utf-8',306
})307
res.end(req.method === 'HEAD'308
? undefined309
: 'dsh web authentication required; reopen the URL printed by dsh web.\n')310
}311
}