Skip to content

Share fails when self-hosted behind an auth proxy (SSO reverse proxy) #50

@jbrooksbartlett

Description

@jbrooksbartlett

Context

We're at Spotify and have self-hosted crit-web behind an SSO reverse proxy that requires browser-based authentication. The proxy sits in front of the app and redirects unauthenticated requests to a login page — there's no way to exempt specific routes, and no programmatic auth mechanism (API keys, service tokens, etc.).

The self-hosted instance works great for viewing shared reviews. The problem is the share flow itself.

Problem

When a user clicks Share in the crit UI, the flow is:

  1. Browser JS sends POST /api/share to the local Go server
  2. The Go server makes a server-side POST {shareURL}/api/reviews to crit-web
  3. Our auth proxy intercepts this request (no session cookie), returns an HTML login page
  4. The Go server tries to decode HTML as JSON → fails with:
    Share failed: decoding share response: invalid character '<' looking for beginning of value

The core issue: the Go binary's server-side HTTP call can never authenticate because the auth proxy requires an interactive browser-based login flow. There's no token or header it could attach.

The same problem affects unpublish, which makes a cross-origin DELETE from the browser to crit-web (app.js:5809-5837) — the auth proxy's session cookie isn't sent on cross-origin fetch requests due to SameSite=Lax.

Proposed Solution: Popup + postMessage

We'd like to propose adding a browser-native share flow using window.postMessage for self-hosted instances behind auth proxies:

Flow

  1. User clicks "Share" in crit UI (localhost:PORT)
  2. Browser JS opens popup: window.open(shareURL + '/share-receiver')
  3. Auth proxy authenticates user in popup (interactive browser login)
  4. /share-receiver page loads, sends postMessage('ready') to opener
  5. Localhost page sends review data via postMessage
  6. /share-receiver makes same-origin POST /api/reviews (cookie included automatically)
  7. Returns {url, delete_token} via postMessage back to opener
  8. Localhost page shows share URL

Why this works

  • The popup is a top-level navigation to the crit-web origin, so the auth proxy authenticates normally
  • After auth, the /share-receiver page is on the same origin as /api/reviews, so the session cookie is included automatically
  • postMessage transfers data in-memory with no size limits (10MB review payloads are fine)
  • The popup + postMessage pattern is well-established for OAuth flows (IETF draft)
  • Unpublish can use the same relay — send {type: 'unpublish', delete_token} via postMessage

Changes needed

crit-web:

  • New page/route at /share-receiver with JavaScript to handle the postMessage relay
  • Route added to router.ex
  • Origin validation: receiver validates event.origin matches localhost pattern (similar to existing LocalhostCors plug)

crit CLI (app.js):

  • When shareURL is configured (self-hosted), use popup flow instead of POST /api/share
  • Could be opt-in via a config flag like auth_required: true or auto-detected (try POST, fallback to popup on HTML response)

Security considerations

  • Both sides validate event.origin (localhost validates crit-web origin, receiver validates localhost origin)
  • window.open() is called from a user-initiated click (no popup blocker issues)
  • crit-web does not set Cross-Origin-Opener-Policy, so window.opener remains accessible

Questions

  1. Does this approach make sense for crit-web? Is there a simpler alternative we're missing?
  2. Would you prefer the popup flow to be the default for self-hosted instances, or opt-in via config?
  3. Should the /share-receiver page be a LiveView or a simple static HTML page with vanilla JS?
  4. We're happy to contribute a PR for this — would that be welcome?

Environment

  • crit-web: self-hosted via Docker (ghcr.io/tomasz-tomczyk/crit-web:latest)
  • Auth: SSO reverse proxy in front of the app (all routes require browser auth, no exemptions)
  • crit CLI: latest version, configured with CRIT_SHARE_URL pointing to our instance

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions