Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Cross domain state cookie issue for oAuth using firebase functions while on the same domain

I am implementing a oAuth login for a user for the firebase platform.

All works fine except if the user has disabled cross domain cookies.

Here is what I did.

  1. From my domain/app the user gets redirected to a cloud function.
  2. The could function sets the state cookie and redirects the user to the oAuth provider.
  3. The user signs in to the oAuth provider and gets redirected back to another function to get the code etc. And here is the problem

On step 3 above the function cannot read any cookie if the user has disabled the cross domain party cookies from his browser. Both functions are on the same domain as seen below in the screenshot.

enter image description here

Is there any way I can remedy this issue? Am I doing something wrong in my approach?

I cannot understand why the 2 functions are treated as crossdomain.

Update to include more info

Request:

Request URL: https://europe-west2-quantified-self-io.cloudfunctions.net/authRedirect
Request Method: GET
Status Code: 302 
Remote Address: [2a00:1450:4007:811::200e]:443
Referrer Policy: no-referrer-when-downgrade

Request Headers

:authority: europe-west2-quantified-self-io.cloudfunctions.net
:method: GET
:path: /authRedirect
:scheme: https
accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
accept-encoding: gzip, deflate, br
accept-language: en-GB,en-US;q=0.9,en;q=0.8
cookie: signInWithService=false; state=877798d3672e7d6fa9588b03f1e26794f4ede3a0
dnt: 1
upgrade-insecure-requests: 1
user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36

Response Headers

alt-svc: quic=":443"; ma=2592000; v="46,43,39"
cache-control: private
content-encoding: gzip
content-length: 218
content-type: text/html; charset=utf-8
date: Sat, 03 Aug 2019 08:55:18 GMT
function-execution-id: c8rjc7xnvoy8
location: https://cloudapi-oauth.suunto.com/oauth/authorize?response_type=code&client_id=xxx&redirect_uri=&scope=workout&state=1c8073866d1ffaacf2d4709090ad099872718afa
server: Google Frontend
set-cookie: state=1c8073866d1ffaacf2d4709090ad099872718afa; Max-Age=3600; Path=/; Expires=Sat, 03 Aug 2019 09:55:18 GMT; HttpOnly; Secure
set-cookie: signInWithService=false; Max-Age=3600; Path=/; Expires=Sat, 03 Aug 2019 09:55:18 GMT; HttpOnly; Secure
status: 302
vary: Accept
x-cloud-trace-context: 99a93680a17770f848f200a9e729b122;o=1
x-powered-by: Express

After that and once the user returns from the service he authenticated against the code that parses the cookies (or the function that handles that) is:

export const authToken = functions.region('europe-west2').https.onRequest(async (req, res) => {
  const oauth2 = suuntoAppAuth();
  cookieParser()(req, res, async () => {
    try {
      const currentDate = new Date();
      const signInWithService = req.cookies.signInWithService === 'true';
      console.log('Should sign in:', signInWithService);
      console.log('Received verification state:', req.cookies.state);
      console.log('Received state:', req.query.state);
      if (!req.cookies.state) {
        throw new Error('State cookie not set or expired. Maybe you took too long to authorize. Please try again.');
      } else if (req.cookies.state !== req.query.state) {
        throw new Error('State validation failed');
      }
      console.log('Received auth code:', req.query.code);
      const results = await oauth2.authorizationCode.getToken({
        code: req.query.code,
        redirect_uri: determineRedirectURI(req), // @todo fix,
      });

      // console.log('Auth code exchange result received:', results);

      // We have an access token and the user identity now.
      const accessToken = results.access_token;
      const suuntoAppUserName = results.user;

      // Create a Firebase account and get the Custom Auth Token.
      let firebaseToken;
      if (signInWithService) {
        firebaseToken = await createFirebaseAccount(suuntoAppUserName, accessToken);
      }
      return res.jsonp({
        firebaseAuthToken: firebaseToken,
        serviceAuthResponse: <ServiceTokenInterface>{
          accessToken: results.access_token,
          refreshToken: results.refresh_token,
          tokenType: results.token_type,
          expiresAt: currentDate.getTime() + (results.expires_in * 1000),
          scope: results.scope,
          userName: results.user,
          dateCreated: currentDate.getTime(),
          dateRefreshed: currentDate.getTime(),
        },
        serviceName: ServiceNames.SuuntoApp
      });
    } catch (error) {
      return res.jsonp({
        error: error.toString(),
      });
    }
  });
});

The above code does not find a cookie with the name state

So it fails here

if (!req.cookies.state) {
        throw new Error('State cookie not set or expired. Maybe you took too long to authorize. Please try again.');
      } else if (req.cookies.state !== req.query.state) {
        throw new Error('State validation failed');
      }

Did a little more search here is some more info.

The example I based on https://github.com/firebase/functions-samples/tree/master/instagram-auth

Looks like other users suffer from the same issue https://github.com/firebase/functions-samples/issues/569

I opened also this issue https://github.com/firebase/firebase-functions/issues/544

like image 701
Jimmy Kane Avatar asked Jul 29 '19 12:07

Jimmy Kane


1 Answers

Your Response shows a Set-Cookie header for state and signInWithService cookies without a domain attribute:

set-cookie: state=1c8073866d1ffaacf2d4709090ad099872718afa; Max-Age=3600; Path=/; Expires=Sat, 03 Aug 2019 09:55:18 GMT; HttpOnly; Secure
set-cookie: signInWithService=false; Max-Age=3600; Path=/; Expires=Sat, 03 Aug 2019 09:55:18 GMT; HttpOnly; Secure

Set-Cookie without a domain means that what happens to the cookie on the way back to the server is browser-dependent. The "default", spec-compliant behavior: the browser will grab the FQDN of the service URL and associate it with the cookie. RFC6265:

Unless the cookie's attributes indicate otherwise, the cookie is returned only to the origin server (and not, for example, to any subdomains)...If the server omits the Domain attribute, the user agent will return the cookie only to the origin server.

When the browser decides whether to accept the cookie from a HTTP service, one of the decision criteria is if the cookie is first-party or third-party:

  • First-party cookie: if the resource (web page) you requested that triggered a call to europe-west2-quantified-self-io.cloudfunctions.net/authRedirect resides at https://europe-west2-quantified-self-io.cloudfunctions.net/...
  • Third-party cookie: if the resource (web page) you requested that triggered a call to europe-west2-quantified-self-io.cloudfunctions.net/authRedirect resides at https://some.domain.app.com/...

In your case the FQDN of your "parent" app/page is likely different from europe-west2-quantified-self-io.cloudfunctions.net, thus these cookies are labeled as third-party. As you found out, a user can choose to block third-party cookies. As of August 2019, Firefox and Safari block 3rd-party cookies by default. Most (if not all) ad blockers and similar extensions also block them. This would lead the browser to simply ignore Set-Cookie header in the HTTP response from europe-west2-quantified-self-io.cloudfunctions.net/authRedirect. The cookie would not be sent back to 2nd Firebase function at europe-west2-quantified-self-io.cloudfunctions.net/authToken because it doesn't exist on the client.

Your options:

  1. Host your app and Firebase functions on the same domain.
  2. An architecture where all HTTP requests (app and Firebase functions) flow through the app; the latter acts as a proxy of sorts for the function calls. Here's one way to do it in Firebase.
  3. Let's say your app and Firebase functions do reside in different domains. In Javascript you can create a small piece of middleware that calls the /authRedirect FB function, parses the response (incl. the cookies via Set-Cookie header), then writes the response (incl. cookies) back to the browser via document.cookie. The cookies in this case would be first-party.
  4. Don't use cookies at all. The oAuth authorization grant flow that you're doing against cloudapi-oauth.suunto.com as the authorization server does not require cookies. You followed an instagram-auth example that recommends this flow

When clicking the Sign in with Instagram button a popup is shown which redirects users to the redirect Function URL.

The redirect Function then redirects the user to the Instagram OAuth 2.0 consent screen where (the first time only) the user will have to grant approval. Also the state cookie is set on the client with the value of the state URL query parameter to check against later on.

The check against state query parameter is based on an implementation best practice for oAuth clients when authorization servers don't support the PKCE extension (cloudapi-oauth.suunto.com doesn't support it):

Clients MUST prevent CSRF. One-time use CSRF tokens carried in the "state" parameter, which are securely bound to the user agent, SHOULD be used for that purpose. If PKCE [RFC7636] is used by the client and the authorization server supports PKCE, clients MAY opt to not use "state" for CSRF protection, as such protection is provided by PKCE. In this case, "state" MAY be used again for its original purpose, namely transporting data about the application state of the client

The key phrase is securely bound to the user agent. For web apps, a cookie is a decent option of implementing this binding but it's not the only option. You can stick the value of state into local or session storage, single-page apps do exactly that in practice. If you want to live in the cloud, you can stick state in Cloud Storage or equivalent...but you'd have to manufacture a key that uniquely identifies your client and this particular HTTP request. Not impossible but perhaps overkill for a simple scenario.

like image 155
identigral Avatar answered Oct 02 '22 17:10

identigral