├── .credo.exs ├── .formatter.exs ├── .github └── workflows │ └── elixir.yml ├── .gitignore ├── .tool-versions ├── LICENSE.md ├── README.md ├── config └── config.exs ├── lib ├── keycloak.ex └── keycloak │ ├── admin.ex │ ├── claims.ex │ ├── client.ex │ ├── plug │ └── verify_token.ex │ └── service.ex ├── mix.exs ├── mix.lock └── test ├── keycloak ├── claims_test.exs ├── keycloak_test.exs └── plug │ └── verify_token_test.exs └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | configs: [ 3 | %{ 4 | name: "default", 5 | color: true, 6 | strict: false, 7 | check_for_updates: true, 8 | files: %{ 9 | included: ["lib/", "config/", "test/"], 10 | excluded: [] 11 | }, 12 | checks: [ 13 | {Credo.Check.Refactor.MapInto, false}, 14 | {Credo.Check.Warning.LazyLogging, false}, 15 | {Credo.Check.Consistency.ExceptionNames}, 16 | {Credo.Check.Consistency.LineEndings}, 17 | {Credo.Check.Consistency.ParameterPatternMatching}, 18 | {Credo.Check.Consistency.SpaceAroundOperators}, 19 | {Credo.Check.Consistency.SpaceInParentheses}, 20 | {Credo.Check.Consistency.TabsOrSpaces, priority: :higher}, 21 | 22 | {Credo.Check.Design.TagTODO}, 23 | {Credo.Check.Design.TagFIXME}, 24 | {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, 25 | {Credo.Check.Design.AliasUsage, priority: :low}, 26 | 27 | {Credo.Check.Readability.FunctionNames}, 28 | {Credo.Check.Readability.LargeNumbers}, 29 | {Credo.Check.Readability.MaxLineLength, priority: :normal, max_length: 100}, 30 | {Credo.Check.Readability.ModuleAttributeNames}, 31 | {Credo.Check.Readability.ModuleDoc}, 32 | {Credo.Check.Readability.ModuleNames}, 33 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, 34 | {Credo.Check.Readability.ParenthesesInCondition}, 35 | {Credo.Check.Readability.PredicateFunctionNames}, 36 | {Credo.Check.Readability.PreferImplicitTry}, 37 | {Credo.Check.Readability.RedundantBlankLines}, 38 | {Credo.Check.Readability.StringSigils}, 39 | {Credo.Check.Readability.TrailingBlankLine}, 40 | {Credo.Check.Readability.TrailingWhiteSpace}, 41 | {Credo.Check.Readability.VariableNames}, 42 | {Credo.Check.Readability.Semicolons}, 43 | {Credo.Check.Readability.SpaceAfterCommas}, 44 | 45 | {Credo.Check.Refactor.DoubleBooleanNegation}, 46 | {Credo.Check.Refactor.CondStatements}, 47 | {Credo.Check.Refactor.CyclomaticComplexity}, 48 | {Credo.Check.Refactor.FunctionArity}, 49 | {Credo.Check.Refactor.LongQuoteBlocks}, 50 | {Credo.Check.Refactor.MatchInCondition}, 51 | {Credo.Check.Refactor.NegatedConditionsInUnless}, 52 | {Credo.Check.Refactor.NegatedConditionsWithElse}, 53 | {Credo.Check.Refactor.Nesting}, 54 | # {Credo.Check.Refactor.PipeChainStart}, 55 | {Credo.Check.Refactor.UnlessWithElse}, 56 | 57 | {Credo.Check.Warning.BoolOperationOnSameValues}, 58 | {Credo.Check.Warning.IExPry}, 59 | {Credo.Check.Warning.IoInspect}, 60 | {Credo.Check.Warning.OperationOnSameValues}, 61 | {Credo.Check.Warning.OperationWithConstantResult}, 62 | {Credo.Check.Warning.UnusedEnumOperation}, 63 | {Credo.Check.Warning.UnusedFileOperation}, 64 | {Credo.Check.Warning.UnusedKeywordOperation}, 65 | {Credo.Check.Warning.UnusedListOperation}, 66 | {Credo.Check.Warning.UnusedPathOperation}, 67 | {Credo.Check.Warning.UnusedRegexOperation}, 68 | {Credo.Check.Warning.UnusedStringOperation}, 69 | {Credo.Check.Warning.UnusedTupleOperation}, 70 | {Credo.Check.Warning.RaiseInsideRescue}, 71 | ] 72 | } 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | locals_without_parens = [] 2 | 3 | [ 4 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"], 5 | line_length: 100, 6 | import_deps: [], 7 | locals_without_parens: locals_without_parens 8 | ] 9 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Verify 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 13 | strategy: 14 | matrix: 15 | otp: ['24.1.7'] 16 | elixir: ['1.13.0'] 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: erlef/setup-beam@v1 20 | with: 21 | otp-version: ${{matrix.otp}} 22 | elixir-version: ${{matrix.elixir}} 23 | - run: mix deps.get 24 | - name: Compile Dependencies 25 | run: mix deps.compile 26 | - name: Tests 27 | run: mix test 28 | - name: Format 29 | run: mix format --check-formatted 30 | - name: Credo 31 | run: mix credo --mute-exit-status 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.10.3-otp-22 2 | erlang 22.3.3 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ## License (MIT) 2 | 3 | Copyright (c) 2017 Matt McFarland 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Keycloak 2 | 3 | ![CI](https://github.com/vanetix/elixir-keycloak/workflows/Verify/badge.svg) 4 | 5 | Elixir client for working with a Keycloak authorization server. API documentation can be found at https://hexdocs.pm/keycloak/. 6 | 7 | ## Installation 8 | 9 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 10 | by adding `keycloak` to your list of dependencies in `mix.exs`: 11 | 12 | ```elixir 13 | def deps do 14 | [{:keycloak, "~> 0.1.0"}] 15 | end 16 | ``` 17 | 18 | ## Configuration 19 | 20 | ### Base 21 | 22 | ```elixir 23 | config :keycloak, 24 | realm: 25 | site: 26 | client_id: 27 | client_secret: 28 | ``` 29 | 30 | ### Plugs 31 | 32 | ### VerifyToken 33 | 34 | ```elixir 35 | config :keycloak, Keycloak.Plug.VerifyToken, 36 | hmac: "", 37 | public_key: "" 38 | ``` 39 | 40 | ## Usage in Phoenix 41 | 42 | ```elixir 43 | def login(conn, _) do 44 | redirect(conn, external: Keycloak.authorize_url!()) 45 | end 46 | 47 | def callback(conn, %{"code" => code}) do 48 | %{token: token} = Keycloak.get_token!(code: code) 49 | 50 | conn 51 | |> put_session(:token, token) 52 | |> redirect(to: "/manage") 53 | end 54 | ``` 55 | 56 | ## License (MIT) 57 | 58 | Copyright (c) 2017-2020 Matt McFarland and Contributors 59 | 60 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 61 | 62 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 63 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :keycloak, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:keycloak, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /lib/keycloak.ex: -------------------------------------------------------------------------------- 1 | defmodule Keycloak do 2 | @moduledoc """ 3 | An OAuth2.Strategy implementation for authorizing with a 4 | [Keycloak](http://www.keycloak.org/) server. 5 | 6 | ## Example 7 | 8 | #### Phoenix controller 9 | 10 | def login(conn, _) do 11 | redirect(conn, external: Keycloak.authorize_url!()) 12 | end 13 | 14 | def callback(conn, %{"code" => code}) do 15 | %{token: token} = Keycloak.get_token!(code: code) 16 | 17 | conn 18 | |> put_session(:token, token) 19 | |> redirect(to: "/manage") 20 | end 21 | """ 22 | 23 | use OAuth2.Strategy 24 | 25 | alias Keycloak.Client 26 | alias OAuth2.Strategy.AuthCode 27 | 28 | def authorize_url!(params \\ []) do 29 | Client.new() 30 | |> OAuth2.Client.authorize_url!(params) 31 | end 32 | 33 | @doc """ 34 | Creates a `OAuth2.Client` using the keycloak configuration and 35 | attempts fetch a access token. 36 | """ 37 | @spec get_token!(keyword(), keyword()) :: any() 38 | def get_token!(params \\ [], _headers \\ []) do 39 | data = Keyword.merge(params, client_secret: Client.new().client_secret) 40 | 41 | Client.new() 42 | |> OAuth2.Client.get_token!(data) 43 | end 44 | 45 | @doc """ 46 | Returns the authorize url for the keycloak client. 47 | """ 48 | @spec authorize_url(OAuth2.Client.t(), keyword()) :: any() 49 | def authorize_url(client, params) do 50 | AuthCode.authorize_url(client, params) 51 | end 52 | 53 | @doc """ 54 | Gets a token given a preconfigured `OAuth2.Client`. 55 | """ 56 | @spec get_token(OAuth2.Client.t(), keyword(), keyword()) :: any() 57 | def get_token(client, params, headers) do 58 | client 59 | |> OAuth2.Client.put_header("Accept", "application/json") 60 | |> AuthCode.get_token(params, headers) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/keycloak/admin.ex: -------------------------------------------------------------------------------- 1 | defmodule Keycloak.Admin do 2 | @moduledoc ~S""" 3 | This module is responsible for making calls to the Keycloak admin api. 4 | 5 | ## Example 6 | 7 | client = Keycloak.Client.new(token: "supersecret") 8 | 9 | case Keycloak.Admin.get(client, "/") do 10 | {:ok, %O{body: body}} -> 11 | body 12 | {:error, %{body: body}} -> 13 | "#{inspect body}" 14 | end 15 | 16 | response = OAuth2.Client.get!(client, "/some/resource") 17 | """ 18 | 19 | alias OAuth2.Client 20 | 21 | @type result() :: {:ok, OAuth2.Response.t()} | {:error, OAuth2.Response.t()} 22 | @type headers() :: OAuth2.Client.headers() 23 | @type body() :: OAuth2.Client.body() 24 | 25 | @default_headers [{"content-type", "application/json"}] 26 | 27 | @spec admin_client(Client.t()) :: Client.t() 28 | defp admin_client(%Client{site: base} = client) do 29 | %{client | site: "#{base}/admin"} 30 | |> Client.put_header("accept", "application/json") 31 | end 32 | 33 | @doc """ 34 | Makes an authorized GET request to `url` 35 | """ 36 | @spec get(Client.t(), String.t(), Keyword.t(), headers(), Keyword.t()) :: result() 37 | def get(%Client{} = client, url, params \\ [], headers \\ [], opts \\ []) do 38 | opts = Keyword.update(opts, :params, params, &Keyword.merge/2) 39 | 40 | client 41 | |> admin_client() 42 | |> Client.get(url, headers, opts) 43 | end 44 | 45 | @doc """ 46 | Makes an authorized POST request to `url` 47 | """ 48 | @spec post(Client.t(), String.t(), body(), Keyword.t(), headers(), Keyword.t()) :: result() 49 | def post( 50 | %Client{} = client, 51 | url, 52 | body \\ "", 53 | params \\ [], 54 | headers \\ @default_headers, 55 | opts \\ [] 56 | ) do 57 | opts = Keyword.update(opts, :params, params, &Keyword.merge/2) 58 | 59 | client 60 | |> admin_client() 61 | |> Client.post(url, body, headers, opts) 62 | end 63 | 64 | @doc """ 65 | Makes an authorized PUT request to `url` 66 | """ 67 | @spec put(Client.t(), String.t(), body(), Keyword.t(), headers(), Keyword.t()) :: result() 68 | def put( 69 | %Client{} = client, 70 | url, 71 | body \\ "", 72 | params \\ [], 73 | headers \\ @default_headers, 74 | opts \\ [] 75 | ) do 76 | opts = Keyword.update(opts, :params, params, &Keyword.merge/2) 77 | 78 | client 79 | |> admin_client() 80 | |> Client.put(url, body, headers, opts) 81 | end 82 | 83 | @doc """ 84 | Makes an authorized DELETE request to `url` 85 | """ 86 | @spec delete(Client.t(), String.t(), body(), Keyword.t(), headers(), Keyword.t()) :: result() 87 | def delete( 88 | %Client{} = client, 89 | url, 90 | body \\ "", 91 | params \\ [], 92 | headers \\ @default_headers, 93 | opts \\ [] 94 | ) do 95 | opts = Keyword.update(opts, :params, params, &Keyword.merge/2) 96 | 97 | client 98 | |> admin_client() 99 | |> Client.put(url, body, headers, opts) 100 | end 101 | 102 | @doc """ 103 | A simple wrapper around the current `admin_client` functionality. 104 | 105 | NOTE: This is a temporary fix for some missing functionality. 106 | """ 107 | def new(client), do: admin_client(client) 108 | end 109 | -------------------------------------------------------------------------------- /lib/keycloak/claims.ex: -------------------------------------------------------------------------------- 1 | defmodule Keycloak.Claims do 2 | @moduledoc """ 3 | A helper module for extracting claims from the `%Joken.Token{}`. In order 4 | to use these helpers, you **must** have the `VerifyToken` plug in your plug 5 | pipeline. 6 | 7 | ## Example 8 | 9 | ### Phoenix controller 10 | 11 | def index(conn, _) do 12 | sub = Keycloak.Claims.get_claim(conn, "sub") 13 | 14 | conn 15 | |> put_session(:user, sub) 16 | |> render("index.html") 17 | end 18 | """ 19 | 20 | alias Plug.Conn 21 | 22 | @doc """ 23 | Pulls given `claim` from the Joken token 24 | """ 25 | @spec get_claim(Plug.Conn.t() | Joken.claims(), String.t()) :: String.t() | nil 26 | def get_claim(%Conn{assigns: %{claims: claims}}, claim), 27 | do: get_claim(claims, claim) 28 | 29 | def get_claim(claims, claim) do 30 | case claims do 31 | %{^claim => value} -> value 32 | _ -> nil 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/keycloak/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Keycloak.Client do 2 | @moduledoc """ 3 | Module resposible for creating a properly configured 4 | `OAuth2.Client` for use with the Keycloak configuration. 5 | 6 | ## Configuration 7 | 8 | config :keycloak, 9 | realm: 10 | site: 11 | client_id: 12 | client_secret: 13 | """ 14 | 15 | alias OAuth2.Client 16 | 17 | @spec config() :: keyword() 18 | defp config() do 19 | config = Application.get_all_env(:keycloak) 20 | {realm, config} = Keyword.pop(config, :realm) 21 | {site, config} = Keyword.pop(config, :site) 22 | 23 | [ 24 | strategy: Keycloak, 25 | realm: realm, 26 | site: site, 27 | authorize_url: "/realms/#{realm}/protocol/openid-connect/auth", 28 | token_url: "/realms/#{realm}/protocol/openid-connect/token", 29 | serializers: %{"application/json" => Poison} 30 | ] 31 | |> Keyword.merge(config) 32 | end 33 | 34 | @doc """ 35 | Returns a new `OAuth2.Client` ready to make requests to the configured 36 | Keycloak server. 37 | """ 38 | @spec new(keyword()) :: OAuth2.Client.t() 39 | def new(opts \\ []) do 40 | config() 41 | |> Keyword.merge(opts) 42 | |> Client.new() 43 | end 44 | 45 | @doc """ 46 | Fetches the current user profile from the Keycloak userinfo endpoint. The 47 | passed `client` must have already been authorized and have a valid access token. 48 | """ 49 | @spec me(OAuth2.Client.t()) :: {:ok, OAuth2.Response.t()} | {:error, String.t()} 50 | def me(%Client{} = client) do 51 | realm = 52 | config() 53 | |> Keyword.get(:realm) 54 | 55 | client 56 | |> Client.put_header("accept", "application/json") 57 | |> Client.get("/realms/#{realm}/protocol/openid-connect/userinfo") 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/keycloak/plug/verify_token.ex: -------------------------------------------------------------------------------- 1 | defmodule Keycloak.Plug.VerifyToken do 2 | @moduledoc """ 3 | Plug for verifying authorization on a per request basis, verifies that a token is set in the 4 | `Authorization` header. 5 | 6 | ### Example Usage 7 | 8 | config :keycloak, Keycloak.Plug.VerifyToken, hmac: "foo" 9 | 10 | # In your plug pipeline 11 | plug Keycloak.Plug.VerifyToken 12 | """ 13 | use Joken.Config 14 | 15 | import Plug.Conn 16 | 17 | alias JOSE.JWK 18 | 19 | @regex ~r/^Bearer:?\s+(.+)/i 20 | 21 | @doc false 22 | def init(opts), do: opts 23 | 24 | @doc """ 25 | Fetches the `Authorization` header, and verifies the token if present. If a 26 | valid token is passed, the decoded `%Joken.Token{}` is added as `:token` 27 | to the `conn` assigns. 28 | """ 29 | @spec call(Plug.Conn.t(), keyword()) :: Plug.Conn.t() 30 | def call(conn, _) do 31 | token = 32 | conn 33 | |> get_req_header("authorization") 34 | |> fetch_token() 35 | 36 | case verify_token(token) do 37 | {:ok, claims} -> 38 | conn 39 | |> assign(:claims, claims) 40 | 41 | {:error, message} -> 42 | conn 43 | |> put_resp_content_type("application/vnd.api+json") 44 | |> send_resp(401, Poison.encode!(%{error: message})) 45 | |> halt() 46 | end 47 | end 48 | 49 | # 1 hour 50 | def token_config(), do: default_claims(default_exp: 60 * 60) 51 | 52 | @doc """ 53 | Attemps to verify that the passed `token` can be trusted. 54 | 55 | ## Example 56 | 57 | iex> verify_token(nil) 58 | {:error, :not_authenticated} 59 | 60 | iex> verify_token("abc123") 61 | {:error, :signature_error} 62 | """ 63 | @spec verify_token(String.t() | nil) :: {atom(), Joken.Token.t() | atom()} 64 | def verify_token(nil), do: {:error, :not_authenticated} 65 | 66 | def verify_token(token) do 67 | verify_and_validate(token, signer_key()) 68 | end 69 | 70 | @doc """ 71 | Fetches the token from the `Authorization` headers array, attempting 72 | to match the token in the format `Bearer `. 73 | 74 | ### Example 75 | 76 | iex> fetch_token([]) 77 | nil 78 | 79 | iex> fetch_token(["abc123"]) 80 | nil 81 | 82 | iex> fetch_token(["Bearer abc123"]) 83 | "abc123" 84 | """ 85 | @spec fetch_token([String.t()] | []) :: String.t() | nil 86 | def fetch_token([]), do: nil 87 | 88 | def fetch_token([token | tail]) do 89 | case Regex.run(@regex, token) do 90 | [_, token] -> String.trim(token) 91 | nil -> fetch_token(tail) 92 | end 93 | end 94 | 95 | @doc """ 96 | Returns the configured `public_key` or `hmac` key used to sign the token. 97 | 98 | ### Example 99 | 100 | iex> %Joken.Signer{} = signer_key() 101 | %Joken.Signer{ 102 | alg: "HS512", 103 | jwk: %JOSE.JWK{fields: %{}, keys: :undefined, kty: {:jose_jwk_kty_oct, "akbar"}}, 104 | jws: %JOSE.JWS{alg: {:jose_jws_alg_hmac, :HS512}, b64: :undefined, fields: %{"typ" => "JWT"}} 105 | } 106 | """ 107 | @spec signer_key() :: Joken.Signer.t() 108 | def signer_key() do 109 | {config, _} = 110 | :keycloak 111 | |> Application.get_env(__MODULE__, []) 112 | |> Keyword.split([:hmac, :public_key]) 113 | 114 | case config do 115 | [hmac: hmac] -> 116 | hmac 117 | |> (&Joken.Signer.create("HS512", &1)).() 118 | 119 | [public_key: public_key] -> 120 | public_key 121 | |> JWK.from_pem() 122 | |> (&Joken.Signer.create("RS256", &1)).() 123 | 124 | _ -> 125 | raise "No signer configuration present for #{__MODULE__}" 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/keycloak/service.ex: -------------------------------------------------------------------------------- 1 | defmodule Keycloak.Service do 2 | @moduledoc """ 3 | Module that handles authorization flow for a client credentials grant 4 | 5 | 6 | ## Example 7 | 8 | client = Keycloak.Service.get_token!() 9 | 10 | users = 11 | case Keycloak.Admin.get(client, "/realms/test-realm/users") do 12 | {:ok, %{body: body}} -> body 13 | {:error, _} -> [] 14 | end 15 | """ 16 | 17 | alias OAuth2.Client 18 | 19 | @doc """ 20 | Get a token for the configured OAuth2 client 21 | 22 | ### Example 23 | 24 | iex> Keycloak.Service.get_token!() 25 | %OAuth2.Client{ 26 | token: %OAuth2.AccessToken{}, 27 | expires_at: nil, 28 | other_params: %{}, 29 | refresh_token: nil, 30 | token_type: "Bearer" 31 | } 32 | """ 33 | @spec get_token(keyword()) :: {:ok, Client.t()} | {:error, Client.t()} 34 | def get_token(params \\ []) do 35 | Keyword.merge(params, strategy: OAuth2.Strategy.ClientCredentials) 36 | |> Keycloak.Client.new() 37 | |> OAuth2.Client.get_token(params) 38 | end 39 | 40 | @doc """ 41 | Same as `get_token/1` but raises on error 42 | """ 43 | @spec get_token!(keyword()) :: Client.t() 44 | def get_token!(params \\ []) do 45 | Keyword.merge(params, strategy: OAuth2.Strategy.ClientCredentials) 46 | |> Keycloak.Client.new() 47 | |> OAuth2.Client.get_token!(params) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Keycloak.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :keycloak, 7 | version: "1.1.0", 8 | elixir: "~> 1.6", 9 | name: "keycloak", 10 | description: "Library for interacting with a Keycloak authorization server", 11 | package: package(), 12 | deps: deps(), 13 | docs: [extras: ["README.md"], main: "readme"], 14 | source_url: "https://github.com/vanetix/elixir-keycloak" 15 | ] 16 | end 17 | 18 | # Configuration for the OTP application 19 | def application do 20 | [extra_applications: [:logger, :oauth2]] 21 | end 22 | 23 | defp deps do 24 | [ 25 | {:joken, "~> 2.0"}, 26 | {:oauth2, "~> 2.0"}, 27 | {:plug, "~> 1.4"}, 28 | {:poison, "~> 4.0"}, 29 | {:credo, "~> 1.4", only: [:dev, :test], runtime: false}, 30 | {:ex_doc, "~> 0.26", only: :dev, runtime: false}, 31 | {:rexbug, "~> 1.0", only: :dev, runtime: false} 32 | ] 33 | end 34 | 35 | defp package do 36 | [ 37 | maintainers: ["Matthew McFarland"], 38 | licenses: ["MIT"], 39 | links: %{"Github" => "https://github.com/vanetix/elixir-keycloak"} 40 | ] 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], []}, 3 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 4 | "certifi": {:hex, :certifi, "2.8.0", "d4fb0a6bb20b7c9c3643e22507e42f356ac090a1dcea9ab99e27e0376d695eba", [:rebar3], [], "hexpm", "6ac7efc1c6f8600b08d625292d4bbf584e14847ce1b6b5c44d983d273e1097ea"}, 5 | "credo": {:hex, :credo, "1.6.1", "7dc76dcdb764a4316c1596804c48eada9fff44bd4b733a91ccbf0c0f368be61e", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "698607fb5993720c7e93d2d8e76f2175bba024de964e160e2f7151ef3ab82ac5"}, 6 | "earmark": {:hex, :earmark, "1.4.19", "3854a17305c880cc46305af15fb1630568d23a709aba21aaa996ced082fc29d7", [:mix], [{:earmark_parser, ">= 1.4.18", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "d5a8c9f9e37159a8fdd3ea8437fb4e229eaf56d5129b9a011dc4780a4872079d"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.18", "e1b2be73eb08a49fb032a0208bf647380682374a725dfb5b9e510def8397f6f2", [:mix], [], "hexpm", "114a0e85ec3cf9e04b811009e73c206394ffecfcc313e0b346de0d557774ee97"}, 8 | "ex_doc": {:hex, :ex_doc, "0.26.0", "1922164bac0b18b02f84d6f69cab1b93bc3e870e2ad18d5dacb50a9e06b542a3", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2775d66e494a9a48355db7867478ffd997864c61c65a47d31c4949459281c78d"}, 9 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 10 | "hackney": {:hex, :hackney, "1.18.0", "c4443d960bb9fba6d01161d01cd81173089686717d9490e5d3606644c48d121f", [:rebar3], [{:certifi, "~>2.8.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "9afcda620704d720db8c6a3123e9848d09c87586dc1c10479c42627b905b5c5e"}, 11 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 12 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 13 | "joken": {:hex, :joken, "2.4.1", "63a6e47aaf735637879f31babfad93c936d63b8b7d01c5ef44c7f37689e71ab4", [:mix], [{:jose, "~> 1.11.2", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "d4fc7c703112b2dedc4f9ec214856c3a07108c4835f0f174a369521f289c98d1"}, 14 | "jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"}, 15 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.2", "dc72dfe17eb240552857465cc00cce390960d9a0c055c4ccd38b70629227e97c", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fd23ae48d09b32eff49d4ced2b43c9f086d402ee4fd4fcb2d7fad97fa8823e75"}, 17 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 18 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 19 | "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, 20 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 21 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.0", "b44d75e2a6542dcb6acf5d71c32c74ca88960421b6874777f79153bbbbd7dccc", [:mix], [], "hexpm", "52b2871a7515a5ac49b00f214e4165a40724cf99798d8e4a65e4fd64ebd002c1"}, 22 | "oauth2": {:hex, :oauth2, "2.0.0", "338382079fe16c514420fa218b0903f8ad2d4bfc0ad0c9f988867dfa246731b0", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "881b8364ac7385f9fddc7949379cbe3f7081da37233a1aa7aab844670a91e7e7"}, 23 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 24 | "plug": {:hex, :plug, "1.12.1", "645678c800601d8d9f27ad1aebba1fdb9ce5b2623ddb961a074da0b96c35187d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d57e799a777bc20494b784966dc5fbda91eb4a09f571f76545b72a634ce0d30b"}, 25 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 26 | "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, 27 | "redbug": {:hex, :redbug, "1.2.2", "366d8961770ddc7bb5d209fbadddfa7271005487f938c087a0e385a57abfee33", [:rebar3], [], "hexpm", "b5fe7b94e487be559cb0ec1c0e938c9761205d3e91a96bf263bdf1beaebea729"}, 28 | "rexbug": {:hex, :rexbug, "1.0.5", "e4fce59d1cb4f574b2d84181507b4782bc4b6afcb64e2cd276003c563ffef766", [:mix], [{:mix_test_watch, ">= 0.5.0", [hex: :mix_test_watch, repo: "hexpm", optional: true]}, {:redbug, "~> 1.2", [hex: :redbug, repo: "hexpm", optional: false]}], "hexpm", "13a3f180a9e490686a774725a07a21caf05735b7c012824d86960e9541aab46a"}, 29 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 30 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, 31 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 32 | } 33 | -------------------------------------------------------------------------------- /test/keycloak/claims_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Keycloak.ClaimsTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | doctest Keycloak.Claims 6 | 7 | import Keycloak.Claims 8 | 9 | def fixture(:claims) do 10 | %{"test" => "abc123"} 11 | end 12 | 13 | def fixture(:conn) do 14 | conn(:get, "/") 15 | |> assign(:claims, fixture(:claims)) 16 | end 17 | 18 | test "get_claim/2 with %Plug.Conn{}" do 19 | assert get_claim(fixture(:conn), "test") == "abc123" 20 | assert get_claim(fixture(:conn), "invalid") == nil 21 | end 22 | 23 | test "get_claim/2 with %Token{}" do 24 | assert get_claim(fixture(:claims), "test") == "abc123" 25 | assert get_claim(fixture(:claims), "invalid") == nil 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/keycloak/keycloak_test.exs: -------------------------------------------------------------------------------- 1 | defmodule KeycloakTest do 2 | use ExUnit.Case 3 | doctest Keycloak 4 | 5 | test "the truth" do 6 | assert 1 + 1 == 2 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/keycloak/plug/verify_token_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Keycloak.Plug.VerifyTokenTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | import Keycloak.Plug.VerifyToken 6 | import Joken.Config 7 | 8 | doctest Keycloak.Plug.VerifyToken 9 | 10 | setup do 11 | Application.put_env(:keycloak, Keycloak.Plug.VerifyToken, hmac: "akbar") 12 | end 13 | 14 | def fixture(:token) do 15 | default_claims() 16 | |> add_claim("sub", "Luke Skywalker") 17 | |> Joken.generate_and_sign!(%{}, Keycloak.Plug.VerifyToken.signer_key()) 18 | end 19 | 20 | test "fetch_token/1" do 21 | assert fetch_token([]) == nil 22 | assert fetch_token(["abc123"]) == nil 23 | assert fetch_token(["Bearer token"]) == "token" 24 | assert fetch_token(["bearer token"]) == "token" 25 | assert fetch_token(["invalid", "Bearer token"]) == "token" 26 | end 27 | 28 | test "verify_token/1 with invalid token" do 29 | assert {:error, :not_authenticated} = verify_token(nil) 30 | assert {:error, :signature_error} = verify_token("abc123") 31 | assert {:ok, %{"aud" => "Joken", "iss" => "Joken"}} = verify_token(fixture(:token)) 32 | end 33 | 34 | test "call/2 with valid token" do 35 | conn = 36 | conn(:get, "/") 37 | |> put_req_header("authorization", "Bearer #{fixture(:token)}") 38 | |> call(%{}) 39 | 40 | refute conn.halted 41 | assert %Plug.Conn{assigns: %{claims: %{"aud" => "Joken", "iss" => "Joken"}}} = conn 42 | end 43 | 44 | test "call/2 with invalid token" do 45 | conn = 46 | conn(:get, "/") 47 | |> put_req_header("authorization", "bearer abc123") 48 | |> call(%{}) 49 | 50 | assert conn.halted 51 | assert conn.status == 401 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------