diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ad2122e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + push: + pull_request: + +jobs: + asdf: + name: ASDF + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + # cache the ASDF directory, using the values from .tool-versions + - name: ASDF cache + uses: actions/cache@v3 + with: + path: ~/.asdf + key: ${{ runner.os }}-asdf-v2-${{ hashFiles('.tool-versions') }} + id: asdf-cache + # only run `asdf install` if we didn't hit the cache + - uses: asdf-vm/actions/install@v2 + if: steps.asdf-cache.outputs.cache-hit != 'true' + + build: + name: Build Elixir + runs-on: ubuntu-22.04 + needs: asdf + steps: + - uses: actions/checkout@v4 + - name: ASDF cache + uses: actions/cache@v3 + with: + path: ~/.asdf + key: ${{ runner.os }}-asdf-v2-${{ hashFiles('.tool-versions') }} + id: asdf-cache + - uses: mbta/actions/reshim-asdf@v1 + - name: Restore dependencies cache + id: deps-cache + uses: actions/cache@v3 + with: + path: deps + key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix- + - name: Install dependencies + run: | + mix local.rebar --force + mix local.hex --force + mix deps.get + + elixir: + name: Test Elixir + runs-on: ubuntu-22.04 + needs: build + steps: + - uses: actions/checkout@v4 + - name: ASDF cache + uses: actions/cache@v3 + with: + path: ~/.asdf + key: ${{ runner.os }}-asdf-v2-${{ hashFiles('.tool-versions') }} + id: asdf-cache + - uses: mbta/actions/reshim-asdf@v1 + - name: Restore dependencies cache + id: deps-cache + uses: actions/cache@v3 + with: + path: deps + key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix- + - name: Install dependencies + run: | + mix local.rebar --force + mix local.hex --force + - name: Compile (warnings as errors) + run: mix compile --force --warnings-as-errors + - name: Check formatting + run: mix format --check-formatted + - name: Run tests + run: mix test --cover + - name: Run Credo + run: mix credo --strict diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..acdb2c3 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +elixir 1.13.4-otp-24 +erlang 24.3.4.14 diff --git a/LICENSE b/LICENSE index d5335aa..3e0c69e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 rng2 +Copyright (c) 2018 rng2, 2023 Massachusetts Bay Transportation Authority Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 01689b3..0a4a757 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ This library provides an OIDC strategy for Ueberauth using the information in th Only supports `authorization_code` flow for now. Has optional support for `/userinfo` endpoints, and has the option to get a user's `uid_field` from either the claims or the userinfo. -*Originally based on rng2/ueberauth_oidc but has now diverged significantly from the source* +*Originally based on Defactosoftware/ueberauth_oidc and rng2/ueberauth_oidc but +has now diverged significantly from the source* ## Installation @@ -14,7 +15,7 @@ Has optional support for `/userinfo` endpoints, and has the option to get a user ```elixir def deps do - [{:ueberauth_oidc, git: "https://github.com/DefactoSoftware/ueberauth_oidc.git"}] + [{:ueberauth_oidc, github: "mbta/ueberauth_oidc"}] end ``` @@ -28,48 +29,30 @@ Has optional support for `/userinfo` endpoints, and has the option to get a user ## Configuration -1. Add OIDC to your Ueberauth configuration: +1. Add OIDC to your Ueberauth configuration. +See [OpenIDConnect](https://github.com/DockYard/openid_connect/blob/master/README.md) and [Ueberauth](https://hexdocs.pm/ueberauth/readme.html#configuring-providers) +for a list of supported options. ```elixir config :ueberauth, Ueberauth, providers: [ - oidc: { Ueberauth.Strategy.OIDC, [ - default: [ - # required, set to default provider you want to use - provider: :default_oidc, - - # optional - uid_field: :sub - ], - - # optional override for each provider - google: [uid_field: :email], - ... - ] } + oidc: { Ueberauth.Strategy.OIDC, + discovery_document_uri: "https://oidc.example/.well-known/openid-configuration", + client_id: "client_id", + client_secret: "123456789", + response_type: "code", + scope: "openid profile email", + # optional + callback_path: "/auth/oidc/callback", + fetch_userinfo: true, # true/false + userinfo_uid_field: "upn", # only include if getting the user_id from userinfo + uid_field: "sub" # only include if getting the user_id from the claims, + request_params: %{} # additional parameters for the initial request + request_uri: "https://oidc-override/request" # override the initial request URI + } ] ``` -1. Update your provider configuration. -See [OpenIDConnect](https://hexdocs.pm/openid_connect/readme.html) -for a list of supported options. - - ```elixir - config :ueberauth, Ueberauth.Strategy.OIDC, - # one or more providers - default_oidc: [ - fetch_userinfo: true, # true/false - userinfo_uid_field: "upn", # only include if getting the user_id from userinfo - uid_field: "sub" # only include if getting the user_id from the claims - discovery_document_uri: "https://oidc.example/.well-known/openid-configuration", - client_id: "client_id", - client_secret: "123456789", - redirect_uri: "https://your.url/auth/oidc/callback", - response_type: "code", - scope: "openid profile email" - ], - ... - ``` - ## Usage 1. Include the Ueberauth plug in your controller: @@ -96,30 +79,12 @@ for a list of supported options. 1. Your controller needs to implement callbacks to deal with `Ueberauth.Auth` and `Ueberauth.Failure` responses. For an example implementation see the [Ueberauth Example](https://github.com/ueberauth/ueberauth_example) application. -Note that the `Ueberauth.Strategy.Info` struct stored in `Ueberauth.Auth` -will be empty. Use the information in `Ueberauth.Auth.Credentials` and -`Ueberauth.Strategy.Extra` instead: - `Ueberauth.Auth.Credentials` contains the `access_token` and related fields - - The `other` map in `Ueberauth.Auth.Credentials` contains `provider` and `user_info` - - - `Ueberauth.Strategy.Extra` contains the raw claims, tokens and opts + - The `other` map in `Ueberauth.Auth.Credentials` contains `id_token` -1. Add `OpenIDConnect.Worker` with a provider list during application startup: - - ```elixir - def start(_type, _args) do - ... - children = [ - ..., - {OpenIDConnect.Worker, Application.get_env(:ueberauth, Ueberauth.Strategy.OIDC)}, - ... - ] - ... - Supervisor.start_link(children, opts) - end - ``` + - `Ueberauth.Strategy.Extra` contains the raw claims, userinfo, and tokens ## Calling @@ -127,13 +92,11 @@ Depending on the configured url, you can initialize the request through: /auth/oidc -To use another provider instead of the configured default, add the `oidc_provider` option: - - /auth/oidc?oidc_provider=google - ## License Please see [LICENSE](https://github.com/DefactoSoftware/ueberauth_oidc/blob/master/LICENSE) for licensing details. -Loosely based on [rng2/ueberauth_oidc](https://github.com/rng2/ueberauth_oidc). +Based on: +- [Defactosoftware/ueberauth_oidc](https://github.com/Defactosoftware/ueberauth_oidc) +- [rng2/ueberauth_oidc](https://github.com/rng2/ueberauth_oidc) diff --git a/config/config.exs b/config/config.exs index 2c0d746..becde76 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,30 +1 @@ -# This file is responsible for configuring your application -# and its dependencies with the aid of the Mix.Config module. -use Mix.Config - -# This configuration is loaded before any dependency and is restricted -# to this project. If another project depends on this project, this -# file won't be loaded nor affect the parent project. For this reason, -# if you want to provide default values for your application for -# 3rd-party users, it should be done in your "mix.exs" file. - -# You can configure your application as: -# -# config :ueberauth_oidc, key: :value -# -# and access this configuration in your application as: -# -# Application.get_env(:ueberauth_oidc, :key) -# -# You can also configure a 3rd-party app: -# -# config :logger, level: :info -# - -# It is also possible to import configuration files, relative to this -# directory. For example, you can emulate configuration per environment -# by uncommenting the line below and defining dev.exs, test.exs and such. -# Configuration from the imported file will override the ones defined -# here (which is why it is important to import them last). -# -# import_config "#{Mix.env}.exs" +import Config diff --git a/lib/ueberauth/strategy/oidc.ex b/lib/ueberauth/strategy/oidc.ex index 0720e84..e248817 100644 --- a/lib/ueberauth/strategy/oidc.ex +++ b/lib/ueberauth/strategy/oidc.ex @@ -13,17 +13,34 @@ defmodule Ueberauth.Strategy.OIDC do Handles the initial authentication request. """ def handle_request!(conn) do - provider_id = conn |> get_options!() |> get_provider() + opts = get_options!(conn) params = params_from_conn(conn) + params = + if request_params = Map.get(opts, :request_params) do + Map.merge(params, request_params) + else + params + end + try do - uri = - if custom_uri = Map.get(conn.params, "uri") do - custom_query = query_map(custom_uri) - custom_params = Map.merge(params, custom_query) - build_uri(custom_uri, custom_params) + {:ok, uri} = + if request_uri = Map.get(opts, :request_uri) do + params = + Map.merge( + params, + %{ + "client_id" => opts.client_id, + "redirect_uri" => opts.redirect_uri, + "response_type" => opts.response_type, + "scope" => opts.scope + } + ) + + query = URI.encode_query(params) + {:ok, "#{request_uri}?#{query}"} else - OpenIDConnect.authorization_uri(provider_id, params) + opts.module.authorization_uri(opts, params) end redirect!(conn, uri) @@ -47,73 +64,40 @@ defmodule Ueberauth.Strategy.OIDC do code -> opts = get_options!(conn) - provider_id = get_provider(opts) params = params_from_conn(conn, %{code: code}) with {:ok, %{"access_token" => access_token, "id_token" => id_token} = tokens} <- - OpenIDConnect.fetch_tokens(provider_id, params), - {:ok, claims} <- OpenIDConnect.verify(provider_id, id_token) do + opts.module.fetch_tokens(opts, params), + {:ok, claims} <- opts.module.verify(opts, id_token) do conn |> put_private(:ueberauth_oidc_claims, claims) |> put_private(:ueberauth_oidc_tokens, tokens) |> put_private(:ueberauth_oidc_opts, opts) |> maybe_put_userinfo(opts, access_token) else - {:error, type, reason} -> - set_error!(conn, type, reason) - {:error, reason} -> set_error!(conn, "error", reason) - - error -> - set_error!(conn, "unknown_error", error) end end end defp params_from_conn(conn, params \\ %{}) do - redirect_uri = conn |> get_options!() |> get_redirect_uri() - - %{redirect_uri: redirect_uri || callback_url(conn)} - |> Map.merge(state_params(conn)) - |> Map.merge(scope_params(conn)) + [] + |> with_state_param(conn) + |> Map.new() |> Map.merge(params) end - defp state_params(conn) do - case conn.private[:ueberauth_state_param] do - nil -> %{} - state -> %{state: state} - end - end - - defp scope_params(conn) do - case conn.private[:ueberauth_request_scope] do - nil -> %{} - scope -> %{scope: scope} - end - end - defp maybe_put_userinfo(conn, opts, access_token) do - with true <- option(opts, :fetch_userinfo), - provider_id <- get_provider(opts), - {:ok, user_info} <- get_userinfo(provider_id, access_token) do - put_private(conn, :ueberauth_oidc_user_info, user_info) + with true <- Map.get(opts, :fetch_userinfo, false), + {:ok, userinfo} <- opts.module.fetch_userinfo(opts, access_token) do + put_private(conn, :ueberauth_oidc_userinfo, userinfo) else - false -> conn - e -> set_error!(conn, "error", "Error retrieving userinfo:" <> inspect(e)) - end - end + false -> + conn - defp get_userinfo(provider_id, access_token) do - headers = [Authorization: "Bearer #{access_token}", "Content-Type": "application/json"] - - with %{"userinfo_endpoint" => userinfo_endpoint} <- - GenServer.call(:openid_connect, {:discovery_document, provider_id}), - %HTTPoison.Response{body: body} <- http_client().get!(userinfo_endpoint, headers), - userinfo_claims <- Jason.decode!(body) do - user_info = for {k, v} <- userinfo_claims, do: {to_string(k), v}, into: %{} - {:ok, user_info} + {:error, reason} -> + set_error!(conn, "error", reason) end end @@ -123,7 +107,7 @@ defmodule Ueberauth.Strategy.OIDC do |> put_private(:ueberauth_oidc_opts, nil) |> put_private(:ueberauth_oidc_claims, nil) |> put_private(:ueberauth_oidc_tokens, nil) - |> put_private(:ueberauth_oidc_user_info, nil) + |> put_private(:ueberauth_oidc_userinfo, nil) end @doc """ @@ -132,14 +116,14 @@ defmodule Ueberauth.Strategy.OIDC do def uid(conn) do private = conn.private - with true <- option(private.ueberauth_oidc_opts, :fetch_userinfo), - user_info <- option(private.ueberauth_oidc_opts, :userinfo_uid_field), - true <- is_bitstring(user_info) do - scrub_value(private.ueberauth_oidc_user_info[user_info]) + with true <- Map.get(private.ueberauth_oidc_opts, :fetch_userinfo, false), + userinfo when is_bitstring(userinfo) <- + Map.get(private.ueberauth_oidc_opts, :userinfo_uid_field, "sub") do + private.ueberauth_oidc_userinfo[userinfo] else _ -> - uid_field = option(private.ueberauth_oidc_opts, :uid_field) - scrub_value(private.ueberauth_oidc_claims[uid_field]) + uid_field = Map.get(private.ueberauth_oidc_opts, :uid_field, "sub") + private.ueberauth_oidc_claims[uid_field] end end @@ -150,14 +134,13 @@ defmodule Ueberauth.Strategy.OIDC do """ def credentials(conn) do private = conn.private - claims = conn.private.ueberauth_oidc_claims - tokens = conn.private.ueberauth_oidc_tokens - user_info = conn.private[:ueberauth_oidc_user_info] + claims = private.ueberauth_oidc_claims + tokens = private.ueberauth_oidc_tokens - exp_at = claims["exp"] |> scrub_value() |> expires_at() - access_token = tokens["access_token"] |> scrub_value() - refresh_token = tokens["refresh_token"] |> scrub_value() - token_type = tokens["token_type"] |> scrub_value() + exp_at = expires_at(claims["exp"]) + access_token = tokens["access_token"] + refresh_token = tokens["refresh_token"] + token_type = tokens["token_type"] %Credentials{ token: access_token, @@ -166,25 +149,20 @@ defmodule Ueberauth.Strategy.OIDC do expires: !!exp_at, expires_at: exp_at, other: %{ - user_info: user_info, - provider: get_provider(private.ueberauth_oidc_opts) + id_token: tokens["id_token"] } } end @doc """ - Returns an `Ueberauth.Auth.Extra` struct containing the raw token map - obtained from `:oidcc`. - - Since `:oidcc` is an erlang library, empty values in the map are - represented by `:undefined` or `:none`, not `nil`. + Returns an `Ueberauth.Auth.Extra` struct containing the raw tokens, claims, and opts. """ def extra(conn) do %Extra{ raw_info: %{ + opts: conn.private.ueberauth_oidc_opts, claims: conn.private.ueberauth_oidc_claims, - tokens: conn.private.ueberauth_oidc_tokens, - opts: conn.private.ueberauth_oidc_opts + userinfo: conn.private[:ueberauth_oidc_userinfo] } } end @@ -196,58 +174,31 @@ defmodule Ueberauth.Strategy.OIDC do This information is also included in the `Ueberauth.Auth.Credentials` struct. """ def info(conn) do - with user_info when not is_nil(user_info) <- conn.private[:ueberauth_oidc_user_info] do - %Info{} - |> Map.from_struct() - |> Enum.reduce(%Info{}, fn {k, v}, struct -> - string_key = Atom.to_string(k) - Map.put(struct, k, Map.get(user_info, string_key, v)) - end) - else - _ -> %Info{} - end - end + userinfo = conn.private[:ueberauth_oidc_userinfo] || %{} + claims = Map.merge(conn.private.ueberauth_oidc_claims, userinfo) - defp scrub_value(:undefined), do: nil - defp scrub_value(:none), do: nil - defp scrub_value(val), do: val + %Info{} + |> Map.from_struct() + |> Enum.reduce(%Info{}, fn {k, v}, struct -> + string_key = Atom.to_string(k) + Map.put(struct, k, Map.get(claims, string_key, v)) + end) + end defp set_error!(conn, key, message) do set_errors!(conn, [error(key, message)]) end - defp get_provider(opts), do: option(opts, :provider) - defp get_redirect_uri(opts), do: option(opts, :redirect_uri) - defp option(opts, key), do: Keyword.get(opts, key) - defp get_options!(conn) do - oidc_opts = Application.get_env(:ueberauth, __MODULE__, []) - supplied_defaults = conn |> options() |> Keyword.get(:default, []) - - # untrusted input - provider_id = conn.params["oidc_provider"] || Keyword.fetch!(supplied_defaults, :provider) - - provider_opts = - case is_atom(provider_id) do - true -> - Keyword.get(oidc_opts, provider_id, []) - - false -> - Enum.find_value(oidc_opts, [], &find_provider_opts(&1, provider_id)) - end - - default_options() - |> Keyword.merge(supplied_defaults) - |> Keyword.merge(provider_opts) - |> Keyword.put(:provider, provider_id) - end - - defp find_provider_opts({key, val}, provider_id) do - if provider_id == to_string(key), do: val + Map.merge( + %{ + module: OpenIDConnect, + redirect_uri: callback_url(conn) + }, + Map.new(options(conn)) + ) end - defp expires_at(nil), do: nil - defp expires_at(val) when is_binary(val) do val |> Integer.parse() @@ -256,30 +207,4 @@ defmodule Ueberauth.Strategy.OIDC do end defp expires_at(expires_at), do: expires_at - - defp http_client do - Application.get_env(:ueberauth_oidc, :http_client, HTTPoison) - end - - @spec query_map(URI.t() | String.t()) :: %{String.t() => String.t()} - defp query_map(uri_string) when is_binary(uri_string), - do: uri_string |> URI.new!() |> query_map() - - defp query_map(%URI{query: nil}), do: decode_query_string_to_map("") - defp query_map(%URI{query: query}), do: decode_query_string_to_map(query) - - @spec decode_query_string_to_map(String.t()) :: %{String.t() => String.t()} - defp decode_query_string_to_map(query_string) do - query_string - |> URI.query_decoder() - |> Enum.into(%{}) - end - - defp build_uri(uri, params) do - query = URI.encode_query(params) - - uri - |> URI.merge("?#{query}") - |> URI.to_string() - end end diff --git a/lib/ueberauth_oidc.ex b/lib/ueberauth_oidc.ex deleted file mode 100644 index 94131cf..0000000 --- a/lib/ueberauth_oidc.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule UeberauthOIDC do - @moduledoc false -end diff --git a/mix.exs b/mix.exs index ed316ad..57e8390 100644 --- a/mix.exs +++ b/mix.exs @@ -5,17 +5,24 @@ defmodule UeberauthOIDC.Mixfile do [ app: :ueberauth_oidc, name: "Ueberauth OIDC", - version: "0.1.7", - elixir: "~> 1.7", + version: "0.2.0", + elixir: "~> 1.13", description: """ An Ueberauth strategy for generic OpenID Connect authentication. """, package: package(), start_permanent: Mix.env() == :prod, - deps: deps() + elixirc_paths: elixirc_paths(Mix.env()), + deps: deps(), + test_coverage: [ + ignore_modules: [FakeOpenIDConnect] + ] ] end + defp elixirc_paths(:test), do: ["test/support" | elixirc_paths(:dev)] + defp elixirc_paths(_), do: ["lib"] + def application do [ extra_applications: [:logger] @@ -35,11 +42,10 @@ defmodule UeberauthOIDC.Mixfile do {:credo, "~> 1.5", only: [:dev, :test]}, {:ex_doc, "~> 0.24", only: [:dev, :test]}, {:jose, "~> 1.11"}, - {:httpoison, "~> 1.8"}, - {:mock, "~> 0.3", only: :test}, - {:openid_connect, "~> 0.2.2"}, + {:openid_connect, + github: "DockYard/openid_connect", ref: "ddafedc3c81a5bc91919d13630b6cb5ea2595ffe"}, {:plug, "~> 1.11"}, - {:ueberauth, "~> 0.7"} + {:ueberauth, "~> 0.10.5"} ] end end diff --git a/mix.lock b/mix.lock index 27bebaf..46327bc 100644 --- a/mix.lock +++ b/mix.lock @@ -1,30 +1,25 @@ %{ - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, - "certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"}, - "credo": {:hex, :credo, "1.5.5", "e8f422026f553bc3bebb81c8e8bf1932f498ca03339856c7fec63d3faac8424b", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dd8623ab7091956a855dc9f3062486add9c52d310dfd62748779c4315d8247de"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"}, - "ex_doc": {:hex, :ex_doc, "0.24.2", "e4c26603830c1a2286dae45f4412a4d1980e1e89dc779fcd0181ed1d5a05c8d9", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "e134e1d9e821b8d9e4244687fb2ace58d479b67b282de5158333b0d57c6fb7da"}, + "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, + "castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"}, + "credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.37", "2ad73550e27c8946648b06905a57e4d454e4d7229c2dafa72a0348c99d8be5f7", [:mix], [], "hexpm", "6b19783f2802f039806f375610faa22da130b8edc21209d0bff47918bb48360e"}, + "ex_doc": {:hex, :ex_doc, "0.30.9", "d691453495c47434c0f2052b08dd91cc32bc4e1a218f86884563448ee2502dd2", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "d7aaaf21e95dc5cddabf89063327e96867d00013963eadf2c6ad135506a8bc10"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, - "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, - "httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}, - "jose": {:hex, :jose, "1.11.1", "59da64010c69aad6cde2f5b9248b896b84472e99bd18f246085b7b9fe435dcdb", [:mix, :rebar3], [], "hexpm", "078f6c9fb3cd2f4cfafc972c814261a7d1e8d2b3685c0a76eb87e158efff1ac5"}, - "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, - "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, - "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "mock": {:hex, :mock, "0.3.7", "75b3bbf1466d7e486ea2052a73c6e062c6256fb429d6797999ab02fa32f29e03", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "4da49a4609e41fd99b7836945c26f373623ea968cfb6282742bcb94440cf7e5c"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, - "openid_connect": {:hex, :openid_connect, "0.2.2", "c05055363330deab39ffd89e609db6b37752f255a93802006d83b45596189c0b", [:mix], [{:httpoison, "~> 1.2", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "735769b6d592124b58edd0582554ce638524c0214cd783d8903d33357d74cc13"}, - "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "plug": {:hex, :plug, "1.13.3", "93b299039c21a8b82cc904d13812bce4ced45cf69153e8d35ca16ffb3e8c5d98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "98c8003e4faf7b74a9ac41bee99e328b08f069bf932747d4a7532e97ae837a17"}, - "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, - "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, - "ueberauth": {:hex, :ueberauth, "0.7.0", "9c44f41798b5fa27f872561b6f7d2bb0f10f03fdd22b90f454232d7b087f4b75", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2efad9022e949834f16cc52cd935165049d81fa9e925690f91035c2e4b58d905"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "finch": {:hex, :finch, "0.16.0", "40733f02c89f94a112518071c0a91fe86069560f5dbdb39f9150042f44dcfb1a", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f660174c4d519e5fec629016054d60edd822cdfe2b7270836739ac2f97735ec5"}, + "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"}, + "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, + "mint": {:hex, :mint, "1.5.1", "8db5239e56738552d85af398798c80648db0e90f343c8469f6c6d8898944fb6f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4a63e1e76a7c3956abd2c72f370a0d0aecddc3976dea5c27eccbecfa5e7d5b1e"}, + "nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, + "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, + "openid_connect": {:git, "https://github.com/DockYard/openid_connect.git", "ddafedc3c81a5bc91919d13630b6cb5ea2595ffe", [ref: "ddafedc3c81a5bc91919d13630b6cb5ea2595ffe"]}, + "plug": {:hex, :plug, "1.15.1", "b7efd81c1a1286f13efb3f769de343236bd8b7d23b4a9f40d3002fc39ad8f74c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "459497bd94d041d98d948054ec6c0b76feacd28eec38b219ca04c0de13c79d30"}, + "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "ueberauth": {:hex, :ueberauth, "0.10.5", "806adb703df87e55b5615cf365e809f84c20c68aa8c08ff8a416a5a6644c4b02", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3efd1f31d490a125c7ed453b926f7c31d78b97b8a854c755f5c40064bf3ac9e1"}, } diff --git a/test/support/fake_openid_connect.ex b/test/support/fake_openid_connect.ex new file mode 100644 index 0000000..be0216a --- /dev/null +++ b/test/support/fake_openid_connect.ex @@ -0,0 +1,102 @@ +defmodule FakeOpenIDConnect do + @moduledoc """ + Fake implementation of the `OpenIDConnect` module to support testing. + """ + + def request_url do + "https://oidc.example/request" + end + + def callback_code do + "valid_code" + end + + def authorization_uri(opts, params \\ %{}) + + def authorization_uri(%{discovery_document_uri: _} = opts, params) do + params = + Map.merge( + %{ + client_id: opts.client_id, + redirect_uri: opts.redirect_uri, + response_type: opts.response_type, + scope: opts.scope + }, + params + ) + + query = URI.encode_query(params) + + {:ok, "#{request_url()}?#{query}"} + end + + def authorization_uri(_opts, _params) do + {:error, :missing_discovery_document_uri} + end + + def fetch_tokens(opts, params) + + def fetch_tokens(%{:_fetch_tokens => false}, _) do + {:error, :no_tokens} + end + + def fetch_tokens(%{discovery_document_uri: _, client_secret: "secret_value"}, params) do + if Map.get(params, :code) == callback_code() do + {:ok, + %{ + "access_token" => "access_token_value", + "id_token" => "id_token_value", + "refresh_token" => "refresh_token_value", + "token_type" => "Bearer" + }} + else + {:error, :invalid_code} + end + end + + def fetch_tokens(_opts, _params) do + {:error, :no_tokens} + end + + def verify(opts, id_token) + + def verify(%{:_verify_tokens => false}, _) do + {:error, :invalid} + end + + def verify(%{discovery_document_uri: _}, "id_token_value") do + {:ok, + %{ + # Sat Nov 20 12:46:40 EST 2286 + "exp" => 10_000_000_000, + "sub" => "sub_value", + "email" => "email_value" + }} + end + + def verify(_opts, _params) do + {:error, :invalid} + end + + def fetch_userinfo(opts, access_token) + + def fetch_userinfo(%{:_fetch_userinfo => false}, _access_token) do + {:error, :invalid} + end + + def fetch_userinfo( + %{discovery_document_uri: _, client_secret: "secret_value"}, + "access_token_value" + ) do + {:ok, + %{ + "sub" => "userinfo_sub", + "name" => "Full Name", + "email" => "userinfo_email" + }} + end + + def fetch_userinfo(_opts, _access_token) do + {:error, :invalid} + end +end diff --git a/test/ueberauth_oidc/strategy/oidc_test.exs b/test/ueberauth_oidc/strategy/oidc_test.exs index ffa4738..5597f94 100644 --- a/test/ueberauth_oidc/strategy/oidc_test.exs +++ b/test/ueberauth_oidc/strategy/oidc_test.exs @@ -1,379 +1,252 @@ defmodule Ueberauth.Strategy.OIDCTest do - use ExUnit.Case - use Ueberauth.Strategy + use ExUnit.Case, async: true + use Plug.Test - import Mock - - alias Ueberauth.Strategy.Helpers alias Ueberauth.Strategy.OIDC - @request_uri "https://oidc.local/request" - @callback_uri "https://oidc.local/callback" - @valid_tokens {:ok, %{"access_token" => "1234", "id_token" => "4321"}} - @valid_claims {:ok, %{"uid" => "1234"}} - @error_response {:error, "reason"} + @default_options [ + module: FakeOpenIDConnect, + discovery_document_uri: "https://oidc.example/.well-known/discovery.json", + response_type: "code", + scope: "openid", + client_id: "oidc_client", + client_secret: "secret_value" + ] describe "OIDC Strategy" do - setup_with_mocks [ - {OpenIDConnect, [], - [ - authorization_uri: fn - "test_provider", %{redirect_uri: redirect_uri} = params -> - state_param = - case params[:state] do - nil -> "" - state -> "&state=#{state}" - end - - scope_param = - case params[:scope] do - nil -> "" - scope -> "&scope=#{scope}" - end - - "#{@request_uri}?redirect_uri=#{redirect_uri}#{state_param}#{scope_param}" - end, - fetch_tokens: fn _, _ -> @valid_tokens end, - verify: fn _, _ -> @valid_claims end - ]}, - {Helpers, [], - [ - options: fn _ -> [] end, - set_errors!: fn _, _ -> nil end, - error: fn key, msg -> {key, msg} end, - redirect!: fn _, url -> url end, - callback_url: fn _ -> @callback_uri end - ]} - ] do - :ok + setup do + {:ok, conn: init_test_session(conn(:get, "/auth/oidc"), %{})} end - test "Handles an OIDC request" do - request = - OIDC.handle_request!(%Plug.Conn{ - params: %{"oidc_provider" => "test_provider"}, - private: %{ueberauth_request_options: %{options: []}} - }) + test "Handles an OIDC request", %{conn: conn} do + conn = Ueberauth.run_request(conn, :provider, {OIDC, @default_options}) - assert request == "#{@request_uri}?redirect_uri=#{@callback_uri}" - end + assert {302, _headers, _body} = sent_resp(conn) - test "Handles an OIDC request with a state param" do - request = - OIDC.handle_request!(%Plug.Conn{ - params: %{"oidc_provider" => "test_provider"}, - private: %{ - ueberauth_request_options: %{options: []}, - ueberauth_state_param: "test_state" - } - }) - - assert request == "#{@request_uri}?redirect_uri=#{@callback_uri}&state=test_state" - end + [location] = get_resp_header(conn, "location") + assert String.starts_with?(location, "#{FakeOpenIDConnect.request_url()}?") - test "Handles an OIDC request for a custom URI" do - custom_uri = "https://example.com/custom/path" - - request = - OIDC.handle_request!(%Plug.Conn{ - params: %{ - "oidc_provider" => "test_provider", - "uri" => custom_uri - }, - private: %{ueberauth_request_options: %{options: []}} - }) + query = URI.decode_query(URI.parse(location).query) - assert request == "#{custom_uri}?#{URI.encode_query(%{redirect_uri: @callback_uri})}" + assert %{ + "redirect_uri" => "http://www.example.com/auth/provider/callback", + "client_id" => "oidc_client", + "scope" => "openid", + "response_type" => "code", + "state" => _ + } = query end - test "Handles an OIDC request for a custom URI with query params" do - base_uri = "https://example.com/custom/path" - - request = - OIDC.handle_request!(%Plug.Conn{ - params: %{ - "oidc_provider" => "test_provider", - "uri" => "#{base_uri}?foo=bar" - }, - private: %{ueberauth_request_options: %{options: []}} - }) + test "Handles an error in an OIDC request", %{conn: conn} do + options = Keyword.delete(@default_options, :discovery_document_uri) + conn = Ueberauth.run_request(conn, :provider, {OIDC, options}) + [error | _] = conn.assigns.ueberauth_failure.errors - assert request =~ "#{base_uri}?" - assert request =~ "redirect_uri=" - assert request =~ "foo=bar" + assert %Ueberauth.Failure.Error{ + message_key: "error", + message: "Authorization URL could not be constructed" + } = error end - test "Handles an error in an OIDC request" do - OIDC.handle_request!(%Plug.Conn{ - params: %{"oidc_provider" => "unregistered_provider"}, - private: %{ueberauth_request_options: %{options: []}} - }) - - assert_called( - Helpers.set_errors!(:_, [ - {"error", "Authorization URL could not be constructed"} - ]) - ) - end + test "Handle callback from provider with a callback_path", %{conn: conn} do + options = Keyword.put(@default_options, :callback_path, "/custom_callback") + conn = Ueberauth.run_request(conn, :provider, {OIDC, options}) - test "Handle callback from provider with a redirect_uri" do - with_mock Application, [], - get_env: fn :ueberauth, OIDC, [] -> - [test_provider: [redirect_uri: "https://oidc.local/custom"]] - end do - request = - OIDC.handle_request!(%Plug.Conn{ - params: %{"oidc_provider" => "test_provider"}, - private: %{ueberauth_request_options: %{options: []}} - }) - - assert request =~ ~r|\?redirect_uri=https://oidc.local/custom$| - end + assert {302, _headers, _body} = sent_resp(conn) + [location] = get_resp_header(conn, "location") + query = URI.decode_query(URI.parse(location).query) + assert query["redirect_uri"] == "http://www.example.com/custom_callback" end - test "Handle callback from provider with custom request scopes" do - with_mock Application, [], - get_env: fn :ueberauth, OIDC, [] -> - [test_provider: [scope: "openid"]] - end do - request = - OIDC.handle_request!(%Plug.Conn{ - params: %{"oidc_provider" => "test_provider"}, - private: %{ueberauth_request_scope: "custom_scope"} - }) - - assert request =~ ~r|\&scope=custom_scope| - end - end + test "Handle callback from provider with custom request scopes", %{conn: conn} do + options = Keyword.put(@default_options, :scope, "openid custom") + conn = Ueberauth.run_request(conn, :provider, {OIDC, options}) - test "Handle callback from provider with a uid_field in the id_token" do - with_mock Application, [], - get_env: fn :ueberauth, OIDC, [] -> - [test_provider: [fetch_userinfo: false, uid_field: "uid"]] - end do - callback = - OIDC.handle_callback!(%Plug.Conn{ - params: %{"code" => 1234, "oidc_provider" => "test_provider"}, - private: %{ueberauth_request_options: %{options: []}} - }) - - assert %Plug.Conn{ - private: %{ - ueberauth_oidc_opts: [ - provider: "test_provider", - fetch_userinfo: false, - uid_field: _ - ], - ueberauth_oidc_tokens: %{"access_token" => _, "id_token" => _} - } - } = callback - end - end + assert {302, _headers, _body} = sent_resp(conn) + [location] = get_resp_header(conn, "location") + query = URI.decode_query(URI.parse(location).query) - test "Handle callback from provider with a user_info endpoint" do - with_mocks [ - {GenServer, [], - [call: fn _, _ -> %{"userinfo_endpoint" => "https://oidc.test/userinfo"} end]}, - {HTTPoison, [], - [ - get!: fn _, _ -> - %HTTPoison.Response{body: "{\"sub\":\"string_key\",\"uid\":\"atom_key\"}"} - end - ]}, - {Application, [], - [ - get_env: fn - :ueberauth, OIDC, [] -> - [test_provider: [fetch_userinfo: true, userinfo_uid_field: "uid"]] - - :ueberauth_oidc, _, default -> - default - end - ]} - ] do - callback = - OIDC.handle_callback!(%Plug.Conn{ - params: %{"code" => 1234, "oidc_provider" => "test_provider"}, - private: %{ueberauth_request_options: %{options: []}} - }) - - assert %Plug.Conn{ - private: %{ - ueberauth_oidc_opts: [ - provider: "test_provider", - fetch_userinfo: true, - userinfo_uid_field: "uid" - ], - ueberauth_oidc_tokens: %{"access_token" => _, "id_token" => _}, - ueberauth_oidc_user_info: %{"sub" => "string_key", "uid" => "atom_key"} - } - } = callback - end + assert %{ + "scope" => "openid custom" + } = query end - test "Handle callback from provider with a missing code" do - OIDC.handle_callback!(%Plug.Conn{params: %{}}) + test "handle additional request parameters", %{conn: conn} do + options = Keyword.put(@default_options, :request_params, %{"request" => "param"}) + conn = Ueberauth.run_request(conn, :provider, {OIDC, options}) + + assert {302, _headers, _body} = sent_resp(conn) + [location] = get_resp_header(conn, "location") + query = URI.decode_query(URI.parse(location).query) - assert_called( - Helpers.set_errors!(:_, [ - {"error", "Query string does not contain field 'code'"} - ]) - ) + assert %{ + "request" => "param" + } = query end - test "Handle callback from provider with an error fetching tokens" do - with_mock OpenIDConnect, fetch_tokens: fn _, _ -> @error_response end do - OIDC.handle_callback!(%Plug.Conn{ - params: %{"code" => 1234, "oidc_provider" => "test_provider"}, - private: %{ueberauth_request_options: %{options: []}} - }) - - assert_called(OpenIDConnect.fetch_tokens("test_provider", %{code: 1234})) - refute called(OpenIDConnect.verify("test_provider", "4321")) - assert_called(Helpers.set_errors!(:_, [{"error", "reason"}])) - end + test "handle overriden request_uri", %{conn: conn} do + options = + Keyword.put(@default_options, :request_uri, "https://oidc-override.example/request") + + conn = Ueberauth.run_request(conn, :provider, {OIDC, options}) + + assert {302, _headers, _body} = sent_resp(conn) + [location] = get_resp_header(conn, "location") + assert String.starts_with?(location, "https://oidc-override.example/request?") + query = URI.decode_query(URI.parse(location).query) + + assert %{ + "redirect_uri" => _, + "client_id" => _, + "scope" => _, + "state" => _, + "response_type" => _ + } = query end - test "Handle callback from provider with an error verifying tokens" do - with_mock OpenIDConnect, - fetch_tokens: fn _, _ -> @valid_tokens end, - verify: fn _, _ -> @error_response end do - OIDC.handle_callback!(%Plug.Conn{ - params: %{"code" => 1234, "oidc_provider" => "test_provider"}, - private: %{ueberauth_request_options: %{options: []}} - }) - - assert_called(OpenIDConnect.fetch_tokens("test_provider", %{code: 1234})) - assert_called(OpenIDConnect.verify("test_provider", "4321")) - assert_called(Helpers.set_errors!(:_, [{"error", "reason"}])) - end + test "Handle callback from OIDC with default uid field (sub)", %{conn: conn} do + conn = run_request_and_callback(conn) + + assert %Ueberauth.Auth{ + provider: :provider, + strategy: Ueberauth.Strategy.OIDC, + uid: "sub_value", + credentials: %Ueberauth.Auth.Credentials{ + expires: true, + expires_at: 10_000_000_000, + token: "access_token_value", + token_type: "Bearer", + refresh_token: "refresh_token_value", + other: %{id_token: "id_token_value"} + }, + info: %Ueberauth.Auth.Info{email: "email_value"}, + extra: %Ueberauth.Auth.Extra{ + raw_info: %{ + opts: %{discovery_document_uri: _}, + claims: %{"sub" => _}, + userinfo: nil + } + } + } = conn.assigns.ueberauth_auth end - test "Handle callback from provider with error type and response" do - with_mock OpenIDConnect, fetch_tokens: fn _, _ -> {:error, :token_error, "some_message"} end do - OIDC.handle_callback!(%Plug.Conn{ - params: %{"code" => 1234, "oidc_provider" => "test_provider"}, - private: %{ueberauth_request_options: %{options: []}} - }) + test "Handle callback from provider with an overriden uid field", %{conn: conn} do + options = Keyword.put(@default_options, :uid_field, "email") + conn = run_request_and_callback(conn, options) - assert_called(Helpers.set_errors!(:_, [{:token_error, "some_message"}])) - end + assert %Ueberauth.Auth{ + uid: "email_value" + } = conn.assigns.ueberauth_auth end - test "Handle callback from provider with an unknown response" do - with_mock OpenIDConnect, fetch_tokens: fn _, _ -> {:unknown, "some_message"} end do - OIDC.handle_callback!(%Plug.Conn{ - params: %{"code" => 1234, "oidc_provider" => "test_provider"}, - private: %{ueberauth_request_options: %{options: []}} - }) + test "Handle callback from provider with an missing uid field", %{conn: conn} do + options = Keyword.put(@default_options, :uid_field, "_missing_") + conn = run_request_and_callback(conn, options) - assert_called(Helpers.set_errors!(:_, [{"unknown_error", {:unknown, "some_message"}}])) - end + assert %Ueberauth.Auth{ + uid: nil + } = conn.assigns.ueberauth_auth end - test "handle cleanup of uberauth values in the conn" do - conn_with_values = %Plug.Conn{ - private: %{ - ueberauth_oidc_opts: :some_value, - ueberauth_oidc_claims: :other_value, - uebrauth_oidc_tokens: :another_value, - ueberauth_oidc_user_info: :different_value - } - } + test "Handle callback from provider with a userinfo endpoint and an override userinfo_uid_field", + %{conn: conn} do + options = + @default_options + |> Keyword.put(:fetch_userinfo, true) + |> Keyword.put(:userinfo_uid_field, "email") - assert %Plug.Conn{ - private: %{ - ueberauth_oidc_opts: nil, - ueberauth_oidc_claims: nil, - ueberauth_oidc_tokens: nil, - ueberauth_oidc_user_info: nil + conn = run_request_and_callback(conn, options) + + assert %Ueberauth.Auth{ + uid: "userinfo_email", + info: %Ueberauth.Auth.Info{ + name: "Full Name", + email: "userinfo_email" } - } = OIDC.handle_cleanup!(conn_with_values) + } = conn.assigns.ueberauth_auth end - test "Get the uid from the user_info" do - conn = %Plug.Conn{ - private: %{ - ueberauth_oidc_opts: [fetch_userinfo: true, userinfo_uid_field: "upn"], - ueberauth_oidc_user_info: %{"upn" => "upn_id"} - } + test "Handle callback from provider with a missing code", %{conn: conn} do + conn_with_cookies = Ueberauth.run_request(conn, :provider, {OIDC, @default_options}) + state_cookie = conn_with_cookies.resp_cookies["ueberauth.state_param"].value + + conn = %{ + conn + | params: %{ + "state" => state_cookie + }, + cookies: %{"ueberauth.state_param" => state_cookie} } - assert OIDC.uid(conn) == "upn_id" + conn = Ueberauth.run_callback(conn, :provider, {OIDC, @default_options}) + [error | _] = conn.assigns.ueberauth_failure.errors + + assert %Ueberauth.Failure.Error{ + message_key: "error", + message: "Query string does not contain field 'code'" + } = error end - test "Get the uid from the id_token if fetch_userinfo is not set" do - conn = %Plug.Conn{ - private: %{ - ueberauth_oidc_opts: [userinfo_uid_field: "upn", uid_field: "sub"], - ueberauth_oidc_claims: %{"sub" => "sub_id"}, - ueberauth_oidc_user_info: %{"upn" => "upn_id"}, - ueberauth_oidc_tokens: %{"id_token" => "4321"} - } - } + test "Handle callback from provider with an error fetching tokens", %{conn: conn} do + options = Keyword.put(@default_options, :_fetch_tokens, false) - assert OIDC.uid(conn) == "sub_id" - end + conn = run_request_and_callback(conn, options) - test "Return nil if fetch_userinfo and uid_field are not set" do - conn = %Plug.Conn{ - private: %{ - ueberauth_oidc_opts: [userinfo_uid_field: "upn"], - ueberauth_oidc_claims: %{"sub" => "sub_id"}, - ueberauth_oidc_user_info: %{"upn" => "upn_id"}, - ueberauth_oidc_tokens: %{"id_token" => "4321"} - } - } + [error | _] = conn.assigns.ueberauth_failure.errors - assert OIDC.uid(conn) == nil + assert %Ueberauth.Failure.Error{ + message_key: "error", + message: :no_tokens + } = error end - test "Get the uid from the id_token" do - conn = %Plug.Conn{ - private: %{ - ueberauth_oidc_opts: [uid_field: "sub"], - ueberauth_oidc_claims: %{"sub" => "some_uid"}, - ueberauth_oidc_tokens: %{"id_token" => "4321"} - } - } + test "Handle callback from provider with an error verifying tokens", %{conn: conn} do + options = Keyword.put(@default_options, :_verify_tokens, false) + + conn = run_request_and_callback(conn, options) - assert OIDC.uid(conn) == "some_uid" + [error | _] = conn.assigns.ueberauth_failure.errors + + assert %Ueberauth.Failure.Error{ + message_key: "error", + message: :invalid + } = error end - test "Return nil when uid_field is invalid" do - conn = %Plug.Conn{ - private: %{ - ueberauth_oidc_opts: [uid_field: "uid"], - ueberauth_oidc_claims: %{"sub" => "some_uid"}, - ueberauth_oidc_tokens: %{"id_token" => "4321"} - } - } + test "Handle callback from provider with an error fetching userinfo", %{conn: conn} do + options = + @default_options + |> Keyword.put(:fetch_userinfo, true) + |> Keyword.put(:_fetch_userinfo, false) + + conn = run_request_and_callback(conn, options) - assert OIDC.uid(conn) == nil + [error | _] = conn.assigns.ueberauth_failure.errors + + assert %Ueberauth.Failure.Error{ + message_key: "error", + message: :invalid + } = error end - test "Get credentials from the conn" do - conn = %Plug.Conn{ + test "handle cleanup of uberauth values in the conn" do + conn_with_values = %Plug.Conn{ private: %{ - ueberauth_oidc_tokens: %{ - "id_token" => "4321", - "access_token" => "1234", - "token_type" => "Bearer" - }, - ueberauth_oidc_claims: %{ - "exp" => 1234 - }, - ueberauth_oidc_opts: [provider: "some_provider"] + ueberauth_oidc_opts: :some_value, + ueberauth_oidc_claims: :other_value, + uebrauth_oidc_tokens: :another_value, + ueberauth_oidc_userinfo: :different_value } } - assert %{ - expires: true, - expires_at: 1234, - other: %{user_info: nil, provider: "some_provider"}, - token: "1234", - token_type: "Bearer" - } = OIDC.credentials(conn) + assert %Plug.Conn{ + private: %{ + ueberauth_oidc_opts: nil, + ueberauth_oidc_claims: nil, + ueberauth_oidc_tokens: nil, + ueberauth_oidc_userinfo: nil + } + } = OIDC.handle_cleanup!(conn_with_values) end test "Parses a binary exp value" do @@ -391,102 +264,26 @@ defmodule Ueberauth.Strategy.OIDCTest do } } - assert %{ + assert %Ueberauth.Auth.Credentials{ expires: true, - expires_at: 1234, - other: %{user_info: nil, provider: "some_provider"}, - token: "1234", - token_type: "Bearer" + expires_at: 1234 } = OIDC.credentials(conn) end + end - test "Credentials include a refresh token if provided" do - conn = %Plug.Conn{ - private: %{ - ueberauth_oidc_tokens: %{ - "id_token" => "4321", - "access_token" => "1234", - "token_type" => "Bearer", - "refresh_token" => "5678" - }, - ueberauth_oidc_claims: %{ - "exp" => 1234 - }, - ueberauth_oidc_opts: [provider: "some_provider"] - } - } - - assert %{refresh_token: refresh_token} = OIDC.credentials(conn) - - refute is_nil(refresh_token) - end - - test "Get info from the conn" do - conn = %Plug.Conn{ - private: %{ - ueberauth_oidc_user_info: %{ - "name" => "name", - "email" => "email" - } - } - } - - assert %Ueberauth.Auth.Info{ - name: "name", - email: "email" - } = OIDC.info(conn) - end - - test "Get info from the conn and ignore invalid fields" do - info = - OIDC.info(%Plug.Conn{ - private: %{ - ueberauth_oidc_user_info: %{ - "name" => "name", - "foo" => "bar" - } - } - }) - - refute Map.has_key?(info, :foo) - assert %Ueberauth.Auth.Info{name: "name"} = info - end - - test "Get info from the conn when there is no info field" do - info = OIDC.info(%Plug.Conn{private: %{}}) - - assert %Ueberauth.Auth.Info{name: nil, email: nil} = info - end - - test "Get info from the conn when fetch_userinfo is disabled" do - conn = %Plug.Conn{ - private: %{ - ueberauth_oidc_user_info: nil - } - } - - assert %Ueberauth.Auth.Info{ - name: nil, - email: nil - } = OIDC.info(conn) - end + defp run_request_and_callback(conn, options \\ @default_options) do + conn_with_cookies = Ueberauth.run_request(conn, :provider, {OIDC, options}) + state_cookie = conn_with_cookies.resp_cookies["ueberauth.state_param"].value - test "Puts the raw token map in the Extra struct" do - conn = %Plug.Conn{ - private: %{ - ueberauth_oidc_tokens: %{"token_key" => "token_value"}, - ueberauth_oidc_claims: %{"claim_key" => "claim_value"}, - ueberauth_oidc_opts: %{"opt_key" => "opt_value"} - } - } + conn = %{ + conn + | params: %{ + "code" => FakeOpenIDConnect.callback_code(), + "state" => state_cookie + }, + cookies: %{"ueberauth.state_param" => state_cookie} + } - assert OIDC.extra(conn) == %Ueberauth.Auth.Extra{ - raw_info: %{ - tokens: %{"token_key" => "token_value"}, - claims: %{"claim_key" => "claim_value"}, - opts: %{"opt_key" => "opt_value"} - } - } - end + Ueberauth.run_callback(conn, :provider, {OIDC, options}) end end