Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
Some checks failed
release / build-release (push) Has been cancelled

- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped)
- IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the
  AES-128 key is fetched server-side with the session cookie, segments stay on the CDN
- delivery/info cached per token lifetime and throttled (~2/min)
- config page: paste sails.sid + validate, extra creators, quality, search test
- scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow
- research docs, HAR analysis (HAR itself gitignored), install-pi5.sh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 09:42:27 +01:00
commit 9e33a343ab
162 changed files with 52004 additions and 0 deletions

View File

@@ -0,0 +1,245 @@
/**
* OIDC discovery + Keycloak device authorization grant helpers.
*
* Frontend TV client (fp-tv-app, usesExtendedSecurity):
* - PKCE S256 on device authorization + code_verifier on token poll
* - DPoP (ES256) proof on token poll (and later on REST calls)
*/
import { createHash, randomBytes } from 'node:crypto';
import { OIDC, USER_AGENT } from './constants.mjs';
import { attachDpopHeaders, generateDpopKeyPair, readDpopNonce } from './dpop.mjs';
/**
* Generate a PKCE code_verifier (43–128 chars, unreserved).
* @param {number} [bytes]
*/
export function generateCodeVerifier(bytes = 32) {
return base64Url(randomBytes(bytes));
}
/**
* S256 code_challenge for a verifier.
* @param {string} verifier
*/
export function generateCodeChallenge(verifier) {
return base64Url(createHash('sha256').update(verifier, 'ascii').digest());
}
/** @param {Buffer} buf */
function base64Url(buf) {
return buf
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
/**
* @param {{ fetchImpl?: typeof fetch, wellKnownUrl?: string }} [opts]
*/
export async function discoverOidc(opts = {}) {
const fetchImpl = opts.fetchImpl || fetch;
const url = opts.wellKnownUrl || OIDC.wellKnownUrl;
const res = await fetchImpl(url, {
method: 'GET',
headers: { accept: 'application/json', 'user-agent': USER_AGENT },
});
if (!res.ok) {
throw new Error(`OIDC discovery failed: HTTP ${res.status} for ${url}`);
}
const doc = await res.json();
if (!doc.device_authorization_endpoint) {
throw new Error('OIDC discovery missing device_authorization_endpoint');
}
if (!doc.token_endpoint) {
throw new Error('OIDC discovery missing token_endpoint');
}
const grants = doc.grant_types_supported || [];
if (!grants.includes(OIDC.grantTypeDeviceCode)) {
throw new Error(`OIDC discovery missing grant ${OIDC.grantTypeDeviceCode}`);
}
return {
issuer: doc.issuer,
deviceAuthorizationEndpoint: doc.device_authorization_endpoint,
tokenEndpoint: doc.token_endpoint,
scopesSupported: doc.scopes_supported || [],
grantTypesSupported: grants,
raw: doc,
};
}
/**
* Start device authorization (public client — no client_secret; PKCE required).
* @param {{
* discovery: Awaited<ReturnType<typeof discoverOidc>>,
* clientId?: string,
* scope?: string,
* fetchImpl?: typeof fetch,
* codeVerifier?: string,
* }} opts
*/
export async function requestDeviceAuthorization(opts) {
const fetchImpl = opts.fetchImpl || fetch;
const clientId = opts.clientId || OIDC.clientId;
const scope = opts.scope || OIDC.defaultScope;
const codeVerifier = opts.codeVerifier || generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const body = new URLSearchParams();
body.set('client_id', clientId);
if (scope) body.set('scope', scope);
// Required by fp-tv-app (frontend USE_CODE_CHALLENGE / Keycloak public client)
body.set('code_challenge_method', 'S256');
body.set('code_challenge', codeChallenge);
const res = await fetchImpl(opts.discovery.deviceAuthorizationEndpoint, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
accept: 'application/json',
'user-agent': USER_AGENT,
},
body,
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const err = json.error || `HTTP ${res.status}`;
const desc = json.error_description ? `: ${json.error_description}` : '';
throw new Error(`device authorization failed: ${err}${desc}`);
}
if (!json.device_code || !json.user_code) {
throw new Error('device authorization response missing device_code/user_code');
}
return {
deviceCode: json.device_code,
userCode: json.user_code,
verificationUri: json.verification_uri || json.verification_uri_complete,
verificationUriComplete: json.verification_uri_complete || null,
expiresIn: Number(json.expires_in) || 600,
interval: Number(json.interval) || 5,
codeVerifier,
};
}
/**
* Poll token endpoint until authorized, expired, or aborted.
* Always sends DPoP (fp-tv-app usesExtendedSecurity / USE_DPOP).
* @param {{
* discovery: Awaited<ReturnType<typeof discoverOidc>>,
* deviceCode: string,
* clientId?: string,
* codeVerifier?: string,
* dpopKeyPair?: Awaited<ReturnType<typeof generateDpopKeyPair>>,
* intervalSec?: number,
* expiresInSec?: number,
* fetchImpl?: typeof fetch,
* sleep?: (ms: number) => Promise<void>,
* now?: () => number,
* onPoll?: (info: { status: string, attempt: number }) => void,
* signal?: AbortSignal,
* }} opts
*/
export async function pollDeviceToken(opts) {
const fetchImpl = opts.fetchImpl || fetch;
const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
const now = opts.now || (() => Date.now());
const clientId = opts.clientId || OIDC.clientId;
const dpopKeyPair = opts.dpopKeyPair || (await generateDpopKeyPair());
let intervalMs = Math.max(1, (opts.intervalSec ?? 5) * 1000);
const deadline = now() + Math.max(1, opts.expiresInSec ?? 600) * 1000;
let attempt = 0;
/** @type {string|null} */
let dpopNonce = null;
while (now() < deadline) {
if (opts.signal?.aborted) {
throw new Error('device token poll aborted');
}
attempt += 1;
const body = new URLSearchParams();
body.set('grant_type', OIDC.grantTypeDeviceCode);
body.set('device_code', opts.deviceCode);
body.set('client_id', clientId);
if (opts.codeVerifier) body.set('code_verifier', opts.codeVerifier);
/** @type {Record<string, string>} */
const headers = {
'content-type': 'application/x-www-form-urlencoded',
accept: 'application/json',
'user-agent': USER_AGENT,
};
await attachDpopHeaders({
headers,
keyPair: dpopKeyPair,
method: 'POST',
url: opts.discovery.tokenEndpoint,
nonce: dpopNonce,
});
const res = await fetchImpl(opts.discovery.tokenEndpoint, {
method: 'POST',
headers,
body,
});
const nonceHeader = readDpopNonce(res);
if (nonceHeader) dpopNonce = nonceHeader;
const json = await res.json().catch(() => ({}));
if (res.ok && json.access_token) {
opts.onPoll?.({ status: 'authorized', attempt });
const obtainedAtMs = now();
const expiresIn =
typeof json.expires_in === 'number' ? json.expires_in : Number(json.expires_in);
const refreshExpiresIn =
typeof json.refresh_expires_in === 'number'
? json.refresh_expires_in
: json.refresh_expires_in != null
? Number(json.refresh_expires_in)
: null;
return {
accessToken: json.access_token,
refreshToken: json.refresh_token || null,
expiresIn: Number.isFinite(expiresIn) ? expiresIn : null,
refreshExpiresIn: Number.isFinite(refreshExpiresIn) ? refreshExpiresIn : null,
accessExpiresAtMs:
Number.isFinite(expiresIn) ? obtainedAtMs + expiresIn * 1000 : null,
refreshExpiresAtMs:
Number.isFinite(refreshExpiresIn) && refreshExpiresIn > 0
? obtainedAtMs + refreshExpiresIn * 1000
: null,
sessionState: json.session_state || null,
tokenType: json.token_type || 'DPoP',
scope: json.scope || null,
obtainedAtMs,
responseKeys: Object.keys(json).sort(),
dpopKeyPair,
};
}
const err = json.error || `HTTP ${res.status}`;
opts.onPoll?.({ status: err, attempt });
if (err === 'authorization_pending') {
await sleep(intervalMs);
continue;
}
if (err === 'slow_down') {
intervalMs += 5000;
await sleep(intervalMs);
continue;
}
// Keycloak may require a nonce retry without waiting the full interval
if (err === 'use_dpop_nonce' || (err === 'invalid_dpop_proof' && nonceHeader)) {
continue;
}
if (err === 'expired_token' || err === 'access_denied') {
throw new Error(
`device token poll ended: ${err}${json.error_description ? `: ${json.error_description}` : ''}`,
);
}
throw new Error(
`device token poll failed: ${err}${json.error_description ? `: ${json.error_description}` : ''}`,
);
}
throw new Error('device token poll timed out');
}