2023年6月21日

Deploying Vue & .NET with Google OAuth on GCP Cloud Run

Deploying Vue & .NET with Google OAuth on GCP Cloud Run

Deploying Vue & .NET with Google OAuth on GCP Cloud Run (vue-dotnet-oauth-gcp-basic)

This example demonstrates the design of programs utilizing Google OAuth for authentication under a decoupled front-end and back-end architecture. Furthermore, it introduces the process of wrapping the program into a Docker image, storing it in the GCP Artifact Registry, and subsequently deploying it to GCP Cloud Run.

  • Frontend: Utilizes the Vue 3 framework with Vuetify as the UI kit. Implements OAuth 2 Authorization Code Grant and Implicit Grant using yobaji/vue3-google-login for Google authentication.
  • Backend: Developed using the .NET Core 6.0 framework, and calls Google APIs to retrieve userinfo.

Reference

Directory Structure

vue-dotnet-oauth-example
├── oauth-backend
│   └── oauth-google
└── oauth-frontend
    └── oauth-app

Frontend

Setting Up a Dev Container for Vue3 Development

Please set up a Dev Container development environment under the oauth-frontend directory:

  • In VS Code, press F1
  • Choose ‘Dev Containers: Open Folder in Container…’
  • Select: ‘Vue community’ (Develop an application with Vue.js, includes everything you need to get up and running.)
  • Choose Node.js version: 18
  • Select additional features to install: (skip)
  • Then waiting “Adding Dev Container Configuration Files…” for couple menutes.

Modify git config

The backend of this example was developed in Windows using Visual Studio 2022, while the frontend was developed in a Linux operating system using the Dev Container in VS Code. To avoid differences in the end-of-line (EOL) character (CRLF in Windows, LF in Linux), it is necessary to adjust the git settings. The following command, run in the Dev Container, can adjust the EOL character to be consistently CRLF.

git config --global core.autocrlf true

Creating a Project with Vuetify 3

The simplest way to set up a Vue3 + Vuetify3 project is to execute the ‘create vuetify’ command:

yarn create vuetify
# Project name: oauth-app
# ✔ Which present would you like ti inatll?
# ✔ > Default (Vuetify)
#     Base (Vuetify, VueRouter)
#     Essentials (Vuetify, VueRouter, Pinia)
#     Custom (Choose your features)
# ✔ Use TypeScript? Yes
# ✔ Would you like to install dependencies with yarn, npm, or pnpm? yarn

cd oauth-app
yarn dev

Vite Polling Configuration

You might encounter an issue when running your application inside a dev containerized environment where file changes are not detected automatically due to file system events not being properly propagated. This could prevent automatic recompilation or reloading of your application.

The Vite configuration snippet you provided is a common way to address this issue. The usePolling: true configuration tells Vite to use polling to detect file changes. Although this method could slightly increase CPU usage, it’s generally acceptable for most cases.

Setting interval: 500 configures Vite to check for file changes every 500 milliseconds.

Here is a complete vite.config.ts file example:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  server: {
    port: 3000,
    watch: {
      usePolling: true,
      interval: 500
    }
  },
})

Creating Google Cloud APIs & Service Credentials

To utilize Google OAuth2 for authentication, the following steps are required and should be performed in Google Cloud Console:

  1. Enable Google+ API: Search for APIs & Service, click on Library on the left side, find Google+ API, and set its status to Enable.
  2. Select Project: Create a new Project or choose an existing one.
  3. Create OAuth 2.0 Client IDs: Click on Credentials on the left side, and create a Web Application through + CREATE CREDENTIALS to obtain Client IDs.
  4. Configure the OAuth consent screen: This is the authorization page that pops up when a user logs in with their Google Account. It can be configured here.

For a detailed setup process, you can refer to this YouTube tutorial: How to generate Google OAuth Client ID and Client Secret?

OAuth2 Login Authentication Process

OAuth 2.0 offers four major authorization flows, applicable to different types of applications:

  1. Authorization Code Grant: This is the most common and secure authorization flow, suitable for a separated front-end and back-end design.

  2. Implicit Grant: It directly returns an access token to obtain user information. Due to security reasons, this flow has been deprecated in OAuth 2.1.

  3. Resource Owner Password Credentials Grant: The username and password are sent to the authorization server to obtain an access token. This flow is only suitable for server-to-server information exchange and has also been deprecated in OAuth 2.1.

  4. Client Credentials Grant: In this flow, the application uses its client ID and secret to directly obtain an access token from the authorization server. This flow is suitable for cases where the application needs to acquire an access token on its behalf (not on behalf of the user).

Below is the UML Sequence Diagram for Google OAuth 2 utilizing Implicit Grant, which is also the method adopted by this project:

UserFront EndBack EndGoogle OAuth2Request to log in with GoogleRedirect to Google sign in pageUser enters credentialsSubmit credentialsSend back access tokenForward access tokenSend access token for verificationVerify access tokenReturn verification resultRespond with authentication statusDisplay authentication statusUserFront EndBack EndGoogle OAuth2

The following UML Sequence Diagram is for OAuth 2.0 Authorization Code Grant. The main difference from the implicit grant is that the user obtains an authorization code (instead of an access token), and the backend uses this authorization code to obtain the access token. Therefore, the access token is only retained in the backend and will not leak to the frontend, thereby enhancing security.

UserFront EndBack EndGoogle OAuth2Request to log in with GoogleRedirect to Google sign in pageUser enters credentialsSubmit credentialsSend back authorization codeForward authorization codeSend authorization code for exchangeExchange authorization code for access tokenReturn access tokenVerify access tokenReturn verification resultRespond with authentication statusDisplay authentication statusUserFront EndBack EndGoogle OAuth2

Using the yobaji/vue3-google-login package

The yobaji/vue3-google-login is a lightweight plugin leveraging Google Identity Services and Google’s 3rd Party Authorization JavaScript Library to simplify Google login and signup workflows. This package utilizes the OAuth 2.0 Implicit Grant flow to secure user authentication. It offers the following functionalities:

  • Login with a Google button
  • Login using a One Tap prompt
  • Automatic login without any user interaction
  • Login with Google using a custom button

The first step is to install it:

yarn add vue3-google-login
# or
npm install vue3-google-login

Then, initialize the plugin:

import { createApp } from 'vue'
import App from './App.vue'
import vue3GoogleLogin from 'vue3-google-login'

const app = createApp(App)

app.use(vue3GoogleLogin, {
  clientId: 'YOUR_GOOGLE_CLIENT_ID'
})

app.mount('#app')

ID Token

The following code will pop up the authentication window provided by Google. Once authorization is successful, the ID Token can be obtained from the returned credential field:

<script setup>
const callback = (response) => {
  // This callback will be triggered when the user selects or login to
  // his Google account from the popup
  console.log("Handle the response", response)
}
</script>

<template>
  <GoogleLogin :callback="callback"/>
</template>

The returned content is as follows:

	clientId: '657755...', 
	client_id: '657755...', , 
	credential: 'eyJhbGciOiJ...', 
	select_by: 'btn'

The credential is in JWT format, and the decoded information is as follows:

"iss": "https://accounts.google.com",
"nbf": 16854...,
"aud": "657755...",
"sub": "104222...",
"email": "dyson...",
"email_verified": true,
"azp": "6577558...",
"name": "Liu Dyson..",
"picture": "https://lh3.goog...",
"given_name": "Liu",
"family_name": "Dyson",
"iat": 16854...,
"exp": 16855...,
"jti": "07e4fc..."

Through the ID Token, the frontend can directly obtain the user’s profile. Although the data can be transmitted from the frontend to the backend, the backend cannot verify its authenticity. The frontend can switch to using the Access Token method, which is transmitted to the backend via the API. The backend retrieves userinfo from the Google Identity Service, which ensures the backend receives authentic information.

However, keep in mind, regardless of whether you adopt the ID Token or Access Token method, the frontend exposes important user information. To meet higher security requirements, the Authorization Code Grant process should be used.

Access Token

If you want to obtain an Access Token, you need to set popup-type to “TOKEN” and use a Custom Login Button. Afterward, send the Access Token to the backend so that the backend can verify its identity. Here is an example:

<script setup>
const callback = (response) => {
  console.log("Handle the response", response)
}
</script>

<template>
  <GoogleLogin :callback="callback" popup-type="TOKEN">
    <v-btn prepend-icon="mdi-google" stacked>
      Log in with Google
    </v-btn>
  </GoogleLogin>
</template>

Another way to acquire an access token is through the googleTokenLogin function. Here’s a sample:

<script setup>
import { googleTokenLogin } from "vue3-google-login"
const login = () => {
  googleTokenLogin().then((response) => {
    console.log("Handle the response", response)
  })
}
</script>

<template>
  <button @click="login">Login Using Google</button>
</template>

The returned response in json format is as follows:

{
    "access_token": "ya29.a0AWY7CkmK6...",
    "token_type": "Bearer",
    "expires_in": 3599,
    "scope": "email profile openid https://www.googleapis.com/...",
    "authuser": "0",
    "prompt": "none"
}

Authorization Code

Using the Authorization Code method to avoid frontend leaking access tokens is a safer approach. However, the process is relatively complex and requires backend assistance to complete. The following example uses the googleAuthCodeLogin function to obtain an authorization code. This is only part of the code. In practice, the authorization code needs to be sent to the backend, and the backend needs to use the client secret, redirect URI, and other information to obtain the access token.

<script setup>
import { googleAuthCodeLogin } from "vue3-google-login"
const login = () => {
  googleAuthCodeLogin().then((response) => {
    console.log("Handle the response", response)
    // Send the code from the response to the backend via an API, and let the backend retrieve the access token
  })
}
</script>

<template>
  <button @click="login">Login Using Google</button>
</template>

The content of the Authorization Code generally looks like this:

{
    "code": "4/0AbUR2VN7RD...",
    "scope": "email profile https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid",
    "authuser": "0",
    "prompt": "consent"
}

Calling backend API to log in

After successfully obtaining the access token, you can log in through the backend API, allowing the backend to obtain user-related information with the access token.

Below is a code example. Subsequently, we will develop the backend API specified by backendUrl to execute the login operation and return the login result and userinfo, etc.

<script setup>
const callback = async (response) => {
  console.log("Handle the response", response)

  const backendUrl = `http://${window.location.hostname}:5000/api/AuthGoogle/TokenLogin`
  const res = await fetch(backendUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(response)
  })

  const data = await res.json()
  console.log('Server response:', data)
}
</script>

Backend

Setup Kestrel configuration

To facilitate testing on localhost, we specify the listening port through builder.WebHost.ConfigureKestrel(), so that the frontend can call the backend API with a fixed URL.

The setup is as follows:

var builder = WebApplication.CreateBuilder(args);
// ... other code
// Setup Kestrel configuration
builder.WebHost.ConfigureKestrel(options =>
{
    options.ListenAnyIP(5000); // HTTP port
    options.ListenAnyIP(5001, listenOptions => // HTTPS port
    {
        listenOptions.UseHttps();
    });
});

// ... other code
var app = builder.Build();

Add CORS policy

Since the frontend runs in VS Code Dev Container, and the backend is developed and run on the local machine with Visual Studio, even though the domain name is localhost, the ports are different. Therefore, a CORS policy needs to be set up.

In ASP.NET Core 6.0, setting up a CORS policy involves two steps: first, add a service with builder.Services.AddCors() in Program.cs, then add middleware with app.UseCors(). The modified Program.cs example is as follows:

var allowSpecificOrigins = "_allowSpecificOrigins";
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Add CORS policy
builder.Services.AddCors(options =>
{
    options.AddPolicy(
        name: allowSpecificOrigins,
        policy =>
        {
            policy.WithOrigins("http://localhost:3000", "http://127.0.0.1:3000")
                        .AllowAnyHeader()
                        .AllowAnyMethod();
        });
});

// Setup Kestrel configuration
builder.WebHost.ConfigureKestrel(options =>
{
    options.ListenAnyIP(5000); // HTTP port
    options.ListenAnyIP(5001, listenOptions => // HTTPS port
    {
        listenOptions.UseHttps();
    });
});

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseCors(allowSpecificOrigins);
app.UseAuthorization();
app.MapControllers();
app.Run();

Add login api

Add a class AuthGoogleController : Controller under the Controllers directory, and provide the HttpPost login API.

The example is as follows:

using Microsoft.AspNetCore.Mvc;

namespace oauth_google.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class AuthGoogleController : Controller
    {
        [HttpPost("TokenLogin")]
        public async Task<ActionResult<LoginResponse>> TokenLogin([FromBody] GoogleLoginRequest googleLoginRequest)
        {
            LoginResponse funcResponse = new();
            return Ok(funcResponse);
        }
    }
}

In the above code, we have designed class GoogleLoginRequest and LoginResponse for HTTP request and response, respectively. The codes are as follows.

GoogleLoginRequest:

using System.Text.Json.Serialization;

namespace oauth_google.Models
{
    public class GoogleLoginRequest
    {
        [JsonPropertyName("access_token")]
        public string? AccessToken { get; set; }
    }
}

LoginResponse:

namespace oauth_google.Models
{
    public class LoginResponse
    {
        public bool Result { get; set; } = false;
        public string Message { get; set; } = string.Empty;
        public UserProfile? UserProfile { get; set; }

    }
}

Call Google APIs to obtain userinfo

Access Token

Once a valid access token is received from the client, the backend server can call https://www.googleapis.com/oauth2/v3/userinfo?access_token

={accessToken} to obtain userinfo. Hence, registration and login operations can be completed at the same time. The example code is as follows:

using Microsoft.AspNetCore.Mvc;

namespace oauth_google.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class AuthGoogleController : Controller
    {
        [HttpPost("TokenLogin")]
        public async Task<ActionResult<LoginResponse>> TokenLogin([FromBody] GoogleLoginRequest googleLoginRequest)
        {
            LoginResponse funcResponse = new();

            try
            {
                string? accessToken = googleLoginRequest.AccessToken ?? throw new Exception();

                using HttpClient httpClient = new();
                var response = await httpClient.GetAsync($"https://www.googleapis.com/oauth2/v3/userinfo?access_token={accessToken}");

                if (response.IsSuccessStatusCode)
                {
                    var content = await response.Content.ReadAsStringAsync();

                    // Get userinfo from Google API response
                    GoogleUserInfo? googleUserProfile = System.Text.Json.JsonSerializer.Deserialize<GoogleUserInfo>(content);

                    return Ok(funcResponse);
                }

                funcResponse.Result = false;
                funcResponse.Message = "Invalid token.";
                return BadRequest(funcResponse);
            }
            catch (Exception)
            {
                funcResponse.Result = false;
                funcResponse.Message = "Invalid or expired token.";
                return Unauthorized();
            }
        }
    }
}

In the above example, the content of the class GoogleUserInfo is as follows:

using System.Text.Json.Serialization;

namespace oauth_google.Models
{
    public class GoogleUserInfo
    {
        [JsonPropertyName("sub")]
        public string? Sub { get; set; }

        [JsonPropertyName("name")]
        public string? Name { get; set; }

        [JsonPropertyName("given_name")]
        public string? GivenName { get; set; }

        [JsonPropertyName("family_name")]
        public string? FamilyName { get; set; }

        [JsonPropertyName("picture")]
        public string? Picture { get; set; }

        [JsonPropertyName("email")]
        public string? Email { get; set; }

        [JsonPropertyName("email_verified")]
        public bool? EmailVerified { get; set; }

        [JsonPropertyName("locale")]
        public string? Locale { get; set; }
    }
}

Authorization Code

To retrieve userinfo using an authorization code involves a two-step process. Initially, the authorization code is used to obtain the access token from https://oauth2.googleapis.com/token. Afterward, userinfo can be accessed. The following code snippet showcases this, illustrating how the backend obtains the access token using the authorization code, client id, client secret, and redirect uri.

private static readonly string GoogleApiUrl = "https://www.googleapis.com/oauth2/v3/userinfo";
private static readonly string GoogleTokenUrl = "https://oauth2.googleapis.com/token";


[HttpPost("AuthCodeLogin")]
public async Task<ActionResult<ResponseBase<UserProfile>>> AuthCodeLogin([FromBody] GoogleAuthCodeLoginRequest googleLoginRequest)
{
    ResponseBase<UserProfile> funcResponse = new();

    try
    {
        // check request format
        string? authCode = googleLoginRequest.Code ?? throw new Exception();

        // Exchange authorization code for access token
        var tokenRequestBody = new Dictionary<string, string>()
        {
            {"code", authCode},
            {"client_id", _configuration["Google:ClientId"]},
            {"client_secret", _configuration["Google:ClientSecret"]},
            {"redirect_uri", _configuration["Google:RedirectUri"]},
            {"grant_type", "authorization_code"}
        };

        using HttpClient httpClient = new();
        var tokenResponse = await httpClient.PostAsync(GoogleTokenUrl, new FormUrlEncodedContent(tokenRequestBody));
        if (!tokenResponse.IsSuccessStatusCode)
        {
            var errorContent = await tokenResponse.Content.ReadAsStringAsync();
            _logger.LogError("Failed to exchange authorization code for access token: {StatusCode}. Response content: {ResponseContent}", tokenResponse.StatusCode, errorContent);

            return Unauthorized();
        }

        var tokenContent = await tokenResponse.Content.ReadAsStringAsync();
        var tokenInfo = JsonSerializer.Deserialize<GoogleTokenInfo>(tokenContent);
        if (tokenInfo == null || tokenInfo.AccessToken == null)
        {
            _logger.LogError(message: "Failed to deserialize exchange authorization code for access token");
            return Unauthorized();
        }

        // Use access token to get user info
        var request = new HttpRequestMessage(HttpMethod.Get, GoogleApiUrl);
        request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenInfo.AccessToken);
        var response = await httpClient.SendAsync(request);

        if (response.IsSuccessStatusCode)
        {
            var content = await response.Content.ReadAsStringAsync();
            GoogleUserInfo? googleUserProfile = System.Text.Json.JsonSerializer.Deserialize<GoogleUserInfo>(content);

            if (googleUserProfile != null && !string.IsNullOrWhiteSpace(googleUserProfile.Sub))
            {
                UserProfile? userProfile = SaveUserProfile(googleUserProfile);

                funcResponse.Result = true;
                funcResponse.Data = userProfile;
                return Ok(funcResponse);
            }
        }

        funcResponse.Message = "Invalid authorization code.";
        return BadRequest(funcResponse);

    }
    catch (Exception)
    {
        funcResponse.Message = "Invalid Authorization Code.";
        return Unauthorized();
    }
}

Vite Proxy

When testing the Frontend initiated with yarn dev, the built-in web server (Koa) from Vite is utilized, while the Backend operates on Kestrel. Consequently, different ports need to be adopted for delineation. For instance, the Frontend can use http://localhost:3000, while the Backend employs http://localhost:5000. Given the different ports, the Backend needs to configure a CORS (Cross-Origin Resource Sharing) policy to smoothly permit connections from the Frontend.
Vite offers a Proxy feature, leveraging this mechanism can obviate the necessity of setting a CORS policy on the Backend during local testing of Frontend and Backend. The method entails adding a proxy configuration in the server section of the vite.config.ts, as shown in the example below:

server: {
  port: 3000,
  watch: {
    usePolling: true,
    interval: 500
  },
  proxy: {
    '/api': 'http://localhost:5000'
  }
}

However, it’s crucial to note that Vite’s Proxy mechanism becomes ineffective when the Frontend is operating in a Dev Container. It can only function correctly in a local Node.js development environment.

Establishing a Testing Environment with Containers

We’ll be carrying out integration tests using containers. The Frontend uses Nginx as its Web Server, while the Backend, because of .NET Core’s built-in Kestrel, can run directly within a container. Additionally, we’re introducing an Nginx container responsible for Reverse Proxy, determining whether to direct to Frontend or Backend based on whether the first-level subdirectory path contains /api.

The architecture diagram is represented with a Flowchart below:

Architecture Diagram
/api
Client
Nginx
Backend
Frontend

Creating the Frontend Docker Image

In the following example, we employ a multistage build to compile a program developed using Vue 3 in a pristine environment. The resulting files are subsequently copied and executed within Nginx.

FROM node:18 AS build
WORKDIR /src
COPY package.json yarn.lock ./
RUN yarn install
COPY . .
RUN yarn run build

FROM nginx:alpine AS final
WORKDIR /app
COPY --from=build /src/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

The above example employs Nginx as the Web Server, so in addition to the Frontend code, an Nginx configuration file is also required. Here is an example of the configuration file:

server {
    listen       80;
    server_name  localhost;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
        try_files $uri $uri/ /index.html;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

The following example shows how to use docker build to create a Docker image according to the contents of the Dockerfile. This example is suitable for Windows PowerShell.

docker build -t vue-dotnet-oauth-example/oauth-frontend-app:1.0 .

Creating the Backend Docker Image

The Backend is developed using .NET Core, which automatically generates a Dockerfile when the project is created. The method for creating the Docker Image is also multistage, like the Frontend. However, it is important to note that the Docker context generated automatically by Visual Studio should be set at the parent directory, i.e., the solution directory, rather than the project directory.

FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
WORKDIR /app
EXPOSE 80

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["oauth-google/oauth-google.csproj", "oauth-google/"]
RUN dotnet restore "oauth-google/oauth-google.csproj"
COPY . .
WORKDIR "/src/oauth-google"
RUN dotnet build "oauth-google.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "oauth-google.csproj" -c Release -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "oauth-google.dll"]

The following example shows how to use the docker build command to create a Docker image from the Dockerfile. The Dockerfile and the Project are in the same directory, but the context must be set to the parent directory.

docker build -t vue-dotnet-oauth-example/oauth-backend-api:1.0 -f Dockerfile ..

Integration Testing

Once the Dockerfiles for both the Frontend and Backend are complete, we can use docker compose to set up the entire environment. Here is an example of a docker-compose.yml file:

version: '3.8'

services:

  oauth-frontend:
    image: vue-dotnet-oauth-example/oauth-frontend-app:1.0
    build:
      context: ./oauth-frontend/oauth-app
      dockerfile: Dockerfile
    networks:
      - oauth-internal

  oauth-backend:
    image: vue-dotnet-oauth-example/oauth-backend-api:1.0
    build:
      context: ./oauth-backend
      dockerfile: oauth-google/Dockerfile
    environment:
      - Google__ClientId=${GOOGLE_CLIENT_ID}
      - Google__ClientSecret=${GOOGLE_CLIENT_SECRET}
      - Google__RedirectUri=${GOOGLE_REDIRECT_URI}
      - AllowedOrigins=http://localhost:3000,http://127.0.0.1:3000
    networks:
      - oauth-internal

  nginx:
    image: vue-dotnet-oauth-example/oauth-proxy:1.0
    build:
      context: .
      dockerfile: Dockerfile.nginx
    ports:
      - 80:80
      - 443:443
    depends_on:
      - oauth-backend
      - oauth-frontend
    networks:
      - oauth-internal

networks:
  oauth-internal:

In the example above, Nginx serves as the reverse proxy with the following configuration:

events {}

http {
    server {
        listen 80;

        location /api {
            proxy_pass http://oauth-backend:80;
        }

        location / {
            proxy_pass http://oauth-frontend:80;
        }
    }
}

The directory structure for the docker-compose.yml, Frontend, Backend and related file locations is as shown below:

vue-dotnet-oauth-example
├── oauth-backend
│   └── oauth-google
│       └─ Dockerfile
├── oauth-frontend
│   └── oauth-app
│       ├─ Dockerfile
│       └─ nginx.conf
├── docker-compose.yml
└── nginx.conf

Docker Compose Up/Down

The following command can be used to start Docker Compose, setting the relevant details for Google APIs & Services into the environment variables (for PowerShell):

$Env:GOOGLE_CLIENT_ID="your-client-id"
$Env:GOOGLE_CLIENT_SECRET="your-client-secret"
$Env:GOOGLE_REDIRECT_URI="your-redirect-uri"
$Env:ALLOWED_ORIGINS="your-allowed-origins"
docker-compose up --build

--build: This optional flag instructs Docker to build images prior to launching the containers. If any modifications have been made to the Dockerfiles since the last time the containers were initiated or the images were built, those modifications will be included when the containers start.

To stop Docker Compose, you can use the following command (for PowerShell):

docker-compose down
Remove-Item Env:\GOOGLE_CLIENT_ID
Remove-Item Env:\GOOGLE_CLIENT_SECRET
Remove-Item Env:\GOOGLE_REDIRECT_URI
Remove-Item Env:\ALLOWED_ORIGINS

Pushing Images to Google Artifact Registry

We have successfully developed and tested three Docker images using docker-compose. Now, we are ready to deploy these on Google Cloud.

Google Cloud Platform’s (GCP) Cloud Run is a convenient service, ideal for quickly deploying and showcasing applications. It offers a serverless environment, manages the infrastructure, and automatically handles network and HTTPS support. This reduces the need to set up a reverse proxy server. In our example, we will deploy the frontend and backend on two separate Cloud Run instances. However, Firebase Hosting is another option for deploying static websites, such as frontend applications. Cloud Run is free within certain limits, making it an excellent choice for demonstrations.

Before we deploy, we need to push our Docker Images, currently stored locally, to the Google Artifact Registry. Suppose we have already created a project via the Google Cloud Console named “vue-dotnet-oauth”. The following steps outline the process:

  1. Under the “vue-dotnet-oauth” project, search for “Artifact Registry”. Click to enter, then click the “Enable” button to activate the Artifact Registry.
  2. Press “+” on the top toolbar and create a repository. Here’s a reference for setting up the various fields:
    • Name: vue-dotnet-example-repos
    • Format: [Docker] / Maven / npm / Python / Apt / Yum / Kubeflow Pipeline / Go
    • Mode: [Standard] / Remote / Virtual
    • Location type: [Region] / Multi-region
    • Region: [asia-east1(Taiwan)] (You can choose the best data center location based on your area)
    • Description: vue-dotnet-example Docker Repository
    • Encryption: [Google-managed encryption key] / Customer-managed encryption key (CMEK)
    • Press [Create]
  3. First, use gcloud auth login in PowerShell to log in. If you haven’t installed the gcloud CLI yet, please refer to the Install the gcloud CLI official website for installation steps.
  4. Then, enter the following command to authorize the Docker repository. The command is gcloud auth configure-docker <hostname>. Here <hostname> is region + “-docker.pkg.dev”. For example, for “asia-east1”, the command is:
    gcloud auth configure-docker asia-east1-docker.pkg.dev
    
    You can replace asia-east1 with the region where you created your repository.
  5. Use the following command to check your local Docker configuration:
    cat $env:UserProfile\.docker\config.json
    
    The displayed result should look something like this:
    {
     "credsStore": "desktop",
     "credHelpers": {
       "asia-east1-docker.pkg.dev": "gcloud"
     }
    }
    
  6. Before pushing, you need to tag the Docker Images. The rule is docker tag <imagename> <hostname>/<project-name>/<repository-name>/<image-name>:<tag>. Here are command examples:
    docker tag vue-dotnet-oauth-example/oauth-frontend-app:1.0 asia-east1-docker.pkg.dev/vue-dotnet-oauth/vue-dotnet-ex-docker-repository/oauth-frontend-app:1.0
    
    docker tag vue-dotnet-oauth-example/oauth-backend-api:1.0 asia-east1-docker.pkg.dev/vue-dotnet-oauth/vue-dotnet-ex-docker-repository/oauth-backend-api:1.0
    
  7. Finally, push the tagged Docker images to the Docker repository with the following commands:
    docker push asia-east1-docker.pkg.dev/vue-dotnet-oauth/vue-dotnet-ex-docker-repository/oauth-frontend-app:1.0
    
    docker push asia-east1-docker.pkg.dev/vue-dotnet-oauth/vue-dotnet-ex-docker-repository/oauth-backend-api:1.0
    

Deployment to GCP Cloud

Up until now, we have successfully pushed our Docker images to the Artifact Registry. However, to activate the programs on Cloud Run, we still need to make some adjustments, such as setting the listening port for the Frontend and the URI for connecting to the Backend API. In addition, there are some preliminary tasks to enable Cloud Run, which need to be executed in the Google Cloud Console.

Create Cloud Run Service

Firstly, we must establish Cloud Run in order to obtain a public domain name, so that we can set the URI for the Backend API in the Frontend. The steps are as follows:

Create Backend Cloud Run Service

  1. Search for Cloud Run in the Google Cloud Console and select CREATE SERVICE.
  2. Choose the Backend image that was uploaded earlier and specify the Region.
  3. Expand the Container, Networking, Security section, and input the following information in the Environment Variables:
    • ASPNETCORE_URLS: http://+:8080 (By default, the Cloud sets the internal listening port of the container to 8080. If you want to modify this, you’ll need to adjust the Container port setting at the same time)
    • AllowedOrigins:<empty> (Leave it blank for now, fill it in after creating the Frontend Cloud Run)
    • Google__ClientId:<empty> (Leave it blank for now, fill it in after creating OAuth)
    • Google__ClientSecret:<empty> (Leave it blank for now, fill it in after creating OAuth)
    • Google__RedirectUri: <empty> (Leave it blank for now, fill it in after creating the Frontend Cloud Run. Please note that GCP has an implicit rule. When exchanging an Authorization Code for an Access Token, the Backend calls the Google API. The RedirectUri cannot be the URL of the caller. Thus, it’s recommended to fill in the URI of the Frontend later)
    • After completing these steps, click DEPLOY. If created successfully, you will obtain the URI for the Backend’s external service.

Create Frontend Cloud Run Service

The Frontend, as a static webpage, is unable to dynamically read environment variables to specify the location of the Backend API connection. Thus, we need to modify the build method. The Frontend and Backend are on two independent Cloud Runs, and connection is achieved via a public domain name. We add a build:cloud_run script in the package.json and add .env and .env.cloud in the root directory to set the Backend URI. Here are examples of the relevant file modifications:

package.json:

  "scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "build:cloud_run": "vue-tsc --noEmit && vite build --mode cloud_run",
    "preview": "vite preview",
    "lint": "eslint . --fix --ignore-path .gitignore"
  },

.env

VITE_BACKEND_URI=

.env.cloud_run

VITE_BACKEND_URI=<backend base uri>

Modify the VUE 3 program that calls the Backend API

// const backendUrl = '/api/AuthGoogle/ClientInfo'
// Add the environment variable setting for the Backend API Base URI to the above program
const backendUrl = `${import.meta.env.VITE_BACKEND_URI || ''}/api/AuthGoogle/ClientInfo`

Since Cloud Run uses the environment variable PORT to set the container’s internal listening port, nginx.conf also needs some adjustments. Set the listening configuration to ${PORT}.

server {
    listen ${PORT};

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
        try_files $uri $uri/ /index.html;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

Finally, rebuild the Docker Image through docker build and upload it to the Artifact Registry.

# docker build
docker build --build-arg BUILD_MODE=cloud_run -t asia-east1-docker.pkg.dev/vue-dotnet-oauth/vue-dotnet-ex-docker-repository/oauth-frontend-app:1.0 .

# docker push
docker push asia-east1-docker.pkg.dev/vue-dotnet-oauth/vue-dotnet-ex-docker-repository/oauth-frontend-app:1.0

After updating the Docker image, you can create a Cloud Run Service in the same way as the Backend. Cloud Run will automatically set the environment variable PORT to specify the container’s internal listening port, and we have already set the corresponding variable in nginx.conf, so there’s no need for additional environment variable settings in the Frontend Cloud Run.

Establish Google OAuth 2.0 API

Our example mainly demonstrates logging in through OAuth 2.0 using a Google account. Therefore, we need to set up OAuth 2.0 related information as follows:

  1. Search for APIs & Service in the Google Cloud Console. After entering, click on + ENABLE APIS AND SERVICES, look for Google+ API, and press ENABLE. Enabling the Google+ API allows the program we developed to execute OAuth authentication operations through the API.
  2. In the menu of APIs & Service, click on OAuth consent screen and create as External. Fill in the necessary fields. When users click login, the pop-up dialog screen is set up via the OAuth consent screen.
  3. In the menu of APIs & Service, click on Credentials. At the top of the screen, press CREATE CREDENTIALS and select OAuth Client ID. Enter the URI of the frontend and backend cloud run service in Authorized JavaScript origins, and enter the URI of the frontend cloud run service in Authorized redirect URIs. As mentioned earlier, GCP OAuth 2.0 has an implicit rule: if the redirect URI is the same as the sent request (API that exchanges the authorization code for an access code), the comparison of the redirect URI will fail.

Reset the Environmental Variables of the Backend Cloud Run

After completing the OAuth setup, you can set the Client ID, Client Secret, and Redirect URI into the Backend’s environmental variables: Google__ClientId, Google__ClientSecret, and Google__RedirectUri. At the same time, remember to assign the content of AllowedOrigins as the URI of the Frontend Cloud Run.

The entire process is generally complete, although it can be somewhat complicated. Practice a few times, and you should be able to complete it smoothly. Finally, remember that changes to the contents of Credentials are not immediately effective. It may take a few minutes to several hours, which can cause some uncertainty when determining whether the settings are effective.

沒有留言:

Deploying Vue & .NET with Google OAuth on GCP Cloud Run

Deploying Vue & .NET with Google OAuth on GCP Cloud Run Deploying Vue & .NET with Google OAuth on GCP Cloud Run...