bitfocus / companion

Bitfocus Companion enables the reasonably priced Elgato Streamdeck and other controllers to be a professional shotbox surface for an increasing amount of different presentation switchers, video playback software and broadcast equipment.
http://bitfocus.io/companion
Other
1.48k stars 492 forks source link

Support OAuth flow for modules #2546

Open Julusian opened 11 months ago

Julusian commented 11 months ago

Is this a feature relevant to companion itself, and not a module?

Is there an existing issue for this?

Describe the feature

Some modules use OAuth to authenticate against an API. This is done in varying ways, due to the complexities involved and it not fitting into our config model very well, as it involves navigating to a URL, and then storing a code that was returned.

The current recommended approach to OAuth is documented on the wiki: https://github.com/bitfocus/companion-module-base/wiki/OAuth

What can we do to simplify/streamline this for modules, to make the experience better for users?

Custom config field helper

A full approach could involve creating a new config field type, which would handle the abstraction of OAuth. It would handle showing/generation of all the necessary config fields for setting up OAuth, and provide them to the module as an object.

{
    type: 'oauth',
    id: 'myoauth',
    label: 'Authentication',
    width: 12,
    exchangeUrl: 'https://api.restream.io/oauth/token',
    authorizationUrl: 'https://api.restream.io/login',
    scopes: 'some scopes here',
},

with the resulting object looking like:

{
  clientId: '',
  clientSecret: '',
  accessToken: '',
  refreshToken: '',
}

As part of this component we could offer a button to open the completed authorizationUrl so that the user can go through the process.
We would also then handle receiving the code through a callback, and converting that into access and refresh tokens.

Some thought will need to go into the UX of this in the browser, and how that will work with saving the module config. But this would allow modules to massively simplify their OAuth usage, and make something that is consistent for the users.

Or keep it simpler with some other options to streamline some small portions:

A way for modules to 'open' a url for the user

Some modules are using open to open the authorizationUrl automatically for users. Perhaps we could provide a way for modules to request a url is opened for the user, which we can show as a prompt in the webui. That way it will work for users who are configuring remotely.

Usecases

No response

Julusian commented 3 months ago

This would work as after filling in the 'automatic' fields in the settings, an 'authorize' button will become enabled. Among these fields, would be a field showing the current callback url, which would be dynamic and use the same ip/hostname as the current browser window. Clicking that will open a modal hosting an iframe which performs the auth. The callback url can point to a simple page of ours, which can emit an event to report that the auth has finished, and will push the final tokens out to the still open settings panel. Then the user can hit save and hand over the tokens we retrieved to the module.

Maybe we should also have a 'lighter' flow for this, where we let modules create these buttons without all the config fields, and we store the whole blob of json returned in a value. That way if there is an oauth like flow that doesn't fit what we have defined, modules can still do it themselves with our help to just help collect and store the tokens

thedist commented 3 months ago

For my needs (utilizing multiple different OAuth flows for several different services) I think module developers themselves can handle many steps of each auth flow, but where work needs to be done is specifically on the Redirect step.

Some of the difficulties Companion faces with OAuth

  1. For user-supplied credentials, they need a URL that can be redirected to. Because module devs don't know the origin for the user we often have to redirect to someone that 404's and have them copy paste the code/token.
  2. For the Implicit flow modules have no easy way to access the token.
  3. For dev supplied credentials, most services don't allow for wildcard redirects so we can't redirect when origin varies, and many also require HTTPS

Possible ways to solve these difficulties

  1. When the redirect URL is to be done on the users side, if the the Web UI could replace a certain string with the origin being used in that web ui then the config could have something like an instruction for the user to use $(internal:origin)/instance/${instance.label}/redirect, where the modules own config code would format that to $(internal:origin)/instance/SomeLabel/redirect, and the Web UI itself would replace the origin to display protocol://domain:port/instance/SomeLabel/redirect
  2. The OAuth Implicit flow provides a token in the URL Hash, rather than a querystring param, so to solve this there needs to be an actual page rendered and then some JS to grab the token and pass it to the module. Rather than every module doing their own version of this, a single unified Companion branded page that could be utilized by many modules would have a more professional look.
  3. Having a static redirect URL used by all users of a module would allow for creating proper Companion branded apps, rather than end users needing to make dev accounts, generating credentials, and copy/pasting them in to Companion. This can't be done within Companion itself, so devs would either need to use their own server solution, or one option could be for an OAuth redirect under the https://bitfocus.io domain. Once redirected to that server the user could be then redirected back their Companions origin along with the token or code depending on flow for the module to handle (this can be achieved through OAuth State).

These difficulties/ideas are also ordered in difficulty to implement. The first one still requires users to make their own credentials, in some cases such as Google they'd get warnings on redirect about the app not being verified, but it would simplify at least one step of the process. The second may not be as important, as I'm not sure how many services would use the Implicit flow in this situation but for completeness it may be useful to have a solution that can be utilized across multiple modules.

The third point is one I think could be the most professional solution, as then we could have a Bitfocus Companion app for Google Sheets that all users auth to, and it could be verified so wouldn't have the warnings a user gets when using credentials they've made themselves. It would require an external site though, such as the bitfocus site, to achieve. This also doesn't solve all OAuth flows, for example Discord will always require user-submitted credentials as they refuse to verify my Companion app like they did with Elgato's Discord plugin for StreamDeck, but for most modules it could be a benefit.

Julusian commented 3 months ago

Yes, the main difficulty is the redirect step. But starting the authorization flow is also something that we should aim to improve. Today there are a few approaches taken:

None of these options are friendly to users.


I did create https://github.com/bitfocus/companion-oauth/tree/main / https://bitfocus.github.io/companion-oauth/callback/ some months back, which was aimed to help solve this in a minimal/non-invasive manner (no core or module-api changes). But this may not work for everything, and could be improved as it is a first draft and could do with better support for multiple companion installations. Usage is described in https://github.com/bitfocus/companion-module-base/wiki/OAuth.


While I would like to explore the idea of having this as a field type, to make it really easy to do oauth, I am happy to start by implementing small chunks of this which can be used by modules to do parts of the flow manually, and then eventually also be used by this all in one field type.

So perhaps the first step could be to have a new config field type that will allow for adding an 'authorize' button in the config. This could either call a new module method to generateAuthorizationUrl(.....) or perhaps it should use the expression syntax to do client side string interpolation. And another expression to check if the button should be enabled. Then as I proposed above: Clicking that will open a modal hosting an iframe which performs the auth. The callback url can point to a simple page of ours, which can emit an event to report that the auth has finished, and will push the final tokens out to the still open settings panel.

It needs some thought on how to parse/fetch the token out of the iframe, considering it could be in the hash or url. (could it be under different parameter names?) I am hoping that we would be able to use https://bitfocus.github.io/companion-oauth/something to be the redirect, and set the iframe/page to allow cross-origin to behave in an 'insecure' manner so that we can extract what we need without needing to redirect back to companion itself.

I think it is worth at least prototyping this to see how it would work in practise (from a UX perspective)


Having a static redirect URL used by all users of a module would allow for creating proper Companion branded apps, rather than end users needing to make dev accounts

I think this would be good, but we should be careful to ensure that modules wont become unusable because the tokens are no longer valid. Because that could happen at any time, including on a show day for someone. And if they are using an older release, being forced to update companion to resolve this will be incredibly annoying or unrealistic as it could require them to rebuild chunks of their config

thedist commented 3 months ago

First off I think an iframe should be ruled out, as while the OAuth 2.0 spec doesn't strictly prohibit the use of iframes it strongly encourages OAuth providers to use X-Frame-Options to deny it, which is why most OAuth providers (Twitch and Google to name a couple off the top of my head) block auth pages from being loaded inside an iframe.

It needs some thought on how to parse/fetch the token out of the iframe, considering it could be in the hash or url. (could it be under different parameter names?)

If a service is compliant with the specification then the implicit flow would redirect with an access_token in the URL hash, and for the Auth Code flow it'd be a code in the querystring params.

I think this would be good, but we should be careful to ensure that modules wont become unusable because the tokens are no longer valid. Because that could happen at any time, including on a show day for someone. And if they are using an older release, being forced to update companion to resolve this will be incredibly annoying or unrealistic as it could require them to rebuild chunks of their config

I don't see anything that would require a user to update their Companion to get new tokens or anything like that. OAuth tokens expire and need refreshing periodically (for example, every hour for Google, 4 hours for Twitch), if a user provides their own credentials then everything could be done in the module to make requests to the service to get new tokens, but that means they have to make their own apps, enable the APIs they need, copy/paste credentials etc... With my suggestion it'd be the module devs making a single app and the credentials and token renewal would be handled outside of the module, with just the access token being passed to the module. This would mean that an outage of that service running outside of Companion would break things, but that has to be weighed against the somewhat amateur nature of the way we're doing things currently needing users to do dev stuff.

One potential idea that just came to mind:

  1. User goes through a modules config, entering whatever they need, then click save.
  2. If the module has everything it needs for OAuth (such as credentials), it goes to a new 'Awaiting Auth' state. This state would show on the Connections screen (which the user would be on after saving the config), and could be clickable and send the user to the service. As part of the generation of the URL, we could encode the module instance id, and origin, into the state param.
  3. The user would agree to the auth, and be redirected to some place like https://bitfocus.github.io/companion-oauth/callback/. This would be HTTPS, as some providers require that, that site could then decode the state param to know where to redirect the user to, and so redirects the user to that URL (which is their Companion web ui), to some path that Companion could use for auth where it takes the params from the URL, passes them to the module (of which it has the instance ID from the state param). and then auto-close the tab.
  4. The auth tab should now be closed, the module has the tokens, and can switch to a Connected state.
  5. When the token expires, if the user supplied their own app credentials then refreshing a token could be done in module, otherwise it could make the request to an external server run by the module dev or bitfocus to handle token renewal with the credentials stored outside the module.
Julusian commented 3 months ago

First off I think an iframe should be ruled out, as while the OAuth 2.0 spec doesn't strictly prohibit the use of iframes it strongly encourages OAuth providers to use X-Frame-Options to deny it

Good to know, I wasn't aware of that. I agree that this shouldn't be an iframe then.

I don't see anything that would require a user to update their Companion to get new tokens or anything like that. OAuth tokens expire and need refreshing periodically

Sorry, I meant the client id and client secret.

As part of the generation of the URL, we could encode the module instance id, and origin, into the state param.

Ooh, good idea. For some reason it didn't occur to me to put the current origin into there..
We would also want to include the config field name(s) to write the result into, probably some kind of version number so that we can change the format without changing the url too. But we don't have to define this too much right now, as its not a 'public api' even though it is called externally.

When the token expires, if the user supplied their own app credentials then refreshing a token could be done in module, otherwise it could make the request to an external server run by the module dev or bitfocus to handle token renewal with the credentials stored outside the module.

For now lets leave this as up to the module to figure out.
I'm open to this, but have questions/concerns about scalability (will some providers see 1000s of different client-ids being used from the same ip as spam/bots and revoke tokens or ban accounts? It sounds kinda suspicious, perhaps they could be stolen credentials that are being collected/kept for an attack), or could it simply be against TOS? It definitely ought to be possible to opt-out of this auto-renew service (or should it be opt-in), some places/people will not like their tokens being held by an external service like this.

This state would show on the Connections screen (which the user would be on after saving the config), and could be clickable and send the user to the service

This will need a bit of thought on how/where to generate the url and get it to the ui (should the second parameter be an object for this state? should it be driven by the manifest.json? something else?).
I have had the thought about maybe we could show a notification that a module needs oauth completing, which this would allow for nicely.

So this approach sounds good to me :)