End user authentication for Cloud Run tutorial

This tutorial shows how to create a voting service, consisting of:

  • A browser-based client that:

    1. Uses Identity Platform to fetch an ID token.
    2. Allows users to vote for their favorite domestic animal.
    3. Adds that ID token to a request to the Cloud Run server that processes the vote.
  • A Cloud Run server that:

    1. Checks to make sure the end-user has properly authenticated by providing a valid ID token.
    2. Processes the end-user's vote.
    3. Using its own credentials, sends the vote on to Cloud SQL for storage.
  • A PostgresSQL database that stores the votes.

For simplicity, this tutorial uses Google as a provider: users must authenticate using a Google account in order to acquire their ID token. However, you can use other providers or authentication methods to sign in users.

This service minimizes security risks by using Secret Manager to protect sensitive data used to connect to the Cloud SQL instance. It also uses a least-privilege service identity to secure access to the database.

Objectives

Write, build, and deploy a service to Cloud Run that shows how to:

  • Use Identity Platform to authenticate an end-user to the Cloud Run service backend.

  • Create a least-privilege identity for the service to grant minimal access to Google Cloud resources.

  • Use Secret Manager to handle sensitive data when connecting the Cloud Run service to a postgreSQL database.

Costs

This tutorial uses the following billable components of Google Cloud:

To generate a cost estimate based on your projected usage, use the pricing calculator. New Google Cloud users might be eligible for a free trial.

Before you begin

  1. Sign in to your Google Cloud account. If you're new to Google Cloud, create an account to evaluate how our products perform in real-world scenarios. New customers also get $300 in free credits to run, test, and deploy workloads.
  2. In the Google Cloud console, on the project selector page, select or create a Google Cloud project.

    Go to project selector

  3. Make sure that billing is enabled for your Cloud project. Learn how to check if billing is enabled on a project.

  4. In the Google Cloud console, on the project selector page, select or create a Google Cloud project.

    Go to project selector

  5. Make sure that billing is enabled for your Cloud project. Learn how to check if billing is enabled on a project.

  6. Enable the Cloud Run, Secret Manager, Cloud SQL, Container Registry, and Cloud Build APIs.

    Enable the APIs

Setting up gcloud defaults

To configure gcloud with defaults for your Cloud Run service:

  1. Set your default project:

    gcloud config set project PROJECT_ID

    Replace PROJECT_ID with the name of the project you created for this tutorial.

  2. Configure gcloud for your chosen region:

    gcloud config set run/region REGION

    Replace REGION with the supported Cloud Run region of your choice.

Cloud Run locations

Cloud Run is regional, which means the infrastructure that runs your Cloud Run services is located in a specific region and is managed by Google to be redundantly available across all the zones within that region.

Meeting your latency, availability, or durability requirements are primary factors for selecting the region where your Cloud Run services are run. You can generally select the region nearest to your users but you should consider the location of the other Google Cloud products that are used by your Cloud Run service. Using Google Cloud products together across multiple locations can affect your service's latency as well as cost.

Cloud Run is available in the following regions:

Subject to Tier 1 pricing

  • asia-east1 (Taiwan)
  • asia-northeast1 (Tokyo)
  • asia-northeast2 (Osaka)
  • europe-north1 (Finland) leaf icon Low CO2
  • europe-southwest1 (Madrid) leaf icon Low CO2
  • europe-west1 (Belgium) leaf icon Low CO2
  • europe-west4 (Netherlands)
  • europe-west8 (Milan)
  • europe-west9 (Paris) leaf icon Low CO2
  • us-central1 (Iowa) leaf icon Low CO2
  • us-east1 (South Carolina)
  • us-east4 (Northern Virginia)
  • us-east5 (Columbus)
  • us-south1 (Dallas)
  • us-west1 (Oregon) leaf icon Low CO2

Subject to Tier 2 pricing

  • asia-east2 (Hong Kong)
  • asia-northeast3 (Seoul, South Korea)
  • asia-southeast1 (Singapore)
  • asia-southeast2 (Jakarta)
  • asia-south1 (Mumbai, India)
  • asia-south2 (Delhi, India)
  • australia-southeast1 (Sydney)
  • australia-southeast2 (Melbourne)
  • europe-central2 (Warsaw, Poland)
  • europe-west2 (London, UK)
  • europe-west3 (Frankfurt, Germany)
  • europe-west6 (Zurich, Switzerland) leaf icon Low CO2
  • northamerica-northeast1 (Montreal) leaf icon Low CO2
  • northamerica-northeast2 (Toronto) leaf icon Low CO2
  • southamerica-east1 (Sao Paulo, Brazil) leaf icon Low CO2
  • southamerica-west1 (Santiago, Chile)
  • us-west2 (Los Angeles)
  • us-west3 (Salt Lake City)
  • us-west4 (Las Vegas)

If you already created a Cloud Run service, you can view the region in the Cloud Run dashboard in the console.

Retrieving the code sample

To retrieve the code sample for use:

  1. Clone the sample app repository to your local machine:

    Node.js

    git clone https://github.com/GoogleCloudPlatform/nodejs-docs-samples.git

    Alternatively, you can download the sample as a zip file and extract it.

    Python

    git clone https://github.com/GoogleCloudPlatform/python-docs-samples.git

    Alternatively, you can download the sample as a zip file and extract it.

    Java

    git clone https://github.com/GoogleCloudPlatform/java-docs-samples.git

    Alternatively, you can download the sample as a zip file and extract it.

  2. Change to the directory that contains the Cloud Run sample code:

    Node.js

    cd nodejs-docs-samples/run/idp-sql/

    Python

    cd python-docs-samples/run/idp-sql/

    Java

    cd java-docs-samples/run/idp-sql/

Visualizing the architecture

Architectural Diagram
Diagram shows an end-user logging-in via a Google sign-in pop-up provided by IdP, and then being redirected back to Cloud Run with the user's identity.
  1. An end-user makes the first request to the Cloud Run server.

  2. The client loads in the browser.

  3. The user provides log-in credentials via the Google sign-in pop-up window from Identity Platform. An alert welcomes the signed-in user.

  4. Control is redirected back to the server. The end-user votes using the client, which fetches an ID token from Identity Platform and adds it to the vote request header.

  5. When the server receives the request, it verifies the Identity Platform ID token, confirming that the end-user is appropriately authenticated. Then server sends the vote to Cloud SQL, using its own credentials.

Understanding the core code

The sample is implemented as client and server, as described next.

Integrating with Identity Platform: client-side code

This sample uses Firebase SDKs to integrate with Identity Platform in order to sign-in and manage users. To connect to Identity Platform, the client-side JavaScript holds the reference to the project's credentials as a config object and imports the necessary Firebase JavaScript SDKs:

const config = {
  apiKey: 'API_KEY',
  authDomain: 'PROJECT_ID.firebaseapp.com',
};
<!-- Firebase App (the core Firebase SDK) is always required and must be listed first-->
<script src="https://www.gstatic.com/firebasejs/7.18/firebase-app.js"></script>
<!-- Add Firebase Auth service-->
<script src="https://www.gstatic.com/firebasejs/7.18/firebase-auth.js"></script>

The Firebase JavaScript SDK handles the sign-in flow by prompting the end-user to sign-in to their Google Account via a popup window. It then redirects them back to the service.

function signIn() {
  const provider = new firebase.auth.GoogleAuthProvider();
  provider.addScope('https://www.googleapis.com/auth/userinfo.email');
  firebase
    .auth()
    .signInWithPopup(provider)
    .then(result => {
      // Returns the signed in user along with the provider's credential
      console.log(`${result.user.displayName} logged in.`);
      window.alert(`Welcome ${result.user.displayName}!`);
    })
    .catch(err => {
      console.log(`Error during sign in: ${err.message}`);
      window.alert('Sign in failed. Retry or check your browser logs.');
    });
}

When a user successfully signs in, the client uses Firebase methods to mint an ID token. The client adds the ID token to the Authorization header of its request to the server.

async function vote(team) {
  if (firebase.auth().currentUser) {
    // Retrieve JWT to identify the user to the Identity Platform service.
    // Returns the current token if it has not expired. Otherwise, this will
    // refresh the token and return a new one.
    try {
      const token = await firebase.auth().currentUser.getIdToken();
      const response = await fetch('/', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          Authorization: `Bearer ${token}`,
        },
        body: 'team=' + team, // send application data (vote)
      });
      if (response.ok) {
        const text = await response.text();
        window.alert(text);
        window.location.reload();
      }
    } catch (err) {
      console.log(`Error when submitting vote: ${err}`);
      window.alert('Something went wrong... Please try again!');
    }
  } else {
    window.alert('User not signed in.');
  }
}

Integrating with Identity Platform: server-side code

The server uses the Firebase Admin SDK to verify the user ID token sent from the client. If the provided ID token has the correct format, is not expired, and is properly signed, the method returns the decoded ID token. The server extracts the Identity Platform uid for that user.

Node.js

const firebase = require('firebase-admin');
// Initialize Firebase Admin SDK
firebase.initializeApp();

// Extract and verify Id Token from header
const authenticateJWT = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (authHeader) {
    const token = authHeader.split(' ')[1];
    // If the provided ID token has the correct format, is not expired, and is
    // properly signed, the method returns the decoded ID token
    firebase
      .auth()
      .verifyIdToken(token)
      .then(decodedToken => {
        const uid = decodedToken.uid;
        req.uid = uid;
        next();
      })
      .catch(err => {
        req.logger.error(`Error with authentication: ${err}`);
        return res.sendStatus(403);
      });
  } else {
    return res.sendStatus(401);
  }
};

Python

def jwt_authenticated(func: Callable[..., int]) -> Callable[..., int]:
    @wraps(func)
    def decorated_function(*args: a, **kwargs: a) -> a:
        header = request.headers.get("Authorization", None)
        if header:
            token = header.split(" ")[1]
            try:
                decoded_token = firebase_admin.auth.verify_id_token(token)
            except Exception as e:
                logger.exception(e)
                return Response(status=403, response=f"Error with authentication: {e}")
        else:
            return Response(status=401)

        request.uid = decoded_token["uid"]
        return func(*args, **kwargs)

    return decorated_function

Java

/** Extract and verify Id Token from header */
private String authenticateJwt(Map<String, String> headers) {
  String authHeader =
      (headers.get("authorization") != null)
          ? headers.get("authorization")
          : headers.get("Authorization");
  if (authHeader != null) {
    String idToken = authHeader.split(" ")[1];
    // If the provided ID token has the correct format, is not expired, and is
    // properly signed, the method returns the decoded ID token
    try {
      FirebaseToken decodedToken = FirebaseAuth.getInstance().verifyIdToken(idToken);
      String uid = decodedToken.getUid();
      return uid;
    } catch (FirebaseAuthException e) {
      logger.error("Error with authentication: " + e.toString());
      throw new ResponseStatusException(HttpStatus.FORBIDDEN, "", e);
    }
  } else {
    logger.error("Error no authorization header");
    throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
  }
}

Connecting the server to Cloud SQL

The server connects to the Cloud SQL instance Unix domain socket using the format: /cloudsql/CLOUD_SQL_CONNECTION_NAME.

Node.js

/**
 * Connect to the Cloud SQL instance through UNIX Sockets
 *
 * @param {object} credConfig The Cloud SQL connection configuration from Secret Manager
 * @returns {object} Knex's PostgreSQL client
 */
const connectWithUnixSockets = async credConfig => {
  const dbSocketPath = process.env.DB_SOCKET_PATH || '/cloudsql';
  // Establish a connection to the database
  return Knex({
    client: 'pg',
    connection: {
      user: credConfig.DB_USER, // e.g. 'my-user'
      password: credConfig.DB_PASSWORD, // e.g. 'my-user-password'
      database: credConfig.DB_NAME, // e.g. 'my-database'
      host: `${dbSocketPath}/${credConfig.CLOUD_SQL_CONNECTION_NAME}`,
    },
    ...config,
  });
};

Python

def init_unix_connection_engine(
    db_config: Dict[str, str]
) -> sqlalchemy.engine.base.Engine:
    creds = credentials.get_cred_config()
    db_user = creds["DB_USER"]
    db_pass = creds["DB_PASSWORD"]
    db_name = creds["DB_NAME"]
    db_socket_dir = creds.get("DB_SOCKET_DIR", "/cloudsql")
    cloud_sql_connection_name = creds["CLOUD_SQL_CONNECTION_NAME"]

    pool = sqlalchemy.create_engine(
        # Equivalent URL:
        # postgres+pg8000://<db_user>:<db_pass>@/<db_name>
        #                         ?unix_sock=<socket_path>/<cloud_sql_instance_name>/.s.PGSQL.5432
        sqlalchemy.engine.url.URL.create(
            drivername="postgresql+pg8000",
            username=db_user,  # e.g. "my-database-user"
            password=db_pass,  # e.g. "my-database-password"
            database=db_name,  # e.g. "my-database-name"
            query={
                "unix_sock": "{}/{}/.s.PGSQL.5432".format(
                    db_socket_dir, cloud_sql_connection_name  # e.g. "/cloudsql"
                )  # i.e "<PROJECT-NAME>:<INSTANCE-REGION>:<INSTANCE-NAME>"
            },
        ),
        **db_config,
    )
    pool.dialect.description_encoding = None
    logger.info("Database engine initialised from unix conection")

    return pool

Java

Use the Spring Cloud Google Cloud PostgreSQL starter integration to interact with your PostgreSQL databases in Google Cloud SQL using Spring JDBC libraries. Set your Cloud SQL for MySQL config to auto-configure a DataSource bean, which, coupled with Spring JDBC, provides a JdbcTemplate object bean that allows for operations such as querying and modifying a database.

# Uncomment and add env vars for local development
# spring.datasource.username=${DB_USER}
# spring.datasource.password=${DB_PASSWORD}
# spring.cloud.gcp.sql.database-name=${DB_NAME}
# spring.cloud.gcp.sql.instance-connection-name=${CLOUD_SQL_CONNECTION_NAME}  
private final JdbcTemplate jdbcTemplate;

public VoteController(JdbcTemplate jdbcTemplate) {
  this.jdbcTemplate = jdbcTemplate;
}

Handling sensitive configuration with Secret Manager

Secret Manager provides centralized and secure storage of sensitive data such as Cloud SQL configuration. The server injects the Cloud SQL credentials from Secret Manager at runtime via an environment variable. Learn more about Using secrets with Cloud Run.

Node.js

// CLOUD_SQL_CREDENTIALS_SECRET is the resource ID of the secret, passed in by environment variable.
// Format: projects/PROJECT_ID/secrets/SECRET_ID/versions/VERSION
const {CLOUD_SQL_CREDENTIALS_SECRET} = process.env;
if (CLOUD_SQL_CREDENTIALS_SECRET) {
  try {
    // Parse the secret that has been added as a JSON string
    // to retrieve database credentials
    return JSON.parse(CLOUD_SQL_CREDENTIALS_SECRET.toString('utf8'));
  } catch (err) {
    throw Error(
      `Unable to parse secret from Secret Manager. Make sure that the secret is JSON formatted: ${err}`
    );
  }
}

Python

def get_cred_config() -> Dict[str, str]:
    secret = os.environ.get("CLOUD_SQL_CREDENTIALS_SECRET")
    if secret:
        return json.loads(secret)

Java

/** Retrieve config from Secret Manager */
public static HashMap<String, Object> getConfig() {
  String secret = System.getenv("CLOUD_SQL_CREDENTIALS_SECRET");
  if (secret == null) {
    throw new IllegalStateException("\"CLOUD_SQL_CREDENTIALS_SECRET\" is required.");
  }
  try {
    HashMap<String, Object> config = new Gson().fromJson(secret, HashMap.class);
    return config;
  } catch (JsonSyntaxException e) {
    logger.error(
        "Unable to parse secret from Secret Manager. Make sure that it is JSON formatted: "
            + e);
    throw new RuntimeException(
        "Unable to parse secret from Secret Manager. Make sure that it is JSON formatted.");
  }
}

Setting up Identity Platform

Identity Platform requires manual setup in the console.

  1. Go to the Identity Platform Marketplace page in the console.

    Go to the Identity Platform Marketplace page

  2. Click Enable Identity Platform. This creates an OAuth2 Client Id with the name: Web client (auto created by Google Service).

  3. Download the generated OAuth2 ID:

    1. In a new window, go to the APIs & Services > Credentials page

      Go to the APIs & Services > Credentials page

    2. On the record for "Web client (auto created by Google Service)", click the Download icon.

    3. Note the client_id and client_secret.

  4. Configure Google as a provider:

    1. Go to the Identity Providers page in the Cloud console.

      Go to the Identity Providers page

    2. Click Add A Provider.

    3. Select Google from the list.

    4. In the Web SDK Configuration settings, enter the values from earlier:

      1. Web Client ID: client_id

      2. Web Client Secret: client_secret

    5. Click Configure Screen.

      1. Select External for User type.

      2. Fill in the required fields (Support email, developer email).

      3. Continue until you reach the Summary page.

    6. Under "Configure your application", click Setup Details. Copy the snippet into the sample's static/config.js to initialize the Identity Platform Client SDK. Alternatively, locate the snippet from the Google Cloud console page, in the Application Setup Details and copy it into the sample's static/config.js.

    7. Click Save.

Deploying the service

Follow the steps below to complete infrastructure provisioning and deployment or automate the process in Cloud Shell by clicking "Run on Google Cloud":

Run on Google Cloud

  1. Create a Cloud SQL instance with postgreSQL database using the console or CLI:

    gcloud sql instances create CLOUD_SQL_INSTANCE_NAME \
        --database-version=POSTGRES_12 \
        --region=CLOUD_SQL_REGION \
        --cpu=2 \
        --memory=7680MB \
        --root-password=DB_PASSWORD
  2. Add your Cloud SQL credential values to postgres-secrets.json:

    Node.js

    {
      "CLOUD_SQL_CONNECTION_NAME": "PROJECT_ID:REGION:INSTANCE",
      "DB_NAME": "postgres",
      "DB_USER": "postgres",
      "DB_PASSWORD": "PASSWORD_SECRET"
    }
    

    Python

    {
      "CLOUD_SQL_CONNECTION_NAME": "PROJECT_ID:REGION:INSTANCE",
      "DB_NAME": "postgres",
      "DB_USER": "postgres",
      "DB_PASSWORD": "PASSWORD_SECRET"
    }
    

    Java

    {
      "spring.cloud.gcp.sql.instance-connection-name": "PROJECT_ID:REGION:INSTANCE",
      "spring.cloud.gcp.sql.database-name": "postgres",
      "spring.datasource.username": "postgres",
      "spring.datasource.password": "PASSWORD_SECRET"
    }

  3. Create a versioned secret using the console or CLI:

    gcloud secrets create idp-sql-secrets \
        --replication-policy="automatic" \
        --data-file=postgres-secrets.json
  4. Create a service account for the server using the console or CLI:

    gcloud iam service-accounts create idp-sql-identity
  5. Grant roles for Secret Manager and Cloud SQL access using the console or CLI:

    1. Allow the service account associated with the server to access the created secret:

      gcloud secrets add-iam-policy-binding idp-sql-secrets \
        --member serviceAccount:idp-sql-identity@PROJECT_ID.iam.gserviceaccount.com \
        --role roles/secretmanager.secretAccessor
    2. Allow the service account associated with the server to access Cloud SQL:

      gcloud projects add-iam-policy-binding PROJECT_ID \
        --member serviceAccount:idp-sql-identity@PROJECT_ID.iam.gserviceaccount.com \
        --role roles/cloudsql.client
  6. Build the container image using Cloud Build:

    Node.js

    gcloud builds submit --tag gcr.io/PROJECT_ID/idp-sql

    Python

    gcloud builds submit --tag gcr.io/PROJECT_ID/idp-sql

    Java

    This sample uses Jib to build Docker images using common Java tools. Jib optimizes container builds without the need for a Dockerfile or having Docker installed. Learn more about building Java containers with Jib.

    1. Use the gcloud credential helper to authorize Docker to push to your Container Registry.

      gcloud auth configure-docker

    2. Use the Jib Maven Plugin to build and push the container to Container Registry.

      mvn compile jib:build -Dimage=gcr.io/PROJECT_ID/idp-sql

  7. Deploy the container image to Cloud Run using the console or CLI. Note that the server is deployed to allow unauthenticated access. This is so that the user can load the client and begin the process. The server verifies the id token added to the vote request manually, authenticating the end-user.

    gcloud run deploy idp-sql \
        --image gcr.io/PROJECT_ID/idp-sql \
        --allow-unauthenticated \
        --service-account idp-sql-identity@PROJECT_ID.iam.gserviceaccount.com \
        --add-cloudsql-instances PROJECT_ID:REGION:CLOUD_SQL_INSTANCE_NAME \
        --update-secrets CLOUD_SQL_CREDENTIALS_SECRET=idp-sql-secrets:latest

    Also note the flags, --service-account, --add-cloudsql-instances, and --update-secrets, which specify the service identity, the Cloud SQL instance connection, and the secret name with version as an environment variable, respectively.

Finishing touches

Identity Platform requires that you authorize the Cloud Run service URL as an allowed redirect after it has signed in the user:

  1. Edit the Google provider by clicking the pen icon in the Identity Providers page.

  2. Click Add Domain under Authorized Domains on the right panel, and enter the Cloud Run service URL.

    You can locate the service URL in the logs after the build or deployment or you can find it anytime using:

    gcloud run services describe idp-sql --format 'value(status.url)'

Trying it out

To try out the complete service:

  1. Navigate your browser to the URL provided by the deployment step above.

  2. Click the Sign in with Google button and go through the authentication flow.

  3. Add your vote!

    It should look like this:

    Screenshot of the User Interface shows vote count for each team and a list of votes.

If you choose to continue developing these services, remember that they have restricted Identity and Access Management (IAM) access to the rest of Google Cloud and will need to be given additional IAM roles to access many other services.

Clean up

If you created a new project for this tutorial, delete the project. If you used an existing project and wish to keep it without the changes added in this tutorial, delete resources created for the tutorial.

Deleting the project

The easiest way to eliminate billing is to delete the project that you created for the tutorial.

To delete the project:

  1. In the console, go to the Manage resources page.

    Go to Manage resources

  2. In the project list, select the project that you want to delete, and then click Delete.
  3. In the dialog, type the project ID, and then click Shut down to delete the project.

Deleting tutorial resources

  1. Delete the Cloud Run service you deployed in this tutorial:

    gcloud run services delete SERVICE-NAME

    Where SERVICE-NAME is your chosen service name.

    You can also delete Cloud Run services from the Google Cloud console.

  2. Remove the gcloud default region configuration you added during tutorial setup:

     gcloud config unset run/region
    
  3. Remove the project configuration:

     gcloud config unset project
    
  4. Delete other Google Cloud resources created in this tutorial:

What's next