Add authenticated image display via read-only auth cookie#3943
Add authenticated image display via read-only auth cookie#3943
Conversation
Browsers cannot include Authorization headers with <img src> requests, causing 401 errors when loading images from authenticated realms. This change augments JWT Bearer token authentication with HttpOnly cookies that browsers automatically include with image requests. Security model: - Cookies work only for GET/HEAD requests (read-only operations) - Mutating requests (POST, PUT, DELETE, PATCH) still require Authorization headers - SameSite=Lax prevents CSRF attacks - HttpOnly flag prevents XSS token theft - Path-scoped cookies ensure isolation between realms Changes: - Add auth-cookie utility module for cookie encoding/parsing - Add cookie-auth middleware to convert cookies to Authorization headers - Set auth cookies in /_realm-auth response alongside JWT tokens - Update CORS config to support credentials - Add credentials: 'include' to client-side /_realm-auth fetch - Add tests for cookie authentication Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Preview deployments |
The fetchRequestFromContext function uses ctx.req.headers (raw Node.js headers) instead of ctx.request.headers (Koa headers) when building the Request object for the realm. Setting the Authorization header on both ensures the cookie-based auth works correctly. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…JWTs Use ctx.state to pass injected auth header from cookie middleware to fetchRequestFromContext, which is more reliable than mutating Node.js request headers. Also fix test JWTs to include all user permissions, matching the realm's strict permission equality check. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When @koa/cors has credentials:true and the origin function returns '*', the library converts '*' to the (empty) request Origin header value, producing an invalid Access-Control-Allow-Origin response. This broke cross-origin requests routed through Playwright's URL rewriting in matrix tests, causing fetchModuleInfo to fail and preventing the ClickableModuleDefinitionContainer from rendering. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The global CORS credentials approach broke VirtualNetwork's cross-origin API/module requests. Instead, only upgrade CORS (specific origin + credentials) for image and binary responses, which are the content types that actually need cookie-based auth since <img> tags can't send Authorization headers. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a read-only, cookie-based authentication path so browser subresource requests (e.g. <img>) can access protected realm content without needing Authorization headers.
Changes:
- Introduces realm-scoped auth cookies (encode/parse utilities) and GET/HEAD-only cookie →
Authorizationinjection middleware. - Sets auth cookies during
POST /_realm-authand updates the host app to request/receive cookies (credentials: 'include'). - Adds tests covering cookie issuance and cookie-auth behavior (GET/HEAD allowed, POST denied, Authorization precedence).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/realm-server/utils/auth-cookie.ts | New helpers for cookie naming, creation, parsing, and lookup by request path. |
| packages/realm-server/middleware/cookie-auth.ts | New middleware to inject Authorization from matching auth cookies for GET/HEAD only. |
| packages/realm-server/handlers/handle-realm-auth.ts | Appends Set-Cookie headers for each realm JWT session. |
| packages/realm-server/server.ts | Wires in cookie auth middleware and adds a CORS post-processor for image/octet-stream responses. |
| packages/realm-server/middleware/index.ts | Ensures injected auth header can be forwarded into the constructed Request. |
| packages/host/app/services/realm-server.ts | Adds credentials: 'include' to the /_realm-auth request so cookies can be stored. |
| packages/realm-server/tests/realm-auth-test.ts | Adds tests for cookie setting and cookie-based auth behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let [name, ...valueParts] = pair.trim().split('='); | ||
| if (name) { | ||
| let value = valueParts.join('='); // Handle values that contain '=' | ||
| cookies.set(name.trim(), decodeURIComponent(value.trim())); |
There was a problem hiding this comment.
parseCookies calls decodeURIComponent on every cookie value without handling decode errors. A malformed Cookie header (e.g. invalid percent-encoding) will throw and turn an otherwise valid GET/HEAD into a 500. Consider wrapping decodeURIComponent in a try/catch per cookie pair and skipping (or leaving undecoded) invalid values so auth-cookie parsing can’t be used to error the request.
| cookies.set(name.trim(), decodeURIComponent(value.trim())); | |
| let rawValue = value.trim(); | |
| let decodedValue: string; | |
| try { | |
| decodedValue = decodeURIComponent(rawValue); | |
| } catch { | |
| // If decoding fails (e.g., invalid percent-encoding), fall back to the raw value | |
| decodedValue = rawValue; | |
| } | |
| cookies.set(name.trim(), decodedValue); |
| export function findAuthCookieForPath( | ||
| cookieHeader: string | undefined, | ||
| requestPath: string, | ||
| ): string | null { | ||
| let cookies = parseCookies(cookieHeader); | ||
|
|
||
| // Find all auth cookies and check if any match the request path | ||
| for (let [name, value] of Array.from(cookies.entries())) { | ||
| let realmPath = parseAuthCookieName(name); | ||
| if (realmPath && requestPath.startsWith(realmPath)) { | ||
| return value; | ||
| } |
There was a problem hiding this comment.
findAuthCookieForPath returns the first auth cookie whose decoded realmPath is a prefix of requestPath. If multiple realm cookies are present (nested realms like /a/ and /a/b/), iteration order can cause the wrong token to be selected. Consider selecting the most specific match (longest matching realmPath) and using path-boundary matching (not just startsWith) to avoid /foo matching /foobar when a client sends a crafted Cookie header.
| .use( | ||
| cors({ | ||
| origin: '*', | ||
| allowHeaders: | ||
| 'Authorization, Content-Type, If-Match, If-None-Match, X-Requested-With, X-Boxel-Client-Request-Id, X-Boxel-Assume-User, X-HTTP-Method-Override, X-Boxel-Disable-Module-Cache, X-Filename', | ||
| allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS,QUERY', | ||
| }), | ||
| ) | ||
| .use(async (ctx, next) => { | ||
| // For cross-origin image/binary requests, upgrade CORS to support | ||
| // cookie-based authentication. Images loaded via <img> tags cannot | ||
| // send Authorization headers, so they rely on cookies set by the | ||
| // _realm-auth endpoint. The CORS spec requires a specific origin | ||
| // (not '*') when credentials are enabled. | ||
| await next(); | ||
| let requestOrigin = ctx.get('Origin'); | ||
| if (requestOrigin) { | ||
| let contentType = ctx.response.get('Content-Type') || ''; | ||
| if ( | ||
| contentType.startsWith('image/') || | ||
| contentType === 'application/octet-stream' | ||
| ) { | ||
| ctx.set('Access-Control-Allow-Origin', requestOrigin); | ||
| ctx.set('Access-Control-Allow-Credentials', 'true'); | ||
| ctx.vary('Origin'); | ||
| } | ||
| } |
There was a problem hiding this comment.
The server still installs cors({ origin: '*' }), but the host app now calls /_realm-auth with credentials: 'include'. In browsers, credentialed CORS responses must not use Access-Control-Allow-Origin: *, so this setup will prevent cookies from being set on cross-origin /_realm-auth requests. Also, reflecting Origin for any image/octet-stream response enables credentialed reads from any requesting origin; this should be restricted to an allowlist of trusted origins. Consider switching to a CORS config that sets credentials: true and returns a validated origin (and applies to /_realm-auth responses), rather than only post-processing image responses.



Summary
<img>tags to load images from authenticated realmsSecurity Model
Changes
packages/realm-server/utils/auth-cookie.ts- New utility module for cookie encoding/parsingpackages/realm-server/middleware/cookie-auth.ts- New middleware to convert cookies to Authorization headerspackages/realm-server/handlers/handle-realm-auth.ts- Set cookies alongside JWT tokenspackages/realm-server/server.ts- Add cookie middleware and enable CORS credentialspackages/host/app/services/realm-server.ts- Addcredentials: 'include'to/_realm-authfetchpackages/realm-server/tests/realm-auth-test.ts- Tests for cookie authenticationTest plan
POST /_realm-authreturns Set-Cookie headers<img>tags load images without 401 errorsCloses CS-10147
🤖 Generated with Claude Code