Frontend/BackendのOAuth2.0クライアント書いてみた
個人的に認証・認可まわりに興味を持ち出して以来、RFCやドキュメントを読みまくっていた。しかしながら、仕事が忙しかったり、そもそもここらへんを仕事でやるポジションにいないため、ちゃんと実装してみないことにはどうにもならんな、と思いだした。よって、最終的なゴールを雑なFAPI*1準拠したOAuth/OIDCシステムを実装していくことにした。具体的には以下の順番でやろうとしている。認証はもしかしたら、以前つくったFIDO2サーバー使うかも。
- OAuth2.0クライアント(Code Grantのみ)
- OAuth2.0認可サーバー
- OAuth2.0リソースサーバー
- FAPI Part1化
- OIDC化
- FAPI Part2化
まずは、OAuth2.0クライアントを雑に作成した。ある程度できたので、一旦、棚卸しもかねてブログを書く。 その過程で湧いた疑問は、解を求める終わりのないRFC・ドキュメント漁りの旅にでるよりも、所感をこの場に残して、棚にあげようと思う。
構成
Front: Vue。Authorization Requestを送信する。
GitHub - ken5scal/oauth-client-front
Back: Go。Token Requestを送信する。
GitHub - ken5scal/oauth-client-back
AS: Okta Preview。rfc6749に則ってて扱いやすかった。 RS: なし
なぜ、フロントエンドとバックエンドを分けたのかと言うと、
- FAPI R&Wが認可レスポンスのパラメタインジェクション攻撃やIdP Mix Up攻撃への緩和策として、OIDC Hybrid Flowを必用としているから
- 過去いた会社では、どのようなステージだろうがビジロジック部(バックエンド)とUI部(フロントエンド)を担当するチームが別だったから
といったところから、分割している。
実装(一部)
- Authorization Requestを送るVue Component(/pages/index.vue)
<template> <v-btn color="info" @click="requestForAuthorizationCode">Authorize</v-btn> </template> <script> export default { data() { return { unreserverdChars: //★PKCEで使うChar郡。RFC3986 Sec2.3参照 '0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz-._~' } }, mounted() { // because it's in dev, secure attribute is set as false const state = uuid() ★stateとして作成。本当はRedisに保存したい。 Cookie.set('SessionID', state, { secure: process.env.NODE_ENV !== 'development' }) }, methods: { // https://tools.ietf.org/html/rfc7636 requestForAuthorizationCode() { let codeVerifier = '' const l = this.unreserverdChars.length for (let i = 0; i < 128; i++) { //★PKCEのCode Verifierは48~128長だが、長くてなんの問題があるんじゃい、と128固定 codeVerifier += this.unreserverdChars[Math.floor(Math.random() * l)] } this.$store.commit('oauth/setVerifier', codeVerifier) ★後につかうのでVuex保存。保存先はSession Storage。 window.crypto.subtle .digest('SHA-256', new TextEncoder().encode(codeVerifier)) ★Code Challenge生成。Plain使ったらPKCEの意味ないよね...なくない...?Sha256できない環境が想像できない。 .then(digestValue => { const codeChallenge = window .btoa( new Uint8Array(digestValue).reduce( (s, b) => s + String.fromCharCode(b), '' ) ) // base64 to base64url .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, '') // ★Authorization Request window.location.href = process.env.oktaAuthzEndpoint + '?client_id=' + process.env.oktaClientId + '&response_type=code&scope=openid offline_access' + '&redirect_uri=' + process.env.oktaAuthzRedirectURL + '&state=' + Cookie.get('SessionID') + '&code_challenge_method=' + 'S256' + // For Now '&code_challenge=' + codeChallenge }) .catch(err => { // console.log(err) }) } } </script>
- 認可サーバーから認可コードを送り、それをバックエンドに連携するVue Component(/pages/callback.vue)
<script> import axios from 'axios' const Cookie = process.client ? require('js-cookie') : undefined export default { mounted: function() { { // Checking CSRF attack on the `code` during the Authorization Response if (this.$route.query.state !== Cookie.get('SessionID')) { ★認可コード差し込み対策 this.$root.error({ statusCode: 403, message: 'State is invalid. Suspected to be CSRFed.' }) } else { this.authzResponse = this.$route.query axios .post( 'http://localhost:9000/token', ★ JSON.stringify({ authz_code: this.$route.query.code, code_verifier: hoge // this.$store.getters['oauth/getVerifier']★PKCE: 認可コード横取り対策 }), { headers: { 'Content-Type': 'application/json' } } ) .then(res => { this.tokenResponse = res.data }) .catch(err => { this.tokenResponseError = err.response.data }) this.$store.commit('oauth/removeVerifier') } } } </script>
- Token Requestを送るGoバックエンド。この後、microservice的なデプロイも試してみたいので別リポジトリに分割。
func init() { tomlInBytes, err := ioutil.ReadFile("config.toml") if err != nil { log.Fatal().AnErr("Failed reading config file", err) } config, err := toml.LoadBytes(tomlInBytes) if err != nil { log.Fatal().AnErr("Failed parsing toml file", err) } // Maybe server config port = strconv.FormatInt(config.Get("env.dev.port").(int64), 10) oauthConfig = oauth2.Config{ ClientID: config.Get("env.dev.as.okta.client_id").(string), ClientSecret: os.Getenv("CLIENT_SECRET"), RedirectURL: config.Get("env.dev.as.okta.callback").(string), Endpoint: oauth2.Endpoint {TokenURL: config.Get("env.dev.as.okta.token_endpoint").(string)}, } //ConfigFromJSONの ConfigFromJSONが参考になる } func handleTokenRequest(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var b struct { AuthzCode string `json:"authz_code"` CodeVerifier string `json:"code_verifier"` } if err := json.NewDecoder(r.Body).Decode(&b); err != nil { w.WriteHeader(http.StatusInternalServerError) fmt.Fprintln(w, err.Error()) ★ return } opt := oauth2.SetAuthURLParam("code_verifier", b.CodeVerifier)★PKCEのパラメタはOption指定すればおk token, err := oauthConfig.Exchange(context.Background(), b.AuthzCode, opt) ★OAuth2パッケージ便利 if err != nil { w.WriteHeader(http.StatusBadRequest) fmt.Fprintln(w, err) ★本当はrfc6749 sec5.2の通りパースしたかったが無理そう。だってこれだし↓。PRが出てるのは確認ずみ //oauth2: cannot fetch token: 401 Unauthorized //Response: {"error":"invalid_client","error_description":"Client authentication failed. Either the client or the client credentials are invalid."} return } tokenForFront := &struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` RefreshToken string `json:"refresh_token,omitempty"` Expiry time.Time `json:"expiry"` }{ AccessToken: token.AccessToken, TokenType: token.TokenType, Expiry: token.Expiry, } w.Header().Set("Content-Type", "application/json;charset=UTF-8") w.Header().Set("Cache-Control", "no-store") w.Header().Set("Pragma", "no-cache") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(tokenForFront) }
メモ・疑問・所感
response_type
の話
最新ドラフトの「OAuth2.0 Security Best Current Practice」*2によると、クライアントはImplicitグラントタイプや"token id_token"および"code token id_token"といった認可レスポンス内にアクセストークンを入れるものを実装すべきでないと書いてある。このベストプラクティスに従うと、FrontendとBackendで分けたHybrid Flow構成の場合*3、"code id_token"くらいしかオプションが残らない。まあ、それは構わないのだが、もし"code id_token"を使うのであれば、Backend側にしかアクセストークンが保持されないため、Implicit GrantなどによるFrontend上で起きやすかったアクセストークンの漏洩について気にするポイントは減らすことができる。その場合、アクセストークンを sender-constrained
するMTLSやToken Bindingが果たしてFAPIのRead & Write Profileでも必要なのだろうか?という疑問はある。それを踏まえたうえで必要なのかもしれない。よくわからん。気にするより、実装を進めることにした。
PKCE
の話
「OAuth2.0 Security Best Current Practice」ではImplicit Grantを使うべきではない(Should Not)と書いてあるので、基本はCode Grantになる。その場合、PKCEが必須になる*4ため、実装した。ネイティブアプリではカスタムスキーマによって認可コード横取りされる経路が明示的にあるもののhttpsスキーマ使うクライアントアプリなら大丈夫じゃない??とも思ったが、PKCEは実装も管理も比較的ライト(だと思う)なので、特に違和感はなかった。
あと、code_verifierってどこに保存するのがベストなんだろう。クッキーにいれるのもちゃうし、ローカルストレージはWindow閉じても消えないし...で、ライフサイクルとしては短い(と思う)ため、XSSなどによる攻撃を受けるリスクは少ないということから、一旦 Session Storageに格納。
セッションとトークンの話
ネット上でJWTをセッション管理にすればいいじゃん!いやだめだ!という議論をよく見るが、そもそもJWT*5ってクレームのフォーマットであり、どうも議論自体の論点がよくわからん...という気持ちが強かった。今でもよくわかってないのが正直なところだが...で、いざOAuth2.0クライアントを実装してみると、別物という結論に自分の中で達し、Cookieを使う方針にした。ID Tokenを取得して検証が成功したら、クッキーを足せばいいんじゃないだろうか。
ユーザーの利用しているユーザーエージェント(クライアント?)とサーバーのセッションを管理するのと、認証されたユーザーのクレームの集合であるID Tokenは本来別ものな気がするし...
— ken\d (@ken5scal) 2019年5月29日
golang/oauth2/について
便利。最初はクライアントもスクラッチで書いちゃろ!と思ってたが、Exchangeの中身が素直だったのと、ASをOktaだけ使う場合は単純なhttp requestにしかならないので辞めた。個人的にはToken RequestのエラーがError型で帰ってくるのは辛かった。パースしてえ。まあ、ASによって仕様が違うからしょうがない。ただ、それに関するPRが4月に作成されているが反応がない。ほかのissue/prへの反応が2019年にはいってから低速化してるように見えるが、気のせいだろうか。あと、ASによって異なる仕様を拾うための仕組みが参考になった。ただし、全体的な設計は知識不足からかよくわからない。なぜ、ここにInterfaceを配置したのか、なぜモジュールを分けたのかなど....
フロント
フロント何もわからん。技術書典で、SPA構成を使ったWebアプリの本をパクり、Nuxtでやってみた。 お守りに猫本も脇において読んだが、完全にFront初心者の自分には無理で、猫本どころか公式docもさっぱりだった。 次の本でことなきをえた。公式docも「あーあれね、知ってる知ってる(汗」というところまでは理解できるようになった。
- 作者: 大津真
- 出版社/メーカー: ソーテック社
- 発売日: 2019/04/04
- メディア: 単行本
- この商品を含むブログを見る
ところで現時点だと、フロントにもClient IDをもたせているが、これはフロントの起動のタイミングでバックエンドに取得させにいったほうがいいと思う。が、コンテナオーケストレーションしないとハードル高そうなので、後回し。
インフラ的な構成
RedisとかDBをたててセッション情報やトークンの管理をしたり、Docker-Composeでデプロイしようと思ったが、後回し。 とりあえず、OAuth/OIDCをやりきるのを優先する。
アプリケーションの適切な設計・コード
弱い。エラーハンドリング、テスト等...
フロントサーバーとバックエンドサーバーの認証・認可
認証はx509でSPIFFEEを使うつもり。認可だが、Microservice in OAuthのイメージがわからない。European Identity Conferenceで「ユーザーが使うOAuthとMicroservice間で使うOAuthの違いはなんだ」と聞いたところ、「そこに違いはない」と言われた。ずっと考えていたが、やはり意味がわからない。この答えは10月のIIWで求める必用がありそうだ。
次は
認可サーバー作成する