Auth0 で Workload Identity 連携
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 連携を実現する方法を紹介します。
認証の流れ
認証の流れを簡単に図にすると以下のようになります。
まず、クライアントが 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 を選択します。
アプリケーションを作成できたら、 Settings > Application URIs から Allowed Callback URLs を設定します。
この URL は、 Auth0 の認証が成功した際にリダイレクトする URL となります。
また、安全のため Advanced Settings > Grant Types から、 Authorization Code 以外のチェックをすべて外しておきます。
初期設定では 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.go1package constants23...45const (6AUTH_URL = "https://app.jp.auth0.com/"7PORT = "3000"8)910var (11AUTH0_CLIENT_ID = os.Getenv("AUTH0_CLIENT_ID")12AUTH0_CLIENT_SECRET = os.Getenv("AUTH0_CLIENT_SECRET")13AUTH0_CALLBACK_URL = fmt.Sprintf("http://localhost:%s/callback", PORT)14)
OAuth2 & OpenID Connect の設定をする
OAuth2 & OpenID Connect クライアントを返す関数と、ID トークンを検証する関数を定義します。
app/internal/auth/authenticator/authenticator.go1package authenticator23import (4"context"5"fmt"6"app/internal/constants"78"github.com/coreos/go-oidc/v3/oidc"9"golang.org/x/oauth2"10)1112// Authenticator is used to authenticate our users.13type Authenticator struct {14*oidc.Provider15oauth2.Config16}1718// New instantiates the *Authenticator.19func New() (*Authenticator, error) {20provider, err := oidc.NewProvider(context.Background(), constants.AUTH_URL)21if err != nil {22return nil, fmt.Errorf("Authenticator.New: %w", err)23}2425conf := oauth2.Config{26ClientID: constants.AUTH0_CLIENT_ID,27ClientSecret: constants.AUTH0_CLIENT_SECRET,28RedirectURL: constants.AUTH0_CALLBACK_URL,29Endpoint: provider.Endpoint(),30Scopes: []string{oidc.ScopeOpenID},31}3233return &Authenticator{34Provider: provider,35Config: conf,36}, nil37}3839// VerifyIDToken verifies that an *oauth2.Token is a valid *oidc.IDToken.40func (a *Authenticator) VerifyIDToken(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) {41oidcConfig := &oidc.Config{42ClientID: a.ClientID,43}4445return a.Verifier(oidcConfig).Verify(ctx, rawIDToken)46}
サーバまわりの処理をかく
認証用にサーバを立て、認証が終わったらサーバを閉じるという処理を書きます。
サーバの起動を goroutine で実行し、認証の完了を待ちます。
認証が完了した後に、サーバを閉じます。
app/internal/auth/server/server.go1package server23import (4"context"5"fmt"6"net/http"7"app/internal/auth/authenticator"8"app/internal/auth/handler"9"app/internal/constants"10"time"1112"github.com/gorilla/sessions"13"github.com/labstack/echo-contrib/session"14"github.com/labstack/echo/v4"15"github.com/labstack/gommon/log"16)1718func RunServer(auth *authenticator.Authenticator) {19authenticated := make(chan bool, 1)2021// Setup22e := echo.New()2324e.HideBanner = true25e.HidePort = true2627loginURL := fmt.Sprintf("http://localhost:%s", constants.PORT)28fmt.Printf("➡️ Visit \x1b[4m%s\x1b[m to authenticate.\n", loginURL)2930e.Use(session.Middleware(sessions.NewCookieStore([]byte("secret"))))3132e.GET("/", handler.HandleLogin(auth))33e.GET("/callback", handler.HandleCallback(auth, authenticated))3435// Start server36addr := fmt.Sprintf(":%s", constants.PORT)37go func() {38if err := e.Start(addr); err != nil && err != http.ErrServerClosed {39e.Logger.Fatal(err)40}41e.Logger.Info("shutting down the server")42}()4344// Wait for authentication completion45<-authenticated4647// Shutdown the server48ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)49defer cancel()50if err := e.Shutdown(ctx); err != nil {51e.Logger.Fatal(err)52}53}
Auth0 の認証 URL にリダイレクトし、認証をする
ログイン URL にアクセスしたときの処理を書きます。
ここでは、state
という値をセッションに保存し、 Auth0 の認証 URL にリダイレクトします。
state
はランダムな文字列で、 Callback URL に戻ったときに同じ値が渡されることをチェックします。
app/internal/auth/handler/login.go1package handler23import (4"crypto/rand"5"encoding/base64"6"fmt"7"net/http"8"app/internal/auth/authenticator"910"github.com/labstack/echo-contrib/session"11"github.com/labstack/echo/v4"12)1314func HandleLogin(auth *authenticator.Authenticator) echo.HandlerFunc {15return func(c echo.Context) error {16state, err := generateRandomState()17if err != nil {18return c.String(http.StatusInternalServerError, err.Error())19}2021// Save the state inside the session.22sess, err := session.Get("state", c)23if err != nil {24return c.String(http.StatusInternalServerError, err.Error())25}2627sess.Values["state"] = state28if err := sess.Save(c.Request(), c.Response()); err != nil {29return c.String(http.StatusInternalServerError, err.Error())30}3132return c.Redirect(http.StatusTemporaryRedirect, auth.AuthCodeURL(state))33}34}3536func generateRandomState() (string, error) {37b := make([]byte, 32)38_, err := rand.Read(b)39if err != nil {40return "", fmt.Errorf("generateRandomState: %w", err)41}4243state := base64.StdEncoding.EncodeToString(b)4445return state, nil46}
このエンドポイント (localhost:3000
) にアクセスすると、 Auth0 の認証画面が表示されます。
ここで使用する認証方法 (パスワードレス認証、多要素認証など) は Auth0 のダッシュボードから設定できます。
認証が成功したら Callback URL にリダイレクトする
Auth0 の画面での認証が成功すると、 Callback URL にリダイレクトします。
Callback URL にアクセスしたときの処理を書きます。
ここでは state
と ID トークンの検証を行ったあとに、ID トークンをファイルに保存します。
また、認証が完了したことを channel に通知します。
app/internal/auth/handler/callback.go1package handler23import (4"net/http"5"app/internal/auth/authenticator"6"app/internal/auth/token"7"app/internal/constants"89"github.com/labstack/echo-contrib/session"10"github.com/labstack/echo/v4"11)1213func HandleCallback(auth *authenticator.Authenticator, authenticated chan<- bool) echo.HandlerFunc {14return func(c echo.Context) error {15sess, err := session.Get("state", c)16if err != nil {17return c.String(http.StatusInternalServerError, err.Error())18}1920if c.QueryParam("state") != sess.Values["state"] {21return c.String(http.StatusBadRequest, "Invalid state parameter.")22}2324// Exchange an authorization code for a token.25tk, err := auth.Exchange(c.Request().Context(), c.QueryParam("code"))26if err != nil {27return c.String(http.StatusUnauthorized, "Failed to exchange an authorization code for a token.")28}2930rawIDToken, ok := tk.Extra("id_token").(string)31if !ok {32return c.String(http.StatusBadRequest, "No id_token field in oauth2 token.")33}3435_, err = auth.VerifyIDToken(c.Request().Context(), rawIDToken)36if err != nil {37return c.String(http.StatusInternalServerError, "Failed to verify ID Token.")38}3940if err := token.SaveTokenToFile(rawIDToken); err != nil {41return c.String(http.StatusInternalServerError, "Failed to save access token.")42}4344authenticated <- true4546return c.String(http.StatusOK, "Authentication succeeded!")47}48}
注意点として、auth.Exchange
によって得られた tk
から tk.AccessToken
を取得できるのですが、
これが JWE 形式のトークンで、 Cloud クライアントライブラリが使用するトークンの要件を満たさないため動作しません。
Cloud クライアントライブラリ用に使用するのは tk.Extra("id_token")
から取得した ID トークンとなります。
取得した ID トークンを一時ファイルに保存する
ID トークンをファイルに保存したり削除したりする関数を定義します。
tokenFilePath
のパスは、 GCP 側で設定する際に同じものを指定します。
app/internal/auth/token/token.go1package token23import (4"fmt"5"os"6)78const tokenFilePath = "./tmp/app-token"910func SaveTokenToFile(token string) error {11f, err := os.Create(tokenFilePath)12if err != nil {13return fmt.Errorf("SaveTokenToFile: %w", err)14}15defer f.Close()1617_, err = f.WriteString(token)18if err != nil {19return fmt.Errorf("SaveTokenToFile: %w", err)20}2122return nil23}2425func WithToken(f func() error) error {26if err := f(); err != nil {27if rmErr := removeToken(); rmErr != nil {28return fmt.Errorf("WithToken: %w\nWithToken: %w", err, rmErr)29}30return fmt.Errorf("WithToken: %w", err)31}3233if err := removeToken(); err != nil {34return fmt.Errorf("WithToken: %w", err)35}3637return nil38}3940func removeToken() error {41if err := os.Remove(tokenFilePath); err != nil {42return fmt.Errorf("removeToken: %w", err)43}44return nil45}
GCP での設定
こちらも基本的には公式のドキュメントに従います。
コンソールでの設定
API を有効化する
まずは必要な API を有効化します。
必要な API については公式のドキュメントを参照してください。
Workload Identity プールを作成する
次に IAM と管理 > Workload Identity 連携 から プールを作成 を押します。
画面の指示に従って Workload Identity プールを作成します。
プロバイダの設定では、プロバイダとして OpenID Connect (OIDC) を選択し、 発行元は Auth0 の認証 URL を指定します。
オーディエンスは許可するオーディエンスとして、 Auth0 の Client ID を指定します。
属性のマッピングでは、google.subject = assertion.sub
と指定します。
サービスアカウントを作成する
IAM と管理 > サービス アカウント から CREATE SERVICE ACCOUNT を押します。
ロールの選択では、Workload Identity ユーザーと、使用したいリソースへのアクセス権をもつロール (Cloud Datastore ユーザー など) を指定します。
Workload にサービスアカウントの権限を許可する
IAM と管理 > Workload Identity 連携 からさきほど作成したプールを選択し、アクセスを許可 を押します。
さきほど作成したサービスアカウントを指定し、保存を押します。
ダイアログが表示されますので、 OIDC ID tokenのパス には ID トークンを保存するファイルのパス (./tmp/app-token
) を指定し、
フォーマット タイプ にはテキストを指定します。
構成をダウンロード を押すと JSON ファイルがダウンロードされるので、 これをプロジェクトに置きます。
このファイルにはサービスアカウントキーと違って機密情報が含まれていません!
コンソール上での設定は以上となります。
実装
環境変数 GOOGLE_APPLICATION_CREDENTIALS
に、次のように構成情報ファイルへのパスを指定しておくと、
Cloud クライアントライブラリは特にコード内で指定しなくてもデフォルト認証情報としてそのファイルを見てくれます。
1GOOGLE_APPLICATION_CREDENTIALS=clientLibraryConfig-app.json
以下には例として、 Cloud Datastore にアクセスする処理を書いています。
app/cmd/main.go1package main23import (4"context"5"fmt"6"log"7"app/internal/auth/authenticator"8"app/internal/auth/server"9"app/internal/auth/token"10"app/internal/constants"1112"cloud.google.com/go/datastore"13)1415func main() {16if err := authenticate(); err != nil {17log.Fatal(err)18}1920if err := token.WithToken(run); err != nil {21log.Fatal(err)22}23}2425func authenticate() error {26auth, err := authenticator.New()27if err != nil {28return fmt.Errorf("authenticate: %w", err)29}3031server.RunServer(auth)3233return nil34}3536func run() error {37ctx := context.Background()38client, err := datastore.NewClient(ctx, constants.PROJECT_ID)39if err != nil {40return nil, fmt.Errorf("run: %w", err)41}42defer client.Close()4344// Some actions for Datastore4546return nil47}
以上で、 Auth0 を用いた Workload Identity 連携ができました!
これで、サービスアカウントキーを管理するコストを省きつつ、アプリケーションの安全性を高めることができます。
この記事が参考になったらうれしいです。
ではまた