GoでOpenID ConnectのClientを実装する(実装編)

こんにちは、エンジニアの稲垣です。

はじめに

この記事はこちらの続きです。

今回は前回Auth0に設定した情報をもとに認証のGoのクライアントを実装してみます。

なお、OpenID Connectの仕様は深掘りせずに、ユーザ認証の雰囲気を掴むことを目的としています。

それでは、見てみましょう。

認証フローについておさらい

あらためて、認証フローについて確認します。

f:id:ingtk:20190530130127p:plain

  1. ユーザのログインボタンクリックなどをトリガーにアプリケーションサーバにリクエストが送信されます。

  2. アプリケーションサーバはリクエストを受信すると、クライアント識別子(Client ID / Client Secretなど)を含めたリクエストを生成し、OPにリダイレクトします。

  3. OPはリクエスト情報を検証し、認証・認可画面を表示します。

  4. ユーザは認証に必要な情報を入力し、OPはその情報を検証します。OPは認可コードを付与し、アプリケーションサーバのコールバックURLにリダイレクトします。

  5. アプリケーションサーバは認可コードを元に、OPにIDトークンと呼ばれる署名つきのJWTを要求します。

  6. アプリケーションサーバはOPからIDトークンが返却されるとそれを解析し正当性を検証します。

太字の部分が今回実装するエンドポイントです。

  • 認証要求を受け取り、OPへリダイレクトする処理を行うエンドポイントを/auth
  • OPでの認証完了後のコールバック用エンドポイントを /callback

として実装します。

IDトークン

IDトークンとは単一の認証イベントに対し、一意に付与される署名付きのJWTのことです。

トークンの発行者や、発行時間、ユーザの一意識別子など認証済みのユーザの属性を含みます。

受け取り手はIDトークンを解析し、以下のような単純なチェックを加えることで攻撃から守ることができます。

  • 有効なJWTであること
  • トークンの署名が正しいこと(発見可能な公開鍵を使う)
  • トークンの発行者が正しいこと
  • 発行時間や有効期限が有効であること

準備

Auth0にログインし、設定編にて設定したアプリケーション画面を再度確認し、Client ID / Client Secret / Domain をメモしておきます。

f:id:ingtk:20190628190232p:plain

また、Allowed Callback URLs の欄に実装するコールバックURLのエンドポイントを設定します。

f:id:ingtk:20190529214330p:plain

使用するライブラリ

今回は以下のライブラリを使用して実装しました。

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

/auth の実装

さて、それでは実装を見てみましょう。

まずはユーザからのログイン要求をトリガーに認証プロバイダへリダイレクトする処理を実装します。

http.HandleFunc("/auth", func(w http.ResponseWriter, r *http.Request) {

    provider, err := oidc.NewProvider(r.Context(), issuer)
    if err != nil {
        log.Fatal(err)

    }
    config := oauth2.Config{
        ClientID:     [clientID],
        ClientSecret: [clientSecret],
        Endpoint:     provider.Endpoint(),
        RedirectURL:  "http://localhost:3000/callback",
        Scopes:       []string{oidc.ScopeOpenID},
    }

    state := [ランダムな文字列]

    authURL:= config.AuthCodeURL(state)
    http.Redirect(w, r, authURL, http.StatusFound)
})

// サーバ起動
http.ListenAndServe(":3000", nil)

この関数内のほとんどは、OPの情報を取得しリダイレクト先のURLを構築する、ということをしています。

provider, err := oidc.NewProvider(r.Context(), issuer)
if err != nil {
    log.Fatal(err)
}
config := oauth2.Config{
    ClientID:     [clientID],
    ClientSecret: [clientSecret],
    Endpoint:     provider.Endpoint(),
    RedirectURL:  "http://localhost:3000/callback", 
    Scopes:       []string{oidc.ScopeOpenID},
}

ここに渡している情報がリダイレクトをするのに必要な項目です。

  • ClientID / ClientSecretにはAuth0で設定したアプリケーションのClient ID / Client Secret をそれぞれ指定します。
  • Endpoint には OPの認証エンドポイントとトークンエンドポイントを渡します。 このエンドポイントを取得するために、 oidc.NewProvider(r.Context(), issuer)という関数を呼び出していますが、これは認証を開始するために必要となる情報を検出できる、OpenID Connectの仕様を利用しています。
  • RedirectURL はOPでの認証完了後に呼び出されるURLです。あとで実装する /callback を指定します。
  • Scopeには 少なくとも openid の指定が必須となっています。この値は保護されたリソースへのアクセス要求を示していて、エンドユーザのプロフィールを要求する場合は profile、メールアドレスを要求する場合には email などの指定をすることができますが、単一の認証イベントを完了するだけであれば、openid だけで大丈夫です。

これらの情報と state という値をもとに config.AuthCodeURL(state) を呼び出すことで、リダイレクトURLを得ることができます。

stateにはランダムな文字列を指定します。

後述の/callbackが呼ばれたときにここで生成した値と同じ値が渡ってくることをチェックすることでCSRF対策になります

動作確認

さてアプリケーションを実行し、動作を確認してみましょう。

http://localhost:3000/auth にアクセスするとリダイレクトされ、以下のようなAuth0の認証画面が表示されるので、準備編で作成したユーザでログインします。

f:id:ingtk:20190627214451p:plain

ログインが成功すると (まだ実装していないので404 エラーになっていますが)/callback に返ってきているのがわかると思います。

/calbackには codestate というパラメータがついています。

code は認証の結果得られる認可コードでこのコードをもとにIDトークンを要求します。

state は先に指定したCSRF対策のための文字列と同じものが返ってきていると思います。

/callbackの実装

それでは、認証完了後にコールバックされるエンドポイントの実装を見てみます。

http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {

    ctx := r.Context()

    // この部分は /auth のコードと同じ
    provider, err := oidc.NewProvider(ctx, issuer)
    if err != nil {
        log.Fatal(err)

    }

    config := oauth2.Config{
        ClientID:     clientID,
        ClientSecret: clientSecret,
        Endpoint:     provider.Endpoint(),
        RedirectURL:  "http://localhost:4000/callback",
        Scopes:       []string{oidc.ScopeOpenID},
    }

    // state := r.URL.Query().Get("state")
    // stateが返ってくるので認証画面へのリダイレクト時に渡したパラメータと矛盾がないか検証
    // verifyState(state)

    // codeをもとにトークンエンドポイントから IDトークン を取得
    code := r.URL.Query().Get("code")
    oauth2Token, err := config.Exchange(ctx, code)
    if err != nil {
        http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
        return
    }

    // IDトークンを取り出す
    rawIDToken, ok := oauth2Token.Extra("id_token").(string)
    if !ok {
        http.Error(w, "missing token", http.StatusInternalServerError)
        return
    }

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

    verifier := provider.Verifier(oidcConfig)

    // IDトークンの正当性の検証
    idToken, err := verifier.Verify(ctx, rawIDToken)
    if err != nil {
        http.Error(w, "Failed to verify ID Token: "+err.Error(), http.StatusInternalServerError)
        return
    }

    // アプリケーションのデータ構造におとすときは以下のように書く
    idTokenClaims := map[string]interface{}{}
    if err := idToken.Claims(&idTokenClaims); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    fmt.Printf("%#v", idTokenClaims)

    fmt.Fprintf(w, "認証成功")
})

このAPIは statecode という2つのパラメータを受け取ります。

state は認証エンドポイントへのリダイレクト時に渡したランダムな文字列と同じものが入ります。

矛盾がないか検証し、悪意のあるリクエストでないことを確認します。

config.Exchange()の呼び出しによって code とIDトークンと交換することができます。

provider.Verifier() 関数を呼び出すことで、IDトークンの検証を行います。

動作確認

さて動作を確認してみましょう。

http://localhost:3000/auth にアクセスし、Auth0で認証すると、認証成功とメッセージが表示されるようになりました。

まとめ

いかがでしたでしょうか。

Auth0のようなIDaaSとGolangのライブラリを組み合わせることによって、簡単にOpenID Connectによる認証を実装できることがおわかりいただけたのではないかと思います。

OpenID Connectは今回書いた内容以外にも多くの仕様を持っています。

さらに理解を深めるためには、

を読んでみるのがオススメです。