Files
computer-playbook/roles/web-app-bluesky/files/playwright/test-oidc-broker-handoff.js
kevinveenbirkenbachandClaude Opus 5.5 c94193c51c fix(web-app-bluesky): move the PDS to 0.4.66 so AppView proxy calls carry lxm
PDS 0.4.0 (@atproto/pds 0.4.0-beta.7) signs the service-auth JWT it
attaches to proxied app.bsky.* calls with iss, aud and exp only
(createServiceJwt in dist/index.js of the image). The AppView requires
the lxm claim and rejects every such call with 401
BadJwtLexiconMethod, so social-app shows errorMessageView on Following
and Notifications. 0.4.66 passes the called method as lxm into every
service JWT it signs and sends every proxied call through the
SSRF-guarded proxy agent.

0.4.66 also replaces the whole-handle limit of 0.4.0 (30 characters
for the entire handle) with a limit of 18 on the part in front of the
service domain, so kc-administrator.<domain> passes for any domain
length, including kc-administrator.infinito.nexus (31).

- meta/services.yml: pin ghcr.io/bluesky-social/pds to 0.4.66.
- vars/main.yml: the public AppView URL is always https. Onion-primary
  deployments took the protocol from the local TLS setting and sent the
  service JWT to api.bsky.app over plain http. services.view.enabled is
  evaluated as a bool.
- templates/env.j2: drop PDS_ADMIN_EMAIL, which neither 0.4.0 nor
  0.4.66 reads. PDS_CONTACT_EMAIL_ADDRESS stays unset on purpose:
  describeServer would publish the administrator's address.
- Administration.md: the account database only migrates forward; a
  rollback restores the bluesky_pds_data backup together with the old
  pin.
- Playwright: the broker login moves into _shared.js and waits for a
  landing outside /oauth2/, because the bare base URL also matches the
  intermediate /oauth2/callback hop. New scenarios check that the PDS
  proxies getTimeline and listNotifications to the AppView with a JWT
  it accepts, for the broker session (OIDC) and for a native session
  (oauth2 disabled), and that social-app renders Following and
  Notifications without errorMessageView or lxm page errors. The
  rendering scenario is clearnet only: social-app boots only after
  loading its age-assurance config from public.api.bsky.app.

Merge note for feature/store-i18n: 19b491b014 raises the pin to 0.4.21
and renames the contact-mail line to PDS_CONTACT_EMAIL_ADDRESS. Resolve
the pin to 0.4.66 (0.4.21 still signs without lxm, and the account
database cannot migrate back) and keep the line deleted. Take the union
of both sides in _shared.js and playwright.spec.js.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 21:45:53 +02:00

46 lines
2.4 KiB
JavaScript

const { test, expect } = require("@playwright/test");
exports.register = function (shared) {
test("OIDC: oauth2-proxy + login-broker drop a Bluesky session into social-app via Keycloak (variant A+)", async ({ page }) => {
// The user-facing entry point at `web.bluesky.<domain>/` is gated
// by the project's oauth2-proxy sidecar. Visiting the root while
// unauthenticated MUST trigger an OIDC redirect to the realm's
// authorization endpoint. After Keycloak login the request flows
// through oauth2-proxy to the in-role login-broker, which (a)
// auto-provisions the PDS account on first visit, (b) decrypts the
// AES-256-GCM-encrypted app-password from the user's Keycloak
// attribute, (c) creates a PDS session via createSession, and (d)
// renders an HTML handoff page that drops the session JWTs into
// localStorage["BSKY_STORAGE"] before redirecting to "/" — at which
// point the social-app is reached as an authenticated Bluesky user
// without ever showing the synthesised app-password to the
// browser.
shared.skipUnlessServiceEnabled("sso");
await shared.loginThroughBroker(page);
// The broker writes a schema-compliant `BSKY_STORAGE` payload
// built from upstream `social-app@1.121.0`'s persisted defaults
// PLUS the PDS session JWTs. Verify both that the entry exists
// and that it carries an `accessJwt` — the latter is the only
// observable proof that the broker's PDS createSession completed
// and the handoff actually flowed through.
const storageProbe = await page.evaluate(() => {
const raw = localStorage.getItem("BSKY_STORAGE");
if (!raw) return { present: false };
try {
const parsed = JSON.parse(raw);
const cur = parsed && parsed.session && parsed.session.currentAccount;
return {
present: true,
hasCurrentAccount: !!cur,
hasAccessJwt: !!(cur && cur.accessJwt),
};
} catch {
return { present: true, hasCurrentAccount: false, hasAccessJwt: false, parseError: true };
}
});
expect(storageProbe.present, `BSKY_STORAGE missing — handoff never reached the browser. Probe: ${JSON.stringify(storageProbe)}`).toBe(true);
expect(storageProbe.hasAccessJwt, `BSKY_STORAGE has no accessJwt — broker rendered the page but PDS createSession did not feed a usable session. Probe: ${JSON.stringify(storageProbe)}`).toBe(true);
});
};