Files
deepseek-harness/packages/client/connection/src/browser-auth.ts
T

314 lines
12 KiB
TypeScript

/** Browser-session authentication for the Host Connection carrier. */
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
import { credentialKey } from '@deepseek-ai/dsh-credentials'
import type { CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials'
import type {
ConnectionIndexRequest,
ConnectionIndexResponse,
ConnectionTrustRequest,
} from './rpc.ts'
const AUTH_RECORD_KEY = credentialKey('client-connection', 'browser-session')
const DAY_MILLISECONDS = 24 * 60 * 60 * 1000
const SECRET_BYTES = 32
const TOKEN_QUERY = 'token'
const COOKIE_PREFIX = 'dsh-auth-'
const COOKIE_PAYLOAD_VERSION = 1
const STORED_SECRET_VERSION = 1
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/
const PROCESS_LAUNCH_TOKENS = new WeakMap<object, string>()
interface StoredSecretPayload {
readonly version: typeof STORED_SECRET_VERSION
readonly secret: string
}
interface BrowserCookiePayload {
readonly version: typeof COOKIE_PAYLOAD_VERSION
readonly authority: string
readonly issuedAt: number
readonly expiresAt: number
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function encodeBase64Url(value: Uint8Array): string {
return Buffer.from(value).toString('base64')
.replaceAll('+', '-')
.replaceAll('/', '_')
.replace(/=+$/u, '')
}
function decodeBase64Url(value: string): Buffer | undefined {
if (!BASE64URL_PATTERN.test(value) || value.length % 4 === 1) return undefined
const padding = '='.repeat((4 - value.length % 4) % 4)
const decoded = Buffer.from(value.replaceAll('-', '+').replaceAll('_', '/') + padding, 'base64')
return encodeBase64Url(decoded) === value ? decoded : undefined
}
function processLaunchToken(owner: object): string {
const existing = PROCESS_LAUNCH_TOKENS.get(owner)
if (existing !== undefined) return existing
const created = encodeBase64Url(randomBytes(SECRET_BYTES))
PROCESS_LAUNCH_TOKENS.set(owner, created)
return created
}
function header(
headers: ConnectionTrustRequest['headers'],
name: string,
): string | undefined {
if (headers instanceof Headers) return headers.get(name) ?? undefined
const value = headers[name]
return typeof value === 'string' ? value : undefined
}
/** Canonical request authority used as the cookie name and signed audience. */
function requestAuthority(headers: ConnectionTrustRequest['headers']): string | undefined {
const host = header(headers, 'host')
if (host === undefined) return undefined
try {
return new URL(`http://${host}`).host
} catch {
return undefined
}
}
function canonicalSecret(value: unknown): Buffer | undefined {
if (typeof value !== 'string') return undefined
const decoded = decodeBase64Url(value)
if (decoded === undefined || decoded.byteLength !== SECRET_BYTES) return undefined
return decoded
}
function storedSecret(record: CredentialRecord | undefined): Buffer | undefined {
if (record === undefined) return undefined
if (record.kind !== 'grant' || !isRecord(record.payload)
|| record.payload.version !== STORED_SECRET_VERSION) {
throw new Error('client-connection: browser-session credential record has an unsupported format')
}
const secret = canonicalSecret(record.payload.secret)
if (secret === undefined) {
throw new Error('client-connection: browser-session credential record has an invalid secret')
}
return secret
}
function tokenMatches(actual: string, expected: string): boolean {
const actualBytes = Buffer.from(actual, 'utf8')
const expectedBytes = Buffer.from(expected, 'utf8')
return actualBytes.byteLength === expectedBytes.byteLength && timingSafeEqual(actualBytes, expectedBytes)
}
function cookieName(authority: string): string {
return COOKIE_PREFIX + encodeBase64Url(createHash('sha256').update(authority).digest())
}
/** Read the exact generated cookie without implementing general Cookie decoding. */
function cookieValue(headerValue: string, name: string): string | undefined {
for (const segment of headerValue.split(';')) {
const at = segment.indexOf('=')
if (at === -1 || segment.slice(0, at).trim() !== name) continue
return segment.slice(at + 1).trim()
}
return undefined
}
/** Serialize the fixed browser-session attributes; generated names and values are cookie-safe base64url. */
function sessionCookie(name: string, value: string, expiresAt: number, maxAgeSeconds: number): string {
return `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict`
}
function signature(secret: Buffer, body: string): Buffer {
return createHmac('sha256', secret).update(body).digest()
}
function encodeCookie(payload: BrowserCookiePayload, secret: Buffer): string {
const body = encodeBase64Url(Buffer.from(JSON.stringify(payload), 'utf8'))
return `v1.${body}.${encodeBase64Url(signature(secret, body))}`
}
function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | undefined {
const parts = value.split('.')
const [version, body, encodedSignature] = parts
if (parts.length !== 3 || version !== 'v1' || body === undefined || encodedSignature === undefined) {
return undefined
}
const actualSignature = decodeBase64Url(encodedSignature)
if (actualSignature === undefined) return undefined
const expectedSignature = signature(secret, body)
if (actualSignature.byteLength !== expectedSignature.byteLength
|| !timingSafeEqual(actualSignature, expectedSignature)) return undefined
let decoded: unknown
try {
const bodyBytes = decodeBase64Url(body)
if (bodyBytes === undefined) return undefined
decoded = JSON.parse(bodyBytes.toString('utf8'))
} catch {
return undefined
}
if (!isRecord(decoded)
|| decoded.version !== COOKIE_PAYLOAD_VERSION
|| typeof decoded.authority !== 'string'
|| !Number.isSafeInteger(decoded.issuedAt)
|| !Number.isSafeInteger(decoded.expiresAt)) return undefined
return decoded as unknown as BrowserCookiePayload
}
async function initializeSecret(credentials: CredentialProvider): Promise<Buffer> {
const generated: StoredSecretPayload = {
version: STORED_SECRET_VERSION,
secret: encodeBase64Url(randomBytes(SECRET_BYTES)),
}
const record = await credentials.modifyRecord(AUTH_RECORD_KEY, (current) => {
if (current !== undefined) {
storedSecret(current)
return Promise.resolve(undefined)
}
return Promise.resolve({ kind: 'grant', payload: generated })
})
const secret = storedSecret(record)
if (secret === undefined) {
throw new Error('client-connection: browser-session credential record was not created')
}
return secret
}
/**
* Process launch-token exchange and persistent signed-cookie verification.
* Connection loads the credential provider's signing secret during activation
* and retains it for synchronous request authentication.
*/
export class BrowserAuth {
private readonly launchToken: string
private readonly maxAgeMilliseconds: number
private constructor(
processOwner: object,
private readonly secret: Buffer,
maxAgeDays: number,
) {
this.launchToken = processLaunchToken(processOwner)
this.maxAgeMilliseconds = maxAgeDays * DAY_MILLISECONDS
if (!Number.isSafeInteger(this.maxAgeMilliseconds)
|| !Number.isSafeInteger(Date.now() + this.maxAgeMilliseconds)) {
throw new Error('client-connection: cookieMaxAgeDays exceeds the safe timestamp range')
}
}
/**
* Initialize browser authentication and create its durable signing secret
* when this Harness home has none.
* @param processOwner - root application context retaining one token across Connection reloads.
* @param credentials - persistent credential provider for the Web profile.
* @param maxAgeDays - positive absolute browser-cookie lifetime in days.
* @returns initialized authentication owner with the process owner's launch token.
*/
static async create(
processOwner: object,
credentials: CredentialProvider,
maxAgeDays: number,
): Promise<BrowserAuth> {
return new BrowserAuth(processOwner, await initializeSecret(credentials), maxAgeDays)
}
/**
* Add this process's launch token to the ordinary application root URL.
* @param baseUrl - canonical browser origin without credentials.
* @returns root URL carrying the process token as its sole authentication input.
*/
authenticatedUrl(baseUrl: string): string {
const url = new URL(baseUrl)
url.pathname = '/'
url.search = ''
url.hash = ''
url.searchParams.set(TOKEN_QUERY, this.launchToken)
return url.href
}
/**
* Authenticate an index request. A valid root query token mints the cookie
* and redirects to clean `/`; a valid cookie lets the caller serve the
* index; every other request receives the same minimal 401 response.
* @param req - incoming root or configured-index request.
* @param res - response owned when this method returns false.
* @returns true only when the caller may serve index.html.
*/
authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse): boolean {
/* v8 ignore next -- node:http always supplies url on server requests. */
const url = new URL(req.url ?? '/', 'http://dsh.invalid')
const tokens = url.searchParams.getAll(TOKEN_QUERY)
if (tokens.length > 0) {
const authority = requestAuthority(req.headers)
if (req.method === 'GET' && url.pathname === '/' && tokens.length === 1
&& authority !== undefined && tokenMatches(tokens.join(''), this.launchToken)) {
const issuedAt = Date.now()
const expiresAt = issuedAt + this.maxAgeMilliseconds
const value = encodeCookie({
version: COOKIE_PAYLOAD_VERSION,
authority,
issuedAt,
expiresAt,
}, this.secret)
res.writeHead(303, {
'cache-control': 'no-store',
'location': '/',
'referrer-policy': 'no-referrer',
'set-cookie': sessionCookie(
cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1000),
),
})
res.end()
return false
}
if (req.method === 'GET' && url.pathname === '/' && this.isAuthenticated(req)) {
res.writeHead(303, {
'cache-control': 'no-store',
'location': '/',
'referrer-policy': 'no-referrer',
})
res.end()
return false
}
this.writeUnauthorized(req, res)
return false
}
if (this.isAuthenticated(req)) return true
this.writeUnauthorized(req, res)
return false
}
/**
* Verify the authority-bound browser cookie on a Host request.
* @param request - request headers carrying Host and Cookie.
* @returns true only for an unexpired cookie signed by this activation's loaded secret.
*/
isAuthenticated(request: ConnectionTrustRequest): boolean {
const authority = requestAuthority(request.headers)
const rawCookie = header(request.headers, 'cookie')
if (authority === undefined || rawCookie === undefined) return false
const value = cookieValue(rawCookie, cookieName(authority))
if (value === undefined) return false
const payload = decodeCookie(value, this.secret)
if (payload === undefined || payload.authority !== authority) return false
const now = Date.now()
return payload.issuedAt <= now
&& payload.expiresAt > now
&& payload.expiresAt > payload.issuedAt
&& payload.expiresAt - payload.issuedAt <= this.maxAgeMilliseconds
}
private writeUnauthorized(req: ConnectionIndexRequest, res: ConnectionIndexResponse): void {
res.writeHead(401, {
'cache-control': 'no-store',
'content-type': 'text/plain; charset=utf-8',
})
res.end(req.method === 'HEAD'
? undefined
: 'dsh web authentication required; reopen the URL printed by dsh web.\n')
}
}