バックエンド側で Sign In With Google を実装する

この記事は [LinuxClub Advent Calendar 2020](https://qiita.com/advent-calendar/2020/linuxclub) の 24 日目の記事です.　~~遅刻しました…~~
タイトル通り, バックエンド側で Sign In With Google を実装し, メールアドレスを取得するまでのお話です.

# 背景
個人的な案件で Google のアカウントでログインするという機能を実装する必要があったのと, `golang.org/x/oauth2` ではできない部分があったので, その部分を自分で実装しました.

環境は Go 1.15.5 で行っています.

## `golang.org/x/oauth2` でできなかった部分
詳しいことはライブラリのドキュメント[^1]や, ソースコードを見ていただければわかるんですが, `golang.org/x/oauth2` では, ライブラリで得る `oauth2.Token` 構造体が以下の形になっております:

```go
type Token struct {
	// AccessToken is the token that authorizes and authenticates
	// the requests.
	AccessToken string `json:"access_token"`

	// TokenType is the type of token.
	// The Type method returns either this or "Bearer", the default.
	TokenType string `json:"token_type,omitempty"`

	// RefreshToken is a token that's used by the application
	// (as opposed to the user) to refresh the access token
	// if it expires.
	RefreshToken string `json:"refresh_token,omitempty"`

	// Expiry is the optional expiration time of the access token.
	//
	// If zero, TokenSource implementations will reuse the same
	// token forever and RefreshToken or equivalent
	// mechanisms for that TokenSource will not be used.
	Expiry time.Time `json:"expiry,omitempty"`

	// contains filtered or unexported fields

}
```

ドキュメントからの直接抜き出したものですが, 得るものは `AccessToken`, `TokenType`, `RefreshToken`, `Expiry` の 4 つのみです. このため, メールアドレスの取得だけとなると, これらの情報だけだとアクセストークンを使って API を叩くといった無駄な処理が増えることとなります. 今回は, その部分を色々やりましょうといった形です.
そこらへんの詳細は実装のところで書きます.

# 実装
全体的なコードは [GitHub](https://github.com/KashEight/advent-oauth2) にあげているので, 足りなかった場合はそちらも参照してください.

ファイル構造は以下のようになってます. 主に `callback.go` の実装がメインとなります.

```
.
├── callback.go
├── config.go
├── go.mod
├── go.sum
├── main.go
└── signin.go
```

`config.go` では, ClientID などの認証までに必要な情報を置いています.
今回は特に API を叩くことはしないので, スコープは `profile` と `email` だけとなります. またリダイレクト先は `/callback` となるようにしています. Google Developer Console でリダイレクト先の指定やらなんやらも必要なのでそこもやっておいてください.

```go:config.go
package main

import (
	"golang.org/x/oauth2"
	"golang.org/x/oauth2/google"
)

var AuthConfig = &oauth2.Config{
	ClientID:     "<Your ClientID>",
	ClientSecret: "<Your Client Secret>",
	Endpoint:     google.Endpoint,
	Scopes: []string{
		"profile",
		"email",
	},
	RedirectURL: "http://localhost:8080/callback",
}
``` 

`signin.go` では, 認証へのリダイレクトまでの処理です.
認証 URL の生成は `golang.org/x/oauth2` に任せています. 本来は, `AuthConfig.AuthCodeURL()` の引数には CSRF 対策のためにランダムな値を入れ, `callback.go` にて検証を行う必要があります. 今回は単純化のために値は固定です.

参考: https://developers.google.com/identity/protocols/oauth2/openid-connect#state-param

```go:signin.go
package main

import "net/http"

func handlerSignIn(w http.ResponseWriter, r *http.Request) {
	url := AuthConfig.AuthCodeURL("state") // 本来は CSRF 対策でランダムな値
	http.Redirect(w, r, url, 302)
}
```

メインとなる `callback.go` では, リダイレクトから, メールアドレスを取得します.
最初のほうで書いた通り `oauth2.Token` では, 必要な情報は含まれていません. Google の OAuth2 認証では, AccessToken のほか, JWT 形式の ID token も含まれており, そこにメールアドレスが含まれております. なので, ID token を取得するために, `Exchange()` で取得した `oauth2.Token` に対して `Extra("id_token").(string)` とすることで ID token を取得しています. `Extra()` の返り値は `interface{}` 型なので `.(string)` で抜き出しています.
そのあと, JWT をパースしてメールアドレスを取得します. JWT の仕様の説明は省略しますので各自で調べてください. ID token は Base64 でエンコードされているので, ペイロード部分をデコード, メールアドレスを取得します. その部分の処理は `parseJWT()` にて書いております.

```go:callback.go
package main

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net/http"
	"strings"
)

func handlerCallback(w http.ResponseWriter, r *http.Request) {
	queries := r.URL.Query()

	if queries == nil {
		fmt.Fprintf(w, "Invalid URL.")
		return
	}

	fmt.Println("------ Queries ------")

	for k, v := range queries {
		fmt.Println(k, v)
	}

	code := queries.Get("code")

	token, err := AuthConfig.Exchange(context.Background(), code)

	if err != nil {
		fmt.Fprintf(w, "%s", err)
		return
	}

	idToken := token.Extra("id_token").(string)

	fmt.Println("------ ID Token ------")
	fmt.Println(idToken)

	email, err := parseJWT(idToken)

	if err != nil {
		fmt.Fprintf(w, "%s", err)
		return
	}

	fmt.Fprintf(w, "email: %s", email)
}

type jwtData struct {
	Email string `json:"email"`
}

func parseJWT(token string) (string, error) {
	jwt := strings.Split(token, ".")
	payload := strings.TrimSuffix(jwt[1], "=")
	b, err := base64.RawURLEncoding.DecodeString(payload)

	if err != nil {
		return "", fmt.Errorf("failed decoding base64")
	}

	fmt.Println("------ JWT Data ------")
	fmt.Println(string(b))

	jd := &jwtData{}

	if err := json.Unmarshal(b, jd); err != nil {
		return "", fmt.Errorf("failed unmarshal json data (in parseJWT())")
	}

	return jd.Email, nil
}
```

最後に実行部分である `main.go` では `/` にリダイレクト処理, `/callback` にリダイレクト先の処理を加えて, 8080 番ポートで listen します.

```go:main.go
package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/", handlerSignIn)
	http.HandleFunc("/callback", handlerCallback)

	fmt.Println("Listen here: http://localhost:8080")

	log.Fatal(http.ListenAndServe(":8080", nil))
}
```

実行し, `localhost:8080` に接続すると, よく見慣れた認証画面に飛び, `localhost:8080/callback` にて `email: <メールアドレス>` と表示されます. コンソールには, パースした結果などを出力させていますので, どうなっているか確認しやすいと思います.

# まとめ
Google の認証から ID token を取得し, パースしてメールアドレスを取得しました. 実際にやる場合は, CSRF 対策用の state だったり, メールアドレスが認証済みかなどの処理を加えたりする必要があります.

ちなみに明日は, ご注文はうさぎですか？ 9 巻の発売日です. 是非買ってください.

# 余談
当初, `golang.org/x/oauth2` で ID token を取得できないと思っていて, トークン取得の部分も一から書いてたんですが, 記事執筆中にドキュメント読み返したらちゃんとできるようになってました…
ドキュメントはちゃんと読みましょうね…

[^1]: https://pkg.go.dev/golang.org/x/oauth2#Token