mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-10 12:47:04 +00:00
fix(web-app-bluesky): derive handles without prefix and let over-long names pick one
The broker built every handle as kc-<username>. The PDS (0.4.66) caps the
first handle label at 18 characters, so kc-kevinveenbirkenbach (22) failed
createAccount with InvalidHandle: Handle too long, and the prefix only
existed to dodge the PDS's reserved names.
handle.js derives the label from the Keycloak username without a prefix and
adds i8- only after the PDS answers Reserved handle (1029 names, among them
administrator). A label that fits neither way leads to a form at /sso/handle
where the user picks 3 to 18 characters of [a-z0-9-] that contain a hyphen,
which no Keycloak username holds, and never contain i8-. The POST must come
from the proxied host's origin; the hostname is compared, since nginx passes
Host without its port.
store.js keeps username -> {handle, did, encrypted app-password} in a JSON
file (0600, written through a rename) in the new broker_data volume, so a
broker restart neither creates a second account nor resets a password. A
stored entry whose login the PDS refuses gets its password reset by DID; when
that login still fails the account is gone (a single-volume PDS rollback) and
the user is provisioned again. Concurrent first visits share one
provisioning. A derived handle that is taken is only recovered when no other
user holds it in the store, and an e-mail address a legacy kc- account still
holds ends in a 409 page instead of a retry loop.
Without the broker, 04_native_admin.yml follows the same rule: the plain
handle first, i8- on Reserved handle. The native Playwright tests sign in by
e-mail, which the PDS resolves, and accept either handle.
Evidence: the PDS rules and messages were read from the 0.4.66 image.
server.test.js runs the broker as a child process against a PDS stub that
answers with those messages: plain, i8-, form, 403/400/409, the takeover
guard, restart, lost account, reset and the concurrent visits, whose test
fails without provisionOnce. Unit tests 21/21, JavaScript suite 102/102,
make test-lint and make lint pass. The image builds and the node user writes
the store into a fresh volume.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
754d35c4de
commit
aadc96561b
18 files changed
+748
-82
No files matched your search
@@ -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-<username>` 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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<username>.<PDS_HANDLE_DOMAIN>` from the
|
||||
Keycloak username, or `i8-<username>.<PDS_HANDLE_DOMAIN>` 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.<domain>`), 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 (`<DOMAIN_PRIMARY>`). |
|
||||
| `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.<domain>`). |
|
||||
| `KC_ADMIN_REALM` | Realm of the broker's service account (typically the same as user realm).|
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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) {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderHandleForm({ error, value }) {
|
||||
const errorHtml = error ? `<p class="error">${escapeHtml(error)}</p>` : "";
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Choose your Bluesky handle</title>
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<style>body{font-family:sans-serif;background:#001a33;color:#eef;margin:0;padding:2em;text-align:center}input,button{font-size:1em;padding:.4em}.error{color:#f99}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Choose your Bluesky handle</h1>
|
||||
<p>Your username is longer than the ${PDS_HANDLE_LABEL_MAX} characters a Bluesky handle allows.</p>
|
||||
<p>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}".</p>
|
||||
${errorHtml}
|
||||
<form method="post" action="${CONFIG.chooseHandlePath}">
|
||||
<input name="handle" value="${escapeHtml(value)}" required minlength="${PDS_HANDLE_LABEL_MIN}" maxlength="${PDS_HANDLE_LABEL_MAX}" pattern="[a-z0-9-]+" autocomplete="off" autofocus />
|
||||
<span>.${escapeHtml(CONFIG.pdsHandleDomain)}</span>
|
||||
<button type="submit">Create account</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
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)}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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}=$
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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-<username>.<domain>` (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 `<username>.<domain>`, or `i8-<username>.<domain>` 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).
|
||||
|
||||
@@ -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') }}"
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user