diff --git a/.gitignore b/.gitignore index 48408db..8411289 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ _build cover/ .vscode/ +doc diff --git a/README.md b/README.md index 1447bc4..e1dbe61 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-# `elixir-auth-microsoft` +

elixir-auth-microsoft

The _easy_ way to add **Microsoft `OAuth` authentication** to your **`Elixir` / `Phoenix`** app. @@ -19,6 +19,29 @@ Just plug-and-play in **5 mins**.
+- [_Why_? 🤷](#why-) +- [_What_? 💭](#what-) +- [_Who_? 👥](#who-) +- [_How_? ✅](#how-) + - [1. Add the hex package to `deps` 📦](#1-add-the-hex-package-to-deps-) + - [2. Create an App Registration in Azure Active Directory 🆕](#2-create-an-app-registration-in-azure-active-directory-) + - [3. Export Environment / Application Variables](#3-export-environment--application-variables) + - [A note on tenants](#a-note-on-tenants) + - [4. Add a "Sign in with Microsoft" Button to your App](#4-add-a-sign-in-with-microsoft-button-to-your-app) + - [5. Use the Built-in Functions to Authenticate People :shipit:](#5-use-the-built-in-functions-to-authenticate-people-shipit) + - [6. Add the `/auth/microsoft/callback` to `router.ex`](#6-add-the-authmicrosoftcallback-to-routerex) + - [6.1 Give it a try!](#61-give-it-a-try) + - [7. Logging the person out](#7-logging-the-person-out) + - [7.1 Setting up the post-logout redirect URI](#71-setting-up-the-post-logout-redirect-uri) + - [7.2 Add button for person to log out](#72-add-button-for-person-to-log-out) + - [_Done_!](#done) + - [Testing](#testing) + - [Complete Working Demo / Example `Phoenix` App 🚀](#complete-working-demo--example-phoenix-app-) + - [Optimised SVG + CSS Button](#optimised-svg--css-button) + - [Notes 📝](#notes-) + - [Branding Guidelines](#branding-guidelines) + + # _Why_? 🤷 Following @@ -403,6 +426,30 @@ Thank you! 🙏
+ +## Testing + +If you want pre-defined responses without making real requests +when testing, +you can add the following property `httpoison_mock` +in your `test.exs` configuration file. + +```elixir +config :elixir_auth_microsoft, + httpoison_mock: true +``` + +With this setting turned on, +calls will return successful requests +with mock data. + +Of course, you could always a mocking library like +[`mox`](https://github.com/dashbitco/mox) +for this. +But if you want a quick way to test your app with this package, +this option may be for you! + + ## Complete Working Demo / Example `Phoenix` App 🚀 If you get stuck diff --git a/demo/lib/app_web/httpoison_mock.ex b/demo/lib/app_web/httpoison_mock.ex index e5574b7..0003c0f 100644 --- a/demo/lib/app_web/httpoison_mock.ex +++ b/demo/lib/app_web/httpoison_mock.ex @@ -1,30 +1,36 @@ defmodule ElixirAuthMicrosoft.HTTPoisonMock do - def get("https://login.microsoftonline.com/common/oauth2/v2.0/authorize") do - {:error, :bad_request} - end + @moduledoc """ + SHOULD BE THE SAME AS THE ORIGINAL `httpoison_mock.ex`. + """ + @spec get(any, nonempty_maybe_improper_list) :: {:error, :bad_request} | {:ok, %{body: binary}} + def get(_url, [ {:Authorization, token} = _authorization | _content_type]) do + is_token_valid = token !== "Bearer invalid_token" - def get(_url, _headers) do - {:ok, - %{ - body: - Jason.encode!(%{ - businessPhones: [], - displayName: "Test Name", - givenName: "Test", - id: "192jnsd9010apd", - jobTitle: nil, - mail: nil, - mobilePhone: '+351928837834', - officeLocation: nil, - preferredLanguage: nil, - surname: "Name", - userPrincipalName: "testemail@hotmail.com"} - ) - }} + if is_token_valid do + {:ok, + %{ + body: + Jason.encode!(%{ + businessPhones: [], + displayName: "Test Name", + givenName: "Test", + id: "192jnsd9010apd", + jobTitle: nil, + mail: nil, + mobilePhone: '+351928837834', + officeLocation: nil, + preferredLanguage: nil, + surname: "Name", + userPrincipalName: "testemail@hotmail.com"} + ) + }} + else + {:error, :bad_request} + end end - + @spec post(any, any, any) :: {:ok, %{body: binary}} def post(_url, _body, _headers) do - {:ok, %{body: Jason.encode!(%{access_token: "token1", code: "code1"})}} + {:ok, %{body: Jason.encode!(%{access_token: "token1"})}} end end diff --git a/demo/lib/app_web/microsoft_auth.ex b/demo/lib/app_web/microsoft_auth.ex index 11472ab..4689bb3 100644 --- a/demo/lib/app_web/microsoft_auth.ex +++ b/demo/lib/app_web/microsoft_auth.ex @@ -1,4 +1,8 @@ defmodule ElixirAuthMicrosoft do + @moduledoc """ + SHOULD BE THE SAME AS THE ORIGINAL `microsoft_auth.ex`. + """ + @default_authorize_url "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" @default_logout_url "https://login.microsoftonline.com/common/oauth2/v2.0/logout" @default_token_url "https://login.microsoftonline.com/common/oauth2/v2.0/token" @@ -8,8 +12,18 @@ defmodule ElixirAuthMicrosoft do @httpoison (Application.compile_env(:elixir_auth_microsoft, :httpoison_mock) && ElixirAuthMicrosoft.HTTPoisonMock) || HTTPoison + @doc """ + `http/0` injects a TestDouble in test envs. + When testing, it uses a mocked version of HTTPoison with predictible results. When in production, it uses the original version. + """ def http, do: @httpoison + @doc """ + `generate_oauth_url_authorize/1` creates an OAuth2 URL with client_id, redirect_uri and scopes (be sure to create the app registration in Azure Portal AD). + The redirect_uri will be the URL Microsoft will redirect after successful sign-in. + This is the URL that you should be used in a "Login with Microsoft"-type button. + """ + @spec generate_oauth_url_authorize(Conn.t()) :: String.t() def generate_oauth_url_authorize(conn) do query = %{ @@ -24,11 +38,24 @@ defmodule ElixirAuthMicrosoft do "#{microsoft_authorize_url()}?&#{params}" end + @doc """ + `generate_oauth_url_authorize/2` is the same as `generate_oauth_url_authorize/1` but with a state parameter. + This state parameter should be compared with the one that is sent as query param in the redirect URI after the sign-in is successful. + """ + @spec generate_oauth_url_authorize(%{:host => any, optional(any) => any}, binary) :: String.t() def generate_oauth_url_authorize(conn, state) when is_binary(state) do params = URI.encode_query(%{state: state}, :rfc3986) generate_oauth_url_authorize(conn) <> "&#{params}" end + + @doc """ + `generate_oauth_url_logout/0` creates a logout URL. + This should the URL the person is redirected to when they want to logout. + To define the redirect URL (the URL that the user will be redirected to after successful logout from Microsoft ), + you need to set the `MICROSOFT_POST_LOGOUT_REDIRECT_URI` env variable + or `:post_logout_redirect_uri` in the config file. + """ def generate_oauth_url_logout() do query = %{ @@ -39,6 +66,11 @@ defmodule ElixirAuthMicrosoft do "#{microsoft_logout_url()}?&#{params}" end + @doc """ + `get_token/2` fetches the ID token using the authorization code that was previously obtained. + Env variables are used to encode information while fetching the ID token from Microsoft, including the registered client ID that was created in Azure Portal AD. + """ + @spec get_token(String.t(), Conn.t()) :: {:ok, map} | {:error, any} def get_token(code, conn) do headers = ["Content-Type": "multipart/form-data"] @@ -57,6 +89,10 @@ defmodule ElixirAuthMicrosoft do end + @doc """ + `get_user_profile/1` fetches the signed-in Microsoft User info according to the token that is passed by calling `get_token/1`. + """ + @spec get_user_profile(String.t()) :: {:error, any} | {:ok, map} def get_user_profile(token) do headers = ["Authorization": "Bearer #{token}", "Content-Type": "application/json"] @@ -65,7 +101,13 @@ defmodule ElixirAuthMicrosoft do end + @doc """ + `parse_body_response/1` parses the response from Microsoft's endpoints. + The keys of the decoded map are converted in atoms, for easier access in templates. + ##TODO check cases where the parsed code when fetching fails. + """ + @spec parse_body_response({atom, String.t()} | {:error, any}) :: {:ok, map} | {:error, any} def parse_body_response({:error, err}), do: {:error, err} def parse_body_response({:ok, response}) do body = Map.get(response, :body) diff --git a/demo/mix.exs b/demo/mix.exs index 9691bcc..556f5e3 100644 --- a/demo/mix.exs +++ b/demo/mix.exs @@ -44,9 +44,11 @@ defmodule App.MixProject do {:telemetry_metrics, "~> 0.6"}, {:telemetry_poller, "~> 1.0"}, {:gettext, "~> 0.18"}, - {:jason, "~> 1.2"}, {:plug_cowboy, "~> 2.5"}, - {:httpoison, "~> 1.8.0"}, + + # Dependencies used by the package (to be the same as the package, make sure it's the same in the root mix.exs) + {:httpoison, ">= 0.6.1"}, + {:jason, ">= 1.0.0"}, ] end diff --git a/lib/elixir_auth_microsoft.ex b/lib/elixir_auth_microsoft.ex index dfb25f1..80b0012 100644 --- a/lib/elixir_auth_microsoft.ex +++ b/lib/elixir_auth_microsoft.ex @@ -28,13 +28,13 @@ defmodule ElixirAuthMicrosoft do @spec generate_oauth_url_authorize(Conn.t()) :: String.t() def generate_oauth_url_authorize(conn) do - query = %{ + query = [ client_id: microsoft_client_id(), response_type: "code", redirect_uri: generate_redirect_uri(conn), scope: get_microsoft_scopes(), response_mode: "query" - } + ] params = URI.encode_query(query, :rfc3986) "#{microsoft_authorize_url()}?&#{params}" @@ -46,7 +46,7 @@ defmodule ElixirAuthMicrosoft do """ @spec generate_oauth_url_authorize(%{:host => any, optional(any) => any}, binary) :: String.t() def generate_oauth_url_authorize(conn, state) when is_binary(state) do - params = URI.encode_query(%{state: state}, :rfc3986) + params = URI.encode_query([state: state], :rfc3986) generate_oauth_url_authorize(conn) <> "&#{params}" end @@ -60,9 +60,9 @@ defmodule ElixirAuthMicrosoft do """ def generate_oauth_url_logout() do - query = %{ + query = [ post_logout_redirect_uri: microsoft_post_logout_redirect_uri(), - } + ] params = URI.encode_query(query, :rfc3986) "#{microsoft_logout_url()}?&#{params}" diff --git a/lib/httpoison_mock.ex b/lib/httpoison_mock.ex index a508ac5..5ebc451 100644 --- a/lib/httpoison_mock.ex +++ b/lib/httpoison_mock.ex @@ -38,7 +38,7 @@ defmodule ElixirAuthMicrosoft.HTTPoisonMock do @doc """ Mocks the `post/3` function from HTTPoison. - It yields a predictable, with always the same access token. + It yields a predictable result, with always the same access token. """ @spec post(any, any, any) :: {:ok, %{body: binary}} def post(_url, _body, _headers) do diff --git a/mix.exs b/mix.exs index 2226d30..89203b2 100644 --- a/mix.exs +++ b/mix.exs @@ -33,8 +33,8 @@ defmodule ElixirAuthMicrosoft.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:httpoison, "~> 1.8.0"}, - {:jason, "~> 1.2"}, + {:httpoison, ">= 0.6.1"}, + {:jason, ">= 1.0.0"}, # Testing {:excoveralls, "~> 0.18.0", only: [:test, :dev]}, diff --git a/mix.lock b/mix.lock index bb7d337..d3c6cd6 100644 --- a/mix.lock +++ b/mix.lock @@ -9,7 +9,7 @@ "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.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.4.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", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, - "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, + "httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, diff --git a/test/elixir_auth_microsoft_test.exs b/test/elixir_auth_microsoft_test.exs index e97d26f..dbafbeb 100644 --- a/test/elixir_auth_microsoft_test.exs +++ b/test/elixir_auth_microsoft_test.exs @@ -19,7 +19,7 @@ defmodule ElixirAuthMicrosoftTest do expected_scope = URI.encode_www_form("https://graph.microsoft.com/User.Read") expected = - "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?&client_id=" <> id <> "&redirect_uri=" <> expected_redirect_uri <> "&response_mode=query&response_type=code&scope=" <> expected_scope <> "&state=" <> state + "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?&client_id=#{id}&response_type=code&redirect_uri=#{expected_redirect_uri}&scope=#{expected_scope}&response_mode=query&state=#{state}" assert ElixirAuthMicrosoft.generate_oauth_url_authorize(conn, state) == expected end @@ -39,7 +39,7 @@ defmodule ElixirAuthMicrosoftTest do expected_scope = URI.encode_www_form("https://graph.microsoft.com/User.Read") expected = - "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?&client_id=" <> id <> "&redirect_uri=" <> expected_redirect_uri <> "&response_mode=query&response_type=code&scope=" <> expected_scope + "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?&client_id=#{id}&response_type=code&redirect_uri=#{expected_redirect_uri}&scope=#{expected_scope}&response_mode=query" assert ElixirAuthMicrosoft.generate_oauth_url_authorize(conn) == expected end @@ -59,7 +59,7 @@ defmodule ElixirAuthMicrosoftTest do expected_redirect_uri = URI.encode_www_form("http://localhost:4000/auth/microsoft/logout") expected = - "https://login.microsoftonline.com/common/oauth2/v2.0/logout?&post_logout_redirect_uri=" <> expected_redirect_uri + "https://login.microsoftonline.com/common/oauth2/v2.0/logout?&post_logout_redirect_uri=#{expected_redirect_uri}" assert ElixirAuthMicrosoft.generate_oauth_url_logout() == expected end