skip to Main Content

Our software uses Google Firebase to provide a "single sign-on" experience for our enterprise clients who have implemented an OIDC (OpenID Connect) provider.

We have a problem with one client.

Their OIDC Discovery Document is located at:
https://abc-vis-cert-cloud3.ourclient.com/.well-known/openid-configuration.

In this Discovery Document, we were expecting an issuer claim of:
"issuer": "https://abc-vis-cert-cloud3.ourclient.com"

But their issuer claim is:
"issuer": "urn:VISCertC3:abc"

When we attempt to connect to their OIDC provider, Google Firebase (quite rightly) returns the error:
"INVALID_IDP_RESPONSE : issuer claim in OIDC discovery document does not match the issuer specified in the request."

We have asked our client to fix the issuer value in their discovery document, but they have said that they can’t….. they have hundreds of existing integrations relying on the current configuration, and all of those are working just fine.

My question:
Is there a workaround that we (or they) can implement to make this work?

i.e. Is there a Google Firebase setting? Or some kind of proxy we can implement? Or some way for them to display a different Discovery Document to us (and us alone) ?

There’s a similar question here regarding Azure B2C, and the answer in Azure B2C looks as simple as setting skipIssuerCheck: true or strictDiscoveryDocumentValidation: false. Another similar question here.

2

Answers


  1. Skipping issuer check altogether should be considered a security issue, as then one has no guarantee if the token was issued by the right party. It would affect other clients which would have less strict token validation and introduce possibility to pass token issued by any party for the problematic client.

    If there is absolutely no possibility to get it right, I’d rather suggest a solution in which you can handle white-listed exceptions.

    Most of the back-end authentication libraries have possibility to hook into the token validation process and introduce custom validation logic. Your logic could be "if is token for client X, check if issuer field matches white-listed exception/override for X". Store your white-list as back-end configuration and you’re good to go.

    Login or Signup to reply.
  2. Is there a Google Firebase setting? … – @Merenzo

    I’m not sure Firebase Authentication can handle this natively. Like other StackOverflow users we also can’t comment on the internals of how Firebase handles these requests as we aren’t members of the Firebase Engineering team.

    Per the OIDC spec, the discovery document should specify the issuer with the https (as they are doing) and use that same value in the tokens they issue (which they are not).

    issuer

    REQUIRED. URL using the https scheme with no query or fragment components that the OP asserts as its Issuer Identifier. If Issuer discovery is supported (see Section 2), this value MUST be identical to the issuer value returned by WebFinger. This also MUST be identical to the iss Claim value in ID Tokens issued from this Issuer.


    … Or some kind of proxy we can implement? Or some way … to display a different Discovery Document to us (and us alone)? – @Merenzo

    In my loose testing, I was able to proxy the request coming from the Firebase Authentication client server using a HTTPS Cloud Function (2nd gen). This function would relay the request to the OpenID provider’s server (the one hosting the OIDC discovery document), download the response, mutate it and return it back to the Firebase Authentication client.

    In theory, with an appropriate API key pair and mutations, this should allow you to override the issuer field and any other necessary fields to make Firebase Authentication think this is the proper discovery document.

    The text that follows assumes you are familiar with Cloud Functions for Firebase and have followed the Getting Started guide on how to deploy functions before continuing.

    The next three code blocks are all in the same file (e.g. index.js), but I’ve split them up here to help explain what each part is doing. They are split into three groups: dependencies & helper functions, configuration, and the request handler.

    Part 1: Dependencies & Helper Functions

    This bit of code just allows for future expandability. During setup, you would call registerMutation to bind a pattern or string value to a callback that changes the response sent back to the client. When handling requests, you would instead call findMutator to check if the incoming request is supported and handle it when it is. Lastly, pick allows for a handful of properties to be selected from an object, but only if they exist on that object with a defined, non-null value.

    // Dependencies
    const {onRequest} = require("firebase-functions/v2/https");
    const logger = require("firebase-functions/logger");
    
    // Define some JSDoc type stubs (to help IDEs and IntelliSense)
    /**
     * @typedef { object } DiscoveryConfig
     * @property { string } issuer
     */
    
    /**
     * @typedef {(config: DiscoveryConfig) => DiscoveryConfig |
     * Promise<DiscoveryConfig>} MutationCallback
     */
    
    // Define somewhere to hold as many mutations as we need ("future-proofing")
    const {registerMutation, findMutator} = (() => {
      /**
       * @type {[stringOrPattern: string | RegExp, mutator: MutationCallback][]}
       */
      const mutators = [];
      return {
    
        /**
         * Registers a new discovery config mutator.
         * @param { string | RegExp } stringOrPattern A value matching the exact domain supported, the
         * exact URL, or a regular expression that matches supported targets.
         * @param { ((config: DiscoveryConfig) => DiscoveryConfig | Promise<DiscoveryConfig>) } mutator
         * A callback that mutates the provided configuration object.
         */
        registerMutation: (stringOrPattern, mutator) => {
          mutators.push([stringOrPattern, mutator]);
        },
    
        /**
         * Returns the registered mutator for the given domain.
         * @param { URL | string } target The target URL/domain to check. String comparisons
         * are case sensitive.
         * @return { MutationCallback | null } The callback associated with the pattern, or `null` if
         * one is not available.
         */
        findMutator: (target) => {
          if (!target) return null;
          const targetAsString = target.toString();
          const hostAsString = target instanceof URL ? target.host : target;
          const mutatorEntry = mutators.find(([stringOrPattern]) =>
            stringOrPattern instanceof RegExp ?
              stringOrPattern.test(targetAsString) || stringOrPattern.test(hostAsString) :
              stringOrPattern === targetAsString || stringOrPattern === hostAsString);
          return mutatorEntry && mutatorEntry[1] || null;
        },
      };
    })();
    
    /**
     * Helper function to pick the named properties from the provided object when they exist.
     * @param { object } obj The object to source properties from.
     * @param { string[] } props An array of properties to be extracted when they are present. 
     * @returns { object } A new object containing the selected properties.
     */
    function pick(obj, props) {
      return props.reduce((acc, prop) => {
        if (prop in obj && obj[prop] != null) {
          acc[prop] = obj[prop];
        }
        return acc;
      }, Object.create(null));
    }
    

    Part 2: Mutators & Configuration

    In this section, we register each mutation that we want to support.

    In the below block, we are defining a mutator for requests targeting abc-vis-cert-cloud3.ourclient.com that overrides the issuer with a new value ("urn:VISCertC3:abc"), leaving the rest of the properties unchanged. Other overrides can be made here as needed.

    registerMutation(
      "abc-vis-cert-cloud3.ourclient.com", // can be the host, the full URL, or a RegExp matching either of those
      (config) => {
        config.issuer = "urn:VISCertC3:abc"; // override the issuer
        return config; // return the rest unchanged
      }
    );
    
    // headers to pass from the client through to the OpenID provider's server
    const PASSED_REQUEST_HEADERS = [
      "forwarded",
      "x-forwarded-for",
      "x-forwarded-host",
      "x-forwarded-proto",
      "user-agent", // proxy should probably have its own user-agent
    ];
    
    // headers to pass from the OpenID provider's server back to the client
    const PASSED_RESPONSE_HEADERS = [
      "cache-control",
      "expires",
      "pragma",
      "x-cache",
    ];
    

    Part 3: Handling Requests

    In this block, we define the HTTPS Cloud Function (2nd gen) at the heart of this mutating proxy. This function expects to be called with a URL of the format https://<DEPLOYED_CLOUD_RUN_DOMAIN>/<PROVIDER_SERVER_DOMAIN>/<PATH_TO_OIDC>. For the given example, this would be https://oidcproxy-<ID>-<REGION>.a.run.app/abc-vis-cert-cloud3.ourclient.com/.well-known/openid-configuration.

    This function can currently handle up to 10 concurrent requests from any origin at a time per instance, thanks to the { cors: true, concurrency: 10 } configuration. This can be adjusted as needed.

    Upon receiving an in-scope request (must match one of the configured patterns), it extracts the domain and path from the requested URL, assembles a HTTPS request to the OpenID provider’s server and executes it. Upon receiving a response, the response body is fed through the mutator before being sent back to the client.

    The error handling below is not very robust, but it should handle most processing errors gracefully.

    // concurrency set to 10 as this function mainly waits on the remote OpenID provider's server
    exports.oidcProxy = onRequest({ cors: true, concurrency: 10 }, async (req, res) => {
      try {
        const [, domain, ...pathParts] = req.path.split("/") || [];
    
        if (!domain) {
          logger.info("Rejected request without target.");
          res.status(404).json({error: "Unsupported domain"});
          return;
        }
    
        const target = new URL("https://" + domain + "/" + pathParts.join("/"));
        const mutator = findMutator(target);
    
        if (!mutator) {
          logger.info("Rejected request for: " + target);
          res.status(404).json({error: "Unsupported domain"});
          return;
        }
    
        const targetResponse = await fetch(target, {
          headers: {
            "Accept": "application/json",
            ...pick(req.headers, PASSED_REQUEST_HEADERS),
          },
          method: "GET",
        });
    
        if (!targetResponse.ok) {
          logger.error("OIDC provider returned unexpected status code.", {
            status: targetResponse.status,
            statusText: targetResponse.statusText,
            body: await targetResponse.text()
          });
          res.status(502).json({error: "Bad Gateway"});
          return;
        }
    
        const config = await targetResponse.json()
            .then((config) => mutator(config));
    
        logger.info("Mutation for " + domain + " was successful.");
        res.setHeader('cache-control', 'no-cache, no-store');
        Object.entries(pick(targetResponse.headers, PASSED_RESPONSE_HEADERS))
          .forEach(kvp => res.setHeader(...kvp));
    
        res.status(200).json(config);
      } catch (err) {
        if (!res.headersSent) {
          logger.error("Failed to mutate discovery document.", err);
          res.status(500).json({error: "Internal server error"});
        } else {
          logger.error(
              "Failed to mutate discovery document, but headers were already sent.",
              err,
          );
          res.end();
        }
      }
    });
    

    If you get errors related to formatting/linting when you try to deploy the above code, you can disable the linting check by removing the contents of the "predeploy" array nested under the "functions" object in your firebase.json file.

    Usage

    Once your Cloud Function is deployed, you will be provided with the URL that your function is hosted at, such as https://oidcproxy-3x4mpl3-uc.a.run.app. Next, you will need the domain of the target OpenID provider’s server, in the provided example, this would be abc-vis-cert-cloud3.ourclient.com. Lastly, you will need the path to the provider’s /.well-known/openid-configuration document. Spec-compliant discovery documents should have it hosted at <issuer>/.well-known/openid-configuration. For the given example, their document is located at https://abc-vis-cert-cloud3.ourclient.com/.well-known/openid-configuration.

    Using these components and the following format:

    https://<DEPLOYED_CLOUD_RUN_DOMAIN>/<PROVIDER_SERVER_DOMAIN>/<PATH_TO_OIDC>
    

    We can now construct the URL:

    https://oidcproxy-3x4mpl3-uc.a.run.app/abc-vis-cert-cloud3.ourclient.com/.well-known/openid-configuration
    

    You should now be able to visit this URL to view the mutated OIDC discovery document.

    Once you’ve checked its working, we need to trim off the .well-known/openid-configuration component before we can use it in the Firebase OIDC Provider setup instructions included below.

    https://oidcproxy-3x4mpl3-uc.a.run.app/abc-vis-cert-cloud3.ourclient.com/
    
    1. Add Firebase to your JavaScript project.

    2. If you haven’t upgraded to Firebase Authentication with Identity Platform, do so. OpenID Connect authentication is only available in upgraded projects.

    3. On the Sign-in providers page of the Firebase console, click Add new provider, and then click OpenID Connect.

    4. Select whether you will be using the authorization code flow or the implicit grant flow.

      You should use always the code flow if your provider supports it. The implicit flow is less secure and using it is strongly discouraged.

    5. Give a name to this provider. Note the provider ID that’s generated: something like oidc.example-provider. You’ll need this ID when you add sign-in code to your app.

    6. Specify your client ID and client secret, and our proxied issuer string (in the format above). The client ID and client secret values must exactly match the values your provider assigned to you.

    Now you can test it by initialising an OAuthProvider for that service provider as described in the rest of the instructions.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search