defmodule Assent.Strategy.OIDC do
@moduledoc """
OpenID Connect strategy.
This is built upon the `Assent.Strategy.OAuth2` strategy with added OpenID
Connect capabilities.
## Configuration
- `:client_id` - The client id, required
- `:site` - The OIDC issuer, required
- `:openid_configuration_uri` - The URI for OpenID Provider, optional,
defaults to `/.well-known/openid-configuration`
- `:client_authentication_method` - The Client Authentication method to
use, optional, defaults to `client_secret_basic`
- `:client_secret` - The client secret, required if
`:client_authentication_method` is `:client_secret_basic`,
`:client_secret_post`, or `:client_secret_jwt`
- `:openid_configuration` - The OpenID configuration, optional, the
configuration will be fetched from `:openid_configuration_uri` if this is
not defined
- `:id_token_signed_response_alg` - The `id_token_signed_response_alg`
parameter sent by the Client during Registration, defaults to `RS256`
- `:id_token_ttl_seconds` - The number of seconds from `iat` that an ID
Token will be considered valid, optional, defaults to nil
- `:nonce` - The nonce to use for authorization request, optional, MUST be
session based and unguessable
See `Assent.Strategy.OAuth2` for more configuration options.
## Usage
config = [
client_id: "REPLACE_WITH_CLIENT_ID",
site: "https://server.example.com",
authorization_params: [scope: "user:read user:write"]
]
{:ok, {url: url, session_params: session_params}} =
config
|> Assent.Config.put(:redirect_uri, "http://localhost:4000/auth/callback")
|> Assent.Strategy.OIDC.authorize_url()
{:ok, %{user: user, token: token}} =
config
|> Assent.Config.put(:session_params, session_params)
|> Assent.Strategy.OIDC.callback(params)
## Nonce
`:nonce` can be set in the provider config. The `:nonce` will be returned in
the `:session_params` along with `:state`. You can use this to store the value
in the current session e.g. a httpOnly session cookie.
A random value generator can look like this:
16
|> :crypto.strong_rand_bytes()
|> Base.encode64(padding: false)
PowAssent will dynamically generate one for the session if `:nonce` is set to
`true`.
See `Assent.Strategy.OIDC.authorize_url/1` for more.
"""
@behaviour Assent.Strategy
alias Assent.Strategy, as: Helpers
alias Assent.{Config, HTTPAdapter.HTTPResponse, RequestError, Strategy.OAuth2}
@doc """
Generates an authorization URL for request phase.
The authorization url will be fetched from the OpenID configuration URI.
`openid` will automatically be added to the `:scope` in
`:authorization_params`, unless `:openid_default_scope` has been set.
Add `:nonce` to the config to pass it with the authorization request. The
nonce will be returned in `:session_params`. The nonce MUST be session based
and unguessable. A cryptographic hash of a cryptographically random value
could be stored in a httpOnly session cookie.
See `Assent.Strategy.OAuth2.authorize_url/1` for more.
"""
@impl true
@spec authorize_url(Config.t()) :: {:ok, %{session_params: %{state: binary()} | %{state: binary(), nonce: binary()}, url: binary()}} | {:error, term()}
def authorize_url(config) do
with {:ok, openid_config} <- openid_configuration(config),
{:ok, authorize_url} <- fetch_from_openid_config(openid_config, "authorization_endpoint"),
{:ok, params} <- authorization_params(config) do
config
|> Config.put(:authorization_params, params)
|> Config.put(:authorize_url, authorize_url)
|> OAuth2.authorize_url()
|> add_nonce_to_session_params(config)
end
end
defp openid_configuration(config) do
case Config.get(config, :openid_configuration, nil) do
nil -> fetch_openid_configuration(config)
openid_config -> {:ok, openid_config}
end
end
defp fetch_openid_configuration(config) do
with {:ok, site} <- Config.fetch(config, :site) do
configuration_url = Config.get(config, :openid_configuration_uri, "/.well-known/openid-configuration")
url = Helpers.to_url(site, configuration_url)
:get
|> Helpers.request(url, nil, [], config)
|> Helpers.decode_response(config)
|> process_openid_configuration_response()
end
end
defp process_openid_configuration_response({:ok, %HTTPResponse{status: 200, body: configuration}}), do: {:ok, configuration}
defp process_openid_configuration_response(any), do: process_response(any)
defp process_response({:ok, %HTTPResponse{} = response}), do: {:error, RequestError.unexpected(response)}
defp process_response({:error, %HTTPResponse{} = response}), do: {:error, RequestError.invalid(response)}
defp process_response({:error, error}), do: {:error, error}
defp fetch_from_openid_config(config, key) do
case Map.fetch(config, key) do
{:ok, value} -> {:ok, value}
:error -> {:error, "`#{key}` not found in OpenID configuration"}
end
end
defp authorization_params(config) do
new_params =
config
|> Config.get(:authorization_params, [])
|> add_default_scope_param(config)
|> add_nonce_param(config)
{:ok, new_params}
end
defp add_default_scope_param(params, config) do
scope = Config.get(params, :scope, "")
default = Config.get(config, :openid_default_scope, "openid")
new_scope = String.trim(default <> " " <> scope)
Config.put(params, :scope, new_scope)
end
defp add_nonce_param(params, config) do
case Config.get(config, :nonce, nil) do
nil -> params
nonce -> Config.put(params, :nonce, nonce)
end
end
defp add_nonce_to_session_params({:ok, resp}, config) do
case Config.get(config, :nonce, nil) do
nil ->
{:ok, resp}
nonce ->
session_params =
resp
|> Map.get(:session_params, %{})
|> Map.put(:nonce, nonce)
{:ok, Map.put(resp, :session_params, session_params)}
end
end
defp add_nonce_to_session_params({:error, error}, _config),
do: {:error, error}
@doc """
Callback phase for generating access token and fetch user data.
The token url will be fetched from the OpenID configuration URI.
If the returned ID Token is signed with a symmetric key, `:client_secret`
will be required and used to verify the ID Token. If it was signed with a
private key, the appropriate public key will be fetched from the `jwks_uri`
setting in the OpenID configuration to verify the ID Token.
The ID Token will be validated per
[OpenID Connect Core 1.0 rules](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation).
See `Assent.Strategy.OAuth2.callback/3` for more.
"""
@impl true
@spec callback(Config.t(), map(), atom()) :: {:ok, %{user: map(), token: map()}} | {:error, term()}
def callback(config, params, strategy \\ __MODULE__) do
with {:ok, openid_config} <- openid_configuration(config),
{:ok, method} <- fetch_client_authentication_method(openid_config, config),
{:ok, token_url} <- fetch_from_openid_config(openid_config, "token_endpoint") do
config
|> Config.put(:openid_configuration, openid_config)
|> Config.put(:auth_method, method)
|> Config.put(:token_url, token_url)
|> OAuth2.callback(params, strategy)
end
end
defp fetch_client_authentication_method(openid_config, config) do
method = Config.get(config, :client_authentication_method, "client_secret_basic")
methods = Map.get(openid_config, "token_endpoint_auth_methods_supported", ["client_secret_basic"])
case method in methods do
true -> to_client_auth_method(method)
false -> {:error, "Unsupported client authentication method: #{method}"}
end
end
defp to_client_auth_method("client_secret_basic"), do: {:ok, :client_secret_basic}
defp to_client_auth_method("client_secret_post"), do: {:ok, :client_secret_post}
defp to_client_auth_method("client_secret_jwt"), do: {:ok, :client_secret_jwt}
defp to_client_auth_method("private_key_jwt"), do: {:ok, :private_key_jwt}
defp to_client_auth_method(method), do: {:error, "Invalid client authentication method: #{method}"}
# https://openid.net/specs/draft-jones-json-web-token-07.html#ReservedClaimName
@reserved_jwt_names ~w(exp nbf iat iss aud prn jti typ)
# https://openid.net/specs/openid-connect-core-1_0.html#IDToken
@id_token_names ~w(iss sub aud exp iat auth_time nonce acr amr azp at_hash c_hash sub_jwk)
# All ID Token claim names to be excluded from the user params
@id_token_names_to_exclude Enum.uniq(@reserved_jwt_names ++ @id_token_names -- ~w(sub))
@doc """
Fetches user params from ID token.
The ID Token is validated, and the claims is returned as the user params.
Use `fetch_userinfo/2` to fetch the claims from the `userinfo` endpoint.
"""
@spec fetch_user(Config.t(), map()) :: {:ok, map()} | {:error, term()}
def fetch_user(config, token) do
with {:ok, id_token} <- fetch_id_token(token),
{:ok, jwt} <- validate_id_token(config, id_token) do
{:ok, Map.drop(jwt.claims, @id_token_names_to_exclude)}
end
end
defp fetch_id_token(token) do
case Map.fetch(token, "id_token") do
{:ok, id_token} -> {:ok, id_token}
:error -> {:error, "The `id_token` key not found in token params, only found these keys: #{Enum.join(Map.keys(token), ", ")}"}
end
end
@doc """
Validates the ID token.
The OpenID configuration will be dynamically fetched if not set in the
config.
The ID Token will be validated per
[OpenID Connect Core 1.0 rules](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation).
"""
@spec validate_id_token(Config.t(), binary()) :: {:ok, map()} | {:error, term()}
def validate_id_token(config, id_token) do
expected_alg = Config.get(config, :id_token_signed_response_alg, "RS256")
with {:ok, openid_config} <- openid_configuration(config),
{:ok, client_id} <- Config.fetch(config, :client_id),
{:ok, issuer} <- fetch_from_openid_config(openid_config, "issuer"),
{:ok, jwt} <- verify_jwt(id_token, openid_config, config),
:ok <- validate_required_fields(jwt),
:ok <- validate_issuer_identifer(jwt, issuer),
:ok <- validate_audience(jwt, client_id),
:ok <- validate_alg(jwt, expected_alg),
:ok <- validate_verified(jwt),
:ok <- validate_expiration(jwt),
:ok <- validate_issued_at(jwt, config),
:ok <- validate_nonce(jwt, config) do
{:ok, jwt}
end
end
defp verify_jwt(token, openid_config, config) do
with {:ok, header} <- peek_header(token, config),
{:ok, secret_or_key} <- fetch_secret(header, openid_config, config) do
Helpers.verify_jwt(token, secret_or_key, config)
end
end
defp peek_header(encoded, config) do
with [header, _, _] <- String.split(encoded, "."),
{:ok, json} <- Base.url_decode64(header, padding: false) do
Config.json_library(config).decode(json)
else
{:error, error} -> {:error, error}
_any -> {:error, "The ID Token is not a valid JWT"}
end
end
defp fetch_secret(%{"alg" => "none"}, _openid_config, _config), do: {:ok, ""}
defp fetch_secret(%{"alg" => "HS" <> _rest}, _openid_config, config) do
Config.fetch(config, :client_secret)
end
defp fetch_secret(header, openid_config, config) do
with {:ok, jwks_uri} <- fetch_from_openid_config(openid_config, "jwks_uri"),
{:ok, keys} <- fetch_public_keys(jwks_uri, config) do
find_key(header, keys)
end
end
defp fetch_public_keys(uri, config) do
:get
|> Helpers.request(uri, nil, [], config)
|> Helpers.decode_response(config)
|> process_public_keys_response()
end
defp process_public_keys_response({:ok, %HTTPResponse{status: 200, body: %{"keys" => keys}}}), do: {:ok, keys}
defp process_public_keys_response({:ok, %HTTPResponse{status: 200}}), do: {:ok, []}
defp process_public_keys_response(any), do: process_response(any)
defp find_key(%{"kid" => kid}, [%{"kid" => kid} = key | _keys]), do: {:ok, key}
defp find_key(%{"kid" => _kid} = header, [%{"kid" => _other} | keys]), do: find_key(header, keys)
defp find_key(%{"kid" => kid}, []), do: {:error, "No keys found for the `kid` value \"#{kid}\" provided in ID Token"}
defp find_key(_header, []), do: {:error, "No keys found in `jwks_uri` provider configuration"}
defp find_key(_header, [key]), do: {:ok, key}
defp find_key(_header, _keys), do: {:error, "Multiple public keys found in provider configuration and no `kid` value in ID Token"}
defp validate_required_fields(%{claims: claims}) do
Enum.find_value(~w(iss sub aud exp iat), :ok, fn key ->
case Map.has_key?(claims, key) do
true -> nil
false -> {:error, "Missing `#{key}` in ID Token claims"}
end
end)
end
defp validate_issuer_identifer(%{claims: %{"iss" => iss}}, iss), do: :ok
defp validate_issuer_identifer(%{claims: %{"iss" => iss}}, _iss), do: {:error, "Invalid issuer \"#{iss}\" in ID Token"}
defp validate_audience(%{claims: %{"aud" => aud}}, aud), do: :ok
defp validate_audience(%{claims: %{"aud" => aud}}, _client_id), do: {:error, "Invalid audience \"#{aud}\" in ID Token"}
defp validate_alg(%{header: %{"alg" => alg}}, alg), do: :ok
defp validate_alg(%{header: %{"alg" => alg}}, expected_alg), do: {:error, "Expected `alg` in ID Token to be \"#{expected_alg}\", got \"#{alg}\""}
defp validate_verified(%{verified?: true}), do: :ok
defp validate_verified(%{verified?: false}), do: {:error, "Invalid JWT signature for ID Token"}
defp validate_expiration(%{claims: %{"exp" => exp}}) do
now = :os.system_time(:second)
case exp > now do
true -> :ok
false -> {:error, "The ID Token has expired"}
end
end
defp validate_issued_at(%{claims: %{"iat" => iat}}, config) do
case Config.get(config, :id_token_ttl_seconds, nil) do
nil -> :ok
ttl -> validate_ttl_reached(iat, ttl)
end
end
defp validate_ttl_reached(iat, ttl) do
now = :os.system_time(:second)
case iat + ttl > now do
true -> :ok
false -> {:error, "The ID Token was issued too long ago"}
end
end
defp validate_nonce(jwt, config) do
with {:ok, session_params} <- Config.fetch(config, :session_params) do
validate_for_nonce(session_params, jwt)
end
end
defp validate_for_nonce(%{nonce: stored_nonce}, %{claims: %{"nonce" => provided_nonce}}) do
case Assent.constant_time_compare(stored_nonce, provided_nonce) do
true -> :ok
false -> {:error, "Invalid `nonce` included in ID Token"}
end
end
defp validate_for_nonce(%{nonce: _nonce}, _jwt),
do: {:error, "`nonce` is not included in ID Token"}
defp validate_for_nonce(_any, %{claims: %{"nonce" => _nonce}}),
do: {:error, "`nonce` included in ID Token but doesn't exist in session params"}
defp validate_for_nonce(_any, _jwt), do: :ok
@doc """
Fetches claims from userinfo endpoint.
The userinfo will be fetched from the `userinfo_endpoint` OpenID
configuration.
The returned claims will be validated against the `id_token` verifying that
`sub` is equal.
"""
@spec fetch_userinfo(Config.t(), map()) :: {:ok, map()} | {:error, term()}
def fetch_userinfo(config, token) do
with {:ok, openid_config} <- openid_configuration(config),
{:ok, userinfo_url} <- fetch_from_openid_config(openid_config, "userinfo_endpoint"),
{:ok, claims} <- fetch_from_userinfo_endpoint(config, openid_config, token, userinfo_url),
:ok <- validate_userinfo_sub(config, token["id_token"], claims) do
{:ok, claims}
end
end
defp fetch_from_userinfo_endpoint(config, openid_config, token, userinfo_url) do
config
|> OAuth2.request(token, :get, userinfo_url)
|> process_userinfo_response(openid_config, config)
end
defp process_userinfo_response({:ok, %HTTPResponse{status: 200, body: body, headers: headers}}, openid_config, config) do
case List.keyfind(headers, "content-type", 0) do
{"content-type", "application/jwt" <> _rest} -> process_jwt(body, openid_config, config)
_any -> {:ok, body}
end
end
defp process_userinfo_response({:error, %HTTPResponse{status: 401}}, _openid_config, _config), do: {:error, %RequestError{message: "Unauthorized token"}}
defp process_userinfo_response(any, _openid_config, _config), do: process_response(any)
defp process_jwt(body, openid_config, config) do
with {:ok, jwt} <- verify_jwt(body, openid_config, config),
:ok <- validate_verified(jwt) do
{:ok, jwt.claims}
end
end
defp validate_userinfo_sub(config, id_token, claims) when is_binary(id_token) do
with {:ok, jwt} <- validate_id_token(config, id_token) do
validate_userinfo_sub(config, jwt.claims, claims)
end
end
defp validate_userinfo_sub(_config, %{"sub" => sub}, %{"sub" => sub}), do: :ok
defp validate_userinfo_sub(_config, %{"sub" => _sub_1}, %{"sub" => _sub_2}), do: {:error, "`sub` in userinfo response not the same as in ID Token"}
defp validate_userinfo_sub(_config, %{"sub" => _sub}, _claims), do: {:error, "`sub` not in userinfo response"}
end