Skip to content

fix(oidc): show the sign-in link as a QR so a TV can hand the consent to a phone (#216) - #218

Merged
ZL154 merged 3 commits into
ZL154:mainfrom
camarigor:feat/oidc-device-flow-qr
Sep 22, 2026
Merged

ZL154 merged 3 commits into
ZL154:mainfrom
camarigor:feat/oidc-device-flow-qr

Conversation

@camarigor

Copy link
Copy Markdown
Contributor

Summary

Fixes #216. A TV cannot open a browser and has no keyboard, so both ways out of the OIDC sign-in modal are dead ends there: LG webOS refuses the link (the reporter sees error -27) and "Copy sign-in link" needs somewhere to paste it. This shows the same authorize URL as a QR code, so the consent can be granted on a phone.

Nothing about the flow changes. Oidc/LoginInfo already returns an authorize URL and a poll token, and Oidc/DevicePoll is keyed by that 256 bit single use token with no binding to the device or the address, which is what makes the consent portable between screens. The modal simply gains a picture of the URL it already has.

Type of change

  • Bug fix (non-breaking)
  • New feature (non-breaking)
  • Breaking change (existing behaviour, config, or API changes)
  • Security fix
  • Documentation only
  • CI / build / tests only
  • Refactor (no functional change)

It reads as a feature, but the sign-in path on a TV is currently unreachable for an OIDC-only account, so I filed it as the fix for that.

Related issues

Fixes #216. Builds on the in-page device flow from #64 (v2.5.9, v2.5.12), which introduced Oidc/LoginInfo, Oidc/DevicePoll and the modal this touches.

How was this tested?

  • Added or updated unit tests
  • Added or updated integration tests
  • Tested manually against a running Jellyfin server (state version)
  • N/A (explain why below)

Server tested against: Jellyfin 12.0.0, official Docker image, with the Jellyfin 12 package built from this branch, and Dex 2.41.1 as the identity provider.

The point to prove is that a consent granted somewhere else finishes the sign-in on the screen that is polling. The test drives two separate HTTP sessions, one standing in for the TV and one for a phone, with their own cookie jars:

[TV]     GET  TwoFactorAuth/Oidc/LoginInfo/dexprov
         -> authUrl, pollToken, qrCodeBase64 (2384 chars, valid PNG, 1788 bytes)
[TV]     GET  TwoFactorAuth/Oidc/DevicePoll?pt=...        -> {"ready":false}
[phone]  GET  <authUrl>  (separate session, follows Dex through the whole consent)
         -> 200, ends at TwoFactorAuth/Oidc/Callback/dexprov?code=...&state=...
[TV]     GET  TwoFactorAuth/Oidc/DevicePoll?pt=...        -> ready:true, username, bridge token
[TV]     POST Users/AuthenticateByName as DeviceId "lg-tv-1" with the bridge token
         -> 200, session created for the IdP user

Server log for the same run:

[2FA] OIDC success for user kilgore (3245a282-...) via dexprov
[2FA] OIDC device-poll completed in browser for kilgore
[2FA] OIDC bridge-token auth attempt for user kilgore
[2FA] OIDC sign-in for kilgore via dexprov

I also checked that the QR is really the URL and not a picture of something else: re-rendering the same authorize URL with the same library at the same settings produces a byte-identical PNG (1788 bytes both ways), and a control with one character appended to the URL produces different bytes.

Local, with the .NET SDK 9.0.316 that CI installs: dotnet build JellyfinSecurity.sln -c Release has 0 errors (the two CS8602 warnings in Jellyfin12ShellTests.cs are on main already and are what #202 fixes), dotnet test -c Release is 513 passed and 0 failed, node --test tests/oidc-bridge.test.mjs is 8 passed, and node --check on the injected script passes. With the .NET SDK 10.0.400, the Jellyfin 12 build has 0 warnings and 0 errors.

What changed

  • Api/SecurityController.cs: Oidc/LoginInfo returns qrCodeBase64 alongside authUrl and pollToken. A private RenderQrPng helper draws it and returns an empty string on any failure, so a sign-in can never break because a picture could not be drawn. Scale 6 rather than the 5 used for TOTP enrolment, since this one is read across a room.
  • Pages/inject.js: showOidcModal takes the QR as an optional third argument and renders it above the existing buttons when present. The modal opened before the server answers passes null and looks exactly as it does today.
  • Pages/translations/*.json: one new key, tfa.login.oidc_qr_help, in all eight languages, so EverySupportedTranslation_HasEnglishKeyParity stays green.
  • tests/.../OidcDeviceFlowQrTests.cs (new, 2 tests): the modal renders the QR only when the server sends one, reads the field in either casing, and the caption exists in every language.

Checklist

  • My code follows the existing style (run dotnet format if unsure)
  • I've added comments only where the why isn't obvious from the code
  • I've updated the README / SECURITY.md / docs if behaviour or config changed: no user-facing setting changes; happy to add a line to the TV section if you want one
  • I've considered backwards compatibility (config migration, file formats, on-disk state): one extra JSON field that older clients ignore, no state, no configuration
  • I've checked the security implications (auth bypass, secret handling, input validation): the QR carries the authorize URL that the same response already returns in clear, so it exposes nothing new; the poll token is not in the QR; nothing about token binding, expiry or single use changes
  • CI passes (dotnet build + dotnet test green)

Additional notes

  • The QR is drawn server side because the plugin already bundles QRCoder for TOTP enrolment and device pairing, so no new dependency and no client-side library on a TV's old browser engine.
  • Two things the reporter also raised that I deliberately left out of this PR: keeping the TV signed in afterwards, which belongs to trusted devices and indefinite trust rather than to getting it signed in at all; and the 2FA challenge page, which on an OIDC-only account should not appear once the provider sign-in satisfies the plugin. I asked them to confirm the second after trying this.
  • The translations for the seven non-English strings are mine; corrections from native speakers are welcome.

@Heldenkrieger01

Copy link
Copy Markdown

Thanks a lot for this PR! I have manually tested it out on my server. The QR code generates successfully, however there is a problem: The redirect URI in the QR Code is wrong: Its my local Jellyfin Server address (192.168.x.y) instead of the public facing URL (jellyfin.example.com). I have noticed the same thing happening in the password reset email as well. My assumption is, that the plugin needs to be made aware of the Jellyfin instance's actual user-facing IP/URL.

When manually editing the redirect_uri, I was able to login :)

@camarigor

Copy link
Copy Markdown
Contributor Author

Thank you for testing it on a real LG, and for spotting this. You are right, and your diagnosis is right too.

Why it happens. Every externally facing link the plugin builds is derived from the incoming request. That works when the request arrives through your reverse proxy, because the plugin honours X-Forwarded-Host and X-Forwarded-Proto once the peer is in Trusted Proxy CIDRs. Your TV is the case it cannot handle: a smart TV on the LAN connects straight to Jellyfin at 192.168.x.y, so there is no proxy in the path and no header to read. The plugin then does the only thing it can and uses the address it was reached on, which is the private one the IdP does not accept. No proxy setting fixes that, because the proxy is not involved in the request at all. The server has to be told its public address.

It is the same root cause in the password reset email you noticed, and in the device pairing QR codes, which build their URLs the same way. Four places in total.

What I am doing about it. PR #219, kept separate because this is a different concern from showing a QR and it touches the OIDC redirect the IdP validates. The plugin will resolve a public base URL once, and use it for all four links, in this order:

  1. An explicit plugin setting, for the cases where nothing else is right.
  2. Jellyfin's own published server URL, the PublishedServerUriBySubnet entry for external or all in Networking settings. If you already have that set to https://jellyfin.example.com, this alone fixes your case with no new configuration.
  3. The current request-derived behaviour, unchanged, when neither is configured.

That PR is #219. This one stays as it is, since the QR itself works. Your case needs both: this PR to show the code, and that one to put the right host inside it.

About the rest of your report. Two things I checked while working on this, both with a throwaway server rather than by reading the code:

The 2FA challenge page only appears for an OIDC account when the provider has "Bypass plugin 2FA" turned off and 2FA is required. With the default, the IdP sign-in satisfies the plugin and no challenge is issued at all, which I confirmed with enforcement set to All. If you are seeing the challenge on the TV, that switch on your provider is the thing to look at, and please tell me either way.

Keeping the TV signed in afterwards already works and needs nothing new. The plugin persists the verified session tokens to disk, so I signed a TV in, restarted the whole server, and the same token still worked. The device itself is not added to any trusted list, but it does not need to be.

One question still open from my earlier comment, which decides whether there is anything left to fix on the TV side: does webOS report error -27 on the popup itself, or only after it tries to hand the link to the TV browser?

@ZL154
ZL154 merged commit af53d86 into ZL154:main Sep 22, 2026
4 checks passed
ZL154 pushed a commit that referenced this pull request Sep 25, 2026
…Seerr guidance (#228)

Bumps 2.6.1.0 -> 2.6.2.0. Rolls up the post-2.6.1 work merged to main:
- #216: OIDC sign-in QR for TVs (#218), public base URL for redirect/QR/reset (#219),
  opt-in second-screen device pairing (#220)
- #215: GeoIP suspicious-login + impossible-travel detectors now called (#217),
  proxy-resolved client IP in session-start audit (#231)
- #228: corrected Seerr/Jellyseerr + device-pairing README guidance (#230)
- deps: Fido2 4.1.0 (RPID/RPName), test tooling (#229); QuestPDF + IdentityModel pins held

All bench-tested on both ABIs (10.11 net9 + JF12 net10), incl. live OIDC->2FA (#205),
QR (#218), public-URL redirect (#219), device pairing (#220). 570/571 tests
(1 Windows-only path flake).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] Unable to sign in with LG TV App and OIDC only setup

3 participants