React と Go で Sign In With Google

フロントエンドを React、バックエンドを Go で Sign In With Google (レガシーな Google Sign-In ではなく新しいほう)を実装する。

開発環境

フロントエンド

  • node v16.13.1
  • npm v8.1.2
  • react v17.0.37
    • create-react-app でひな形作成。TypeScript 使用。

バックエンド

  • go version go1.17.5 linux/amd64

フロントエンド

前提として公式サイトの

Get started

を上から見てやっていくとして、困ったことに外部スクリプトを読み込まなくてはいけないといった React としてはつらい仕様となっている。

結果としては身も蓋も無いが下記の記事の内容をほぼそのまま実装した。

TLDR: Scroll down and copy the code. You only need to add your login logic.

と書いていてくれているのでおそらくコピペしても大丈夫だろう。

少なくとも私の環境ではいくらかエラーが出たのと、一旦不要な部分を削って実装したので、修正結果の中身を張っておく。

src/component/page/Login.tsx (ログイン画面のページコンポーネント)

公式サイトに合わせてログインボタンの表示部は id="buttonDiv" を付与した。
またログインエラー時にエラーメッセージを表示するための領域も確保している。
ロジックはすべて src/hooks/useLogin.ts に切り出した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import React from "react"
import { useLogin } from 'hooks/useLogin';

export default function Login() {
    const [error] = useLogin();

    return (
        <div>
            <div id="buttonDiv"></div>
            <div>{error}</div>
        </div>
    );
}

src/hooks/useLogin.ts(ログインロジック)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import { useEffect, useState, useCallback } from 'react';
import axios from "axios";
import { LoginResponseInterface, LoginRequest } from "types/Login";

export const useLogin = () => {
    const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
    const [error, setError] = useState('');

    const handleGoogleSignIn = useCallback((res: CredentialResponse) => {
        if (!res.clientId || !res.credential) {
            return;
        }

        const requestData: LoginRequest = {
            credential: res.credential
        };

        const url: string = process.env.REACT_APP_API_BASE_URL! + "/login";
        axios.post<LoginResponseInterface>(
            url,
            requestData,
        )
            .then(res => {
                console.log(res.data.result);
                if (res.data.result) {
                    window.location.href = '/';
                } else {
                    setError("エラーが発生しました");
                }
            })
            .catch((e) => {
                setError("エラーが発生しました");
            });
    }, []);

    const initializeGsi = useCallback(() => {
        if (!window.google || gsiScriptLoaded) return

        setGsiScriptLoaded(true)
        window.google.accounts.id.initialize({
            client_id: process.env.REACT_APP_GOOGLE_CLIENT_ID!,
            callback: handleGoogleSignIn,
        });
        window.google.accounts.id.renderButton(
            document.getElementById("buttonDiv")!,
            { type: "standard", theme: "outline", size: "large" }
        );
    }, [handleGoogleSignIn, gsiScriptLoaded]);

    useEffect(() => {
        if (gsiScriptLoaded) {
            return;
        }
        const script = document.createElement("script");
        script.src = "https://accounts.google.com/gsi/client";
        script.onload = initializeGsi;
        script.async = true;
        script.id = "google-client-script";
        document.querySelector("body")?.appendChild(script);

        return () => {
            window.google?.accounts.id.cancel();
            document.getElementById("google-client-script")?.remove();
        }
    }, [initializeGsi, gsiScriptLoaded]);

    return [error];
};

react-app-env.d.ts

元記事の筆者ががんばって作ってくれて感謝しかない。

But it should be correct.

とのことだが、一部エラーになったので修正を入れた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
/// <reference types="react-scripts" />

interface IdConfiguration {
    client_id: string
    auto_select?: boolean
    callback: (handleCredentialResponse: CredentialResponse) => void
    login_uri?: string
    native_callback?: Function
    cancel_on_tap_outside?: boolean
    prompt_parent_id?: string
    nonce?: string
    context?: string
    state_cookie_domain?: string
    ux_mode?: "popup" | "redirect"
    allowed_parent_origin?: string | string[]
    intermediate_iframe_close_callback?: Function
}

interface CredentialResponse {
    credential?: string
    select_by?:
    | "auto"
    | "user"
    | "user_1tap"
    | "user_2tap"
    | "btn"
    | "btn_confirm"
    | "brn_add_session"
    | "btn_confirm_add_session"
    clientId?: string
}

interface GsiButtonConfiguration {
    type: "standard" | "icon"
    theme?: "outline" | "filled_blue" | "filled_black"
    size?: "large" | "medium" | "small"
    text?: "signin_with" | "signup_with" | "continue_with" | "signup_with"
    shape?: "rectangular" | "pill" | "circle" | "square"
    logo_alignment?: "left" | "center"
    width?: string
    locale?: string
}

interface PromptMomentNotification {
    isDisplayMoment: () => boolean
    isDisplayed: () => boolean
    isNotDisplayed: () => boolean
    getNotDisplayedReason: () =>
        | "browser_not_supported"
        | "invalid_client"
        | "missing_client_id"
        | "opt_out_or_no_session"
        | "secure_http_required"
        | "suppressed_by_user"
        | "unregistered_origin"
        | "unknown_reason"
    isSkippedMoment: () => boolean
    getSkippedReason: () =>
        | "auto_cancel"
        | "user_cancel"
        | "tap_outside"
        | "issuing_failed"
    isDismissedMoment: () => boolean
    getDismissedReason: () =>
        | "credential_returned"
        | "cancel_called"
        | "flow_restarted"
    getMomentType: () => "display" | "skipped" | "dismissed"
}
interface Window {
    google?: {
        accounts: {
            id: {
                initialize: (input: IdConfiguration) => void
                prompt: (
                    momentListener?: (res: PromptMomentNotification) => void
                ) => void
                renderButton: (
                    parent: HTMLElement,
                    options: GsiButtonConfiguration
                ) => void
                disableAutoSelect: Function
                storeCredential: Function<{
                    credentials: { id: string; password: string }
                    callback: Function
                }>
                cancel: () => void
                onGoogleLibraryLoad: Function
                revoke: Function<{
                    hint: string
                    callback: Function<{ successful: boolean; error: string }>
                }>
            }
        }
    }
}

これで Sign In With Google ボタンを表示し、ログイン後にバックエンドに idToken を投げることができる。

バックエンド

バックエンドで idToken を取り扱いたいのだが、残念ながら公式サイトの

には Go の実装サンプルが無い上、Go のライブラリを見てもどれを使えばいいのかさっぱりわからない。

苦労してググった結果下記のパッケージを使えばいいらしいということがわかった。

ということで早速使うのだが、実はゼロから Sign In With Google のガイドに沿って進めていくとサービスアカウントを作成していないためエラーになる。

エラーメッセージで下記ページを読むように言われると思う。

とりあえず今は自分のローカル環境で実装をしているので「認証情報を手動で追加する」のセクションから読み進め、環境変数に認証情報を指定することとする。

私は Docker Compose で環境を構築しているので docker-compose.yml に environment として追加した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
services:
  backend:
    container_name: sololog-back
    build:
      context: ./docker/go
      args:
        - UID=${UID}
        - GID=${GID}
    volumes:
      - type: bind
        source: ./go
        target: /app
      - type: volume
        source: backend-data
        target: /go
    ports:
      - 8000:8000
    environment:
      GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS}
    command: sh -c "go mod tidy && air && /bin/sh"

.env に下記のようにバックエンドのコンテナ内におけるサービス アカウント キーの json ファイルのパスを指定しておくと読み込める。

UID=1000
GID=1000
GOOGLE_APPLICATION_CREDENTIALS="/app/configs/dev/xxxx.json"

これで idToken の検証ができるようになった。実装は下記のようになる。

main.go

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strconv"

	"github.com/joho/godotenv"
	"github.com/pkg/errors"
	"google.golang.org/api/idtoken"

	ae "github.com/k1350/sololog/internal/errors"
)

type LoginResponse struct {
	Result bool   `json:"result"`
	UserId string `json:"user_id"`
	Name   string `json:"name"`
}

func main() {
	http.HandleFunc("/login", loginHandler)
	http.ListenAndServe(":8000", nil)
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method == "POST" {
		res, err := login(w, r)
		if err != nil {
			log.Printf("%+v", err)
		}
		body, err := json.Marshal(res)
		if err != nil {
			err = errors.Wrap(ae.MarshalJsonError, err.Error())
			log.Printf("%+v", err)
			w.WriteHeader(http.StatusInternalServerError)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.Write(body)
		return
	}
	w.WriteHeader(http.StatusMethodNotAllowed)
}

func login(w http.ResponseWriter, r *http.Request) (LoginResponse, error) {
	errRes := LoginResponse{
		Result: false,
		UserId: "",
	}

	err := godotenv.Load(".env")
	if err != nil {
		return errRes, ae.LoadEnvError
	}

	googleClientId := os.Getenv("GOOGLE_CLIENT_ID")

	if r.Header.Get("Content-Type") != "application/json" {
		return errRes, ae.ContentTypeError
	}

	length, err := strconv.Atoi(r.Header.Get("Content-Length"))
	if err != nil {
		err = errors.Wrap(ae.ContentLengthError, err.Error())
		return errRes, err
	}

	body := make([]byte, length)
	length, err = r.Body.Read(body)
	if err != nil && err != io.EOF {
		err = errors.Wrap(ae.ReadBodyError, err.Error())
		return errRes, err
	}

	var jsonBody map[string]interface{}
	err = json.Unmarshal(body[:length], &jsonBody)
	if err != nil {
		err = errors.Wrap(ae.UnmarshalJsonError, err.Error())
		return errRes, err
	}

	credential := jsonBody["credential"].(string)

	ctx := context.Background()
	tokenValidator, err := idtoken.NewValidator(ctx)
	if err != nil {
		err = errors.Wrap(ae.CreateIdtokenValidatorError, err.Error())
		return errRes, err
	}

	payload, err := tokenValidator.Validate(ctx, credential, googleClientId)
	if err != nil {
		err = errors.Wrap(ae.ValidateIdtokenError, err.Error())
		return errRes, err
	}

	userId := payload.Claims["sub"]
	name := payload.Claims["name"]

	res := LoginResponse{
		Result: true,
		UserId: userId.(string),
		Name:   name.(string),
	}
	return res, nil
}

参考文献

以上