diff --git a/lib/ueberauth/strategy/keycloak.ex b/lib/ueberauth/strategy/keycloak.ex index 8ac9244..0154988 100644 --- a/lib/ueberauth/strategy/keycloak.ex +++ b/lib/ueberauth/strategy/keycloak.ex @@ -68,179 +68,185 @@ defmodule Ueberauth.Strategy.Keycloak do Default is "api read_user read_registry" """ - require Logger - use Ueberauth.Strategy, - uid_field: :id, - default_scope: "api read_user read_registry", - oauth2_module: Ueberauth.Strategy.Keycloak.OAuth + defmacro __using__(opts) do + quote bind_quoted: [opts: opts] do + @oauth_module Keyword.fetch!(opts, :oauth2_module) - alias Ueberauth.Auth.Info - alias Ueberauth.Auth.Credentials - alias Ueberauth.Auth.Extra - alias Ueberauth.Strategy.Keycloak.OAuth + require Logger - @doc """ - Handles the initial redirect to the keycloak authentication page. + use Ueberauth.Strategy, + uid_field: :id, + default_scope: "api read_user read_registry", + oauth2_module: @oauth_module - To customize the scope (permissions) that are requested by keycloak include them as part of your url: + alias Ueberauth.Auth.Credentials + alias Ueberauth.Auth.Extra + alias Ueberauth.Auth.Info - "/auth/keycloak?scope=api read_user read_registry" + @doc """ + Handles the initial redirect to the keycloak authentication page. - You can also include a `state` param that keycloak will return to you. - """ - def handle_request!(conn) do - scopes = conn.params["scope"] || option(conn, :default_scope) - opts = [redirect_uri: callback_url(conn), scope: scopes] + To customize the scope (permissions) that are requested by keycloak include them as part of your url: - opts = - if conn.params["state"], do: Keyword.put(opts, :state, conn.params["state"]), else: opts + "/auth/keycloak?scope=api read_user read_registry" - module = option(conn, :oauth2_module) - redirect!(conn, apply(module, :authorize_url!, [opts])) - end + You can also include a `state` param that keycloak will return to you. + """ + def handle_request!(conn) do + scopes = conn.params["scope"] || option(conn, :default_scope) + opts = [redirect_uri: callback_url(conn), scope: scopes] - @doc """ - Handles the callback from Keycloak. When there is a failure from Keycloak the failure is included in the - `ueberauth_failure` struct. Otherwise the information returned from Keycloak is returned in the `Ueberauth.Auth` struct. - """ - def handle_callback!(%Plug.Conn{params: %{"code" => code}} = conn) do - module = option(conn, :oauth2_module) + opts = + if conn.params["state"], do: Keyword.put(opts, :state, conn.params["state"]), else: opts - token = apply(module, :get_token!, [[code: code, redirect_uri: callback_url(conn)]]) + module = option(conn, :oauth2_module) + redirect!(conn, apply(module, :authorize_url!, [opts])) + end - if token.access_token == nil do - set_errors!(conn, [ - error(token.other_params["error"], token.other_params["error_description"]) - ]) - else - fetch_user(conn, token) - end - end + @doc """ + Handles the callback from Keycloak. When there is a failure from Keycloak the failure is included in the + `ueberauth_failure` struct. Otherwise the information returned from Keycloak is returned in the `Ueberauth.Auth` struct. + """ + def handle_callback!(%Plug.Conn{params: %{"code" => code}} = conn) do + module = option(conn, :oauth2_module) - @doc false - def handle_callback!(conn) do - set_errors!(conn, [error("missing_code", "No code received")]) - end + token = apply(module, :get_token!, [[code: code, redirect_uri: callback_url(conn)]]) - @doc """ - Cleans up the private area of the connection used for passing the raw Keycloak response around during the callback. - """ - def handle_cleanup!(conn) do - conn - |> put_private(:keycloak_user, nil) - end + if token.access_token == nil do + set_errors!(conn, [ + error(token.other_params["error"], token.other_params["error_description"]) + ]) + else + fetch_user(conn, token) + end + end - @doc """ - Fetches the uid field from the Keycloak response. This defaults to the option `uid_field` which in-turn defaults to `id` - """ - def uid(conn) do - user = - conn - |> option(:uid_field) - |> to_string + @doc false + def handle_callback!(conn) do + set_errors!(conn, [error("missing_code", "No code received")]) + end - conn.private.keycloak_user[user] - end + @doc """ + Cleans up the private area of the connection used for passing the raw Keycloak response around during the callback. + """ + def handle_cleanup!(conn) do + conn + |> put_private(:keycloak_user, nil) + end - @doc """ - Includes the credentials from the Keycloak response. - """ - def credentials(conn) do - token = conn.private.keycloak_token - scope_string = token.other_params["scope"] || "" - scopes = String.split(scope_string, ",") - - %Credentials{ - token: token.access_token, - refresh_token: token.refresh_token, - expires_at: token.expires_at, - token_type: token.token_type, - expires: !!token.expires_at, - scopes: scopes - } - end + @doc """ + Fetches the uid field from the Keycloak response. This defaults to the option `uid_field` which in-turn defaults to `id` + """ + def uid(conn) do + user = + conn + |> option(:uid_field) + |> to_string - @doc """ - Fetches the fields to populate the info section of the `Ueberauth.Auth` struct. - """ - def info(conn) do - user = conn.private.keycloak_user - - %Info{ - name: user["name"], - nickname: user["preferred_username"], - email: user["email"], - location: user["location"], - image: user["avatar_url"], - urls: %{ - web_url: user["web_url"], - website_url: user["website_url"] - } - } - end + conn.private.keycloak_user[user] + end - @doc """ - Stores the raw information (including the token) obtained from the Keycloak callback. - """ - def extra(conn) do - %Extra{ - raw_info: %{ - token: conn.private.keycloak_token, - user: conn.private.keycloak_user - } - } - end + @doc """ + Includes the credentials from the Keycloak response. + """ + def credentials(conn) do + token = conn.private.keycloak_token + scope_string = token.other_params["scope"] || "" + scopes = String.split(scope_string, ",") + + %Credentials{ + token: token.access_token, + refresh_token: token.refresh_token, + expires_at: token.expires_at, + token_type: token.token_type, + expires: !!token.expires_at, + scopes: scopes + } + end - def validate_token(plug, nil), do: {:error, nil} + @doc """ + Fetches the fields to populate the info section of the `Ueberauth.Auth` struct. + """ + def info(conn) do + user = conn.private.keycloak_user + + %Info{ + name: user["name"], + nickname: user["preferred_username"], + email: user["email"], + location: user["location"], + image: user["avatar_url"], + urls: %{ + web_url: user["web_url"], + website_url: user["website_url"] + } + } + end - def validate_token(conn, token) do - introspect_token(conn, token) - end + @doc """ + Stores the raw information (including the token) obtained from the Keycloak callback. + """ + def extra(conn) do + %Extra{ + raw_info: %{ + token: conn.private.keycloak_token, + user: conn.private.keycloak_user + } + } + end - defp introspect_token(conn, token) do - case OAuth.introspect(token) do - {:ok, %OAuth2.Response{status_code: 401, body: _body}} -> - set_errors!(conn, [error("token", "unauthorized")]) + def validate_token(_plug, nil), do: {:error, nil} - {:ok, %OAuth2.Response{status_code: status_code, body: %{"active" => active} = user}} - when status_code in 200..399 -> - if active do - {:ok, user} - else - set_errors!(conn, [error("token", "unauthorized")]) - end + def validate_token(conn, token) do + introspect_token(conn, token) + end - {:error, %OAuth2.Error{reason: reason}} -> - set_errors!(conn, [error("OAuth2", reason)]) - end - end + defp introspect_token(conn, token) do + case @oauth_module.introspect(token) do + {:ok, %OAuth2.Response{status_code: 401, body: _body}} -> + set_errors!(conn, [error("token", "unauthorized")]) + + {:ok, %OAuth2.Response{status_code: status_code, body: %{"active" => active} = user}} + when status_code in 200..399 -> + if active do + {:ok, user} + else + set_errors!(conn, [error("token", "unauthorized")]) + end + + {:error, %OAuth2.Error{reason: reason}} -> + set_errors!(conn, [error("OAuth2", reason)]) + end + end - defp fetch_user(conn, token) do - conn = put_private(conn, :keycloak_token, token) - api_ver = option(conn, :api_ver) || "v4" + defp fetch_user(conn, token) do + conn = put_private(conn, :keycloak_token, token) + api_ver = option(conn, :api_ver) || "v4" - case OAuth.get( - token, - OAuth.userinfo_url() - ) do - {:ok, %OAuth2.Response{status_code: 401, body: _body}} -> - set_errors!(conn, [error("token", "unauthorized")]) + case @oauth_module.get( + token, + @oauth_module.userinfo_url() + ) do + {:ok, %OAuth2.Response{status_code: 401, body: _body}} -> + set_errors!(conn, [error("token", "unauthorized")]) - {:ok, %OAuth2.Response{status_code: status_code, body: user}} - when status_code in 200..399 -> - put_private(conn, :keycloak_user, user) + {:ok, %OAuth2.Response{status_code: status_code, body: user}} + when status_code in 200..399 -> + put_private(conn, :keycloak_user, user) - {:error, %OAuth2.Error{reason: reason}} -> - set_errors!(conn, [error("OAuth2", reason)]) - end - end + {:error, %OAuth2.Error{reason: reason}} -> + set_errors!(conn, [error("OAuth2", reason)]) + end + end - def logout(conn, token) do - OAuth.logout(token) - end + def logout(_conn, token) do + @oauth_module.logout(token) + end - defp option(conn, key) do - Keyword.get(options(conn) || [], key, Keyword.get(default_options(), key)) + defp option(conn, key) do + Keyword.get(options(conn) || [], key, Keyword.get(default_options(), key)) + end + end end end diff --git a/lib/ueberauth/strategy/keycloak/oauth.ex b/lib/ueberauth/strategy/keycloak/oauth.ex index 8861c09..d6b2bc3 100644 --- a/lib/ueberauth/strategy/keycloak/oauth.ex +++ b/lib/ueberauth/strategy/keycloak/oauth.ex @@ -8,135 +8,141 @@ defmodule Ueberauth.Strategy.Keycloak.OAuth do client_id: System.get_env("KEYCLOAK_CLIENT_ID"), client_secret: System.get_env("KEYCLOAK_CLIENT_SECRET") """ - use OAuth2.Strategy - @defaults [ - strategy: __MODULE__, - site: "http://localhost:8080", - authorize_url: "http://localhost:8080/auth/realms/LMS/protocol/openid-connect/auth", - token_url: "http://localhost:8080/auth/realms/LMS/protocol/openid-connect/token", - userinfo_url: "http://localhost:8080/auth/realms/LMS/protocol/openid-connect/userinfo", - introspect_url: - "http://localhost:8080/auth/realms/LMS/protocol/openid-connect/token/introspect", - logout_url: "http://localhost:8080/auth/realms/LMS/protocol/openid-connect/logout", - token_method: :post - ] - - @doc """ - Construct a client for requests to Keycloak. - - Optionally include any OAuth2 options here to be merged with the defaults. - - Ueberauth.Strategy.Keycloak.OAuth.client(redirect_uri: "http://localhost:4000/auth/keycloak/callback") - - This will be setup automatically for you in `Ueberauth.Strategy.Keycloak`. - These options are only useful for usage outside the normal callback phase of Ueberauth. - """ - def client(opts \\ []) do - client_opts = - @defaults - |> Keyword.merge(config()) - |> Keyword.merge(opts) - - OAuth2.Client.new(client_opts) - end - - @doc """ - Fetches configuration for `Ueberauth.Strategy.Keycloak.OAuth` Strategy from `config.exs` - - Also checks if at least `client_id` and `client_secret` are set, raising an error if not. - """ - defp config() do - :ueberauth - |> Application.fetch_env!(Ueberauth.Strategy.Keycloak.OAuth) - |> check_config_key_exists(:client_id) - |> check_config_key_exists(:client_secret) - end - - @doc """ - Provides the authorize url for the request phase of Ueberauth. No need to call this usually. - """ - def authorize_url!(params \\ [], opts \\ []) do - opts - |> client - |> OAuth2.Client.authorize_url!(params) - end - - @doc """ - Fetches `userinfo_url` for `Ueberauth.Strategy.Keycloak.OAuth` Strategy from `config.exs`. - It will be used to get user profile information after an successful authentication. - """ - def userinfo_url() do - config() |> Keyword.get(:userinfo_url) - end - - def get(token, url, headers \\ [], opts \\ []) do - [token: token] - |> client - |> put_param("access_token", token) - |> OAuth2.Client.get(url, headers, opts) - end - - def get_token!(params \\ [], options \\ []) do - headers = Keyword.get(options, :headers, []) - options = Keyword.get(options, :options, []) - client_options = Keyword.get(options, :client_options, []) - client = OAuth2.Client.get_token!(client(client_options), params, headers, options) - client.token - end - - # Strategy Callbacks - def authorize_url(client, params) do - client - |> put_param("response_type", "code") - |> put_param("redirect_uri", client().redirect_uri) - - OAuth2.Strategy.AuthCode.authorize_url(client, params) - end - - def get_token(client, params, headers) do - client - |> put_param("client_id", client().client_id) - |> put_param("client_secret", client().client_secret) - |> put_param("grant_type", "authorization_code") - |> put_param("redirect_uri", client().redirect_uri) - |> put_header("Accept", "application/json") - |> OAuth2.Strategy.AuthCode.get_token(params, headers) - end - - def introspect_url(), - do: config() |> Keyword.get(:introspect_url) - - def logout_url(), do: config() |> Keyword.get(:logout_url) - - def request_post(url, params \\ [], headers \\ []) do - client = client() - - body = - [ - client_id: client.client_id, - client_secret: client.client_secret - ] ++ params - - client - |> put_header("content-type", "application/x-www-form-urlencoded") - |> put_header("accept", "application/json") - |> OAuth2.Client.post(url, body, headers) - end - - def introspect(access_token), do: request_post(introspect_url(), token: access_token) - - def logout(refresh_token), do: request_post(logout_url(), refresh_token: refresh_token) - - defp check_config_key_exists(config, key) when is_list(config) do - unless Keyword.has_key?(config, key) do - raise "#{inspect(key)} missing from config :ueberauth, Ueberauth.Strategy.Keycloak" + defmacro __using__(opts) do + quote bind_quoted: [opts: opts] do + @otp_app Keyword.fetch!(opts, :otp_app) + + use OAuth2.Strategy + + alias OAuth2.Strategy.AuthCode + + @defaults [ + strategy: __MODULE__, + site: "http://localhost:8080", + authorize_url: "http://localhost:8080/auth/realms/LMS/protocol/openid-connect/auth", + token_url: "http://localhost:8080/auth/realms/LMS/protocol/openid-connect/token", + userinfo_url: "http://localhost:8080/auth/realms/LMS/protocol/openid-connect/userinfo", + introspect_url: + "http://localhost:8080/auth/realms/LMS/protocol/openid-connect/token/introspect", + logout_url: "http://localhost:8080/auth/realms/LMS/protocol/openid-connect/logout", + token_method: :post + ] + + @doc """ + Construct a client for requests to Keycloak. + + Optionally include any OAuth2 options here to be merged with the defaults. + + Ueberauth.Strategy.Keycloak.OAuth.client(redirect_uri: "http://localhost:4000/auth/keycloak/callback") + + This will be setup automatically for you in `Ueberauth.Strategy.Keycloak`. + These options are only useful for usage outside the normal callback phase of Ueberauth. + """ + def client(opts \\ []) do + client_opts = + @defaults + |> Keyword.merge(config()) + |> Keyword.merge(opts) + + OAuth2.Client.new(client_opts) + end + + # Fetches configuration for `Ueberauth.Strategy.Keycloak.OAuth` Strategy from `config.exs` + + # Also checks if at least `client_id` and `client_secret` are set, raising an error if not. + defp config do + Application.fetch_env!(@otp_app, __MODULE__) + |> check_config_key_exists(:client_id) + |> check_config_key_exists(:client_secret) + end + + @doc """ + Provides the authorize url for the request phase of Ueberauth. No need to call this usually. + """ + def authorize_url!(params \\ [], opts \\ []) do + opts + |> client() + |> OAuth2.Client.authorize_url!(params) + end + + @doc """ + Fetches `userinfo_url` for `Ueberauth.Strategy.Keycloak.OAuth` Strategy from `config.exs`. + It will be used to get user profile information after an successful authentication. + """ + def userinfo_url do + config() |> Keyword.get(:userinfo_url) + end + + def get(token, url, headers \\ [], opts \\ []) do + [token: token] + |> client() + |> put_param("access_token", token) + |> OAuth2.Client.get(url, headers, opts) + end + + def get_token!(params \\ [], options \\ []) do + headers = Keyword.get(options, :headers, []) + options = Keyword.get(options, :options, []) + client_options = Keyword.get(options, :client_options, []) + client = OAuth2.Client.get_token!(client(client_options), params, headers, options) + client.token + end + + # Strategy Callbacks + def authorize_url(client, params) do + client + |> put_param("response_type", "code") + |> put_param("redirect_uri", client().redirect_uri) + + AuthCode.authorize_url(client, params) + end + + def get_token(client, params, headers) do + client + |> put_param("client_id", client().client_id) + |> put_param("client_secret", client().client_secret) + |> put_param("grant_type", "authorization_code") + |> put_param("redirect_uri", client().redirect_uri) + |> put_header("Accept", "application/json") + |> AuthCode.get_token(params, headers) + end + + def introspect_url, + do: config() |> Keyword.get(:introspect_url) + + def logout_url, do: config() |> Keyword.get(:logout_url) + + def request_post(url, params \\ [], headers \\ []) do + client = client() + + body = + [ + client_id: client.client_id, + client_secret: client.client_secret + ] ++ params + + client + |> put_header("content-type", "application/x-www-form-urlencoded") + |> put_header("accept", "application/json") + |> OAuth2.Client.post(url, body, headers) + end + + def introspect(access_token), do: request_post(introspect_url(), token: access_token) + + def logout(refresh_token), do: request_post(logout_url(), refresh_token: refresh_token) + + defp check_config_key_exists(config, key) when is_list(config) do + unless Keyword.has_key?(config, key) do + raise "#{inspect(key)} missing from config :ueberauth, Ueberauth.Strategy.Keycloak" + end + + config + end + + defp check_config_key_exists(_, _) do + raise "Config :ueberauth, Ueberauth.Strategy.Keycloak is not a keyword list, as expected" + end end - - config - end - - defp check_config_key_exists(_, _) do - raise "Config :ueberauth, Ueberauth.Strategy.Keycloak is not a keyword list, as expected" end end diff --git a/mix.exs b/mix.exs index 7f69e71..eef0992 100644 --- a/mix.exs +++ b/mix.exs @@ -5,7 +5,7 @@ defmodule UeberauthKeycloak.Mixfile do def project do [ - app: :ueberauth_keycloak_strategy, + app: :ueberauth_keycloak, version: @version, package: package(), deps: deps(),