返回源码地图

packages/client/connection/src/browser-auth.ts

main snapshot · da00f7f5358f · 正文引用章节 15;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/** Browser-session authentication for the Host Connection carrier. */
2
3import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
4import { credentialKey } from '@deepseek-ai/dsh-credentials'
5import type { CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials'
6import type {
7 ConnectionIndexRequest,
8 ConnectionIndexResponse,
9 ConnectionTrustRequest,
10} from './rpc.ts'
11
12const AUTH_RECORD_KEY = credentialKey('client-connection', 'browser-session')
13const DAY_MILLISECONDS = 24 * 60 * 60 * 1000
14const SECRET_BYTES = 32
15const TOKEN_QUERY = 'token'
16const COOKIE_PREFIX = 'dsh-auth-'
17const COOKIE_PAYLOAD_VERSION = 1
18const STORED_SECRET_VERSION = 1
19const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/
20const PROCESS_LAUNCH_TOKENS = new WeakMap<object, string>()
21
22interface StoredSecretPayload {
23 readonly version: typeof STORED_SECRET_VERSION
24 readonly secret: string
25}
26
27interface BrowserCookiePayload {
28 readonly version: typeof COOKIE_PAYLOAD_VERSION
29 readonly authority: string
30 readonly issuedAt: number
31 readonly expiresAt: number
32}
33
34function isRecord(value: unknown): value is Record<string, unknown> {
35 return typeof value === 'object' && value !== null && !Array.isArray(value)
36}
37
38function encodeBase64Url(value: Uint8Array): string {
39 return Buffer.from(value).toString('base64')
40 .replaceAll('+', '-')
41 .replaceAll('/', '_')
42 .replace(/=+$/u, '')
43}
44
45function decodeBase64Url(value: string): Buffer | undefined {
46 if (!BASE64URL_PATTERN.test(value) || value.length % 4 === 1) return undefined
47 const padding = '='.repeat((4 - value.length % 4) % 4)
48 const decoded = Buffer.from(value.replaceAll('-', '+').replaceAll('_', '/') + padding, 'base64')
49 return encodeBase64Url(decoded) === value ? decoded : undefined
50}
51
52function processLaunchToken(owner: object): string {
53 const existing = PROCESS_LAUNCH_TOKENS.get(owner)
54 if (existing !== undefined) return existing
55 const created = encodeBase64Url(randomBytes(SECRET_BYTES))
56 PROCESS_LAUNCH_TOKENS.set(owner, created)
57 return created
58}
59
60function header(
61 headers: ConnectionTrustRequest['headers'],
62 name: string,
63): string | undefined {
64 if (headers instanceof Headers) return headers.get(name) ?? undefined
65 const value = headers[name]
66 return typeof value === 'string' ? value : undefined
67}
68
69/** Canonical request authority used as the cookie name and signed audience. */
70function requestAuthority(headers: ConnectionTrustRequest['headers']): string | undefined {
71 const host = header(headers, 'host')
72 if (host === undefined) return undefined
73 try {
74 return new URL(`http://${host}`).host
75 } catch {
76 return undefined
77 }
78}
79
80function canonicalSecret(value: unknown): Buffer | undefined {
81 if (typeof value !== 'string') return undefined
82 const decoded = decodeBase64Url(value)
83 if (decoded === undefined || decoded.byteLength !== SECRET_BYTES) return undefined
84 return decoded
85}
86
87function storedSecret(record: CredentialRecord | undefined): Buffer | undefined {
88 if (record === undefined) return undefined
89 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 secret
98}
99
100function 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}
105
106function cookieName(authority: string): string {
107 return COOKIE_PREFIX + encodeBase64Url(createHash('sha256').update(authority).digest())
108}
109
110/** Read the exact generated cookie without implementing general Cookie decoding. */
111function 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) continue
115 return segment.slice(at + 1).trim()
116 }
117 return undefined
118}
119
120/** Serialize the fixed browser-session attributes; generated names and values are cookie-safe base64url. */
121function 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}
124
125function signature(secret: Buffer, body: string): Buffer {
126 return createHmac('sha256', secret).update(body).digest()
127}
128
129function 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}
133
134function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | undefined {
135 const parts = value.split('.')
136 const [version, body, encodedSignature] = parts
137 if (parts.length !== 3 || version !== 'v1' || body === undefined || encodedSignature === undefined) {
138 return undefined
139 }
140 const actualSignature = decodeBase64Url(encodedSignature)
141 if (actualSignature === undefined) return undefined
142 const expectedSignature = signature(secret, body)
143 if (actualSignature.byteLength !== expectedSignature.byteLength
144 || !timingSafeEqual(actualSignature, expectedSignature)) return undefined
145 let decoded: unknown
146 try {
147 const bodyBytes = decodeBase64Url(body)
148 if (bodyBytes === undefined) return undefined
149 decoded = JSON.parse(bodyBytes.toString('utf8'))
150 } catch {
151 return undefined
152 }
153 if (!isRecord(decoded)
154 || decoded.version !== COOKIE_PAYLOAD_VERSION
155 || typeof decoded.authority !== 'string'
156 || !Number.isSafeInteger(decoded.issuedAt)
157 || !Number.isSafeInteger(decoded.expiresAt)) return undefined
158 return decoded as unknown as BrowserCookiePayload
159}
160
161async 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 secret
178}
179
180/**
181 * Process launch-token exchange and persistent signed-cookie verification.
182 * Connection loads the credential provider's signing secret during activation
183 * and retains it for synchronous request authentication.
184 */
185export class BrowserAuth {
186 private readonly launchToken: string
187 private readonly maxAgeMilliseconds: number
188
189 private constructor(
190 processOwner: object,
191 private readonly secret: Buffer,
192 maxAgeDays: number,
193 ) {
194 this.launchToken = processLaunchToken(processOwner)
195 this.maxAgeMilliseconds = maxAgeDays * DAY_MILLISECONDS
196 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 }
201
202 /**
203 * Initialize browser authentication and create its durable signing secret
204 * 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 }
217
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.href
227 }
228
229 /**
230 * Authenticate an index request. A valid root query token mints the cookie
231 * and redirects to the directory-relative clean `./`; a valid cookie lets
232 * the caller serve the index; every other request receives the same minimal
233 * 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 === 1
245 && authority !== undefined && tokenMatches(tokens.join(''), this.launchToken)) {
246 const issuedAt = Date.now()
247 const expiresAt = issuedAt + this.maxAgeMilliseconds
248 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 false
264 }
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 false
273 }
274 this.writeUnauthorized(req, res)
275 return false
276 }
277 if (this.isAuthenticated(req)) return true
278 this.writeUnauthorized(req, res)
279 return false
280 }
281
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 false
291 const value = cookieValue(rawCookie, cookieName(authority))
292 if (value === undefined) return false
293 const payload = decodeCookie(value, this.secret)
294 if (payload === undefined || payload.authority !== authority) return false
295 const now = Date.now()
296 return payload.issuedAt <= now
297 && payload.expiresAt > now
298 && payload.expiresAt > payload.issuedAt
299 && payload.expiresAt - payload.issuedAt <= this.maxAgeMilliseconds
300 }
301
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 ? undefined
309 : 'dsh web authentication required; reopen the URL printed by dsh web.\n')
310 }
311}