Files
claude 9e33a343ab
Some checks failed
release / build-release (push) Has been cancelled
Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
- 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>
2026-10-25 09:42:27 +01:00

246 lines
8.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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');
}