defmodule Kinde do @moduledoc """ OpenID Connect authentication with PKCE for [Kinde](https://kinde.com). Provides two main functions: * `auth/2` — generates an OAuth2 authorization URL and stores the PKCE code verifier in state management * `token/4` — exchanges the authorization code for an ID token, verifies it, and returns user attributes ## Configuration Required keys can be set via application config or passed directly as a map: config :kinde, domain: "https://yourapp.kinde.com", client_id: "client_id", client_secret: "client_secret", redirect_uri: "http://localhost:4000/callback" When a map is passed to `auth/2` or `token/4`, its values take precedence over the application config. See the "All configuration keys" section in the README for a complete reference. ## Example # Step 1: redirect user to Kinde {:ok, url} = Kinde.auth() # Step 2: handle the callback {:ok, user, extra_params} = Kinde.token(code, state) """ alias Kinde.{MissingConfigError, ObtainingTokenError, StateManagement, Token, URL} require Logger @type config :: %{ optional(:domain) => String.t(), optional(:client_id) => String.t(), optional(:client_secret) => String.t(), optional(:redirect_uri) => String.t(), optional(:prompt) => String.t(), optional(:scopes) => [String.t()] } @type state_params :: %{ code_verifier: String.t(), extra_params: map() } @scopes ~w[openid profile email offline] @config_keys ~w[domain client_id client_secret redirect_uri]a @finch_name Kinde.Finch @doc """ Generates an OAuth2 authorization URL with PKCE. Accepts an optional `config` map (overrides app env) and an optional `extra_params` map that will be returned alongside user data after a successful `token/4` call. Returns `{:ok, url}` on success or `{:error, %MissingConfigError{}}` when required configuration keys are missing. ## Examples iex> Kinde.auth() {:ok, "https://yourapp.kinde.com/oauth2/auth?..."} iex> Kinde.auth(%{}, %{return_to: "/dashboard"}) {:ok, "https://yourapp.kinde.com/oauth2/auth?..."} """ @spec auth(config(), map()) :: {:ok, String.t()} | {:error, term()} def auth(config \\ %{}, extra_params \\ %{}) do with {:ok, config} <- load_config_from_app_env(config), {verifier, challenge} = pkce(), {:ok, state} <- create_state(verifier, extra_params) do auth(config, challenge, state) end end defp auth(%{domain: domain} = config, challenge, state) do scope = config |> Map.get(:scopes, @scopes) |> Enum.join(" ") qs = build_query_string(config, scope, state, challenge) {:ok, URL.auth_url(domain, qs)} end @doc """ Exchanges an authorization code for user attributes. Takes the `code` and `state` from the Kinde callback, verifies the ID token via JWKS, and returns user attributes along with any `extra_params` that were passed to `auth/2`. Returns `{:ok, user_params, extra_params}` on success, where `user_params` is a map with keys: `:id`, `:given_name`, `:family_name`, `:email`, `:picture`. ## Errors * `{:error, %ObtainingTokenError{}}` — the token endpoint returned an error * `{:error, %StateNotFoundError{}}` — the state was not found (expired or already used) * `{:error, %MissingConfigError{}}` — required config keys are missing """ @spec token(String.t(), String.t(), config(), Keyword.t()) :: {:ok, map(), map()} | {:error, term()} def token(code, state, config \\ %{}, opts \\ []) do with {:ok, config} <- load_config_from_app_env(config), {:ok, params} <- StateManagement.take_state(state) do fetch_token(config, code, params, opts) end end defp fetch_token(config, code, params, opts) do %{ domain: domain, client_id: client_id, client_secret: client_secret, redirect_uri: redirect_uri } = config %{ code_verifier: code_verifier, extra_params: extra_params } = params form = %{ grant_type: "authorization_code", code: code, client_id: client_id, client_secret: client_secret, redirect_uri: redirect_uri, code_verifier: code_verifier } with {:ok, response} <- run_request(domain, form, opts), {:ok, claims} <- handle_response(response) do {:ok, user_params(claims), extra_params} end end defp run_request(domain, form, opts) do opts |> Keyword.put(:url, "/oauth2/token") |> Keyword.put(:base_url, URL.base_url(domain)) |> Keyword.put(:form, form) |> Keyword.put(:finch, @finch_name) |> Req.post() end defp handle_response(%Req.Response{status: 200, body: %{"id_token" => token}}) do Token.verify_and_validate(token) end defp handle_response(%Req.Response{status: status, body: body}) do {:error, %ObtainingTokenError{status: status, body: body}} end defp user_params(claims) do %{ id: claims["sub"], given_name: claims["given_name"], family_name: claims["family_name"], email: claims["email"], picture: claims["picture"] } end defp pkce do verifier = 64 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower) challenge = :sha256 |> :crypto.hash(verifier) |> Base.url_encode64(padding: false) {verifier, challenge} end defp generate_state do 32 |> :crypto.strong_rand_bytes() |> Base.url_encode64() end defp create_state(verifier, extra_params) do state = generate_state() params = %{code_verifier: verifier, extra_params: extra_params} with :ok <- StateManagement.put_state(state, params) do {:ok, state} end end defp build_query_string(config, scope, state, challenge) do config |> Map.take(~w[client_id redirect_uri prompt]a) |> Map.put_new(:prompt, "login") |> Map.put(:response_type, :code) |> Map.put(:scope, scope) |> Map.put(:state, state) |> Map.put(:code_challenge, challenge) |> Map.put(:code_challenge_method, "S256") |> URI.encode_query() end defp load_config_from_app_env(config) do @config_keys |> Enum.reduce(config, &load_config_from_app_env/2) |> check_config() end defp load_config_from_app_env(key, acc) do case Application.fetch_env(:kinde, key) do {:ok, value} -> Map.put_new(acc, key, value) :error -> acc end end defp check_config(config) do case Enum.reject(@config_keys, fn key -> Map.has_key?(config, key) end) do [] -> {:ok, config} missing_keys -> {:error, %MissingConfigError{keys: missing_keys}} end end end