defmodule OIDC.Auth do
@moduledoc """
Create and verify OpenID Connect challenges for a specific OP
"""
alias OIDC.Auth.{
Challenge,
OPResponseError,
OPResponseSuccess,
ProtocolError
}
alias OIDC.Utils.ServerMetadata
alias OIDC.ClientConfig
alias OIDC.IDToken
@type challenge_opts :: [challenge_opt()]
@type challenge_opt ::
{:acr_values, [OIDC.acr()]}
| {:claims, OIDC.claims()}
| {:client_config, module()}
| {:client_id, OIDC.client_id()}
| {:display, String.t()}
| {:id_token_iat_max_time_gap, non_neg_integer()}
| {:issuer, OIDC.issuer()}
| {:login_hint, String.t()}
| {:max_age, non_neg_integer()}
| {:oauth2_metadata_updater_opts, Keyword.t()}
| {:prompt, String.t()}
| {:redirect_uri, String.t()}
| {:response_mode, OIDC.response_mode()}
| {:response_type, OIDC.response_type()}
| {:scope, [OIDC.scope()]}
| {:server_metadata, OIDC.server_metadata()}
| {:ui_locales, [OIDC.ui_locale()]}
| {:use_nonce, :when_mandatory | :always}
@type verify_opts() :: [verify_opt()]
@type verify_opt() ::
{:jti_register, module()}
| {:tesla_auth_middleware_opts, Keyword.t()}
| {:tesla_middlewares, [Tesla.Client.middleware()]}
@type op_response :: %{optional(String.t()) => any()}
@allowed_response_modes [
"query",
"fragment",
"form_post"
]
@allowed_response_types [
"code",
"id_token",
"id_token token",
"code id_token",
"code token",
"code id_token token"
]
@doc """
Generates an OpenID Connect challenge or raise an exception if a parameter is missing
This challenge is to be passed back to `verify_challenge/2` when redirected back from the
OpenID Provider
Note that a code verifier is automatically generated when supported by the OP and a code
is requested by the response type.
## Options
- `:acr_values`: voluntary set of ACRs to be requested via the `"acr_values"` parameter
- `:claims`: claims requested in the `"claims"` parameter
- `:client_config` **[Mandatory]**: a module that implements the `OIDC.Auth.ClientConfig`
behaviour
- `:client_id` **[Mandatory]**: the client id of the application using this library and
initiating the request
- `:dispay`: the display OpenID Connect parameter (mostly unused)
- `:id_token_iat_max_time_gap`: max time gap to accept an ID token, in seconds. Defaults to 0
- `:issuer` **[Mandatory]**: the OpenID Provider (OP) issuer. Metadata and JWKs are
automatically retrieved from it
- `:login_hint`: the login hint OpenID Connect parameter
- `:max_age`: the max age OpenID Connect parameter
- `:oauth2_metadata_updater_opts`: options that will be passed to `Oauth2MetadataUpdater`
- `:prompt`: the prompt OpenID Connect parameter
- `:redirect_uri` **[Mandatory]**: the redirect URI the OP has to use for redirect
- `:response_mode`: one of:
- `"query"`
- `"fragment"`
- `"form_post"`
- `nil` which means that the OP decides for the response mode
- `:response_type` **[Mandatory]**: one of:
- `"code"`
- `"id_token"`
- `"id_token token"`
- `"code token"`
- `"code id_token"`
- `"code id_token token"`
- `:scope`: a list of scopes (`[String.t()]`) to be requested. The `"openid"` scope
is automatically requested
- `:server_metadata`: server metadata that takes precedence over those automatically retrieve
on the OP configuration (requested from the issuer). Usefull when the OP does not support
OpenID Connect discovery, or the override one or more parameters
- `ui_locales`: the ui locales OpenID Connect parameter
- `:use_nonce`: one of:
- `:when_mandatory` [*Default*]: a nonce is included when using the implicit and
hybrid flows
- `:always`: always include a nonce (i.e. also in the code flow in which it is
optional)
"""
@spec gen_challenge(challenge_opts()) :: Challenge.t() | no_return()
def gen_challenge(opts) do
unless opts[:issuer], do: raise("missing issuer")
unless opts[:client_id], do: raise("missing client_id")
unless opts[:client_config], do: raise("missing client configuration callback module")
unless opts[:redirect_uri], do: raise("missing redirect URI")
unless opts[:response_type] in @allowed_response_types do
raise "Invalid response mode, must be one of: #{inspect(@allowed_response_types)}"
end
scope =
MapSet.new(opts[:scope] || [])
|> MapSet.put("openid")
|> MapSet.to_list()
%Challenge{
auth_time_required: auth_time_required?(opts),
client_id: opts[:client_id],
client_config: opts[:client_config],
id_token_iat_max_time_gap: opts[:id_token_iat_max_time_gap],
issuer: opts[:issuer],
mandatory_acrs: mandatory_acrs(opts[:claims]),
nonce: maybe_gen_nonce(opts),
oauth2_metadata_updater_opts: opts[:oauth2_metadata_updater_opts],
pkce_code_verifier: maybe_gen_pkce_code_verifier(opts),
redirect_uri: opts[:redirect_uri],
response_type: opts[:response_type],
scope: scope,
server_metadata: opts[:server_metadata],
state_param: gen_secure_random_string()
}
end
@doc """
Verifies an OpenID Connect challenge against the OP's response
## Options
- `:jti_register`: a module implementing the `JTIRegister` behaviour, used to check against
ID token replay
- `:tesla_middlewares`: `Tesla` middlewares added to outbound request (for exemple requests
to the token endpoint)
- `:tesla_auth_middleware_opts`: additional `Keyword.t()` options to be passed as options to
the `TeslaOAuth2ClientAuth` authentication middleware
"""
@spec verify_response(
op_response(),
Challenge.t(),
verify_opts()
) ::
{:ok, OPResponseSuccess.t()} | {:error, OPResponseError.t()} | {:error, Exception.t()}
def verify_response(op_response, challenge, verify_opts \\ [])
def verify_response(%{"error" => _} = op_response, _challenge, _opts) do
{
:error,
%OPResponseError{
error: op_response["error"],
error_description: op_response["error_description"],
error_uri: op_response["error_uri"]
}
}
end
def verify_response(op_response, challenge, opts) do
with :ok <- verify_response_params(op_response, challenge),
{:ok, client_config} <- client_config(challenge),
{:ok, response} <- validate_op_response(op_response, challenge, client_config, opts) do
{:ok, response}
end
end
@doc """
Generates an OpenID Connect request URI from a challenge and associated options
"""
@spec request_uri(Challenge.t(), challenge_opts()) :: URI.t()
def request_uri(challenge, opts) do
authorization_endpoint =
ServerMetadata.get(opts)["authorization_endpoint"] ||
raise "Unable to retrieve `authorization_endpoint` from server metadata or configuration"
if opts[:response_mode] && opts[:response_mode] not in @allowed_response_modes do
raise "Invalid response mode, must be one of: #{inspect(@allowed_response_modes)}"
end
{code_challenge, code_challenge_method} = maybe_hash_code_verifier_and_method(challenge, opts)
params =
Map.new()
|> Map.put(:acr_values, opts[:acr_values])
|> Map.put(:claims, opts[:claims])
|> Map.put(:client_id, challenge.client_id)
|> Map.put(:code_challenge, code_challenge)
|> Map.put(:code_challenge_method, code_challenge_method)
|> Map.put(:display, opts[:display])
|> Map.put(:id_token_hint, opts[:id_token_hint])
|> Map.put(:login_hint, opts[:login_hint])
|> Map.put(:max_age, opts[:max_age])
|> Map.put(:nonce, challenge.nonce)
|> Map.put(:prompt, opts[:prompt])
|> Map.put(:redirect_uri, challenge.redirect_uri)
|> Map.put(:response_mode, opts[:response_mode])
|> Map.put(:response_type, challenge.response_type)
|> Map.put(:scope, challenge.scope)
|> Map.put(:state, challenge.state_param)
|> Map.put(:ui_locales, opts[:ui_locales])
|> Enum.filter(fn {_k, v} -> v != nil end)
|> Enum.filter(fn {_k, v} -> v != [] end)
|> Enum.map(fn
{k, [_ | _] = v} -> {k, Enum.join(v, " ")}
{k, %{} = v} -> {k, Jason.encode!(v)}
{k, v} -> {k, to_string(v)}
end)
|> Enum.into(%{})
authorization_endpoint_uri = URI.parse(authorization_endpoint)
query =
URI.decode_query(authorization_endpoint_uri.query || "")
|> Map.merge(params)
|> URI.encode_query()
authorization_endpoint_uri
|> Map.put(:query, query)
|> Map.put(:fragment, nil)
end
@spec maybe_hash_code_verifier_and_method(Challenge.t(), challenge_opts()) ::
{String.t() | nil, String.t() | nil}
defp maybe_hash_code_verifier_and_method(%Challenge{pkce_code_verifier: nil}, _opts) do
{nil, nil}
end
defp maybe_hash_code_verifier_and_method(challenge, opts) do
methods = ServerMetadata.get(opts)["code_challenge_methods_supported"] || []
if "S256" in methods do
{
:crypto.hash(:sha256, challenge.pkce_code_verifier) |> Base.url_encode64(padding: false),
"S256"
}
else
if "plain" in methods do
{challenge.pkce_code_verifier, "plain"}
else
{nil, nil}
end
end
end
@spec auth_time_required?(challenge_opts()) :: boolean()
defp auth_time_required?(opts) do
cond do
is_integer(opts[:max_age]) ->
true
opts[:claims]["id_token"]["auth_time"]["essential"] == true ->
true
true ->
false
end
end
@spec mandatory_acrs(String.t() | nil) :: [OIDC.acr()] | nil
defp mandatory_acrs(%{"id_token" => %{"acr" => %{"essential" => true, "value" => acr}}}) do
[acr]
end
defp mandatory_acrs(%{"id_token" => %{"acr" => %{"essential" => true, "values" => acrs}}}) do
acrs
end
defp mandatory_acrs(_) do
nil
end
@spec maybe_gen_nonce(challenge_opts()) :: String.t() | nil
defp maybe_gen_nonce(opts) do
case opts[:use_nonce] do
:always ->
gen_secure_random_string()
_ ->
# implicit & hybrid flows
if opts[:response_type] in [
"id_token",
"id_token token",
"code id_token",
"code token",
"code id_token token"
] do
gen_secure_random_string()
end
end
end
@spec maybe_gen_pkce_code_verifier(challenge_opts()) :: String.t() | nil
defp maybe_gen_pkce_code_verifier(opts) do
if opts[:response_type] in ["code", "code id_token", "code token", "code id_token token"] do
case ServerMetadata.get(opts) do
%{"code_challenge_methods_supported" => [_ | _]} ->
gen_secure_random_string()
_ ->
nil
end
end
end
@spec verify_response_params(op_response(), Challenge.t()) :: :ok | {:error, Exception.t()}
defp verify_response_params(op_response, challenge) do
case challenge.response_type do
"code" ->
match?(%{"code" => _}, op_response)
"id_token" ->
match?(%{"id_token" => _}, op_response)
"id_token token" ->
match?(%{"id_token" => _, "access_token" => _, "token_type" => _}, op_response)
"code id_token" ->
match?(%{"code" => _, "id_token" => _}, op_response)
"code token" ->
match?(%{"code" => _, "access_token" => _, "token_type" => _}, op_response)
"code id_token token" ->
match?(
%{"code" => _, "id_token" => _, "access_token" => _, "token_type" => _},
op_response
)
end
|> if do
:ok
else
{:error, %ProtocolError{error: :missing_response_params}}
end
end
@spec client_config(Challenge.t()) :: {:ok, ClientConfig.t()} | {:error, Exception.t()}
defp client_config(challenge) do
case challenge.client_config.get(challenge.client_id) do
%{} = client_config ->
{:ok, client_config}
_ ->
{:error, %ProtocolError{error: :missing_client_config}}
end
end
@spec validate_op_response(
op_response(),
Challenge.t(),
ClientConfig.t(),
verify_opts()
) :: {:ok, OPResponseSuccess.t()} | {:error, Exception.t()}
defp validate_op_response(
%{"code" => code} = params,
%Challenge{response_type: "code"} = challenge,
client_config,
opts
) do
verification_data = verification_data(challenge, opts)
with :ok <- verify_resp_iss_param(challenge, params),
{:ok, token_endpoint_response} <- exchange_code(code, challenge, client_config, opts),
:ok <- validate_token_endpoint_response(token_endpoint_response),
id_token = token_endpoint_response["id_token"],
access_token = token_endpoint_response["access_token"],
{:ok, {claims, jwk}} <- IDToken.verify(id_token, client_config, verification_data),
:ok <- IDToken.verify_hash_if_present("at_hash", access_token, claims, jwk),
{:ok, granted_scopes} <- granted_scopes(token_endpoint_response, challenge) do
{
:ok,
%OPResponseSuccess{
access_token: token_endpoint_response["access_token"],
access_token_expires_in: token_endpoint_response["expires_in"],
access_token_type: token_endpoint_response["token_type"],
refresh_token: token_endpoint_response["refresh_token"],
id_token: id_token,
id_token_claims: claims,
granted_scopes: granted_scopes
}
}
end
end
defp validate_op_response(
%{"id_token" => id_token} = params,
%Challenge{response_type: "id_token"} = challenge,
client_config,
opts
) do
verification_data = verification_data(challenge, opts)
with :ok <- verify_resp_iss_param(challenge, params),
{:ok, {claims, _jwk}} <- IDToken.verify(id_token, client_config, verification_data),
:ok <- verify_resp_iss_param_matches_id_token(challenge, claims, params),
{:ok, granted_scopes} <- granted_scopes(params, challenge) do
{
:ok,
%OPResponseSuccess{
id_token: id_token,
id_token_claims: claims,
granted_scopes: granted_scopes
}
}
end
end
defp validate_op_response(
%{"id_token" => id_token, "access_token" => access_token} = params,
%Challenge{response_type: "id_token token"} = challenge,
client_config,
opts
) do
verification_data = verification_data(challenge, opts)
with :ok <- verify_resp_iss_param(challenge, params),
{:ok, {claims, jwk}} <- IDToken.verify(id_token, client_config, verification_data),
:ok <- verify_resp_iss_param_matches_id_token(challenge, claims, params),
{:ok, granted_scopes} <- granted_scopes(params, challenge),
:ok <- IDToken.verify_hash("at_hash", access_token, claims, jwk) do
{
:ok,
%OPResponseSuccess{
access_token: access_token,
access_token_expires_in: params["expires_in"],
access_token_type: params["token_type"],
id_token: params["id_token"],
id_token_claims: claims,
granted_scopes: granted_scopes
}
}
end
end
defp validate_op_response(
%{"code" => code, "id_token" => id_token} = params,
%Challenge{response_type: "code id_token"} = challenge,
client_config,
opts
) do
verification_data = verification_data(challenge, opts)
with :ok <- verify_resp_iss_param(challenge, params),
{:ok, {claims, jwk}} <- IDToken.verify(id_token, client_config, verification_data),
:ok <- verify_resp_iss_param_matches_id_token(challenge, claims, params),
:ok <- IDToken.verify_hash("c_hash", code, claims, jwk),
{:ok, token_endpoint_response} <- exchange_code(code, challenge, client_config, opts),
:ok <- validate_token_endpoint_response(token_endpoint_response),
id_token = token_endpoint_response["id_token"],
access_token = token_endpoint_response["access_token"],
# we through away the first ID token, because the new one must be the same except
# it can contains more claims
{:ok, {claims, jwk}} <- IDToken.verify(id_token, client_config, verification_data),
{:ok, granted_scopes} <- granted_scopes(token_endpoint_response, challenge),
:ok <- IDToken.verify_hash_if_present("at_hash", access_token, claims, jwk),
:ok <- IDToken.verify_hash_if_present("c_hash", code, claims, jwk) do
{
:ok,
%OPResponseSuccess{
access_token: access_token,
access_token_expires_in: token_endpoint_response["expires_in"],
access_token_type: token_endpoint_response["token_type"],
refresh_token: token_endpoint_response["refresh_token"],
id_token: id_token,
id_token_claims: claims,
granted_scopes: granted_scopes
}
}
end
end
defp validate_op_response(
%{"code" => code, "access_token" => _access_token} = params,
%Challenge{response_type: "code token"} = challenge,
client_config,
opts
) do
verification_data = verification_data(challenge, opts)
with :ok <- verify_resp_iss_param(challenge, params),
{:ok, token_endpoint_response} <- exchange_code(code, challenge, client_config, opts),
:ok <- validate_token_endpoint_response(token_endpoint_response),
id_token = token_endpoint_response["id_token"],
access_token = token_endpoint_response["access_token"],
{:ok, {claims, jwk}} <- IDToken.verify(id_token, client_config, verification_data),
{:ok, granted_scopes} <- granted_scopes(token_endpoint_response, challenge),
:ok <- IDToken.verify_hash_if_present("at_hash", access_token, claims, jwk),
:ok <- IDToken.verify_hash_if_present("c_hash", code, claims, jwk) do
{
:ok,
%OPResponseSuccess{
access_token: access_token,
access_token_expires_in: token_endpoint_response["expires_in"],
access_token_type: token_endpoint_response["token_type"],
refresh_token: token_endpoint_response["refresh_token"],
id_token: id_token,
id_token_claims: claims,
granted_scopes: granted_scopes
}
}
end
end
defp validate_op_response(
%{"code" => code, "id_token" => id_token, "access_token" => access_token} = params,
%Challenge{response_type: "code id_token token"} = challenge,
client_config,
opts
) do
verification_data = verification_data(challenge, opts)
with :ok <- verify_resp_iss_param(challenge, params),
{:ok, {claims, jwk}} <- IDToken.verify(id_token, client_config, verification_data),
:ok <- verify_resp_iss_param_matches_id_token(challenge, claims, params),
:ok <- IDToken.verify_hash("at_hash", access_token, claims, jwk),
:ok <- IDToken.verify_hash("c_hash", code, claims, jwk),
{:ok, token_endpoint_response} <- exchange_code(code, challenge, client_config, opts),
:ok <- validate_token_endpoint_response(token_endpoint_response),
id_token = token_endpoint_response["id_token"],
access_token = token_endpoint_response["access_token"],
# we through away the first ID token, because the new one must be the same except
# it can contains more claims
{:ok, {claims, jwk}} <- IDToken.verify(id_token, client_config, verification_data),
{:ok, granted_scopes} <- granted_scopes(token_endpoint_response, challenge),
:ok <- IDToken.verify_hash_if_present("at_hash", access_token, claims, jwk),
:ok <- IDToken.verify_hash_if_present("c_hash", code, claims, jwk) do
{
:ok,
%OPResponseSuccess{
access_token: access_token,
access_token_expires_in: token_endpoint_response["expires_in"],
access_token_type: token_endpoint_response["token_type"],
refresh_token: token_endpoint_response["refresh_token"],
id_token: id_token,
id_token_claims: claims,
granted_scopes: granted_scopes
}
}
end
end
@spec validate_token_endpoint_response(map()) :: :ok | {:error, Exception.t()}
defp validate_token_endpoint_response(%{
"access_token" => _,
"token_type" => _,
"id_token" => _
}) do
:ok
end
defp validate_token_endpoint_response(_) do
{:error, %ProtocolError{error: :token_endpoint_invalid_response}}
end
@spec exchange_code(
String.t(),
Challenge.t(),
ClientConfig.t(),
verify_opts()
) :: {:ok, map()} | {:error, Exception.t()}
defp exchange_code(code, challenge, client_config, opts) do
token_endpoint =
ServerMetadata.get(challenge)["token_endpoint"] ||
raise "Unable to retrieve `token_endpoint` from server metadata or configuration"
body =
%{
"grant_type" => "authorization_code",
"code" => code,
"redirect_uri" => challenge.redirect_uri
}
|> maybe_set_code_verifier(challenge)
with {:ok, middlewares} <- tesla_middlewares(challenge, client_config, opts),
http_client = Tesla.client(middlewares, tesla_adapter()),
{:ok, %Tesla.Env{status: 200} = resp} <- Tesla.post(http_client, token_endpoint, body),
true <- header_has_value?(resp.headers, "Cache-Control", "no-store"),
true <- header_has_value?(resp.headers, "Pragma", "no-cache") do
{:ok, resp.body}
else
{:ok, %Tesla.Env{status: status, body: body}} ->
{:error,
%ProtocolError{
error: :token_endpoint_invalid_http_status,
details: %{
status: status,
error: body["error"]
}
}}
{:error, _} ->
{:error, %ProtocolError{error: :token_endpoint_http_error}}
false ->
{:error, %ProtocolError{error: :token_endpoint_invalid_cache_header}}
end
end
@spec header_has_value?(Tesla.Env.headers(), String.t(), String.t()) :: boolean()
defp header_has_value?(headers, name, value) do
headers
|> Enum.find(fn {k, _v} -> String.downcase(name) == String.downcase(k) end)
|> case do
nil ->
false
{_, v} ->
v =~ value
end
end
@spec maybe_set_code_verifier(map(), Challenge.t()) :: map()
defp maybe_set_code_verifier(body, %Challenge{pkce_code_verifier: nil}), do: body
defp maybe_set_code_verifier(body, challenge),
do: Map.put(body, "code_verifier", challenge.pkce_code_verifier)
@spec tesla_middlewares(
Challenge.t(),
ClientConfig.t(),
verify_opts()
) :: {:ok, [Tesla.Client.middleware()]} | {:error, Exception.t()}
defp tesla_middlewares(challenge, client_config, opts) do
auth_method = client_config["token_endpoint_auth_method"] || "client_secret_basic"
case TeslaOAuth2ClientAuth.implementation(auth_method) do
{:ok, authenticator} ->
middleware_opts =
Map.merge(
opts[:tesla_auth_middleware_opts] || %{},
%{
client_config: client_config,
server_metadata: ServerMetadata.get(challenge)
}
)
{
:ok,
[{authenticator, middleware_opts}] ++
[Tesla.Middleware.FormUrlencoded] ++
[Tesla.Middleware.DecodeJson] ++
(opts[:tesla_middlewares] || []) ++
Application.get_env(:oidc, :tesla_middlewares, [])
}
{:error, _} ->
{:error, %ProtocolError{error: :token_endpoint_authenticator_not_found}}
end
end
@spec granted_scopes(
op_response(),
Challenge.t()
) :: {:ok, [OIDC.scope()]} | {:error, Exception.t()}
defp granted_scopes(%{"scope" => scope_param}, _challenge) do
case OAuth2Utils.Scope.Set.from_scope_param(scope_param) do
{:ok, scope_set} ->
{:ok, MapSet.to_list(scope_set)}
{:error, _} ->
{:error, %ProtocolError{error: :op_response_malformed_scope_param}}
end
end
defp granted_scopes(_op_response, challenge) do
{:ok, challenge.scope}
end
defp verify_resp_iss_param(challenge, params) do
case ServerMetadata.get(challenge) do
%{"authorization_response_iss_parameter_supported" => true, "issuer" => issuer} ->
if issuer == params["iss"] do
:ok
else
{:error, %ProtocolError{error: :non_matching_resp_iss_param}}
end
_ ->
:ok
end
end
defp verify_resp_iss_param_matches_id_token(challenge, claims, params) do
if ServerMetadata.get(challenge)["authorization_response_iss_parameter_supported"] do
if claims["iss"] == params["iss"] do
:ok
else
{:error, %ProtocolError{error: :non_matching_resp_iss_param_with_id_token}}
end
else
:ok
end
end
@spec gen_secure_random_string() :: String.t()
defp gen_secure_random_string() do
:crypto.strong_rand_bytes(32)
|> Base.url_encode64(padding: false)
end
@spec verification_data(Challenge.t(), verify_opts()) :: OIDC.IDToken.verification_data()
defp verification_data(challenge, opts) do
challenge
|> Map.from_struct()
|> Map.put(:jti_register, opts[:jti_register])
|> Enum.reject(fn {_k, v} -> v == nil end)
|> Enum.into(%{})
end
defp tesla_adapter(), do: Application.get_env(:tesla, :adapter, Tesla.Adapter.Hackney)
end