Simple minds think alike

より多くの可能性を

【Golang】Auth0のOpenID Connect連携アプリを作るチュートリアルをやってみた

仕事でOpenID Connect(以降OIDC)連携の実装した際、勉強のためにAuth0連携のサンプルアプリを作ってみたので、その一連の手順をまとめてみたいと思います。

手順は基本的にAuth0のチュートリアル通りなのですが、補足文章をつけて書いてみたいと思います。

手順は2022年1月時点のもので、今後Auth0の仕様変更により手順が変わる可能性がありますので、最新の情報はAuth0のドキュメントを確認してください。

サンプルで実現するもの

以下のような画面遷移のサンプルを作ります

  • ログイン画面を表示し、サインインリンクを押すとOIDC認証を開始
  • Auth0のログイン画面を表示
  • Auth0にログインしたユーザーのプロフィール情報(ニックネーム・写真)をユーザー画面に表示

f:id:moritamorie:20220110141908p:plain

既にAuth0にログインした状態で「サインイン」リンクが押された場合は、Auth0のログイン画面を経由せずにログイン後のユーザー画面に遷移します。

サンプルで実現するシステムの構成

サンプルで作る構成全体は以下のようになり、認可コードフローによってOIDC認証します。

f:id:moritamorie:20220110143321j:plain

環境

Goと使用するOIDCライブラリのバージョンは以下の通りです

  • Go 1.16
  • go-oidc 3.1.0

サンプル作成手順

Auth0側の設定、サンプルアプリケーション(Goプロジェクト側のコード)という順番で手順を書いていきます。

Auth0側の設定

Auth0側でアプリケーションを作成し、Auth0への接続情報を取得します。またAuth0ログイン後のリダイレクト先であるサンプルアプリケーションのコールバックURLを設定します。

アプリケーションの作成

メニューの「Applications」から「+Create Application」ボタンを押して、アプリケーションを作成するダイアログを表示します。

ダイアログ上でアプリケーション名を入力し、「Regular Web Application」を選択後「Create」ボタンを押し、アプリケーションを作成します。 f:id:moritamorie:20220105152048p:plain

アプリケーションの設定から取得する情報

作成されたアプリケーションの「Settings」タブを選択し、以下の3つの情報を控えておきます。

  • Domain
  • Client ID
  • Client Secret

f:id:moritamorie:20220105235044p:plain

アプリケーションに設定する内容

また「Settings」タブにおいて、Auth0にログインした後にリダイレクトするコールバックURLを設定します。

  • Callback URL
    • http://localhost:3000/callback

f:id:moritamorie:20220106115154p:plain

デフォルトの設定に関して

大体の要件では上記の設定でうまくいくと思いますが、要件に応じて変更する必要がありますがある以下の2点に関しては押さえておく必要があると思います。

  • 署名アルゴリズム
    • デフォルトではRS256で署名がされます。
    • HS256にしたい場合は設定変更及びHS256で署名検証するように実装も変更する必要があります。
  • OIDC Comfrontの設定
    • 厳密にOIDCの仕様に従うようになる設定です。デフォルトはONです。
    • 既にOIDC連携のアプリケーションの実装がある状態でAuth0に関しても連携できるようにしたいという場合で、厳密にOIDCの仕様に則っていない場合はOFFにする必要があるかもしれません。

これらはアプリケーションの「Settings」タブの下部にある「Advanced Settings」の「Auth」タブで設定できます。

サンプルアプリケーション(Goプロジェクト)

以下の機能を持つサンプルアプリケーションを作っていきます。

  • アプリケーションのログイン画面(home.html)を返すだけの無名ハンドラ
  • ログイン画面でサインインリンクを押した時のログインハンドラ
  • Auth0ログイン後のコールバック処理のハンドラ
  • コールバック処理でOIDC認証が終わった後にリダイレクトされるユーザー画面(user.html)を返すハンドラ

ディレクトリ構成

最終的なディレクトリ・ファイル構成は以下のようになります。

├── go.mod
├── go.sum
├── .env
├── main.go(認証器、ルーターを生成し、ポート3000でブラウザからのリクエストを待ち受ける)
├── web
│     ├── app
│     │     ├── user
│     │     │     └── user.go (ユーザー画面表示のハンドラ)
│     │     ├── login
│     │     │     └──login.go (ログイン処理のハンドラ)
│     │     └── callback
│     │           └── callback.go (コールバック処理のハンドラ)
│     └── template
│           ├──user.html (ユーザー画面のテンプレート)
│           └──home.html (ログイン画面のテンプレート)
└── platform
      ├── authenticator
      │     └── auth.go (認証器:Outh2設定、OIDCプロバイダー生成)
      └── router
            └── router.go (ルーター:セッション初期設定、HTMLテンプレートパス設定、パスとハンドラの対応付けを行う)

go.mod作成

空のgo.modファイルを作成し、依存関係があるライブラリを記載します。

$ go mod init github.com/moritamori/go-oidc-sample
module github.com/moritamori/go-oidc-sample

go 1.16

require (
  github.com/coreos/go-oidc/v3 v3.1.0
  github.com/gin-contrib/sessions v0.0.3
  github.com/gin-gonic/gin v1.7.4
  github.com/joho/godotenv v1.4.0
  golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
)

go.sum生成

$ go mod download

.envファイル作成

Auth0のsettings tabで取得・設定した内容を.envファイルに記載します。

# Auth0テナントドメイン
AUTH0_DOMAIN='dev-a4jtqeow.us.auth0.com'

# Auth0アプリケーションのClient ID.
AUTH0_CLIENT_ID='YOUR_CLIENT_ID'

# Auth0アプリケーションのClient Secret.
AUTH0_CLIENT_SECRET='YOUR_CLIENT_SECRET'

# アプリケーションのコールバックURL
AUTH0_CALLBACK_URL='http://localhost:3000/callback'

アプリケーションのエントリーポイント( main.go )

main.go では主に認証器、ルーターを生成し、ポート3000でブラウザからのリクエストを待ち受けます。

package main

import (
    "log"
    "net/http"

    "github.com/joho/godotenv"

    "github.com/moritamori/go-oidc-sample/platform/authenticator"
    "github.com/moritamori/go-oidc-sample/platform/router"
)

func main() {
    if err := godotenv.Load(); err != nil {
        log.Fatalf("envファイルの読み込みに失敗しました: %v", err)
    }

    auth, err := authenticator.New()
    if err != nil {
        log.Fatalf("認証器の生成に失敗しました: %v", err)
    }

    rtr := router.New(auth)

    log.Print("Server listening on http://localhost:3000/")
    if err := http.ListenAndServe("0.0.0.0:3000", rtr); err != nil {
        log.Fatalf("HTTPサーバーの起動時にエラーが発生しました: %v", err)
    }
}

ルーター作成

セッションの初期設定とHTMLテンプレートパス設定、パスとハンドラの対応付けをしています。 ログイン画面だけルーターの中で無名関数を作り、ルートパスにアクセスされた時にログイン画面のテンプレート(home.html) を返します。

package router

import (
    "encoding/gob"
    "net/http"

    "github.com/gin-contrib/sessions"
    "github.com/gin-contrib/sessions/cookie"
    "github.com/gin-gonic/gin"

    "github.com/moritamori/go-oidc-sample/platform/authenticator"
    "github.com/moritamori/go-oidc-sample/web/app/callback"
    "github.com/moritamori/go-oidc-sample/web/app/login"
    "github.com/moritamori/go-oidc-sample/web/app/user"
)

// New はroutesを登録し、ルーターを返す
func New(auth *authenticator.Authenticator) *gin.Engine {
    router := gin.Default()

    // セッション初期設定
    gob.Register(map[string]interface{}{})
    store := cookie.NewStore([]byte("secret"))
    router.Use(sessions.Sessions("auth-session", store))

    // HTMLテンプレートパス設定
    router.LoadHTMLGlob("web/template/*")

    // 無名ハンドラ
    router.GET("/", func(ctx *gin.Context) {
        ctx.HTML(http.StatusOK, "home.html", nil)
    })
    // ログイン処理のハンドラ
    router.GET("/login", login.Handler(auth))
    // コールバックハンドラ
    router.GET("/callback", callback.Handler(auth))
    // ユーザーハンドラ
    router.GET("/user", user.Handler)

    return router
}

ログイン画面のHTML(home.html)

ルートパスにアクセスされた時に返るログイン画面のHTML。「サインイン」リンクがあるだけのシンプルな画面。

<div>
    <h3>Auth0 OpenID Connectサンプル</h3>
    <a href="/login">サインイン</a>
</div>

ログイン処理のハンドラ

ログイン画面の「サインイン」リンクを押すと/loginにGETリクエストが送られ、ログイン処理のハンドラで処理されます。

ログイン処理のハンドラでは、ランダムなstate値を生成し、セッションに保存後、AuthCodeURLにリダイレクトします。AuthCodeURLは、Discovery Endpoint(http://dev-a4jtqeow.us.auth0.com/.well-known/openid-configuration)のauthorization_endpointから取得したURLを使います。

package login

import (
    "crypto/rand"
    "encoding/base64"
    "net/http"

    "github.com/gin-contrib/sessions"
    "github.com/gin-gonic/gin"

    "github.com/moritamori/go-oidc-sample/platform/authenticator"
)

// Handler はログイン処理のハンドラ
func Handler(auth *authenticator.Authenticator) gin.HandlerFunc {
    return func(ctx *gin.Context) {
        state, err := generateRandomState()
        if err != nil {
            ctx.String(http.StatusInternalServerError, err.Error())
            return
        }

        // セッションにstateを保存
        session := sessions.Default(ctx)
        session.Set("state", state)
        if err := session.Save(); err != nil {
            ctx.String(http.StatusInternalServerError, err.Error())
            return
        }

        // 認証コードURLにリダイレクト
        ctx.Redirect(http.StatusTemporaryRedirect,
            auth.AuthCodeURL(state))
    }
}

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

    state := base64.StdEncoding.EncodeToString(b)

    return state, nil
}

コールバックのハンドラ

コールバックハンドラは、Auth0の認証後にリダイレクトされてアクセスされます。

クエリパラメータによって渡される認可コードをアクセストークン、IDトークンと交換し、トークン内の署名検証(RS256)して問題がなければ、プロフィール情報をセッションに保存し、ログイン後のユーザー画面にリダイレクトします。

package callback

import (
    "net/http"

    "github.com/gin-contrib/sessions"
    "github.com/gin-gonic/gin"

    "github.com/moritamori/go-oidc-sample/platform/authenticator"
)

// Handler はコールバックのハンドラ
func Handler(auth *authenticator.Authenticator) gin.HandlerFunc {
    return func(ctx *gin.Context) {
        session := sessions.Default(ctx)
        if ctx.Query("state") != session.Get("state") {
            ctx.String(http.StatusBadRequest,
                "stateパラメータが無効です")
            return
        }

        // 認可コードをトークンに交換する
        token, err := auth.Exchange(ctx.Request.Context(), ctx.Query("code"))
        if err != nil {
            ctx.String(http.StatusUnauthorized,
                "認可コードからトークンへの交換に失敗しました")
            return
        }

        idToken, err := auth.VerifyIDToken(ctx.Request.Context(), token)
        if err != nil {
            ctx.String(http.StatusInternalServerError,
                "IDトークンの検証に失敗しました")
            return
        }

        var profile map[string]interface{}
        if err := idToken.Claims(&profile); err != nil {
            ctx.String(http.StatusInternalServerError, err.Error())
            return
        }

        session.Set("access_token", token.AccessToken)
        session.Set("profile", profile)
        if err := session.Save(); err != nil {
            ctx.String(http.StatusInternalServerError, err.Error())
            return
        }

        // ログイン後のユーザー画面にリダイレクト
        ctx.Redirect(http.StatusTemporaryRedirect, "/user")
    }
}

認証情報作成(Outh2設定、OIDCプロバイダー生成)

以下の2つを生成し、それらのデータを持つ認証器を返します。

  • OpenID Discoveryを元にOIDC認証を行うプロバイダー
  • OAuth接続の設定情報(Auth0側で取得・設定したClient ID、Client Secret、Callback URLなどです)

また、この認証器にはIDトークンを検証し、問題がなければIDトークンを返す関数(VerifyIDToken)があります。

package authenticator

import (
    "context"
    "errors"
    "os"

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

// Authenticator はユーザーを認証するための認証器
type Authenticator struct {
    *oidc.Provider
    oauth2.Config
}

// New はユーザーを認証するための認証器を生成する
func New() (*Authenticator, error) {
    provider, err := oidc.NewProvider(
        context.Background(),
        "https://"+os.Getenv("AUTH0_DOMAIN")+"/",
    )
    if err != nil {
        return nil, err
    }

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

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

// VerifyIDToken は*oauth2.Tokenが有効なトークンであることを検証する
func (a *Authenticator) VerifyIDToken(ctx context.Context,
    token *oauth2.Token) (*oidc.IDToken, error) {
    rawIDToken, ok := token.Extra("id_token").(string)
    if !ok {
        return nil, errors.New("oauth2 tokenにid_tokenフィールドがありませんでした")
    }

    oidcConfig := &oidc.Config{
        ClientID: a.ClientID,
    }

    return a.Verifier(oidcConfig).Verify(ctx, rawIDToken)
}

ユーザー画面のハンドラ

OIDC認証後にコールバックハンドラからリダイレクトされてアクセスされます。

セッションからログインユーザーのプロフィール情報を取り出し、値をテンプレートuser.htmlに渡し、生成したHTMLのレスポンスを返します。

package user

import (
    "net/http"

    "github.com/gin-contrib/sessions"
    "github.com/gin-gonic/gin"
)

// Handler はログイン済みユーザー画面のハンドラ
func Handler(ctx *gin.Context) {
    session := sessions.Default(ctx)
    profile := session.Get("profile")

    ctx.HTML(http.StatusOK, "user.html", profile)
}

ユーザー画面のHTML(user.html)

ユーザー画面のハンドラから読み込まれるユーザー画面のHTMLテンプレート。ハンドラからプロフィール情報を受け取り、展開します。

<div>
    <img class="avatar" src="{{ .picture }}"/>
    <h2>Welcome {{.nickname}}</h2>
</div>

gitリポジトリにpushする

事前にGithubリポジトリを作って、PUSHしておきます。

作ったサンプルを動かしてみる

$ go run main.go
[GIN-debug] Loaded HTML Templates (3):
    -
    - home.html
    - user.html

[GIN-debug] GET    /            --> github.com/moritamori/go-oidc-sample/platform/router.New.func1 (4 handlers)
[GIN-debug] GET    /login       --> github.com/moritamori/go-oidc-sample/web/app/login.Handler.func1 (4 handlers)
[GIN-debug] GET    /callback    --> github.com/moritamori/go-oidc-sample/web/app/callback.Handler.func1 (4 handlers)
[GIN-debug] GET    /user        --> github.com/moritamori/go-oidc-sample/web/app/user.Handler (4 handlers)
2022/01/06 14:56:21 Server listening on http://localhost:3000/

http://localhost:3000Webブラウザからアクセスするとログイン画面が表示され、サインインリンクをクリックし、OIDC連携の動作を確認できます。

f:id:moritamorie:20220106102448p:plain

リポジトリ

コードをGithubリポジトリにあげてみたのでよろしければ参考にしてみてください。 github.com

関連記事

本記事内では署名検証にHS256に対応する方法も記載してみましたので、もしよろしければご参考ください。

simple-minds-think-alike.moritamorie.com

参考資料