Skip to content

Add authenticated image display via read-only auth cookie#3943

Open
lukemelia wants to merge 6 commits intomainfrom
cs-10147-authenticated-image-display-via-read-only-auth-cookie
Open

Add authenticated image display via read-only auth cookie#3943
lukemelia wants to merge 6 commits intomainfrom
cs-10147-authenticated-image-display-via-read-only-auth-cookie

Conversation

@lukemelia
Copy link
Contributor

Summary

  • Adds HttpOnly cookies alongside JWT tokens to enable <img> tags to load images from authenticated realms
  • Browsers automatically include cookies with image requests, solving the 401 error issue
  • Cookies only work for GET/HEAD (read-only), mutating requests still require Authorization headers

Security Model

  • SameSite=Lax prevents CSRF attacks
  • HttpOnly flag prevents XSS token theft
  • Path-scoped cookies ensure isolation between realms
  • Cookies are ignored for POST/PUT/DELETE/PATCH requests

Changes

  • packages/realm-server/utils/auth-cookie.ts - New utility module for cookie encoding/parsing
  • packages/realm-server/middleware/cookie-auth.ts - New middleware to convert cookies to Authorization headers
  • packages/realm-server/handlers/handle-realm-auth.ts - Set cookies alongside JWT tokens
  • packages/realm-server/server.ts - Add cookie middleware and enable CORS credentials
  • packages/host/app/services/realm-server.ts - Add credentials: 'include' to /_realm-auth fetch
  • packages/realm-server/tests/realm-auth-test.ts - Tests for cookie authentication

Test plan

  • Lint passes
  • POST /_realm-auth returns Set-Cookie headers
  • GET request with only cookie succeeds for protected realm
  • POST request with only cookie fails (requires Authorization header)
  • Cookie doesn't override explicit Authorization header
  • Manual testing: <img> tags load images without 401 errors

Closes CS-10147

🤖 Generated with Claude Code

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>
@github-actions
Copy link

github-actions bot commented Feb 3, 2026

Preview deployments

@github-actions
Copy link

github-actions bot commented Feb 3, 2026

Host Test Results

    1 files  ±    0      1 suites  ±0   3h 18m 14s ⏱️ + 1h 32m 42s
1 944 tests +    3  1 927 ✅ +    3  17 💤 ± 0  0 ❌ ±0 
3 739 runs  +1 783  3 708 ✅ +1 769  31 💤 +14  0 ❌ ±0 

Results for commit f05385d. ± Comparison against base commit bcc525f.

♻️ This comment has been updated with latest results.

lukemelia and others added 5 commits February 3, 2026 15:37
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>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 → Authorization injection middleware.
  • Sets auth cookies during POST /_realm-auth and 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()));
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +149 to +160
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;
}
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +177 to +203
.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');
}
}
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@backspace
Copy link
Contributor

How can I exercise this? I’m running it locally and added an image card but it 401s:

e8c0a21ef0e0f5101a0d9e590435203ca7bf96af 2026-02-05 19-01-49 e8c0a21ef0e0f5101a0d9e590435203ca7bf96af 2026-02-05 19-02-45 image-maybe 2026-02-05 19-03-05

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants