Skip to content

Commit

Permalink
Merge pull request #48 from cheerfulstoic/freeze-httpoison-jason-issu…
Browse files Browse the repository at this point in the history
…e-#45

Freeze httpoison jason issue #45
  • Loading branch information
LuchoTurtle authored Feb 8, 2024
2 parents c380e75 + c8718a0 commit 8a934b1
Show file tree
Hide file tree
Showing 10 changed files with 136 additions and 38 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ _build
cover/

.vscode/
doc
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<div align="center">

# `elixir-auth-microsoft`
<h1>elixir-auth-microsoft</h1>

The _easy_ way to add **Microsoft `OAuth` authentication**
to your **`Elixir` / `Phoenix`** app.
Expand All @@ -19,6 +19,29 @@ Just plug-and-play in **5 mins**.

</div>

- [_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
Expand Down Expand Up @@ -403,6 +426,30 @@ Thank you! 🙏

<br />


## 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
Expand Down
52 changes: 29 additions & 23 deletions demo/lib/app_web/httpoison_mock.ex
Original file line number Diff line number Diff line change
@@ -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: "[email protected]"}
)
}}
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: "[email protected]"}
)
}}
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
42 changes: 42 additions & 0 deletions demo/lib/app_web/microsoft_auth.ex
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 = %{
Expand All @@ -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 = %{
Expand All @@ -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"]

Expand All @@ -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"]

Expand All @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions demo/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions lib/elixir_auth_microsoft.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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

Expand All @@ -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}"
Expand Down
2 changes: 1 addition & 1 deletion lib/httpoison_mock.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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]},
Expand Down
2 changes: 1 addition & 1 deletion mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
6 changes: 3 additions & 3 deletions test/elixir_auth_microsoft_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down

0 comments on commit 8a934b1

Please sign in to comment.