この記事はjsys Advent Calendar 2025の17日目の記事です。大遅刻してしまい申し訳ありません。

昨日の記事はtemariさんのGOlang・バトルでした。


私は今年、筑波大学学園祭実行委員会の情報メディアシステム局(jsys)で雙峰祭の企画検索システムの開発を行っていました。そこで、開発を行う際に苦戦したJSON Web Token(JWT)を用いたユーザー認証についての知見を共有すべく本記事を執筆しました。

JWTを用いたユーザー認証

Webサービスにおいてユーザー認証をする際の方式の一つに、JSON Web Token(以下、JWT)を用いた認証方法があります。JWTを用いた認証はトークンベースの認証方法の一つです。トークンベースの認証とは、ユーザーのログイン情報をバックエンドが持つことでログインを管理するのではなく、あらかじめ発行されたトークンを認証が必要なHTTPリクエストに添付して送ることでログイン状態を管理する方法です。

JWTはHTTPリクエストのAuthorizationヘッダーにBearerトークンと共にセットされ、バックエンドに送られます。

JWTの本体

JWTについて分かりやすく解説した記事は数多く存在するため、詳しい説明は割愛します。

本記事を作成するのに参考にしたサイトの一部を以下に挙げます。

JWTは、ヘッダ、ペイロード、署名の3つの部分に分かれており、それぞれの部分が.(ピリオド)で結合されています。以下に、JWTの例を挙げて各部分の役割を説明します。

image.png

緑:ヘッダ,

黒:ペイロード,

青:署名,

以下がヘッダの内容です。署名を行う際のアルゴリズムや、トークンのタイプなどが含まれます。

{
  "alg": "HS256",
  "typ": "JWT"
}

以下がペイロードの内容です。

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true,
  "iat": 1516239022
}

上はペイロードの一例であり、他にも様々なフィールドが存在します。

以下はJWTの予約済みクレーム名の例です。

  • iss: Issuerクレーム、JWTの発行者の識別子
  • sub: Subjectクレーム、JWTによる認証の主体、つまりユーザーの識別子
  • aud: Audienceクレーム、JWTを利用する主体の識別子
  • exp: Expiration timeクレーム、JWTの有効期限を示す
  • iat: Issued atクレーム、JWTが発行された時刻を示す

ヘッダとペイロードの内容をそれぞれBASE64URLエンコードしたものが、JWTのヘッダとペイロードになります。

そして、ヘッダとペイロード(BASE64URLエンコード済み)を、ヘッダのalgに書かれた方式で暗号化し、それをBASE64URLエンコードしたものが署名になります。

署名を復号したものがヘッダ及びペイロードと同一であれば内容が改ざんされていないことが分かるという仕組みです。

Google Oauthを用いたJWTの発行及びGoを用いたバックエンドでのJWT検証

さて、本題に入ります。Google Oauthを用いてJWTを発行し、それをGoで作成したバックエンドに送信することによってユーザー認証を行ってみたいと思います。 ソースコードはこちらからご覧ください

バックエンド

Go言語で実装します。

コードは以下のリポジトリを大いに参考にしました。

https://github.com/sohosai/takoyaki25-server

JWTの検証機構

JWTの検証機構をミドルウェアとして実装します。認証が必要なエンドポイントにこのミドルウェアを設定することで認証を行うことができます。

JWTの検証にはgoogle.golang.org/api/idtokenというライブラリを用いました。

package middlewares

import (
	"log/slog"
	"strings"

	"github.com/gin-gonic/gin"
	"github.com/tomi-saku/jsys25-advent-calender/models"
	"google.golang.org/api/idtoken"
)

func verifyTokenAndGetPayload(c *gin.Context, clientID string) *idtoken.Payload {
	const bearerPrefix = "Bearer "
	AuthHeader := c.GetHeader("Authorization")
	slog.Debug("Auth Middleware", "AuthHeader", AuthHeader)
	if AuthHeader == "" {
		errRes := models.Error{
			Message: "Authorization header is required",
		}
		c.AbortWithStatusJSON(401, errRes)
		return nil
	}
	//Bearerトークンを削除
	if !strings.HasPrefix(AuthHeader, bearerPrefix) {
		errRes := models.Error{
			Message: "Authorization header must be Bearer token",
		}
		c.AbortWithStatusJSON(401, errRes)
		return nil
	}
	token := strings.TrimPrefix(AuthHeader, bearerPrefix)
	slog.Debug("Auth Utility", "Token", token)
	// JWTを検証し、ペイロードを変数に格納
	payload, err := idtoken.Validate(c.Request.Context(), token, clientID)
	if err != nil {
		slog.Debug("failed to Authorize", "error", err)
		errRes := models.Error{
			Message: err.Error(),
		}
		c.AbortWithStatusJSON(401, errRes)
		return nil
	}
	return payload
}

func AuthMiddleware(clientID string) gin.HandlerFunc {
	return func(c *gin.Context) {
		payload := verifyTokenAndGetPayload(c, clientID)
		if payload == nil {
			return
		}
		c.Set("email", payload.Claims["email"])
		c.Set("image", payload.Claims["picture"])
		slog.Info("Auth Middleware", "email", payload.Claims["email"])
		slog.Info("Auth Middleware", "image", payload.Claims["picture"])

		c.Next()
	}
}

ここで、フロントから送られてくるJWTの内容がどのようなものかを見てみたいと思います。

https://pkg.go.dev/google.golang.org/api/idtokenによると、JWTのペイロード(を構造体にバインドしたもの)は以下のような形式になっています。

type Payload struct {
	Issuer   string                 `json:"iss"`
	Audience string                 `json:"aud"`
	Expires  int64                  `json:"exp"`
	IssuedAt int64                  `json:"iat"`
	Subject  string                 `json:"sub,omitempty"`
	Claims   map[string]interface{} `json:"-"`
}

前述の予約クレーム以外にClaims(json:"-")というものがあるのが分かると思います。このフィールド内にGoogleアカウントのユーザー名やメールアドレス、はたまたアイコンの写真のリンクなどの情報がMap形式で格納されています。auth.go

c.Set("email", payload.Claims["email"])
c.Set("image", payload.Claims["picture"])

という箇所でそれらのデータを取り出しています。

バックエンドの本体の実装

ここで、バックエンドのアプリケーション全体やエンドポイントの設定を行います。

package main

import (
	"fmt"
	"net/http"
	"os"
	"time"

	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
	middlewares "github.com/tomi-saku/jsys25-advent-calender/middleware"
	"github.com/tomi-saku/jsys25-advent-calender/models"
)

func main() {
	fmt.Println("Application Initializing...")

	r := gin.Default()

	// CORSポリシーを満たすようにミドルウェアを設定
	r.Use(cors.New(cors.Config{
		AllowOrigins: []string{"http://localhost:5173"},
		AllowMethods: []string{
			"GET",
			"POST",
			"OPTIONS",
			"DELETE",
		},
		AllowHeaders: []string{
			"Content-Type",
			"Authorization",
		},
		AllowCredentials: true,
		MaxAge:           12 * time.Hour,
	}))

	r.GET("/health", getHealth)

	// 特定のエンドポイントにミドルウェアを設定
	authorizedRoutes := r.Group("/authorization")
	authorizedRoutes.Use(middlewares.AuthMiddleware(os.Getenv("GOOGLE_CLIENT_ID")))
	{
		authorizedRoutes.GET("", getEmailAddress)
	}

	fmt.Println("Application Starts!")
	r.Run("0.0.0.0:8080") // listen and serve on 0.0.0.0:8080
}

func getHealth(c *gin.Context) {
	message := models.Message{
		Message: "Hello, World!",
	}
	c.IndentedJSON(http.StatusOK, message)
}

func getEmailAddress(c *gin.Context) {
	email := c.GetString("email")
	image := c.GetString("image")
	res := models.User{
		Email: email,
		Image: image,
	}
	c.IndentedJSON(http.StatusOK, res)
}

上記のコードにおいて、”GOOGLE_CLIENT_ID”という名前の環境変数には後述するクライアントIDを設定します。

また、後々フロントエンドと接続する際にCORS(Cross-Origin Resource Sharing、異なるオリジン間での通信を意味する)という事象絡みの問題が発生するため、別途ミドルウェアを設定して対策しています。CORSはブラウザのセキュリティ機能であるため、curlなどのツールでAPIを叩く際には発生しないので注意してください。

CORSについて、詳しくは以下の記事などを参考にしてください。

フロントエンド

Google Cloudの設定

JWTの発行のためにGoogle Cloudというサービスを利用しました。ここでは、フロントエンドを実装する前にあらかじめGoogle Cloudの設定を行います。

以下のサイトを参考にしました。

  1. 上記サイトのコンソールから新規にプロジェクトを作成します。

image.png

  1. Google Auth Platformに移動し、新しくクライアントを作成します。

image.png

  1. クライアントの各種設定を行います。

image.png

  1. クライアントを作成したのち、クライアントIDとクライアントシークレットを控えておきます。このクライアントIDが前述したGOOGLE_CLIENT_IDの値になります。

フロントエンドの実装

フロントエンドはReact×Viteで実装しました。

また、ログインボタンの実装のために@react-oauth/googleというライブラリを使用し、バックエンドへのHTTPリクエストを行うためにfetchAPIを利用しました。

ログインボタン

import { GoogleLogin, type CredentialResponse } from "@react-oauth/google";
import { type UserData } from "../App";

type GoogleLoginButtonProps = {
  handleValueChange: (data: UserData) => void;
};

const GoogleLoginButton = ({ handleValueChange }: GoogleLoginButtonProps) => {
  // ログイン成功時の処理
  const handleLoginSuccess = (credentialResponse: CredentialResponse) => {
    // バックエンドへのリクエスト
    fetch("http://localhost:8080/authorization", {
      method: "GET",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${credentialResponse.credential}`,
      },
    })
      .then((response) => response.json())
      .then((data: UserData) => {
        console.log("Email adress: ", data.email);
        console.log("Image: ", data.image);
        //親要素へユーザーデータを渡す
        handleValueChange(data);
      })
      .catch((error) => {
        console.log("Error: ", error);
      });
  };
  // ログイン失敗時の処理
  const handleLoginError = () => {
    console.log("Login Failed");
  };

  return (
    <GoogleLogin onSuccess={handleLoginSuccess} onError={handleLoginError} />
  );
};

export default GoogleLoginButton;

アプリケーション全体

import { useState } from "react";
import "./App.css";
import GoogleLoginButton from "./components/login";

export type UserData = {
  email: string;
  image: string;
};

function App() {
  const [userData, setUserData] = useState<UserData | null>(null);

  const getUserData = (newData: UserData) => {
    setUserData(newData);
  };

  if (userData) {
    return (
      <div>
        <img src={userData.image} alt="User profile" />
        <p>ようこそ、{userData.email}さん</p>
      </div>
    );
  } else {
    return (
      <>
        <p>ログインテスト</p>
        <GoogleLoginButton handleValueChange={getUserData} />
      </>
    );
  }
}

export default App;

また、ライブラリを使用するために、アプリケーション全体をGoogleOAuthProviderタグでラップしてください。

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { GoogleOAuthProvider } from "@react-oauth/google";

//GoogleOAuthProviderでアプリケーション全体をラップする
createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}>
      <App />
    </GoogleOAuthProvider>
  </StrictMode>,
);

ここで、VITE_GOOGLE_CLIENT_ID はバックエンドと同様に、Google CloudのクライアントIDを環境変数から取得するようにします。(注:Viteにおいては、環境変数をVITE_から始める必要があります。)

実際にやってみる

バックエンドを実行

cd .\server\
go run main.go

フロントエンドの開発環境を立ち上げ

cd .\client\
npm run dev

こんな感じのが立ち上がり…

image.png

Googleでログインボタンを押すと、ユーザー情報が表示されました。

advent_calender_result.png


おわりに

JWTの認証は独特の内容が多く、理解するのに時間がかかりました。この記事が理解の一助になれば幸いです。また、情報メディアシステム局(jsys)は未経験者も大募集中です。Web開発に興味がある方は今年の夏をjsysで過ごしてみませんか?

(暦上の)明日の記事は、marunyannさんのライブレコーディング用デジタルミキサーでステージ配信をした話です。面白い記事ですので是非読んでみてください。