store the token provisioning mints, so members can open video
Provisioning already created each member's Jellyfin account and signed in as them to get a token — its own header says the token exists so "the person's Officer session can already read their library". It was then dropped, because linkJellyfinAccount had nowhere to put it. So every non-owner resolved to no config and got a 503 setup form, asking them to connect a Jellyfin server with a password nobody knows. It reads as a permissions fault and is not one: the grant, the role and the account list were all correct. jellyfin_accounts now carries access_token and device_id, getConfig falls back to the link row when the caller has no registry of their own, and there is no path from there to the owner's token — a member resolving to the owner's credential would read the owner's watch state and every library. POST /_config/:id/provision repairs accounts linked before the column existed. It resets the password to mint, which is free on an account Officer created (random, discarded, known to nobody) and destructive on an adopted one, so adopted accounts are skipped unless includeAdopted says otherwise. Applied on alpha: conceiba and guipinto minted and verified against the live server; fedra and diogo skipped as adopted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+63
-6
@@ -58,10 +58,19 @@ async function grantAllLibraries(
|
||||
/** Long enough that it does not matter that it is never rotated, since nobody ever knows it. */
|
||||
const generatePassword = (): string => randomBytes(32).toString('base64url');
|
||||
|
||||
/**
|
||||
* The DeviceId one person's Officer sessions are keyed by.
|
||||
*
|
||||
* Derived from the Officer user id rather than random, so re-minting a token for somebody reuses their
|
||||
* entry in Jellyfin's Devices screen instead of adding a second ghost beside it — the same reasoning
|
||||
* `editServer` applies when the owner signs in again.
|
||||
*/
|
||||
export const memberDeviceId = (officerUserId: number): string => `officer-${officerUserId}`;
|
||||
|
||||
export type ProvisionOutcome = {
|
||||
/** Officer user id → what happened. */
|
||||
linked: { userId: number; jellyfinUserId: string; username: string }[];
|
||||
created: { userId: number; jellyfinUserId: string; username: string; token: string }[];
|
||||
created: { userId: number; jellyfinUserId: string; username: string; token: string; deviceId: string }[];
|
||||
/** Left alone, reported so the owner can see accounts Officer does not manage. */
|
||||
untouched: JellyfinUser[];
|
||||
failed: { username: string; error: string }[];
|
||||
@@ -87,7 +96,7 @@ export async function listJellyfinUsers(cfg: UpstreamTarget): Promise<JellyfinUs
|
||||
async function createOne(
|
||||
cfg: UpstreamTarget,
|
||||
user: ProvisionableUser,
|
||||
): Promise<{ jellyfinUserId: string; token: string }> {
|
||||
): Promise<{ jellyfinUserId: string; token: string; deviceId: string }> {
|
||||
const password = generatePassword();
|
||||
|
||||
const res = await callUpstream(cfg, {
|
||||
@@ -120,8 +129,56 @@ async function createOne(
|
||||
|
||||
// Rule 4, and the token in one call. A per-user device id keeps this session distinct from the owner's
|
||||
// in Jellyfin's Devices screen, so revoking one does not sign out the other.
|
||||
const auth = await authenticate(cfg.base, `officer-${user.id}`, user.username!, password);
|
||||
return { jellyfinUserId: created.Id, token: auth.token };
|
||||
const deviceId = memberDeviceId(user.id);
|
||||
const auth = await authenticate(cfg.base, deviceId, user.username!, password);
|
||||
return { jellyfinUserId: created.Id, token: auth.token, deviceId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a token for an account that is ALREADY linked — the repair path, and the only way to give a member
|
||||
* access to an account whose password nobody holds.
|
||||
*
|
||||
* ── Why this has to reset the password ──
|
||||
*
|
||||
* Jellyfin issues a token exactly one way: `AuthenticateByName`, with the password. There is no admin
|
||||
* impersonation endpoint, and its API keys are server-wide and identity-less, so they cannot stand in for
|
||||
* a user (the same reason `ConnectionSection` offers no "paste an API key" field). An administrator CAN
|
||||
* set another user's password without knowing the old one — `POST /Users/{id}/Password` with an empty
|
||||
* `CurrentPw`, verified against 10.11 — so the sequence is: set a fresh random password, spend it once on
|
||||
* a sign-in, discard it. Exactly what `createOne` does, minus the create.
|
||||
*
|
||||
* ── Which accounts this is safe on, and which it is not ──
|
||||
*
|
||||
* Safe on an account Officer created (`adopted: false`). Its password was random and discarded at
|
||||
* creation, so nobody knows it, nothing stores it, and replacing it takes nothing away from anyone.
|
||||
*
|
||||
* NOT safe on an adopted one. That account existed before Officer and its password is somebody's, known
|
||||
* to them and possibly used to sign into Jellyfin directly. Resetting it would lock that person out of
|
||||
* their own account to fix a problem they do not have. The caller decides — see `backfillAccountTokens`,
|
||||
* which refuses adopted accounts unless told otherwise in as many words.
|
||||
*/
|
||||
export async function mintTokenForLinked(
|
||||
cfg: UpstreamTarget,
|
||||
account: { officerUserId: number; jellyfinUserId: string; username: string },
|
||||
): Promise<{ token: string; deviceId: string }> {
|
||||
const password = generatePassword();
|
||||
|
||||
const res = await callUpstream(cfg, {
|
||||
method: 'POST',
|
||||
path: `/Users/${account.jellyfinUserId}/Password`,
|
||||
contentType: 'application/json',
|
||||
// `ResetPassword: false` — that flag CLEARS the password rather than setting it, which would leave the
|
||||
// account reachable by anyone who can see the login screen.
|
||||
body: JSON.stringify({ CurrentPw: '', NewPw: password, ResetPassword: false }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`POST /Users/${account.jellyfinUserId}/Password → ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const deviceId = memberDeviceId(account.officerUserId);
|
||||
const auth = await authenticate(cfg.base, deviceId, account.username, password);
|
||||
return { token: auth.token, deviceId };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,8 +218,8 @@ export async function provision(params: {
|
||||
|
||||
for (const user of plan.create) {
|
||||
try {
|
||||
const { jellyfinUserId, token } = await createOne(cfg, user);
|
||||
outcome.created.push({ userId: user.id, jellyfinUserId, username: user.username!, token });
|
||||
const { jellyfinUserId, token, deviceId } = await createOne(cfg, user);
|
||||
outcome.created.push({ userId: user.id, jellyfinUserId, username: user.username!, token, deviceId });
|
||||
} catch (error) {
|
||||
// One person failing must not stop the rest. A partially provisioned server is recoverable by
|
||||
// running again; a run that aborts on the first error leaves everyone after them unprovisioned and
|
||||
|
||||
Reference in New Issue
Block a user