dshimizu/blog

アルファ版

Auth0 のチュートリアルをやって Client Credentials Flow の認可の仕組みに触れた

はじめに

認証認可について理解が浅かったので、Auth0 のチュートリアルをやってみた時の備忘。

基本的には下記のリンク先に書いてある通りのことをやったのみとなる。

RFC 6749 では、アクセストークンを取得するための Authorization Code, Implicit, Resource Owner Password Credentials, Client Credentials の 4 つのフローが定義されている。 このチュートリアルでは Client Credentials フローで実行する形となる。

Client Credentials Flow

RFC 6749 の 4.4. Client Credentials Grant の項目で定義されているフロー。 認可サーバーここでは Auth0)の API エンドポイント(にリクエストを投げて、そのレスポンスでアクセストークンを受け取る。 ユーザー認証自体は行われず、リクエスト元となるクライアントの認証のみ行うような形となるやり方になる。

環境

  • Auth0 アカウント (Auth0 のアカウントを作成しておく)
  • Go 1.19 (認可に利用するテスト用の API 用)

Auth0 の設定

Auth0 の APIs に、自分が作成する API アプリケーションを示す情報を登録する。 値は何でも良いけど Identifier の値は、認可リクエストで利用することになり、後の変更不可なので注意。

APIs の項目に遷移し、 Permission を定義する。 チュートリアルの通りに、 Permissionread:messages を、 Descriptionread messages としておく。

Settings で、 RBAC Settings の項目から、 Enable RBACAdd Permissions in the Access Token を有効化し、画面下の Save ボタンをクリックする。これでアクセストークンに Permissions の項目が含まれるようになる。

Applications の項目に遷移し、 APIs タブの画面で、Permissions から、先ほど作成した read:messages にチェックを入れ、画面下の Update ボタンをクリックして更新する。

API 環境作成

モジュール用ディレクトリ作成

モジュール用ディレクトリを作成する。

% mkdir auth0-go

そのディレクトリに移動して、 go mod init を実行する。

% cd auth0-go

モジュールの初期化

go mod init を実行してモジュールを初期化する。 必要に応じて引数としてモジュール名(ここでは localhost/auth0-go としているが任意)を指定する。

% go mod init localhost/auth0-go

.env ファイル作成

Auth0 の各種パラメータを定義した .env ファイルを作成する。

% cat .env
# Auth0 テナント ドメインの URL。
# カスタム ドメインを使用している場合は、そのドメインを設定する。
AUTH0_DOMAIN='********.{Region}.auth0.com'

# API の AUDIENCE
AUTH0_AUDIENCE='https://localhost:3000/'  # <- Auth0の

自己証明書作成

秘密鍵/CSRと証明書を作成する。

% openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -subj "/C=JP" -nodes -keyout server.key -out server.crt

アプリケーション作成

auth0-go 直下に middleware ディレクトリを作成し、 jwt.go を作成する。 Auth0 のライブラリを使ってアクセストークンを検証する処理を記述する。

以下では JWKS (Json Web Key Set)を使うようにしている。 JWKS についてはまだあまり詳しく理解できていないが、 https://{Auth0テナント名}.us.auth0.com/.well-known/jwks.json の情報をもとに KeyFunc の値を生成して、この値を JWT の検証に利用するもの、と思っている。

JWKS を使わない場合は Auth0 の APIs の Signing Secrets の値を KeyFunc へセットするようにし、JWT の検証を行う。

KeyFunc というのが一般的な用語なのかもわかってないけど go-jwt-middleware のパッケージでは KeyFunc という名称のものを利用している。

package middleware

import (
    "context"
    "log"
    "net/http"
    "net/url"
    "os"
    "strings"
    "time"

    jwtmiddleware "github.com/auth0/go-jwt-middleware/v2"
    "github.com/auth0/go-jwt-middleware/v2/jwks"
    "github.com/auth0/go-jwt-middleware/v2/validator"
)

// CustomClaims contains custom data we want from the token.
type CustomClaims struct {
    Scope string `json:"scope"`
}

// Validate does nothing for this example, but we need
// it to satisfy validator.CustomClaims interface.
func (c CustomClaims) Validate(ctx context.Context) error {
    return nil
}

func (c CustomClaims) HasScope(expectedScope string) bool {
    result := strings.Split(c.Scope, " ")
    for i := range result {
        if result[i] == expectedScope {
            return true
        }
    }

    return false
}

// EnsureValidToken is a middleware that will check the validity of our JWT.
func EnsureValidToken() func(next http.Handler) http.Handler {
    issuerURL, err := url.Parse("https://" + os.Getenv("AUTH0_DOMAIN") + "/")
    if err != nil {
        log.Fatalf("Failed to parse the issuer url: %v", err)
    }

    provider := jwks.NewCachingProvider(issuerURL, 5*time.Minute)

    jwtValidator, err := validator.New(
        provider.KeyFunc,
        validator.RS256,
        issuerURL.String(),
        []string{os.Getenv("AUTH0_AUDIENCE")},
        validator.WithCustomClaims(
            func() validator.CustomClaims {
                return &CustomClaims{}
            },
        ),
        validator.WithAllowedClockSkew(time.Minute),
    )
    if err != nil {
        log.Fatalf("Failed to set up the jwt validator")
    }

    errorHandler := func(w http.ResponseWriter, r *http.Request, err error) {
        log.Printf("Encountered error while validating JWT: %v", err)

        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusUnauthorized)
        w.Write([]byte(`{"message":"Failed to validate JWT."}`))
    }

    middleware := jwtmiddleware.New(
        jwtValidator.ValidateToken,
        jwtmiddleware.WithErrorHandler(errorHandler),
    )

    return func(next http.Handler) http.Handler {
        return middleware.CheckJWT(next)
    }
}

auth0-go 直下に main.go を作成する。 テスト用に3本のAPI ( /api/public, /api/private, /api/private-socped ) を定義している。

package main

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

    jwtmiddleware "github.com/auth0/go-jwt-middleware/v2"
    "github.com/auth0/go-jwt-middleware/v2/validator"
    "github.com/joho/godotenv"

    "localhost/auth0-go/middleware"
)

func main() {
    if err := godotenv.Load(); err != nil {
        log.Fatalf("Error loading the .env file: %v", err)
    }

    router := http.NewServeMux()

    // This route is always accessible.
    router.Handle("/api/public", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"message":"Hello from a public endpoint! You don't need to be authenticated to see this."}`))
    }))

    // This route is only accessible if the user has a valid access_token.
    router.Handle("/api/private", middleware.EnsureValidToken()(
        http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // CORS Headers.
            w.Header().Set("Access-Control-Allow-Credentials", "true")
            w.Header().Set("Access-Control-Allow-Origin", "https://localhost:3000")
            w.Header().Set("Access-Control-Allow-Headers", "Authorization")

            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(http.StatusOK)
            w.Write([]byte(`{"message":"Hello from a private endpoint! You need to be authenticated to see this."}`))
        }),
    ))

    router.Handle("/api/private-scoped", middleware.EnsureValidToken()(
        http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            w.Header().Set("Content-Type", "application/json")

            token := r.Context().Value(jwtmiddleware.ContextKey{}).(*validator.ValidatedClaims)
            fmt.Println(token)

            claims := token.CustomClaims.(*middleware.CustomClaims)
            fmt.Println(claims)
            if !claims.HasScope("read:messages") {
                w.WriteHeader(http.StatusForbidden)
                w.Write([]byte(`{"message":"Insufficient scope."}`))
                return
            }

            w.WriteHeader(http.StatusOK)
            w.Write([]byte(`{"message":"Hello from a private endpoint! You need to be authenticated to see this."}`))
        }),
    ))

    log.Print("Server listening on https://localhost:3000")

    srv := &http.Server{
        Addr:    "localhost:3000",
        Handler: router,
    }
    if err := srv.ListenAndServeTLS("server.crt", "server.key"); err != nil {
        log.Fatalf("There was an error with the http server: %v", err)
    }
}

Client Credentials Flow を試す。

Access Token Request

Auth0 のマネジメント API (/oauth/token)へリクエストし、アクセストークンを取得する。 client_idclient_secret パラメータは、 ApplicationsSettings 画面から値を確認してセットする。

% curl --request POST \
  --url https://********.{Region}.auth0.com/oauth/token \
  --header 'content-type: application/json' \
  --data '{"client_id":"xxxxxxxx","client_secret":"xxxxxxxx","audience":"https://localhost:3000/","grant_type":"client_credentials"}'

Access Token Response

以下のような形で結果が返って来れば成功となる。

{"access_token":"****...****","expires_in":86400,"token_type":"Bearer"}%

取得したアクセストークンを用いて、作った API へリクエストする。

~ %  curl --request GET -k --url https://localhost:3000/api/private-scoped --header 'Authorization: bearer ****...****'

下記のような結果が返されれば正常なリクエストが実行できている。

{"message":"Hello from a private endpoint! You need to be authenticated to see this."}%

アクセストークンが不正なリクエストを送ってみる。

~ %  curl --request GET -k --url https://localhost:3000/api/private --header 'Authorization: bearer ****...****z'

JWT の検証でエラーが返される。

{"message":"Failed to validate JWT."}%

Applications の項目に遷移し、 APIs タブの画面で、Permissions から、先ほど作成した read:messages にチェックを外し、画面下の Update ボタンをクリックして更新した状態で、再度アクセストークンを取得し、そのアクセストークンを使ってリクエストする。

~ %  curl --request GET -k --url https://localhost:3000/api/private-scoped --header 'Authorization: bearer ****...****'

以下のようなエラーが返され、きちんと Permission の検証が行われている正常な動作を確認できる。

{"message":"Insufficient scope."}%

まとめ

Auth0 のチュートリアルをやって Client Credentials Flow の認可の仕組みに触れた。 認可の仕組みにいろいろなやり方があることをわかっていなかったので、その辺りの理解が深まったのと、実際に Client Credentials Flow で動かしてみて処理の流れが掴めたのはよかった。

参考