🔐

Auth0 で Workload Identity 連携

2023/06/08に公開

Cloud Datastore などの Google Cloud リソースにアクセスする際にサービスアカウントを作成することがあります。

サービスアカウントはリソースにアクセスする際に様々な認証方法を使用することができます。

そのうちのひとつがサービスアカウントキーで、これはユーザーがユーザー名とパスワードを使って認証するのと同じような方法です。

サービスアカウントキーは JSON ファイルとして Google Cloud からダウンロードして使用しますが、このファイルは機密情報で、扱いには注意が必要です。

また、サービスアカウントキーには有効期限がなく、キーさえあれば誰でもリソースにアクセスできてしまいます 😇

そのため、サービスアカウントキーの使用はセキュリティリスクが高く、推奨されていません


Workload Identity 連携 は、サービスアカウントの別の認証方法のひとつです。

この方法では、サービスアカウントキーのような機密情報の入ったファイルを保存することはありません。

代わりに、外部の ID プロバイダ (IdP) によって発行された認証情報を使用してリソースにアクセスをします。

使用できる IdP としては AWS や Azure などがありますが、 OpenID Connect (OIDC) や SAML をサポートする任意の IdP も使用できます。

Auth0 は OIDC をサポートする IdP で、
SNS ログイン、パスワードレス認証、多要素認証など、様々な機能が使用できます。

この記事では、 Auth0 を用いて Workload Identity 連携を実現する方法を紹介します。

認証の流れ

認証の流れを簡単に図にすると以下のようになります。

Diagram

まず、クライアントが Auth0 の Client ID と Client Secret を使って Auth0 の認証 URL にアクセスします。

Auth0 での認証が成功すると、クライアントに ID トークンが渡されます。

クライアントは受け取った ID トークンを使って、 Google Cloud リソースにアクセスすることができます。

今回の実装では、認証のために一時的にサーバを立てて、認証が終わったらサーバを落とし、
Cloud クライアントライブラリを用いて Google Cloud リソースにアクセスするアプリの処理が続くという形になっています。

次のセクションから、具体的な設定方法について説明します。

Auth0 での設定

例として、 Go と Echo で実装をする場合のコードを載せています。

基本的には Auth0 公式の docs のとおりに進めていきます。

ダッシュボードでの設定

Auth0 のダッシュボードの Applications ページから、Create Application を押して、
新しいアプリケーションを作成します。

アプリケーションの種類を選択できますが、今回は Regular Web Applications を選択します。

Auth0 - Create application

アプリケーションを作成できたら、 Settings > Application URIs から Allowed Callback URLs を設定します。

この URL は、 Auth0 の認証が成功した際にリダイレクトする URL となります。

Auth0 - Application Callback URLs

また、安全のため Advanced Settings > Grant Types から、 Authorization Code 以外のチェックをすべて外しておきます。

Grant Types

初期設定では ON になっていますが、もし Client Credentials にチェックが入っていると、
Client ID と Client Secret を用いて、認証せずとも Google Cloud のリソースにアクセスできるアクセストークンを取得できてしまいます。

これでは Client ID と Client Secret がサービスアカウントキーと同じ状態になってしまうので望ましくありません。

ダッシュボード上での設定は以上となります。

実装

流れとしては、

  • OAuth2 & OpenID Connect の設定をする
  • Auth0 の認証 URL にリダイレクトし、認証をする
  • 認証が成功したら Callback URL にリダイレクトする
  • 取得した ID トークンを一時ファイルに保存する

となります。

次のセクションで後述しますが、Cloud クライアントライブラリが認証構成ファイルをもとにして ID トークンが記述されたファイルを読み込むため、
一時ファイルに保存する必要があるようです。

環境変数を設定する

Client ID と Client Secret は、 Application の Settings > Basic Information から取得します。

app/internal/constants/constants.go
package constants

...

const (
	AUTH_URL = "https://app.jp.auth0.com/"
	PORT     = "3000"
)

var (
	AUTH0_CLIENT_ID     = os.Getenv("AUTH0_CLIENT_ID")
	AUTH0_CLIENT_SECRET = os.Getenv("AUTH0_CLIENT_SECRET")
	AUTH0_CALLBACK_URL  = fmt.Sprintf("http://localhost:%s/callback", PORT)
)
OAuth2 & OpenID Connect の設定をする

OAuth2 & OpenID Connect クライアントを返す関数と、ID トークンを検証する関数を定義します。

app/internal/auth/authenticator/authenticator.go
package authenticator

import (
	"context"
	"fmt"
	"app/internal/constants"

	"github.com/coreos/go-oidc/v3/oidc"
	"golang.org/x/oauth2"
)

// Authenticator is used to authenticate our users.
type Authenticator struct {
	*oidc.Provider
	oauth2.Config
}

// New instantiates the *Authenticator.
func New() (*Authenticator, error) {
	provider, err := oidc.NewProvider(context.Background(), constants.AUTH_URL)
	if err != nil {
		return nil, fmt.Errorf("Authenticator.New: %w", err)
	}

	conf := oauth2.Config{
		ClientID:     constants.AUTH0_CLIENT_ID,
		ClientSecret: constants.AUTH0_CLIENT_SECRET,
		RedirectURL:  constants.AUTH0_CALLBACK_URL,
		Endpoint:     provider.Endpoint(),
		Scopes:       []string{oidc.ScopeOpenID},
	}

	return &Authenticator{
		Provider: provider,
		Config:   conf,
	}, nil
}

// VerifyIDToken verifies that an *oauth2.Token is a valid *oidc.IDToken.
func (a *Authenticator) VerifyIDToken(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) {
	oidcConfig := &oidc.Config{
		ClientID: a.ClientID,
	}

	return a.Verifier(oidcConfig).Verify(ctx, rawIDToken)
}
サーバまわりの処理をかく

認証用にサーバを立て、認証が終わったらサーバを閉じるという処理を書きます。

サーバの起動を goroutine で実行し、認証の完了を待ちます。

認証が完了した後に、サーバを閉じます。

app/internal/auth/server/server.go
package server

import (
	"context"
	"fmt"
	"net/http"
	"app/internal/auth/authenticator"
	"app/internal/auth/handler"
	"app/internal/constants"
	"time"

	"github.com/gorilla/sessions"
	"github.com/labstack/echo-contrib/session"
	"github.com/labstack/echo/v4"
	"github.com/labstack/gommon/log"
)

func RunServer(auth *authenticator.Authenticator) {
	authenticated := make(chan bool, 1)

	// Setup
	e := echo.New()

	e.HideBanner = true
	e.HidePort = true

	loginURL := fmt.Sprintf("http://localhost:%s", constants.PORT)
	fmt.Printf("➡️ Visit \x1b[4m%s\x1b[m to authenticate.\n", loginURL)

	e.Use(session.Middleware(sessions.NewCookieStore([]byte("secret"))))

	e.GET("/", handler.HandleLogin(auth))
	e.GET("/callback", handler.HandleCallback(auth, authenticated))

	// Start server
	addr := fmt.Sprintf(":%s", constants.PORT)
	go func() {
		if err := e.Start(addr); err != nil && err != http.ErrServerClosed {
			e.Logger.Fatal(err)
		}
		e.Logger.Info("shutting down the server")
	}()

	// Wait for authentication completion
	<-authenticated

	// Shutdown the server
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := e.Shutdown(ctx); err != nil {
		e.Logger.Fatal(err)
	}
}
Auth0 の認証 URL にリダイレクトし、認証をする

ログイン URL にアクセスしたときの処理を書きます。

ここでは、state という値をセッションに保存し、 Auth0 の認証 URL にリダイレクトします。

state はランダムな文字列で、 Callback URL に戻ったときに同じ値が渡されることをチェックします。

app/internal/auth/handler/login.go
package handler

import (
	"crypto/rand"
	"encoding/base64"
	"fmt"
	"net/http"
	"app/internal/auth/authenticator"

	"github.com/labstack/echo-contrib/session"
	"github.com/labstack/echo/v4"
)

func HandleLogin(auth *authenticator.Authenticator) echo.HandlerFunc {
	return func(c echo.Context) error {
		state, err := generateRandomState()
		if err != nil {
			return c.String(http.StatusInternalServerError, err.Error())
		}

		// Save the state inside the session.
		sess, err := session.Get("state", c)
		if err != nil {
			return c.String(http.StatusInternalServerError, err.Error())
		}

		sess.Values["state"] = state
		if err := sess.Save(c.Request(), c.Response()); err != nil {
			return c.String(http.StatusInternalServerError, err.Error())
		}

		return c.Redirect(http.StatusTemporaryRedirect, auth.AuthCodeURL(state))
	}
}

func generateRandomState() (string, error) {
	b := make([]byte, 32)
	_, err := rand.Read(b)
	if err != nil {
		return "", fmt.Errorf("generateRandomState: %w", err)
	}

	state := base64.StdEncoding.EncodeToString(b)

	return state, nil
}

このエンドポイント (localhost:3000) にアクセスすると、 Auth0 の認証画面が表示されます。

Auth0 login page

ここで使用する認証方法 (パスワードレス認証、多要素認証など) は Auth0 のダッシュボードから設定できます。

認証が成功したら Callback URL にリダイレクトする

Auth0 の画面での認証が成功すると、 Callback URL にリダイレクトします。

Callback URL にアクセスしたときの処理を書きます。

ここでは state と ID トークンの検証を行ったあとに、ID トークンをファイルに保存します。

また、認証が完了したことを channel に通知します。

app/internal/auth/handler/callback.go
package handler

import (
	"net/http"
	"app/internal/auth/authenticator"
	"app/internal/auth/token"
	"app/internal/constants"

	"github.com/labstack/echo-contrib/session"
	"github.com/labstack/echo/v4"
)

func HandleCallback(auth *authenticator.Authenticator, authenticated chan<- bool) echo.HandlerFunc {
	return func(c echo.Context) error {
		sess, err := session.Get("state", c)
		if err != nil {
			return c.String(http.StatusInternalServerError, err.Error())
		}

		if c.QueryParam("state") != sess.Values["state"] {
			return c.String(http.StatusBadRequest, "Invalid state parameter.")
		}

		// Exchange an authorization code for a token.
		tk, err := auth.Exchange(c.Request().Context(), c.QueryParam("code"))
		if err != nil {
			return c.String(http.StatusUnauthorized, "Failed to exchange an authorization code for a token.")
		}

		rawIDToken, ok := tk.Extra("id_token").(string)
		if !ok {
			return c.String(http.StatusBadRequest, "No id_token field in oauth2 token.")
		}

		_, err = auth.VerifyIDToken(c.Request().Context(), rawIDToken)
		if err != nil {
			return c.String(http.StatusInternalServerError, "Failed to verify ID Token.")
		}

		if err := token.SaveTokenToFile(rawIDToken); err != nil {
			return c.String(http.StatusInternalServerError, "Failed to save access token.")
		}

		authenticated <- true

		return c.String(http.StatusOK, "Authentication succeeded!")
	}
}

注意点として、auth.Exchange によって得られた tk から tk.AccessToken を取得できるのですが、
これが JWE 形式のトークンで、 Cloud クライアントライブラリが使用するトークンの要件を満たさないため動作しません。

Cloud クライアントライブラリ用に使用するのは tk.Extra("id_token") から取得した ID トークンとなります。

取得した ID トークンを一時ファイルに保存する

ID トークンをファイルに保存したり削除したりする関数を定義します。

tokenFilePath のパスは、 GCP 側で設定する際に同じものを指定します。

app/internal/auth/token/token.go
package token

import (
	"fmt"
	"os"
)

const tokenFilePath = "./tmp/app-token"

func SaveTokenToFile(token string) error {
	f, err := os.Create(tokenFilePath)
	if err != nil {
		return fmt.Errorf("SaveTokenToFile: %w", err)
	}
	defer f.Close()

	_, err = f.WriteString(token)
	if err != nil {
		return fmt.Errorf("SaveTokenToFile: %w", err)
	}

	return nil
}

func WithToken(f func() error) error {
	if err := f(); err != nil {
		if rmErr := removeToken(); rmErr != nil {
			return fmt.Errorf("WithToken: %w\nWithToken: %w", err, rmErr)
		}
		return fmt.Errorf("WithToken: %w", err)
	}

	if err := removeToken(); err != nil {
		return fmt.Errorf("WithToken: %w", err)
	}

	return nil
}

func removeToken() error {
	if err := os.Remove(tokenFilePath); err != nil {
		return fmt.Errorf("removeToken: %w", err)
	}
	return nil
}

GCP での設定

こちらも基本的には公式のドキュメントに従います。

コンソールでの設定

API を有効化する

まずは必要な API を有効化します。

必要な API については公式のドキュメントを参照してください。

Workload Identity プールを作成する

次に IAM と管理 > Workload Identity 連携 から プールを作成 を押します。

画面の指示に従って Workload Identity プールを作成します。

プロバイダの設定では、プロバイダとして OpenID Connect (OIDC) を選択し、
発行元は Auth0 の認証 URL を指定します。

オーディエンスは許可するオーディエンスとして、 Auth0 の Client ID を指定します。

Create Workload Identity pool

属性のマッピングでは、google.subject = assertion.sub と指定します。

サービスアカウントを作成する

IAM と管理 > サービス アカウント から CREATE SERVICE ACCOUNT を押します。

ロールの選択では、Workload Identity ユーザーと、使用したいリソースへのアクセス権をもつロール (Cloud Datastore ユーザー など) を指定します。

Service account roles

Workload にサービスアカウントの権限を許可する

IAM と管理 > Workload Identity 連携 からさきほど作成したプールを選択し、アクセスを許可 を押します。

さきほど作成したサービスアカウントを指定し、保存を押します。

ダイアログが表示されますので、 OIDC ID tokenのパス には ID トークンを保存するファイルのパス (./tmp/app-token) を指定し、
フォーマット タイプ にはテキストを指定します。

Download configuration file

構成をダウンロード を押すと JSON ファイルがダウンロードされるので、
これをプロジェクトに置きます。

このファイルにはサービスアカウントキーと違って機密情報が含まれていません!🔒

コンソール上での設定は以上となります。

実装

環境変数 GOOGLE_APPLICATION_CREDENTIALS に、次のように構成情報ファイルへのパスを指定しておくと、
Cloud クライアントライブラリは特にコード内で指定しなくてもデフォルト認証情報としてそのファイルを見てくれます。

GOOGLE_APPLICATION_CREDENTIALS=clientLibraryConfig-app.json

以下には例として、 Cloud Datastore にアクセスする処理を書いています。

app/cmd/main.go
package main

import (
	"context"
	"fmt"
	"log"
	"app/internal/auth/authenticator"
	"app/internal/auth/server"
	"app/internal/auth/token"
	"app/internal/constants"

	"cloud.google.com/go/datastore"
)

func main() {
	if err := authenticate(); err != nil {
		log.Fatal(err)
	}

	if err := token.WithToken(run); err != nil {
		log.Fatal(err)
	}
}

func authenticate() error {
	auth, err := authenticator.New()
	if err != nil {
		return fmt.Errorf("authenticate: %w", err)
	}

	server.RunServer(auth)

	return nil
}

func run() error {
	ctx := context.Background()
	client, err := datastore.NewClient(ctx, constants.PROJECT_ID)
	if err != nil {
		return nil, fmt.Errorf("run: %w", err)
	}
	defer client.Close()
  
	// Some actions for Datastore

	return nil
}

以上で、 Auth0 を用いた Workload Identity 連携ができました!

これで、サービスアカウントキーを管理するコストを省きつつ、アプリケーションの安全性を高めることができます。

この記事が参考になったらうれしいです。

ではまた 👋

Discussion