ElixirのOAuth 2.0用ライブラリ "OAuth2" の使い方

14 min read読了の目安(約13000字

ritou です。

前に ElixirのTwitter OAuthライブラリ "ExTwitter" の使い方 という記事を書きましたが、今回は OAuth2.0用のライブラリ "OAuth2" を見てみます。

OAuth2

oauth2 | Hex

依存してるのが hackney | Hex だけなのでシンプルで良さそうなのと、しこたま他のライブラリから使われてるので間違いなさそう。

処理の流れ

OAuth 1.0のライブラリの動作確認の記事で

基本的に、プロトコルが OAuth 1.0でも2.0でも今後出てくる新しいやつでも、処理の比重が変わるだけでこの4つの段階を踏んでリソースアクセスが行われます。

と書いたように、OAuth 2.0 や OIDC でも、OAuth 1.0(1.0aとか呼ばれてるやつ) と同様の分類ができます。

  1. Authorization Requrest 作成 & セッションとの紐付け : 認可リクエストを作成してセッションと紐付ける
  2. Resource Owner Authorization : Authorization Server にリダイレクトしてリソースアクセスに対するリソースオーナーの許可を得る
  3. Token Credentials : Access Token/Refresh Token を取得
  4. Authorized Requests : リソースアクセス

他にAT更新とかもありますがいったん置いておきます。

各プロトコル毎の一連の流れをこの4段階に分けておくことで、いろんなところとID連携しようとする時にもあまり悩まずに設計/実装ができるでしょう。

今のところ、SAMLについて取り上げる予定はありません

for OIDC

Google のアカウント、エンドポイントを用いて OpenID Connect の Authorization Code Grant の手順をまとめます。
Pure OAuth 2.0な手順も試したんですが、OIDCの手順に完全に内包されそうなので省略します。

1. Authorization Requrest 作成 & セッションとの紐付け

OAuth2.Client.new に最初からたくさん値を指定してやる方が簡単ですが、必要最低限のパラメータ指定でやってみます。

# 必要最低限の値として、AuthZ Serverの認可エンドポイントの情報が必要です
iex(1)> client = OAuth2.Client.new([
...(1)>   strategy: OAuth2.Strategy.AuthCode, #default
...(1)>   site: "https://accounts.google.com",
...(1)>   authorize_url: "/o/oauth2/auth"
...(1)> ])
%OAuth2.Client{
  authorize_url: "/o/oauth2/auth",
  client_id: "",
  client_secret: "",
  headers: [],
  params: %{},
  redirect_uri: "",
  ref: nil,
  request_opts: [],
  serializers: %{},
  site: "https://accounts.google.com",
  strategy: OAuth2.Strategy.AuthCode,
  token: nil,
  token_method: :post,
  token_url: "/oauth/token"
}

# 認可リクエストに指定するパラメータを用意します。
iex(2)> client_id = "68572(masked).apps.googleusercontent.com"
"68572(masked).apps.googleusercontent.com"
iex(3)> redirect_uri = "http://localhost:3000/cb"
"http://localhost:3000/cb"
iex(4)> params = [
...(4)>   client_id: client_id,
...(4)>   redirect_uri: redirect_uri,
...(4)>   scope: "openid",
...(4)>   state: "xyz",
...(4)>   access_type: "offline",
...(4)>   nonce: "12345"
...(4)> ]
[
  client_id: "68572(masked).apps.googleusercontent.com",
  redirect_uri: "http://localhost:3000/cb",
  scope: "openid",
  state: "xyz",
  access_type: "offline",
  nonce: "12345"
]

# `client`, `params` を引数にして Authorization Request の URL を作成します。
iex(5)> {client, authorization_url} = OAuth2.Client.authorize_url(client, params)
{%OAuth2.Client{
   authorize_url: "/o/oauth2/auth",
   client_id: "",
   client_secret: "",
   headers: [],
   params: %{
     "access_type" => "offline",
     "client_id" => "68572(masked).apps.googleusercontent.com",
     "nonce" => "12345",
     "redirect_uri" => "http://localhost:3000/cb",
     "response_type" => "code",
     "scope" => "openid",
     "state" => "xyz"
   },
   redirect_uri: "",
   ref: nil,
   request_opts: [],
   serializers: %{},
   site: "https://accounts.google.com",
   strategy: OAuth2.Strategy.AuthCode,
   token: nil,
   token_method: :post,
   token_url: "/oauth/token"
 },
 "https://accounts.google.com/o/oauth2/auth?access_type=offline&client_id=68572(masked).apps.googleusercontent.com&nonce=12345&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcb&response_type=code&scope=openid&state=xyz"}

# おまけ : 頑張ればこうも書けそう。
# {client, authorization_url} = OAuth2.Client.new([...])
#                               |> OAuth2.Client.authorize_url(params)

URL作成に指定したパラメータのうち、セッションと紐づける必要があるのは

  • state
  • nonce

の値です。

2. Resource Owner Authorization

以下のような認可レスポンスから code, state の値を取得して、state がセッションに紐づけられた値と一致することを検証します。

http://localhost:3000/cb?state=xyz&code=4%2F0AY0e-g5zRi3nV7sXKTjrRmV9Y4KvIKx37NeYj_1ggYvLHi_8Kuc3p_dihc1v_hMJJopQVw&scope=openid&authuser=0&prompt=consent#
iex(6)> code = "4/0AY0e-g5zRi3nV7sXKTjrRmV9Y4KvIKx37NeYj_1ggYvLHi_8Kuc3p_dihc1v_hMJJopQVw"
"4/0AY0e-g5zRi3nV7sXKTjrRmV9Y4KvIKx37NeYj_1ggYvLHi_8Kuc3p_dihc1v_hMJJopQVw"

3. Token Credentials

Access Token/Refresh Token/IDToken を取得します。

# Access Token Request では Client 認証を行うので、`client_secret` を用意します。
iex(7)> client_secret = "49Z6W(masked)"
"49Z6W(masked)"

# `client_id`, `client_secret`, Token Endpoint の情報も指定します。
# さらに、レスポンスのJSONをパースするために `OAuth2.Client.put_serializer` をする必要もあります。
iex(8)> client = OAuth2.Client.new([
...(8)>   strategy: OAuth2.Strategy.AuthCode, #default
...(8)>   client_id: client_id,
...(8)>   client_secret: client_secret,
...(8)>   site: "https://accounts.google.com",
...(8)>   token_url: "/o/oauth2/token"
...(8)> ]) |> OAuth2.Client.put_serializer("application/json", Jason)
%OAuth2.Client{
  authorize_url: "/oauth/authorize",
  client_id: "68572(masked).apps.googleusercontent.com",
  client_secret: "49Z6W(masked)",
  headers: [],
  params: %{},
  redirect_uri: "",
  ref: nil,
  request_opts: [],
  serializers: %{"application/json" => Jason},
  site: "https://accounts.google.com",
  strategy: OAuth2.Strategy.AuthCode,
  token: nil,
  token_method: :post,
  token_url: "/o/oauth2/token"
}

# Access Token Request に必要なパラメータを用意します。
iex(9)> params = [
...(9)>   code: code,
...(9)>   redirect_uri: redirect_uri
...(9)> ]
[
  code: "4/0AY0e-g5zRi3nV7sXKTjrRmV9Y4KvIKx37NeYj_1ggYvLHi_8Kuc3p_dihc1v_hMJJopQVw",
  redirect_uri: "http://localhost:3000/cb"
]

# `OAuth2.Client.get_token!` 関数で各種トークンを取得します。
iex(10)> client = OAuth2.Client.get_token!(client, params)
%OAuth2.Client{
  authorize_url: "/oauth/authorize",
  client_id: "68572(masked).apps.googleusercontent.com",
  client_secret: "49Z6W(masked)",
  headers: [],
  params: %{},
  redirect_uri: "",
  ref: nil,
  request_opts: [],
  serializers: %{"application/json" => Jason},
  site: "https://accounts.google.com",
  strategy: OAuth2.Strategy.AuthCode,
  token: %OAuth2.AccessToken{
    access_token: "ya29.a(masked)",
    expires_at: 1610189174,
    other_params: %{
      "id_token" => "eyJ(masked)",
      "scope" => "openid"
    },
    refresh_token: "1//0e(masked)",
    token_type: "Bearer"
  },
  token_method: :post,
  token_url: "/o/oauth2/token"
}

# おまけ : これも短くかける。
# client = client = OAuth2.Client.new([...])
           |> OAuth2.Client.put_serializer("application/json", Jason)
           |> OAuth2.Client.get_token!(params)

取得したトークンは

  • Access Token : client.token.access_token
  • Refresh Token : client.token.refresh_token
  • ID Token : client.token.other_params.id_token

に格納されます。

4. Authorized Request

上記のままトークンを保持している client がある or 用意できる場合は

iex(11)> resource = OAuth2.Client.get!(client, "https://openidconnect.googleapis.com/v1/userinfo").body
%{
  "picture" => "https://lh3.googleusercontent.com/a-/AOh14GjQ_fcwsIRk6LalbnjCHWzWfk7BkYvX9XAkZP8b8Q=s96-c",
  "sub" => "114181308725730985237" 
}

という感じでAccess Tokenで保護されたリソースにアクセスできます。
また、OAuth2.Client.get! は Header の値を指定できるので、手元にある Access Token を Authorization Header の値として指定することもできます。

# 例えば Access Token を別で持っている状況で
iex(12)> access_token = client.token.access_token
"ya29.a(masked)"

# Access Token を保持していない `client` を使っても
iex(13)> client = OAuth2.Client.new([])
%OAuth2.Client{
  authorize_url: "/oauth/authorize",
  client_id: "",
  client_secret: "",
  headers: [],
  params: %{},
  redirect_uri: "",
  ref: nil,
  request_opts: [],
  serializers: %{},
  site: "",
  strategy: OAuth2.Strategy.AuthCode,
  token: nil,
  token_method: :post,
  token_url: "/oauth/token"
}

# リソースアクセス時に指定することでリクエストに含めてくれます。
iex(14)> resource = OAuth2.Client.get!(client, "https://openidconnect.googleapis.com/v1/userinfo", [authorization: "Bearer #{access_token}"]).body
"{\n  \"sub\": \"114181308725730985237\",\n  \"picture\": \"https://lh3.googleusercontent.com/a-/AOh14GjQ_fcwsIRk6LalbnjCHWzWfk7BkYvX9XAkZP8b8Q\\u003ds96-c\"\n}"

ということで、OIDCの一通りの処理はできそうです。

for OIDC + PKCE

上記の通り、パラメータ指定が柔軟なので、PKCEを使ったリクエストもできそうです。
引き続き、Google先生にお相手をしていただきます。

# しばらく OIDC のと一緒
iex(1)> client = OAuth2.Client.new([
...(1)>   strategy: OAuth2.Strategy.AuthCode, #default
...(1)>   site: "https://accounts.google.com",
...(1)>   authorize_url: "/o/oauth2/auth"
...(1)> ])
%OAuth2.Client{
  authorize_url: "/o/oauth2/auth",
  client_id: "",
  client_secret: "",
  headers: [],
  params: %{},
  redirect_uri: "",
  ref: nil,
  request_opts: [],
  serializers: %{},
  site: "https://accounts.google.com",
  strategy: OAuth2.Strategy.AuthCode,
  token: nil,
  token_method: :post,
  token_url: "/oauth/token"
}
iex(2)> client_id = "68572(masked).apps.googleusercontent.com"
"68572(masked).apps.googleusercontent.com"
iex(3)> redirect_uri = "http://localhost:3000/cb"
"http://localhost:3000/cb"

# パラメータとして `code_challenge`, `code_challenge_method` を指定
# 値は RFC7636 の `Appendix B.  Example for the S256 code_challenge_method` のものを利用する。
iex(4)> params = [
...(4)>   client_id: client_id,
...(4)>   redirect_uri: redirect_uri,
...(4)>   scope: "openid",
...(4)>   state: "xyz",
...(4)>   access_type: "offline",
...(4)>   nonce: "12345",
...(4)>   code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", # new!
...(4)>   code_challenge_method: "S256" # new!
...(4)> ]
[
  client_id: "68572(masked).apps.googleusercontent.com",
  redirect_uri: "http://localhost:3000/cb",
  scope: "openid",
  state: "xyz",
  access_type: "offline",
  nonce: "12345",
  code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
  code_challenge_method: "S256"
]

# URLに含まれる。
iex(5)> {client, authorization_url} = OAuth2.Client.authorize_url(client, params)
{%OAuth2.Client{
   authorize_url: "/o/oauth2/auth",
   client_id: "",
   client_secret: "",
   headers: [],
   params: %{
     "access_type" => "offline",
     "client_id" => "68572(masked).apps.googleusercontent.com",
     "code_challenge" => "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
     "code_challenge_method" => "S256",
     "nonce" => "12345",
     "redirect_uri" => "http://localhost:3000/cb",
     "response_type" => "code",
     "scope" => "openid",
     "state" => "xyz"
   },
   redirect_uri: "",
   ref: nil,
   request_opts: [],
   serializers: %{},
   site: "https://accounts.google.com",
   strategy: OAuth2.Strategy.AuthCode,
   token: nil,
   token_method: :post,
   token_url: "/oauth/token"
 },
 "https://accounts.google.com/o/oauth2/auth?access_type=offline&client_id=68572(masked).apps.googleusercontent.com&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&nonce=12345&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcb&response_type=code&scope=openid&state=xyz"}

# code の値を取得
iex(6)> code = "4/0AY0e-g7_HBXkysnGt4r9rWeveXD-FaVwMsu7RnY16qFPFfsrhBh36QWBx4d2m7aAVNbXmQ"
"4/0AY0e-g7_HBXkysnGt4r9rWeveXD-FaVwMsu7RnY16qFPFfsrhBh36QWBx4d2m7aAVNbXmQ"
iex(7)> client_secret = "49Z6W(masked)"

iex(8)> client = OAuth2.Client.new([
...(8)>   strategy: OAuth2.Strategy.AuthCode, #default
...(8)>   client_id: client_id,
...(8)>   client_secret: client_secret,
...(8)>   site: "https://accounts.google.com",
...(8)>   authorize_url: "/o/oauth2/auth",
...(8)>   token_url: "/o/oauth2/token"
...(8)> ]) |> OAuth2.Client.put_serializer("application/json", Jason)
%OAuth2.Client{
  authorize_url: "/o/oauth2/auth",
  client_id: "68572(masked).apps.googleusercontent.com",
  client_secret: "49Z6W(masked)",
  headers: [],
  params: %{},
  redirect_uri: "",
  ref: nil,
  request_opts: [],
  serializers: %{"application/json" => Jason},
  site: "https://accounts.google.com",
  strategy: OAuth2.Strategy.AuthCode,
  token: nil,
  token_method: :post,
  token_url: "/o/oauth2/token"
}

# パラメータに `code_verifier` の値を指定
iex(9)> params = [
...(9)>   code: code,
...(9)>   redirect_uri: redirect_uri,
...(9)>   code_verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
...(9)> ]
[
  code: "4/0AY0e-g7_HBXkysnGt4r9rWeveXD-FaVwMsu7RnY16qFPFfsrhBh36QWBx4d2m7aAVNbXmQ",
  redirect_uri: "http://localhost:3000/cb",
  code_verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
]

# Access Token 取得成功!
iex(10)> client = OAuth2.Client.get_token!(client, params)
%OAuth2.Client{
  authorize_url: "/o/oauth2/auth",
  client_id: "68572(masked)",
  client_secret: "49Z6W(masked)",
  headers: [],
  params: %{},
  redirect_uri: "",
  ref: nil,
  request_opts: [],
  serializers: %{"application/json" => Jason},
  site: "https://accounts.google.com",
  strategy: OAuth2.Strategy.AuthCode,
  token: %OAuth2.AccessToken{
    access_token: "ya29.(masked)",
    expires_at: 1610216983,
    other_params: %{
      "id_token" => "eyJ(masked)",
      "scope" => "openid"
    },
    refresh_token: "1//0e(masked)",
    token_type: "Bearer"
  },
  token_method: :post,
  token_url: "/o/oauth2/token"
}

ということで、パラメータ指定が簡単なのでPKCEの実装も可能であることがわかりました。

まとめ

  • Elixir の OAuth 2.0 Client 用ライブラリに "OAuth2" というのがある
  • OIDC, OIDC w/ PKCEのケースで使い方を調べた
  • PKCEのところはパラメータに追加で指定してやるとあっさり動いた

Client認証で他の認証方式を使いたいとか、エラーハンドリングとか実際のプロダクトで使うためには他にも考慮しなければならない点もあるものの、シンプルなOAuth 2.0/OIDCのClient実装としては便利そうです。

ではまた。