supabase / auth

A JWT based API for managing users and issuing JWT tokens
https://supabase.com/docs/guides/auth
MIT License
1.54k stars 373 forks source link

Cross-Origin Refreshing of `provider_token` is not allowed under OAuth #1387

Closed dannypurcell closed 9 months ago

dannypurcell commented 9 months ago

Bug report

Describe the bug

Related to https://github.com/supabase/gotrue/pull/641#issuecomment-1235178425 and https://github.com/supabase/gotrue/issues/83#issuecomment-1913370935

It appears there is no viable way for a client app to use the provider_refresh_token returned by GoTrue.

This is essentially a request for the suggestion in point 2. of @kangmingtay 's https://github.com/supabase/gotrue/pull/641#issuecomment-1235178425 to be implemented as mentioned.

At this point, gotrue does not provide any endpoints to help refresh the provider_access_token, though that can be a feature we'll consider adding in the future.

As far as my own research on this use case has taken me, it seems a Supabase Auth endpoint is needed which would make the token refresh request to the provider and return a new provider_access_token and new provider_refresh_token (if the provider includes it). Without this, the provider_token may still be used to interact with the Provider's APIs but once it expires there is no good way to refresh it from the origin the app client is running on.

Alternative options include, making the user login again or making the user login twice in the first place, once for supabase and once for the app itself. Both of those are no good from a UX standpoint and remove much of the utility of using Supabase, so are not really viable.

Reporting as a bug due to the documented method for an app client to use both Supabase Auth and the provider's API, not being possible as the authors originally intended.

To Reproduce

  1. Create an application using Supabase with login possibly using https://github.com/supabase-community/flutter-auth-ui#social-auth

  2. Ensure OAuth scopes requested include offline_access as in example:

    return SocialsAuth(
    socialProviders: [
    SocialProviders.azure,
    ],
    colored: true,
    redirectUrl: authService.redirectUrl,
    onSuccess: (Session response) {},
    onError: (error) {},
    scopes: '.default offline_access',
    queryParams: (localSettings.get('promptConsent', defaultValue: false))
    ? {'prompt': 'consent'}
    : {},
    );
  3. Set up a capture for supabase client auth state change event such as:

    subscription = supabase.auth.onAuthStateChange.listen((event) async {
      switch (event.event) {
        case AuthChangeEvent.initialSession:
          if (event.session == null) {
            return;
          }
          _initProviderSession(
            event.session?.providerToken,
            event.session?.providerRefreshToken,
          );
          break;
        case AuthChangeEvent.tokenRefreshed:
          _refreshProviderToken();
        default:
      }
    });
  4. Set up secondary OAuth client for use with providerToken and for refreshing with providerRefreshToken

    Future<void> _initProviderSession(
      String? accessToken, String? refreshToken) async {
    tokenStore = await Hive.openBox('auth');
    if (accessToken != null) {
      tokenStore.put('providerToken', accessToken);
    }
    if (refreshToken != null) {
      tokenStore.put('providerRefreshToken', refreshToken);
    }
    String tenantId = (await secrets())['azure_tenant_id'];
    microsoftAuthClient = MicrosoftOauth2Client(
        tenant: tenantId,
        redirectUri: redirectUrl);
    }
    
    void _refreshProviderToken() async {
    AccessTokenResponse res = await microsoftAuthClient.refreshToken(
      tokenStore.get('providerRefreshToken'),
      clientId: (await secrets())['azure_client_id'],
      scopes: ['.default', 'offline_access'],
    );
    if (res.error != null) {
      throw HttpException('${res.error}: ${res.errorDescription}');
    }
    tokenStore.put('providerToken', res.accessToken);
    tokenStore.put('providerRefreshToken', res.refreshToken);
    }
  5. Sign in and trigger token refresh request, verify token request in browser network log

    
    POST https://login.microsoftonline.com/<tenant_id>/oauth2/v2.0/token HTTP/1.1
    Accept: */*
    Accept-Encoding: gzip, deflate, br
    Accept-Language: en-US,en;q=0.9
    Connection: keep-alive
    Content-Length: 916
    Host: login.microsoftonline.com
    Origin: http://localhost:8080
    Referer: http://localhost:8080/
    Sec-Fetch-Dest: empty
    Sec-Fetch-Mode: cors
    Sec-Fetch-Site: cross-site
    User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
    content-type: application/x-www-form-urlencoded; charset=utf-8
    sec-ch-ua: "Not_A Brand";v="8", "Chromium";v="120"
    sec-ch-ua-mobile: ?0
    sec-ch-ua-platform: "Linux"

grant_type: refresh_token refresh_token: ... client_id: ...


6. Receive HTTP error response in browser network log

HTTP/1.1 400 Bad Request Cache-Control: no-store, no-cache Pragma: no-cache Content-Type: application/json; charset=utf-8 Expires: -1 Strict-Transport-Security: max-age=31536000; includeSubDomains X-Content-Type-Options: nosniff Access-Control-Allow-Origin: * Access-Control-Expose-Headers: Content-Length,Content-Encoding,x-ms-request-id Access-Control-Allow-Methods: POST, OPTIONS P3P: CP="DSP CUR OTPi IND OTRi ONL FIN" x-ms-request-id: ... x-ms-ests-server: 2.1.17122.3 - WUS3 ProdSlices report-to: {"group":"network-errors","max_age":86400,"endpoints":[{"url":"https://identity.nel.measure.office.net/api/report?catId=GW+estsfd+chi"}]} nel: {"report_to":"network-errors","max_age":86400,"success_fraction":0.001,"failure_fraction":1.0} Referrer-Policy: strict-origin-when-cross-origin X-XSS-Protection: 0 Set-Cookie: fpc=...; expires=Tue, 27-Feb-2024 00:38:50 GMT; path=/; secure; HttpOnly; SameSite=None Set-Cookie: x-ms-gateway-slice=estsfd; path=/; secure; samesite=none; httponly Set-Cookie: stsservicecookie=estsfd; path=/; secure; samesite=none; httponly Date: Sun, 28 Jan 2024 00:38:49 GMT Content-Length: 564

{"error":"invalid_request","error_description":"AADSTS9002326: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type. Request origin: 'http://localhost:8080'. Trace ID: ... Correlation ID: ... Timestamp: 2024-01-28 00:38:50Z","error_codes":[9002326],"timestamp":"2024-01-28 00:38:50Z","trace_id":"...","correlation_id":"...","error_uri":"https://login.microsoftonline.com/error?code=9002326"}

and error in the app code

HttpException: invalid_request: AADSTS9002326: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type. Request origin: 'http://localhost:8080'. Trace ID: ... Correlation ID: ... Timestamp: 2024-01-28 00:36:29Z

7. Note: It is not possible to switch the Supabase app registration to a `Single-Page Application` client-type as it is a `confidential` server type and uses the ClientId/ClientSecret to authenticate itself with the Proivder's token endpoint. Changing the app type results in a failure to complete the initial Sign-in for the user.

## Expected behavior

There should be a documented/tested way to use the `providerRefreshToken` to conduct a token refresh request with the provider which is valid under the OAuth standard, as suggested in https://supabase.com/docs/guides/auth/social-login#provider-tokens

Supabase Auth does not manage refreshing the provider token for the user. Your application will need to use the provider refresh token to obtain a new provider token.


## System information

- OS: Kubuntu 22.04
- Browser: Chromium, Version 120.0.6099.199 (Official Build) snap (64-bit)
- Version of supabase-flutter: 

supabase_flutter: ^2.0.2 supabase_auth_ui: ^0.3.0 gotrue: ^2.3.0 oauth2_client: ^3.2.2


## Additional context
---
The method currently suggested in the Supabase docs, having the client app code use the refresh token obtained by the Supabase GoTrue server to request a new access token from the provider, appears to be disallowed under the OAuth standard as documented here https://datatracker.ietf.org/doc/html/rfc6749#section-10.4

> Refresh tokens MUST be kept confidential in transit and storage, and
   **shared only among the authorization server and the client to whom the
   refresh tokens were issued.**

In this scenario, what is defined as the "client to whom the refresh tokens were issued" could be understandably seen as a little ambiguous since both the Supabase project and the app code use the same ClientID. However, the standard further makes a distinction between client types https://datatracker.ietf.org/doc/html/rfc6749#section-2.1
which classifies the GoTrue server as a `confidential` type using the ClientSecret to authenticate itself, where the app code client is a `public` type and uses only the `authorization_code` flow to obtain access to resources as publicly shared app code is not capable of securing credentials and is therefore not implicitly trusted.

> The authorization server MUST maintain
   the binding between a refresh token and the client to whom it was
   issued...

This requirement establishes that sharing the `refresh_token` between two clients is not allowed under the standard. With that it seems we can reasonably expect that no OAuth providers will allow our app code, hosted from a different origin, to use a secondary OAuth client library to refresh an `access_token` which was issued to Supabase's auth server origin. To the extent that this does work, it may be considered a security flaw and patched out at some point.

> The authorization server MUST verify the binding between the refresh
   token and client identity whenever the client identity can be
   authenticated.  When client authentication is not possible, the
   authorization server SHOULD deploy other means to detect refresh
   token abuse.

It seems like this requirement would be the motivation for what Microsoft, Google, Spotify (naming only those I've seen in the issues/discussions) and others are doing when they block cross-origin token refreshes.

---

Since there is also no allowance made for passing tokens between two valid clients according to the standard refresh flow shown in the figure here https://datatracker.ietf.org/doc/html/rfc6749#section-1.5, technically speaking, even after we have a means of having GoTrue maintain the `provider_access_token`, we'll still be a non-standard use case. 

I can guess the only fully standards compliant means of having something like GoTrue proxy the sign-in for an app, would be to have Supabase also act as a proxy for API requests to the provider, that way the provider authorization stays with Supabase and it is Supabase who is extending the trust zone to the app client via Supabase Auth's own JWT based authentication/authorization.

The other way to flip this problem on it's head might be to have the PKCE flow entirely conducted on the app client side in the Supabase.auth libraries and have GoTrue accept and validate the provider's JWT as part of it's authentication process before issuing it's own JWT back to the client. Essentially treating the validated provider JWT as a credential functionally equivalent to a username/password or OTP.

---

It has been stated that there is a security concern around keeping the `provider_token` and `provider_refresh_token` in the database after the initial sign-in flow is completed. However, I was unable to locate a reference to what that specific concern was at the time.

If that concern is still well-known, implementing the proposed token refresh endpoint by requiring the client app to store and send the `provider_refresh_token` upon refresh request seems fine. A little clunky to have the app code store the tokens separately though. Since the GoTrue clients already have slots for the provider tokens in their local storage management, we might like to have the Supabase auth libraries keep those around instead of clearing them on Session refresh.

If database storage ends up being necessary, it may be worth revisiting the reasoning behind null'ing these columns out after the original provider token request flow is completed to see if we still think it's a risk to have them in the database until the user signs out.
https://github.com/supabase/gotrue/blob/master/migrations/20230322519590_add_flow_state_table.up.sql#L15

I'm not a long term OAuth expert or anything but it would seem the GoTrue server is already part of the trust chain even if the JWTs issued by the provider are later deleted. I am curious as to what specific attack vector is secured by not letting them live in the table until the user signs out given that they are there for at least some time anyway and may even get picked up in a backup or other queries.

---
hf commented 9 months ago

Quite a large wall of text, in summary the issue seems to be this:

It is not possible to switch the Supabase app registration to a Single-Page Application client-type as it is a confidential server type and uses the ClientId/ClientSecret to authenticate itself with the Proivder's token endpoint. Changing the app type results in a failure to complete the initial Sign-in for the user.

This is very much not an issue with Supabase Auth but with your setup. The problem comes in that it appears that you are trying to refresh the provider_refresh_token inside your frontend code. This is incorrect, and it should be done inside your server. You can use a Supabase Edge function if you like, or build any other server.

That server will use the Microsoft OAuth APIs including the client_secret to refresh the token in question.

dannypurcell commented 9 months ago

This was a request to implement an endpoint for triggering that refresh as suggested by @kangmingtay in https://github.com/supabase/gotrue/pull/641#issuecomment-1235178425

The developer using gotrue will have to manage the provider_access_token and provider_refresh_token on their own. At this point, gotrue does not provide any endpoints to help refresh the provider_access_token, though that can be a feature we'll consider adding in the future.

This comment along with the general design implied by returning the provider_refresh_token in the front-end client in the first place, along with the efforts and discussion in this issue https://github.com/supabase/gotrue-js/issues/131 all indicate that the original intent was that refreshing could be done in the front-end.

That is the reason this was reported as an issue for GoTrue.

Since you are declining to help developers out with that, at minimum the documentation needs changed to match your suggested workaround here.

Currently https://supabase.com/docs/guides/auth/social-login#provider-tokens states

Your application will need to use the provider refresh token to obtain a new provider token.

Typically that means the front-end application. That will almost certainly be how most people will read it in the context of Supabase, which is a backend as a service. The whole idea is that we don't have to build and host another separate service in addition to what Supabase provides, especially if the only function for the separate service is just to refresh a token.

Edge function can make sense but that's rather awkward, non-intuitive, and not documented anywhere as far as I have seen.

Sorry it was a lot to read; however, the detail was intended to ensure that this was a sensible request to be making and that I had exhausted all of the obvious and documented choices for how to handle this before bothering the team with it.

dannypurcell commented 9 months ago

@hf do you have an example of an edge function doing a provider token refresh such that supabase.auth.currentSession.providerToken continues to have a valid provider token after the supabase client does a token refresh?

I'm looking in to your suggestion and it really seems like the edge functions have the same issue as trying to use the provider_refresh_token to request a new provider_token from the front end code. The Deno invocations will still be calling the provider's token endpoint from a different origin than the provider_refresh_token was issued to. So it still will not work.

Additionally, it is unclear what the edge function should do with any token it does receive, such that said token will then be available to the front-end app.

I can see trying to store it in the auth schema's flow_state since it has a provider_access_token column but that goes against the documentation and console's warnings against modifying anything in that schema and against the apparent reasons why the token is not already maintained by GoTrue.

https://supabase.com/docs/guides/auth/social-login#provider-tokens

Provider tokens are intentionally not stored in your project's database. This is because provider tokens give access to potentially sensitive user data in third-party systems.

Could also just return it from the invocation but then what is the point of using supabase.auth.currentSession.providerToken in the first place? And this approach is basically the same, from a security standpoint, as just sending the client_secret to the front-end app. Which, again, is not recommended under OAuth spec. The point of the PKCE flow is that we don't trust the front-end code to get tokens independent of the browser/origin the user is signing in from, since it is publicly hosted.

To be clear, what we're trying to understand here is why Supabase auth offers the provider_token for the front-end code to use if it is going to be nulled out when Supabase does it's own token refresh. If you provide us the token GoTrue used to login with the provider, then it needs to stick around and there needs to be a way to refresh it, otherwise it's not actually useful.

However, if that token was never intended to be used to work with the provider API, and that reasoning in the docs is a real security concern, then there is no reason for it to even be available to the front-end code and the token's presence there represents a security flaw.

coldham10 commented 2 months ago

Any updates on this? @dannypurcell I'm curious what workaround you ended up using, if you don't mind sharing