Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.

### Added

- **Relinking a FID-only account from the web UI.** When someone signs in through the FID portal with a new key for a username whose account already has another key (the effect of `fid` 5.0 re-keying every identity), the SSO now answers `FID_KEY_CHANGED` and records a relink request on that account (`admin.fid_relink_pub`, `fid_relink_requested_at`; the first request wins, so a stranger cannot replace the owner's). **Admin → Users** shows it with the key and Approve / Dismiss buttons for the primary admin (`POST /api/admin/system/users/:id/approve-fid-relink`, `DELETE /api/admin/system/users/:id/fid-relink`). Approving does what `npm run fid:relink` does; the new key proves only that its holder wants the account, so the administrator has to confirm with the person first. The move logic lives in `modules/auth/fid-relink.ts`, shared by the UI and the CLI. Accounts that have a password never get a request: they log in and link the key from their profile.
- **`npm run fid:relink -- <username> <new-public-key>` points an account at a new FID key.** `fid` 5.0 re-keyed every identity, and an account created through FID has no password (the server refuses to write one), so its owner could not get back in: the SSO answered `Username already exists` and the link flow `FID identity not found`, both telling them to use a Settings screen that does not exist. The operator can now relink the account from the host: the tool validates the key, refuses one another account owns, bumps the account's `token_version` (signing its sessions out), resets its passports and drops the old identity's library records. The two errors now say what happened and who can fix it (`FID_KEY_CHANGED`). See `docs/FID-IDENTITY.md`.

- **Library sync for the website player, over plain HTTP.** `GET`/`PUT /api/auth/zen/library/:pub` store a listener's favorites, artists and playlists as opaque records (encrypted in the browser) `GET /api/auth/zen/library/:pub/shared/:id` serves a playlist its owner published, and `GET /api/auth/zen/library/:pub/account` tells a new device whether this instance holds an account for its key. Requests are signed with the identity key in `X-Fid-Auth` (method, path, timestamp and body hash, 5-minute window), the key must belong to an active account on the instance, and storage is capped (200 records per request, 64 KB per record, 5000 records / 8 MB per identity, 120 requests a minute per IP). CORS is open without credentials because the signature is the credential. Last write wins by the record's own timestamp; a deleted record stays as a tombstone. New table `library_sync`. This replaces the Zen relay the player used before, so no relay is involved anywhere any more.
Expand Down
6 changes: 5 additions & 1 deletion docs/FID-IDENTITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ The official central SSO and identity portal is deployed at:

`fid` 5.0 replaced Zen SEA keys with Ed25519 keys, so the same alias and passphrase now derive a **different** key. An account whose `zen_pub` is the old key is no longer reached by signing in: the SSO answers `Username already exists…` (`FID_KEY_CHANGED`) or the link flow `FID identity not found`.

Accounts created through FID have no password, and the server refuses to write one, so the owner cannot fix this from the web UI. The instance operator points the account at the new key:
Accounts created through FID have no password, and the server refuses to write one, and the old key can no longer be verified (Zen is gone), so the owner cannot prove the account is theirs by themselves. An administrator has to approve the move.

**From the web UI.** The owner signs in through the FID portal with the new key. The instance answers `FID_KEY_CHANGED` and records a **relink request** on the account (the first request wins; a different key cannot replace it until the administrator dismisses it). In **Admin → Users** the account shows *FID relink requested* with the key and **Approve** / **Dismiss** buttons (primary admin only). Confirm with the person that the key is the one their portal shows before approving: anyone who knows a username can send a request. After approval the owner signs in again through the SSO.

**From the host.** The same move without the UI:

```bash
npm run fid:relink -- <username> <new-public-key> # add --db path/to/db if it is not the configured one
Expand Down
6 changes: 5 additions & 1 deletion docs/it/FID-IDENTITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ Il portale centralizzato SSO e d'identità ufficiale è distribuito su:

`fid` 5.0 ha sostituito le chiavi Zen SEA con chiavi Ed25519, quindi lo stesso alias e la stessa passphrase ora derivano una chiave **diversa**. Un account il cui `zen_pub` è la vecchia chiave non viene più raggiunto dall'accesso: l'SSO risponde `Username already exists…` (`FID_KEY_CHANGED`) oppure il collegamento `FID identity not found`.

Gli account creati tramite FID non hanno password e il server rifiuta di scriverne una, quindi il proprietario non può risolvere dall'interfaccia web. L'operatore dell'istanza punta l'account alla nuova chiave:
Gli account creati tramite FID non hanno password, il server rifiuta di scriverne una e la vecchia chiave non si può più verificare (Zen non c'è più): il proprietario non può dimostrare da solo che l'account è suo. Serve l'approvazione di un amministratore.

**Dall'interfaccia web.** Il proprietario accede dal portale FID con la nuova chiave. L'istanza risponde `FID_KEY_CHANGED` e registra una **richiesta di ricollegamento** sull'account (vince la prima richiesta: un'altra chiave non può sostituirla finché l'amministratore non la rifiuta). In **Admin → Users** l'account mostra *FID relink requested* con la chiave e i pulsanti **Approve** / **Dismiss** (solo l'amministratore principale). Prima di approvare, conferma con la persona che la chiave è quella mostrata dal suo portale: chiunque conosca un nome utente può inviare una richiesta. Dopo l'approvazione il proprietario accede di nuovo con l'SSO.

**Dall'host.** Lo stesso spostamento senza interfaccia:

```bash
npm run fid:relink -- <username> <nuova-chiave-pubblica> # aggiungi --db percorso/del/db se non è quello configurato
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tunecamp",
"version": "6.2.1",
"version": "6.3.0",
"description": "A decentralized music platform for artists and labels, with streaming, federation, and Subsonic API support",
"main": "dist/server/server.js",
"type": "module",
Expand Down
31 changes: 31 additions & 0 deletions src/server/__tests__/zen.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,37 @@ describe("Zen SEA Integration Routes", () => {
expect(res.body.error).toContain("Username already exists");
});

test("POST /api/auth/zen/sso turns a new key for a FID-only account into a relink request, never a login", async () => {
const runs: unknown[][] = [];
const previous = mockContainer.database.prepare.getMockImplementation();
mockContainer.database.prepare.mockImplementation((query: string) => {
if (query.includes("fid_relink_pub FROM admin WHERE username")) {
return {
get: () => ({ id: 1, zen_pub: baseKeys.pub, zen_auth_mode: "zen", fid_relink_pub: null }),
all: () => [],
run: () => ({}),
};
}
if (query.includes("UPDATE admin SET fid_relink_pub")) {
return { get: () => null, all: () => [], run: (...args: unknown[]) => (runs.push(args), {}) };
}
return previous!(query);
});
try {
const token = await buildSsoToken({ username: "scobru" }, altKeys2);
const res = await request(app)
.post("/api/auth/zen/sso")
.send({ ssoToken: token, apSeed: validApSeed() });
expect(res.status).toBe(409);
expect(res.body.code).toBe("FID_KEY_CHANGED");
expect(res.body.relinkRequested).toBe(true);
expect(res.body.token).toBeUndefined();
expect(runs).toEqual([[altKeys2.pub, 1]]);
} finally {
mockContainer.database.prepare.mockImplementation(previous!);
}
});

test("POST /api/auth/zen/sso logs in existing user on valid request", async () => {
const token = await buildSsoToken();
const res = await request(app)
Expand Down
9 changes: 9 additions & 0 deletions src/server/core/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1442,6 +1442,15 @@ export function createDatabase(dbPath: string): DatabaseService {
"ALTER TABLE admin ADD COLUMN artist_requested_at TEXT DEFAULT NULL",
);
}
// A FID key someone signed in with after fid 5.0 re-keyed identities, waiting for an
// administrator to approve moving the account to it (see modules/auth/fid-relink.ts).
if (!cols.some((col) => col.name === "fid_relink_pub")) {
console.log(
"📦 [Database] Migrating admin table: adding fid_relink_pub columns...",
);
db.exec("ALTER TABLE admin ADD COLUMN fid_relink_pub TEXT DEFAULT NULL");
db.exec("ALTER TABLE admin ADD COLUMN fid_relink_requested_at TEXT DEFAULT NULL");
}
// Opt-in "now listening" presence (off by default for privacy).
if (!cols.some((col) => col.name === "now_playing_enabled")) {
console.log(
Expand Down
64 changes: 64 additions & 0 deletions src/server/modules/auth/__tests__/fid-relink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, test, expect, beforeEach, afterEach } from "@jest/globals";
import { createDatabase } from "../../../core/database.js";
import { requestRelink, approveRelink, dismissRelink } from "../fid-relink.js";

const OLD = "0DGULtYbQYzYDlRUddrRNoS7NrEzGIZAsQrXSKQYThMX1";
const NEW_A = "bE9DAycqb9gbxMJHTxh5RRxVQRPpG-wrCojHVl0s9sM";
const NEW_B = "WZIxm8Lu1Pco9Y-FnOOQcA_wNtjRUr5MFVMjmHf5Zp0";

describe("FID relink requests", () => {
let db: any;
let fidOnlyId: number;
let passwordId: number;

const waiting = (id: number) => db.prepare("SELECT fid_relink_pub AS pub, fid_relink_requested_at AS at FROM admin WHERE id = ?").get(id);

beforeEach(() => {
db = createDatabase(":memory:").db;
const insert = db.prepare("INSERT INTO admin (username, password_hash, role, zen_pub, zen_auth_mode, is_active) VALUES (?, ?, 'user', ?, ?, 1)");
fidOnlyId = Number(insert.run("scobru", "", OLD, "zen").lastInsertRowid);
passwordId = Number(insert.run("carla", "hash", OLD.replace("0", "1"), "hybrid").lastInsertRowid);
});

afterEach(() => db.close());

test("a new key for a FID-only account is recorded as a request, not applied", () => {
expect(requestRelink(db, "scobru", NEW_A)).toBe("requested");
const row = waiting(fidOnlyId);
expect(row.pub).toBe(NEW_A);
expect(row.at).toBeTruthy();
expect(db.prepare("SELECT zen_pub FROM admin WHERE id = ?").get(fidOnlyId).zen_pub).toBe(OLD);
});

test("the first request wins: another key cannot replace it, the same key is idempotent", () => {
requestRelink(db, "scobru", NEW_A);
expect(requestRelink(db, "scobru", NEW_B)).toBe("pending");
expect(waiting(fidOnlyId).pub).toBe(NEW_A);
expect(requestRelink(db, "SCOBRU", NEW_A)).toBe("requested");
});

test("accounts that do not qualify never get a request", () => {
expect(requestRelink(db, "carla", NEW_A)).toBe("not-eligible"); // has a password: link from the profile instead
expect(requestRelink(db, "nobody", NEW_A)).toBe("not-eligible");
expect(requestRelink(db, "scobru", OLD)).toBe("not-eligible"); // not an Ed25519 key
expect(requestRelink(db, "scobru", "short")).toBe("not-eligible");
expect(waiting(passwordId).pub).toBeNull();
});

test("approving moves the account to the requested key and clears the request", () => {
requestRelink(db, "scobru", NEW_A);
const result = approveRelink(db, fidOnlyId);
expect(result).toEqual({ username: "scobru", oldPub: OLD, newPub: NEW_A });
expect(db.prepare("SELECT zen_pub FROM admin WHERE id = ?").get(fidOnlyId).zen_pub).toBe(NEW_A);
expect(waiting(fidOnlyId).pub).toBeNull();
});

test("approving needs a request; dismissing leaves the account alone", () => {
expect(() => approveRelink(db, fidOnlyId)).toThrow(/no relink request/);
expect(() => approveRelink(db, 99999)).toThrow(/not found/);
requestRelink(db, "scobru", NEW_A);
dismissRelink(db, fidOnlyId);
expect(waiting(fidOnlyId).pub).toBeNull();
expect(db.prepare("SELECT zen_pub FROM admin WHERE id = ?").get(fidOnlyId).zen_pub).toBe(OLD);
});
});
6 changes: 5 additions & 1 deletion src/server/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ export interface AuthService {
is_root: boolean;
can_peer: number;
zen_auth_mode: string;
fid_relink_pub: string | null;
fid_relink_requested_at: string | null;
}[];
deleteAdmin(id: number): void;
deleteUsersBatch(ids: number[]): void;
Expand Down Expand Up @@ -1286,10 +1288,12 @@ export function createAuthService(
is_root: boolean;
can_peer: number;
zen_auth_mode: string;
fid_relink_pub: string | null;
fid_relink_requested_at: string | null;
}[] {
const rows = db
.prepare(`
SELECT a.id, a.username, a.artist_id, a.role, a.storage_quota, a.is_active, a.created_at, a.artist_requested_at, a.can_peer, ar.name as artist_name, a.zen_auth_mode
SELECT a.id, a.username, a.artist_id, a.role, a.storage_quota, a.is_active, a.created_at, a.artist_requested_at, a.can_peer, ar.name as artist_name, a.zen_auth_mode, a.fid_relink_pub, a.fid_relink_requested_at
FROM admin a
LEFT JOIN artists ar ON a.artist_id = ar.id
ORDER BY a.username
Expand Down
82 changes: 82 additions & 0 deletions src/server/modules/auth/fid-relink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { Database } from "better-sqlite3";

/** An Ed25519 public key as `fid` 5 writes it: 32 bytes, base64url, 43 characters. */
const KEY_RE = /^[A-Za-z0-9_-]{43}$/;

export interface RelinkResult {
username: string;
oldPub: string | null;
newPub: string;
}

/**
* Moves `username` to `newPub`. Throws, and changes nothing, if the key is malformed, the
* account does not exist, or another account already owns the key.
*/
export function relinkFid(db: Database, username: string, newPub: string): RelinkResult {
if (!KEY_RE.test(newPub)) {
throw new Error("The new public key must be 43 base64url characters (an Ed25519 key from fid 5).");
}
const user = db
.prepare("SELECT id, username, zen_pub FROM admin WHERE username = ? COLLATE NOCASE")
.get(username) as { id: number; username: string; zen_pub: string | null } | undefined;
if (!user) throw new Error(`No account named "${username}".`);

const owner = db
.prepare("SELECT username FROM admin WHERE zen_pub = ? AND id != ?")
.get(newPub, user.id) as { username: string } | undefined;
if (owner) throw new Error(`That key already belongs to "${owner.username}".`);

db.transaction(() => {
// token_version + 1 signs out every session issued for the old identity.
db.prepare("UPDATE admin SET zen_pub = ?, token_version = COALESCE(token_version, 0) + 1 WHERE id = ?").run(newPub, user.id);
// Passports and the cached profile were issued for the old key; the library records are
// ciphertext under it and nobody can read them any more.
db.prepare("UPDATE fid_registry SET public_key = ?, verified = 0, passport_signature = NULL WHERE user_id = ?").run(newPub, user.id);
if (user.zen_pub) {
db.prepare("DELETE FROM zen_users WHERE pub = ?").run(user.zen_pub);
db.prepare("DELETE FROM library_sync WHERE pub = ?").run(user.zen_pub);
}
})();

return { username: user.username, oldPub: user.zen_pub, newPub };
}

/**
* What the SSO does when a signed-in-with-a-new-key request names an account that already has a
* different FID key (one created before fid 5.0 re-keyed every identity). The new key proves only
* that the requester holds it, not that they own the account, so the request is just recorded for
* an administrator to approve.
*
* - `requested`: recorded (or the same key was already waiting).
* - `pending`: a request for another key is already waiting; the first one wins until the
* administrator dismisses it, so a stranger cannot replace the owner's request.
* - `not-eligible`: the account has no FID key to replace, or logs in with a password.
*/
export function requestRelink(db: Database, username: string, newPub: string): "requested" | "pending" | "not-eligible" {
if (!KEY_RE.test(newPub)) return "not-eligible";
const user = db
.prepare("SELECT id, zen_pub, zen_auth_mode, fid_relink_pub FROM admin WHERE username = ? COLLATE NOCASE")
.get(username) as { id: number; zen_pub: string | null; zen_auth_mode: string | null; fid_relink_pub: string | null } | undefined;
if (!user || !user.zen_pub || user.zen_pub === newPub || user.zen_auth_mode !== "zen") return "not-eligible";
if (user.fid_relink_pub) return user.fid_relink_pub === newPub ? "requested" : "pending";
db.prepare("UPDATE admin SET fid_relink_pub = ?, fid_relink_requested_at = CURRENT_TIMESTAMP WHERE id = ?").run(newPub, user.id);
return "requested";
}

/** Drops a waiting request without changing the account. */
export function dismissRelink(db: Database, userId: number): void {
db.prepare("UPDATE admin SET fid_relink_pub = NULL, fid_relink_requested_at = NULL WHERE id = ?").run(userId);
}

/** Applies the waiting request: the account moves to the requested key. Throws if there is none. */
export function approveRelink(db: Database, userId: number): RelinkResult {
const row = db.prepare("SELECT username, fid_relink_pub FROM admin WHERE id = ?").get(userId) as
| { username: string; fid_relink_pub: string | null }
| undefined;
if (!row) throw new Error("User not found.");
if (!row.fid_relink_pub) throw new Error("There is no relink request for this account.");
const result = relinkFid(db, row.username, row.fid_relink_pub);
dismissRelink(db, userId);
return result;
}
53 changes: 53 additions & 0 deletions src/server/routes/admin/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { isDownloadProviderEnabled } from "../../middleware/provider-gate.js";
import { aiService } from "../../modules/ai/ai.service.js";
import { taskManager } from "../../modules/workers/task-manager.js";
import { createRssService } from "../../modules/network/rss.service.js";
import { approveRelink, dismissRelink } from "../../modules/auth/fid-relink.js";
import {
getSiteHandle,
slugifySiteName,
Expand Down Expand Up @@ -2552,6 +2553,58 @@ export function createAdminRoutes(container: ServiceContainer): Router {
},
);

/**
* POST /api/admin/system/users/:id/approve-fid-relink
* Moves a FID-only account to the key it asked to be relinked to. The request was made by
* someone who signed in through the FID portal with a key the account does not have yet
* (fid 5.0 re-keyed every identity), so the primary admin approves it only after confirming
* with the person that the key shown is theirs.
*/
router.post(
"/system/users/:id/approve-fid-relink",
(req: AuthenticatedRequest, res: any) => {
try {
if (
!req.context ||
!VisibilityGuardian.can(req.context, Capability.MANAGE_SYSTEM)
) {
return res
.status(403)
.json({ error: "Only the primary admin can approve FID relink requests" });
}
const result = approveRelink(database.db, parseInt(req.params.id, 10));
res.json({ success: true, ...result });
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
},
);

/**
* DELETE /api/admin/system/users/:id/fid-relink
* Dismiss a pending FID relink request without changing the account.
*/
router.delete(
"/system/users/:id/fid-relink",
(req: AuthenticatedRequest, res: any) => {
try {
if (
!req.context ||
!VisibilityGuardian.can(req.context, Capability.MANAGE_SYSTEM)
) {
return res
.status(403)
.json({ error: "Only the primary admin can manage FID relink requests" });
}
dismissRelink(database.db, parseInt(req.params.id, 10));
res.json({ success: true });
} catch (error) {
console.error("Error dismissing FID relink request:", error);
res.status(500).json({ error: "Failed to dismiss the FID relink request" });
}
},
);

/**
* DELETE /api/admin/system/users/:id
* Delete admin user (root admin only)
Expand Down
Loading
Loading