Files
plezy/lib/services/jellyfin_auth_header.dart
T
edde746 53535e1678 fix(jellyfin): percent-encode the MediaBrowser auth header
Since real device names started reaching the header, an accented one
made login impossible: dart:io refuses header values above 0x7F, and
CFNetwork puts the raw code unit on the wire as a Latin-1 byte, which
Kestrel rejects as a malformed request with 400 before Jellyfin routes
POST /Users/AuthenticateByName.

Encode every field the way the official Jellyfin SDK does; the server
already reverses it with WebUtility.UrlDecode, so the wire value stays
pure ASCII while the device list shows the real name. Quotes, commas
and `=` no longer need stripping either. sanitizeHeaderValue, which
still guards the Plex headers, now folds Latin letters to their base
form instead of emitting bytes no transport accepts.

close #1685
2026-07-28 05:07:00 +02:00

67 lines
3.0 KiB
Dart

import '../utils/device_identity.dart';
/// Build the `MediaBrowser` Authorization header value the way the official
/// Jellyfin SDK formats it: every field value is percent-encoded, and the
/// server reverses that with `WebUtility.UrlDecode` while parsing the header.
/// Used at auth time and on every authenticated request so the server sees a
/// consistent client identity.
///
/// Encoding is what keeps the header sendable at all. A device name like
/// `Bjørn PC` cannot travel verbatim: `dart:io` rejects header values above
/// 0x7F outright, and CFNetwork puts the raw code unit on the wire as a
/// Latin-1 byte, which Kestrel — the HTTP server hosting Jellyfin — refuses
/// as a malformed header with 400 before the request is ever routed. It also
/// removes the grammar hazards the header has no escape for: quotes, commas,
/// and `=` inside a value.
///
/// Jellyfin requires non-empty client, device, and version fields when
/// creating a session, so those values use stable fallbacks. An empty device
/// ID is omitted for authenticated requests, where Jellyfin can recover it
/// from the token; unauthenticated entry points must call
/// [requireJellyfinDeviceId].
String buildJellyfinAuthHeader({
required String clientName,
required String clientVersion,
required String deviceName,
required String deviceId,
String? accessToken,
}) {
String field(String name, String value) => '$name="${Uri.encodeComponent(value)}"';
final client = _meaningful(clientName);
final effectiveClient = client.isEmpty ? 'Plezy' : client;
final device = _meaningful(deviceName);
final version = _meaningful(clientVersion);
final id = _meaningful(deviceId);
final token = _meaningful(accessToken ?? '');
final parts = <String>[
field('Client', effectiveClient),
field('Device', device.isEmpty ? effectiveClient : device),
if (id.isNotEmpty) field('DeviceId', id),
field('Version', version.isEmpty ? '1.0' : version),
if (token.isNotEmpty) field('Token', token),
];
return 'MediaBrowser ${parts.join(', ')}';
}
final RegExp _controlCharacters = RegExp(r'[\x00-\x1f\x7f-\x9f]');
/// Percent-encoding makes any byte transportable, so the only values worth
/// filtering are the ones that carry no identity at all — a name of control
/// characters would otherwise reach Jellyfin's device list as `%00` noise
/// instead of falling back to a readable label.
String _meaningful(String value) => value.replaceAll(_controlCharacters, '').trim();
/// Validates the stable device identity required by unauthenticated Jellyfin
/// session creation. Never substitute a placeholder: Jellyfin keys sessions
/// and access tokens by this value, so a shared fallback would collide across
/// installations.
String requireJellyfinDeviceId(String deviceId) {
final sanitized = sanitizeHeaderValue(deviceId);
if (sanitized == null || sanitized != deviceId || sanitized.contains('"')) {
throw ArgumentError.value(deviceId, 'deviceId', 'must be a non-empty HTTP-safe value');
}
return sanitized;
}