diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..32ade00 --- /dev/null +++ b/.credo.exs @@ -0,0 +1,206 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "src/", + "test/", + "web/", + "apps/*/lib/", + "apps/*/src/", + "apps/*/test/", + "apps/*/web/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: false, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: %{ + enabled: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 2]}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 0]}, + {Credo.Check.Design.TagFIXME, []}, + + # + ## Readability Checks + # + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.RejectReject, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.WrongTestFileExtension, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.UnsafeExec, []} + ], + disabled: [ + {Credo.Check.Readability.AliasOrder, []}, + + # + # Controversial and experimental checks (opt-in, just move the check to `:enabled` + # and be sure to use `mix credo --strict` to see low priority checks) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, + {Credo.Check.Consistency.UnusedVariableNames, []}, + {Credo.Check.Design.DuplicatedCode, []}, + {Credo.Check.Design.SkipTestWithoutComment, []}, + {Credo.Check.Readability.AliasAs, []}, + {Credo.Check.Readability.BlockPipe, []}, + {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.MultiAlias, []}, + {Credo.Check.Readability.NestedFunctionCalls, []}, + {Credo.Check.Readability.SeparateAliasRequire, []}, + {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, + {Credo.Check.Readability.SinglePipe, []}, + {Credo.Check.Readability.Specs, []}, + {Credo.Check.Readability.StrictModuleLayout, []}, + {Credo.Check.Readability.WithCustomTaggedTuple, []}, + {Credo.Check.Refactor.ABCSize, []}, + {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.DoubleBooleanNegation, []}, + {Credo.Check.Refactor.FilterReject, []}, + {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.ModuleDependencies, []}, + {Credo.Check.Refactor.NegatedIsNil, []}, + {Credo.Check.Refactor.PipeChainStart, []}, + {Credo.Check.Refactor.RejectFilter, []}, + {Credo.Check.Refactor.VariableRebinding, []}, + {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.LeakyEnvironment, []}, + {Credo.Check.Warning.MapGetUnsafePass, []}, + {Credo.Check.Warning.MixEnv, []}, + {Credo.Check.Warning.UnsafeToAtom, []} + + # {Credo.Check.Refactor.MapInto, []}, + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + } + ] +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..bb58b7f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,92 @@ +name: Test +on: [push, pull_request] + +jobs: + static-analysis: + runs-on: ubuntu-latest + env: + MIX_ENV: dev + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v2 + with: + python-version: "3.9" + - uses: erlef/setup-beam@v1 + with: + otp-version: "25" + elixir-version: "1.14" + - uses: actions/cache@v3.0.11 + name: Setup Elixir cache + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-otp-25-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix-otp-25- + - uses: actions/cache@v3.0.11 + name: Setup Python cache + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + - name: Install Elixir Dependencies + run: mix deps.get --only dev + - name: Install Python Dependencies + run: | + pip install -r requirements.txt + # Don't cache PLTs based on mix.lock hash, as Dialyzer can incrementally update even old ones + # Cache key based on Elixir & Erlang version (also usefull when running in matrix) + - name: Restore PLT cache + uses: actions/cache@v3.0.11 + id: plt_cache + with: + key: | + ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plt + restore-keys: | + ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plt + path: | + priv/plts + # Create PLTs if no cache was found + - name: Create PLTs + if: steps.plt_cache.outputs.cache-hit != 'true' + run: mix dialyzer --plt + - name: Run pre-commit + run: | + pre-commit install + SKIP=no-commit-to-branch pre-commit run --all-files + + unit-test: + runs-on: ubuntu-22.04 + env: + MIX_ENV: test + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + strategy: + matrix: + include: + - elixir-version: "1.12" + otp-version: "24" + - elixir-version: "1.13" + otp-version: "24" + - elixir-version: "1.14" + otp-version: "25" + steps: + - uses: actions/checkout@v3 + - uses: erlef/setup-beam@v1 + with: + otp-version: "${{ matrix.otp-version }}" + elixir-version: "${{ matrix.elixir-version }}" + - uses: actions/cache@v3.0.11 + with: + path: | + deps + _build + key: ${{ runner.os }}-${{ matrix.otp-version }}-${{ matrix.elixir-version }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.otp-version }}-${{ matrix.elixir-version }}- + - name: Install Dependencies + run: mix deps.get --only test + - name: Run Tests + run: mix test diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..198f719 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,43 @@ +repos: + - repo: local + hooks: + # Elixir config + # Randomly started failing + # - id: mix-format + # name: 'elixir: mix format' + # entry: mix format --check-formatted + # language: system + - id: mix-lint + name: 'elixir: mix credo' + entry: mix credo --strict + language: system + pass_filenames: false + files: \.exs*$ + - id: mix-analysis + name: 'elixir: mix dialyzer' + entry: mix dialyzer --format dialyxir + language: system + pass_filenames: false + files: \.exs*$ + - id: mix-compile + name: 'elixir: mix compile' + entry: mix compile --force --warnings-as-errors + language: system + pass_filenames: false + files: \.ex$ + + # Standard pre-commit hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.3.0 + hooks: + - id: mixed-line-ending + args: ['--fix=lf'] + description: Forces to replace line ending by the UNIX 'lf' character. + - id: check-merge-conflict + - id: end-of-file-fixer + exclude: "^omnibus/config/patches/" + - id: trailing-whitespace + exclude: "^omnibus/config/patches/" + - id: check-merge-conflict + - id: no-commit-to-branch + args: [-b, master, -b, develop] diff --git a/CHANGELOG.md b/CHANGELOG.md index 51c5416..b11d4a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## v0.2.2 * Allow missing `claims_supported` in discovery document -* Allow overriding document params +* Allow overriding document params ## v0.2.1 * Relaxed jason version requirement @@ -20,4 +20,4 @@ ## v0.1.0 -* Initial public release \ No newline at end of file +* Initial public release diff --git a/LICENSE.md b/LICENSE.md index cceb743..8c3e3ab 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -4,4 +4,4 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -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. \ No newline at end of file +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. diff --git a/README.md b/README.md index dedffe2..c4660e7 100644 --- a/README.md +++ b/README.md @@ -12,53 +12,31 @@ Add `openid_connect` to your list of dependencies in `mix.exs`: ```elixir def deps do - [{:openid_connect, "~> 0.2.2"}] + [{:openid_connect, "~> 1.0.0"}] end ``` ## Getting Started - ### Configuration -You should add the configuration settings for each of your providers into one of your app's configuration: +Most of the functions expect a `config` map which means application developer should take care for the storage of those options, eg: ```elixir -config :my_app, :openid_connect_providers, - google: [ - discovery_document_uri: "https://accounts.google.com/.well-known/openid-configuration", - client_id: "CLIENT_ID", - client_secret: "CLIENT_SECRET", - redirect_uri: "https://example.com/session", - response_type: "code", - scope: "openid email profile" - ] +google_config = %{ + discovery_document_uri: "https://accounts.google.com/.well-known/openid-configuration", + client_id: "CLIENT_ID", + client_secret: "CLIENT_SECRET", + redirect_uri: "https://example.com/session", + response_type: "code", + scope: "openid email profile" +} + +authorization_uri(google_config) ``` -You *must* setup with your provider first. Your provider will have the correct data to provider for the config settings above. - Most major OAuth2 providers have added support for OpenIDConnect. [See a short list of most major adopters of OpenIDConnect](https://en.wikipedia.org/wiki/List_of_OAuth_providers). -> You can add multiple providers in the list. The key for each provider is just a reference that you'll -> use with the `OpenIDConnect` module in your app code. - -### Worker - -Next add the `OpenIDConnect.Worker` to your app's supervisor along with your provider configs. - -```elixir -children = - [ - { - OpenIDConnect.Worker, - Application.get_env(:my_app, :openid_connect_providers) - }, - ] - -opts = [strategy: :one_for_one, name: MyApp.Supervisor] -Supervisor.start_link(children, opts) -``` - ### Usage In your app code you will need to do a few things: @@ -74,12 +52,12 @@ In your app code you will need to do a few things: You can build the correct authorization URI for a provider with: ```elixir -OpenIDConnect.authorization_uri(:google) +OpenIDConnect.authorization_uri(google_config) ``` In this case we are requesting that `OpenIDConnect` build the authorization URI for -the `:google` provider that we setup on in configuration above. You should use this URI for -your users to link out to for authenticating with the given provider +the google provider. You should use this URI for your users to link out to for +authenticating with the given provider #### Handling the redirect from the provider @@ -92,7 +70,7 @@ The JSON Web Token (JWT) must be fetched, using the key/value pairs from the `re part of the redirect to your application: ```elixir -{:ok, tokens} = OpenIDConnect.fetch_tokens(:google, %{code: params["code"]}) +{:ok, tokens} = OpenIDConnect.fetch_tokens(google_config, %{code: params["code"]}) ``` #### Verify the JWT @@ -100,7 +78,7 @@ part of the redirect to your application: The JWT is encrypted and it should always be verified with the JSON Web Keys (JWK) for the provider: ```elixir -{:ok, claims} = OpenIDConnect.verify(:google, tokens["id_token"]) +{:ok, claims} = OpenIDConnect.verify(google_config, tokens["id_token"]) ``` The `claims` is a payload with the information from the `scopes` you requested of the provider. @@ -120,14 +98,17 @@ get("/session/authorization-uri", SessionController, :authorization_uri) # session_controller.ex # you could also take the `provider` as a query param to pass into the function def authorization_uri(conn, _params) do - json(conn, %{uri: OpenIDConnect.authorization_uri(:google)}) + google_config = Application.fetch_env!(:my_app, :google_oidc_config) + json(conn, %{uri: OpenIDConnect.authorization_uri(google_config)}) end # The `Authentication` module here is an imaginary interface for setting session state def create(conn, params) do - with {:ok, tokens} <- OpenIDConnect.fetch_tokens(:google, params["code"]), - {:ok, claims} <- OpenIDConnect.verify(:google, tokens["id_token"]), - {:ok, user} <- Authentication.call(:google, Repo, claims) do + google_config = Application.fetch_env!(:my_app, :google_oidc_config) + + with {:ok, tokens} <- OpenIDConnect.fetch_tokens(google_config, params["code"]), + {:ok, claims} <- OpenIDConnect.verify(google_config, tokens["id_token"]), + {:ok, user} <- Authentication.call(google_config, Repo, claims) do conn |> put_status(200) @@ -138,30 +119,30 @@ def create(conn, params) do end ``` -## Authors ## +## Authors -* [Brian Cardarella](http://twitter.com/bcardarella) +- [Brian Cardarella](http://twitter.com/bcardarella) [We are very thankful for the many contributors](https://github.com/dockyard/openid_connect/graphs/contributors) -## Versioning ## +## Versioning This library follows [Semantic Versioning](http://semver.org) -## Looking for help with your Elixir project? ## +## Looking for help with your Elixir project? [At DockYard we are ready to help you build your next Elixir project](https://dockyard.com/phoenix-consulting). We have a unique expertise in Elixir and Phoenix development that is unmatched. [Get in touch!](https://dockyard.com/contact/hire-us) At DockYard we love Elixir! You can [read our Elixir blog posts](https://dockyard.com/blog/categories/elixir) or come visit us at [The Boston Elixir Meetup](http://www.meetup.com/Boston-Elixir/) that we organize. -## Want to help? ## +## Want to help? Please do! We are always looking to improve this library. Please see our [Contribution Guidelines](https://github.com/dockyard/openid_connect/blob/master/CONTRIBUTING.md) on how to properly submit issues and pull requests. -## Legal ## +## Legal [DockYard](http://dockyard.com/), Inc. © 2018 diff --git a/config/config.exs b/config/config.exs deleted file mode 100644 index f321714..0000000 --- a/config/config.exs +++ /dev/null @@ -1,30 +0,0 @@ -# This file is responsible for configuring your application -# and its dependencies with the aid of the Mix.Config module. -use Mix.Config - -# This configuration is loaded before any dependency and is restricted -# to this project. If another project depends on this project, this -# file won't be loaded nor affect the parent project. For this reason, -# if you want to provide default values for your application for -# 3rd-party users, it should be done in your "mix.exs" file. - -# You can configure for your application as: -# -# config :openid_connect, key: :value -# -# And access this configuration in your application as: -# -# Application.get_env(:openid_connect, :key) -# -# Or configure a 3rd-party app: -# -# config :logger, level: :info -# - -# It is also possible to import configuration files, relative to this -# directory. For example, you can emulate configuration per environment -# by uncommenting the line below and defining dev.exs, test.exs and such. -# Configuration from the imported file will override the ones defined -# here (which is why it is important to import them last). -# -import_config "#{Mix.env()}.exs" diff --git a/config/dev.exs b/config/dev.exs deleted file mode 100644 index 18c4b68..0000000 --- a/config/dev.exs +++ /dev/null @@ -1,11 +0,0 @@ -use Mix.Config - -config :openid_connect, :providers, - google: [ - discovery_document_uri: "https://accounts.google.com/.well-known/openid-configuration", - client_id: "CLIENT_ID_1", - client_secret: "CLIENT_SECRET_1", - redirect_uri: "https://dev.example.com:4200/session", - scope: "openid email profile", - response_type: "code id_token token" - ] diff --git a/config/test.exs b/config/test.exs deleted file mode 100644 index e4dfa14..0000000 --- a/config/test.exs +++ /dev/null @@ -1,13 +0,0 @@ -use Mix.Config - -config :openid_connect, :http_client, OpenIDConnect.HTTPClientMock - -config :openid_connect, :providers, - google: [ - discovery_document_uri: "https://accounts.google.com/.well-known/openid-configuration", - client_id: "CLIENT_ID_1", - client_secret: "CLIENT_SECRET_1", - redirect_uri: "https://dev.example.com:4200/session", - scope: "openid email profile", - response_type: "code id_token token" - ] diff --git a/lib/openid_connect.ex b/lib/openid_connect.ex index a532724..3b0185c 100644 --- a/lib/openid_connect.ex +++ b/lib/openid_connect.ex @@ -2,78 +2,67 @@ defmodule OpenIDConnect do @moduledoc """ Handles a majority of the life-cycle concerns with [OpenID Connect](http://openid.net/connect/) """ + alias OpenIDConnect.Document @typedoc """ - URI as a string + URL to a [OpenID Discovery Document](https://openid.net/specs/openid-connect-discovery-1_0.html) endpoint. """ - @type uri :: String.t() + @type discovery_document_uri :: String.t() @typedoc """ - JSON Web Token - - See: https://jwt.io/introduction/ + OAuth 2.0 Client Identifier valid at the Authorization Server. """ - @type jwt :: String.t() + @type client_id :: String.t() @typedoc """ - The provider name as an atom - - Example: `:google` - - This atom should match what you've used in your application config + OAuth 2.0 Client Secret valid at the Authorization Server. """ - @type provider :: atom + @type client_secret :: String.t() @typedoc """ - The payload of user data from the provider - """ - @type claims :: map + Redirection URI to which the response will be sent. - @typedoc """ - The name of the genserver + This URI MUST exactly match one of the Redirection URI values for the Client pre-registered at the OpenID Provider, + with the matching performed as described in Section 6.2.1 of [RFC3986] (Simple String Comparison). - This is optional and will default to `:openid_connect` unless overridden + When using this flow, the Redirection URI SHOULD use the https scheme; however, it MAY use the http scheme, + provided that the Client Type is confidential, as defined in Section 2.1 of OAuth 2.0, + and provided the OP allows the use of http Redirection URIs in this case. The Redirection URI MAY use an alternate scheme, + such as one that is intended to identify a callback into a native application. """ - @type name :: atom + @type redirect_uri :: String.t() @typedoc """ - Query param map + OAuth 2.0 Response Type value that determines the authorization processing flow to be used, + including what parameters are returned from the endpoints used. """ - @type params :: map - - @typedoc """ - The success tuple + @type response_type :: [String.t()] | String.t() - The 2nd element will be the relevant value to work with - """ - @type success(value) :: {:ok, value} @typedoc """ - A string reason for an error failure + OAuth 2.0 Scope Values that the Client is declaring that it will restrict itself to using. """ - @type reason :: String.t() | %HTTPoison.Error{} | %HTTPoison.Response{} + @type scope :: [String.t()] | String.t() @typedoc """ - An error tuple - - The 2nd element will indicate which function failed - The 3rd element will give details of the failure + The configuration of a OpenID provider. """ - @type error(name) :: {:error, name, reason} + @type config :: %{ + required(:discovery_document_uri) => discovery_document_uri(), + required(:client_id) => client_id(), + required(:client_secret) => client_secret(), + required(:redirect_uri) => redirect_uri(), + required(:response_type) => response_type(), + required(:scope) => scope(), + optional(:leeway) => non_neg_integer() + } @typedoc """ - A provider's documents + JSON Web Token - * discovery_document: the provider's discovery document for OpenID Connect - * jwk: the provider's certificates converted into a JOSE JSON Web Key - * remaining_lifetime: how long the provider's JWK is valid for + See: https://jwt.io/introduction/ """ - @type documents :: %{ - discovery_document: map, - jwk: JOSE.JWK.t(), - remaining_lifetime: integer | nil - } + @type jwt :: String.t() - @spec authorization_uri(provider, params, name) :: uri @doc """ Builds the authorization URI according to the spec in the providers discovery document @@ -85,27 +74,96 @@ defmodule OpenIDConnect do > It is *highly suggested* that you add the `state` param for security reasons. Your > OpenID Connect provider should have more information on this topic. """ - def authorization_uri(provider, params \\ %{}, name \\ :openid_connect) do - document = discovery_document(provider, name) - config = config(provider, name) + @spec authorization_uri(config(), params :: %{optional(atom) => term()}) :: + {:ok, uri :: String.t()} | {:error, term()} + def authorization_uri(config, params \\ %{}) do + discovery_document_uri = config.discovery_document_uri + + with {:ok, document} <- Document.fetch_document(discovery_document_uri), + {:ok, response_type} <- fetch_response_type(config, document), + {:ok, scope} <- fetch_scope(config) do + params = + Map.merge( + %{ + client_id: config.client_id, + redirect_uri: config.redirect_uri, + response_type: response_type, + scope: scope + }, + params + ) + + {:ok, build_uri(document.authorization_endpoint, params)} + end + end - uri = Map.get(document, "authorization_endpoint") + defp fetch_scope(%{scope: scope}) when is_nil(scope) or scope == [] or scope == "", + do: {:error, :invalid_scope} - params = - Map.merge( - %{ - client_id: client_id(config), - redirect_uri: redirect_uri(config), - response_type: response_type(provider, config, name), - scope: normalize_scope(provider, config[:scope]) - }, - params - ) + defp fetch_scope(%{scope: scope}) when is_binary(scope), + do: {:ok, scope} - build_uri(uri, params) + defp fetch_scope(%{scope: scopes}) when is_list(scopes), + do: {:ok, Enum.join(scopes, " ")} + + defp fetch_response_type( + %{response_type: response_type}, + %Document{response_types_supported: response_types_supported} + ) do + with {:ok, response_type} <- parse_response_type(response_type) do + response_type = Enum.sort(response_type) + + if Enum.all?(response_type, &(&1 in response_types_supported)) do + {:ok, Enum.join(response_type, " ")} + else + {:error, + {:response_type_not_supported, response_types_supported: response_types_supported}} + end + end + end + + defp parse_response_type(nil), do: {:error, :invalid_response_type} + defp parse_response_type([]), do: {:error, :invalid_response_type} + defp parse_response_type(""), do: {:error, :invalid_response_type} + + defp parse_response_type(response_type) when is_binary(response_type), + do: {:ok, String.split(response_type)} + + defp parse_response_type(response_type) when is_list(response_type), + do: {:ok, response_type} + + @doc """ + Builds the end session URI according to the spec in the providers discovery document + + The `params` option can be used to add additional query params to the URI + + Example: + OpenIDConnect.end_session_uri(:azure, %{"client_id" => "5d4c39b4-660f-41c9-9a99-2a6a9c263f07"}) + + See more about this feature of the OpenID Connect spec: + https://openid.net/specs/openid-connect-rpinitiated-1_0.html + + Each provider will typically require one or more of the supported query params, e.g. `id_token_hint` or + `client_id`. Read your provider's OIDC documentation to determine which one(s) you should add. + + Some providers don't specify `end_session_endpoint` in their discovery documents, + in such cases `{:error, :endpoint_not_set}` is returned. + """ + @spec end_session_uri(config(), params :: %{optional(atom) => term()}) :: + {:ok, uri :: String.t()} | {:error, term()} + def end_session_uri(config, params \\ %{}) do + discovery_document_uri = config.discovery_document_uri + + with {:ok, document} <- Document.fetch_document(discovery_document_uri) do + if end_session_endpoint = document.end_session_endpoint do + params = Map.merge(%{client_id: config.client_id}, params) + {:ok, build_uri(end_session_endpoint, params)} + else + {:error, :endpoint_not_set} + end + end end - @spec fetch_tokens(provider, params, name) :: success(map) | error(:fetch_tokens) @doc """ Fetches the authentication tokens from the provider @@ -113,140 +171,91 @@ defmodule OpenIDConnect do was requested during authorization. `params` may also include any one-off overrides for token fetching. """ - def fetch_tokens(provider, params, name \\ :openid_connect) - - def fetch_tokens(provider, code, name) when is_binary(code) do - IO.warn( - "Deprecation: `OpenIDConnect.fetch_tokens/3` no longer takes a binary as the 2nd argument. Please refer to the docs for the new API." - ) - - fetch_tokens(provider, %{code: code}, name) - end - - def fetch_tokens(provider, params, name) do - uri = access_token_uri(provider, name) - config = config(provider, name) + @spec fetch_tokens(config(), params :: %{optional(atom) => term()}) :: + {:ok, response :: map()} | {:error, term()} + def fetch_tokens(config, params) do + discovery_document_uri = config.discovery_document_uri form_body = Map.merge( %{ - client_id: client_id(config), - client_secret: client_secret(config), - grant_type: "authorization_code", - redirect_uri: redirect_uri(config) + client_id: config.client_id, + client_secret: config.client_secret, + redirect_uri: config.redirect_uri, + grant_type: "authorization_code" }, params ) - |> Map.to_list() + |> URI.encode_query(:www_form) headers = [{"Content-Type", "application/x-www-form-urlencoded"}] - with {:ok, %HTTPoison.Response{status_code: status_code} = resp} when status_code in 200..299 <- - http_client().post(uri, {:form, form_body}, headers, http_client_options()), - {:ok, json} <- Jason.decode(resp.body), - {:ok, json} <- assert_json(json) do + with {:ok, document} <- Document.fetch_document(discovery_document_uri), + request = Finch.build(:post, document.token_endpoint, headers, form_body), + {:ok, %Finch.Response{body: response, status: status}} when status in 200..299 <- + Finch.request(request, OpenIDConnect.Finch), + {:ok, json} <- Jason.decode(response) do {:ok, json} else - {:ok, resp} -> {:error, :fetch_tokens, resp} - {:error, reason} -> {:error, :fetch_tokens, reason} + {:ok, %Finch.Response{body: response, status: status}} -> {:error, {status, response}} + other -> other end end - @spec verify(provider, jwt, name) :: success(claims) | error(:verify) @doc """ Verifies the validity of the JSON Web Token (JWT) This verification will assert the token's encryption against the provider's JSON Web Key (JWK) """ - def verify(provider, jwt, name \\ :openid_connect) do - jwk = jwk(provider, name) + @spec verify(config(), jwt :: String.t()) :: + {:ok, claims :: map()} | {:error, term()} + def verify(config, jwt) do + discovery_document_uri = config.discovery_document_uri with {:ok, protected} <- peek_protected(jwt), {:ok, decoded_protected} <- Jason.decode(protected), {:ok, token_alg} <- Map.fetch(decoded_protected, "alg"), - {true, claims, _jwk} <- do_verify(jwk, token_alg, jwt) do - Jason.decode(claims) + {:ok, document} <- Document.fetch_document(discovery_document_uri), + {true, claims, _jwk} <- verify_signature(document.jwks, token_alg, jwt), + {:ok, unverified_claims} <- Jason.decode(claims), + {:ok, verified_claims} <- verify_claims(unverified_claims, config) do + {:ok, verified_claims} else {:error, %Jason.DecodeError{}} -> - {:error, :verify, "token claims did not contain a JSON payload"} + {:error, {:invalid_jwt, "token claims did not contain a JSON payload"}} {:error, :peek_protected} -> - {:error, :verify, "invalid token format"} + {:error, {:invalid_jwt, "invalid token format"}} + + {:error, invalid_claim, message} -> + {:error, {:invalid_jwt, "invalid #{invalid_claim} claim: #{message}"}} :error -> - {:error, :verify, "no `alg` found in token"} + {:error, {:invalid_jwt, "no `alg` found in token"}} {false, _claims, _jwk} -> - {:error, :verify, "verification failed"} - - _ -> - {:error, :verify, "verification error"} - end - end + {:error, {:invalid_jwt, "verification failed"}} - @spec update_documents(list) :: success(documents) | error(:update_documents) - @doc """ - Requests updated documents from the provider + {:error, {:case_clause, _}} -> + {:error, {:invalid_jwt, "verification failed"}} - This function is used by `OpenIDConnect.Worker` for document updates - according to the lifetime returned by the provider - """ - def update_documents(config) do - uri = discovery_document_uri(config) - - with {:ok, discovery_document, _} <- fetch_resource(uri), - {:ok, certs, remaining_lifetime} <- fetch_resource(discovery_document["jwks_uri"]), - {:ok, jwk} <- from_certs(certs) do - {:ok, - %{ - discovery_document: normalize_discovery_document(discovery_document), - jwk: jwk, - remaining_lifetime: remaining_lifetime - }} - else - {:error, reason} -> {:error, :update_documents, reason} + other -> + other end end - @doc false - def normalize_discovery_document(discovery_document) do - # claims_supported may be missing as it is marked RECOMMENDED by the spec, default to an empty list - sorted_claims_supported = - discovery_document - |> Map.get("claims_supported", []) - |> Enum.sort() - - # response_types_supported's presence is REQUIRED by the spec, crash when missing - sorted_response_types_supported = - discovery_document - |> Map.get("response_types_supported") - |> Enum.map(fn response_type -> - response_type - |> String.split() - |> Enum.sort() - |> Enum.join(" ") - end) - - Map.merge(discovery_document, %{ - "claims_supported" => sorted_claims_supported, - "response_types_supported" => sorted_response_types_supported - }) - end - - defp peek_protected(jwt) do - try do - {:ok, JOSE.JWS.peek_protected(jwt)} - rescue - _ -> {:error, :peek_protected} - end + defp peek_protected(jwks) do + {:ok, JOSE.JWS.peek_protected(jwks)} + rescue + _ -> {:error, :peek_protected} end - defp do_verify(%JOSE.JWK{keys: {:jose_jwk_set, jwks}}, token_alg, jwt) do + defp verify_signature(%JOSE.JWK{keys: {:jose_jwk_set, jwks}}, token_alg, jwt) do Enum.find_value(jwks, {false, "{}", jwt}, fn jwk -> jwk |> JOSE.JWK.from() - |> do_verify(token_alg, jwt) + |> verify_signature(token_alg, jwt) |> case do {false, _claims, _jwt} -> false verified_claims -> verified_claims @@ -254,104 +263,65 @@ defmodule OpenIDConnect do end) end - defp do_verify(%JOSE.JWK{} = jwk, token_alg, jwt), + defp verify_signature(%JOSE.JWK{} = jwk, token_alg, jwt), do: JOSE.JWS.verify_strict(jwk, [token_alg], jwt) - defp from_certs(certs) do - try do - {:ok, JOSE.JWK.from(certs)} - rescue - _ -> - {:error, "certificates bad format"} - end - end - - defp discovery_document(provider, name) do - GenServer.call(name, {:discovery_document, provider}) - end - - defp jwk(provider, name) do - GenServer.call(name, {:jwk, provider}) - end - - defp config(provider, name) do - GenServer.call(name, {:config, provider}) - end + defp verify_claims(claims, config) do + leeway = Map.get(config, :leeway, 30) + client_id = Map.fetch!(config, :client_id) - defp access_token_uri(provider, name) do - Map.get(discovery_document(provider, name), "token_endpoint") + with :ok <- verify_exp_claim(claims, leeway), + :ok <- verify_aud_claim(claims, client_id) do + {:ok, claims} + end end - defp client_id(config) do - Keyword.get(config, :client_id) - end + defp verify_exp_claim(claims, leeway) do + case Map.fetch(claims, "exp") do + {:ok, exp} when is_integer(exp) -> + epoch = DateTime.utc_now() |> DateTime.to_unix() - defp client_secret(config) do - Keyword.get(config, :client_secret) - end + if epoch < exp + leeway, + do: :ok, + else: {:error, "exp", "token has expired"} - defp redirect_uri(config) do - Keyword.get(config, :redirect_uri) - end + {:ok, _exp} -> + {:error, "exp", "is invalid"} - defp response_type(provider, config, name) do - response_type = - config - |> Keyword.get(:response_type) - |> normalize_response_type(provider) - - response_types_supported = response_types_supported(provider, name) - - cond do - response_type in response_types_supported -> - response_type - - true -> - raise ArgumentError, - message: """ - Requested response type (#{response_type}) not supported by provider (#{provider}). - Supported types: - #{Enum.join(response_types_supported, "\n")} - """ + :error -> + {:error, "exp", "missing"} end end - defp normalize_response_type(response_type, provider) - when is_nil(response_type) or response_type == [] do - raise ArgumentError, "no response_type has been defined for provider `#{provider}`" - end + defp verify_aud_claim(claims, expected_aud) do + case Map.fetch(claims, "aud") do + {:ok, aud} -> + if audience_matches?(aud, expected_aud), + do: :ok, + else: {:error, "aud", "token is intended for another application"} - defp normalize_response_type(response_type, provider) when is_binary(response_type) do - response_type - |> String.split() - |> normalize_response_type(provider) + :error -> + {:error, "aud", "missing"} + end end - defp normalize_response_type(response_type, _provider) when is_list(response_type) do - response_type - |> Enum.sort() - |> Enum.join(" ") - end + defp audience_matches?(aud, expected_aud) when is_list(aud), do: Enum.member?(aud, expected_aud) + defp audience_matches?(aud, expected_aud), do: aud === expected_aud - defp response_types_supported(provider, name) do - provider - |> discovery_document(name) - |> Map.get("response_types_supported") - end + def fetch_userinfo(config, access_token) do + discovery_document_uri = config.discovery_document_uri - defp discovery_document_uri(config) do - Keyword.get(config, :discovery_document_uri) - end + headers = [{"Authorization", "Bearer #{access_token}"}] - defp fetch_resource(uri) do - with {:ok, %HTTPoison.Response{status_code: status_code} = resp} when status_code in 200..299 <- - http_client().get(uri, [], http_client_options()), - {:ok, json} <- Jason.decode(resp.body), - {:ok, json} <- assert_json(json) do - {:ok, json, remaining_lifetime(resp.headers)} + with {:ok, document} <- Document.fetch_document(discovery_document_uri), + request = Finch.build(:get, document.userinfo_endpoint, headers), + {:ok, %Finch.Response{body: response, status: status}} when status in 200..299 <- + Finch.request(request, OpenIDConnect.Finch), + {:ok, json} <- Jason.decode(response) do + {:ok, json} else - {:ok, resp} -> {:error, resp} - error -> error + {:ok, %Finch.Response{body: response, status: status}} -> {:error, {status, response}} + other -> other end end @@ -362,47 +332,4 @@ defmodule OpenIDConnect do |> URI.merge("?#{query}") |> URI.to_string() end - - defp assert_json(%{"error" => reason}), do: {:error, reason} - defp assert_json(json), do: {:ok, json} - - @spec remaining_lifetime([{String.t(), String.t()}]) :: integer | nil - defp remaining_lifetime(headers) do - with headers = Enum.into(headers, %{}), - {:ok, max_age} <- find_max_age(headers), - {:ok, age} <- find_age(headers) do - max_age - age - else - _ -> nil - end - end - - defp normalize_scope(provider, scopes) when is_nil(scopes) or scopes == [] do - raise ArgumentError, "no scopes have been defined for provider `#{provider}`" - end - - defp normalize_scope(_provider, scopes) when is_binary(scopes), do: scopes - defp normalize_scope(_provider, scopes) when is_list(scopes), do: Enum.join(scopes, " ") - - defp find_max_age(headers) when is_map(headers) do - case Regex.run(~r"(?<=max-age=)\d+", Map.get(headers, "Cache-Control", "")) do - [max_age] -> {:ok, String.to_integer(max_age)} - _ -> :error - end - end - - defp find_age(headers) when is_map(headers) do - case Map.get(headers, "Age") do - nil -> :error - age -> {:ok, String.to_integer(age)} - end - end - - defp http_client do - Application.get_env(:openid_connect, :http_client, HTTPoison) - end - - defp http_client_options do - Application.get_env(:openid_connect, :http_client_options, []) - end end diff --git a/lib/openid_connect/application.ex b/lib/openid_connect/application.ex new file mode 100644 index 0000000..dc5db23 --- /dev/null +++ b/lib/openid_connect/application.ex @@ -0,0 +1,24 @@ +defmodule OpenIDConnect.Application do + use Application + + def start(_type, _args) do + opts = [strategy: :one_for_one, name: __MODULE__.Supervisor] + Supervisor.start_link(children(), opts) + end + + def children do + [ + {Finch, + name: OpenIDConnect.Finch, + pools: %{ + default: pool_opts() + }}, + OpenIDConnect.Document.Cache + ] + end + + defp pool_opts do + transport_opts = Application.get_env(:openid_connect, :finch_transport_opts, []) + [conn_opts: [transport_opts: transport_opts]] + end +end diff --git a/lib/openid_connect/document.ex b/lib/openid_connect/document.ex new file mode 100644 index 0000000..bab3a25 --- /dev/null +++ b/lib/openid_connect/document.ex @@ -0,0 +1,148 @@ +defmodule OpenIDConnect.Document do + @doc """ + This module caches OIDC documents and their JWKs for a limited timeframe, which is min(`@refresh_time`, `document.remaining_lifetime`). + """ + alias OpenIDConnect.Document.Cache + + defstruct raw: nil, + authorization_endpoint: nil, + end_session_endpoint: nil, + token_endpoint: nil, + userinfo_endpoint: nil, + claims_supported: nil, + response_types_supported: nil, + jwks: nil, + expires_at: nil + + @refresh_time_seconds Application.compile_env( + :openid_connect, + :document_max_expiration_seconds, + 60 * 60 + ) + + def fetch_document(uri) do + with :error <- Cache.fetch(uri), + {:ok, document_json, document_expires_at} <- fetch_remote_resource(uri), + {:ok, document} <- build_document(document_json), + {:ok, jwks_json, jwks_expires_at} <- fetch_remote_resource(document_json["jwks_uri"]), + {:ok, jwks} <- from_certs(jwks_json) do + now = DateTime.utc_now() + + expires_at = + [ + DateTime.add(now, @refresh_time_seconds, :second), + document_expires_at, + jwks_expires_at + ] + |> Enum.reject(&is_nil/1) + |> Enum.min(DateTime) + + document = %{ + document + | jwks: jwks, + expires_at: expires_at + } + + _ = Cache.put(uri, document) + + {:ok, document} + end + end + + defp fetch_remote_resource(uri) when is_nil(uri), do: {:error, :invalid_discovery_document_uri} + + defp fetch_remote_resource(uri) do + request = Finch.build(:get, uri) + + with {:ok, + %Finch.Response{ + headers: headers, + body: response, + status: status + }} + when status in 200..299 <- + Finch.request(request, OpenIDConnect.Finch), + {:ok, json} <- Jason.decode(response) do + expires_at = + if remaining_lifetime = remaining_lifetime(headers) do + DateTime.add(DateTime.utc_now(), remaining_lifetime, :second) + end + + {:ok, json, expires_at} + else + {:ok, %Finch.Response{body: response, status: status}} -> {:error, {status, response}} + other -> other + end + end + + defp remaining_lifetime(headers) do + headers = + for {k, v} <- headers, into: %{} do + {String.downcase(k), v} + end + + max_age = get_max_age(headers) + age = get_age(headers) + + cond do + not is_nil(max_age) and max_age > 0 and not is_nil(age) -> max_age - age + not is_nil(max_age) and max_age > 0 -> max_age + true -> nil + end + end + + defp get_max_age(headers) when is_map(headers) do + cache_control = Map.get(headers, "cache-control", "") + + case Regex.run(~r"(?<=max-age=)\d+", cache_control) do + [max_age] -> String.to_integer(max_age) + _ -> nil + end + end + + defp get_age(headers) when is_map(headers) do + case Map.get(headers, "age") do + nil -> nil + age -> String.to_integer(age) + end + end + + defp build_document(document_json) do + keys = Map.keys(document_json) + required_keys = ["jwks_uri", "authorization_endpoint", "token_endpoint"] + + if Enum.all?(required_keys, &(&1 in keys)) do + document = %__MODULE__{ + raw: document_json, + authorization_endpoint: Map.fetch!(document_json, "authorization_endpoint"), + end_session_endpoint: Map.get(document_json, "end_session_endpoint"), + token_endpoint: Map.fetch!(document_json, "token_endpoint"), + userinfo_endpoint: Map.fetch!(document_json, "userinfo_endpoint"), + response_types_supported: + Map.get(document_json, "response_types_supported") + |> Enum.map(fn response_type -> + response_type + |> String.split() + |> Enum.sort() + |> Enum.join(" ") + end), + claims_supported: + Map.get(document_json, "claims_supported") + |> sort_claims() + } + + {:ok, document} + else + {:error, :invalid_document} + end + end + + defp sort_claims(nil), do: nil + defp sort_claims(claims), do: Enum.sort(claims) + + defp from_certs(certs) do + {:ok, JOSE.JWK.from(certs)} + rescue + _ -> {:error, :invalid_jwks_certificates} + end +end diff --git a/lib/openid_connect/document/cache.ex b/lib/openid_connect/document/cache.ex new file mode 100644 index 0000000..fa48b9d --- /dev/null +++ b/lib/openid_connect/document/cache.ex @@ -0,0 +1,90 @@ +defmodule OpenIDConnect.Document.Cache do + use GenServer + alias OpenIDConnect.Document + + @max_size Application.compile_env(:openid_connect, :document_cache_max_size, 1_000) + + def start_link(opts \\ []) do + {name, opts} = Keyword.pop(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, opts, name: name) + end + + def init(_opts) do + Process.send_after(self(), :gc, :timer.minutes(1)) + {:ok, %{}} + end + + def put(pid \\ __MODULE__, uri, document) do + GenServer.cast(pid, {:put, uri, document}) + end + + def fetch(pid \\ __MODULE__, uri) do + GenServer.call(pid, {:fetch, uri}) + end + + def flush(pid \\ __MODULE__) do + GenServer.call(pid, :flush) + end + + def handle_cast({:put, uri, document}, state) do + if document_expired?(document) do + {:noreply, state} + else + expires_in_seconds = expires_in_seconds(document.expires_at) + timer_ref = Process.send_after(self(), {:remove, uri}, :timer.seconds(expires_in_seconds)) + state = Map.put(state, uri, {timer_ref, DateTime.utc_now(), document}) + {:noreply, state} + end + end + + def handle_call(:flush, _from, state) do + {:reply, state, state} + end + + def handle_call({:fetch, uri}, _from, state) do + case Map.fetch(state, uri) do + {:ok, {timer_ref, _last_fetched_at, document}} -> + if document_expired?(document) do + state = Map.delete(state, uri) + {:reply, :error, state} + else + state = Map.put(state, uri, {timer_ref, DateTime.utc_now(), document}) + {:reply, {:ok, document}, state} + end + + :error -> + {:reply, :error, state} + end + end + + def handle_info({:remove, uri}, state) do + {:noreply, Map.delete(state, uri)} + end + + def handle_info(:gc, state) do + state = + if Enum.count(state) > @max_size do + state + |> Enum.sort_by( + fn {_key, {_ref, last_fetched_at, _document}} -> last_fetched_at end, + {:desc, DateTime} + ) + |> Enum.take(@max_size) + |> Enum.into(%{}) + else + state + end + + Process.send_after(self(), :gc, :timer.minutes(1)) + + {:noreply, state} + end + + defp expires_in_seconds(%DateTime{} = datetime) do + max(DateTime.diff(datetime, DateTime.utc_now(), :second), 0) + end + + defp document_expired?(%Document{expires_at: expires_at}) do + DateTime.compare(expires_at, DateTime.utc_now()) != :gt + end +end diff --git a/lib/openid_connect/worker.ex b/lib/openid_connect/worker.ex deleted file mode 100644 index 1b73484..0000000 --- a/lib/openid_connect/worker.ex +++ /dev/null @@ -1,71 +0,0 @@ -defmodule OpenIDConnect.Worker do - use GenServer - - @moduledoc """ - Worker module for OpenID Connect - - This worker will store and periodically update each provider's documents and JWKs according to the lifetimes - """ - - @refresh_time 60 * 60 * 1000 - - def start_link(provider_configs, name \\ :openid_connect) do - GenServer.start_link(__MODULE__, provider_configs, name: name) - end - - def init(:ignore) do - :ignore - end - - def init(provider_configs) do - state = - Enum.into(provider_configs, %{}, fn {provider, config} -> - documents = update_documents(provider, config) - {provider, %{config: config, documents: documents}} - end) - - {:ok, state} - end - - def handle_call({:discovery_document, provider}, _from, state) do - discovery_document = get_in(state, [provider, :documents, :discovery_document]) - {:reply, discovery_document, state} - end - - def handle_call({:jwk, provider}, _from, state) do - jwk = get_in(state, [provider, :documents, :jwk]) - {:reply, jwk, state} - end - - def handle_call({:config, provider}, _from, state) do - config = get_in(state, [provider, :config]) - {:reply, config, state} - end - - def handle_info({:update_documents, provider}, state) do - config = get_in(state, [provider, :config]) - documents = update_documents(provider, config) - - state = put_in(state, [provider, :documents], documents) - - {:noreply, state} - end - - defp update_documents(provider, config) do - {:ok, %{remaining_lifetime: remaining_lifetime}} = - {:ok, documents} = OpenIDConnect.update_documents(config) - - refresh_time = time_until_next_refresh(remaining_lifetime) - - Process.send_after(self(), {:update_documents, provider}, refresh_time) - - documents - end - - defp time_until_next_refresh(nil), do: @refresh_time - - defp time_until_next_refresh(time_in_seconds) when time_in_seconds > 0, - do: :timer.seconds(time_in_seconds) - - defp time_until_next_refresh(time_in_seconds) when time_in_seconds <= 0, do: 0 -end diff --git a/mix.exs b/mix.exs index 3c3f5e6..05219c1 100644 --- a/mix.exs +++ b/mix.exs @@ -1,13 +1,13 @@ defmodule OpenIDConnect.Mixfile do use Mix.Project - @version "0.2.2" + @version "1.0.0" def project do [ app: :openid_connect, version: @version, - elixir: "~> 1.3", + elixir: "~> 1.12", build_embedded: Mix.env() == :prod, start_permanent: Mix.env() == :prod, elixirc_paths: elixirc_paths(Mix.env()), @@ -17,18 +17,25 @@ defmodule OpenIDConnect.Mixfile do deps: deps(), docs: docs(), name: "OpenID Connect", - source_url: "https://github.com/DockYard/openid_connect" + source_url: "https://github.com/DockYard/openid_connect", + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test + ] ] end - # Specifies which paths to compile per environment defp elixirc_paths(:test), do: elixirc_paths(nil) ++ ["test/support"] defp elixirc_paths(_), do: ["lib"] - # Configuration for the OTP application - # - # Type "mix help compile.app" for more information + def application do - [extra_applications: [:logger]] + [ + mod: {OpenIDConnect.Application, []}, + extra_applications: [:logger] + ] end def description do @@ -57,12 +64,18 @@ defmodule OpenIDConnect.Mixfile do defp deps do [ - {:httpoison, "~> 1.2"}, {:jason, ">= 1.0.0"}, + {:finch, "~> 0.14"}, {:jose, "~> 1.8"}, + + # Test deps {:earmark, "~> 1.2", only: :dev}, + {:credo, "~> 1.6", only: :dev}, + {:dialyxir, "~> 1.2", only: :dev}, {:ex_doc, "~> 0.18", only: :dev}, - {:mox, "~> 0.4", only: :test} + {:excoveralls, "~> 0.14", only: :test}, + {:plug_cowboy, "~> 2.6", only: :test}, + {:bypass, "~> 2.1", only: :test} ] end end diff --git a/mix.lock b/mix.lock index 9d5194b..c9b0072 100644 --- a/mix.lock +++ b/mix.lock @@ -1,22 +1,44 @@ %{ - "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, - "certifi": {:hex, :certifi, "2.4.2", "75424ff0f3baaccfd34b1214184b6ef616d89e420b258bb0a5ea7d7bc628f7f0", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, + "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm", "fab09b20e3f5db886725544cbcf875b8e73ec93363954eb8a1a9ed834aa8c1f9"}, + "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, + "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, + "castore": {:hex, :castore, "0.1.20", "62a0126cbb7cb3e259257827b9190f88316eb7aa3fdac01fd6f2dfd64e7f46e9", [:mix], [], "hexpm", "a020b7650529c986c454a4035b6b13a328e288466986307bea3aadb4c95ac98a"}, + "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, + "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, + "credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [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", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"}, "cutkey": {:git, "https://github.com/potatosalad/cutkey.git", "47640d04fb4db4a0b79168d7fca0df87aaa42751", []}, - "earmark": {:hex, :earmark, "1.2.6", "b6da42b3831458d3ecc57314dff3051b080b9b2be88c2e5aa41cd642a5b044ed", [:mix], [], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.19.1", "519bb9c19526ca51d326c060cb1778d4a9056b190086a8c6c115828eaccea6cf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.7", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, - "hackney": {:hex, :hackney, "1.14.3", "b5f6f5dcc4f1fba340762738759209e21914516df6be440d85772542d4a5e412", [:rebar3], [{:certifi, "2.4.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, - "httpoison": {:hex, :httpoison, "1.4.0", "e0b3c2ad6fa573134e42194d13e925acfa8f89d138bc621ffb7b1989e6d22e73", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, - "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, - "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, - "jose": {:hex, :jose, "1.8.4", "7946d1e5c03a76ac9ef42a6e6a20001d35987afd68c2107bcd8f01a84e75aa73", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"}, - "makeup": {:hex, :makeup, "0.5.5", "9e08dfc45280c5684d771ad58159f718a7b5788596099bdfb0284597d368a882", [:mix], [{:nimble_parsec, "~> 0.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.10.0", "0f09c2ddf352887a956d84f8f7e702111122ca32fbbc84c2f0569b8b65cbf7fa", [:mix], [{:makeup, "~> 0.5.5", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, - "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, - "mox": {:hex, :mox, "0.4.0", "7f120840f7d626184a3d65de36189ca6f37d432e5d63acd80045198e4c5f7e6e", [:mix], [], "hexpm"}, - "nimble_parsec": {:hex, :nimble_parsec, "0.4.0", "ee261bb53214943679422be70f1658fff573c5d0b0a1ecd0f18738944f818efe", [:mix], [], "hexpm"}, - "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, + "dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"}, + "earmark": {:hex, :earmark, "1.4.33", "2b33a505180583f98bfa17317f03973b52081bdb24a11be05a7f4fa6d64dd8bf", [:mix], [{:earmark_parser, "~> 1.4.29", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "21b31363d6a0a70802cfbaf2de88355778aa76654298a072bce2e01d1858ae06"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "ex_doc": {:hex, :ex_doc, "0.29.0", "4a1cb903ce746aceef9c1f9ae8a6c12b742a5461e6959b9d3b24d813ffbea146", [:mix], [{:earmark_parser, "~> 1.4.19", [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", "f096adb8bbca677d35d278223361c7792d496b3fc0d0224c9d4bc2f651af5db1"}, + "excoveralls": {:hex, :excoveralls, "0.15.0", "ac941bf85f9f201a9626cc42b2232b251ad8738da993cf406a4290cacf562ea4", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9631912006b27eca30a2f3c93562bc7ae15980afb014ceb8147dc5cdd8f376f1"}, + "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "finch": {:hex, :finch, "0.14.0", "619bfdee18fc135190bf590356c4bf5d5f71f916adb12aec94caa3fa9267a4bc", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5459acaf18c4fdb47a8c22fb3baff5d8173106217c8e56c5ba0b93e66501a8dd"}, + "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~> 2.9.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", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, + "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, + "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.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"}, + "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"}, + "nimble_options": {:hex, :nimble_options, "0.5.2", "42703307b924880f8c08d97719da7472673391905f528259915782bb346e0a1b", [:mix], [], "hexpm", "4da7f904b915fd71db549bcdc25f8d56f378ef7ae07dc1d372cbe72ba950dce0"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, + "nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"}, + "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, + "plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [: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", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "telemetry": {:hex, :telemetry, "1.2.0", "a8ce551485a9a3dac8d523542de130eafd12e40bbf76cf0ecd2528f24e812a44", [:rebar3], [], "hexpm", "1427e73667b9a2002cf1f26694c422d5c905df889023903c4518921d53e3e883"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..561cab7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pre-commit==2.20.0 diff --git a/test/fixtures/google/certs.exs b/test/fixtures/google/certs.exs deleted file mode 100644 index 46cf037..0000000 --- a/test/fixtures/google/certs.exs +++ /dev/null @@ -1,41 +0,0 @@ -%HTTPoison.Response{ - body: %{ - "keys" => [ - %{ - "alg" => "RS256", - "e" => "AQAB", - "kid" => "dad44739576485ec30d228842e73ace0bc367bc4", - "kty" => "RSA", - "n" => - "0_8faqWY4x8LCariP6tIbqt2atUM2nSKAuTb57NYZYOGeUYo4uaEhJjF6sJ2djOSdpj-a70zEamhlHGyCsgzNroQX70oaL05zL1wV0Q9TPHa1qRobVDSBglhmJLHfER9L0mk9jokXJhWpL7NFU7qyKCD7gwe84NR4C7emXtfQGzFS4VcKqxNO17uwmRKzx_ZMy4St999KEDBPCtuq8XGvjMYZE2Rfk6-LWcmAtJ7COOS1yfEfAgbvCAbDKnIZqojPusn_5jRUOotJd2T3GgnSMEFn1G1DGK7hNHLBKqzimeEMTQpnhYXbPwqTbAAll3rtKV4PCM585QTsV0U3U5VaQ", - "use" => "sig" - }, - %{ - "alg" => "RS256", - "e" => "AQAB", - "kid" => "4ef5118b0800bd60a4194186dcb538fc66e5eb34", - "kty" => "RSA", - "n" => - "4ZSPB8TO7y3xZF_GxB_JSx_yBEtNs0mDilLvesSLLypYmxt4U7Dxk-vLAf1IVRwaZeeqQRIhrKJjljIqd33tVwfAp5PinjUm7lHi-ufZ_VNQw3uJA5_3tmkMWaLcvdRcILFMlVfBcESp-R5mcF6-bMeYH0n3D5CCJKspIqDERD1gQxfVxWDzafyrqkIROXKEtv3rMe7Z9Yc4mBsL02G6dDKVbjSxvkZ14wMykXEnGkfIiTUSiH8Qm1rdniZigPv2Pa2uSnJ94V-tIDHigjkXR7Cfun4Z38KZdSDRNgJr-m41Pu-plX98j59iGvVyaKP24ZbukGIJRPHYn06xkQeoWw", - "use" => "sig" - } - ] - }, - headers: [ - {"Expires", "Sun, 06 May 2018 03:43:54 GMT"}, - {"Date", "Sat, 05 May 2018 21:58:32 GMT"}, - {"Vary", "Origin"}, - {"Vary", "X-Origin"}, - {"Content-Type", "application/json; charset=UTF-8"}, - {"X-Content-Type-Options", "nosniff"}, - {"X-Frame-Options", "SAMEORIGIN"}, - {"X-XSS-Protection", "1; mode=block"}, - {"Content-Length", "987"}, - {"Server", "GSE"}, - {"Cache-Control", "public, max-age=20722, must-revalidate, no-transform"}, - {"Age", "3972"}, - {"Alt-Svc", - "hq=\":443\"; ma=2592000; quic=51303433; quic=51303432; quic=51303431; quic=51303339; quic=51303335,quic=\":443\"; ma=2592000; v=\"43,42,41,39,35\""} - ], - status_code: 200 -} diff --git a/test/fixtures/google/discovery_document.exs b/test/fixtures/google/discovery_document.exs deleted file mode 100644 index 08f1417..0000000 --- a/test/fixtures/google/discovery_document.exs +++ /dev/null @@ -1,57 +0,0 @@ -%HTTPoison.Response{ - body: %{ - "authorization_endpoint" => "https://accounts.google.com/o/oauth2/v2/auth", - "claims_supported" => [ - "aud", - "email", - "email_verified", - "exp", - "family_name", - "given_name", - "iat", - "iss", - "locale", - "name", - "picture", - "sub" - ], - "code_challenge_methods_supported" => ["plain", "S256"], - "id_token_signing_alg_values_supported" => ["RS256"], - "issuer" => "https://accounts.google.com", - "jwks_uri" => "https://www.googleapis.com/oauth2/v3/certs", - "response_types_supported" => [ - "code", - "token", - "id_token", - "code token", - "code id_token", - "token id_token", - "code token id_token", - "none" - ], - "revocation_endpoint" => "https://accounts.google.com/o/oauth2/revoke", - "scopes_supported" => ["openid", "email", "profile"], - "subject_types_supported" => ["public"], - "token_endpoint" => "https://www.googleapis.com/oauth2/v4/token", - "token_endpoint_auth_methods_supported" => ["client_secret_post", "client_secret_basic"], - "userinfo_endpoint" => "https://www.googleapis.com/oauth2/v3/userinfo" - }, - headers: [ - {"Accept-Ranges", "none"}, - {"Vary", "Accept-Encoding"}, - {"Content-Type", "application/json"}, - {"Access-Control-Allow-Origin", "*"}, - {"Date", "Mon, 30 Apr 2018 06:25:58 GMT"}, - {"Expires", "Mon, 30 Apr 2018 07:25:58 GMT"}, - {"Last-Modified", "Mon, 01 Feb 2016 19:53:44 GMT"}, - {"X-Content-Type-Options", "nosniff"}, - {"Server", "sffe"}, - {"X-XSS-Protection", "1; mode=block"}, - {"Age", "700"}, - {"Cache-Control", "public, max-age=3600"}, - {"Alt-Svc", - "hq=\":443\"; ma=2592000; quic=51303433; quic=51303432; quic=51303431; quic=51303339; quic=51303335,quic=\":443\"; ma=2592000; v=\"43,42,41,39,35\""}, - {"Transfer-Encoding", "chunked"} - ], - status_code: 200 -} diff --git a/test/fixtures/http/auth0/discovery_document.exs b/test/fixtures/http/auth0/discovery_document.exs new file mode 100644 index 0000000..3a1ace9 --- /dev/null +++ b/test/fixtures/http/auth0/discovery_document.exs @@ -0,0 +1,91 @@ +%{ + status_code: 200, + body: %{ + "authorization_endpoint" => "https://common.auth0.com/authorize", + "claims_supported" => [ + "aud", + "auth_time", + "created_at", + "email", + "email_verified", + "exp", + "family_name", + "given_name", + "iat", + "identities", + "iss", + "name", + "nickname", + "phone_number", + "picture", + "sub" + ], + "code_challenge_methods_supported" => ["S256", "plain"], + "device_authorization_endpoint" => "https://common.auth0.com/oauth/device/code", + "id_token_signing_alg_values_supported" => ["HS256", "RS256"], + "issuer" => "https://common.auth0.com/", + "jwks_uri" => "https://common.auth0.com/.well-known/jwks.json", + "mfa_challenge_endpoint" => "https://common.auth0.com/mfa/challenge", + "registration_endpoint" => "https://common.auth0.com/oidc/register", + "request_parameter_supported" => false, + "request_uri_parameter_supported" => false, + "response_modes_supported" => ["query", "fragment", "form_post"], + "response_types_supported" => [ + "code", + "token", + "id_token", + "code token", + "code id_token", + "token id_token", + "code token id_token" + ], + "revocation_endpoint" => "https://common.auth0.com/oauth/revoke", + "scopes_supported" => [ + "openid", + "profile", + "offline_access", + "name", + "given_name", + "family_name", + "nickname", + "email", + "email_verified", + "picture", + "created_at", + "identities", + "phone", + "address" + ], + "subject_types_supported" => ["public"], + "token_endpoint" => "https://common.auth0.com/oauth/token", + "token_endpoint_auth_methods_supported" => ["client_secret_basic", "client_secret_post"], + "userinfo_endpoint" => "https://common.auth0.com/userinfo" + }, + headers: [ + {"Date", "Sat, 12 Nov 2022 19:57:59 GMT"}, + {"Content-Type", "application/json; charset=utf-8"}, + {"CF-Ray", "7691d6fb8d50f96b-SJC"}, + {"Access-Control-Allow-Origin", "*"}, + {"Cache-Control", "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400"}, + {"Last-Modified", "Sat, 12 Nov 2022 19:57:59 GMT"}, + {"Strict-Transport-Security", "max-age=31536000"}, + {"Vary", "Accept-Encoding, Origin, Accept-Encoding"}, + {"CF-Cache-Status", "MISS"}, + {"Access-Control-Allow-Credentials", "false"}, + {"Access-Control-Expose-Headers", + "X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset"}, + {"ot-baggage-auth0-request-id", "7691d6fb8d50f96b"}, + {"ot-tracer-sampled", "true"}, + {"ot-tracer-spanid", "273c5b5354590f5b"}, + {"ot-tracer-traceid", "5026231a2155221a"}, + {"traceparent", "00-00000000000000005026231a2155221a-273c5b5354590f5b-01"}, + {"tracestate", "auth0-request-id=7691d6fb8d50f96b,auth0=true"}, + {"X-Auth0-RequestId", "04426ddefbd03b8009aa"}, + {"X-Content-Type-Options", "nosniff"}, + {"X-RateLimit-Limit", "300"}, + {"X-RateLimit-Remaining", "299"}, + {"X-RateLimit-Reset", "1668283080"}, + {"Server", "cloudflare"}, + {"alt-svc", "h3=\":443\"; ma=86400, h3-29=\":443\"; ma=86400"} + ] +} diff --git a/test/fixtures/http/auth0/jwks.exs b/test/fixtures/http/auth0/jwks.exs new file mode 100644 index 0000000..fe41812 --- /dev/null +++ b/test/fixtures/http/auth0/jwks.exs @@ -0,0 +1,60 @@ +%{ + status_code: 200, + body: %{ + "keys" => [ + %{ + "alg" => "RS256", + "e" => "AQAB", + "kid" => "QjYxQ0U1QTVCMzk4RTJFNEIxMERGN0NBOUVBQTk1NDEzRTcwNTVBMg", + "kty" => "RSA", + "n" => + "u4qDTgepr5ifzZOxampfmt9naaih6CgOGGdc3F4Bd9r-R4X4gkIu6S9kN76mZ3O9_q4o4G29yJrEr2bUhYl96vrK-hbFddMhTtxy6nkKqmuPj5PXV5IEBogZwzZebXqARjmOVqxhAgZPLGCoD_ebIzYTT7ozprwlbaBS7ZmRXj3vwlffRae4T-gamIcQWhGLujxOY6FsLUP4ExqAeUcftRd0pi0XGBVL1qmu4xRPLznV6Z7OVHemtgBQ4tAKvwSYQWIDKJ886Y3jhLyQukC10N0clnPVuHDPByGXDPTDvhQRN2xTU4PNjGGu_WDElzenA-8CyZf8gEJ0ZM8_j0kmkQ", + "use" => "sig", + "x5c" => [ + "MIIC6DCCAdCgAwIBAgIJZ9K1arBHwKy5MA0GCSqGSIb3DQEBBQUAMBsxGTAXBgNVBAMTEGNvbW1vbi5hdXRoMC5jb20wHhcNMTUxMTEwMTMxNjMzWhcNMjkwNzE5MTMxNjMzWjAbMRkwFwYDVQQDExBjb21tb24uYXV0aDAuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu4qDTgepr5ifzZOxampfmt9naaih6CgOGGdc3F4Bd9r+R4X4gkIu6S9kN76mZ3O9/q4o4G29yJrEr2bUhYl96vrK+hbFddMhTtxy6nkKqmuPj5PXV5IEBogZwzZebXqARjmOVqxhAgZPLGCoD/ebIzYTT7ozprwlbaBS7ZmRXj3vwlffRae4T+gamIcQWhGLujxOY6FsLUP4ExqAeUcftRd0pi0XGBVL1qmu4xRPLznV6Z7OVHemtgBQ4tAKvwSYQWIDKJ886Y3jhLyQukC10N0clnPVuHDPByGXDPTDvhQRN2xTU4PNjGGu/WDElzenA+8CyZf8gEJ0ZM8/j0kmkQIDAQABoy8wLTAMBgNVHRMEBTADAQH/MB0GA1UdDgQWBBRkWtTfBgFGEkhPxk2TeGcjty6aDTANBgkqhkiG9w0BAQUFAAOCAQEAIFbLdkw6cSXm0OQ/rzBlzM6uBWXAYXQM2HSiMQqW4b/C37LeW0upG/0FU6hWa5fbMQvAf9QSgJsG/7CrQgnjssnqvIlw5S1enj5wzFQd9yHgb/1X+e8qOT7VEXdOZXCR/8aYuLiaHuYyNQ8XMXAl/pwaJCrcq9YhA2kBjJ/rojHXdXd9DbT/TJMXNAaNNMicRN1R8OOw+JBbjxA3dMZ9qKRN89JdTyF6c2G/FTXVw4cBoJi/lVKKBXOwT4DYZtDCyOrFaFyhkop1uRzC74pxp2lRYuaKD8ev6suRJTbukcjeerCtN/AUVXd515FyL1mdS4qVJMnT8lt6NRxXqo3EaQ==" + ], + "x5t" => "QjYxQ0U1QTVCMzk4RTJFNEIxMERGN0NBOUVBQTk1NDEzRTcwNTVBMg" + }, + %{ + "alg" => "RS256", + "e" => "AQAB", + "kid" => "Lk-5NnjByFf99cg0nxMdi", + "kty" => "RSA", + "n" => + "0MdUr5IDFWWPbLFM7itct-DHoAgfAD4gfCUlGlupRIk8AvDBf0allrSjznh78gn0XcCmq5MSKH6dJYAalhz5pnKESCZS0NGRijYCnP0FWdwZmPoRfMRe-O7Fw4ogMYYcWN7Iy_6HBefNvkQ91PhDksm0KpXH-yxrFC8gcbQNV14Q9bRwFq3nWmmDj5A7OvEuFmszIvZtpFbSyrCuqMHYR-Pyo-dWRUJ8egqjiOyn2LLWX0iMASJyasG6wF6cQC7k6sIYacxn_rBtuldKBBRlGboTkFl0NW9_xw4CwGxzFj_-GacCkaL7axObS34UN2bowZkXZsSE5l8v54NjalXHcw", + "use" => "sig", + "x5c" => [ + "MIIC+zCCAeOgAwIBAgIJKlrwnBd5HfDrMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNVBAMTEGNvbW1vbi5hdXRoMC5jb20wHhcNMjAwMzExMTg1NjM1WhcNMzMxMTE4MTg1NjM1WjAbMRkwFwYDVQQDExBjb21tb24uYXV0aDAuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0MdUr5IDFWWPbLFM7itct+DHoAgfAD4gfCUlGlupRIk8AvDBf0allrSjznh78gn0XcCmq5MSKH6dJYAalhz5pnKESCZS0NGRijYCnP0FWdwZmPoRfMRe+O7Fw4ogMYYcWN7Iy/6HBefNvkQ91PhDksm0KpXH+yxrFC8gcbQNV14Q9bRwFq3nWmmDj5A7OvEuFmszIvZtpFbSyrCuqMHYR+Pyo+dWRUJ8egqjiOyn2LLWX0iMASJyasG6wF6cQC7k6sIYacxn/rBtuldKBBRlGboTkFl0NW9/xw4CwGxzFj/+GacCkaL7axObS34UN2bowZkXZsSE5l8v54NjalXHcwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSR1Dfrm7GcV0/c0Nbd1q950Kuz1DAOBgNVHQ8BAf8EBAMCAoQwDQYJKoZIhvcNAQELBQADggEBAJm/bBoE60mpzzdPGrRvNdZgYEe1faPpj5AIk8h2c+Q5v1lB7olJ4Sfb4WIvFeDxQrxB0GlW8tgN2rgtABUjs5Nhc0h7sfB9MZlkQVAZjTqVFXpv1UzzwI2e0RmD4O6m6CvVE+p5bPHao5XvtgEZKL7Q6VXTLGeyQ9XTnlda2IdcZb4IGQTzrqmq6zlAGn3u4P9hlg+7GlldfC+ROKCreFkJODU+ZPr1c1m0wdZ0UDSvk71PPhlNBLP32jRUlaiL3H9ssMoW9rP0UhYAnP6hY0aC1cmeikF1GQXn4ySK5NlKRV+iA8LY/XhYrqXsSQJPn0RkSj4JRbJbP03SZWh9aOI=" + ], + "x5t" => "WaiAZeN6U1Tc9bpQHGclV01odiw" + } + ] + }, + headers: [ + {"Date", "Sat, 12 Nov 2022 19:58:26 GMT"}, + {"Content-Type", "application/json; charset=utf-8"}, + {"CF-Ray", "7691d7a5d88df96b-SJC"}, + {"Access-Control-Allow-Origin", "*"}, + {"Cache-Control", "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400"}, + {"Last-Modified", "Sat, 12 Nov 2022 19:58:26 GMT"}, + {"Strict-Transport-Security", "max-age=31536000"}, + {"Vary", "Accept-Encoding, Origin, Accept-Encoding"}, + {"CF-Cache-Status", "MISS"}, + {"Access-Control-Allow-Credentials", "false"}, + {"Access-Control-Expose-Headers", + "X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset"}, + {"ot-baggage-auth0-request-id", "7691d7a5d88df96b"}, + {"ot-tracer-sampled", "true"}, + {"ot-tracer-spanid", "7656a9c95f8d129c"}, + {"ot-tracer-traceid", "0d36337a675d4ae3"}, + {"traceparent", "00-00000000000000000d36337a675d4ae3-7656a9c95f8d129c-01"}, + {"tracestate", "auth0-request-id=7691d7a5d88df96b,auth0=true"}, + {"X-Auth0-RequestId", "f7a94a46ab5d86bb7f12"}, + {"X-Content-Type-Options", "nosniff"}, + {"X-RateLimit-Limit", "20"}, + {"X-RateLimit-Remaining", "19"}, + {"X-RateLimit-Reset", "1668283107"}, + {"Server", "cloudflare"}, + {"alt-svc", "h3=\":443\"; ma=86400, h3-29=\":443\"; ma=86400"} + ] +} diff --git a/test/fixtures/http/azure/discovery_document.exs b/test/fixtures/http/azure/discovery_document.exs new file mode 100644 index 0000000..ec6857a --- /dev/null +++ b/test/fixtures/http/azure/discovery_document.exs @@ -0,0 +1,72 @@ +%{ + status_code: 200, + body: %{ + "authorization_endpoint" => "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + "claims_supported" => [ + "sub", + "iss", + "cloud_instance_name", + "cloud_instance_host_name", + "cloud_graph_host_name", + "msgraph_host", + "aud", + "exp", + "iat", + "auth_time", + "acr", + "nonce", + "preferred_username", + "name", + "tid", + "ver", + "at_hash", + "c_hash", + "email" + ], + "cloud_graph_host_name" => "graph.windows.net", + "cloud_instance_name" => "microsoftonline.com", + "device_authorization_endpoint" => + "https://login.microsoftonline.com/common/oauth2/v2.0/devicecode", + "end_session_endpoint" => "https://login.microsoftonline.com/common/oauth2/v2.0/logout", + "frontchannel_logout_supported" => true, + "http_logout_supported" => true, + "id_token_signing_alg_values_supported" => ["RS256"], + "issuer" => "https://login.microsoftonline.com/{tenantid}/v2.0", + "jwks_uri" => "https://login.microsoftonline.com/common/discovery/v2.0/keys", + "kerberos_endpoint" => "https://login.microsoftonline.com/common/kerberos", + "msgraph_host" => "graph.microsoft.com", + "rbac_url" => "https://pas.windows.net", + "request_uri_parameter_supported" => false, + "response_modes_supported" => ["query", "fragment", "form_post"], + "response_types_supported" => ["code", "id_token", "code id_token", "id_token token"], + "scopes_supported" => ["openid", "profile", "email", "offline_access"], + "subject_types_supported" => ["pairwise"], + "tenant_region_scope" => nil, + "token_endpoint" => "https://login.microsoftonline.com/common/oauth2/v2.0/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_post", + "private_key_jwt", + "client_secret_basic" + ], + "userinfo_endpoint" => "https://graph.microsoft.com/oidc/userinfo" + }, + headers: [ + {"Cache-Control", "max-age=86400, private"}, + {"Content-Type", "application/json; charset=utf-8"}, + {"Strict-Transport-Security", "max-age=31536000; includeSubDomains"}, + {"X-Content-Type-Options", "nosniff"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "GET, OPTIONS"}, + {"P3P", "CP=\"DSP CUR OTPi IND OTRi ONL FIN\""}, + {"x-ms-request-id", "d81e7f56-0451-4de4-a5c5-4af112d02001"}, + {"x-ms-ests-server", "2.1.14006.10 - NCUS ProdSlices"}, + {"X-XSS-Protection", "0"}, + {"Set-Cookie", + "fpc=AuKLSwY1b3xLiInKP16p3E4; expires=Mon, 12-Dec-2022 19:36:30 GMT; path=/; secure; HttpOnly; SameSite=None"}, + {"Set-Cookie", + "esctx=AQABAAAAAAD--DLA3VO7QrddgJg7Wevr2ALKzZMjPY-Tt7ffB-f_7y4AMTUR-4m-AQDAi0jJ1K4_N7dY0CZmKZdSweQPMgerZ-TeKnty43nfmYRZS2G39bKUZp5erQLwiB9rkuLis4_ee_cAZK7nh1pkqOh0_t52P9svf75Le0-ex8iyPVhexTbIROTaaYvo6Fl9DFqOtZOnmQplc6ken-ddUcLbnZRSKOTFdr03VB8oSt5gD2BBw2e5qeBuocgX0hS-W-FNbG0gAA; domain=.login.microsoftonline.com; path=/; secure; HttpOnly; SameSite=None"}, + {"Set-Cookie", "x-ms-gateway-slice=estsfd; path=/; secure; samesite=none; httponly"}, + {"Set-Cookie", "stsservicecookie=estsfd; path=/; secure; samesite=none; httponly"}, + {"Date", "Sat, 12 Nov 2022 19:36:29 GMT"} + ] +} diff --git a/test/fixtures/http/azure/jwks.exs b/test/fixtures/http/azure/jwks.exs new file mode 100644 index 0000000..e307144 --- /dev/null +++ b/test/fixtures/http/azure/jwks.exs @@ -0,0 +1,156 @@ +%{ + status_code: 200, + body: %{ + "keys" => [ + %{ + "e" => "AQAB", + "issuer" => "https://login.microsoftonline.com/{tenantid}/v2.0", + "kid" => "nOo3ZDrODXEK1jKWhXslHR_KXEg", + "kty" => "RSA", + "n" => + "oaLLT9hkcSj2tGfZsjbu7Xz1Krs0qEicXPmEsJKOBQHauZ_kRM1HdEkgOJbUznUspE6xOuOSXjlzErqBxXAu4SCvcvVOCYG2v9G3-uIrLF5dstD0sYHBo1VomtKxzF90Vslrkn6rNQgUGIWgvuQTxm1uRklYFPEcTIRw0LnYknzJ06GC9ljKR617wABVrZNkBuDgQKj37qcyxoaxIGdxEcmVFZXJyrxDgdXh9owRmZn6LIJlGjZ9m59emfuwnBnsIQG7DirJwe9SXrLXnexRQWqyzCdkYaOqkpKrsjuxUj2-MHX31FqsdpJJsOAvYXGOYBKJRjhGrGdONVrZdUdTBQ", + "use" => "sig", + "x5c" => [ + "MIIDBTCCAe2gAwIBAgIQN33ROaIJ6bJBWDCxtmJEbjANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTIwMTIyMTIwNTAxN1oXDTI1MTIyMDIwNTAxN1owLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKGiy0/YZHEo9rRn2bI27u189Sq7NKhInFz5hLCSjgUB2rmf5ETNR3RJIDiW1M51LKROsTrjkl45cxK6gcVwLuEgr3L1TgmBtr/Rt/riKyxeXbLQ9LGBwaNVaJrSscxfdFbJa5J+qzUIFBiFoL7kE8ZtbkZJWBTxHEyEcNC52JJ8ydOhgvZYykete8AAVa2TZAbg4ECo9+6nMsaGsSBncRHJlRWVycq8Q4HV4faMEZmZ+iyCZRo2fZufXpn7sJwZ7CEBuw4qycHvUl6y153sUUFqsswnZGGjqpKSq7I7sVI9vjB199RarHaSSbDgL2FxjmASiUY4RqxnTjVa2XVHUwUCAwEAAaMhMB8wHQYDVR0OBBYEFI5mN5ftHloEDVNoIa8sQs7kJAeTMA0GCSqGSIb3DQEBCwUAA4IBAQBnaGnojxNgnV4+TCPZ9br4ox1nRn9tzY8b5pwKTW2McJTe0yEvrHyaItK8KbmeKJOBvASf+QwHkp+F2BAXzRiTl4Z+gNFQULPzsQWpmKlz6fIWhc7ksgpTkMK6AaTbwWYTfmpKnQw/KJm/6rboLDWYyKFpQcStu67RZ+aRvQz68Ev2ga5JsXlcOJ3gP/lE5WC1S0rjfabzdMOGP8qZQhXk4wBOgtFBaisDnbjV5pcIrjRPlhoCxvKgC/290nZ9/DLBH3TbHk8xwHXeBAnAjyAqOZij92uksAv7ZLq4MODcnQshVINXwsYshG1pQqOLwMertNaY5WtrubMRku44Dw7R" + ], + "x5t" => "nOo3ZDrODXEK1jKWhXslHR_KXEg" + }, + %{ + "e" => "AQAB", + "issuer" => "https://login.microsoftonline.com/{tenantid}/v2.0", + "kid" => "l3sQ-50cCH4xBVZLHTGwnSR7680", + "kty" => "RSA", + "n" => + "sfsXMXWuO-dniLaIELa3Pyqz9Y_rWff_AVrCAnFSdPHa8__Pmkbt_yq-6Z3u1o4gjRpKWnrjxIh8zDn1Z1RS26nkKcNg5xfWxR2K8CPbSbY8gMrp_4pZn7tgrEmoLMkwfgYaVC-4MiFEo1P2gd9mCdgIICaNeYkG1bIPTnaqquTM5KfT971MpuOVOdM1ysiejdcNDvEb7v284PYZkw2imwqiBY3FR0sVG7jgKUotFvhd7TR5WsA20GS_6ZIkUUlLUbG_rXWGl0YjZLS_Uf4q8Hbo7u-7MaFn8B69F6YaFdDlXm_A0SpedVFWQFGzMsp43_6vEzjfrFDJVAYkwb6xUQ", + "use" => "sig", + "x5c" => [ + "MIIDBTCCAe2gAwIBAgIQWPB1ofOpA7FFlOBk5iPaNTANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTIxMDIwNzE3MDAzOVoXDTI2MDIwNjE3MDAzOVowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALH7FzF1rjvnZ4i2iBC2tz8qs/WP61n3/wFawgJxUnTx2vP/z5pG7f8qvumd7taOII0aSlp648SIfMw59WdUUtup5CnDYOcX1sUdivAj20m2PIDK6f+KWZ+7YKxJqCzJMH4GGlQvuDIhRKNT9oHfZgnYCCAmjXmJBtWyD052qqrkzOSn0/e9TKbjlTnTNcrIno3XDQ7xG+79vOD2GZMNopsKogWNxUdLFRu44ClKLRb4Xe00eVrANtBkv+mSJFFJS1Gxv611hpdGI2S0v1H+KvB26O7vuzGhZ/AevRemGhXQ5V5vwNEqXnVRVkBRszLKeN/+rxM436xQyVQGJMG+sVECAwEAAaMhMB8wHQYDVR0OBBYEFLlRBSxxgmNPObCFrl+hSsbcvRkcMA0GCSqGSIb3DQEBCwUAA4IBAQB+UQFTNs6BUY3AIGkS2ZRuZgJsNEr/ZEM4aCs2domd2Oqj7+5iWsnPh5CugFnI4nd+ZLgKVHSD6acQ27we+eNY6gxfpQCY1fiN/uKOOsA0If8IbPdBEhtPerRgPJFXLHaYVqD8UYDo5KNCcoB4Kh8nvCWRGPUUHPRqp7AnAcVrcbiXA/bmMCnFWuNNahcaAKiJTxYlKDaDIiPN35yECYbDj0PBWJUxobrvj5I275jbikkp8QSLYnSU/v7dMDUbxSLfZ7zsTuaF2Qx+L62PsYTwLzIFX3M8EMSQ6h68TupFTi5n0M2yIXQgoRoNEDWNJZ/aZMY/gqT02GQGBWrh+/vJ" + ], + "x5t" => "l3sQ-50cCH4xBVZLHTGwnSR7680" + }, + %{ + "e" => "AQAB", + "issuer" => "https://login.microsoftonline.com/{tenantid}/v2.0", + "kid" => "Mr5-AUibfBii7Nd1jBebaxboXW0", + "kty" => "RSA", + "n" => + "yr3v1uETrFfT17zvOiy01w8nO-1t67cmiZLZxq2ISDdte9dw-IxCR7lPV2wezczIRgcWmYgFnsk2j6m10H4tKzcqZM0JJ_NigY29pFimxlL7_qXMB1PorFJdlAKvp5SgjSTwLrXjkr1AqWwbpzG2yZUNN3GE8GvmTeo4yweQbNCd-yO_Zpozx0J34wHBEMuaw-ZfCUk7mdKKsg-EcE4Zv0Xgl9wP2MpKPx0V8gLazxe6UQ9ShzNuruSOncpLYJN_oQ4aKf5ptOp1rsfDY2IK9frtmRTKOdQ-MEmSdjGL_88IQcvCs7jqVz53XKoXRlXB8tMIGOcg-ICer6yxe2itIQ", + "use" => "sig", + "x5c" => [ + "MIIDBTCCAe2gAwIBAgIQff8yrFO3CINPHUTT76tUsTANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTIxMTAyNDE3NDU1NloXDTI2MTAyNDE3NDU1NlowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMq979bhE6xX09e87zostNcPJzvtbeu3JomS2catiEg3bXvXcPiMQke5T1dsHs3MyEYHFpmIBZ7JNo+ptdB+LSs3KmTNCSfzYoGNvaRYpsZS+/6lzAdT6KxSXZQCr6eUoI0k8C6145K9QKlsG6cxtsmVDTdxhPBr5k3qOMsHkGzQnfsjv2aaM8dCd+MBwRDLmsPmXwlJO5nSirIPhHBOGb9F4JfcD9jKSj8dFfIC2s8XulEPUoczbq7kjp3KS2CTf6EOGin+abTqda7Hw2NiCvX67ZkUyjnUPjBJknYxi//PCEHLwrO46lc+d1yqF0ZVwfLTCBjnIPiAnq+ssXtorSECAwEAAaMhMB8wHQYDVR0OBBYEFDiZG6s5d9RvorpqbVdS2/MD8ZKhMA0GCSqGSIb3DQEBCwUAA4IBAQAQAPuqqKj2AgfC9ayx+qUu0vjzKYdZ6T+3ssJDOGwB1cLMXMTUVgFwj8bsX1ahDUJdzKpWtNj7bno+Ug85IyU7k89U0Ygr55zWU5h4wnnRrCu9QKvudUPnbiXoVuHPwcK8w1fdXZQB5Qq/kKzhNGY57cG1bwj3R/aIdCp+BjgFppOKjJpK7FKS8G2v70eIiCLMapK9lLEeQOxIvzctTsXy9EZ7wtaIiYky4ZSituphToJUkakHaQ6evbn82lTg6WZz1tmSmYnPqRdAff7aiQ1Sw9HpuzlZY/piTVqvd6AfKZqyxu/FhENE0Odv/0hlHzI15jKQWL1Ljc0Nm3y1skut" + ], + "x5t" => "Mr5-AUibfBii7Nd1jBebaxboXW0" + }, + %{ + "e" => "AQAB", + "issuer" => "https://login.microsoftonline.com/{tenantid}/v2.0", + "kid" => "jS1Xo1OWDj_52vbwGNgvQO2VzMc", + "kty" => "RSA", + "n" => + "spvQcXWqYrMcvcqQmfSMYnbUC8U03YctnXyLIBe148OzhBrgdAOmPfMfJi_tUW8L9svVGpk5qG6dN0n669cRHKqU52GnG0tlyYXmzFC1hzHVgQz9ehve4tlJ7uw936XIUOAOxx3X20zdpx7gm4zHx4j2ZBlXskAj6U3adpHQNuwUE6kmngJWR-deWlEigMpRsvUVQ2O5h0-RSq8Wr_x7ud3K6GTtrzARamz9uk2IXatKYdnj5Jrk2jLY6nWt-GtxlA_l9XwIrOl6Sqa_pOGIpS01JKdxKvpBC9VdS8oXB-7P5qLksmv7tq-SbbiOec0cvU7WP7vURv104V4FiI_qoQ", + "use" => "sig", + "x5c" => [ + "MIIDBTCCAe2gAwIBAgIQHsetP+i8i6VIAmjmfVGv6jANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTIyMDEzMDIzMDYxNFoXDTI3MDEzMDIzMDYxNFowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALKb0HF1qmKzHL3KkJn0jGJ21AvFNN2HLZ18iyAXtePDs4Qa4HQDpj3zHyYv7VFvC/bL1RqZOahunTdJ+uvXERyqlOdhpxtLZcmF5sxQtYcx1YEM/Xob3uLZSe7sPd+lyFDgDscd19tM3ace4JuMx8eI9mQZV7JAI+lN2naR0DbsFBOpJp4CVkfnXlpRIoDKUbL1FUNjuYdPkUqvFq/8e7ndyuhk7a8wEWps/bpNiF2rSmHZ4+Sa5Noy2Op1rfhrcZQP5fV8CKzpekqmv6ThiKUtNSSncSr6QQvVXUvKFwfuz+ai5LJr+7avkm24jnnNHL1O1j+71Eb9dOFeBYiP6qECAwEAAaMhMB8wHQYDVR0OBBYEFGzVFjAbYpU/2en4ry4LMLUHJ3GjMA0GCSqGSIb3DQEBCwUAA4IBAQBU0YdNVfdByvpwsPfwNdD8m1PLeeCKmLHQnWRI5600yEHuoUvoAJd5dwe1ZU1bHHRRKWN7AktUzofP3yF61xtizhEbyPjHK1tnR+iPEviWxVvK37HtfEPzuh1Vqp08bqY15McYUtf77l2HXTpak+UWYRYJBi++2umIDKY5UMqU+LEZnvaXybLUKN3xG4iy2q1Ab8syGFaUP7J3nCtVrR7ip39BnvSTTZZNo/OC7fYXJ2X4sN1/2ZhR5EtnAgwi2RvlZl0aWPrczArUCxDBCbsKPL/Up/kID1ir1VO4LT09ryfv2nx3y6l0YvuL7ePz4nGYCWHcbMVcUrQUXquZ3XtI" + ], + "x5t" => "jS1Xo1OWDj_52vbwGNgvQO2VzMc" + }, + %{ + "e" => "AQAB", + "issuer" => "https://login.microsoftonline.com/{tenantid}/v2.0", + "kid" => "2ZQpJ3UpbjAYXYGaXEJl8lV0TOI", + "kty" => "RSA", + "n" => + "wEMMJtj9yMQd8QS6Vnm538K5GN1Pr_I31_LUl9-OCYu-9_DrDvPGjViQK9kOiCjBfyqoAL-pBecn9-XXaS-C4xZTn1ZRw--GELabuo0u-U6r3TKj42xFDEP-_R5RpOGshoC95lrKiU5teuhn4fBM3XfR2GB0dVMcpzN3h4-0OMvBK__Zr9tkQCU_KzXTbNCjyA7ybtbr83NF9k3KjpTyOyY2S-qvFbY-AoqMhL9Rp8r2HBj_vrsr6RX6GeiSxxjbEzDFA2VIcSKbSHvbNBEeW2KjLXkz6QG2LjKz5XsYLp6kv_-k9lPQBy_V7Ci4ZkhAN-6j1S1Kcq58aLbp0wDNKQ", + "use" => "sig", + "x5c" => [ + "MIIDBTCCAe2gAwIBAgIQH4FlYNA+UJlF0G3vy9ZrhTANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTIyMDUyMjIwMDI0OVoXDTI3MDUyMjIwMDI0OVowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMBDDCbY/cjEHfEEulZ5ud/CuRjdT6/yN9fy1JffjgmLvvfw6w7zxo1YkCvZDogowX8qqAC/qQXnJ/fl12kvguMWU59WUcPvhhC2m7qNLvlOq90yo+NsRQxD/v0eUaThrIaAveZayolObXroZ+HwTN130dhgdHVTHKczd4ePtDjLwSv/2a/bZEAlPys102zQo8gO8m7W6/NzRfZNyo6U8jsmNkvqrxW2PgKKjIS/UafK9hwY/767K+kV+hnokscY2xMwxQNlSHEim0h72zQRHltioy15M+kBti4ys+V7GC6epL//pPZT0Acv1ewouGZIQDfuo9UtSnKufGi26dMAzSkCAwEAAaMhMB8wHQYDVR0OBBYEFLFr+sjUQ+IdzGh3eaDkzue2qkTZMA0GCSqGSIb3DQEBCwUAA4IBAQCiVN2A6ErzBinGYafC7vFv5u1QD6nbvY32A8KycJwKWy1sa83CbLFbFi92SGkKyPZqMzVyQcF5aaRZpkPGqjhzM+iEfsR2RIf+/noZBlR/esINfBhk4oBruj7SY+kPjYzV03NeY0cfO4JEf6kXpCqRCgp9VDRM44GD8mUV/ooN+XZVFIWs5Gai8FGZX9H8ZSgkIKbxMbVOhisMqNhhp5U3fT7VPsl94rilJ8gKXP/KBbpldrfmOAdVDgUC+MHw3sSXSt+VnorB4DU4mUQLcMriQmbXdQc8d1HUZYZEkcKaSgbygHLtByOJF44XUsBotsTfZ4i/zVjnYcjgUQmwmAWD" + ], + "x5t" => "2ZQpJ3UpbjAYXYGaXEJl8lV0TOI" + }, + %{ + "e" => "AQAB", + "issuer" => "https://login.microsoftonline.com/{tenantid}/v2.0", + "kid" => "-KI3Q9nNR7bRofxmeZoXqbHZGew", + "kty" => "RSA", + "n" => + "tJL6Wr2JUsxLyNezPQh1J6zn6wSoDAhgRYSDkaMuEHy75VikiB8wg25WuR96gdMpookdlRvh7SnRvtjQN9b5m4zJCMpSRcJ5DuXl4mcd7Cg3Zp1C5-JmMq8J7m7OS9HpUQbA1yhtCHqP7XA4UnQI28J-TnGiAa3viPLlq0663Cq6hQw7jYo5yNjdJcV5-FS-xNV7UHR4zAMRruMUHxte1IZJzbJmxjKoEjJwDTtcd6DkI3yrkmYt8GdQmu0YBHTJSZiz-M10CY3LbvLzf-tbBNKQ_gfnGGKF7MvRCmPA_YF_APynrIG7p4vPDRXhpG3_CIt317NyvGoIwiv0At83kQ", + "use" => "sig", + "x5c" => [ + "MIIDBTCCAe2gAwIBAgIQGQ6YG6NleJxJGDRAwAd/ZTANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTIyMTAwMjE4MDY0OVoXDTI3MTAwMjE4MDY0OVowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALSS+lq9iVLMS8jXsz0IdSes5+sEqAwIYEWEg5GjLhB8u+VYpIgfMINuVrkfeoHTKaKJHZUb4e0p0b7Y0DfW+ZuMyQjKUkXCeQ7l5eJnHewoN2adQufiZjKvCe5uzkvR6VEGwNcobQh6j+1wOFJ0CNvCfk5xogGt74jy5atOutwquoUMO42KOcjY3SXFefhUvsTVe1B0eMwDEa7jFB8bXtSGSc2yZsYyqBIycA07XHeg5CN8q5JmLfBnUJrtGAR0yUmYs/jNdAmNy27y83/rWwTSkP4H5xhihezL0QpjwP2BfwD8p6yBu6eLzw0V4aRt/wiLd9ezcrxqCMIr9ALfN5ECAwEAAaMhMB8wHQYDVR0OBBYEFJcSH+6Eaqucndn9DDu7Pym7OA8rMA0GCSqGSIb3DQEBCwUAA4IBAQADKkY0PIyslgWGmRDKpp/5PqzzM9+TNDhXzk6pw8aESWoLPJo90RgTJVf8uIj3YSic89m4ftZdmGFXwHcFC91aFe3PiDgCiteDkeH8KrrpZSve1pcM4SNjxwwmIKlJdrbcaJfWRsSoGFjzbFgOecISiVaJ9ZWpb89/+BeAz1Zpmu8DSyY22dG/K6ZDx5qNFg8pehdOUYY24oMamd4J2u2lUgkCKGBZMQgBZFwk+q7H86B/byGuTDEizLjGPTY/sMms1FAX55xBydxrADAer/pKrOF1v7Dq9C1Z9QVcm5D9G4DcenyWUdMyK43NXbVQLPxLOng51KO9icp2j4U7pwHP" + ], + "x5t" => "-KI3Q9nNR7bRofxmeZoXqbHZGew" + }, + %{ + "e" => "AQAB", + "issuer" => "https://login.microsoftonline.com/{tenantid}/v2.0", + "kid" => "DqUu8gf-nAgcyjP3-SuplNAXAnc", + "kty" => "RSA", + "n" => + "1n7-nWSLeuWQzBRlYSbS8RjvWvkQeD7QL9fOWaGXbW73VNGH0YipZisPClFv6GzwfWECTWQp19WFe_lASka5-KEWkQVzCbEMaaafOIs7hC61P5cGgw7dhuW4s7f6ZYGZEzQ4F5rHE-YNRbvD51qirPNzKHk3nji1wrh0YtbPPIf--NbI98bCwLLh9avedOmqESzWOGECEMXv8LSM-B9SKg_4QuBtyBwwIakTuqo84swTBM5w8PdhpWZZDtPgH87Wz-_WjWvk99AjXl7l8pWPQJiKNujt_ck3NDFpzaLEppodhUsID0ptRA008eCU6l8T-ux19wZmb_yBnHcV3pFWhQ", + "use" => "sig", + "x5c" => [ + "MIIC8TCCAdmgAwIBAgIQYVk/tJ1e4phISvVrAALNKTANBgkqhkiG9w0BAQsFADAjMSEwHwYDVQQDExhsb2dpbi5taWNyb3NvZnRvbmxpbmUudXMwHhcNMjAxMjIxMDAwMDAwWhcNMjUxMjIxMDAwMDAwWjAjMSEwHwYDVQQDExhsb2dpbi5taWNyb3NvZnRvbmxpbmUudXMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDWfv6dZIt65ZDMFGVhJtLxGO9a+RB4PtAv185ZoZdtbvdU0YfRiKlmKw8KUW/obPB9YQJNZCnX1YV7+UBKRrn4oRaRBXMJsQxppp84izuELrU/lwaDDt2G5bizt/plgZkTNDgXmscT5g1Fu8PnWqKs83MoeTeeOLXCuHRi1s88h/741sj3xsLAsuH1q9506aoRLNY4YQIQxe/wtIz4H1IqD/hC4G3IHDAhqRO6qjzizBMEznDw92GlZlkO0+AfztbP79aNa+T30CNeXuXylY9AmIo26O39yTc0MWnNosSmmh2FSwgPSm1EDTTx4JTqXxP67HX3BmZv/IGcdxXekVaFAgMBAAGjITAfMB0GA1UdDgQWBBQ2r//lgTPcKughDkzmCtRlw+P9SzANBgkqhkiG9w0BAQsFAAOCAQEAsFdRyczNWh/qpYvcIZbDvWYzlrmFZc6blcUzns9zf7sUWtQZrZPu5DbetV2Gr2r3qtMDKXCUaR+pqoy3I2zxTX3x8bTNhZD9YAgAFlTLNSydTaK5RHyB/5kr6B7ZJeNIk3PRVhRGt6ybCJSjV/VYVkLR5fdLP+5GhvBESobAR/d0ntriTzp7/tLMb/oXx7w5Hu1m3I8rpMocoXfH2SH1GLmMXj6Mx1dtwCDYM6bsb3fhWRz9O9OMR6QNiTnq8q9wn1QzBAnRcswYzT1LKKBPNFSasCvLYOCPOZCL+W8N8jqa9ZRYNYKWXzmiSptgBEM24t3m5FUWzWqoLu9pIcnkPQ==" + ], + "x5t" => "DqUu8gf-nAgcyjP3-SuplNAXAnc" + }, + %{ + "e" => "AQAB", + "issuer" => "https://login.microsoftonline.com/{tenantid}/v2.0", + "kid" => "OzZ5Dbmcso9Qzt2ModGmihg30Bo", + "kty" => "RSA", + "n" => + "01re9a2BUTtNtdFzLNI-QEHW8XhDiDMDbGMkxHRIYXH41zBccsXwH9vMi0HuxXHpXOzwtUYKwl93ZR37tp6lpvwlU1HePNmZpJ9D-XAvU73x03YKoZEdaFB39VsVyLih3fuPv6DPE2qT-TNE3X5YdIWOGFrcMkcXLsjO-BCq4qcSdBH2lBgEQUuD6nqreLZsg-gPzSDhjVScIUZGiD8M2sKxADiIHo5KlaZIyu32t8JkavP9jM7ItSAjzig1W2yvVQzUQZA-xZqJo2jxB3g_fygdPUHK6UN-_cqkrfxn2-VWH1wMhlm90SpxTMD4HoYOViz1ggH8GCX2aBiX5OzQ6Q", + "use" => "sig", + "x5c" => [ + "MIIC8TCCAdmgAwIBAgIQQrXPXIlUE4JMTMkVj+02YTANBgkqhkiG9w0BAQsFADAjMSEwHwYDVQQDExhsb2dpbi5taWNyb3NvZnRvbmxpbmUudXMwHhcNMjEwMzEwMDAwMDAwWhcNMjYwMzEwMDAwMDAwWjAjMSEwHwYDVQQDExhsb2dpbi5taWNyb3NvZnRvbmxpbmUudXMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDTWt71rYFRO0210XMs0j5AQdbxeEOIMwNsYyTEdEhhcfjXMFxyxfAf28yLQe7Fcelc7PC1RgrCX3dlHfu2nqWm/CVTUd482Zmkn0P5cC9TvfHTdgqhkR1oUHf1WxXIuKHd+4+/oM8TapP5M0Tdflh0hY4YWtwyRxcuyM74EKripxJ0EfaUGARBS4Pqeqt4tmyD6A/NIOGNVJwhRkaIPwzawrEAOIgejkqVpkjK7fa3wmRq8/2Mzsi1ICPOKDVbbK9VDNRBkD7FmomjaPEHeD9/KB09QcrpQ379yqSt/Gfb5VYfXAyGWb3RKnFMwPgehg5WLPWCAfwYJfZoGJfk7NDpAgMBAAGjITAfMB0GA1UdDgQWBBTECjBRANDPLGrn1p7qtwswtBU7JzANBgkqhkiG9w0BAQsFAAOCAQEAq1Ib4ERvXG5kiVmhfLOpun2ElVOLY+XkvVlyVjq35rZmSIGxgfFc08QOQFVmrWQYrlss0LbboH0cIKiD6+rVoZTMWxGEicOcGNFzrkcG0ulu0cghKYID3GKDTftYKEPkvu2vQmueqa4t2tT3PlYF7Fi2dboR5Y96Ugs8zqNwdBMRm677N/tJBk53CsOf9NnBaxZ1EGArmEHHIb80vODStv35ueLrfMRtCF/HcgkGxy2U8kaCzYmmzHh4zYDkeCwM3Cq2bGkG+Efe9hFYfDHw13DzTR+h9pPqFFiAxnZ3ofT96NrZHdYjwbfmM8cw3ldg0xQzGcwZjtyYmwJ6sDdRvQ==" + ], + "x5t" => "OzZ5Dbmcso9Qzt2ModGmihg30Bo" + }, + %{ + "e" => "AQAB", + "issuer" => "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0", + "kid" => "1LTMzakihiRla_8z2BEJVXeWMqo", + "kty" => "RSA", + "n" => + "3sKcJSD4cHwTY5jYm5lNEzqk3wON1CaARO5EoWIQt5u-X-ZnW61CiRZpWpfhKwRYU153td5R8p-AJDWT-NcEJ0MHU3KiuIEPmbgJpS7qkyURuHRucDM2lO4L4XfIlvizQrlyJnJcd09uLErZEO9PcvKiDHoois2B4fGj7CsAe5UZgExJvACDlsQSku2JUyDmZUZP2_u_gCuqNJM5o0hW7FKRI3MFoYCsqSEmHnnumuJ2jF0RHDRWQpodhlAR6uKLoiWHqHO3aG7scxYMj5cMzkpe1Kq_Dm5yyHkMCSJ_JaRhwymFfV_SWkqd3n-WVZT0ADLEq0RNi9tqZ43noUnO_w", + "use" => "sig", + "x5c" => [ + "MIIDYDCCAkigAwIBAgIJAIB4jVVJ3BeuMA0GCSqGSIb3DQEBCwUAMCkxJzAlBgNVBAMTHkxpdmUgSUQgU1RTIFNpZ25pbmcgUHVibGljIEtleTAeFw0xNjA0MDUxNDQzMzVaFw0yMTA0MDQxNDQzMzVaMCkxJzAlBgNVBAMTHkxpdmUgSUQgU1RTIFNpZ25pbmcgUHVibGljIEtleTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN7CnCUg+HB8E2OY2JuZTRM6pN8DjdQmgETuRKFiELebvl/mZ1utQokWaVqX4SsEWFNed7XeUfKfgCQ1k/jXBCdDB1NyoriBD5m4CaUu6pMlEbh0bnAzNpTuC+F3yJb4s0K5ciZyXHdPbixK2RDvT3Lyogx6KIrNgeHxo+wrAHuVGYBMSbwAg5bEEpLtiVMg5mVGT9v7v4ArqjSTOaNIVuxSkSNzBaGArKkhJh557pridoxdERw0VkKaHYZQEerii6Ilh6hzt2hu7HMWDI+XDM5KXtSqvw5ucsh5DAkifyWkYcMphX1f0lpKnd5/llWU9AAyxKtETYvbameN56FJzv8CAwEAAaOBijCBhzAdBgNVHQ4EFgQU9IdLLpbC2S8Wn1MCXsdtFac9SRYwWQYDVR0jBFIwUIAU9IdLLpbC2S8Wn1MCXsdtFac9SRahLaQrMCkxJzAlBgNVBAMTHkxpdmUgSUQgU1RTIFNpZ25pbmcgUHVibGljIEtleYIJAIB4jVVJ3BeuMAsGA1UdDwQEAwIBxjANBgkqhkiG9w0BAQsFAAOCAQEAXk0sQAib0PGqvwELTlflQEKS++vqpWYPW/2gCVCn5shbyP1J7z1nT8kE/ZDVdl3LvGgTMfdDHaRF5ie5NjkTHmVOKbbHaWpTwUFbYAFBJGnx+s/9XSdmNmW9GlUjdpd6lCZxsI6888r0ptBgKINRRrkwMlq3jD1U0kv4JlsIhafUIOqGi4+hIDXBlY0F/HJPfUU75N885/r4CCxKhmfh3PBM35XOch/NGC67fLjqLN+TIWLoxnvil9m3jRjqOA9u50JUeDGZABIYIMcAdLpI2lcfru4wXcYXuQul22nAR7yOyGKNOKULoOTE4t4AeGRqCogXSxZgaTgKSBhvhE+MGg==" + ], + "x5t" => "1LTMzakihiRla_8z2BEJVXeWMqo" + }, + %{ + "e" => "AQAB", + "issuer" => "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0", + "kid" => "bW8ZcMjBCnJZS-ibX5UQDNStvx4", + "kty" => "RSA", + "n" => + "2a70SwgqIh8U-Shj_VJJGBheEVk2F4ygmMCRtKUAb1jMP6R1j5Mc5xaqhgzlWjckJI1lx4rha1oNLrdg8tJBxdm8V8xZohCOanJ52uAwoc6FFTY3VRLaUZSJ3zCXfuJwy4KvFHJUAuLhLj0hVeq-y10CmRJ1_MPTuNRJLdblSWcXyWYIikIRggQWS04M-QjR7571mX-Lu_eDs8xJVrnNFMVGRmFqf3EFD4QLNjW9JJj0m_prnTv41V_E8AA7MQZ12ip3u5aeOAQqGjVyzdHxvV9laxta6XWaM8QSTIu_Zav1-aDYExp99nCP4Hw0_Oom5vK5N88DB8VM0mouQi8a8Q", + "use" => "sig", + "x5c" => [ + "MIIDYDCCAkigAwIBAgIJAN2X7t+ckntxMA0GCSqGSIb3DQEBCwUAMCkxJzAlBgNVBAMTHkxpdmUgSUQgU1RTIFNpZ25pbmcgUHVibGljIEtleTAeFw0yMTAzMjkyMzM4MzNaFw0yNjAzMjgyMzM4MzNaMCkxJzAlBgNVBAMTHkxpdmUgSUQgU1RTIFNpZ25pbmcgUHVibGljIEtleTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANmu9EsIKiIfFPkoY/1SSRgYXhFZNheMoJjAkbSlAG9YzD+kdY+THOcWqoYM5Vo3JCSNZceK4WtaDS63YPLSQcXZvFfMWaIQjmpyedrgMKHOhRU2N1US2lGUid8wl37icMuCrxRyVALi4S49IVXqvstdApkSdfzD07jUSS3W5UlnF8lmCIpCEYIEFktODPkI0e+e9Zl/i7v3g7PMSVa5zRTFRkZhan9xBQ+ECzY1vSSY9Jv6a507+NVfxPAAOzEGddoqd7uWnjgEKho1cs3R8b1fZWsbWul1mjPEEkyLv2Wr9fmg2BMaffZwj+B8NPzqJubyuTfPAwfFTNJqLkIvGvECAwEAAaOBijCBhzAdBgNVHQ4EFgQU57BsETnF8TctGU87R4N9YxmNWoIwWQYDVR0jBFIwUIAU57BsETnF8TctGU87R4N9YxmNWoKhLaQrMCkxJzAlBgNVBAMTHkxpdmUgSUQgU1RTIFNpZ25pbmcgUHVibGljIEtleYIJAN2X7t+ckntxMAsGA1UdDwQEAwIBxjANBgkqhkiG9w0BAQsFAAOCAQEAcsk+LGlTzSQdnh3mtCBMNCGZCiTYvFcqenwjDf1/c4U+Yi7fxYmAXm7wVLX+GVMxpLPpzMuVOXztGoPMUgWH59CFWhsMvZbIUKsd8xbEKmls1ZIgxRYdagcWTGeBET6XIoF6Ba57BhRCxFPslhIpg27/HnfHtTdGfjRpafNbBYvC/9PL/s2E9U4AklpUn2W19UiJLRFgXGPjYPLW0j1Od0qzHHJ84saclVwvuOrpp75Y+0Du5Z2OrjNF1W4dEWZMJmmOe73ejAnoiWJI25kQpkd4ooNasw3HIZEJZ6cKctmPJLdvx0tJ8bde4DivtWOeFIwcAkokH2jlHmAOipNETw==" + ], + "x5t" => "bW8ZcMjBCnJZS-ibX5UQDNStvx4" + } + ] + }, + headers: [ + {"Cache-Control", "max-age=86400, private"}, + {"Content-Type", "application/json; charset=utf-8"}, + {"Strict-Transport-Security", "max-age=31536000; includeSubDomains"}, + {"X-Content-Type-Options", "nosniff"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "GET, OPTIONS"}, + {"P3P", "CP=\"DSP CUR OTPi IND OTRi ONL FIN\""}, + {"x-ms-request-id", "66b44222-1ec7-41ae-ad80-b5ec809c1e00"}, + {"x-ms-ests-server", "2.1.14059.13 - WUS2 ProdSlices"}, + {"X-XSS-Protection", "0"}, + {"Set-Cookie", + "fpc=AhXhm59ZPKxAuoDA0xJOTXw; expires=Mon, 12-Dec-2022 20:59:22 GMT; path=/; secure; HttpOnly; SameSite=None"}, + {"Set-Cookie", + "esctx=AQABAAAAAAD--DLA3VO7QrddgJg7WevrisuCv4ebeqYc-_POPk9YcVRhn9bd7CUqVj30-s0iSKalFe-d2lZ-f4-XK0n9ihY93a7m9SsGmsIMY7zD9ez66cNlKITs_ezKr4bRpFPqimcqQaHWPo_UKxjSYOQvsOH1N0F2vp-2Ht7cFowAp8vUIO0oCj1Fbps_-Di7qvstKBXVmkHe6uENMnH0BpbyPPa0DskUrFDPPwH1po1Hthps2l35xFERrP6mYU6aS4jwW98gAA; domain=.login.microsoftonline.com; path=/; secure; HttpOnly; SameSite=None"}, + {"Set-Cookie", "x-ms-gateway-slice=estsfd; path=/; secure; samesite=none; httponly"}, + {"Set-Cookie", "stsservicecookie=estsfd; path=/; secure; samesite=none; httponly"}, + {"Date", "Sat, 12 Nov 2022 20:59:21 GMT"} + ] +} diff --git a/test/fixtures/http/cognito/discovery_document.exs b/test/fixtures/http/cognito/discovery_document.exs new file mode 100644 index 0000000..55f0615 --- /dev/null +++ b/test/fixtures/http/cognito/discovery_document.exs @@ -0,0 +1,49 @@ +%{ + status_code: 200, + body: %{ + "authorization_endpoint" => "https://DOMAIN/oauth2/authorize", + "id_token_signing_alg_values_supported" => [ + "RS256" + ], + "issuer" => "https://cognito-idp.REGION.amazonaws.com/REGION-CODE", + "jwks_uri" => "https://cognito-idp.REGIONamazonaws.com/REGION-CODE/.well-known/jwks.json", + "response_types_supported" => [ + "code", + "token" + ], + "scopes_supported" => [ + "openid", + "email", + "phone", + "profile" + ], + "subject_types_supported" => [ + "public" + ], + "token_endpoint" => "https://DOMAIN/oauth2/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "client_secret_post" + ], + "userinfo_endpoint" => "https://DOMAIN/oauth2/userInfo" + }, + headers: [ + {"Cache-Control", "max-age=86400, private"}, + {"Content-Type", "application/json; charset=utf-8"}, + {"Strict-Transport-Security", "max-age=31536000; includeSubDomains"}, + {"X-Content-Type-Options", "nosniff"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "GET, OPTIONS"}, + {"P3P", "CP=\"DSP CUR OTPi IND OTRi ONL FIN\""}, + {"x-ms-request-id", "d81e7f56-0451-4de4-a5c5-4af112d02001"}, + {"x-ms-ests-server", "2.1.14006.10 - NCUS ProdSlices"}, + {"X-XSS-Protection", "0"}, + {"Set-Cookie", + "fpc=AuKLSwY1b3xLiInKP16p3E4; expires=Mon, 12-Dec-2022 19:36:30 GMT; path=/; secure; HttpOnly; SameSite=None"}, + {"Set-Cookie", + "esctx=AQABAAAAAAD--DLA3VO7QrddgJg7Wevr2ALKzZMjPY-Tt7ffB-f_7y4AMTUR-4m-AQDAi0jJ1K4_N7dY0CZmKZdSweQPMgerZ-TeKnty43nfmYRZS2G39bKUZp5erQLwiB9rkuLis4_ee_cAZK7nh1pkqOh0_t52P9svf75Le0-ex8iyPVhexTbIROTaaYvo6Fl9DFqOtZOnmQplc6ken-ddUcLbnZRSKOTFdr03VB8oSt5gD2BBw2e5qeBuocgX0hS-W-FNbG0gAA; domain=.login.microsoftonline.com; path=/; secure; HttpOnly; SameSite=None"}, + {"Set-Cookie", "x-ms-gateway-slice=estsfd; path=/; secure; samesite=none; httponly"}, + {"Set-Cookie", "stsservicecookie=estsfd; path=/; secure; samesite=none; httponly"}, + {"Date", "Sat, 12 Nov 2022 19:36:29 GMT"} + ] +} diff --git a/test/fixtures/http/cognito/jwks.exs b/test/fixtures/http/cognito/jwks.exs new file mode 100644 index 0000000..a5972e5 --- /dev/null +++ b/test/fixtures/http/cognito/jwks.exs @@ -0,0 +1,26 @@ +%{ + status_code: 200, + body: %{ + "keys" => [ + %{ + "alg" => "RS256", + "e" => "AQAB", + "kid" => "f451345fad08101bfb345cf642a2da9267b9ebeb", + "kty" => "RSA", + "n" => + "ppFPAZUqIVqCf_SffT6xDCXu1R7aRoT6TNT5_Q8PKxkkqbOVysJPNwliF-486VeM8KNW8onFOv0GkP0lJ2ASrVgyMG1qmlGUlKug64dMQXPxSlVUCXCPN676W5IZTvT0tD2byM_29HZXnOifRg-d7PRRvIBLSUWe-fGb1-tP2w65SOW-W6LuOjGzLNPJFYQvHyUx_uXHOCfIoSb8kaMwx8bCWvKc76yT0DG1wcygGXKuFQHW-Sdi1j_6bF19lVu30DX-jhYsNMUnGUr6g2iycQ50pWMORZqvcHVOH1bbDrWuz0b564sK0ET2B3XDR37djNQ305PxiQZaBStm-hM8Aw", + "use" => "sig" + }, + %{ + "alg" => "RS256", + "e" => "AQAB", + "kid" => "713fd68c966e29380981edc0164a2f6c06c5702a", + "kty" => "RSA", + "n" => + "z8PS6saDU3h5ZbQb3Lwl_Arwgu65ECMi79KUlzx4tqk8bgxtaaHcqyvWqVdsA9H6Q2ZtQhBZivqV4Jg0HoPHcEwv46SEziFQNR2LH86e-WIDI5pk2NKg_9cFMee9Mz7f_NSQJ3uyD1pu86bdUTYhCw57DbEVDOuubClNMUV456dWx7dx5W4kdcQe63vGg9LXQ-9PPz9AL-0ZKr8eQEHp4KRfRUfngjqjYBMTFuuo38l94KR99B04Z-FboGnqYLgNxctwZ9eXbCerb9bV5-Q9Gb3zoo0x1h90tFdgmC2ZU1xcIIjHmFqJ29mSDZHYAAYtMNAeWreK4gqWJunc9o0vpQ", + "use" => "sig" + } + ] + }, + headers: [] +} diff --git a/test/fixtures/http/google/discovery_document.exs b/test/fixtures/http/google/discovery_document.exs new file mode 100644 index 0000000..e1ec2bc --- /dev/null +++ b/test/fixtures/http/google/discovery_document.exs @@ -0,0 +1,68 @@ +%{ + status_code: 200, + body: %{ + "authorization_endpoint" => "https://accounts.google.com/o/oauth2/v2/auth", + "claims_supported" => [ + "aud", + "email", + "email_verified", + "exp", + "family_name", + "given_name", + "iat", + "iss", + "locale", + "name", + "picture", + "sub" + ], + "code_challenge_methods_supported" => ["plain", "S256"], + "device_authorization_endpoint" => "https://oauth2.googleapis.com/device/code", + "grant_types_supported" => [ + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:device_code", + "urn:ietf:params:oauth:grant-type:jwt-bearer" + ], + "id_token_signing_alg_values_supported" => ["RS256"], + "issuer" => "https://accounts.google.com", + "jwks_uri" => "https://www.googleapis.com/oauth2/v3/certs", + "response_types_supported" => [ + "code", + "token", + "id_token", + "code token", + "code id_token", + "token id_token", + "code token id_token", + "none" + ], + "revocation_endpoint" => "https://oauth2.googleapis.com/revoke", + "scopes_supported" => ["openid", "email", "profile"], + "subject_types_supported" => ["public"], + "token_endpoint" => "https://oauth2.googleapis.com/token", + "token_endpoint_auth_methods_supported" => ["client_secret_post", "client_secret_basic"], + "userinfo_endpoint" => "https://openidconnect.googleapis.com/v1/userinfo" + }, + headers: [ + {"Accept-Ranges", "bytes"}, + {"Vary", "Accept-Encoding"}, + {"Access-Control-Allow-Origin", "*"}, + {"Content-Security-Policy-Report-Only", + "require-trusted-types-for 'script'; report-uri https://csp.withgoogle.com/csp/federated-signon-mpm-access"}, + {"Cross-Origin-Opener-Policy", "same-origin; report-to=\"federated-signon-mpm-access\""}, + {"Report-To", + "{\"group\":\"federated-signon-mpm-access\",\"max_age\":2592000,\"endpoints\":[{\"url\":\"https://csp.withgoogle.com/csp/report-to/federated-signon-mpm-access\"}]}"}, + {"X-Content-Type-Options", "nosniff"}, + {"Server", "sffe"}, + {"X-XSS-Protection", "0"}, + {"Date", "Sat, 12 Nov 2022 19:03:58 GMT"}, + {"Expires", "Sat, 12 Nov 2022 20:03:58 GMT"}, + {"Cache-Control", "public, max-age=3600"}, + {"Age", "1698"}, + {"Last-Modified", "Thu, 16 Jan 2020 21:53:16 GMT"}, + {"Content-Type", "application/json"}, + {"Alt-Svc", + "h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\""} + ] +} diff --git a/test/fixtures/http/google/jwks.exs b/test/fixtures/http/google/jwks.exs new file mode 100644 index 0000000..694ed89 --- /dev/null +++ b/test/fixtures/http/google/jwks.exs @@ -0,0 +1,40 @@ +%{ + status_code: 200, + body: %{ + "keys" => [ + %{ + "alg" => "RS256", + "e" => "AQAB", + "kid" => "f451345fad08101bfb345cf642a2da9267b9ebeb", + "kty" => "RSA", + "n" => + "ppFPAZUqIVqCf_SffT6xDCXu1R7aRoT6TNT5_Q8PKxkkqbOVysJPNwliF-486VeM8KNW8onFOv0GkP0lJ2ASrVgyMG1qmlGUlKug64dMQXPxSlVUCXCPN676W5IZTvT0tD2byM_29HZXnOifRg-d7PRRvIBLSUWe-fGb1-tP2w65SOW-W6LuOjGzLNPJFYQvHyUx_uXHOCfIoSb8kaMwx8bCWvKc76yT0DG1wcygGXKuFQHW-Sdi1j_6bF19lVu30DX-jhYsNMUnGUr6g2iycQ50pWMORZqvcHVOH1bbDrWuz0b564sK0ET2B3XDR37djNQ305PxiQZaBStm-hM8Aw", + "use" => "sig" + }, + %{ + "alg" => "RS256", + "e" => "AQAB", + "kid" => "713fd68c966e29380981edc0164a2f6c06c5702a", + "kty" => "RSA", + "n" => + "z8PS6saDU3h5ZbQb3Lwl_Arwgu65ECMi79KUlzx4tqk8bgxtaaHcqyvWqVdsA9H6Q2ZtQhBZivqV4Jg0HoPHcEwv46SEziFQNR2LH86e-WIDI5pk2NKg_9cFMee9Mz7f_NSQJ3uyD1pu86bdUTYhCw57DbEVDOuubClNMUV456dWx7dx5W4kdcQe63vGg9LXQ-9PPz9AL-0ZKr8eQEHp4KRfRUfngjqjYBMTFuuo38l94KR99B04Z-FboGnqYLgNxctwZ9eXbCerb9bV5-Q9Gb3zoo0x1h90tFdgmC2ZU1xcIIjHmFqJ29mSDZHYAAYtMNAeWreK4gqWJunc9o0vpQ", + "use" => "sig" + } + ] + }, + headers: [ + {"Server", "scaffolding on HTTPServer2"}, + {"X-XSS-Protection", "0"}, + {"X-Frame-Options", "SAMEORIGIN"}, + {"X-Content-Type-Options", "nosniff"}, + {"Date", "Sat, 12 Nov 2022 19:33:44 GMT"}, + {"Expires", "Sun, 13 Nov 2022 01:32:36 GMT"}, + {"Cache-Control", "public, max-age=21532, must-revalidate, no-transform"}, + {"Content-Type", "application/json; charset=UTF-8"}, + {"Age", "5"}, + {"Alt-Svc", + "h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\""}, + {"Accept-Ranges", "none"}, + {"Vary", "Origin,X-Origin,Referer,Accept-Encoding"} + ] +} diff --git a/test/fixtures/http/google/userinfo.exs b/test/fixtures/http/google/userinfo.exs new file mode 100644 index 0000000..ded1a96 --- /dev/null +++ b/test/fixtures/http/google/userinfo.exs @@ -0,0 +1,31 @@ +%{ + body: %{ + "sub" => "353690423699814251281", + "name" => "Ada Lovelace", + "given_name" => "Ada", + "family_name" => "Lovelace", + "picture" => + "https://lh3.googleusercontent.com/-XdUIqdMkCWA/AAAAAAAAAAI/AAAAAAAAAAA/4252rscbv5M/photo.jpg", + "email" => "ada@example.com", + "email_verified" => true, + "locale" => "en" + }, + headers: [ + {"Date", "Thu, 17 Dec 2020 14:29:16 GMT"}, + {"Cache-Control", "no-cache, no-store, max-age=0, must-revalidate"}, + {"Expires", "Mon, 01 Jan 1990 00:00:00 GMT"}, + {"Pragma", "no-cache"}, + {"Content-Type", "application/json; charset=utf-8"}, + {"Vary", "X-Origin"}, + {"Vary", "Referer"}, + {"Server", "ESF"}, + {"X-XSS-Protection", "0"}, + {"X-Frame-Options", "SAMEORIGIN"}, + {"X-Content-Type-Options", "nosniff"}, + {"Alt-Svc", + "h3-29=\":443\"; ma=2592000,h3-T051=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\""}, + {"Accept-Ranges", "none"}, + {"Vary", "Origin,Accept-Encoding"} + ], + status_code: 200 +} diff --git a/test/fixtures/http/keycloak/discovery_document.exs b/test/fixtures/http/keycloak/discovery_document.exs new file mode 100644 index 0000000..6e43e83 --- /dev/null +++ b/test/fixtures/http/keycloak/discovery_document.exs @@ -0,0 +1,287 @@ +%{ + status_code: 200, + body: %{ + "frontchannel_logout_supported" => true, + "userinfo_encryption_enc_values_supported" => [ + "A256GCM", + "A192GCM", + "A128GCM", + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512" + ], + "authorization_encryption_alg_values_supported" => ["RSA-OAEP", "RSA-OAEP-256", "RSA1_5"], + "scopes_supported" => [ + "openid", + "phone", + "acr", + "microprofile-jwt", + "email", + "profile", + "web-origins", + "offline_access", + "address", + "roles" + ], + "introspection_endpoint" => + "http://localhost:8080/realms/master/protocol/openid-connect/token/introspect", + "token_endpoint" => "http://localhost:8080/realms/master/protocol/openid-connect/token", + "backchannel_logout_session_supported" => true, + "token_endpoint_auth_methods_supported" => [ + "private_key_jwt", + "client_secret_basic", + "client_secret_post", + "tls_client_auth", + "client_secret_jwt" + ], + "request_object_encryption_enc_values_supported" => [ + "A256GCM", + "A192GCM", + "A128GCM", + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512" + ], + "require_pushed_authorization_requests" => false, + "request_parameter_supported" => true, + "revocation_endpoint_auth_signing_alg_values_supported" => [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "device_authorization_endpoint" => + "http://localhost:8080/realms/master/protocol/openid-connect/auth/device", + "authorization_signing_alg_values_supported" => [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "code_challenge_methods_supported" => ["plain", "S256"], + "request_object_encryption_alg_values_supported" => ["RSA-OAEP", "RSA-OAEP-256", "RSA1_5"], + "id_token_signing_alg_values_supported" => [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "check_session_iframe" => + "http://localhost:8080/realms/master/protocol/openid-connect/login-status-iframe.html", + "issuer" => "http://localhost:8080/realms/master", + "id_token_encryption_alg_values_supported" => ["RSA-OAEP", "RSA-OAEP-256", "RSA1_5"], + "authorization_endpoint" => + "http://localhost:8080/realms/master/protocol/openid-connect/auth", + "userinfo_encryption_alg_values_supported" => ["RSA-OAEP", "RSA-OAEP-256", "RSA1_5"], + "id_token_encryption_enc_values_supported" => [ + "A256GCM", + "A192GCM", + "A128GCM", + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512" + ], + "backchannel_authentication_request_signing_alg_values_supported" => [ + "PS384", + "ES384", + "RS384", + "ES256", + "RS256", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "jwks_uri" => "http://localhost:8080/realms/master/protocol/openid-connect/certs", + "backchannel_authentication_endpoint" => + "http://localhost:8080/realms/master/protocol/openid-connect/ext/ciba/auth", + "subject_types_supported" => ["public", "pairwise"], + "authorization_encryption_enc_values_supported" => [ + "A256GCM", + "A192GCM", + "A128GCM", + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512" + ], + "userinfo_endpoint" => "http://localhost:8080/realms/master/protocol/openid-connect/userinfo", + "response_types_supported" => [ + "code", + "none", + "id_token", + "token", + "id_token token", + "code id_token", + "code token", + "code id_token token" + ], + "mtls_endpoint_aliases" => %{ + "backchannel_authentication_endpoint" => + "http://localhost:8080/realms/master/protocol/openid-connect/ext/ciba/auth", + "device_authorization_endpoint" => + "http://localhost:8080/realms/master/protocol/openid-connect/auth/device", + "introspection_endpoint" => + "http://localhost:8080/realms/master/protocol/openid-connect/token/introspect", + "pushed_authorization_request_endpoint" => + "http://localhost:8080/realms/master/protocol/openid-connect/ext/par/request", + "registration_endpoint" => + "http://localhost:8080/realms/master/clients-registrations/openid-connect", + "revocation_endpoint" => + "http://localhost:8080/realms/master/protocol/openid-connect/revoke", + "token_endpoint" => "http://localhost:8080/realms/master/protocol/openid-connect/token", + "userinfo_endpoint" => + "http://localhost:8080/realms/master/protocol/openid-connect/userinfo" + }, + "backchannel_token_delivery_modes_supported" => ["poll", "ping"], + "pushed_authorization_request_endpoint" => + "http://localhost:8080/realms/master/protocol/openid-connect/ext/par/request", + "grant_types_supported" => [ + "authorization_code", + "implicit", + "refresh_token", + "password", + "client_credentials", + "urn:ietf:params:oauth:grant-type:device_code", + "urn:openid:params:grant-type:ciba" + ], + "request_object_signing_alg_values_supported" => [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512", + "none" + ], + "tls_client_certificate_bound_access_tokens" => true, + "userinfo_signing_alg_values_supported" => [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512", + "none" + ], + "claims_supported" => [ + "aud", + "sub", + "iss", + "auth_time", + "name", + "given_name", + "family_name", + "preferred_username", + "email", + "acr" + ], + "introspection_endpoint_auth_signing_alg_values_supported" => [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "revocation_endpoint_auth_methods_supported" => [ + "private_key_jwt", + "client_secret_basic", + "client_secret_post", + "tls_client_auth", + "client_secret_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported" => [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "acr_values_supported" => ["0", "1"], + "registration_endpoint" => + "http://localhost:8080/realms/master/clients-registrations/openid-connect", + "frontchannel_logout_session_supported" => true, + "require_request_uri_registration" => true, + "revocation_endpoint" => "http://localhost:8080/realms/master/protocol/openid-connect/revoke", + "request_uri_parameter_supported" => true, + "end_session_endpoint" => + "http://localhost:8080/realms/master/protocol/openid-connect/logout", + "claims_parameter_supported" => true, + "response_modes_supported" => [ + "query", + "fragment", + "form_post", + "query.jwt", + "fragment.jwt", + "form_post.jwt", + "jwt" + ], + "claim_types_supported" => ["normal"], + "backchannel_logout_supported" => true, + "introspection_endpoint_auth_methods_supported" => [ + "private_key_jwt", + "client_secret_basic", + "client_secret_post", + "tls_client_auth", + "client_secret_jwt" + ] + }, + headers: [ + {"Referrer-Policy", "no-referrer"}, + {"X-Frame-Options", "SAMEORIGIN"}, + {"Strict-Transport-Security", "max-age=31536000; includeSubDomains"}, + {"Cache-Control", "no-cache, must-revalidate, no-transform, no-store"}, + {"X-Content-Type-Options", "nosniff"}, + {"X-XSS-Protection", "1; mode=block"}, + {"Content-Type", "application/json"} + ] +} diff --git a/test/fixtures/http/keycloak/jwks.exs b/test/fixtures/http/keycloak/jwks.exs new file mode 100644 index 0000000..d512b56 --- /dev/null +++ b/test/fixtures/http/keycloak/jwks.exs @@ -0,0 +1,44 @@ +%{ + status_code: 200, + body: %{ + "keys" => [ + %{ + "alg" => "RS256", + "e" => "AQAB", + "kid" => "nB0vgzwAcgJmjXwQZSzBMNkhoCaH4fvSwx5GC8Jq93c", + "kty" => "RSA", + "n" => + "zTxXhjNpLJy13O1sqVqZnlbqB0U618c9micjrs2f4NzdPT7rwLRnG3TYgTgMLDN8ERLffw-5RLZAOvTyryC0JLL2KhM-n6myrpJ5vjemp-l4f-RpcgtEVx1pe8ylKpj3SytZglMBC8ds8zFHMMf3y2HrqRUPfPHKCmdpRkLs7PhEBv8A3OTgCtp-g1YUB4s46vRun5AutMDogEXUMHdgkwPjTPTTRoUOiSulb5enhKIYj3xxUsK3yTT3Y6KkHuiHUvEfgX4l7ZsEAk1U1nA_-u86QlONRu4XVloiOHEU18zoFn6-xhER1j6lX000zTPf2FSbPpL2GdTPCN7Bj9t7rQ", + "use" => "sig", + "x5c" => [ + "MIICmzCCAYMCBgGEZ9w6QTANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjIxMTExMTgwMTM2WhcNMzIxMTExMTgwMzE2WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDNPFeGM2ksnLXc7WypWpmeVuoHRTrXxz2aJyOuzZ/g3N09PuvAtGcbdNiBOAwsM3wREt9/D7lEtkA69PKvILQksvYqEz6fqbKuknm+N6an6Xh/5GlyC0RXHWl7zKUqmPdLK1mCUwELx2zzMUcwx/fLYeupFQ988coKZ2lGQuzs+EQG/wDc5OAK2n6DVhQHizjq9G6fkC60wOiARdQwd2CTA+NM9NNGhQ6JK6Vvl6eEohiPfHFSwrfJNPdjoqQe6IdS8R+BfiXtmwQCTVTWcD/67zpCU41G7hdWWiI4cRTXzOgWfr7GERHWPqVfTTTNM9/YVJs+kvYZ1M8I3sGP23utAgMBAAEwDQYJKoZIhvcNAQELBQADggEBALXouAkGrzu+5EpJLWwfQdtEYuEwK9VNheDdR2WqQDNwduk71hZWr5olNyhJz6ogMPOcNhQ33B9MssYqKtINrgxeSrO5k2QAYRYRq4BEJ+3Y9Yif9KNYV7IBXcNz4Z6fiFUnmIPoOU7dCb1Lt8sFLaa5EL1TVKQkMJ5nXLpMtTHywwEPvb/Aul1ssydfanpDW4/nCf512UpsnWetfiKw+IlTB7Rt5zMQ65RAimxz+hnY2+LF/XGnyNnPY6IPvX9suyt6u3EBM2xdXD+2UedJ8EbSvPTVo9nPfH59HmeIrIyJcw6/4xEjEVWN8oQEeYM3VOACORr2yK7zqbVpHQvOcz4=" + ], + "x5t" => "JZ8jwBZn8nzU0Rl33DcHwZU_Qio", + "x5t#S256" => "Y1XJpsaLUg3zbJVHyJij-zkNWmTbnM0y0Er1No84uTE" + }, + %{ + "alg" => "RSA-OAEP", + "e" => "AQAB", + "kid" => "Q2O_f0Z7hVL1WUxKQbChyWK_FzQK-qRZh4Kg9mxxt_I", + "kty" => "RSA", + "n" => + "jY-UVOgGl1Io_aL8jS8__Y25tqsefFfjR-JIcd3fhMjHWfoomfPlz0YfUHC6UYF7dgQeQnFEQqjxonJLDh32EWKWuXvcEvfp5592tx6COsOku_jeypwNurj9iGkbx3bv8w7x-SVp5VLdCM0IXBASIVTdmOwVfMJChIrJbjsk_wgCyL2IzU4w5cb2NdodEf1cf5ROt25EhdVZhvFzcxsfHaqOvKPBtP1W3FXbuVAIkFuXxSZdAKOZHS00RX_YOFIcOr5USIof9lBF_fXo7UXY-gDz95MkwgnfbC6WnVk4v57fniytwCNwZO3Smt3WTDBhAeFp9d8Xn_sUhwoqcBw_9Q", + "use" => "enc", + "x5c" => [ + "MIICmzCCAYMCBgGEZ9w7jTANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjIxMTExMTgwMTM3WhcNMzIxMTExMTgwMzE3WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCNj5RU6AaXUij9ovyNLz/9jbm2qx58V+NH4khx3d+EyMdZ+iiZ8+XPRh9QcLpRgXt2BB5CcURCqPGicksOHfYRYpa5e9wS9+nnn3a3HoI6w6S7+N7KnA26uP2IaRvHdu/zDvH5JWnlUt0IzQhcEBIhVN2Y7BV8wkKEisluOyT/CALIvYjNTjDlxvY12h0R/Vx/lE63bkSF1VmG8XNzGx8dqo68o8G0/VbcVdu5UAiQW5fFJl0Ao5kdLTRFf9g4Uhw6vlRIih/2UEX99ejtRdj6APP3kyTCCd9sLpadWTi/nt+eLK3AI3Bk7dKa3dZMMGEB4Wn13xef+xSHCipwHD/1AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAD0e9ia9dDegLJFjRdAtfMZXj+6nVfSPNSPgMp4FBa1lx98EP5B3cEapVDRoAr/q3W3sI/88mzzXhrjhZEENep02JcKTZdZfyRoPShyVcPTLLUH0iiRYszTYj05iUpB9/wETN7rAjqpP+CV2a5uUL14K4sPZeWKOx3wCjEl7AdzlWCc65/XB1ZRVrRF0zJPcKQWb0YWgJb5cbj6/PNR3ZCHUw+PYi+i3/lJ3XObXmv/5+2PP0eXmeo9eTxoctKN947He95ugsOekzB2nU1XNcxDZzlMvKD2OiwkuG9SM+Uw7/sTBf/X/pHfzF9sKeq7B0vtHDunm+uBvRTZfbrDYKWk=" + ], + "x5t" => "Y8cA4ZtbCbhVKJWC4swg2H3oBRE", + "x5t#S256" => "pYO4_DMcglAP1G5HUhZxwlTo_nnDTpt7ORinPpUEiRc" + } + ] + }, + headers: [ + {"Referrer-Policy", "no-referrer"}, + {"X-Frame-Options", "SAMEORIGIN"}, + {"Strict-Transport-Security", "max-age=31536000; includeSubDomains"}, + {"Cache-Control", "no-cache"}, + {"X-Content-Type-Options", "nosniff"}, + {"X-XSS-Protection", "1; mode=block"}, + {"Content-Type", "application/json"} + ] +} diff --git a/test/fixtures/http/okta/discovery_document.exs b/test/fixtures/http/okta/discovery_document.exs new file mode 100644 index 0000000..9652d7a --- /dev/null +++ b/test/fixtures/http/okta/discovery_document.exs @@ -0,0 +1,129 @@ +%{ + status_code: 200, + body: %{ + "authorization_endpoint" => "https://common.okta.com/oauth2/v1/authorize", + "claims_supported" => [ + "iss", + "ver", + "sub", + "aud", + "iat", + "exp", + "jti", + "auth_time", + "amr", + "idp", + "nonce", + "name", + "nickname", + "preferred_username", + "given_name", + "middle_name", + "family_name", + "email", + "email_verified", + "profile", + "zoneinfo", + "locale", + "address", + "phone_number", + "picture", + "website", + "gender", + "birthdate", + "updated_at", + "at_hash", + "c_hash" + ], + "code_challenge_methods_supported" => ["S256"], + "device_authorization_endpoint" => "https://common.okta.com/oauth2/v1/device/authorize", + "end_session_endpoint" => "https://common.okta.com/oauth2/v1/logout", + "grant_types_supported" => [ + "authorization_code", + "implicit", + "refresh_token", + "password", + "urn:ietf:params:oauth:grant-type:device_code" + ], + "id_token_signing_alg_values_supported" => ["RS256"], + "introspection_endpoint" => "https://common.okta.com/oauth2/v1/introspect", + "introspection_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "client_secret_post", + "client_secret_jwt", + "private_key_jwt", + "none" + ], + "issuer" => "https://common.okta.com", + "jwks_uri" => "https://common.okta.com/oauth2/v1/keys", + "registration_endpoint" => "https://common.okta.com/oauth2/v1/clients", + "request_object_signing_alg_values_supported" => [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512" + ], + "request_parameter_supported" => true, + "response_modes_supported" => ["query", "fragment", "form_post", "okta_post_message"], + "response_types_supported" => [ + "code", + "id_token", + "code id_token", + "code token", + "id_token token", + "code id_token token" + ], + "revocation_endpoint" => "https://common.okta.com/oauth2/v1/revoke", + "revocation_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "client_secret_post", + "client_secret_jwt", + "private_key_jwt", + "none" + ], + "scopes_supported" => [ + "openid", + "email", + "profile", + "address", + "phone", + "offline_access", + "groups" + ], + "subject_types_supported" => ["public"], + "token_endpoint" => "https://common.okta.com/oauth2/v1/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "client_secret_post", + "client_secret_jwt", + "private_key_jwt", + "none" + ], + "userinfo_endpoint" => "https://common.okta.com/oauth2/v1/userinfo" + }, + headers: [ + {"Date", "Sat, 12 Nov 2022 19:40:24 GMT"}, + {"Content-Type", "application/json"}, + {"Connection", "keep-alive"}, + {"Server", "nginx"}, + {"Public-Key-Pins-Report-Only", + "pin-sha256=\"r5EfzZxQVvQpKo3AgYRaT7X2bDO/kj3ACwmxfdT2zt8=\"; pin-sha256=\"MaqlcUgk2mvY/RFSGeSwBRkI+rZ6/dxe/DuQfBT/vnQ=\"; pin-sha256=\"72G5IEvDEWn+EThf3qjR7/bQSWaS2ZSLqolhnO6iyJI=\"; pin-sha256=\"rrV6CLCCvqnk89gWibYT0JO6fNQ8cCit7GGoiVTjCOg=\"; max-age=60; report-uri=\"https://okta.report-uri.com/r/default/hpkp/reportOnly\""}, + {"x-xss-protection", "0"}, + {"p3p", "CP=\"HONK\""}, + {"content-security-policy", + "default-src 'self' common.okta.com *.oktacdn.com; connect-src 'self' common.okta.com common-admin.okta.com *.oktacdn.com *.mixpanel.com *.mapbox.com app.pendo.io data.pendo.io pendo-static-5634101834153984.storage.googleapis.com pendo-static-5391521872216064.storage.googleapis.com common.kerberos.okta.com https://oinmanager.okta.com data:; script-src 'unsafe-inline' 'unsafe-eval' 'self' common.okta.com *.oktacdn.com; style-src 'unsafe-inline' 'self' common.okta.com *.oktacdn.com app.pendo.io cdn.pendo.io pendo-static-5634101834153984.storage.googleapis.com pendo-static-5391521872216064.storage.googleapis.com; frame-src 'self' common.okta.com common-admin.okta.com login.okta.com; img-src 'self' common.okta.com *.oktacdn.com *.tiles.mapbox.com *.mapbox.com app.pendo.io data.pendo.io cdn.pendo.io pendo-static-5634101834153984.storage.googleapis.com pendo-static-5391521872216064.storage.googleapis.com data: blob:; font-src 'self' common.okta.com data: *.oktacdn.com fonts.gstatic.com; frame-ancestors 'self'"}, + {"expect-ct", + "report-uri=\"https://oktaexpectct.report-uri.com/r/t/ct/reportOnly\", max-age=0"}, + {"cache-control", "max-age=86400, must-revalidate"}, + {"expires", "Sun, 13 Nov 2022 19:40:24 GMT"}, + {"vary", "Origin"}, + {"x-content-type-options", "nosniff"}, + {"Strict-Transport-Security", "max-age=315360000; includeSubDomains"}, + {"X-Okta-Request-Id", "Y2_2qFFQ3zhcoZh3312xKwAAARU"} + ] +} diff --git a/test/fixtures/http/okta/jwks.exs b/test/fixtures/http/okta/jwks.exs new file mode 100644 index 0000000..8180266 --- /dev/null +++ b/test/fixtures/http/okta/jwks.exs @@ -0,0 +1,47 @@ +%{ + status_code: 200, + body: %{ + "keys" => [ + %{ + "alg" => "RS256", + "e" => "AQAB", + "kid" => "sljES1Bh0VMGwUpPfFunCVAzvLRdea7WluQfeV6zWTQ", + "kty" => "RSA", + "n" => + "7d7C4UL4HujzZesiEOtQUZcusHzBoUJVJXarHz0x9vMzQ1PYaGwivWJimnBHQXw6r1T05PQxOik9NnxvtPF7snPxVzDtDgrqzjd3WoYWmFiWrJz1vwebiioeFQKla7GkxfoE4cNFlIzi-i9y76zWwR3R3u0hUzHyY5XZcIBWnnInYKFACCNES7lqKu4qE3XTluJiP-WvDo79iFM67V2ZDowOWPLKoJQI0CA9l1Nkklaq32bjtMD9njl1Pl1KOKqZNyn1RzkmG0V15CYR959EEU7_Pl1LrrxGcgS-wafoyKILaJxEyeMWd3_SM0_anSAVvyUA46PYefcdEuURp-r4vQ", + "use" => "sig" + }, + %{ + "alg" => "RS256", + "e" => "AQAB", + "kid" => "TgT2Cxt-D-sVMlgTm3On7DLee8ljXgYhdzkrPkTc4sY", + "kty" => "RSA", + "n" => + "3VQaNpbZ67tiVkNqLF4j5skeys9D0Vzfu8NpOE8ZRVBmzLXa-FZ65cm6IGObMHhyDEBT4MTD3DLTRufVaiUbGcvrx5qee9eV_U3AwxSkRBEuHi-4HvUGkbvvXJpaoIHrNONZ_qLnL-GQm-kWTr3BaaRQ8lmMQjh3G4aCzzsFCpMT2HEe1GwCWDGTS_tDGt7oyueOtaPYFP3YLW7n8GW0-nVdiFxXYU0F-l9BF95YgYSut18r6xKk4EfHY4VNC6Y-qbldyEJ0iGdUT5sa07d7q6ocwDRO6iB07j65v43-A-H5vcew9N1JvFXXiJZ4Qn2UhzAGgUm6-Exr6fOko0W3zw", + "use" => "sig" + } + ] + }, + headers: [ + {"Date", "Sat, 12 Nov 2022 19:41:02 GMT"}, + {"Content-Type", "application/json"}, + {"Connection", "keep-alive"}, + {"Server", "nginx"}, + {"Public-Key-Pins-Report-Only", + "pin-sha256=\"r5EfzZxQVvQpKo3AgYRaT7X2bDO/kj3ACwmxfdT2zt8=\"; pin-sha256=\"MaqlcUgk2mvY/RFSGeSwBRkI+rZ6/dxe/DuQfBT/vnQ=\"; pin-sha256=\"72G5IEvDEWn+EThf3qjR7/bQSWaS2ZSLqolhnO6iyJI=\"; pin-sha256=\"rrV6CLCCvqnk89gWibYT0JO6fNQ8cCit7GGoiVTjCOg=\"; max-age=60; report-uri=\"https://okta.report-uri.com/r/default/hpkp/reportOnly\""}, + {"x-xss-protection", "0"}, + {"p3p", "CP=\"HONK\""}, + {"content-security-policy", + "default-src 'self' common.okta.com *.oktacdn.com; connect-src 'self' common.okta.com common-admin.okta.com *.oktacdn.com *.mixpanel.com *.mapbox.com app.pendo.io data.pendo.io pendo-static-5634101834153984.storage.googleapis.com pendo-static-5391521872216064.storage.googleapis.com common.kerberos.okta.com https://oinmanager.okta.com data:; script-src 'unsafe-inline' 'unsafe-eval' 'self' common.okta.com *.oktacdn.com; style-src 'unsafe-inline' 'self' common.okta.com *.oktacdn.com app.pendo.io cdn.pendo.io pendo-static-5634101834153984.storage.googleapis.com pendo-static-5391521872216064.storage.googleapis.com; frame-src 'self' common.okta.com common-admin.okta.com login.okta.com; img-src 'self' common.okta.com *.oktacdn.com *.tiles.mapbox.com *.mapbox.com app.pendo.io data.pendo.io cdn.pendo.io pendo-static-5634101834153984.storage.googleapis.com pendo-static-5391521872216064.storage.googleapis.com data: blob:; font-src 'self' common.okta.com data: *.oktacdn.com fonts.gstatic.com; frame-ancestors 'self'; report-uri https://oktacsp.report-uri.com/r/t/csp/enforce; report-to csp"}, + {"report-to", + "{\"group\":\"csp\",\"max_age\":31536000,\"endpoints\":[{\"url\":\"https://oktacsp.report-uri.com/a/t/g\"}],\"include_subdomains\":true}"}, + {"expect-ct", + "report-uri=\"https://oktaexpectct.report-uri.com/r/t/ct/reportOnly\", max-age=0"}, + {"cache-control", "max-age=3828277, must-revalidate"}, + {"expires", "Tue, 27 Dec 2022 03:05:39 GMT"}, + {"vary", "Origin"}, + {"x-content-type-options", "nosniff"}, + {"Strict-Transport-Security", "max-age=315360000; includeSubDomains"}, + {"X-Okta-Request-Id", "Y2_2zY7e1--88ktkBh3QSgAABkg"} + ] +} diff --git a/test/fixtures/http/onelogin/discovery_document.exs b/test/fixtures/http/onelogin/discovery_document.exs new file mode 100644 index 0000000..7818b0e --- /dev/null +++ b/test/fixtures/http/onelogin/discovery_document.exs @@ -0,0 +1,76 @@ +%{ + status_code: 200, + body: %{ + "acr_values_supported" => ["onelogin:nist:level:1:re-auth"], + "authorization_endpoint" => "https://common.onelogin.com/oidc/2/auth", + "claim_types_supported" => ["normal"], + "claims_parameter_supported" => true, + "claims_supported" => [ + "sub", + "email", + "preferred_username", + "name", + "updated_at", + "given_name", + "family_name", + "locale", + "groups", + "email_verified", + "params", + "phone_number", + "acr", + "sid", + "auth_time", + "iss" + ], + "code_challenge_methods_supported" => ["S256"], + "end_session_endpoint" => "https://common.onelogin.com/oidc/2/logout", + "grant_types_supported" => [ + "authorization_code", + "implicit", + "refresh_token", + "client_credentials", + "password" + ], + "id_token_signing_alg_values_supported" => ["HS256", "RS256", "PS256"], + "introspection_endpoint" => "https://common.onelogin.com/oidc/2/token/introspection", + "introspection_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "client_secret_post", + "none" + ], + "issuer" => "https://common.onelogin.com/oidc/2", + "jwks_uri" => "https://common.onelogin.com/oidc/2/certs", + "registration_endpoint" => "https://common.onelogin.com/oidc/2/register", + "request_parameter_supported" => false, + "request_uri_parameter_supported" => false, + "response_modes_supported" => ["form_post", "fragment", "query"], + "response_types_supported" => ["code", "id_token token", "id_token"], + "revocation_endpoint" => "https://common.onelogin.com/oidc/2/token/revocation", + "revocation_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "client_secret_post", + "none" + ], + "scopes_supported" => ["openid", "name", "profile", "groups", "email", "params", "phone"], + "subject_types_supported" => ["public"], + "token_endpoint" => "https://common.onelogin.com/oidc/2/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "client_secret_post", + "none" + ], + "userinfo_endpoint" => "https://common.onelogin.com/oidc/2/me", + "userinfo_signing_alg_values_supported" => ["HS256", "RS256", "PS256"] + }, + headers: [ + {"Date", "Sat, 12 Nov 2022 19:41:55 GMT"}, + {"Content-Type", "application/json; charset=utf-8"}, + {"Connection", "keep-alive"}, + {"vary", "Origin"}, + {"strict-transport-security", "max-age=63072000; includeSubDomains;"}, + {"x-content-type-options", "nosniff"}, + {"set-cookie", "ol_oidc_canary_115=false; path=/; domain=.onelogin.com; HttpOnly; Secure"}, + {"cache-control", "private"} + ] +} diff --git a/test/fixtures/http/onelogin/jwks.exs b/test/fixtures/http/onelogin/jwks.exs new file mode 100644 index 0000000..e188733 --- /dev/null +++ b/test/fixtures/http/onelogin/jwks.exs @@ -0,0 +1,26 @@ +%{ + status_code: 200, + body: %{ + "keys" => [ + %{ + "e" => "AQAB", + "kid" => "JRcO4nxs5jgc8YdN7I2hLO4V_ql1bdoiMXmcYgHm4Hs", + "kty" => "RSA", + "n" => + "z8fZszkUNh1y1iSI6ZCkrwoZx1ZcFuQEngI8G_9VPjJXupqbgXedsV0YqDzQzYmdXd_lLb_OYWdyAP1FV6d2d4PfVjw4rGLqgYN5hEPFYqDEusiKtXyeh38xl37Nb8LGTX1qdstZjcXRo2YQ64W4UyuMko_TGOCxRNJg1fAfxRt1yV_ZeFV_93BMNjubV2D7kvpzaStJmYJi8A6QHqaqHaQkxAvYhJVi9XDajD3vvUlTVyOjURAnuaByA749glGBio5N9AfFTnYbHbeBOK3VJi6EJZzsuj3-5P4GUTYnSfrScs_kblaoeqt4GkExJqMZXGJTfGnX2UbYAjGHSTAoQw", + "status" => "active", + "use" => "sig" + } + ] + }, + headers: [ + {"Date", "Sat, 12 Nov 2022 19:42:26 GMT"}, + {"Content-Type", "application/json; charset=utf-8"}, + {"Connection", "keep-alive"}, + {"vary", "Origin"}, + {"strict-transport-security", "max-age=63072000; includeSubDomains;"}, + {"x-content-type-options", "nosniff"}, + {"set-cookie", "ol_oidc_canary_115=true; path=/; domain=.onelogin.com; HttpOnly; Secure"}, + {"cache-control", "private"} + ] +} diff --git a/test/fixtures/http/vault/discovery_document.exs b/test/fixtures/http/vault/discovery_document.exs new file mode 100644 index 0000000..8890321 --- /dev/null +++ b/test/fixtures/http/vault/discovery_document.exs @@ -0,0 +1,38 @@ +%{ + status_code: 200, + body: %{ + "authorization_endpoint" => + "http://0.0.0.0:8200/ui/vault/identity/oidc/provider/default/authorize", + "claims_supported" => [], + "grant_types_supported" => ["authorization_code"], + "id_token_signing_alg_values_supported" => [ + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + "EdDSA" + ], + "issuer" => "http://0.0.0.0:8200/v1/identity/oidc/provider/default", + "jwks_uri" => "http://0.0.0.0:8200/v1/identity/oidc/provider/default/.well-known/keys", + "request_parameter_supported" => false, + "request_uri_parameter_supported" => false, + "response_types_supported" => ["code"], + "scopes_supported" => ["openid"], + "subject_types_supported" => ["public"], + "token_endpoint" => "http://0.0.0.0:8200/v1/identity/oidc/provider/default/token", + "token_endpoint_auth_methods_supported" => [ + "none", + "client_secret_basic", + "client_secret_post" + ], + "userinfo_endpoint" => "http://0.0.0.0:8200/v1/identity/oidc/provider/default/userinfo" + }, + headers: [ + {"Cache-Control", "max-age=3600"}, + {"Content-Type", "application/json"}, + {"Strict-Transport-Security", "max-age=31536000; includeSubDomains"}, + {"Date", "Sat, 12 Nov 2022 19:50:54 GMT"} + ] +} diff --git a/test/fixtures/http/vault/jwks.exs b/test/fixtures/http/vault/jwks.exs new file mode 100644 index 0000000..c11c772 --- /dev/null +++ b/test/fixtures/http/vault/jwks.exs @@ -0,0 +1,31 @@ +%{ + status_code: 200, + body: %{ + "keys" => [ + %{ + "alg" => "RS256", + "e" => "AQAB", + "kid" => "cfdb8380-cbe8-a9f6-fcd4-51abcec905ce", + "kty" => "RSA", + "n" => + "93riTBOuVsRDQPoXK-mJSDxbKj_m2nBZH6k47wFAzo1qPkcQqz6pcJMPLAZgBMuKUjXi0BNPwn7FO0jzyLA2dnMrk1Mu3qBBKfC6gD0TPe5r4EaQvtCuHzVr8sHd6io1FIhG4d7VmYK7wAtIwGwBix_NS7qitZsi-B8JlkttDXa_HsllB_81OCdgRctyHt3Up5RE7hjMEOn8kQv_UCSIexZ3GGQ4adkDe9Ufq-pfQCaBWxhxr6Ekr_P2beb-YWuEBlJdTFRR6jcluei5LFbseLcg5cYCfACNH10GAlURskHXXbfNrC8-9I7fgTlcntSSnsd0qFN-P6FQBd4aSwF1_Q", + "use" => "sig" + }, + %{ + "alg" => "RS256", + "e" => "AQAB", + "kid" => "5e18c268-79c7-b761-16fd-3b241fa33336", + "kty" => "RSA", + "n" => + "0YkIT7NWG0cWdIpV8GqPpn8b7x7N6AbmT1ZTvc-ozzVa8O-x4hscV2wCl1_UYjWwVVYQAUqg6NeqWTk3MGHfzTAxcSQALV4wMJkG98fCcazQ3KZZCpMfnaZymW5mEZyDMaZYaqanIcAlU0s6zCw7A145ykoeGUyd6j2u1_BC1LoqSLWz-8U2iJFZH2nq7wm9lVmzb2Wwe65zvvj6rg_HajgkJ2OBOEgnErZtZoJxV0sVsP87aO-KhZk97XAu0Y8sLzh7HtMMWHV_QrNTEUvVrAFzMDgAGC9Jc6nVM3idS0mdl70SWXQE_IjCrMf5ZXOliw1G2AMPXJVjae99l8fZKw", + "use" => "sig" + } + ] + }, + headers: [ + {"Cache-Control", "no-store"}, + {"Content-Type", "application/json"}, + {"Strict-Transport-Security", "max-age=31536000; includeSubDomains"}, + {"Date", "Sat, 12 Nov 2022 19:56:52 GMT"} + ] +} diff --git a/test/fixtures/rsa/jwk1.exs b/test/fixtures/jwks/jwk.exs similarity index 100% rename from test/fixtures/rsa/jwk1.exs rename to test/fixtures/jwks/jwk.exs diff --git a/test/fixtures/rsa/jwks.exs b/test/fixtures/jwks/jwks.exs similarity index 100% rename from test/fixtures/rsa/jwks.exs rename to test/fixtures/jwks/jwks.exs diff --git a/test/fixtures/rsa/jwk2.exs b/test/fixtures/rsa/jwk2.exs deleted file mode 100644 index d4ccf70..0000000 --- a/test/fixtures/rsa/jwk2.exs +++ /dev/null @@ -1,11 +0,0 @@ -%{ - "alg" => "RS256", - "d" => - "ezKeapIKr6YkDoWKarXmj3dytjlS1_kuSHCQs9HLFftqR0MYIJICcBeXz2mmYUjkl0cpnce_xDCS2IOfV6KUpRhkNsOWHNtjqKXs-YHMapffgo_zeoZrdIdiDEqvm1X4D4qg8qbN2IGhhFsb8WO-aP3otSsSXreA7ibaDusxBNbxxWkaEwikBy61A1MTu5CR5GLYgsgQsZnawxJqDo1-QH6b-5E05XHD2PHG_xADLePC9zDsCr0kpCw1c-DuJ0SdKzV-OxoCbHuTRRP8Df7WztsLyFxYtDdZhgYOGmofPa_w8L6jLZTecixhBuSzV3OSGxua7eGd7MQDHf1vcpbh", - "e" => "AQAB", - "kid" => "example@dockyard.com", - "kty" => "RSA", - "n" => - "iUteZhwFt_wvc8QR5rfh7zShXqwlRMMwB-9kst-A2ixeUXrBkwqQrEoAy_FUOFgQTIb8gRFvzlc7oB6cWC-OFdt5XLQsBV6fTtnkEVZtVdze22V42qz3y0l5lztKGXJYjLSbB6kUF1SiT7wpbT7J-M9bSkxwdVWQYAMZsPg71IZ7qX4JyxcvUnqnXEseHMunsWcGGdqR6OcVQylAlKBi_biKSjbXVavWjXbk25-IDs6YQxNGu7RF-DZoDNYSyEFZWN9pY_wdPZy2RvgW_5okNjffcecg-HPMHRFHsKbIsHlX1XLJ7gbY79MtCTFQym4ZcrJqn42r99dw6242KoAB", - "use" => "sig" -} diff --git a/test/openid_connect/application_test.exs b/test/openid_connect/application_test.exs new file mode 100644 index 0000000..b771b68 --- /dev/null +++ b/test/openid_connect/application_test.exs @@ -0,0 +1,26 @@ +defmodule OpenIDConnect.ApplicationTest do + use ExUnit.Case, async: true + import OpenIDConnect.Application + + test "allows to override Finch transport options" do + assert children() == [ + {Finch, + [name: OpenIDConnect.Finch, pools: %{default: [conn_opts: [transport_opts: []]]}]}, + OpenIDConnect.Document.Cache + ] + + transport_opts = [cacertfile: "foo.pem"] + Application.put_env(:openid_connect, :finch_transport_opts, transport_opts) + + assert children() == [ + {Finch, + [ + name: OpenIDConnect.Finch, + pools: %{default: [conn_opts: [transport_opts: transport_opts]]} + ]}, + OpenIDConnect.Document.Cache + ] + + Application.put_env(:openid_connect, :finch_transport_opts, []) + end +end diff --git a/test/openid_connect/document/cache_test.exs b/test/openid_connect/document/cache_test.exs new file mode 100644 index 0000000..a4b3027 --- /dev/null +++ b/test/openid_connect/document/cache_test.exs @@ -0,0 +1,139 @@ +defmodule OpenIDConnect.Document.CacheTest do + use ExUnit.Case, async: true + import OpenIDConnect.Document.Cache + + @valid_document %OpenIDConnect.Document{ + authorization_endpoint: "https://common.auth0.com/authorize", + claims_supported: [ + "aud", + "auth_time", + "created_at", + "email", + "email_verified", + "exp", + "family_name", + "given_name", + "iat", + "identities", + "iss", + "name", + "nickname", + "phone_number", + "picture", + "sub" + ], + end_session_endpoint: nil, + expires_at: DateTime.utc_now(), + jwks: %JOSE.JWK{}, + raw: "", + response_types_supported: [ + "code", + "token", + "id_token", + "code token", + "code id_token", + "id_token token", + "code id_token token" + ], + token_endpoint: "https://common.auth0.com/oauth/token" + } + + describe "put/2" do + test "persists a documents to the cache" do + uri = uniq_uri() + document = %{@valid_document | expires_at: DateTime.utc_now() |> DateTime.add(60, :second)} + + put(uri, document) + + assert %{^uri => {_ref, _last_fetched_at, ^document}} = flush() + end + + test "does not persist expired documents" do + uri = uniq_uri() + document = %{@valid_document | expires_at: DateTime.utc_now() |> DateTime.add(-60, :second)} + + put(uri, document) + + refute Map.has_key?(flush(), uri) + end + + test "schedules document removal and removes it once it's expired" do + uri = uniq_uri() + document = %{@valid_document | expires_at: DateTime.utc_now() |> DateTime.add(60, :second)} + + put(uri, document) + + assert %{^uri => {ref, _last_fetched_at, _document}} = flush() + assert Process.read_timer(ref) in 58_000..62_000 + + send(OpenIDConnect.Document.Cache, {:remove, uri}) + refute Map.has_key?(flush(), uri) + end + end + + describe "fetch/1" do + test "returns error when there is no cache" do + uri = uniq_uri() + assert fetch(uri) == :error + end + + test "returns cached documents" do + uri = uniq_uri() + document = %{@valid_document | expires_at: DateTime.utc_now() |> DateTime.add(60, :second)} + put(uri, document) + + assert {:ok, cached_document} = fetch(uri) + assert document == cached_document + end + + test "does not return documents that already expired" do + uri = uniq_uri() + now = DateTime.utc_now() + document = %{@valid_document | expires_at: DateTime.add(now, -1, :second)} + state = %{uri => {nil, now, document}} + + assert handle_call({:fetch, uri}, self(), state) == {:reply, :error, %{}} + end + end + + describe ":gc" do + test "doesn't do anything when cache is empty" do + {:ok, pid} = start_link(name: :gc_test1) + assert Enum.empty?(flush(pid)) + send(pid, :gc) + assert flush(pid) == %{} + end + + test "removes excessive entries from cache" do + {:ok, pid} = start_link(name: :gc_test2) + + documents = + for i <- 1..2000 do + expires_at = DateTime.utc_now() |> DateTime.add(60 + i, :second) + document = %{@valid_document | expires_at: expires_at} + put(pid, uniq_uri(), document) + document + end + + assert Enum.count(flush(pid)) == 2000 + + send(pid, :gc) + + assert state = flush(pid) + assert Enum.count(state) == 1000 + + {_uri, {_ref, _last_fetched_at, document}} = + Enum.min_by( + state, + fn {_uri, {_ref, last_fetched_at, _document}} -> + last_fetched_at + end, + DateTime + ) + + assert document.expires_at == Enum.at(documents, 1000).expires_at + end + end + + defp uniq_uri, do: "http://example.com:#{System.unique_integer([:positive])}" +end diff --git a/test/openid_connect/document_test.exs b/test/openid_connect/document_test.exs new file mode 100644 index 0000000..a96aabe --- /dev/null +++ b/test/openid_connect/document_test.exs @@ -0,0 +1,235 @@ +defmodule OpenIDConnect.DocumentTest do + use ExUnit.Case, async: true + import OpenIDConnect.Fixtures + import OpenIDConnect.Document + + describe "fetch_document/1" do + test "returns error when URL is nil" do + assert fetch_document(nil) == {:error, :invalid_discovery_document_uri} + end + + test "returns valid document from a given url" do + {_bypass, uri} = start_fixture("auth0") + + assert {:ok, document} = fetch_document(uri) + + assert %OpenIDConnect.Document{ + authorization_endpoint: "https://common.auth0.com/authorize", + claims_supported: [ + "aud", + "auth_time", + "created_at", + "email", + "email_verified", + "exp", + "family_name", + "given_name", + "iat", + "identities", + "iss", + "name", + "nickname", + "phone_number", + "picture", + "sub" + ], + end_session_endpoint: nil, + expires_at: expires_at, + jwks: %JOSE.JWK{}, + raw: _json, + response_types_supported: [ + "code", + "token", + "id_token", + "code token", + "code id_token", + "id_token token", + "code id_token token" + ], + token_endpoint: "https://common.auth0.com/oauth/token" + } = document + + assert DateTime.diff(expires_at, DateTime.utc_now()) in (60 * 60 - 10)..(60 * 60 + 10) + end + + test "supports all gateway providers" do + for provider <- [ + "auth0", + "azure", + "google", + "keycloak", + "okta", + "onelogin", + "vault", + "cognito" + ] do + {_bypass, uri} = start_fixture(provider) + assert {:ok, document} = fetch_document(uri) + assert not is_nil(document.jwks) + end + end + + test "caches the document" do + {_bypass, uri} = start_fixture("auth0") + + assert {:ok, document} = fetch_document(uri) + assert {:ok, ^document} = fetch_document(uri) + end + + test "returns error when JSWKS is invalid" do + invalid_jwks = %{ + "keys" => [ + %{ + "kid" => "1234example=", + "alg" => "RS256", + "kty" => "RSA", + "e" => "AQAB", + "n" => "1234567890", + "use" => "sig" + }, + %{ + "kid" => "5678example=", + "alg" => "RS256", + "kty" => "RSA", + "e" => "AQAB", + "n" => "987654321", + "use" => "sig" + } + ] + } + + {_bypass, uri} = start_fixture("auth0", %{"jwks" => invalid_jwks}) + + assert fetch_document(uri) == {:error, :invalid_jwks_certificates} + end + + test "handles non 2XX response codes" do + bypass = Bypass.open() + + Bypass.expect_once(bypass, "GET", "/.well-known/discovery-document.json", fn conn -> + Plug.Conn.resp(conn, 401, "{}") + end) + + uri = "http://localhost:#{bypass.port}/.well-known/discovery-document.json" + + assert fetch_document(uri) == {:error, {401, "{}"}} + end + + test "handles invalid responses" do + bypass = Bypass.open() + + Bypass.expect_once(bypass, "GET", "/.well-known/discovery-document.json", fn conn -> + Plug.Conn.resp(conn, 200, "{}") + end) + + uri = "http://localhost:#{bypass.port}/.well-known/discovery-document.json" + + assert fetch_document(uri) == {:error, :invalid_document} + end + + test "handles response errors" do + bypass = Bypass.open() + uri = "http://localhost:#{bypass.port}/.well-known/discovery-document.json" + Bypass.down(bypass) + + assert fetch_document(uri) == {:error, %Mint.TransportError{reason: :econnrefused}} + end + + test "takes expiration date from Cache-Control headers of the discovery document" do + bypass = Bypass.open() + endpoint = "http://localhost:#{bypass.port}/" + provider = "vault" + + Bypass.expect_once(bypass, "GET", "/.well-known/jwks.json", fn conn -> + {status_code, body, headers} = load_fixture(provider, "jwks") + send_response(conn, status_code, body, headers) + end) + + Bypass.expect_once(bypass, "GET", "/.well-known/discovery-document.json", fn conn -> + {status_code, body, headers} = load_fixture(provider, "discovery_document") + body = Map.merge(body, %{"jwks_uri" => "#{endpoint}.well-known/jwks.json"}) + + headers = + for {k, v} <- headers, + k = String.downcase(k), + k not in ["cache-control", "age"] do + {k, v} + end + + headers = headers ++ [{"cache-control", "max-age=300"}] + send_response(conn, status_code, body, headers) + end) + + uri = "#{endpoint}.well-known/discovery-document.json" + + assert {:ok, document} = fetch_document(uri) + expected_expires_at = DateTime.add(DateTime.utc_now(), 300, :second) + assert DateTime.diff(document.expires_at, expected_expires_at) in -3..3 + end + + test "takes expiration date from Cache-Control and Age headers of the discovery document" do + bypass = Bypass.open() + endpoint = "http://localhost:#{bypass.port}/" + provider = "vault" + + Bypass.expect_once(bypass, "GET", "/.well-known/jwks.json", fn conn -> + {status_code, body, headers} = load_fixture(provider, "jwks") + send_response(conn, status_code, body, headers) + end) + + Bypass.expect_once(bypass, "GET", "/.well-known/discovery-document.json", fn conn -> + {status_code, body, headers} = load_fixture(provider, "discovery_document") + body = Map.merge(body, %{"jwks_uri" => "#{endpoint}.well-known/jwks.json"}) + + headers = + for {k, v} <- headers, + k = String.downcase(k), + k not in ["cache-control", "age"] do + {k, v} + end + + headers = headers ++ [{"cache-control", "max-age=300"}, {"age", "100"}] + send_response(conn, status_code, body, headers) + end) + + uri = "#{endpoint}.well-known/discovery-document.json" + + assert {:ok, document} = fetch_document(uri) + expected_expires_at = DateTime.add(DateTime.utc_now(), 300 - 100, :second) + assert DateTime.diff(document.expires_at, expected_expires_at) in -3..3 + end + + test "takes expiration date from Cache-Control and Age headers of the jwks document" do + bypass = Bypass.open() + endpoint = "http://localhost:#{bypass.port}/" + provider = "vault" + + Bypass.expect_once(bypass, "GET", "/.well-known/jwks.json", fn conn -> + {status_code, body, headers} = load_fixture(provider, "jwks") + + headers = + for {k, v} <- headers, + k = String.downcase(k), + k not in ["cache-control", "age"] do + {k, v} + end + + headers = headers ++ [{"cache-control", "max-age=300"}, {"age", "100"}] + send_response(conn, status_code, body, headers) + end) + + Bypass.expect_once(bypass, "GET", "/.well-known/discovery-document.json", fn conn -> + {status_code, body, headers} = load_fixture(provider, "discovery_document") + body = Map.merge(body, %{"jwks_uri" => "#{endpoint}.well-known/jwks.json"}) + + send_response(conn, status_code, body, headers) + end) + + uri = "#{endpoint}.well-known/discovery-document.json" + + assert {:ok, document} = fetch_document(uri) + expected_expires_at = DateTime.add(DateTime.utc_now(), 300 - 100, :second) + assert DateTime.diff(document.expires_at, expected_expires_at) in -3..3 + end + end +end diff --git a/test/openid_connect/worker_test.exs b/test/openid_connect/worker_test.exs deleted file mode 100644 index 281d3a9..0000000 --- a/test/openid_connect/worker_test.exs +++ /dev/null @@ -1,101 +0,0 @@ -defmodule OpenIDConnect.WorkerTest do - use ExUnit.Case - import Mox - - setup :set_mox_global - setup :verify_on_exit! - - @google_document Fixtures.load(:google, :discovery_document) - @google_certs Fixtures.load(:google, :certs) - - alias OpenIDConnect.{HTTPClientMock} - - test "starting with :ignore does nothing" do - :ignore = OpenIDConnect.Worker.start_link(:ignore) - end - - test "starting with a single provider will retrieve the necessary documents" do - mock_http_requests() - - config = Application.get_env(:openid_connect, :providers) - - {:ok, pid} = start_supervised({OpenIDConnect.Worker, config}) - - state = :sys.get_state(pid) - - expected_document = - @google_document - |> elem(1) - |> Map.get(:body) - |> Jason.decode!() - |> OpenIDConnect.normalize_discovery_document() - - expected_jwk = - @google_certs - |> elem(1) - |> Map.get(:body) - |> Jason.decode!() - |> JOSE.JWK.from() - - assert expected_document == get_in(state, [:google, :documents, :discovery_document]) - assert expected_jwk == get_in(state, [:google, :documents, :jwk]) - end - - test "worker can respond to a call for the config" do - mock_http_requests() - - config = Application.get_env(:openid_connect, :providers) - - {:ok, pid} = start_supervised({OpenIDConnect.Worker, config}) - - google_config = GenServer.call(pid, {:config, :google}) - - assert get_in(config, [:google]) == google_config - end - - test "worker can respond to a call for a provider's discovery document" do - mock_http_requests() - - config = Application.get_env(:openid_connect, :providers) - - {:ok, pid} = start_supervised({OpenIDConnect.Worker, config}) - - discovery_document = GenServer.call(pid, {:discovery_document, :google}) - - expected_document = - @google_document - |> elem(1) - |> Map.get(:body) - |> Jason.decode!() - |> OpenIDConnect.normalize_discovery_document() - - assert expected_document == discovery_document - end - - test "worker can respond to a call for a provider's jwk" do - mock_http_requests() - - config = Application.get_env(:openid_connect, :providers) - - {:ok, pid} = start_supervised({OpenIDConnect.Worker, config}) - - jwk = GenServer.call(pid, {:jwk, :google}) - - expected_jwk = - @google_certs - |> elem(1) - |> Map.get(:body) - |> Jason.decode!() - |> JOSE.JWK.from() - - assert expected_jwk == jwk - end - - defp mock_http_requests do - HTTPClientMock - |> expect(:get, fn "https://accounts.google.com/.well-known/openid-configuration", _headers, _opts -> - @google_document - end) - |> expect(:get, fn "https://www.googleapis.com/oauth2/v3/certs", _headers, _opts -> @google_certs end) - end -end diff --git a/test/openid_connect_test.exs b/test/openid_connect_test.exs index b964560..277d48c 100644 --- a/test/openid_connect_test.exs +++ b/test/openid_connect_test.exs @@ -1,533 +1,604 @@ defmodule OpenIDConnectTest do - use ExUnit.Case - import Mox - - setup :set_mox_global - setup :verify_on_exit! - setup :set_jose_json_lib - - @google_document Fixtures.load(:google, :discovery_document) - @google_certs Fixtures.load(:google, :certs) - - alias OpenIDConnect.{HTTPClientMock, MockWorker} - - test "README install version check" do - app = :openid_connect - - app_version = "#{Application.spec(app, :vsn)}" - readme = File.read!("README.md") - [_, readme_versions] = Regex.run(~r/{:#{app}, "(.+)"}/, readme) - - assert Version.match?( - app_version, - readme_versions - ), - """ - Install version constraint in README.md does not match to current app version. - Current App Version: #{app_version} - Readme Install Versions: #{readme_versions} - """ - end + use ExUnit.Case, async: true + import OpenIDConnect.Fixtures + import OpenIDConnect + + @config %{ + discovery_document_uri: nil, + client_id: "CLIENT_ID", + client_secret: "CLIENT_SECRET", + redirect_uri: "https://localhost/redirect_uri", + response_type: "code id_token token", + scope: "openid email profile" + } + + describe "authorization_uri/2" do + test "generates authorization url with scope and response_type as binaries" do + {_bypass, uri} = start_fixture("google") + config = %{@config | discovery_document_uri: uri} + + assert authorization_uri(config) == + {:ok, + "https://accounts.google.com/o/oauth2/v2/auth?" <> + "client_id=CLIENT_ID" <> + "&redirect_uri=https%3A%2F%2Flocalhost%2Fredirect_uri" <> + "&response_type=code+id_token+token" <> + "&scope=openid+email+profile"} + end - describe "update_documents" do - test "when the new documents are retrieved successfully" do - config = [ - discovery_document_uri: "https://accounts.google.com/.well-known/openid-configuration" - ] - - HTTPClientMock - |> expect(:get, fn "https://accounts.google.com/.well-known/openid-configuration", - _headers, - _opts -> - @google_document - end) - |> expect(:get, fn "https://www.googleapis.com/oauth2/v3/certs", _headers, _opts -> - @google_certs - end) + test "generates authorization url with scope as enum" do + {_bypass, uri} = start_fixture("google") + config = %{@config | discovery_document_uri: uri, scope: ["openid", "email", "profile"]} + + assert authorization_uri(config) == + {:ok, + "https://accounts.google.com/o/oauth2/v2/auth?" <> + "client_id=CLIENT_ID" <> + "&redirect_uri=https%3A%2F%2Flocalhost%2Fredirect_uri" <> + "&response_type=code+id_token+token" <> + "&scope=openid+email+profile"} + end - expected_document = - @google_document - |> elem(1) - |> Map.get(:body) - |> Jason.decode!() - |> OpenIDConnect.normalize_discovery_document() - - expected_jwk = - @google_certs - |> elem(1) - |> Map.get(:body) - |> Jason.decode!() - |> JOSE.JWK.from() + test "generates authorization url with response_type as enum" do + {_bypass, uri} = start_fixture("google") + + config = %{ + @config + | discovery_document_uri: uri, + response_type: ["code", "id_token", "token"] + } + + assert authorization_uri(config) == + {:ok, + "https://accounts.google.com/o/oauth2/v2/auth?" <> + "client_id=CLIENT_ID" <> + "&redirect_uri=https%3A%2F%2Flocalhost%2Fredirect_uri" <> + "&response_type=code+id_token+token" <> + "&scope=openid+email+profile"} + end - {:ok, - %{ - discovery_document: discovery_document, - jwk: jwk, - remaining_lifetime: remaining_lifetime - }} = OpenIDConnect.update_documents(config) - - assert expected_document == discovery_document - assert expected_jwk == jwk - assert remaining_lifetime == 16750 - end - - test "fails during open id configuration document with HTTPoison error" do - config = [ - discovery_document_uri: "https://accounts.google.com/.well-known/openid-configuration" - ] - - expect( - HTTPClientMock, - :get, - fn "https://accounts.google.com/.well-known/openid-configuration", _headers, _opts -> - {:ok, %HTTPoison.Error{id: nil, reason: :nxdomain}} - end - ) - - assert OpenIDConnect.update_documents(config) == - {:error, :update_documents, %HTTPoison.Error{id: nil, reason: :nxdomain}} - end - - test "non-200 response for open id configuration document" do - config = [ - discovery_document_uri: "https://accounts.google.com/.well-known/openid-configuration" - ] - - expect( - HTTPClientMock, - :get, - fn "https://accounts.google.com/.well-known/openid-configuration", _headers, _opts -> - {:ok, %HTTPoison.Response{status_code: 404}} - end - ) - - assert OpenIDConnect.update_documents(config) == - {:error, :update_documents, %HTTPoison.Response{status_code: 404}} - end - - test "fails during certs with HTTPoison error" do - config = [ - discovery_document_uri: "https://accounts.google.com/.well-known/openid-configuration" - ] - - HTTPClientMock - |> expect(:get, fn "https://accounts.google.com/.well-known/openid-configuration", - _headers, - _opts -> - @google_document - end) - |> expect(:get, fn "https://www.googleapis.com/oauth2/v3/certs", _headers, _opts -> - {:ok, %HTTPoison.Error{reason: :nxdomain}} - end) + test "returns error on empty scope" do + {_bypass, uri} = start_fixture("google") - assert OpenIDConnect.update_documents(config) == - {:error, :update_documents, %HTTPoison.Error{id: nil, reason: :nxdomain}} + config = %{@config | discovery_document_uri: uri, scope: nil} + assert authorization_uri(config) == {:error, :invalid_scope} + + config = %{@config | discovery_document_uri: uri, scope: ""} + assert authorization_uri(config) == {:error, :invalid_scope} + + config = %{@config | discovery_document_uri: uri, scope: []} + assert authorization_uri(config) == {:error, :invalid_scope} end - test "non-200 response for certs" do - config = [ - discovery_document_uri: "https://accounts.google.com/.well-known/openid-configuration" - ] + test "returns error on empty response_type" do + {_bypass, uri} = start_fixture("google") - HTTPClientMock - |> expect(:get, fn "https://accounts.google.com/.well-known/openid-configuration", - _headers, - _opts -> - @google_document - end) - |> expect(:get, fn "https://www.googleapis.com/oauth2/v3/certs", _headers, _opts -> - {:ok, %HTTPoison.Response{status_code: 404}} - end) + config = %{@config | discovery_document_uri: uri, response_type: nil} + assert authorization_uri(config) == {:error, :invalid_response_type} + + config = %{@config | discovery_document_uri: uri, response_type: ""} + assert authorization_uri(config) == {:error, :invalid_response_type} - assert OpenIDConnect.update_documents(config) == - {:error, :update_documents, %HTTPoison.Response{status_code: 404}} + config = %{@config | discovery_document_uri: uri, response_type: []} + assert authorization_uri(config) == {:error, :invalid_response_type} end - test "with HTTP client options" do - config = [ - discovery_document_uri: "https://accounts.google.com/.well-known/openid-configuration" - ] + test "adds optional params" do + {_bypass, uri} = start_fixture("google") + config = %{@config | discovery_document_uri: uri} + + assert authorization_uri(config, %{"state" => "foo"}) == + {:ok, + "https://accounts.google.com/o/oauth2/v2/auth?" <> + "client_id=CLIENT_ID" <> + "&redirect_uri=https%3A%2F%2Flocalhost%2Fredirect_uri" <> + "&response_type=code+id_token+token" <> + "&scope=openid+email+profile" <> + "&state=foo"} + end - opts = [ssl: [{:verify, :verify_none}]] - Application.put_env(:openid_connect, :http_client_options, opts) + test "params can override default values" do + {_bypass, uri} = start_fixture("google") + config = %{@config | discovery_document_uri: uri} + + assert authorization_uri(config, %{client_id: "foo"}) == + {:ok, + "https://accounts.google.com/o/oauth2/v2/auth?" <> + "client_id=foo" <> + "&redirect_uri=https%3A%2F%2Flocalhost%2Fredirect_uri" <> + "&response_type=code+id_token+token" <> + "&scope=openid+email+profile"} + end - HTTPClientMock - |> expect(:get, fn - "https://accounts.google.com/.well-known/openid-configuration", _headers, ^opts -> - @google_document - end) - |> expect(:get, fn "https://www.googleapis.com/oauth2/v3/certs", _headers, ^opts -> - {:ok, %HTTPoison.Response{status_code: 404}} - end) + test "returns error when document is not available" do + bypass = Bypass.open() + uri = "http://localhost:#{bypass.port}/.well-known/discovery-document.json" + Bypass.down(bypass) - assert OpenIDConnect.update_documents(config) == - {:error, :update_documents, %HTTPoison.Response{status_code: 404}} + config = %{@config | discovery_document_uri: uri} + + assert authorization_uri(config, %{client_id: "foo"}) == + {:error, %Mint.TransportError{reason: :econnrefused}} end end - describe "normalize_discovery_document" do - test "defaults to empty list if claims_supported is missing" do - document_without_claims = - @google_document - |> elem(1) - |> Map.get(:body) - |> Jason.decode!() - |> Map.delete("claims_supported") + describe "end_session_uri/2" do + test "returns error when provider doesn't specify end_session_endpoint" do + {_bypass, uri} = start_fixture("google") + config = %{@config | discovery_document_uri: uri} + + assert end_session_uri(config) == {:error, :endpoint_not_set} + end + + test "generates authorization url" do + {_bypass, uri} = start_fixture("okta") + config = %{@config | discovery_document_uri: uri} + + assert end_session_uri(config) == + {:ok, "https://common.okta.com/oauth2/v1/logout?client_id=CLIENT_ID"} + end + + test "adds optional params" do + {_bypass, uri} = start_fixture("okta") + config = %{@config | discovery_document_uri: uri} + + assert end_session_uri(config, %{"state" => "foo"}) == + {:ok, "https://common.okta.com/oauth2/v1/logout?client_id=CLIENT_ID&state=foo"} + end + + test "params can override default values" do + {_bypass, uri} = start_fixture("okta") + config = %{@config | discovery_document_uri: uri} + + assert end_session_uri(config, %{client_id: "foo"}) == + {:ok, "https://common.okta.com/oauth2/v1/logout?client_id=foo"} + end + + test "returns error when document is not available" do + bypass = Bypass.open() + uri = "http://localhost:#{bypass.port}/.well-known/discovery-document.json" + Bypass.down(bypass) - normalized_claims = - document_without_claims - |> OpenIDConnect.normalize_discovery_document() - |> Map.get("claims_supported") + config = %{@config | discovery_document_uri: uri} - assert normalized_claims == [] + assert end_session_uri(config, %{client_id: "foo"}) == + {:error, %Mint.TransportError{reason: :econnrefused}} end end - describe "generating the authorization uri" do - test "with default worker name" do - {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) + describe "fetch_tokens/2" do + test "fetches the token from OAuth token endpoint" do + bypass = Bypass.open() + test_pid = self() + + token_response_attrs = %{ + "access_token" => "ACCESS_TOKEN", + "id_token" => "ID_TOKEN", + "refresh_token" => "REFRESH_TOKEN" + } + + Bypass.expect_once(bypass, "POST", "/token", fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + send(test_pid, {:req, body}) + Plug.Conn.resp(conn, 200, Jason.encode!(token_response_attrs)) + end) - try do - expected = - "https://accounts.google.com/o/oauth2/v2/auth?client_id=CLIENT_ID_1&redirect_uri=https%3A%2F%2Fdev.example.com%3A4200%2Fsession&response_type=code+id_token+token&scope=openid+email+profile" + token_endpoint = "http://localhost:#{bypass.port}/token" + {_bypass, uri} = start_fixture("google", %{token_endpoint: token_endpoint}) + config = %{@config | discovery_document_uri: uri} - assert OpenIDConnect.authorization_uri(:google) == expected - after - GenServer.stop(pid) - end + assert fetch_tokens(config, %{code: "1234", id_token: "abcd"}) == + {:ok, token_response_attrs} + + assert_receive {:req, body} + + assert body == + "client_id=CLIENT_ID" <> + "&client_secret=CLIENT_SECRET" <> + "&code=1234" <> + "&grant_type=authorization_code" <> + "&id_token=abcd" <> + "&redirect_uri=https%3A%2F%2Flocalhost%2Fredirect_uri" end - test "with optional params" do - {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) + test "allows to override the default params" do + bypass = Bypass.open() + test_pid = self() - try do - expected = - "https://accounts.google.com/o/oauth2/v2/auth?client_id=CLIENT_ID_1&redirect_uri=https%3A%2F%2Fdev.example.com%3A4200%2Fsession&response_type=code+id_token+token&scope=openid+email+profile&hd=dockyard.com" + token_response_attrs = %{ + "access_token" => "ACCESS_TOKEN", + "id_token" => "ID_TOKEN", + "refresh_token" => "REFRESH_TOKEN" + } - assert OpenIDConnect.authorization_uri(:google, %{"hd" => "dockyard.com"}) == expected - after - GenServer.stop(pid) - end + Bypass.expect_once(bypass, "POST", "/token", fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + send(test_pid, {:req, body}) + Plug.Conn.resp(conn, 200, Jason.encode!(token_response_attrs)) + end) + + token_endpoint = "http://localhost:#{bypass.port}/token" + {_bypass, uri} = start_fixture("google", %{token_endpoint: token_endpoint}) + config = %{@config | discovery_document_uri: uri} + + fetch_tokens(config, %{client_id: "foo"}) + + assert_receive {:req, body} + + assert body == + "client_id=foo" <> + "&client_secret=CLIENT_SECRET" <> + "&grant_type=authorization_code" <> + "&redirect_uri=https%3A%2F%2Flocalhost%2Fredirect_uri" + end + + test "returns error when token endpoint is not available" do + bypass = Bypass.open() + Bypass.down(bypass) + token_endpoint = "http://localhost:#{bypass.port}/token" + {_bypass, uri} = start_fixture("google", %{token_endpoint: token_endpoint}) + config = %{@config | discovery_document_uri: uri} + + assert fetch_tokens(config, %{client_id: "foo"}) == + {:error, %Mint.TransportError{reason: :econnrefused}} end - test "with overridden params" do - {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) + test "returns error when token endpoint is responds with non 2XX status code" do + bypass = Bypass.open() + + Bypass.expect_once(bypass, "POST", "/token", fn conn -> + Plug.Conn.resp(conn, 401, Jason.encode!(%{"error" => "unauthorized"})) + end) + + token_endpoint = "http://localhost:#{bypass.port}/token" + {_bypass, uri} = start_fixture("google", %{token_endpoint: token_endpoint}) + config = %{@config | discovery_document_uri: uri} - try do - expected = - "https://accounts.google.com/o/oauth2/v2/auth?client_id=CLIENT_ID_1&redirect_uri=https%3A%2F%2Fdev.example.com%3A4200%2Fsession&response_type=code+id_token+token&scope=something+else" + assert fetch_tokens(config, %{client_id: "foo"}) == + {:error, {401, "{\"error\":\"unauthorized\"}"}} + end - assert OpenIDConnect.authorization_uri(:google, %{scope: "something else"}) == expected - after - GenServer.stop(pid) + test "returns error when real provider token endpoint is responded with invalid code" do + {_bypass, uri} = start_fixture("google") + config = %{@config | discovery_document_uri: uri} + assert {:error, {401, resp}} = fetch_tokens(config, %{code: "foo"}) + resp_json = Jason.decode!(resp) + + assert resp_json == %{ + "error" => "invalid_client", + "error_description" => "The OAuth client was not found." + } + + for provider <- ["auth0", "okta", "onelogin"] do + {_bypass, uri} = start_fixture(provider) + config = %{@config | discovery_document_uri: uri} + assert {:error, {status, _resp}} = fetch_tokens(config, %{code: "foo"}) + assert status in 400..499 end end - test "with custom worker name" do - {:ok, pid} = GenServer.start_link(MockWorker, [], name: :other_openid_worker) + test "returns error when document is not available" do + bypass = Bypass.open() + uri = "http://localhost:#{bypass.port}/.well-known/discovery-document.json" + Bypass.down(bypass) - try do - expected = - "https://accounts.google.com/o/oauth2/v2/auth?client_id=CLIENT_ID_1&redirect_uri=https%3A%2F%2Fdev.example.com%3A4200%2Fsession&response_type=code+id_token+token&scope=openid+email+profile" + config = %{@config | discovery_document_uri: uri} - assert OpenIDConnect.authorization_uri(:google, %{}, :other_openid_worker) == expected - after - GenServer.stop(pid) - end + assert fetch_tokens(config, %{code: "foo"}) == + {:error, %Mint.TransportError{reason: :econnrefused}} end end - describe "fetching tokens" do - test "when token fetch is successful" do - {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) - - config = GenServer.call(:openid_connect, {:config, :google}) - - form_body = [ - client_id: config[:client_id], - client_secret: config[:client_secret], - code: "1234", - grant_type: "authorization_code", - redirect_uri: config[:redirect_uri] - ] - - try do - expect(HTTPClientMock, :post, fn "https://www.googleapis.com/oauth2/v4/token", - {:form, ^form_body}, - _headers, - _opts -> - {:ok, %HTTPoison.Response{status_code: 200, body: Jason.encode!(%{})}} - end) - - {:ok, body} = OpenIDConnect.fetch_tokens(:google, %{code: "1234"}) - - assert body == %{} - after - GenServer.stop(pid) - end + describe "verify/2" do + test "returns error when token has invalid format" do + assert verify(@config, "foo") == + {:error, {:invalid_jwt, "invalid token format"}} end - test "when token fetch is successful with a different GenServer name" do - {:ok, pid} = GenServer.start_link(MockWorker, [], name: :other_openid_connect) - - config = GenServer.call(:other_openid_connect, {:config, :google}) - - form_body = [ - client_id: config[:client_id], - client_secret: config[:client_secret], - code: "1234", - grant_type: "authorization_code", - id_token: "abcd", - redirect_uri: config[:redirect_uri] - ] - - try do - expect(HTTPClientMock, :post, fn "https://www.googleapis.com/oauth2/v4/token", - {:form, ^form_body}, - _headers, - _opts -> - {:ok, %HTTPoison.Response{status_code: 200, body: Jason.encode!(%{})}} - end) - - {:ok, body} = - OpenIDConnect.fetch_tokens( - :google, - %{code: "1234", id_token: "abcd"}, - :other_openid_connect - ) - - assert body == %{} - after - GenServer.stop(pid) - end + test "returns error when encoded token is not a JSON map" do + token = + ["fail", "fail", "fail"] + |> Enum.map_join(".", fn header -> Base.encode64(header) end) + + assert verify(@config, token) == + {:error, {:invalid_jwt, "token claims did not contain a JSON payload"}} end - test "when params are overridden" do - {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) + test "returns error when encoded token is doesn't have valid 'alg'" do + token = + ["{}", "{}", "{}"] + |> Enum.map_join(".", fn header -> Base.encode64(header) end) - config = GenServer.call(:openid_connect, {:config, :google}) + assert verify(@config, token) == + {:error, {:invalid_jwt, "no `alg` found in token"}} + end - form_body = [ - client_id: config[:client_id], - client_secret: config[:client_secret], - grant_type: "refresh_token", - redirect_uri: config[:redirect_uri] - ] + test "returns error when token is valid but invalid for a provider" do + {_bypass, uri} = start_fixture("okta") + config = %{@config | discovery_document_uri: uri} + {jwk, []} = Code.eval_file("test/fixtures/jwks/jwk.exs") - try do - expect(HTTPClientMock, :post, fn "https://www.googleapis.com/oauth2/v4/token", - {:form, ^form_body}, - _headers, - _opts -> - {:ok, %HTTPoison.Response{status_code: 200, body: Jason.encode!(%{})}} - end) + claims = %{"email" => "brian@example.com"} - {:ok, body} = OpenIDConnect.fetch_tokens(:google, %{grant_type: "refresh_token"}) + {_alg, token} = + jwk + |> JOSE.JWK.from() + |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) + |> JOSE.JWS.compact() - assert body == %{} - after - GenServer.stop(pid) - end + assert verify(config, token) == {:error, {:invalid_jwt, "verification failed"}} end - test "when token fetch fails with bad domain" do - {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) + test "returns claims when encoded token is valid" do + {jwks, []} = Code.eval_file("test/fixtures/jwks/jwk.exs") + jwk = JOSE.JWK.from(jwks) + {_, jwk_pubkey} = JOSE.JWK.to_public_map(jwk) - http_error = %HTTPoison.Error{reason: :nxdomain} + {_bypass, uri} = start_fixture("vault", %{"jwks" => jwk_pubkey}) + config = %{@config | discovery_document_uri: uri} - try do - expect(HTTPClientMock, :post, fn "https://www.googleapis.com/oauth2/v4/token", - {:form, _form_body}, - _headers, - _opts -> - {:ok, http_error} - end) + claims = %{ + "email" => "brian@example.com", + "exp" => DateTime.utc_now() |> DateTime.add(10, :second) |> DateTime.to_unix(), + "aud" => config.client_id + } - resp = OpenIDConnect.fetch_tokens(:google, %{code: "1234"}) + {_alg, token} = + jwk + |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) + |> JOSE.JWS.compact() - assert resp == {:error, :fetch_tokens, http_error} - after - GenServer.stop(pid) - end + assert verify(config, token) == {:ok, claims} end - test "when token fetch doesn't return a 200 response" do - {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) + test "returns claims when encoded token is valid using multiple keys" do + {jwks, []} = Code.eval_file("test/fixtures/jwks/jwks.exs") - http_error = %HTTPoison.Response{status_code: 404} + jwk = + jwks + |> Map.fetch!("keys") + |> List.first() + |> JOSE.JWK.from() - try do - expect(HTTPClientMock, :post, fn "https://www.googleapis.com/oauth2/v4/token", - {:form, _form_body}, - _headers, - _opts -> - {:ok, http_error} - end) + {_, jwk_pubkey} = JOSE.JWK.to_public_map(jwk) - resp = OpenIDConnect.fetch_tokens(:google, %{code: "1234"}) + {_bypass, uri} = start_fixture("vault", %{"jwks" => jwk_pubkey}) + config = %{@config | discovery_document_uri: uri} - assert resp == {:error, :fetch_tokens, http_error} - after - GenServer.stop(pid) - end - end - end + claims = %{ + "email" => "brian@example.com", + "exp" => DateTime.utc_now() |> DateTime.add(10, :second) |> DateTime.to_unix(), + "aud" => config.client_id + } - describe "jwt verification" do - test "is successful" do - {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) + {_alg, token} = + jwk + |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) + |> JOSE.JWS.compact() - try do - {jwk, []} = Code.eval_file("test/fixtures/rsa/jwk1.exs") - :ok = GenServer.call(pid, {:put, :jwk, JOSE.JWK.from(jwk)}) + assert verify(config, token) == {:ok, claims} - claims = %{"email" => "brian@example.com"} + claims = %{ + "email" => "brian@example.com", + "exp" => DateTime.utc_now() |> DateTime.add(-29, :second) |> DateTime.to_unix(), + "aud" => config.client_id + } - {_alg, token} = - jwk - |> JOSE.JWK.from() - |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) - |> JOSE.JWS.compact() + {_alg, token} = + jwk + |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) + |> JOSE.JWS.compact() - result = OpenIDConnect.verify(:google, token) - assert result == {:ok, claims} - after - GenServer.stop(pid) - end + assert verify(config, token) == {:ok, claims} end - test "is successful with multiple jwks" do - {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) + test "returns error when token is expired" do + {jwks, []} = Code.eval_file("test/fixtures/jwks/jwk.exs") + jwk = JOSE.JWK.from(jwks) + {_, jwk_pubkey} = JOSE.JWK.to_public_map(jwk) - try do - {jwk, []} = Code.eval_file("test/fixtures/rsa/jwks.exs") - :ok = GenServer.call(pid, {:put, :jwk, JOSE.JWK.from(jwk)}) + {_bypass, uri} = start_fixture("vault", %{"jwks" => jwk_pubkey}) + config = %{@config | discovery_document_uri: uri} - claims = %{"email" => "brian@example.com"} + claims = %{ + "email" => "brian@example.com", + "exp" => DateTime.utc_now() |> DateTime.add(-31, :second) |> DateTime.to_unix(), + "aud" => config.client_id + } - {_alg, token} = - jwk - |> Map.get("keys") - |> List.last() - |> JOSE.JWK.from() - |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) - |> JOSE.JWS.compact() + {_alg, token} = + jwk + |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) + |> JOSE.JWS.compact() - result = OpenIDConnect.verify(:google, token) - assert result == {:ok, claims} - after - GenServer.stop(pid) - end + assert verify(config, token) == + {:error, {:invalid_jwt, "invalid exp claim: token has expired"}} end - test "fails with invalid token format" do - {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) + test "returns error when token expiration is not set" do + {jwks, []} = Code.eval_file("test/fixtures/jwks/jwk.exs") + jwk = JOSE.JWK.from(jwks) + {_, jwk_pubkey} = JOSE.JWK.to_public_map(jwk) - try do - {jwk, []} = Code.eval_file("test/fixtures/rsa/jwk1.exs") - :ok = GenServer.call(pid, {:put, :jwk, JOSE.JWK.from(jwk)}) + {_bypass, uri} = start_fixture("vault", %{"jwks" => jwk_pubkey}) + config = %{@config | discovery_document_uri: uri} - result = OpenIDConnect.verify(:google, "fail") - assert result == {:error, :verify, "invalid token format"} - after - GenServer.stop(pid) - end + claims = %{ + "email" => "brian@example.com", + "aud" => config.client_id + } + + {_alg, token} = + jwk + |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) + |> JOSE.JWS.compact() + + assert verify(config, token) == {:error, {:invalid_jwt, "invalid exp claim: missing"}} end - test "fails with invalid token claims format" do - {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) + test "returns error when aud claim is for another application" do + {jwks, []} = Code.eval_file("test/fixtures/jwks/jwk.exs") + jwk = JOSE.JWK.from(jwks) + {_, jwk_pubkey} = JOSE.JWK.to_public_map(jwk) - try do - {jwk, []} = Code.eval_file("test/fixtures/rsa/jwk1.exs") - :ok = GenServer.call(pid, {:put, :jwk, JOSE.JWK.from(jwk)}) + {_bypass, uri} = start_fixture("vault", %{"jwks" => jwk_pubkey}) + config = %{@config | discovery_document_uri: uri} - token = - [ - "fail", - "fail", - "fail" - ] - |> Enum.map(fn header -> Base.encode64(header) end) - |> Enum.join(".") + claims = %{ + "email" => "brian@example.com", + "exp" => DateTime.utc_now() |> DateTime.to_unix(), + "aud" => "foo" + } - result = OpenIDConnect.verify(:google, token) - assert result == {:error, :verify, "token claims did not contain a JSON payload"} - after - GenServer.stop(pid) - end + {_alg, token} = + jwk + |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) + |> JOSE.JWS.compact() + + assert verify(config, token) == + {:error, + {:invalid_jwt, "invalid aud claim: token is intended for another application"}} end - test "fails with token not including algorithm hint" do - {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) + test "returns error when aud claim is not set" do + {jwks, []} = Code.eval_file("test/fixtures/jwks/jwk.exs") + jwk = JOSE.JWK.from(jwks) + {_, jwk_pubkey} = JOSE.JWK.to_public_map(jwk) - try do - {jwk, []} = Code.eval_file("test/fixtures/rsa/jwk1.exs") - :ok = GenServer.call(pid, {:put, :jwk, JOSE.JWK.from(jwk)}) + {_bypass, uri} = start_fixture("vault", %{"jwks" => jwk_pubkey}) + config = %{@config | discovery_document_uri: uri} - token = - [ - "{}", - "{}", - "{}" - ] - |> Enum.map(fn header -> Base.encode64(header) end) - |> Enum.join(".") + claims = %{ + "email" => "brian@example.com", + "exp" => DateTime.utc_now() |> DateTime.to_unix() + } - result = OpenIDConnect.verify(:google, token) - assert result == {:error, :verify, "no `alg` found in token"} - after - GenServer.stop(pid) - end + {_alg, token} = + jwk + |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) + |> JOSE.JWS.compact() + + assert verify(config, token) == {:error, {:invalid_jwt, "invalid aud claim: missing"}} end - test "fails when verification fails" do - {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) + test "returns error when token is altered" do + {jwks, []} = Code.eval_file("test/fixtures/jwks/jwk.exs") + jwk = JOSE.JWK.from(jwks) + {_, jwk_pubkey} = JOSE.JWK.to_public_map(jwk) - try do - {jwk1, []} = Code.eval_file("test/fixtures/rsa/jwk1.exs") - {jwk2, []} = Code.eval_file("test/fixtures/rsa/jwk2.exs") - :ok = GenServer.call(pid, {:put, :jwk, JOSE.JWK.from(jwk1)}) + {_bypass, uri} = start_fixture("vault", %{"jwks" => jwk_pubkey}) + config = %{@config | discovery_document_uri: uri} - claims = %{"email" => "brian@example.com"} + claims = %{"email" => "brian@example.com"} - {_alg, token} = - jwk2 - |> JOSE.JWK.from() - |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) - |> JOSE.JWS.compact() + {_alg, token} = + jwk + |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) + |> JOSE.JWS.compact() - result = OpenIDConnect.verify(:google, token) - assert result == {:error, :verify, "verification failed"} - after - GenServer.stop(pid) - end + assert verify(config, token <> ":)") == {:error, {:invalid_jwt, "verification failed"}} end - test "fails when verification fails due to token manipulation" do - {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) + test "returns error when document is not available" do + {jwks, []} = Code.eval_file("test/fixtures/jwks/jwk.exs") - try do - {jwk, []} = Code.eval_file("test/fixtures/rsa/jwk1.exs") - :ok = GenServer.call(pid, {:put, :jwk, JOSE.JWK.from(jwk)}) + bypass = Bypass.open() + uri = "http://localhost:#{bypass.port}/.well-known/discovery-document.json" + Bypass.down(bypass) - claims = %{"email" => "brian@example.com"} + config = %{@config | discovery_document_uri: uri} - {_alg, token} = - jwk - |> JOSE.JWK.from() - |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) - |> JOSE.JWS.compact() + claims = %{"email" => "brian@example.com"} - result = OpenIDConnect.verify(:google, token <> " :)") - assert result == {:error, :verify, "verification error"} - after - GenServer.stop(pid) - end + {_alg, token} = + jwks + |> JOSE.JWK.from() + |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) + |> JOSE.JWS.compact() + + assert verify(config, token) == + {:error, %Mint.TransportError{reason: :econnrefused}} end end - defp set_jose_json_lib(_) do - JOSE.json_module(JasonEncoder) - [] + describe "fetch_userinfo/2" do + test "returns user info using endpoint from discovery document" do + bypass = Bypass.open() + test_pid = self() + + { + userinfo_status_code, + userinfo_response_attrs, + userinfo_response_headers + } = load_fixture("google", "userinfo") + + Bypass.expect_once(bypass, "GET", "/userinfo", fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + + conn = + Enum.reduce(userinfo_response_headers, conn, fn {k, v}, conn -> + Plug.Conn.put_resp_header(conn, k, v) + end) + + send(test_pid, {:req, body, conn.req_headers}) + Plug.Conn.resp(conn, userinfo_status_code, Jason.encode!(userinfo_response_attrs)) + end) + + userinfo_endpoint = "http://localhost:#{bypass.port}/userinfo" + + {jwks, []} = Code.eval_file("test/fixtures/jwks/jwk.exs") + jwk = JOSE.JWK.from(jwks) + {_, jwk_pubkey} = JOSE.JWK.to_public_map(jwk) + + {_bypass, uri} = + start_fixture("vault", %{ + "jwks" => jwk_pubkey, + "userinfo_endpoint" => userinfo_endpoint + }) + + config = %{@config | discovery_document_uri: uri} + + claims = %{"email" => userinfo_response_attrs["email"]} + + {_alg, token} = + jwk + |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) + |> JOSE.JWS.compact() + + assert {:ok, userinfo} = fetch_userinfo(config, token) + + assert userinfo == userinfo_response_attrs + + assert_receive {:req, "", headers} + assert {"authorization", "Bearer #{token}"} in headers + end + + test "returns error when userinfo endpoint is not available" do + bypass = Bypass.open() + userinfo_endpoint = "http://localhost:#{bypass.port}/userinfo" + Bypass.down(bypass) + + {jwks, []} = Code.eval_file("test/fixtures/jwks/jwk.exs") + jwk = JOSE.JWK.from(jwks) + {_, jwk_pubkey} = JOSE.JWK.to_public_map(jwk) + + {_bypass, uri} = + start_fixture("vault", %{ + "jwks" => jwk_pubkey, + "userinfo_endpoint" => userinfo_endpoint + }) + + config = %{@config | discovery_document_uri: uri} + + claims = %{"email" => "foo@john.com"} + + {_alg, token} = + jwk + |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) + |> JOSE.JWS.compact() + + assert fetch_userinfo(config, token) == + {:error, %Mint.TransportError{reason: :econnrefused}} + end end end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index 30ddfe2..392cf98 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -1,15 +1,37 @@ -defmodule Fixtures do - def load(provider, type) do - response = - Code.eval_file("test/fixtures/#{provider}/#{type}.exs") - |> elem(0) - |> serialize() - - {:ok, response} +defmodule OpenIDConnect.Fixtures do + def start_fixture(provider, overrides \\ %{}) do + bypass = Bypass.open() + endpoint = "http://localhost:#{bypass.port}/" + {jwks, overrides} = Map.pop(overrides, "jwks") + + Bypass.expect_once(bypass, "GET", "/.well-known/jwks.json", fn conn -> + {status_code, body, headers} = load_fixture(provider, "jwks") + body = if jwks, do: jwks, else: body + send_response(conn, status_code, body, headers) + end) + + Bypass.expect_once(bypass, "GET", "/.well-known/discovery-document.json", fn conn -> + {status_code, body, headers} = load_fixture(provider, "discovery_document") + body = Map.merge(body, %{"jwks_uri" => "#{endpoint}.well-known/jwks.json"}) + body = Map.merge(body, overrides) + send_response(conn, status_code, body, headers) + end) + + {bypass, "#{endpoint}.well-known/discovery-document.json"} end - defp serialize(%HTTPoison.Response{body: body} = response), - do: %{response | body: Jason.encode!(body)} + def load_fixture(provider, type) do + {%{status_code: status_code, body: body, headers: headers}, _} = + Code.eval_file("test/fixtures/http/#{provider}/#{type}.exs") - defp serialize(response), do: response + {status_code, body, headers} + end + + def send_response(conn, status_code, body, headers) do + headers + |> Enum.reduce(conn, fn {key, value}, conn -> + Plug.Conn.put_resp_header(conn, key, value) + end) + |> Plug.Conn.resp(status_code, Jason.encode!(body)) + end end diff --git a/test/support/jason_encoder.ex b/test/support/jason_encoder.ex deleted file mode 100644 index b8a9957..0000000 --- a/test/support/jason_encoder.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule JasonEncoder do - def encode(term) do - Jason.encode!(term) - end - - def decode(string) do - Jason.decode!(string) - end -end diff --git a/test/support/mock_worker.ex b/test/support/mock_worker.ex deleted file mode 100644 index b9fa4d9..0000000 --- a/test/support/mock_worker.ex +++ /dev/null @@ -1,48 +0,0 @@ -defmodule OpenIDConnect.MockWorker do - use GenServer - - @google_document Fixtures.load(:google, :discovery_document) - |> elem(1) - |> Map.get(:body) - |> Jason.decode!() - |> OpenIDConnect.normalize_discovery_document() - - @google_jwk Fixtures.load(:google, :certs) - |> elem(1) - |> Map.get(:body) - |> Jason.decode!() - |> JOSE.JWK.from() - - def init(_) do - config = - Application.get_env(:openid_connect, :providers) - |> Keyword.get(:google) - - {:ok, - %{ - config: config, - jwk: @google_jwk, - document: @google_document - }} - end - - def handle_call({:discovery_document, :google}, _from, state) do - {:reply, Map.get(state, :document), state} - end - - def handle_call({:jwk, :google}, _from, state) do - {:reply, Map.get(state, :jwk), state} - end - - def handle_call({:config, :google}, _from, state) do - {:reply, Map.get(state, :config), state} - end - - def handle_call({:put, key, value}, _from, state) do - {:reply, :ok, Map.put(state, key, value)} - end - - def handle_call(_anything, _from, _state) do - {:reply, nil, %{}} - end -end diff --git a/test/test_helper.exs b/test/test_helper.exs index 213b010..869559e 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,3 +1 @@ -Mox.defmock(OpenIDConnect.HTTPClientMock, for: HTTPoison.Base) - ExUnit.start()