I've been blocked for this some time already and I haven't been able to find a solution to this problem, so hoping anyone can help me.
I have a TS express app which is powered by Firebase functions, besides, I have a custom domain (hosted using Firebase Hosting) which points to my function so I could use my function using a custom domain api.myserver.com
. Now, my app works with different businesses to which each has a dedicated URL, e.g. https://company.api.myserver.com
.
One of these customers asked me to support SSO with Okta, so I followed Okta's tutorial/sample project which is a simple jsExpress app using express-openid-connect
.
In localhost, I'm simulating the business dedicated URL by adding the company prefix to the URL that Firebase emulator gives me, e.g. http://company.api.localhost:5001/project-id/us-central1/function-name
Everything works in localhost, I'm able to connect through SSO to my Okta environment, I see I receive the /callback
request after logging in and later I get redirected to my target URL.
I shipped this to production and now I'm testing it using my real domain (and company subdomain) https://company.api.myserver.com
. I'm able to login using my credentials, the /callback
endpoint gets called and later redirected to my target URL. However, when my target URL is loading, I realized that auth(config)
inside is redirecting again to my /login
endpoint, which is causing to start the whole process again, creating an infinite loop.
A few things I noticed though after lots of debugging:
/callback
endpoint but they aren't persisted, so when I get to my target URL, req.cookies
is empty.req.protocol + "://" + req.get("host") + req.originalUrl
and the result is the URL provided by Firebase instead of my custom domain! e.g. https://us-central1-project-id.cloudfunctions.net/login
instead of company.api.myserver.com
(I'm not sure if this could be the reason of the cookies not being persisted)By this point, I've exhausted looking into forums, debugging and asking chatGPT, so decided to post it here. Does anyone know?
Here are a few code snippets of what I have with some comments to help understanding:
export const app = express();
app.set("trust proxy", true);
app.use(favicon(getAssetUri("favicon.ico")));
app.use(cors()); // So I can enable calls from different domains
const port = 3000;
app.use(express.static("public"));
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(cookieParser());
app.use(domainConfig()); // -> Retrieves the hostname
app.use(enterpriseConfig()); // -> Retrieves from the hostname which company is
app.use(authConfig()); // -> Code shared below
app.use(function (req, res, next) {
res.locals.user = req.oidc?.user;
const isAuthenticated = req.oidc?.isAuthenticated(); // THIS IS ALWAYS FALSE AND SOMETIMES IS NEVER REACHED AS authConfig() REDIRECTS BEFORE GETTING HERE
debug(`AA: req.oidc.isAuthenticated() = ${isAuthenticated}`, {
email: req.oidc?.user?.email,
});
next();
});
app.use(genAuthenticateUser);
app.use(genValidateEnterprise);
async function authConfig(
req: express.Request,
res: express.Response,
next: express.NextFunction
): Promise<void> {
debug("genSSOAuth.CookiesSet", { cookies: req.cookies }); // -> COOKIES ARE ALWAYS EMPTY
const authConfig = getAuthConfig();
const config = {
authRequired: false,
auth0Logout: true,
baseURL: getBaseURL(req),
clientID: authConfig.clientID,
issuerBaseURL: authConfig.issuerBaseURL,
secret: authConfig.secret,
errorOnRequiredAuth: true,
afterCallback: async (
req: express.Request,
res: express.Response,
session: Session,
decodedState: { [key: string]: any }
) => {
debug("After callback", { // -> THIS PRINTS THE COOKIES TO BE SET. HOWEVER, THEY AREN'T PERSISTED
req,
res,
session,
decodedState,
});
return session;
},
};
debug("genSSOAuth.AuthMiddleware.Setup");
const authMiddleware = auth(config);
authMiddleware(req, res, next); // -> HERE IS WHERE THE REDIRECTS HAPPENS AGAIN
debug("genSSOAuth.AuthMiddleware.Setup.Done");
}
Firebase Hosting sits behind a CDN, when you use a Cloud Function or Cloud Run to serve content to Firebase Hosting, your express instance now also sits behind the CDN. This has the benefit of allowing you to cache the responses from your server-side code so you don't have to invoke them as often.
However, one of the major caveats of this setup is that cookies MUST be named __session
, as it is the only cookie that is not stripped from the request by the Firebase Hosting CDN. Unfortunately this has caught out many other users because this strict cookie policy is not replicated by the Firebase Emulators which means the problem doesn't present itself when executing locally.
Taking a look at the documentation for the ConfigParams
object that is passed into the express-openid-connect
constructor, the cookie used by that library is named appSession
by default.
So to use express-openid-connect
with Express behind the CDN, you need to pass in the cookie name in your configuration object:
const config = {
// ... other config
session: {
name: "__session"
},
// ... other config
};
You should make sure that your auth-related endpoints include a Cache-Control
header set to private
. I don't think this is handled by express-openid-connect
by default, but you might be able to just throw it into afterCallback()
and similar hooks.
res.setHeader('Cache-Control', 'private');
Note: The Cloud Functions/Run Invoker (that runs your code) also uses Express under the hood before the request is handled by your code. This can be seen by reviewing the public version of its code and the 1st gen Cloud Functions documentation (to my understanding this still applies to 2nd gen Cloud Functions too). At the time of writing, the following things are done to the request before it is handled by your code:
Injects the following middleware:
bodyParser.json({ ... })
(for both 'application/json' and 'application/cloudevents+json')bodyParser.text({ ... })
(for 'text/plain')extended: true
- bodyParser.urlencoded({ ... })
req.rawBody
as a Buffer
by bodyParser.raw({ ... })
.Additionally, HTTP request handlers will automatically return HTTP 404 for requests for /favicon.ico
and /robots.txt
(these should be deployed in your Firebase Hosting public folder anyway).
Generally, static files should be deployed to Firebase Hosting rather than with your Cloud Function/Cloud Run code. A static resource will be served by hosting and will save spinning up your code just to serve a static resource.