jellyfin / jellyfin-meta

A repository to hold our roadmap, policies, and more.
25 stars 4 forks source link

Add custom scheme handler / intent handler to accomodate single-sign-on via browser #28

Closed strazto closed 5 months ago

strazto commented 2 years ago

Request

Basically, I'd like to be able to open a link with a custom schema to the app that supplies:

- serverUrl
- userId
- accessToken

To an intent handler (or whatever we call it in ios), that stores that information in the web client to initiate a session, and skips the login.

This would intend to accomodate single-sign-on via plugin, eg the popular

https://github.com/9p4/jellyfin-plugin-sso

Here is a demonstration of the current flow on the android client (behaves similarly)

https://user-images.githubusercontent.com/39424834/180710755-63c8addc-7c18-412f-8e1d-47bc411a1006.mp4

Take note that although we can get a via single sign on, the new session is in the browser, rather than the jellyfin app.

From a technical perspective, this is not too hard to implement, both Android and iOS have APIs for using custom links open apps with some kind of data payload https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app

Background

I am one of the maintainers of a plugin that provides single-sign-on to jellyfin servers

https://github.com/9p4/jellyfin-plugin-sso

The plugin is now at a stage of development where its functionality is quite mature, but its main limitation is that it only supports the web-client.

Flows

Assume the jellyfin server lives at https://myjellyfin.com

The plugin can serve a page that initiates the SSO flow (either oauth or saml, I'm mainly going to talk about oauth), this page is at https://myjellyfin.com/SSO/OID/p/<provider_name>.

The plugin also serves a callback page for completing SSO flows, eg https://myjellyfin.com/SSO/OID/r/<provider_name>.

For example, you might use Google as your provider, so you'd use https://myjellyfin.com/SSO/OID/p/google, and https://myjellyfin.com/SSO/OID/r/google

On the web-client, the SSO flow from login-page to signed in dashboard is straightforward.

User perspective (Web client):

  1. On the login page, not signed in
  2. Click on the link to "Sign in via SSO"
    • Get redirected to the sign in-page for your SSO provider (eg, google)
    • complete the flow on your SSO provider (eg, confirm sign-in, or select your account)
    • get redirected back to jellyfin, and you're signed in

Actual flow (Web client)

  1. User on the login page, not signed in
  2. User click on the link to "Sign in via SSO"
  3. (OAuth magic) This takes user to a page (https://myjellyfin.com/SSO/OID/p/google with a javascript client that initiates an OAuth flow
    1. The js client redirects the user to https://accounts.google.com/o/oauth2/v2/auth
      • The user completes the prompt on google
    2. Google redirects to https://myjellyfin.com/SSO/OID/r/google with the oauth state required
  4. On the redirect page, a js client uses the state given by google to confirm a valid session with the jellyfin server
    • The server gives the client a user ID + authorization token
    • THe client stores this in localStorage, with enough information for the browser to now have a valid, logged in session
    • The client redirects back to the homepage, (https://myjellyfin.com) and is now logged in

This flow works reliably. An important note, however, is that a lot of the oauth client-handling is done using javascript clients in web pages server directly by the plugin.

Limitations

Because the above flow relies on a browser oauth client, it effectively establishes a valid session within the browser

https://github.com/9p4/jellyfin-plugin-sso/blob/fad5a62e07e44c84bc94d5962b8b79e80c102ee8/SSO-Auth/WebResponse.cs#L449-L458

    var responseJson = JSON.parse(response);
    var userId = 'user-' + responseJson['User']['Id'] + '-' + responseJson['User']['ServerId'];
    responseJson['User']['EnableAutoLogin'] = true;
    localStorage.setItem(userId, JSON.stringify(responseJson['User']));
    var jfCreds = JSON.parse(localStorage.getItem('jellyfin_credentials'));
    jfCreds['Servers'][0]['AccessToken'] = responseJson['AccessToken'];
    jfCreds['Servers'][0]['UserId'] = responseJson['User']['Id'];
    localStorage.setItem('jellyfin_credentials', JSON.stringify(jfCreds));
    localStorage.setItem('enableAutoLogin', 'true');
    window.location.replace('" + baseUrl + @"');

It literally stores all the state the web-app needs in order to resume a session.

The problem is, that we can't use this to log-into the android or ios app

Additional context

Discussion migrated from Matrix - Pretty long but included for posterity [Matthew Strasiotto](https://matrix.to/#/!YOoxJKhsHoXZiIHyBG:matrix.org/$qultQ8GE3lk6Jmf0N1MIkSj6OmZ9BqqNzpNG4xP9--A?via=bonifacelabs.ca&via=t2bot.io&via=matrix.org): (July 6ish) > question - > Is it at all possible to add an intent to the jf android app that signs onto a user account using a token generated from elsewhere? > i'm reading about SSO for android, and how to add SSO clients to android apps > https://github.com/openid/AppAuth-Android > > i'm a contributor to https://github.com/9p4/jellyfin-plugin-sso > the actual oauth client works on jellyfin-web, & i'd like to get it to work for the android client - > The best way to do that is probably something along the lines of AppAuth-Android / maybe intent filters > > > in reality, as long as we can trigger some kind of redirect back to the jellyfin app with enough data from the authenticated web-session to get a user logged into the app, there isn't a tonne of work > ... [Matthew Strasiottio](https://matrix.to/#/!YOoxJKhsHoXZiIHyBG:matrix.org/$8C4eqpMWwCWa0Gy98pbg0wzlkaKdc_87syR_FUIOL-s?via=bonifacelabs.ca&via=t2bot.io&via=matrix.org): > [mcarlton](https://matrix.to/#/!YOoxJKhsHoXZiIHyBG:matrix.org/$n_R7GOhYoukWRovxbBn7v2Uou4-E42oek3h5o9WLrGM?via=bonifacelabs.ca&via=t2bot.io&via=matrix.org) > Matthew Strasiotto > > > would quick connect accomplish what you're looking for? i'm not sure if it can be used in the android app as a client side yet > > Quick connect is good, and a good workaround for the use-case where people's primary authentication is SSO > It does work on the android app, but the limitation is that the current SSO flow my plugin gives will basically Auth a session in the browser, and a user would need to > 1. Initiate browser SSO > 2. Swap back to jellyfin, initiate quick connect > 3. Swap back to browser, complete quick connect flow > > Pretty clunky, good for Auth across devices tho [Matthew Strasiotto](https://matrix.to/#/!YOoxJKhsHoXZiIHyBG:matrix.org/$Yr7YShEbHvvZCsHIgz4hZbtAELNGxugmjdp6V1qFoBQ?via=bonifacelabs.ca&via=t2bot.io&via=matrix.org) July 17: > I'd like to support the SSO plugin i contribute to in jellyfin-android & jellyfin-expo > I've heard from maintainers that they're not interested in supporting SSO in their apps at this stage as it would require each client implement it. > I'd like to get around that with fully browser/web-app based implementation that's handled by the plugin, which basically handles the flow, gets the userID + access token > > The browser client would then open a custom scheme link that passes the server ID, user ID & access token back to the app, and triggers a "sign on" intent > > for either case, the jellyfin-android + jellyfin-expo app would only have to handle an intent being given with fully-formed credentials, and everything else could defer back to their web wrappers. > > Before i commence work on this, I'm curious if maintainers are open to considering PRs on either project (jf-android / jf-expo) that allow custom URL scheme handling for the scope of "given serverID, userID & accessToken, initate a session then commence main activity" so that my plugin can handle the rest from a native browser ![image](https://user-images.githubusercontent.com/39424834/180710127-d02fb28b-f6a7-4edb-9a5f-1512c961b933.png) ![image](https://user-images.githubusercontent.com/39424834/180710179-eb04ab85-f442-4c79-9fce-25bc5556f27a.png) ![image](https://user-images.githubusercontent.com/39424834/180710206-baa22bc8-04b8-4cf7-b693-4d348478dc89.png) July 21 https://matrix.to/#/!YOoxJKhsHoXZiIHyBG:matrix.org/$qn4tvt0GXVlDnBYIGgM0wCuO0C2SO2AAUyAI9xiQHlc?via=bonifacelabs.ca&via=t2bot.io&via=matrix.org ![image](https://user-images.githubusercontent.com/39424834/180710303-47979e81-4cfa-4b09-9926-4adfbac28e6d.png) ![image](https://user-images.githubusercontent.com/39424834/180710385-33b0f080-0b48-4b3a-9e78-e1c992b8d0a7.png) ![image](https://user-images.githubusercontent.com/39424834/180710445-f995c547-0122-4e71-a8e3-fe6d7a7ab562.png)
strazto commented 2 years ago

Hi - This is migrated from the Jf-dev matrix channel @thornbill @nielsvanvelzen

nielsvanvelzen commented 2 years ago

Thanks, I've looked at some other apps (mainly Element) to see how they add a similar feature. One change I'd like to make is to use a temporary login token that needs to be exchanged by the Jellyfin app for an access token.

So you get a flow similar to this:

  1. User opens SSO app
  2. SSO app deals with signin
  3. SSO app creates an exchange token in the server, this token expires in 5 minutes
  4. SSO app redirects to jellyfin://connect?exchange_token=ABCDEF&server=SERVERURL
  5. Jellyfin app reacts to this URL and opens a sign in activity
  6. Sign in activity checks the server (calling public system info) to see if it exists and if the server version is compatible with the app
  7. Sign in activity exchanges token with an access_token (server also responds with UserDto) and stores this locally (at this point the token is revoked serverside and cannot be used again)
  8. User is signed in

Login implementation for Element: https://github.com/vector-im/element-android/blob/afaa89ad42e1803b53c4610169646fedef4c0f68/docs/signin.md#login-with-sso

The sign in activity in the app will likely show a popup to confirm if the user wants to sign in. Maybe an endpoint to introspect the login token can be used to add more details to this popup (name of the user to sign in with for example).

strazto commented 2 years ago

Awesome, glad to hear you're on-board with the use case!

The sign in activity in the app will likely show a popup to confirm if the user wants to sign in.

Seems reasonable - it'd be nice to make this optional, just for UX, since it's one extra click/touch, though I don't really know how configuring that could work (the point of an explicit login-consent flow is to ensure the user wants this, so we can't trust the query string to toggle it for example)

One change I'd like to make is to use a temporary login token that needs to be exchanged by the Jellyfin app for an access token.

Yep, this occurred to me too- It might be slightly more complex to implement (And I think I'll have to touch other aspects of the architecture?) but I do understand why you're requesting this.

it's kind of like a "reverse quick-connect" - From an authenticated session, generate a one-time code & a client consumes that.

Do you anticipate that I'll have to make changes to jellyfin-server to accomodate the exchange-token API? I suspect I will.

I don't think this is a bad thing- This being a general feature of jellyfin-server is probably a good thing, and will likely service use-cases beyond my specific one (Once it's generally available, i might even be able to use it to simplify my plugin)

Projected Overall architectual scope

My suspicion is that I'll be exposing the following API endpoints:

(These are just sketches of what it might look like so i understand the components)

jellyfin-server:

my plugin:

This is pretty straightforward from my end

clients:

Versioning concern

Assuming we need to change the jellyfin server's API - which I think is likely:

for a client to use this functionality, they will need to call:

${SERVERURL}/getAccessToken?excahnge_token=ABCDEF

This will fail (404 probably) on jf server versions that predate this feature.

We have a few options to handle this version dependency -

nielsvanvelzen commented 2 years ago

Do you anticipate that I'll have to make changes to jellyfin-server to accomodate the exchange-token API? I suspect I will.

Yeah definitely, the server should always have the API available and just do nothing when there is no plugin using it.

[api stuff]

The API changes seem fine to me, the generateExchangeToken operation might not be needed. The paths and exact parameters will probably change when the actual PR for the server is made. I think most requests will be POST instead of GET.

org.jellyfin://

I think just jellyfin:// should be fine here.

Versioning concern

Assuming all server changes are merged for 10.9 the official clients will only start supporting the feature in 10.9. They will likely read the public system info first (/system/info/public) and check the server version for support. When connect is not supported on the server it will show an error in the UI.

nielsvanvelzen commented 2 years ago

(Moved to jellyfin-meta since this repository makes more sense for discussing organization wide changes)

LexNastin commented 1 year ago

Any updates on this? Sounds like an absolute rocker of a feature!

Sapd commented 9 months ago

Thanks, I've looked at some other apps (mainly Element) to see how they add a similar feature. One change I'd like to make is to use a temporary login token that needs to be exchanged by the Jellyfin app for an access token.

So you get a flow similar to this:

  1. User opens SSO app
  2. SSO app deals with signin
  3. SSO app creates an exchange token in the server, this token expires in 5 minutes
  4. SSO app redirects to jellyfin://connect?exchange_token=ABCDEF&server=SERVERURL
  5. Jellyfin app reacts to this URL and opens a sign in activity
  6. Sign in activity checks the server (calling public system info) to see if it exists and if the server version is compatible with the app
  7. Sign in activity exchanges token with an access_token (server also responds with UserDto) and stores this locally (at this point the token is revoked serverside and cannot be used again)
  8. User is signed in

Login implementation for Element: https://github.com/vector-im/element-android/blob/afaa89ad42e1803b53c4610169646fedef4c0f68/docs/signin.md#login-with-sso

The sign in activity in the app will likely show a popup to confirm if the user wants to sign in. Maybe an endpoint to introspect the login token can be used to add more details to this popup (name of the user to sign in with for example).

@nielsvanvelzen

To give my view: A flow this way would not be safe. Oauth2 standard requires PKCE for mobile apps, bc otherwise other (malicious) apps could receive the login code.

A safe flow (see also https://datatracker.ietf.org/doc/html/rfc8252 ):

  1. User opens Jellyfin App and wants to do a SSO sign in (the flow must begin at least here!)
  2. App generates a random string called verifier and saves it in a variable. Also it will generate a variable challenge= BASE64URL-ENCODE(SHA256(ASCII(verifier))). verifier will be kept secret until the token exchange.
  3. App will do an API call to jellyfin.myserver.com/login?code_challenge=thechallenge&code_challenge_method=S256 (Alternatively this step can be skipped if the app knows the SSO Provider URL. However the advantage is that the clientid can be kept secret).
  4. Jellyfin will proxy/forward it to sso provider auth.ssoprovider.com/realm/jellyfin/login?client_id=CLIENTID&code_challenge=thechallenge&code_challenge_method=S256&redirect=jellyfin://login (URL depends on the SSO Provider)
  5. The result of the API request will be a redirect (usually as Location: header). This must not be followed
  6. Open up the content of Location: header in a browser an open sign in activity on the SSO provider page
  7. On successful login, the SSO provider will redirect to jellyfin://login?code=CODE with a code.
  8. The code together with verifier from step 2 can now be used to exchange the code for a token.
  9. jellyfin.myserver.com/login/callback?code=CODE&code_verifier=VERIFIER (which can be forwarded to the SSO provider again, and the jellyfin server can retrieve further information like username, email etc.)

The SSO provider checks in a flow beginning with a code_challenge if verifier matches the code, if not the code cannot be used. A malicious app cannot unhash the verifier and as such it is safe.

j007bond007 commented 5 months ago

I hope this releases soon... it would be very useful for what appears to be a common setup (using SSO at home to ease password fatigue and improve security)!