diff --git a/roles/web-app-bluesky/README.md b/roles/web-app-bluesky/README.md index 9f200d8238..cb7d69f624 100644 --- a/roles/web-app-bluesky/README.md +++ b/roles/web-app-bluesky/README.md @@ -114,7 +114,7 @@ docker run --rm -it \ OIDC integration follows the variant A+ pattern documented in requirement 013: `web-app-keycloak`'s SSO-proxy sidecar is the gate, the in-role login-broker is the session-handoff agent, and the official `social-app` web client is the user-facing UI. -The broker MUST receive `X-Forwarded-User`, `X-Forwarded-Preferred-Username`, and `X-Forwarded-Email` from `oauth2-proxy`. On a user's first authenticated visit the broker calls the PDS `com.atproto.server.createAccount` endpoint with a synthesised app-password, encrypts that password with AES-256-GCM under a key from the project's credentials vault, and caches the ciphertext in the broker's in-process `sessionCache` keyed by Keycloak username. Subsequent visits decrypt the cached password in-broker, exchange it for a PDS session via `com.atproto.server.createSession`, and seed `localStorage["BSKY_STORAGE"]` so the social-app finds an authenticated session at boot. A broker restart drops the cache; the next SSO visit triggers a fresh `createAccount` and the previous app-password remains valid in PDS until rotated. +The broker MUST receive `X-Forwarded-User`, `X-Forwarded-Preferred-Username`, and `X-Forwarded-Email` from `oauth2-proxy`. On a user's first authenticated visit the broker calls the PDS `com.atproto.server.createAccount` endpoint with a synthesised app-password, encrypts that password with AES-256-GCM under a key from the project's credentials vault, and records handle, DID and ciphertext in the broker volume keyed by Keycloak username. Subsequent visits, also after a broker restart, decrypt the stored password in-broker, exchange it for a PDS session via `com.atproto.server.createSession`, and seed `localStorage["BSKY_STORAGE"]` so the social-app finds an authenticated session at boot. The handle is the username without prefix, `i8-` for a name the PDS reserves, and a handle the user picks in a form when the username exceeds the PDS limit of 18 characters. LDAP rides the same broker path: Keycloak federates LDAP via `svc-db-openldap` and the broker reads the resulting Keycloak headers via `oauth2-proxy`. Direct LDAP-to-PDS sync is NOT supported, the Keycloak federation layer is the single authority for user identity. diff --git a/roles/web-app-bluesky/files/login-broker/Dockerfile b/roles/web-app-bluesky/files/login-broker/Dockerfile index e2fe71349b..2cd57ab0e4 100644 --- a/roles/web-app-bluesky/files/login-broker/Dockerfile +++ b/roles/web-app-bluesky/files/login-broker/Dockerfile @@ -10,7 +10,9 @@ RUN apk add --no-cache ca-certificates && update-ca-certificates WORKDIR /app -COPY package.json server.js ./ +COPY package.json server.js handle.js store.js ./ + +RUN mkdir -p /data && chown node:node /data USER node EXPOSE 8080 diff --git a/roles/web-app-bluesky/files/login-broker/README.md b/roles/web-app-bluesky/files/login-broker/README.md index cfe89ebb96..2ed13a1cb0 100644 --- a/roles/web-app-bluesky/files/login-broker/README.md +++ b/roles/web-app-bluesky/files/login-broker/README.md @@ -7,13 +7,17 @@ The broker sits behind `web-app-keycloak`'s SSO-proxy sidecar and in front of th official `@bluesky-social/social-app` web client. On a user's first OIDC-authenticated visit, the broker: -1. Looks up the Keycloak user via the Admin REST API. The - service-account credentials are mounted as env, see `KC_ADMIN_*`. +1. Derives the handle `.` from the + Keycloak username, or `i8-.` when the + PDS answers `Reserved handle`. A first label longer than 18 + characters cannot be a handle; the broker then shows a form at + `/sso/handle` where the user picks one of 3 to 18 characters from + `[a-z0-9-]` that contains a hyphen and never contains `i8-`. 2. Provisions a Bluesky PDS account via - `com.atproto.server.createAccount` if no encrypted app-password is - recorded on the user yet, encrypts the synthesised app-password - with **AES-256-GCM**, and stores the ciphertext as the - `bluesky_app_password_enc` user attribute. + `com.atproto.server.createAccount` if `BROKER_STORE_PATH` holds no + entry for the username yet, encrypts the synthesised app-password + with **AES-256-GCM**, and records the handle, the DID and the + ciphertext there, keyed by the Keycloak username. 3. Decrypts the stored app-password in-process and exchanges it for a PDS session via `com.atproto.server.createSession`. 4. Renders an HTML handoff page that drops the resulting session @@ -35,6 +39,7 @@ synthesised app-password. | `PDS_URL` | Public Bluesky PDS base URL (`https://api.bluesky.`), written into the browser `service` field. | | `PDS_INTERNAL_URL` | In-stack PDS URL for server-side XRPC calls (`http://pds:3000`); defaults to `PDS_URL` when unset. | | `PDS_HANDLE_DOMAIN` | Suffix for synthesised handles (``). | +| `BROKER_STORE_PATH` | JSON file in the broker volume that maps usernames to their PDS account. | | `PDS_INVITE_CODE` | Optional invite code for gated PDS deployments. | | `KC_ADMIN_BASE_URL` | Keycloak base URL (`https://auth.`). | | `KC_ADMIN_REALM` | Realm of the broker's service account (typically the same as user realm).| diff --git a/roles/web-app-bluesky/files/login-broker/handle.js b/roles/web-app-bluesky/files/login-broker/handle.js new file mode 100644 index 0000000000..71732a175f --- /dev/null +++ b/roles/web-app-bluesky/files/login-broker/handle.js @@ -0,0 +1,64 @@ +"use strict"; + +const PDS_HANDLE_LABEL_MIN = 3; +const PDS_HANDLE_LABEL_MAX = 18; +const RESERVED_PREFIX = "i8-"; + +/** + * First handle label derived from a Keycloak username. + * + * @param {string} username Keycloak username + * @returns {string} lowercase label of [a-z0-9-], without prefix + */ +function deriveLabel(username) { + const mapped = String(username || "") + .toLowerCase() + .slice(0, 256) + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, ""); + return mapped || "user"; +} + +/** + * Label for a name the PDS reserves ("admin", "administrator", ...). + * + * @param {string} label derived label + * @returns {string} label carrying the reserved-name prefix + */ +function prefixedLabel(label) { + return `${RESERVED_PREFIX}${label}`; +} + +/** + * @param {string} label first handle label + * @returns {boolean} whether the PDS accepts its length + */ +function fitsPds(label) { + return label.length >= PDS_HANDLE_LABEL_MIN && label.length <= PDS_HANDLE_LABEL_MAX; +} + +/** + * @param {string} label label the user typed + * @returns {string|null} reason shown in the form, null when acceptable + */ +function chosenLabelError(label) { + if (!/^[a-z0-9-]+$/.test(label)) return "Use lowercase letters, digits and hyphens only."; + if (!fitsPds(label)) { + return `Use ${PDS_HANDLE_LABEL_MIN} to ${PDS_HANDLE_LABEL_MAX} characters.`; + } + if (!label.includes("-")) return "Include at least one hyphen (-)."; + if (label.includes(RESERVED_PREFIX)) return `Do not use "${RESERVED_PREFIX}" anywhere in the handle.`; + if (label.startsWith("-") || label.endsWith("-")) return "Do not start or end the handle with a hyphen."; + return null; +} + +module.exports = { + PDS_HANDLE_LABEL_MAX, + PDS_HANDLE_LABEL_MIN, + RESERVED_PREFIX, + chosenLabelError, + deriveLabel, + fitsPds, + prefixedLabel, +}; diff --git a/roles/web-app-bluesky/files/login-broker/server.js b/roles/web-app-bluesky/files/login-broker/server.js index aceba6b871..95dfb37cb8 100644 --- a/roles/web-app-bluesky/files/login-broker/server.js +++ b/roles/web-app-bluesky/files/login-broker/server.js @@ -1,4 +1,3 @@ -// nocheck: mirrored-unit-test - a standalone Express server whose module body binds a port and reads the container environment at import /* * Bluesky login-broker. * @@ -49,6 +48,16 @@ const http = require("node:http"); const https = require("node:https"); const crypto = require("node:crypto"); const { URL } = require("node:url"); +const { + PDS_HANDLE_LABEL_MAX, + PDS_HANDLE_LABEL_MIN, + RESERVED_PREFIX, + chosenLabelError, + deriveLabel, + fitsPds, + prefixedLabel +} = require("./handle"); +const { openStore } = require("./store"); // --- Configuration -------------------------------------------------- @@ -72,7 +81,9 @@ const CONFIG = { // nocheck: env-default the broker owns its own cookie, no role renders it handoffCookieMaxAgeSec: parseInt(process.env.HANDOFF_COOKIE_MAX_AGE || "3300", 10), insecureTls: (process.env.INSECURE_TLS || "").toLowerCase() === "true", - logoutPath: "/sso/logout" + storePath: requireEnv("BROKER_STORE_PATH"), + logoutPath: "/sso/logout", + chooseHandlePath: "/sso/handle" }; function requireEnv(name) { @@ -163,18 +174,6 @@ function fetchJson(method, urlString, opts = {}) { // --- PDS client ----------------------------------------------------- -function sanitiseHandle(username) { - if (!username) return "kc-user"; - const lower = username.toLowerCase().slice(0, 256); - const mapped = lower.replace(/[^a-z0-9-]+/g, "-").replace(/^-+/, "").replace(/-+$/, ""); - // PDS reserves common service handles ("administrator", "admin", "api", - // "support", "www", "bsky", "atproto", etc.) and rejects createAccount - // with `HandleNotAvailable: Reserved handle`. Always prefix with `kc-` - // so Keycloak-derived handles cannot collide with that reserved list, - // independent of which usernames the realm carries. - return `kc-${mapped || "user"}`; -} - async function pdsCreateAccount({ handle, email, password }) { const url = `${CONFIG.pdsInternalUrl}/xrpc/com.atproto.server.createAccount`; const body = { handle, email, password }; @@ -195,7 +194,10 @@ async function pdsCreateSession({ handle, password }) { const url = `${CONFIG.pdsInternalUrl}/xrpc/com.atproto.server.createSession`; const res = await fetchJson("POST", url, { body: { identifier: handle, password } }); if (res.status < 200 || res.status >= 300) { - throw new Error(`PDS createSession failed: status=${res.status} body=${res.raw}`); + const err = new Error(`PDS createSession failed: status=${res.status} body=${res.raw}`); + err.pdsStatus = res.status; + err.pdsBody = res.body; + throw err; } return res.body; } @@ -232,61 +234,109 @@ async function pdsAdminUpdatePassword(did, newPassword) { // --- High-level orchestration -------------------------------------- -// Per-user PDS app-password / DID / handle cache, keyed by Keycloak -// username. In-memory, AES-256-GCM-encrypted at rest. -// -// We deliberately do NOT write the encrypted blob into Keycloak user -// attributes (the previous design): on a realm with LDAP federation -// in WRITABLE mode the bluesky_* attribute write triggers an LDAP -// schema-violating sync push and leaves the federated user in a -// partially-synced state, which then breaks subsequent direct OIDC -// logins for that user (every other realm-OIDC test in the matrix -// would regress as a side-effect of running the broker once). The -// broker is a single-instance sidecar so an in-memory map is enough; -// on broker restart the user re-creates a PDS app-password on first -// SSO visit. The previous app-password remains valid in PDS until -// rotated explicitly — acceptable for this auto-provision flow. -const sessionCache = new Map(); +// Exception: the account map lives in the broker's own volume, never in a +// Keycloak user attribute; a bluesky_* attribute write under LDAP federation +// in WRITABLE mode triggers a schema-violating sync push that breaks later +// OIDC logins of that user. +const store = openStore(CONFIG.storePath); -async function ensurePdsSession({ kcUsername, kcEmail }) { - const fullHandle = `${sanitiseHandle(kcUsername)}.${CONFIG.pdsHandleDomain}`; - const cached = sessionCache.get(kcUsername); - let appPasswordEnc = cached ? cached.appPasswordEnc : null; - let did = cached ? cached.did : null; - if (!appPasswordEnc) { - const synthesised = crypto.randomBytes(18).toString("base64url"); +class HandleChoiceRequired extends Error {} + +function pdsMessageIncludes(err, text) { + return Boolean( + err.pdsBody && typeof err.pdsBody.message === "string" && err.pdsBody.message.includes(text) + ); +} + +function accountEmail(kcUsername, kcEmail) { + return kcEmail || `${kcUsername}@bridge.local`; +} + +async function createAccountWithLabel({ label, kcUsername, kcEmail, password, recoverTaken }) { + const handle = `${label}.${CONFIG.pdsHandleDomain}`; + try { + const created = await pdsCreateAccount({ handle, email: accountEmail(kcUsername, kcEmail), password }); + return { handle, did: created.did }; + } catch (err) { + if (!recoverTaken || !pdsMessageIncludes(err, "Handle already taken")) throw err; + const owner = store.ownerOf(handle); + if (owner && owner !== kcUsername) throw err; + const did = await pdsResolveHandle(handle); + await pdsAdminUpdatePassword(did, password); + return { handle, did }; + } +} + +async function provisionAccount({ kcUsername, kcEmail, chosenLabel }) { + const password = crypto.randomBytes(18).toString("base64url"); + const request = { kcUsername, kcEmail, password }; + let account; + if (chosenLabel) { + account = await createAccountWithLabel(Object.assign({ label: chosenLabel, recoverTaken: false }, request)); + } else { + const label = deriveLabel(kcUsername); + if (!fitsPds(label)) throw new HandleChoiceRequired(); try { - const created = await pdsCreateAccount({ - handle: fullHandle, - email: kcEmail || `${kcUsername}@bridge.local`, - password: synthesised - }); - did = created.did || did; + account = await createAccountWithLabel(Object.assign({ label, recoverTaken: true }, request)); } catch (err) { - // Recover from `Handle already taken` — happens when the broker's - // in-memory cache has been lost (container restart, cross-variant - // deploy that shares the PDS data volume) but the PDS still has - // the account from a previous broker session. Use the PDS admin - // API to reset the password to a fresh value we control, so the - // subsequent createSession works again. - const isHandleTaken = - err.pdsBody && err.pdsBody.error === "InvalidRequest" && - typeof err.pdsBody.message === "string" && - err.pdsBody.message.includes("Handle already taken"); - if (!isHandleTaken) throw err; - did = await pdsResolveHandle(fullHandle); - await pdsAdminUpdatePassword(did, synthesised); + if (!pdsMessageIncludes(err, "Reserved handle")) throw err; + const reservedLabel = prefixedLabel(label); + if (!fitsPds(reservedLabel)) throw new HandleChoiceRequired(); + account = await createAccountWithLabel(Object.assign({ label: reservedLabel, recoverTaken: true }, request)); } - appPasswordEnc = encrypt(synthesised); - sessionCache.set(kcUsername, { appPasswordEnc, did, handle: fullHandle }); + } + const entry = { handle: account.handle, did: account.did, appPasswordEnc: encrypt(password) }; + store.set(kcUsername, entry); + return entry; +} + +const provisioning = new Map(); + +function provisionOnce(request) { + const pending = provisioning.get(request.kcUsername); + if (pending) return pending; + const run = provisionAccount(request).finally(() => provisioning.delete(request.kcUsername)); + provisioning.set(request.kcUsername, run); + return run; +} + +function sessionFor(entry) { + return pdsCreateSession({ handle: entry.handle, password: decrypt(entry.appPasswordEnc) }); +} + +async function storedSession(kcUsername, entry) { + try { + return await sessionFor(entry); + } catch (err) { + if (!pdsMessageIncludes(err, "Invalid identifier or password")) throw err; + } + const password = crypto.randomBytes(18).toString("base64url"); + await pdsAdminUpdatePassword(entry.did, password); + const repaired = Object.assign({}, entry, { appPasswordEnc: encrypt(password) }); + try { + const session = await sessionFor(repaired); + store.set(kcUsername, repaired); + return session; + } catch (err) { + if (!pdsMessageIncludes(err, "Invalid identifier or password")) throw err; + store.delete(kcUsername); + return null; + } +} + +async function ensurePdsSession({ kcUsername, kcEmail, chosenLabel }) { + const stored = store.get(kcUsername); + let session = stored ? await storedSession(kcUsername, stored) : null; + let entry = stored; + if (!session) { + entry = await provisionOnce({ kcUsername, kcEmail, chosenLabel }); + session = await sessionFor(entry); } - const password = decrypt(appPasswordEnc); - const session = await pdsCreateSession({ handle: fullHandle, password }); return { service: CONFIG.pdsUrl, - did: session.did || did, - handle: session.handle || fullHandle, - email: kcEmail || `${kcUsername}@bridge.local`, + did: session.did || entry.did, + handle: session.handle || entry.handle, + email: accountEmail(kcUsername, kcEmail), emailConfirmed: true, accessJwt: session.accessJwt, refreshJwt: session.refreshJwt @@ -394,6 +444,59 @@ function renderHandoff(session, redirectTo) { `; } +function renderHandleForm({ error, value }) { + const errorHtml = error ? `

${escapeHtml(error)}

` : ""; + return ` + + + +Choose your Bluesky handle + + + + +

Choose your Bluesky handle

+

Your username is longer than the ${PDS_HANDLE_LABEL_MAX} characters a Bluesky handle allows.

+

Pick ${PDS_HANDLE_LABEL_MIN} to ${PDS_HANDLE_LABEL_MAX} lowercase letters, digits and hyphens. The handle must contain a hyphen (-) and must not contain "${RESERVED_PREFIX}".

+${errorHtml} +
+ +.${escapeHtml(CONFIG.pdsHandleDomain)} + +
+ +`; +} + +function readBody(req, limitBytes) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on("data", (chunk) => { + size += chunk.length; + if (size > limitBytes) { + reject(new Error("request body too large")); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +function isSameOrigin(req) { + const origin = req.headers["origin"]; + const host = (req.headers["x-forwarded-host"] || req.headers["host"] || "").toString().split(",")[0].trim(); + if (!origin || !host) return false; + try { + return new URL(origin).hostname === new URL(`http://${host}`).hostname; + } catch { + return false; + } +} + function endHtml(res, status, html) { res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", @@ -471,13 +574,49 @@ const server = http.createServer(async (req, res) => { return; } - const session = await ensurePdsSession({ kcUsername, kcEmail }); + if (path === CONFIG.chooseHandlePath && req.method === "POST") { + if (!isSameOrigin(req)) { + endText(res, 403, "Cross-origin handle submission refused."); + return; + } + const chosenLabel = (new URLSearchParams(await readBody(req, 1024)).get("handle") || "").trim(); + const error = chosenLabelError(chosenLabel); + if (error) { + endHtml(res, 400, renderHandleForm({ error, value: chosenLabel })); + return; + } + let chosenSession; + try { + chosenSession = await ensurePdsSession({ kcUsername, kcEmail, chosenLabel }); + } catch (err) { + if (!pdsMessageIncludes(err, "Handle already taken")) throw err; + endHtml(res, 409, renderHandleForm({ error: "This handle is already taken.", value: chosenLabel })); + return; + } + console.log(`[broker:${reqId}] chosen handle ${logSafe(chosenSession.handle)} ready (${Date.now() - reqStart}ms)`); + endHtml(res, 200, renderHandoff(chosenSession, "/")); + return; + } + + let session; + try { + session = await ensurePdsSession({ kcUsername, kcEmail }); + } catch (err) { + if (!(err instanceof HandleChoiceRequired)) throw err; + console.log(`[broker:${reqId}] username too long for a handle → handle form (${Date.now() - reqStart}ms)`); + endHtml(res, 200, renderHandleForm({ error: null, value: "" })); + return; + } console.log(`[broker:${reqId}] PDS session ready did=${logSafe(session.did)} handle=${logSafe(session.handle)} hasAccessJwt=${!!session.accessJwt} (${Date.now() - reqStart}ms)`); const html = renderHandoff(session, requestUrl.pathname + (requestUrl.search || "")); endHtml(res, 200, html); console.log(`[broker:${reqId}] handoff HTML sent (${Date.now() - reqStart}ms)`); } catch (err) { console.error(`[broker:${reqId}] error:`, err.stack || err.message); + if (pdsMessageIncludes(err, "Email already taken")) { + endText(res, 409, "Another Bluesky account on this server already uses your e-mail address, most likely a legacy kc- account. Ask an administrator to delete it."); + return; + } endText(res, 500, `Broker error: ${escapeHtml(err.message)}`); } }); diff --git a/roles/web-app-bluesky/files/login-broker/store.js b/roles/web-app-bluesky/files/login-broker/store.js new file mode 100644 index 0000000000..a15809e10f --- /dev/null +++ b/roles/web-app-bluesky/files/login-broker/store.js @@ -0,0 +1,43 @@ +"use strict"; + +const fs = require("node:fs"); + +/** + * Persistent map from Keycloak username to the broker's PDS account. + * + * @param {string} file JSON file inside the broker's volume + * @returns {{get: function(string): (object|null), set: function(string, object): void, delete: function(string): void, ownerOf: function(string): (string|null)}} + * get returns {handle, did, appPasswordEnc} or null; set and delete write + * the file through a rename; ownerOf names the username that holds a handle + */ +function openStore(file) { + const entries = new Map( + fs.existsSync(file) ? Object.entries(JSON.parse(fs.readFileSync(file, "utf8"))) : [], + ); + + function save() { + const tmp = `${file}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(entries)), { mode: 0o600 }); + fs.renameSync(tmp, file); + } + + return { + get: (username) => entries.get(username) || null, + set: (username, entry) => { + entries.set(username, entry); + save(); + }, + delete: (username) => { + entries.delete(username); + save(); + }, + ownerOf: (handle) => { + for (const [username, entry] of entries) { + if (entry.handle === handle) return username; + } + return null; + }, + }; +} + +module.exports = { openStore }; diff --git a/roles/web-app-bluesky/files/playwright/_shared.js b/roles/web-app-bluesky/files/playwright/_shared.js index a771f91692..6c0f936ac2 100644 --- a/roles/web-app-bluesky/files/playwright/_shared.js +++ b/roles/web-app-bluesky/files/playwright/_shared.js @@ -16,7 +16,9 @@ const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || ""); const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN || ""); const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME); const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD); +const adminEmail = decodeDotenvQuotedValue(process.env.ADMIN_EMAIL || ""); const adminHandle = decodeDotenvQuotedValue(process.env.ADMIN_HANDLE || ""); +const adminHandleReserved = decodeDotenvQuotedValue(process.env.ADMIN_HANDLE_RESERVED || ""); const pdsBaseUrl = decodeDotenvQuotedValue(process.env.PDS_BASE_URL || ""); async function beforeEach({ page }) { @@ -62,7 +64,9 @@ module.exports = { canonicalDomain, adminUsername, adminPassword, + adminEmail, adminHandle, + adminHandleReserved, pdsBaseUrl, }, skipUnlessServiceEnabled, diff --git a/roles/web-app-bluesky/files/playwright/test-appview-native.js b/roles/web-app-bluesky/files/playwright/test-appview-native.js index 17e944015a..bd8ff4136e 100644 --- a/roles/web-app-bluesky/files/playwright/test-appview-native.js +++ b/roles/web-app-bluesky/files/playwright/test-appview-native.js @@ -7,14 +7,14 @@ const { expectPdsProxiesAppView } = require("./_appview"); exports.register = function (shared) { test("native: PDS proxies Following and Notifications to its configured AppView with an lxm-bound service JWT (no oauth2)", async ({ page }) => { skipUnlessServiceDisabled("sso"); - const { adminHandle, adminPassword, pdsBaseUrl } = shared.env; - expect(adminHandle, "ADMIN_HANDLE must be set when oauth2 is disabled").toBeTruthy(); + const { adminEmail, adminPassword, pdsBaseUrl } = shared.env; + expect(adminEmail, "ADMIN_EMAIL must be set when oauth2 is disabled").toBeTruthy(); expect(adminPassword, "ADMIN_PASSWORD must be set when oauth2 is disabled").toBeTruthy(); expect(pdsBaseUrl, "PDS_BASE_URL must be set when oauth2 is disabled").toBeTruthy(); const session = await apiFetchOnion(page.request, `${pdsBaseUrl}/xrpc/com.atproto.server.createSession`, { method: "POST", - data: { identifier: adminHandle, password: adminPassword }, + data: { identifier: adminEmail, password: adminPassword }, timeout: resolveTimeout(30_000), }); expect(session.status(), `PDS createSession must return 200 (got body: ${await session.text()})`).toBe(200); diff --git a/roles/web-app-bluesky/files/playwright/test-native-admin.js b/roles/web-app-bluesky/files/playwright/test-native-admin.js index 0228db4c4c..a207ad8dbb 100644 --- a/roles/web-app-bluesky/files/playwright/test-native-admin.js +++ b/roles/web-app-bluesky/files/playwright/test-native-admin.js @@ -7,8 +7,10 @@ exports.register = function (shared) { process.env.SSO_SERVICE_ENABLED === "true", "Native login path only exists when oauth2 is disabled — when oauth2 is on, the broker owns provisioning and the OIDC test covers the journey.", ); - const { baseUrl, adminHandle, adminPassword, pdsBaseUrl } = shared.env; + const { baseUrl, adminEmail, adminHandle, adminHandleReserved, adminPassword, pdsBaseUrl } = shared.env; + expect(adminEmail, "ADMIN_EMAIL must be set when oauth2 is disabled").toBeTruthy(); expect(adminHandle, "ADMIN_HANDLE must be set when oauth2 is disabled").toBeTruthy(); + expect(adminHandleReserved, "ADMIN_HANDLE_RESERVED must be set when oauth2 is disabled").toBeTruthy(); expect(adminPassword, "ADMIN_PASSWORD must be set when oauth2 is disabled").toBeTruthy(); expect(pdsBaseUrl, "PDS_BASE_URL must be set when oauth2 is disabled").toBeTruthy(); @@ -30,13 +32,16 @@ exports.register = function (shared) { `${pdsBaseUrl}/xrpc/com.atproto.server.createSession`, { headers: { "Content-Type": "application/json" }, - data: { identifier: adminHandle, password: adminPassword }, + data: { identifier: adminEmail, password: adminPassword }, }, ); const sessionText = await sessionResp.text(); expect(sessionResp.status(), `PDS createSession must return 200 for the deployed administrator (got body: ${sessionText})`).toBe(200); const sessionJson = JSON.parse(sessionText); - expect(sessionJson.handle, "createSession response must echo the administrator handle").toBe(adminHandle); + expect( + [adminHandle, adminHandleReserved], + "createSession must echo the administrator handle, without prefix or with i8- for a reserved name", + ).toContain(sessionJson.handle); expect(typeof sessionJson.accessJwt, "createSession response must include an accessJwt").toBe("string"); await apiContext.dispose(); }); diff --git a/roles/web-app-bluesky/meta/secrets.yml b/roles/web-app-bluesky/meta/secrets.yml index 75054810a6..a12c5fe3a1 100644 --- a/roles/web-app-bluesky/meta/secrets.yml +++ b/roles/web-app-bluesky/meta/secrets.yml @@ -12,8 +12,8 @@ credentials: description: |- AES-256-GCM symmetric key used by the login-broker to encrypt the per-user Bluesky app-password held in the broker's - in-memory cache (see files/login-broker/server.js, - `sessionCache`). Format: `base64:<44-char-base64-of-32-random-bytes>`. + account store (see files/login-broker/store.js, + BROKER_STORE_PATH). Format: `base64:<44-char-base64-of-32-random-bytes>`. The broker strips the `base64:` prefix at startup. algorithm: base64_prefixed_32 validation: ^base64:[A-Za-z0-9+/]{43}=$ diff --git a/roles/web-app-bluesky/meta/volumes.yml b/roles/web-app-bluesky/meta/volumes.yml index 016db93bb4..c0745f6e35 100644 --- a/roles/web-app-bluesky/meta/volumes.yml +++ b/roles/web-app-bluesky/meta/volumes.yml @@ -5,6 +5,16 @@ pds_data: - service: pds target: /opt/pds +broker_data: + type: volume + nfs: + uid: "1000" + gid: "1000" + mode: "0700" + mounts: + - service: broker + target: /data + pdsadmin: type: bind source: "{{ [lookup('container', 'web-app-bluesky', 'directories.volumes'), 'pdsadmin', 'pdsadmin'] | path_join }}" diff --git a/roles/web-app-bluesky/tasks/04_native_admin.yml b/roles/web-app-bluesky/tasks/04_native_admin.yml index d755cc3ca4..520d43eaa3 100644 --- a/roles/web-app-bluesky/tasks/04_native_admin.yml +++ b/roles/web-app-bluesky/tasks/04_native_admin.yml @@ -30,4 +30,28 @@ failed_when: - bluesky_admin_create.status != 200 - "'Handle already taken' not in (bluesky_admin_create.content | default(''))" + - "'Reserved handle' not in (bluesky_admin_create.content | default(''))" + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + +- name: "📁 Create the native PDS admin account with the i8- prefix (idempotent)" + uri_retry: + url: "{{ BLUESKY_PDS_INTERNAL_URL }}/xrpc/com.atproto.server.createAccount" + method: POST + headers: + Content-Type: application/json + body_format: json + body: + handle: "{{ BLUESKY_ADMIN_HANDLE_RESERVED }}" + email: "{{ BLUESKY_ADMIN_EMAIL }}" + password: "{{ BLUESKY_ADMIN_PASSWORD }}" + status_code: [200, 400] + validate_certs: false + return_content: true + register: bluesky_admin_create_reserved + changed_when: + - bluesky_admin_create_reserved.status == 200 + failed_when: + - bluesky_admin_create_reserved.status != 200 + - "'Handle already taken' not in (bluesky_admin_create_reserved.content | default(''))" + when: "'Reserved handle' in (bluesky_admin_create.content | default(''))" no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" diff --git a/roles/web-app-bluesky/templates/compose.yml.j2 b/roles/web-app-bluesky/templates/compose.yml.j2 index a0e3389adc..80bcfdf3e7 100644 --- a/roles/web-app-bluesky/templates/compose.yml.j2 +++ b/roles/web-app-bluesky/templates/compose.yml.j2 @@ -41,9 +41,11 @@ hostname: {{ service_name }} {{ lookup('compose_only', 'pull_policy', 'never') }} {{ lookup('compose_restart') }} + {{ lookup('container_volumes', service_name) | indent(4) }} {{ lookup('container_ports', [BLUESKY_BROKER_SERVICE, 'http', DOCKER_BIND_HOST]) | indent(4) }} environment: BROKER_PORT: "{{ BLUESKY_BROKER_INTERNAL_PORT }}" + BROKER_STORE_PATH: "{{ BLUESKY_BROKER_STORE_PATH }}" SOCIAL_APP_URL: "http://web:{{ BLUESKY_WEB_INTERNAL_PORT }}" PDS_URL: "{{ lookup('tls', application_id, 'protocols.web') }}://{{ BLUESKY_API_DOMAIN }}" PDS_INTERNAL_URL: "http://pds:{{ BLUESKY_PDS_INTERNAL_PORT }}" diff --git a/roles/web-app-bluesky/templates/playwright.env.j2 b/roles/web-app-bluesky/templates/playwright.env.j2 index 6366092da7..6109122e86 100644 --- a/roles/web-app-bluesky/templates/playwright.env.j2 +++ b/roles/web-app-bluesky/templates/playwright.env.j2 @@ -11,8 +11,12 @@ # therefore blocked at the generic dashboard → app → logout persona-flow level — the role's OIDC + # LDAP tests carry the equivalent contract. +ADMIN_EMAIL={{ BLUESKY_ADMIN_EMAIL | dotenv_quote }} + ADMIN_HANDLE={{ BLUESKY_ADMIN_HANDLE | dotenv_quote }} +ADMIN_HANDLE_RESERVED={{ BLUESKY_ADMIN_HANDLE_RESERVED | dotenv_quote }} + ADMIN_PASSWORD={{ lookup('users', 'administrator').password | dotenv_quote }} ADMIN_USERNAME={{ lookup('users', 'administrator').username | dotenv_quote }} @@ -42,7 +46,7 @@ OIDC_ISSUER_URL={{ OIDC.CLIENT.ISSUER_URL | dotenv_quote }} PDS_BASE_URL={{ lookup('canonical_url', application_id, 'api') | dotenv_quote }} -# nocheck: persona-block-rationale — the PDS knows no role beyond "account exists / does not exist" (README "Single Sign-On" records this as the documented RBAC exception), so there is no admin surface for the helper's adminInteraction; the administrator's PDS identity is the derived handle `kc-.` (vars/main.yml BLUESKY_ADMIN_HANDLE) and is administered through the `pdsadmin` CLI (Administration.md), not a browser panel. +# nocheck: persona-block-rationale — the PDS knows no role beyond "account exists / does not exist" (README "Single Sign-On" records this as the documented RBAC exception), so there is no admin surface for the helper's adminInteraction; the administrator's PDS identity is the derived handle `.`, or `i8-.` for a name the PDS reserves (vars/main.yml BLUESKY_ADMIN_HANDLE, BLUESKY_ADMIN_HANDLE_RESERVED), and is administered through the `pdsadmin` CLI (Administration.md), not a browser panel. PERSONA_ADMINISTRATOR_BLOCKED=true # nocheck: persona-block-rationale — social-app is a mobile-first SPA whose logout control sits behind a profile menu the shared auth-surface check cannot reach; the role's own login-broker OIDC test and its LDAP counterpart already prove the Keycloak → broker → BSKY_STORAGE.accessJwt handoff (see TODO.md and the file header). diff --git a/roles/web-app-bluesky/vars/main.yml b/roles/web-app-bluesky/vars/main.yml index 569d9b793a..7d86f5d9e1 100644 --- a/roles/web-app-bluesky/vars/main.yml +++ b/roles/web-app-bluesky/vars/main.yml @@ -44,7 +44,8 @@ BLUESKY_ROTATION_KEY: "{{ lookup('config', application_id, 'secrets.credentials. BLUESKY_ADMIN_EMAIL: "{{ lookup('users', 'administrator').email }}" BLUESKY_ADMIN_PASSWORD: "{{ lookup('users', 'administrator').password }}" -BLUESKY_ADMIN_HANDLE: "kc-{{ lookup('users', 'administrator').username }}.{{ BLUESKY_PDS_HANDLE_DOMAIN }}" +BLUESKY_ADMIN_HANDLE: "{{ lookup('users', 'administrator').username }}.{{ BLUESKY_PDS_HANDLE_DOMAIN }}" +BLUESKY_ADMIN_HANDLE_RESERVED: "i8-{{ BLUESKY_ADMIN_HANDLE }}" BLUESKY_PDS_INTERNAL_URL: "http://127.0.0.1:{{ BLUESKY_API_PORT }}" BLUESKY_FRONT_PROXY_CONTENT: "{{ lookup('template', 'extra_locations.conf.j2') }}" @@ -56,6 +57,7 @@ BLUESKY_BROKER_IMAGE: "bluesky_broker_custom" BLUESKY_BROKER_BASE_IMAGE: "{{ lookup('config', application_id, 'services.broker.image') }}" BLUESKY_BROKER_BASE_VERSION: "{{ lookup('config', application_id, 'services.broker.version') }}" BLUESKY_BROKER_SERVICE: "broker" +BLUESKY_BROKER_STORE_PATH: "/data/accounts.json" BLUESKY_BROKER_CONTAINER: "{{ lookup('config', application_id, 'services.' ~ BLUESKY_BROKER_SERVICE ~ '.name') }}" BLUESKY_BROKER_PORT: "{{ lookup('config', application_id, 'services.broker.ports.local.http') }}" BLUESKY_BROKER_INTERNAL_PORT: "{{ lookup('config', application_id, 'services.broker.ports.internal.http') }}" diff --git a/tests/unit/javascript/roles/web-app-bluesky/files/login-broker/handle.test.js b/tests/unit/javascript/roles/web-app-bluesky/files/login-broker/handle.test.js new file mode 100644 index 0000000000..aaaef0da32 --- /dev/null +++ b/tests/unit/javascript/roles/web-app-bluesky/files/login-broker/handle.test.js @@ -0,0 +1,41 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); + +const PROJECT_ROOT = path.resolve(__dirname, "../../../../../../.."); +const { + PDS_HANDLE_LABEL_MAX, + chosenLabelError, + deriveLabel, + fitsPds, + prefixedLabel, +} = require(path.join(PROJECT_ROOT, "roles/web-app-bluesky/files/login-broker/handle.js")); + +test("a username becomes its label without any prefix", () => { + assert.equal(deriveLabel("biber"), "biber"); + assert.equal(deriveLabel("Biber.Test"), "biber-test"); + assert.equal(deriveLabel(""), "user"); +}); + +test("only a reserved name carries the i8- prefix", () => { + assert.equal(prefixedLabel("administrator"), "i8-administrator"); + assert.ok(fitsPds(prefixedLabel("administrator"))); +}); + +test("a label longer than the PDS limit needs a chosen handle", () => { + assert.equal(deriveLabel("kevinveenbirkenbach").length, PDS_HANDLE_LABEL_MAX + 1); + assert.equal(fitsPds(deriveLabel("kevinveenbirkenbach")), false); + assert.equal(fitsPds("a".repeat(PDS_HANDLE_LABEL_MAX)), true); + assert.equal(fitsPds("ab"), false); +}); + +test("a chosen handle must carry a hyphen and never i8-", () => { + assert.equal(chosenLabelError("kevin-veen"), null); + assert.match(chosenLabelError("kevinveen"), /hyphen/); + assert.match(chosenLabelError("i8-kevin"), /i8-/); + assert.match(chosenLabelError("kevin-i8-veen"), /i8-/); + assert.match(chosenLabelError("-kevin"), /start or end/); + assert.match(chosenLabelError("kevin-"), /start or end/); + assert.match(chosenLabelError("Kevin-Veen"), /lowercase/); + assert.match(chosenLabelError("kevin-veen-birkenbach"), /characters/); +}); diff --git a/tests/unit/javascript/roles/web-app-bluesky/files/login-broker/server.test.js b/tests/unit/javascript/roles/web-app-bluesky/files/login-broker/server.test.js new file mode 100644 index 0000000000..fd08512f8f --- /dev/null +++ b/tests/unit/javascript/roles/web-app-bluesky/files/login-broker/server.test.js @@ -0,0 +1,266 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const http = require("node:http"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); + +const PROJECT_ROOT = path.resolve(__dirname, "../../../../../../.."); +const SERVER = path.join(PROJECT_ROOT, "roles/web-app-bluesky/files/login-broker/server.js"); +const DOMAIN = "example.test"; +const PUBLIC_HOST = "web.bluesky.example.test"; +const ADMIN_PASSWORD = "stub-admin-password"; + +function startPds() { + const accounts = new Map(); + const calls = { createAccount: 0, createAccountDelayMs: 0, adminUpdatePassword: [] }; + const reserved = new Set(["administrator", "admin"]); + const server = http.createServer((req, res) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + const url = new URL(req.url, "http://pds"); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {}; + const send = (status, payload) => { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(payload)); + }; + const fail = (error, message) => send(400, { error, message }); + if (url.pathname === "/xrpc/com.atproto.server.createAccount") { + calls.createAccount += 1; + const front = body.handle.slice(0, -(DOMAIN.length + 1)); + if (front.length > 18) return fail("InvalidHandle", "Handle too long"); + if (reserved.has(front)) return fail("HandleNotAvailable", "Reserved handle"); + for (const account of accounts.values()) { + if (account.handle === body.handle) return fail("HandleNotAvailable", "Handle already taken"); + if (account.email === body.email) return fail("InvalidRequest", "Email already taken"); + } + const did = `did:plc:${front}`; + accounts.set(did, { handle: body.handle, email: body.email, password: body.password }); + return setTimeout(() => send(200, { did, handle: body.handle }), calls.createAccountDelayMs); + } + if (url.pathname === "/xrpc/com.atproto.server.createSession") { + for (const [did, account] of accounts) { + const matches = account.handle === body.identifier || account.email === body.identifier; + if (matches && account.password === body.password) { + return send(200, { did, handle: account.handle, accessJwt: "a", refreshJwt: "r" }); + } + } + return send(401, { error: "AuthenticationRequired", message: "Invalid identifier or password" }); + } + if (url.pathname === "/xrpc/com.atproto.identity.resolveHandle") { + for (const [did, account] of accounts) { + if (account.handle === url.searchParams.get("handle")) return send(200, { did }); + } + return fail("InvalidRequest", "Unable to resolve handle"); + } + if (url.pathname === "/xrpc/com.atproto.admin.updateAccountPassword") { + const expected = `Basic ${Buffer.from(`admin:${ADMIN_PASSWORD}`).toString("base64")}`; + if (req.headers.authorization !== expected) return send(401, { error: "AuthMissing" }); + calls.adminUpdatePassword.push(body.did); + if (accounts.has(body.did)) accounts.get(body.did).password = body.password; + return send(200, {}); + } + return send(404, { error: "NotFound" }); + }); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve({ server, accounts, calls, port: server.address().port })); + }); +} + +function freePort() { + return new Promise((resolve) => { + const probe = http.createServer().listen(0, "127.0.0.1", () => { + const { port } = probe.address(); + probe.close(() => resolve(port)); + }); + }); +} + +async function startBroker(pdsPort, storePath) { + const port = await freePort(); + const child = spawn(process.execPath, [SERVER], { + env: { + PATH: process.env.PATH, + BROKER_PORT: String(port), + SOCIAL_APP_URL: "http://127.0.0.1:9", + PDS_URL: "https://api.bluesky.example.test", + PDS_INTERNAL_URL: `http://127.0.0.1:${pdsPort}`, + PDS_HANDLE_DOMAIN: DOMAIN, + PDS_ADMIN_PASSWORD: ADMIN_PASSWORD, + BLUESKY_BRIDGE_ENCRYPTION_KEY: `base64:${Buffer.alloc(32, 7).toString("base64")}`, + BROKER_STORE_PATH: storePath, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + await new Promise((resolve, reject) => { + child.stdout.on("data", (chunk) => { + if (chunk.toString().includes("listening")) resolve(); + }); + child.on("exit", (code) => reject(new Error(`broker exited with ${code}`))); + }); + return { child, port }; +} + +function stopBroker(broker) { + return new Promise((resolve) => { + broker.child.once("exit", resolve); + broker.child.kill(); + }); +} + +function request(broker, { method = "GET", requestPath = "/", user, email, origin, form }) { + const headers = { Host: PUBLIC_HOST }; + if (user) headers["X-Forwarded-Preferred-Username"] = user; + if (email) headers["X-Forwarded-Email"] = email; + if (origin) headers.Origin = origin; + const body = form ? new URLSearchParams(form).toString() : null; + if (body) { + headers["Content-Type"] = "application/x-www-form-urlencoded"; + headers["Content-Length"] = Buffer.byteLength(body); + } + return new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port: broker.port, method, path: requestPath, headers }, (res) => { + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString("utf8") })); + }); + req.on("error", reject); + if (body) req.write(body); + req.end(); + }); +} + +const ORIGIN = `https://${PUBLIC_HOST}`; +let pds; +let storePath; +let broker; + +test.before(async () => { + pds = await startPds(); + storePath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "broker-server-")), "accounts.json"); + broker = await startBroker(pds.port, storePath); +}); + +test.after(async () => { + await stopBroker(broker); + pds.server.close(); +}); + +test("a short username gets a handle without prefix", async () => { + const res = await request(broker, { user: "biber", email: "biber@example.test" }); + assert.equal(res.status, 200); + assert.match(res.body, /BSKY_STORAGE/); + assert.equal(pds.accounts.get("did:plc:biber").handle, `biber.${DOMAIN}`); +}); + +test("a reserved username falls back to the i8- prefix", async () => { + const res = await request(broker, { user: "administrator", email: "admin@example.test" }); + assert.equal(res.status, 200); + assert.equal(pds.accounts.get("did:plc:i8-administrator").handle, `i8-administrator.${DOMAIN}`); +}); + +test("a username over 18 characters gets the handle form and no account", async () => { + const before = pds.calls.createAccount; + const res = await request(broker, { user: "kevinveenbirkenbach", email: "kevin@example.test" }); + assert.equal(res.status, 200); + assert.match(res.body, /Choose your Bluesky handle/); + assert.equal(pds.calls.createAccount, before); +}); + +test("the handle form refuses a cross-origin or malformed submission", async () => { + const user = { user: "kevinveenbirkenbach", email: "kevin@example.test", method: "POST", requestPath: "/sso/handle" }; + const noOrigin = await request(broker, Object.assign({ form: { handle: "kevin-veen" } }, user)); + assert.equal(noOrigin.status, 403); + const foreign = await request(broker, Object.assign({ origin: "https://evil.example", form: { handle: "kevin-veen" } }, user)); + assert.equal(foreign.status, 403); + const noHyphen = await request(broker, Object.assign({ origin: ORIGIN, form: { handle: "kevinveen" } }, user)); + assert.equal(noHyphen.status, 400); + assert.match(noHyphen.body, /hyphen/); + const prefixed = await request(broker, Object.assign({ origin: ORIGIN, form: { handle: "i8-kevin" } }, user)); + assert.equal(prefixed.status, 400); +}); + +test("an origin with a port still matches the proxied host", async () => { + const res = await request(broker, { + user: "kevinveenbirkenbach", + email: "kevin@example.test", + method: "POST", + requestPath: "/sso/handle", + origin: `${ORIGIN}:8443`, + form: { handle: "kevin-veen" }, + }); + assert.equal(res.status, 200); + assert.match(res.body, /BSKY_STORAGE/); + assert.equal(pds.accounts.get("did:plc:kevin-veen").handle, `kevin-veen.${DOMAIN}`); +}); + +test("a chosen handle another user holds is offered again, not taken over", async () => { + const res = await request(broker, { + user: "anotherverylongusername", + email: "other@example.test", + method: "POST", + requestPath: "/sso/handle", + origin: ORIGIN, + form: { handle: "kevin-veen" }, + }); + assert.equal(res.status, 409); + assert.match(res.body, /already taken/); + assert.equal(pds.calls.adminUpdatePassword.includes("did:plc:kevin-veen"), false); +}); + +test("a derived handle whose name another user chose is never taken over", async () => { + const res = await request(broker, { user: "kevin.veen", email: "kv@example.test" }); + assert.equal(res.status, 500); + assert.equal(pds.calls.adminUpdatePassword.includes("did:plc:kevin-veen"), false); +}); + +test("an e-mail address a legacy kc- account holds is reported, not retried", async () => { + pds.accounts.set("did:plc:legacy", { handle: `kc-legacy.${DOMAIN}`, email: "legacy@example.test", password: "x" }); + const res = await request(broker, { user: "legacy", email: "legacy@example.test" }); + assert.equal(res.status, 409); + assert.match(res.body, /legacy kc- account/); +}); + +test("concurrent first visits create one account", async () => { + const before = pds.calls.createAccount; + pds.calls.createAccountDelayMs = 200; + const visits = await Promise.all([ + request(broker, { user: "mapache", email: "mapache@example.test" }), + request(broker, { user: "mapache", email: "mapache@example.test" }), + ]); + pds.calls.createAccountDelayMs = 0; + assert.deepEqual(visits.map((v) => v.status), [200, 200]); + assert.equal(pds.calls.createAccount, before + 1); +}); + +test("a broker restart keeps every account", async () => { + await stopBroker(broker); + broker = await startBroker(pds.port, storePath); + const before = pds.calls.createAccount; + for (const [user, email] of [["biber", "biber@example.test"], ["kevinveenbirkenbach", "kevin@example.test"]]) { + const res = await request(broker, { user, email }); + assert.equal(res.status, 200); + assert.match(res.body, /BSKY_STORAGE/); + } + assert.equal(pds.calls.createAccount, before); +}); + +test("an account the PDS lost is provisioned again", async () => { + pds.accounts.delete("did:plc:biber"); + const res = await request(broker, { user: "biber", email: "biber@example.test" }); + assert.equal(res.status, 200); + assert.match(res.body, /BSKY_STORAGE/); + assert.ok(pds.accounts.has("did:plc:biber")); +}); + +test("a password the PDS no longer accepts is reset in place", async () => { + pds.accounts.get("did:plc:i8-administrator").password = "changed-elsewhere"; + const before = pds.calls.createAccount; + const res = await request(broker, { user: "administrator", email: "admin@example.test" }); + assert.equal(res.status, 200); + assert.equal(pds.calls.createAccount, before); + assert.ok(pds.calls.adminUpdatePassword.includes("did:plc:i8-administrator")); +}); diff --git a/tests/unit/javascript/roles/web-app-bluesky/files/login-broker/store.test.js b/tests/unit/javascript/roles/web-app-bluesky/files/login-broker/store.test.js new file mode 100644 index 0000000000..a02eb95083 --- /dev/null +++ b/tests/unit/javascript/roles/web-app-bluesky/files/login-broker/store.test.js @@ -0,0 +1,55 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const PROJECT_ROOT = path.resolve(__dirname, "../../../../../../.."); +const { openStore } = require( + path.join(PROJECT_ROOT, "roles/web-app-bluesky/files/login-broker/store.js"), +); + +function tempFile() { + return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "broker-store-")), "accounts.json"); +} + +test("an entry survives a reopen, as after a broker restart", () => { + const file = tempFile(); + openStore(file).set("biber", { handle: "biber.example", did: "did:plc:b", appPasswordEnc: "x" }); + const reopened = openStore(file); + assert.deepEqual(reopened.get("biber"), { + handle: "biber.example", + did: "did:plc:b", + appPasswordEnc: "x", + }); + assert.equal(reopened.get("mapache"), null); +}); + +test("ownerOf names the username that holds a handle", () => { + const store = openStore(tempFile()); + store.set("kevinveenbirkenbach", { handle: "kevin-veen.example", did: "did:plc:k", appPasswordEnc: "y" }); + assert.equal(store.ownerOf("kevin-veen.example"), "kevinveenbirkenbach"); + assert.equal(store.ownerOf("other.example"), null); +}); + +test("a deleted entry stays gone after a reopen", () => { + const file = tempFile(); + const store = openStore(file); + store.set("biber", { handle: "biber.example", did: "did:plc:b", appPasswordEnc: "x" }); + store.delete("biber"); + assert.equal(openStore(file).get("biber"), null); + assert.equal(openStore(file).ownerOf("biber.example"), null); +}); + +test("the file is written owner-only", () => { + const file = tempFile(); + openStore(file).set("biber", { handle: "biber.example", did: "did:plc:b", appPasswordEnc: "x" }); + assert.equal(fs.statSync(file).mode & 0o777, 0o600); +}); + +test("a username named like an object key stays a plain entry", () => { + const file = tempFile(); + openStore(file).set("__proto__", { handle: "p.example", did: "did:plc:p", appPasswordEnc: "z" }); + assert.equal(openStore(file).get("__proto__").handle, "p.example"); + assert.equal({}.handle, undefined); +});