defmodule Ueberauth.Strategy.Oidcc do @moduledoc """ OIDC Strategy for Ueberauth. """ use Ueberauth.Strategy alias Ueberauth.Auth.Credentials alias Ueberauth.Auth.Extra alias Ueberauth.Auth.Info @session_key "ueberauth_strategy_oidcc" @doc """ Handles the initial authentication request. """ def handle_request!(conn) do opts = get_options!(conn) # Nonce: stored as raw bytes, sent as an encoded SHA512 string. This is the # approach recommended by the spec: # https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes # 64 random bytes is the same size as the SHA512 output. raw_nonce = :crypto.strong_rand_bytes(64) # PKCE Verifier: a 43 - 128 character string from the alphabet [A-Z] / [a-z] # / [0-9] / "-" / "." / "_" / "~". The recommendation is to generate a # random sequence and then base64url-encode it. # https://datatracker.ietf.org/doc/html/rfc7636#page-8 # 96 random bytes results in an encoded 128 byte verifier. pkce_verifier = url_encode64(:crypto.strong_rand_bytes(96)) redirect_params = [ response_type: opts.response_type, redirect_uri: opts.redirect_uri, pkce_verifier: pkce_verifier, nonce: url_encode64(:crypto.hash(:sha512, raw_nonce)), scopes: opts.scopes ] |> with_state_param(conn) |> Map.new() case create_redirect_url(opts, redirect_params) do {:ok, uri} -> conn |> put_session(@session_key, %{ raw_nonce: raw_nonce, pkce_verifier: pkce_verifier, redirect_uri: opts.redirect_uri }) |> redirect!(IO.iodata_to_binary(uri)) {:error, reason} when is_binary(reason) -> set_error!( conn, "create_redirect_url", reason ) {:error, reason} -> set_error!( conn, "create_redirect_url", inspect(reason) ) end end @doc """ Handles the callback from the oidc provider. """ def handle_callback!(%{params: %{"code" => code}} = conn) when is_binary(code) do session = get_session(conn, @session_key, %{}) opts = get_options!(conn) conn = conn |> delete_session(@session_key) |> put_private(:ueberauth_oidcc_opts, opts) userinfo? = Map.get(opts, :userinfo, false) nonce = case Map.fetch(session, :raw_nonce) do {:ok, raw_nonce} -> url_encode64(:crypto.hash(:sha512, raw_nonce)) :error -> :any end retrieve_token_params = Map.merge( opts, %{ nonce: nonce, pkce_verifier: Map.get(session, :pkce_verifier, :none) } ) maybe_token = with :ok <- validate_redirect_uri(Map.get(session, :redirect_uri, :any), conn), {:ok, token} <- opts.module.retrieve_token( code, opts.issuer, opts.client_id, opts.client_secret, retrieve_token_params ) do {:ok, token} else {:error, {:none_alg_used, token}} when userinfo? -> # the none algorithm is okay for the ID token if we then verify the # userinfo (oidcc-client-test-idtoken-sig-none) {:ok, token} {:error, reason} -> {:error, reason} end with {:ok, token} <- maybe_token, :ok <- validate_token_scopes(token, retrieve_token_params.scopes, opts.validate_scopes) do conn |> put_private(:ueberauth_oidcc_token, token) |> maybe_put_userinfo(userinfo?) else {:error, {:additional_scopes, scopes}} -> set_error!( conn, "retrieve_token", "Unrequested scopes received: #{Enum.intersperse(scopes, " ")}" ) {:error, {:invalid_redirect_uri, uri}} -> set_error!(conn, "retrieve_token", "Redirected to the wrong URI: #{uri}") {:error, reason} -> set_error!(conn, "retrieve_token", inspect(reason)) end end def handle_callback!(conn) do set_error!(conn, "code", "Query string does not contain field 'code'") end defp create_redirect_url(opts, redirect_params) defp create_redirect_url( %{authorization_endpoint: authorization_endpoint} = opts, redirect_params ) when is_binary(authorization_endpoint) do redirect_params = redirect_params |> Map.put(:client_id, opts.client_id) |> Map.put(:scope, Enum.join(redirect_params.scopes, " ")) |> Map.delete(:scopes) |> Map.merge(Map.get(opts, :authorization_params, %{})) query = URI.encode_query(redirect_params) {:ok, "#{authorization_endpoint}?#{query}"} end defp create_redirect_url(opts, redirect_params) do redirect_params = case Map.fetch(opts, :authorization_params) do {:ok, additional} -> Map.put(redirect_params, :url_extension, to_url_extension(additional)) :error -> redirect_params end case opts do %{issuer: _, client_id: _} -> opts.module.create_redirect_url( opts.issuer, opts.client_id, :unauthenticated, redirect_params ) %{client_id: _} -> {:error, "Missing issuer"} %{} -> {:error, "Missing client_id"} end end # https://openid.net/specs/openid-financial-api-part-1-1_0.html#public-client # > shall store the redirect URI value in the resource owner's user-agents # > (such as browser) session and compare it with the redirect URI that the # > authorization response was received at, where, if the URIs do not match, the # > client shall terminate the process with error defp validate_redirect_uri(:any, _) do :ok end defp validate_redirect_uri(uri, conn) do # generate the current URL but without the query string parameters case Plug.Conn.request_url(%{conn | query_string: ""}) do ^uri -> :ok actual_uri -> {:error, {:invalid_redirect_uri, actual_uri}} end end defp validate_token_scopes(token, requested_scopes, validate_scopes?) defp validate_token_scopes(_, _, false) do :ok end defp validate_token_scopes(token, requested_scopes, true) do # https://openid.net/specs/openid-financial-api-part-1-1_0.html#public-client # # > shall verify that the scope received in the token response is either # > an exact match, or contains a subset of the scope sent in the # > authorization request; and additional_scopes = for scope <- token.scope, scope not in requested_scopes do scope end if additional_scopes == [] do :ok else {:error, {:additional_scopes, additional_scopes}} end end defp maybe_put_userinfo(conn, true) do opts = conn.private.ueberauth_oidcc_opts case opts.module.retrieve_userinfo( conn.private.ueberauth_oidcc_token, opts.issuer, opts.client_id, opts.client_secret, opts ) do {:ok, userinfo} -> put_private(conn, :ueberauth_oidcc_userinfo, userinfo) {:error, reason} -> set_error!(conn, "retrieve_userinfo", inspect(reason)) end end defp maybe_put_userinfo(conn, false) do conn end @doc false def handle_cleanup!(conn) do conn |> put_private(:ueberauth_oidcc_opts, nil) |> put_private(:ueberauth_oidcc_token, nil) |> put_private(:ueberauth_oidcc_userinfo, nil) end @doc """ Returns the configured uid field from the claims. """ def uid(conn) do opts = conn.private.ueberauth_oidcc_opts uid_field = Map.get(opts, :uid_field, "sub") case conn.private do %{ueberauth_oidcc_userinfo: %{^uid_field => uid}} -> uid %{ueberauth_oidcc_token: token} -> Map.get(token.id.claims, uid_field) end end @doc """ Returns the credentials from the oidc response. `other` includes `id_token` """ def credentials(conn) do token = conn.private.ueberauth_oidcc_token refresh_token = case token.refresh do %{token: token} -> token _ -> nil end expires_at = case token.access.expires do e when is_integer(e) -> System.system_time(:second) + e _ -> nil end %Credentials{ token: token.access.token, refresh_token: refresh_token, token_type: "Bearer", expires: !!token.access.expires, expires_at: expires_at, scopes: token.scope, other: %{ id_token: token.id.token } } end @doc """ Returns an `Ueberauth.Auth.Extra` struct containing the claims and userinfo response. """ def extra(conn) do %Extra{ raw_info: %UeberauthOidcc.RawInfo{ opts: conn.private.ueberauth_oidcc_opts, claims: conn.private.ueberauth_oidcc_token.id.claims, userinfo: conn.private[:ueberauth_oidcc_userinfo] } } end @doc """ Returns a `Ueberauth.Auth.Info` struct populated with the data returned from the userinfo endpoint. This information is also included in the `Ueberauth.Auth.Credentials` struct. """ def info(conn) do userinfo = conn.private[:ueberauth_oidcc_userinfo] || %{} claims = Map.merge(conn.private.ueberauth_oidcc_token.id.claims, userinfo) urls = %{} |> add_optional_url(:profile, claims["profile"]) |> add_optional_url(:website, claims["website"]) # https://openid.net/specs/openid-connect-core-1_0.html#Claims %Info{ name: claims["name"], first_name: claims["given_name"], last_name: claims["family_name"], nickname: claims["nickname"], email: claims["email"], # address claim is a JSON blob location: nil, description: nil, image: claims["picture"], phone: claims["phone_number"], birthday: claims["birthdate"], urls: urls } end defp set_error!(conn, key, message) when is_binary(key) and is_binary(message) do set_errors!(conn, [error(key, message)]) end defp get_options!(conn) do defaults = %{ module: Oidcc, redirect_uri: callback_url(conn), response_type: "code", scopes: ["openid"], validate_scopes: false } compile_opts = Map.new(options(conn)) runtime_opts = Map.new( (Application.get_env(:ueberauth_oidcc, :providers) || [])[strategy_name(conn)] || %{} ) defaults |> Map.merge(compile_opts) |> Map.merge(runtime_opts) end defp add_optional_url(urls, field, value) defp add_optional_url(urls, _field, nil), do: urls defp add_optional_url(urls, field, value), do: Map.put(urls, field, value) defp to_url_extension(enum) do for {key, value} <- enum do key = case key do a when is_atom(a) -> Atom.to_string(a) b when is_binary(b) -> b end {key, value} end end defp url_encode64(bytes) do Base.url_encode64(bytes, padding: false) end end