1

TLDR;

When my users sign in, I give them a session cookie. When they attempt to hit a callable function, they get a 403 error stating that the request was unauthenticated.


Authentication

I'm using Firebase + Next.js 13 (with app/ dir). When my users sign in, I issue them a session cookie as described here. That process looks like this

// sign in function (handled client-side)

signInWithEmailAndPassword(auth, data.email, data.password)
.then(async (userCredential) => {
  const { user } = userCredential

  // Get the user's ID token and send it to the sessionLogin endpoint to set the session cookie
  return user.getIdToken()
    .then(idToken => {
      return fetch('/api/sessionLogin', {
        method: 'POST',
        headers: {
          'Accept': 'application/json',
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ idToken })
      })
    })
})

As you can see, this process makes a POST request to /api/sessionLogin, an HTTP function that I'm hosting on Vercel (i.e. not hosted on Firebase). Here's what that looks like

// sessionLogin API endpoint (handled server-side)

import { NextResponse } from "next/server"
import { adminAuth } from "../../../../firebase/firebaseAdmin"

export async function POST(request) {

  // Get the ID token passed
  const body = await request.json()
  const idToken = body.idToken.toString()

  // Set session expiration to 5 days.
  const expiresIn = 60 * 60 * 24 * 5 * 1000

  return adminAuth
    .createSessionCookie(idToken, { expiresIn })
    .then(
      (sessionCookie) => {

        // Instantiate the response
        const response = new NextResponse(null, { status: 200, statusText: "OK" })

        // Set cookie policy for session cookie
        response.cookies.set({
          name: "sessionCookie",
          value: sessionCookie,
          maxAge: expiresIn,
          sameSite: "lax",
          httpOnly: true,
          secure: true,
          path: "/"
        })
        
        return response
      },
      (error) => {
        const response = new NextResponse(null, { status: 401, statusText: "UNAUTHORIZED REQUEST!" })
        return response
      }
    )
}

Demo:

This part appears to work fine. I can sign in without error and I receive the session cookie.

enter image description here

The session cookie is clearly set, below. enter image description here

Callable function

Next, my user attempts to run a callable function. This is where the error occurs.

// Header.js (client side header)
// ... (imports & setup code here)

const createPost = httpsCallable(functions, 'createPost')
createPost({ authorId: sessionCookie.uid })
// functions/index.js (firebase cloud functions)
// ... (imports & setup code here)

initializeApp()
setGlobalOptions({ region: "us-central1" })

export const createPost = onCall({ cors: true }, async (request) => {
  ...
})

^ Notice I'm setting { cors: true }, so cross-origin requests should be allowed.

Demo

There are two requests. My understanding is that the first request is the "preflight" request which checks if it's safe to send the real request. Notice that both requests fail.

enter image description here

This is the preflight request. It fails with a 403. enter image description here

This is the actual request. It fails with a CORS error enter image description here enter image description here

localhost/:1 Access to fetch at 'https://us-central1-scipress-dev.cloudfunctions.net/createPost' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

Lastly, when I check the function logs, I see this. enter image description here

The request was not authenticated. Either allow unauthenticated invocations or set the proper Authorization header.


Additional Notes

  • This error does not occur when I develop locally with Firebase emulators
  • Two ways in which I've deviated from the session cookie docs are
    1. I do not use a csrfToken
    2. I do not set the auth persistence to NONE, as I want the auth state to be persisted on the client.

Any help debugging this would be greatly appreciated!

Doug Stevenson
  • 297,357
  • 32
  • 422
  • 441
Ben
  • 20,038
  • 30
  • 112
  • 189
  • There is a lot to digest in your post. I think the critical issue is the OPTIONS 403 error. That means the endpoint is not correctly handling the HTTP OPTIONS method. IIRC, the HTTP headers are stripped for an OPTIONS request. That means your endpoint must process an OPTIONS request **without** requiring the authorization header. You then tell the client which headers you accept. The response should specify that you support the `Authorization` header. – John Hanley Aug 28 '23 at 21:39
  • Thanks John. I realized this is a very "involved" error that takes effort to digest. What I don't understand is, **why is my request different than any normal request to a callable function?** I've used callables in the past and I never had to handle a special OPTIONS request. Shouldn't callable functions take care of auth stuff natively, as long as the user is logged in on the client? – Ben Aug 28 '23 at 23:38
  • The browser is making a cross-origin request and enforcing CORS. Your API endpoint is not handling CORS correctly. If you were not using a browser, you would not see the problem. – John Hanley Aug 28 '23 at 23:42
  • Thanks John, but how can you tell? Notice I'm passing `{ cors: true }` to my `onCall()` function. And I don't get a CORS error when I refactor the `onCall()` function to a `onRequest()` function. – Ben Aug 28 '23 at 23:56
  • Aha! Finally got it to work by deleting my cloud function `firebase functions:delete createPost` and then redeploying it, lol. Seems like there was some sort of caching issue that required me to actually delete the function and redeploy it as opposed to just deploying an update to it. Weird. – Ben Aug 29 '23 at 00:06
  • 1
    Sounds to me like the function didn't correctly set its permissions for who can execute it. This is described [here](https://stackoverflow.com/a/66814095/3068190) and [here](https://stackoverflow.com/a/68276538/3068190). This is meant to be handled automatically by Firebase Tools when the function is first deployed. Simply publishing a new version won't fix it as the permissions are left alone on later deployments. In your case, the correct course would be to delete and redeploy the function, as you already discovered. – samthecodingman Aug 29 '23 at 01:01
  • Thanks @samthecodingman, those writeups are great! – Ben Aug 29 '23 at 16:30

1 Answers1

0

Not the solution I was looking for, but I did find a viable workaround. The idea is to refactor my onCall() functions into onRequest() functions and handle the authorization mechanism myself.

So, my createPost() function goes from this

// BEFORE
// functions/index.js (firebase cloud functions)
// ... (imports & setup code here)

export const createPost = onCall({ cors: true }, async (request) => {
  ...
})

to this

// AFTER
// functions/index.js (firebase cloud functions)
// ... (imports & setup code here)

export const createPost = onRequest({ cors: true }, async (req, res) => {
  const tokenId = req.get('Authorization').split('Bearer ')[1];
    return getAuth().verifyIdToken(tokenId)
      .then((decoded) => {
        ...
        res.status(200).send(decoded)
      })
      .catch((err) => res.status(401).send(err))
})

And the way I invoke it from the client goes from this

// BEFORE
// Header.js (client side header)
// ... (imports & setup code here)

const createPost = httpsCallable(functions, 'createPost')
createPost({ authorId: sessionCookie.uid })

to this

// AFTER
// Header.js (client side header)
// ... (imports & setup code here)

auth.currentUser.getIdToken()
.then(token => {
  return fetch(
    'https://us-central1-scipress-dev.cloudfunctions.net/createPost',
    {
      headers: {
        'Content-Type': 'application/json',
        Authorization: 'Bearer ' + token
      },
      method: 'post',
      body: JSON.stringify({ data: "just a test" })
    })
})
Ben
  • 20,038
  • 30
  • 112
  • 189