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>
246 lines
8.2 KiB
JavaScript
246 lines
8.2 KiB
JavaScript
/**
|
||
* 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');
|
||
}
|