0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Google OAuth 2.0を基盤としたOIDCをハンズオンする

Last updated at Posted at 2025-10-14

この記事で分かること

  • OAuthは認可の仕組みであって、認証の仕組みではないと言うこと
  • OpenID Connect(OIDC)はOAuthに認証のための情報伝達を追加し、認証処理を可能にしたもの
  • Google OAuth2.0でOpenID Connect(OIDC)を実現する方法
  • scopeについて
  • openid単体ではユーザーの認証は保証できるが、どのアカウントであるかはクライアントは分からない
  • scopeにemail,profileを追加し、アクセストークンでユーザー情報を取得する必要がある

背景

こちらの記事でGoogle OAuth2.0でOIDCを実現できることを知り、実際にハンズオンをしてみます。

以下を参考に進めます。

OpenID Connect(OIDC)とは

リソース所有者
とはユーザー本人

保護対象リソース
ユーザーのデータを持っているサービス

認可サーバー

  • 保護対象リソースに信頼されているサーバー
  • 保護対象リソースへ制限したアクセスをする為のクレデンシャル(アクセストークン)をクライアントに発行する
    クライアント:ユーザーのデータ(リソース)を使いたいアプリのこと

まずOAuthは認可の仕組みであり、認証の仕組みではありません。
認可サーバーから発行されるアクセストークンでは認証で確認すべき以下を確認できません。

  • リソース所有者が誰であるか
  • そのクライアントに存在するのか

アクセストークンは保護対象リソースのAPIにアクセスする為のものです。

以下がOAuthの仕組みを図にしたものです。

image.png

こちらの記事にも記載しましたのでご覧ください🙏
https://qiita.com/yamatai12/items/8e95410ebf74baf74315

OpenID Connect(OIDC)はOAuthに認証のための情報伝達を追加し、認証処理を可能にしたものです。

OAuthとOpenID Connect(OIDC)両者の対応関係は以下の通りです。

OAuth OpenID Connect (OIDC) 説明
リソース所有者 エンドユーザー 自分の情報を持つ本人
クライアント リライングパーティ (RP) OpenIDプロバイダ(Googleなど)に認証を委ねるアプリ
認可サーバー アイデンティティプロバイダ (IdP,OpenIDプロバイダー,OPとも言う) 認証とトークン発行を行うサーバー
保護対象リソース UserInfoエンドポイント ユーザー情報を持つAPI(RPが認証されたユーザーを特定する為に必要なもの)

image.png

次にGoogle OAuth 2.0でOpenID Connect(OIDC)を実現する手順を説明します。
サービス(クライアント)側はGoで実装します。

今回実装したものは以下です。

OAuth 2.0の設定

以下でOAuthクライアントIDを作成します。

これを保存すると、クライアントIDとクライアントシークレットが発行されるのでそれを控えておきます。

サービス(クライアント、リライングパーティ)側の設定

サービス側のユーザー認証では、Google OAuth2.0からIDトークンを取得し、IDトークンの検証を行います。
その検証が問題なければユーザーの認証が完了します。

ユーザー認証とIDトークン取得で最も一般的に使用されるアプローチは以下があります。

  • サーバーフロー(一般的に言うと認可コードによる付与方式
  • 暗黙的フロー

サーバーフローでは、アプリケーションのバックエンドサーバーが、ブラウザやモバイルデバイスを使用する人物の身元を確認できます。

暗黙的フローは、クライアントサイドアプリケーションがバックエンドサーバーを経由せず直接APIにアクセスする必要がある場合に使用されます。

暗黙的フローはフロントエンドでIDトークンを扱う為セキュリティをより考慮する必要があります。

今回はサーバーフロー(認可コードによる付与方式)の実装方法について説明します。

認可コードによる付与方式の実装をする

CSRFトークンを生成する

CSRF(クロスサイトリクエストフォージェリ)について
ウェブサイトにログインした利用者が、悪意のある人が用意した罠により、利用者が予期しないリクエスト処理を実行させられてしまうこと

https://www.ipa.go.jp/security/vuln/websecurity/csrf.html

サービスのサーバーとクライアントサイド(ユーザー)間の状態を保持する一意のセッショントークンを作成します。
このトークンは、後でGoogle OAuthログインサービスから返される認証レスポンスと照合され、リクエストを行っているのがユーザーであり、悪意のある攻撃者ではないことを確認します。

トークンとして適しているのは、乱数ジェネレータを使用して作成された30文字程度の文字列だそうです。

CSRFトークンを生成する関数

// stateの生成(CSRF対策)
func generateState() string {
	b := make([]byte, 30)
	_, err := rand.Read(b) //暗号学的に安全な乱数を生成して書き込む
	if err != nil {
		// エラー処理
		return ""
	}
	return base64.URLEncoding.EncodeToString(b)
}

crypto/randを使用しました。

Googleに認証リクエストを送信する

基本的なリクエストの場合は、次のパラメータを指定します。

  • client_id

    • OAuthクライアントとして機能するアプリを一意に表すもの(GCPのConsoleから取得可能)
  • response_type

    • どの種類のレスポンスをクライアントが認可エンドポイントから受け取るか決めるもの
  • scope

    • トークンをリクエストする際にクライアントが使える権限のこと
    • 基本的にscopeは「openid email」となる(openid値で始まり、profile値、email値、またはその両方を必ず含める必要がある)

    openid → 「この人がGoogleで認証済み」であることを保証
    email → 「この人がどのGoogleアカウント(メール)か」を知るために必要

  • redirect_uri

    • Google OAuthから認可済みの場合のリダイレクトURIのこと
    • GCPコンソールのクライアントのページにある「承認済みのリダイレクトURI」と同じ値にする必要がある
  • state

    • CSRFトークンのこと
  • nonce

    • クライアントによって生成されるランダムな値
    • リプレイ攻撃を防ぐことができる
  • login_hint

    • クライアントが認証対象のユーザーを特定している場合、このパラメータを認証サーバーへのヒントとして提供する
    • これによりOAuth同意画面でアカウント選択画面が表示されなくなり、サインインフォームのメールアドレス欄が事前入力されるか、適切なセッションが選択される
  • hd

    • 特定のGoogle WorkspaceまたはCloud組織に関連付けられたドメインのユーザー向けにOpenID Connectフローを最適化するパラメーター

リプレイ攻撃とは
正規のユーザーとサーバーの通信を傍受して再度その通信を行い不正なアクセスをする攻撃です
https://act1.co.jp/column/0303-2/

コンソールでopenidのスコープにチェックを入れます。

image.png

Googleに認証リクエストを送信する関数

// Google OAuth2 Config
var (
	googleOAuth2Config = &oauth2.Config{
		ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
		ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
		RedirectURL:  os.Getenv("GOOGLE_REDIRECT_URL"),
		Scopes: []string{"openid",},
		Endpoint: google.Endpoint,
	}
)

func (s *Server) RegisterRoutes() http.Handler {
	mux := http.NewServeMux()

	// Register routes
	mux.HandleFunc("/auth", s.handleGoogleAuth)

	// Wrap the mux with CORS middleware
	return s.corsMiddleware(mux)
}

func (s *Server) handleGoogleAuth(w http.ResponseWriter, r *http.Request) {
	state := generateState()
	http.SetCookie(w, &http.Cookie{
		Name:     "oauthstate",
		Value:    state,
		Path:     "/",
		HttpOnly: true,
		Secure:   false,
		MaxAge:   300,
	})
	url := googleOAuth2Config.AuthCodeURL(state, oauth2.AccessTypeOffline)
	http.Redirect(w, r, url, http.StatusFound)
}

実際のGoogleへの認証リクエストのクエリパラメータは以下でした。

スクリーンショット 2025-10-15 0.15.49.png

サーバー側でのCSRFトークンの確認

サーバー側では、Googleからのレスポンスにあるstateがクライアント側で作成したstate(CSRFトークン)と一致することを確認する必要があります。
この検証により、悪意のあるスクリプトではなくユーザー自身がリクエストを行っていることを確認できます。

codeをアクセストークンとIDトークンに交換する

codeはサーバーがアクセストークンとIDトークンと交換できるワンタイム認証コードです。
サーバーはこの交換をPOSTリクエストを送信することで行います。
POSTリクエストはトークンエンドポイントに送信されます。

oauth2というライブラリでcodeをトークンに交換する処理を実装できます。

IDトークンを検証する

アイデンティティプロバイダから受け取ったIDトークンは必ずクライアント側で検証する必要があります(Googleから直接受け取ったものでない限り、信頼してはいけないです)。

IDトークンはJWT (JSON Web Token) 形式で発行されます。
署名・検証・ペイロード構造などの詳細は RFC 7519 (JSON Web Token) に定義されています。
JWTの構造や検証方法については別途記事にまとめたいと思います。

以下を検証します。

  • トークンが正しく署名されていること

    • jwks_uri(Discovery Document内に記載)から取得した公開鍵で署名を検証する
    • Google発行のトークンであることを確認すること
  • iss(発行者)がhttps://accounts.google.com または accounts.google.com であること

  • aud(受信者)が自アプリのClientIDと一致すること

  • exp(有効期限)が期限切れでないこと

Google公式ライブラリのidtoken は内部で署名・iss・aud・exp の検証をすべて行います。

ユーザーが同意した後にGoogleからのリダイレクト受け取り、トークン取得し検証をする関数が以下です。

// /callback: Googleからのリダイレクト受け取り、トークン取得
func (s *Server) handleGoogleCallback(w http.ResponseWriter, r *http.Request) {
	state := r.URL.Query().Get("state")
	code := r.URL.Query().Get("code")
	if state == "" || code == "" {
		http.Error(w, "state or code missing", http.StatusBadRequest)
		return
	}
	cookie, err := r.Cookie("oauthstate")
	if err != nil || cookie.Value != state { //リクエストのstate値とクッキーのstate値が一致するかを確認(CSRF対策)
		http.Error(w, "invalid state", http.StatusBadRequest)
		return
	}
	token, err := googleOAuth2Config.Exchange(context.Background(), code)
	if err != nil {
		log.Printf("token exchange error: %v", err)
		http.Error(w, "token exchange failed", http.StatusInternalServerError)
		return
	}

	idToken, ok := token.Extra("id_token").(string)
	if !ok {
		log.Print("id_token not found")
		http.Error(w, "id_token not found", http.StatusInternalServerError)
		return
	}
    //本番ではDBに保存するなどの対応が必要
	http.SetCookie(w, &http.Cookie{
		Name:     "id_token",
		Value:    idToken,
		Path:     "/",
		HttpOnly: true,
		Secure:   false,
		MaxAge:   300,
	})
	http.SetCookie(w, &http.Cookie{
		Name:     "access_token",
		Value:    token.AccessToken,
		Path:     "/",
		HttpOnly: true,
		Secure:   false,
		MaxAge:   300,
	})

	// IDトークンの検証
	ctx := context.Background()
	_, err = idtoken.Validate(ctx, idToken, googleOAuth2Config.ClientID)
	if err != nil {
		log.Printf("ID Token validation failed: %v", err)
		http.Error(w, "ID Token validation failed", http.StatusUnauthorized)
		return
	}

	// 検証結果を表示
	w.WriteHeader(http.StatusOK)
	fmt.Fprintf(w, "✅ IDトークンの検証に成功しました")
}

実際にOAuth同意画面に遷移する

http://localhost:8000/authに遷移すると以下が表示されます。

https___qiita-image-store.s3.ap-northeast-1.amazonaws.com_0_405182_9b06b1fa-a72b-4675-a7d9-a6e0d1cd9dfbのコピー.png

アカウントをクリックすると以下が表示されます。

https___qiita-image-store.s3.ap-northeast-1.amazonaws.com_0_405182_0a493040-4076-45b5-aeb2-47e52d79b737のコピー.png

次へを選択すると以下にリダイレクトされます。
http://localhost:8000/auth/callback?state=~~~&scope=openid&authuser=0&prompt=none

表示されるのは以下です(IDトークンの検証が正常に終了しました)。

image.png

これでクライアントにユーザーがGoogleで認証済みであることを保証しました。
しかし、クライアントは認証されたユーザーがどのGoogleアカウントなのかまではわかりません。
そこで次にGCPコンソール、ソースを修正します。

scopeを追加してUserInfoエンドポイントからprofileとemailを取得する

image.png

以下2つを追加します。
.../auth/userinfo.email
.../auth/userinfo.profile

Configを以下のように修正します。

// Google OAuth2 Config
var (
	googleOAuth2Config = &oauth2.Config{
		ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
		ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
		RedirectURL:  os.Getenv("GOOGLE_REDIRECT_URL"),
		Scopes: []string{
			"openid",
			"https://www.googleapis.com/auth/userinfo.email",//←追加
			"https://www.googleapis.com/auth/userinfo.profile",//←追加
		},
		Endpoint: google.Endpoint,
	}
)

userinfoエンドポイントからユーザー情報を取得する処理を追加します。

// /callback: Googleからのリダイレクト受け取り、トークン取得
func (s *Server) handleGoogleCallback(w http.ResponseWriter, r *http.Request) {
//~~~~~~~~~~略

//~~~~~~~~~~
	userInfo, err := fetchUserInfo(token)
	if err != nil {
		log.Printf("Failed to fetch user info: %v", err)
		http.Error(w, "Failed to fetch user info", http.StatusInternalServerError)
		return
	}
	userInfoJSON, _ := json.MarshalIndent(userInfo, "", "  ")
	fmt.Fprintf(w, "\nUser Info:\n%s", userInfoJSON)
}

// userinfoエンドポイントの呼び出し
func fetchUserInfo(token *oauth2.Token) (map[string]interface{}, error) {
	client := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(token))
	resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo")
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var userInfo map[string]interface{}
	if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
		return nil, err
	}
	return userInfo, nil
}

OAuth同意画面に遷移する(UserInfoエンドポイントからユーザー情報を取得する)

https___qiita-image-store.s3.ap-northeast-1.amazonaws.com_0_405182_61826cd4-0723-4da1-8e60-f9bc57bbffb0のコピー.png

次へをクリックすると以下にリダイレクトされます。
http://localhost:8000/auth/callback?state=~~&scope=email+profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+openid&authuser=0&prompt=consent

以下が表示されてユーザー情報が確認できます。
これによりクライアント(リライングパーティ)はこのユーザーがどのGoogleアカウントなのか特定できます。

✅ IDトークンの検証に成功しました
User Info:
{
  "email": "----@gmail.com",
  "email_verified": true,
  "family_name": "T~~",
  "given_name": "Y~~",
  "name": "Y~~ T~~ (~~)",
  "picture": "https://lh3.googleusercontent.com/a/~~",
  "sub": "````"
}

まとめ

  • GCPを利用してクライアント(リライングパーティ)を自前で実装してOIDCの構築ができました
  • scopeについても整理できました

openid単体ではユーザーの認証は保証できるが、どのアカウントであるかはクライアントは分からない。
scopeにemail,profileを追加し、アクセストークンでユーザー情報を取得する必要がある。

  • IDトークンの検証、JWTについて深ぼれなかったので今度調査したいと思います

参考

他にもOAuthやOIDC関連の記事を書いています
興味のある方はぜひこちらもご覧ください🙌

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?