├── .formatter.exs ├── .github └── FUNDING.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── QUICKSTART.md ├── README.md ├── config └── config.exs ├── lib ├── plugoid.ex ├── plugoid │ ├── oidc_request.ex │ ├── redirect_uri.ex │ ├── session │ │ ├── auth_session.ex │ │ └── state_session.ex │ └── utils.ex └── plugoid_web │ ├── templates │ └── preserve_request_params │ │ ├── restore.html.eex │ │ └── save.html.eex │ └── views │ └── preserve_request_params_view.ex ├── mix.exs ├── mix.lock └── test ├── plugoid_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [tanguilp] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | plugoid-*.tar 24 | 25 | *~ 26 | *.sw* 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on Keep a Changelog and this project adheres to Semantic Versioning. 6 | 7 | ## [0.6.0] - 2022-03-20 8 | 9 | ### Changed 10 | 11 | - [`Plugoid`] RFC9207 is supported. As a consequence, redirect URI no longer contains the 12 | issuer when generated by the `plugoid_redirect_uri` function (see [`Plugoid.RedirectURI`]) 13 | 14 | ## [0.5.1] - 2022-03-20 15 | 16 | ### Added 17 | 18 | - [`Plugoid`] `Plugoid.authenticate/2` is now public 19 | 20 | ### Changed 21 | 22 | - [`Plugoid`] GET request parameters are now stored in a cookie unless `:preserve_initial_request` 23 | is set to `true`, in which case it is sotred in local storage in the browser 24 | 25 | ## [0.5.0] - 2021-11-16 26 | 27 | ### Changed 28 | 29 | - [`Plugoid.Redirect`] **Breaking change** The token callback now takes a `Plug.Conn.t()` 30 | as an additional parameter and returns it 31 | 32 | ### Fixed 33 | 34 | - [`Plugoid.Session.StateSession`] Set `secure: true` to state session cookie (#14) 35 | 36 | ## [0.4.3] - 2021-10-25 37 | 38 | ### Fixed 39 | 40 | - [`Plugoid`] Relaxed requirements for the `phoenix_html` dependency 41 | 42 | ## [0.4.2] - 2021-09-22 43 | 44 | ### Fixed 45 | 46 | - [`Plugoid`] Fixed a bug with token hash validation in imported library 47 | 48 | ## [0.4.1] - 2020-10-16 49 | 50 | ### Fixed 51 | 52 | - [`Plugoid`] Fixed erroneous handling of custom OP metadata 53 | 54 | ## [0.4.0] - 2020-09-26 55 | 56 | ### Added 57 | 58 | - [`Plugoid.RedirectURI`] Mix-up attack protection. Redirect URIs are generated with an `iss` 59 | parameter, which is verified when receiving the answer from the OP 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /QUICKSTART.md: -------------------------------------------------------------------------------- 1 | # Quick start 2 | 3 | ## Step 1: install the redirect URI plug 4 | 5 | In your `router.ex` file, add at the beginning: 6 | 7 | ```elixir 8 | use Plugoid.RedirectURI 9 | ``` 10 | 11 | ## Step 2: create an OpenID Connect pipeline 12 | 13 | Still in `router.ex`, add a Plugoid pipeline: 14 | 15 | ```elixir 16 | pipeline :oidc_auth do 17 | plug Plugoid, 18 | issuer: "", 19 | client_id: "", 20 | client_config: MyApp.ClientCallback 21 | end 22 | ``` 23 | 24 | where `` is the OpenID Provider's (OP) issuer URL and `` is the client 25 | identifier provided upon application registration at the OP. 26 | 27 | ## Step 3: protect some routes with the pipeline 28 | 29 | Again in `router.ex`, add the pipeline to some routes: 30 | 31 | ```elixir 32 | scope "/private", MyAppWeb do 33 | pipe_through :browser 34 | pipe_through :oidc_auth 35 | 36 | get "/", PageController, :index 37 | end 38 | ``` 39 | 40 | ## Step 4: create a client callback 41 | 42 | Create the `myapp/lib/myapp/client_callback.ex` file (where `myapp` is replaced by the name 43 | of your application) and add the following code: 44 | 45 | ```elixir 46 | defmodule MyApp.ClientCallback do 47 | @behaviour OIDC.ClientConfig 48 | 49 | @impl true 50 | def get("") do 51 | %{ 52 | "client_id" => "", 53 | "client_secret" => "" 54 | } 55 | end 56 | end 57 | ``` 58 | 59 | where `` is the same client identifier as before, and `` is the 60 | application password provided by the OP upon registration of the application. 61 | 62 | If another type of credential was provided, refer to `TeslaOAuth2ClientAuth` documentation. 63 | 64 | In production environment, it is unsafe to hardcode application password in an Elixir module. 65 | Use `Application.fetch_env!/2` or another secure mean instead. 66 | 67 | ## Step 5: get the redirect URI for the OpenID Provider 68 | 69 | Open an IEx shell and type the following command: 70 | 71 | 72 | ```elixir 73 | iex> MyAppWeb.Router.plugoid_redirect_uri() 74 | "https://my_app_url.com/openid_connect_redirect_uri" 75 | ``` 76 | 77 | The returned value is the redirect URI of your site to be configured at the OpenID Provider. 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plugoid 2 | 3 | OpenID Connect Plug for Phoenix 4 | 5 | Plugoid lets you protect some routes with OpenID Connect authentication, for instance: 6 | 7 | ```elixir 8 | defmodule PlugoidDemoWeb.Router do 9 | use PlugoidDemoWeb, :router 10 | use Plugoid.RedirectURI 11 | 12 | pipeline :oidc_auth do 13 | plug Plugoid, 14 | issuer: "https://repentant-brief-fishingcat.gigalixirapp.com", 15 | client_id: "client1", 16 | client_config: PlugoidDemo.OpenIDConnect.Client 17 | end 18 | 19 | scope "/private", PlugoidDemoWeb do 20 | pipe_through :browser 21 | pipe_through :oidc_auth 22 | 23 | get "/", PageController, :index 24 | post "/", PageController, :index 25 | end 26 | end 27 | ``` 28 | 29 | ## Documentation 30 | 31 | - Full documentation on [hex.pm](https://hexdocs.pm/plugoid/) 32 | - [Quick start guide](https://hexdocs.pm/plugoid/0.1.0/quickstart.html) 33 | - [`plugoid_demo`](https://github.com/tanguilp/plugoid_demo): a demo application using `Plugoid` 34 | 35 | ## Installation 36 | 37 | ```elixir 38 | def deps do 39 | [ 40 | {:plugoid, "~> 0.6.0"}, 41 | {:hackney, "~> 1.0"} 42 | ] 43 | end 44 | ``` 45 | 46 | The hackney dependency is used as the default adapter for Tesla (for outbound HTTP requests). 47 | Another one can be used instead (see 48 | [https://github.com/teamon/tesla#adapters](https://github.com/teamon/tesla#adapters)) and then 49 | has to be configured in your `config.exs`: 50 | 51 | ```elixir 52 | config :tesla, adapter: Tesla.Adapter.AnotherOne 53 | ``` 54 | 55 | ## When to use it 56 | 57 | Possible uses are: 58 | - when you entirely delegate user authentication to an external OpenID Connect Provider (OP) 59 | - when you want to integrate with third-party providers ("social login"). Note that: 60 | - this library and the library it uses are very strict and might fail with some social login 61 | providers that don't strictly follows the standard 62 | - it has not been tested with any public OpenID Connect Provider (social login provider) 63 | - it does not support pure OAuth2 authentication providers 64 | 65 | ## Project status 66 | 67 | The implementation of the standard is comprehsensive but as for all security related libraries, 68 | care should be taken when assessing it. This library is not (yet?) widely used and has 69 | received little scrutiny by other programmers or security specialists. 70 | 71 | This project is also looking for contributors. Feel free to take a look at issues opened in the 72 | following projects: 73 | - [Plugoid issues](https://github.com/tanguilp/plugoid/issues) 74 | - [OIDC issues](https://github.com/tanguilp/oidc/issues) 75 | - [OAuth2TokenManager issues](https://github.com/tanguilp/oauth2_token_manager/issues) 76 | 77 | ## Protocol support 78 | 79 | - [x] [OpenID Connect Core 1.0 incorporating errata set 1](https://openid.net/specs/openid-connect-core-1_0.html) 80 | - [x] 3. Authentication 81 | - [x] authorization code flow: 82 | - [x] `"code"` response type 83 | - [x] implicit flow: 84 | - [x] `"id_token"` response type 85 | - [x] `"id_token token"` response type 86 | - [x] hybrid flow: 87 | - [x] `"code id_token"` response type 88 | - [x] `"code token"` response type 89 | - [x] `"code id_token token"` response type 90 | - [ ] 4. Initiating Login from a Third Party 91 | - [x] 5. Claims 92 | - [x] 5.3. UserInfo Endpoint (via 93 | [`OAuth2TokenManager`](https://github.com/tanguilp/oauth2_token_manager)) 94 | - [x] 5.4. Requesting Claims using Scope Values 95 | - [x] 5.5. Requesting Claims using the "claims" Request Parameter, including special 96 | handling of: 97 | - `"acr"` 98 | - `"auth_time"` 99 | - [ ] 6. Passing Request Parameters as JWTs 100 | - [x] 9. Client Authentication (via 101 | [`TeslaOAuth2ClientAuth`](https://github.com/tanguilp/tesla_oauth2_client_auth)) 102 | - [x] `"client_secret_basic"` 103 | - [x] `"client_secret_post"` 104 | - [x] `"client_secret_jwt"` 105 | - [x] `"private_key_jwt"` 106 | - [x] `"none"` 107 | - [x] 12. Using Refresh Tokens (via 108 | [`OAuth2TokenManager`](https://github.com/tanguilp/oauth2_token_manager)) 109 | - [x] [OpenID Connect Discovery 1.0 incorporating errata set 1](https://openid.net/specs/openid-connect-discovery-1_0.html) 110 | - [x] [OAuth 2.0 Multiple Response Type Encoding Practices](https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html) 111 | - [x] [OAuth 2.0 Form Post Response Mode](https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html) 112 | - [x] [RFC7636 - Proof Key for Code Exchange by OAuth Public Clients](https://tools.ietf.org/html/rfc7636) 113 | - [x] [RFC9207 - OAuth 2.0 Authorization Server Issuer Identification](https://www.rfc-editor.org/rfc/rfc9207.html) 114 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :phoenix, :json_library, Jason 4 | -------------------------------------------------------------------------------- /lib/plugoid.ex: -------------------------------------------------------------------------------- 1 | defmodule Plugoid do 2 | @moduledoc """ 3 | ## Basic use 4 | 5 | defmodule MyAppWeb.Router do 6 | use MyAppWeb, :router 7 | use Plugoid.RedirectURI 8 | 9 | pipeline :oidc_auth do 10 | plug Plugoid, 11 | issuer: "https://repentant-brief-fishingcat.gigalixirapp.com", 12 | client_id: "client1", 13 | client_config: PlugoidDemo.OpenIDConnect.Client 14 | end 15 | 16 | scope "/private", MyAppWeb do 17 | pipe_through :browser 18 | pipe_through :oidc_auth 19 | 20 | get "/", PageController, :index 21 | post "/", PageController, :index 22 | end 23 | end 24 | 25 | ## Plug options 26 | 27 | ### Mandatory plug options 28 | 29 | - `:client_id` **[Mandatory]**: the client id to be used for interaction with the OpenID 30 | Provider (OP) 31 | - `:client_config` **[Mandatory]**: a module that implements the 32 | [`OIDC.ClientConfig`](https://hexdocs.pm/oidc/OIDC.ClientConfig.html) behaviour and returns 33 | the client configuration 34 | - `:issuer` **[Mandatory]**: the OpenID Provider (OP) issuer. Server metadata and keys are 35 | automatically retrieved from it if the OP supports it 36 | 37 | ### Additional plug options 38 | 39 | - `:acr_values`: one of: 40 | - `nil` [*Default*]: no acr values requested 41 | - `[String.t()]`: a list of acr values 42 | - `:acr_values_callback`: a `t:opt_callback/0` that dynamically returns a list of ACRs. Called 43 | only if `:acr_values` is not set 44 | - `:claims`: the `"claims"` parameter 45 | - `:claims_callback`: a `t:opt_callback/0` that dynamically returns the claim parameter. Called 46 | only if `:claims` is not set 47 | - `:display`: display parameter. Mostly unused. Defaults to `nil` 48 | - `:error_view`: the error view to be called in case of error. See the 49 | [Error handling](#module-error-handling) section bellow. If not set, it will be automatically 50 | set to `MyApp.ErrorView` where `MyApp` is the base module name of the application 51 | - `:id_token_iat_max_time_gap`: max time gap to accept an ID token, in seconds. 52 | Defaults to `30` 53 | - `:login_hint_callback`: a `t:opt_callback/0` that dynamically returns the login hint 54 | parameter 55 | - `:max_age`: the OIDC max age (`non_neg_integer()`) parameter 56 | - `:max_concurrent_state_session`: maximum of state sessions stored concurrently. Defaults to 57 | `4`, set to `nil` for no limits. See [On state cookies](#module-on-state-cookies) 58 | - `:oauth2_metadata_updater_opts`: options that will be passed to `Oauth2MetadataUpdater`. 59 | Some authorization server do not follow standards when forming the metadata's URI. In such a 60 | case, you might need to use the `:url_construction` option of `Oauth2MetadataUpdater` 61 | - `:on_unauthenticated`: action to be taken when the request is not authenticated. One 62 | of: 63 | - `:auth` **[Default]**: redirects to the authorization endpoint of the OP 64 | - `:fail`: returns an HTTP 401 error 65 | - `:pass`: hands over the request to the next plug. The request is unauthenticated 66 | (this can be checked using the `authenticated?/1` function) 67 | - `:on_unauthorized`: action to be taken when the user is not authorized, because of invalid 68 | ACR. One of: 69 | - `:auth` **[Default]**: redirects to the authorization endpoint of the OP 70 | - `:fail`: returns an HTTP 403 error 71 | - `:preserve_initial_request`: a boolean. Defaults to `false`. See further 72 | [Preserving request parameters](#module-preserving-request-parameters) 73 | - `:prompt`: one of the standard values (`"none"`, `"login"`, `"consent"`, or 74 | `"select_account"`) 75 | - `:prompt_callback`: a `t:opt_callback/0` that dynamically returns the prompt parameter. 76 | Called only if `:prompt` is not set 77 | - `:redirect_uri`: the redirect URI the OP has to use for redirect. If not set, 78 | defaults to 79 | `Myapp.Router.Helpers.openid_connect_redirect_uri(Myapp.Endpoint, :call)` 80 | It asumes that such a route was installed. See also `Plugoid.RedirectURI` for automatic 81 | installation of this route and the available 82 | [helpers](Plugoid.RedirectURI.html#module-determining-the-redirect-uri). 83 | - `:response_mode`: one of: 84 | - `"query"` 85 | - `"fragment"` 86 | - `"form_post"` 87 | - `:response_mode_callback`: a `t:opt_callback/0` that dynamically returns the response mode 88 | for the request. Called only if `:response_mode` is not set 89 | - `:response_type`: one of: 90 | - `"code"` (code flow) 91 | - `"id_token"` (implicit flow) 92 | - `"id_token token"` (implicit flow) 93 | - `"code token"` (hybrid flow) 94 | - `"code id_token"` (hybrid flow) 95 | - `"code id_token token"` (hybrid flow) 96 | - `:response_type_callback`: a `t:opt_callback/0` that dynamically returns the response type 97 | for the request. Called only if `:response_type` is not set 98 | - `:session_lifetime`: the local session duration in seconds. After this time interval, the 99 | user is considered unauthenticated and is redirected again to the OP. Defaults to `3600` 100 | - `:scope`: a list of scopes (`[String.t()]`) to be requested. The `"openid"` scope 101 | is automatically requested. The `"offline_access"` scope is to be added here if one 102 | wants OAuth2 tokens to remain active after the user's logout from the OP 103 | - `:server_metadata`: a `t:OIDC.server_metadata/0` of server metadata that will take precedence 104 | over those of the issuer (published on the `"https://issuer/.well-known/openid-configuration"` URI). 105 | Useful to override one or more server metadata fields 106 | - `ui_locales`: a list of UI locales 107 | - `:use_nonce`: one of: 108 | - `:when_mandatory` [*Default*]: a nonce is included when using the implicit and 109 | hybrid flows 110 | - `:always`: always include a nonce (i.e. also in the code flow in which it is 111 | optional) 112 | 113 | ## Cookie configuration 114 | 115 | Plugoid uses 2 cookies, different from the Phoenix session cookie (which allows more control 116 | over the security properties of these cookies): 117 | - authentication cookie: stores the information about authenticated session, after being 118 | successfully redirected from the OP 119 | - state session: store the information about the in-flight requests to the OP. It is set 120 | before redirecting to the OP, and then used and deleted when coming back from it 121 | 122 | It uses the standard `Plug.Session.Store` behaviour: any existing plug session stores can 123 | work with Plugoid. 124 | 125 | Plugoid cookies use the following application environment options that can be configured 126 | under the `:plugoid` key: 127 | - authentication cookie: 128 | - `:auth_cookie_name`: the name of the authentication cookie. Defaults to 129 | `"plugoid_auth"` 130 | - `:auth_cookie_opts`: `opts` arg of `Plug.Conn.put_resp_cookie/4`. Defaults to 131 | `[extra: "SameSite=Lax"]` 132 | - `:auth_cookie_store`: a module implementing the `Plug.Session.Store` behaviour. 133 | Defaults to `:ets` (which is `Plug.Session.ETS`) 134 | - `:auth_cookie_store_opts`: options for the `:auth_cookie_store`. Defaults to 135 | `[table: :plugoid_auth_cookie]`. Note that the `:plugoid_auth_cookie_store` 136 | ETS table is expected to exist, i.e. to be created beforehand. It is also not suitable for 137 | production, as cookies are never deleted 138 | - state cookie: 139 | - `:state_cookie_name`: the base name of the state cookie. Defaults to 140 | `"plugoid_state"` 141 | - `:state_cookie_opts`: `opts` arg of `Plug.Conn.put_resp_cookie/4`. Defaults to 142 | `[secure: true, extra: "SameSite=None"]`. `SameSite` is set to `None` because OpenID Connect 143 | can redirect with a HTTP post request (`"form_post"` response mode) and cross-domain cookies 144 | are not sent except with this setting 145 | - `:state_cookie_store`: a module implementing the `Plug.Session.Store` behaviour. 146 | Defaults to `:cookie` (which is `Plug.Session.COOKIE`) 147 | - `:state_cookie_store_opts`: options for the `:state_cookie_store`. Defaults to `[]` 148 | 149 | Note that by default, `:http_only` is set to `true` as well as the `:secure` cookie flag if 150 | the connection is using https. 151 | 152 | ### On state cookies 153 | Plugoid allows having several in-flight requests to one or more OPs, because a user could 154 | inadvertently open 2 pages for authentication, or authenticate in parallel to several OPs 155 | (social network OIDC providers, for instance). 156 | 157 | Also, as state cookies are by definition created by unauthenticated users, it is easy for 158 | an attacker to generate a lot of state sessions and overwhelm a relying party (the site using 159 | Plugoid), especially if the sessions are stored in the backend. 160 | 161 | This is why it is safer to store state session on the client side. By default, Plugoid uses 162 | the `:cookie` session store for state sessions: in-flight OIDC requests are stored in the 163 | browser's cookies. Note that the secret key base **must** be set in the connection. 164 | 165 | This, however, has the some limitations: 166 | - cookies are limited to 4kb of data 167 | - header size is also limited by web servers. Cowboy (Phoenix's web server) limits headers 168 | to 4kb as well 169 | 170 | To deal with the first problem, Plugoid: 171 | - limits the amount of information stored in the state session to the minimum 172 | - uses different cookies for different OIDC requests (`"plugoid_state_1"`, 173 | `"plugoid_state_2"`, `"plugoid_state_3"`, `"plugoid_state_4"` and so on) 174 | - limits the number of concurrent requests and deletes the older ones when needed, with the 175 | `:max_concurrent_state_session` option 176 | 177 | However, the 4kb limit is still low and only a few state cookies can be stored concurrently. 178 | It is recommended to test it in your application before releasing it in production to find 179 | the right `:max_concurrent_state_session`. Also note that it is possible to raise this limit 180 | in Cowboy (see [Configure max http header size in Elixir Phoenix](https://til.hashrocket.com/posts/cvkpwqampv-configure-max-http-header-size-in-elixir-phoenix)). 181 | 182 | ## Preserving request parameters 183 | 184 | When set to `true` through the `:preserve_initial_request` option, body parameters 185 | are replayed when redirected back from the OP. This is useful to avoid losing form 186 | data when the user becomes unauthenticated while filling it. 187 | 188 | Like for state session, it cannot be stored on server side because it would expose the server 189 | to DOS attacks (even more, as query and body parameters can be way larger). Therefore, 190 | these parameters are stored in the browser's session storage. The flow is as follows: 191 | - the user is not authenticated and hits a Plugoid-protected page 192 | - Plugoid displays a special blank page with javascript code. The javascript code stores 193 | the parameters in the session storage 194 | - the user is redirected to the OP (via javascript), authenticates, and is redirected to 195 | Plugoid's redirect URI 196 | - OIDC response is checked and, if valid, Plugoid's redirect URI plug redirects the user to 197 | the initial page 198 | - Plugoid displays a blank page containing javascript code, which: 199 | - redirects to the initial page with query parameters if the initial request was a `GET` 200 | request 201 | - builds an HTML form with initial body parameters and post it to the initial page (with 202 | query parameters as well) if the initial request was a `POST` request 203 | 204 | The user is always returned to the initial page with the query parameters that existed. 205 | However, when this option is enabled, the query parameters are saved in the browser 206 | session storage instead of in a cookie, which helps saving space for long URLs. 207 | 208 | Note that request data is stored **unencrypted** in the browser. If your forms may contain 209 | sensitive data, consider not using this feature. This is why this option is set to `false` 210 | by default. 211 | 212 | Limitations: 213 | - The body must be parsed (`Plug.Parsers`) before reaching the Plugoid plug 214 | - The body's encoding must be `application/x-www-form-urlencoded`. File upload using the 215 | `multipart/form-data` as the encoding is not supported, and cannot be replayed 216 | - Only `GET` and `POST` request are supported ; in other cases Plugoid will fail restoring 217 | state silently 218 | 219 | ## Client authentication 220 | 221 | Upon registration, a client registers a unique authentication scheme to be used by 222 | itself to authenticate to the OP. In other words, a client cannot use different 223 | authentication schemes on different endpoints. 224 | 225 | OAuth2 REST endpoints usually demand client authentication. Client authentication is handled 226 | by the `TeslaOAuth2ClientAuth` library. The authentication middleware to be used is 227 | determined based on the client configuration. For instance, to authenticate to the 228 | token endpoint, the `"token_endpoint_auth_method"` is used to determine which authentication 229 | middleware to use. 230 | 231 | Thus, to configure a client for Basic authentication, the client configuration callback must 232 | return a configuration like: 233 | 234 | %{ 235 | "client_id" => "some_client_id_provided_by_the_OP", 236 | "token_endpoint_auth_method" => "client_secret_basic", 237 | "client_secret" => "", 238 | ... 239 | } 240 | 241 | However, the default value for the token endpoint auth method is `"client_secret_basic"`, thus 242 | the following is enough: 243 | 244 | %{ 245 | "client_id" => "some_client_id_provided_by_the_OP", 246 | "client_secret" => "", 247 | ... 248 | } 249 | 250 | Also note that the implicit flow does not require client authentication. 251 | 252 | ## Default responses type and mode 253 | 254 | By default and if supported by the OP, these values are set to: 255 | - response mode: `"form_post"` 256 | - response type: `"id_token"` 257 | 258 | These values allows direct authentication without additional roundtrip to the server, at the 259 | expense of: 260 | - not receiving access tokens, which is fine if only authentication is needed 261 | - slightly lesser security: the ID token can be replayed, while an authorization code cannot. 262 | This can be mitigated using a JTI register (see the 263 | [Security considerations](#module-security-considerations)) section. 264 | 265 | Otherwise it falls back to the `"code"` response type. 266 | 267 | ## Session 268 | 269 | When using OpenID Connect, the OP is authoritative to determine whether the user is 270 | authenticated or not. There are 2 ways for or Relying Party (the site using a library like 271 | Plugoid) to determine it: 272 | - using [OpenID Connect Session Management](https://openid.net/specs/openid-connect-session-1_0.html), 273 | which is unsupported by Plugoid 274 | - periodically redirecting to the OP to check for authentication. If the user is authenticated 275 | on the OP, he's not asked to reauthenticate (in the browser it materializes by being swiftly 276 | redirected to the OP and back to the relying party (the site using `Plugoid`)). 277 | 278 | By default, Plugoid cookies have no timeout, and are therefore session cookies. When the user 279 | closes his browser, there are destroyed. 280 | 281 | However, another parameter is taken into account: the `:session_lifetime` parameter, which 282 | defaults to 1 hour. This ensures that a user can not remain indefinitely authenticated, and 283 | prevents an attacker from using a stolen cookie for too long. 284 | 285 | That is, authenticated session cookie's lifetime is not correlated from the `:session_lifetime` 286 | and keeping this cookie as a session cookie is fine - it's the OP's work to handle long-lived 287 | authenticated sessions. 288 | 289 | ## Logout 290 | 291 | Plugoid does not support OpenID Connect logout. However, the functions: 292 | - `Plugoid.logout/1` 293 | - `Plugoid.logout/2` 294 | 295 | allow loging out a user **locally** by removing authenticated session data or the whole 296 | authentication cookie and session. 297 | 298 | Note that, however, the user will be redirected again to the OP (and might be seamlessly 299 | authenticated, if his session is active on the OP) when reaching a path protected by Plugoid. 300 | 301 | ## Error handling 302 | 303 | Errors can occur: 304 | - when redirected back from the OP. This is an OP error (for instance the user denied the 305 | authorization to share his personal information) 306 | - when analyzing the request back from the OP, if an error occurs (for instance, the ID token 307 | was expired) 308 | - ACR is no sufficient (user is authenticated, but not authorized) 309 | - when `:on_unauthenticated` or `:on_unauthorized` are set to `:fail` 310 | 311 | Depending on the case, Plugoid renders one of the following templates: 312 | - `:"401"` 313 | - `:"403"` 314 | - `:"500"` 315 | 316 | It also sets the `@error` assign in them to an **exception**, one of Plugoid or one of the 317 | `OIDC` library. 318 | 319 | When the error occured on the OP, the `:401` error template is called with an 320 | `OIDC.Auth.OPResponseError` exception. 321 | 322 | ## Security considerations 323 | 324 | - Consider renaming the cookies to make it harder to detect which library is used 325 | - Consider setting the `:domain` and `:path` settings of the cookies 326 | - When using the implicit or hybrid flow, consider setting a JTI register to prevent replay 327 | attacks of ID tokens. This is configured in the `Plugoid.RedirectURI` plug 328 | - Consider filtering Phoenix's parameters in the logs. To do so, add in the configuration 329 | file `config/config.exs` the following line: 330 | 331 | ```elixir 332 | config :phoenix, :filter_parameters, ["id_token", "code", "token"] 333 | ``` 334 | """ 335 | 336 | defmodule AuthenticationRequiredError do 337 | defexception message: "authentication is required to access this page" 338 | end 339 | 340 | defmodule UnauthorizedError do 341 | defexception message: "access to this page is denied" 342 | end 343 | 344 | alias OIDC.Auth.OPResponseError 345 | 346 | alias Plugoid.{ 347 | OIDCRequest, 348 | Session.AuthSession, 349 | Session.StateSession, 350 | Utils 351 | } 352 | 353 | @behaviour Plug 354 | 355 | @type opts :: [opt | OIDC.Auth.challenge_opt()] 356 | 357 | @type opt :: 358 | {:acr_values_callback, opt_callback()} 359 | | {:claims_callback, opt_callback()} 360 | | {:error_view, module()} 361 | | {:id_token_hint_callback, opt_callback()} 362 | | {:login_hint_callback, opt_callback()} 363 | | {:max_concurrent_state_session, non_neg_integer() | nil} 364 | | {:on_unauthenticated, :auth | :fail | :pass} 365 | | {:on_unauthorized, :auth | :fail} 366 | | {:prompt_callback, opt_callback()} 367 | | {:redirect_uri, String.t()} 368 | | {:redirect_uri_callback, opt_callback()} 369 | | {:response_mode_callback, opt_callback()} 370 | | {:response_type_callback, opt_callback()} 371 | | {:server_metadata, OIDC.server_metadata()} 372 | | {:session_lifetime, non_neg_integer()} 373 | 374 | @type opt_callback :: (Plug.Conn.t(), opts() -> any()) 375 | 376 | @implicit_response_types ["id_token", "id_token token"] 377 | @hybrid_response_types ["code id_token", "code token", "code id_token token"] 378 | 379 | @impl Plug 380 | def init(opts) do 381 | unless opts[:issuer], do: raise("Missing issuer") 382 | unless opts[:client_id], do: raise("Missing client_id") 383 | unless opts[:client_config], do: raise("Missing client configuration callback") 384 | 385 | opts 386 | |> Keyword.put_new(:id_token_iat_max_time_gap, 30) 387 | |> Keyword.put_new(:max_concurrent_state_session, 4) 388 | |> Keyword.put_new(:on_unauthenticated, :auth) 389 | |> Keyword.put_new(:on_unauthorized, :auth) 390 | |> Keyword.put_new(:preserve_initial_request, false) 391 | |> Keyword.put_new(:redirect_uri_callback, &__MODULE__.redirect_uri/2) 392 | |> Keyword.put_new(:response_mode_callback, &__MODULE__.response_mode/2) 393 | |> Keyword.put_new(:response_type_callback, &__MODULE__.response_type/2) 394 | |> Keyword.put_new(:session_lifetime, 3600) 395 | end 396 | 397 | @impl Plug 398 | def call(%Plug.Conn{private: %{plugoid_authenticated: true}} = conn, _opts) do 399 | conn 400 | end 401 | 402 | def call(conn, opts) do 403 | case Plug.Conn.fetch_query_params(conn) do 404 | %Plug.Conn{query_params: %{"redirected" => _}} = conn -> 405 | if opts[:preserve_initial_request] do 406 | conn 407 | |> Phoenix.Controller.put_view(PlugoidWeb.PreserveRequestParamsView) 408 | |> Phoenix.Controller.render("restore.html") 409 | |> Plug.Conn.halt() 410 | else 411 | conn 412 | |> maybe_set_authenticated(opts) 413 | |> do_call(opts) 414 | end 415 | 416 | %Plug.Conn{query_params: %{"oidc_error" => error_token}} -> 417 | {:ok, token_content} = 418 | Phoenix.Token.verify(conn, "plugoid error token", error_token, max_age: 60) 419 | 420 | error = :erlang.binary_to_term(token_content) 421 | 422 | respond_unauthorized(conn, error, opts) 423 | 424 | conn -> 425 | conn 426 | |> maybe_set_authenticated(opts) 427 | |> do_call(opts) 428 | end 429 | end 430 | 431 | @spec do_call(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t() 432 | defp do_call(conn, opts) do 433 | authenticated = authenticated?(conn) 434 | authorized = authorized?(conn, opts) 435 | on_unauthenticated = opts[:on_unauthenticated] 436 | on_unauthorized = opts[:on_unauthorized] 437 | redirected = conn.query_params["redirected"] != nil 438 | 439 | cond do 440 | authenticated and authorized -> 441 | conn 442 | 443 | not authenticated and not redirected and on_unauthenticated == :auth -> 444 | authenticate(conn, opts) 445 | 446 | not authenticated and not redirected and on_unauthenticated == :pass -> 447 | conn 448 | 449 | not authenticated and not redirected and on_unauthenticated == :fail -> 450 | respond_unauthorized(conn, %AuthenticationRequiredError{}, opts) 451 | 452 | not authenticated and redirected and on_unauthenticated in [:auth, :fail] -> 453 | respond_unauthorized(conn, %AuthenticationRequiredError{}, opts) 454 | 455 | not authenticated and redirected and on_unauthenticated in :pass -> 456 | conn 457 | 458 | authenticated and not authorized and not redirected and on_unauthorized == :auth -> 459 | authenticate(conn, opts) 460 | 461 | authenticated and not authorized -> 462 | respond_forbidden(conn, opts) 463 | end 464 | end 465 | 466 | @spec maybe_set_authenticated(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t() 467 | defp maybe_set_authenticated(conn, opts) do 468 | case AuthSession.info(conn, opts[:issuer]) do 469 | %AuthSession.Info{} = auth_session_info -> 470 | now_monotonic = System.monotonic_time(:second) 471 | 472 | if now_monotonic < auth_session_info.auth_time_monotonic + opts[:session_lifetime] do 473 | conn 474 | |> Plug.Conn.put_private(:plugoid_authenticated, true) 475 | |> Plug.Conn.put_private(:plugoid_auth_iss, opts[:issuer]) 476 | |> Plug.Conn.put_private(:plugoid_auth_sub, auth_session_info.sub) 477 | else 478 | Plug.Conn.put_private(conn, :plugoid_authenticated, false) 479 | end 480 | 481 | nil -> 482 | Plug.Conn.put_private(conn, :plugoid_authenticated, false) 483 | end 484 | end 485 | 486 | @spec authorized?(Plug.Conn.t(), opts()) :: boolean() 487 | defp authorized?(%Plug.Conn{private: %{plugoid_authenticated: true}} = conn, opts) do 488 | %AuthSession.Info{acr: current_acr} = AuthSession.info(conn, opts[:issuer]) 489 | 490 | case opts[:claims] do 491 | %{ 492 | "id_token" => %{ 493 | "acr" => %{ 494 | "essential" => true, 495 | "value" => required_acr 496 | } 497 | } 498 | } -> 499 | current_acr == required_acr 500 | 501 | %{ 502 | "id_token" => %{ 503 | "acr" => %{ 504 | "essential" => true, 505 | "values" => acceptable_acrs 506 | } 507 | } 508 | } -> 509 | current_acr in acceptable_acrs 510 | 511 | _ -> 512 | true 513 | end 514 | end 515 | 516 | defp authorized?(_conn, _opts) do 517 | false 518 | end 519 | 520 | @spec respond_unauthorized( 521 | Plug.Conn.t(), 522 | OPResponseError.t() | Exception.t(), 523 | opts() 524 | ) :: Plug.Conn.t() 525 | defp respond_unauthorized(conn, error, opts) do 526 | conn 527 | |> Plug.Conn.put_status(:unauthorized) 528 | |> Phoenix.Controller.put_view(error_view(conn, opts)) 529 | |> Phoenix.Controller.render(:"401", error: error) 530 | |> Plug.Conn.halt() 531 | end 532 | 533 | @spec respond_forbidden(Plug.Conn.t(), opts()) :: Plug.Conn.t() 534 | defp respond_forbidden(conn, opts) do 535 | conn 536 | |> Plug.Conn.put_status(:forbidden) 537 | |> Phoenix.Controller.put_view(error_view(conn, opts)) 538 | |> Phoenix.Controller.render(:"403", error: %UnauthorizedError{}) 539 | |> Plug.Conn.halt() 540 | end 541 | 542 | @doc """ 543 | Triggers authentication by redirecting to the OP 544 | 545 | This function, initially only used internally, can be used to trigger redirect 546 | to the OP. This allows more fine control on when to redirect user, or to which 547 | OP redirect this user. 548 | 549 | It is recommended to not use it if a plug-based approach can be used instead. 550 | For example, you can redirect to a Plugoid-protected route (`/route/auth_with_op1`) 551 | to automatically have Plugoid redirect to a specific OP, instead of using this 552 | function. 553 | """ 554 | @spec authenticate( 555 | Plug.Conn.t(), 556 | opts() 557 | ) :: Plug.Conn.t() 558 | def authenticate(conn, opts) do 559 | opts = 560 | Enum.reduce( 561 | [ 562 | :acr_values, 563 | :claims, 564 | :id_token_hint, 565 | :login_hint, 566 | :prompt, 567 | :redirect_uri, 568 | :response_mode, 569 | :response_type 570 | ], 571 | opts, 572 | &apply_opt_callback(&2, &1, conn) 573 | ) 574 | 575 | challenge = OIDC.Auth.gen_challenge(opts) 576 | 577 | op_request_uri = OIDC.Auth.request_uri(challenge, opts) |> URI.to_string() 578 | 579 | conn = 580 | StateSession.store_oidc_request( 581 | conn, 582 | %OIDCRequest{ 583 | challenge: challenge, 584 | initial_request_path: conn.request_path, 585 | initial_request_params: initial_request_params(conn, opts) 586 | }, 587 | opts[:max_concurrent_state_session] 588 | ) 589 | 590 | if opts[:preserve_initial_request] do 591 | conn 592 | |> Phoenix.Controller.put_view(PlugoidWeb.PreserveRequestParamsView) 593 | |> Phoenix.Controller.render("save.html", conn: conn, op_request_uri: op_request_uri) 594 | |> Plug.Conn.halt() 595 | else 596 | conn 597 | |> Phoenix.Controller.redirect(external: op_request_uri) 598 | |> Plug.Conn.halt() 599 | end 600 | end 601 | 602 | @spec apply_opt_callback(opts(), atom(), Plug.Conn.t()) :: opts() 603 | defp apply_opt_callback(opts, opt_name, conn) do 604 | if opts[opt_name] do 605 | opts 606 | else 607 | opt_callback_name = String.to_atom(Atom.to_string(opt_name) <> "_callback") 608 | 609 | case opts[opt_callback_name] do 610 | callback when is_function(callback, 2) -> 611 | Keyword.put(opts, opt_name, callback.(conn, opts)) 612 | 613 | _ -> 614 | opts 615 | end 616 | end 617 | end 618 | 619 | defp initial_request_params(conn, opts) do 620 | if opts[:preserve_initial_request] do 621 | %{} 622 | else 623 | conn.query_params 624 | end 625 | end 626 | 627 | # Returns a response type supported by the OP 628 | # In order of preference: 629 | # - `"id_token"`: allows authentication in one unique round-trip 630 | # - `"code"`: forces client authentication that can be considered an additional 631 | # layer of security (when simply redirecting to an URI is not trusted) 632 | # - or the first supported response type set in the OP metadata 633 | @doc false 634 | @spec response_type(Plug.Conn.t(), opts()) :: String.t() 635 | def response_type(_conn, opts) do 636 | response_types_supported = 637 | Utils.server_metadata(opts)["response_types_supported"] || 638 | raise "Unable to retrieve `response_types_supported` from server metadata or configuration" 639 | 640 | response_modes_supported = Utils.server_metadata(opts)["response_modes_supported"] || [] 641 | 642 | cond do 643 | "id_token" in response_types_supported and "form_post" in response_modes_supported -> 644 | "id_token" 645 | 646 | "code" in response_types_supported -> 647 | "code" 648 | 649 | true -> 650 | List.first(response_types_supported) 651 | end 652 | end 653 | 654 | # Returns the response mode from the options 655 | # In the implicit and hybrid flows, returns `"form_post"` if supported by the server, `"query"` 656 | # otherwise. In the code flow, returns `nil` (the default used by the server is `"query"`). 657 | @doc false 658 | @spec response_mode(Plug.Conn.t(), opts()) :: String.t() | nil 659 | def response_mode(conn, opts) do 660 | response_type = opts[:response_type] || response_type(conn, opts) 661 | response_modes_supported = Utils.server_metadata(opts)["response_modes_supported"] || [] 662 | 663 | if response_type in @implicit_response_types or response_type in @hybrid_response_types do 664 | if "form_post" in response_modes_supported do 665 | "form_post" 666 | else 667 | "query" 668 | end 669 | end 670 | end 671 | 672 | @doc false 673 | @spec redirect_uri(Plug.Conn.t() | module(), opts()) :: String.t() 674 | def redirect_uri(%Plug.Conn{} = conn, opts) do 675 | router = Phoenix.Controller.router_module(conn) 676 | 677 | base_redirect_uri = 678 | apply( 679 | Module.concat(router, Helpers), 680 | :openid_connect_redirect_uri_url, 681 | [Phoenix.Controller.endpoint_module(conn), :call] 682 | ) 683 | 684 | base_redirect_uri <> "?iss=" <> URI.encode(opts[:issuer]) 685 | end 686 | 687 | @doc """ 688 | Returns `true` if the connection is authenticated with `Plugoid`, `false` otherwise 689 | """ 690 | @spec authenticated?(Plug.Conn.t()) :: boolean() 691 | def authenticated?(conn), do: conn.private[:plugoid_authenticated] == true 692 | 693 | @doc """ 694 | Returns the issuer which has authenticated the current authenticated user, or `nil` if 695 | the user is unauthenticated 696 | """ 697 | @spec issuer(Plug.Conn.t()) :: String.t() | nil 698 | def issuer(conn), do: conn.private[:plugoid_auth_iss] 699 | 700 | @doc """ 701 | Returns the subject (OP's "user id") of current authenticated user, or `nil` if 702 | the user is unauthenticated 703 | """ 704 | @spec subject(Plug.Conn.t()) :: String.t() | nil 705 | def subject(conn), do: conn.private[:plugoid_auth_sub] 706 | 707 | @doc """ 708 | Returns `true` if the current request happens after a redirection from the OP, `false` 709 | otherwise 710 | """ 711 | @spec redirected_from_OP?(Plug.Conn.t()) :: boolean() 712 | def redirected_from_OP?(conn) do 713 | case conn.params do 714 | %{"redirected" => _} -> 715 | true 716 | 717 | %{"oidc_error" => _} -> 718 | true 719 | 720 | %{"restored" => _} -> 721 | true 722 | 723 | _ -> 724 | false 725 | end 726 | end 727 | 728 | @doc """ 729 | Logs out a user from an issuer 730 | 731 | The connection should be eventually sent to have the cookie updated 732 | """ 733 | @spec logout(Plug.Conn.t(), OIDC.issuer()) :: Plug.Conn.t() 734 | def logout(conn, issuer), do: AuthSession.set_unauthenticated(conn, issuer) 735 | 736 | @doc """ 737 | Logs out a user from all issuers 738 | 739 | The connection should be eventually sent to have the cookie unset 740 | """ 741 | @spec logout(Plug.Conn.t()) :: Plug.Conn.t() 742 | def logout(conn), do: AuthSession.destroy(conn) 743 | 744 | @spec error_view(Plug.Conn.t(), opts()) :: module() 745 | defp error_view(conn, opts) do 746 | case opts[:error_view] do 747 | nil -> 748 | Utils.error_view_from_conn(conn) 749 | 750 | view when is_atom(view) -> 751 | view 752 | end 753 | end 754 | end 755 | -------------------------------------------------------------------------------- /lib/plugoid/oidc_request.ex: -------------------------------------------------------------------------------- 1 | defmodule Plugoid.OIDCRequest do 2 | @moduledoc false 3 | 4 | @enforce_keys [:challenge, :initial_request_path] 5 | 6 | defstruct [ 7 | :challenge, 8 | :initial_request_path, 9 | :initial_request_params 10 | ] 11 | 12 | @type t :: %__MODULE__{ 13 | challenge: OIDC.Auth.Challenge.t(), 14 | initial_request_path: binary(), 15 | initial_request_params: Plug.Conn.query_params() 16 | } 17 | end 18 | -------------------------------------------------------------------------------- /lib/plugoid/redirect_uri.ex: -------------------------------------------------------------------------------- 1 | defmodule Plugoid.RedirectURI do 2 | @moduledoc """ 3 | Plug to configure the application redirect URI 4 | 5 | An OAuth2 / OpenID Connect redirect URI is a vanity, non-dynamic URI. The authorization 6 | server redirects to this URI after authentication and authorization success or failure. 7 | 8 | ## Automatic configuration in a router 9 | 10 | defmodule Myapp.Router do 11 | use Plugoid.RedirectURI 12 | end 13 | 14 | installs a route to `/openid_connect_redirect_uri` in a Phoenix router. 15 | 16 | ## Determining the redirect URI 17 | 18 | When using `Plugoid.RedirectURI`, an `plugoid_redirect_uri/1` function is automatically 19 | installed in the router. It takes the endpoint as the first parameter and the issuer 20 | as the second: 21 | 22 | iex> PlugoidDemoWeb.Router.plugoid_redirect_uri(PlugoidDemoWeb.Endpoint) 23 | "http://localhost:4000/openid_connect_redirect_uri" 24 | 25 | It can be called without the endpoint, in which case it is inferred from the router's 26 | module name: 27 | 28 | iex> PlugoidDemoWeb.Router.plugoid_redirect_uri() 29 | "http://localhost:4000/openid_connect_redirect_uri" 30 | 31 | ## Options 32 | 33 | - `:error_view`: the error view to be called in case of error. The `:"500"` template is 34 | rendered in case of error (bascially, when the `state` parameter is missing from the response). 35 | If not set, it will be automatically set to `MyApp.ErrorView` where `MyApp` is the 36 | base module name of the application 37 | - `:jti_register`: a module implementing the `JTIRegister` behaviour, to check the ID 38 | Token against replay attack when a nonce is used (in the implicit and hybrid flows). 39 | See also [`JTIRegister`](https://github.com/tanguilp/jti_register) 40 | - `:path`: the path of the redirect URI. Defaults to `"openid_connect_redirect_uri"` 41 | - `:token_callback`: a `t:token_callback/0` function to which are passed the received 42 | tokens, for further use (for example, to store a refresh token) and returns the `t:Plug.Conn.t/0` 43 | 44 | Options of `t:OIDC.Auth.verify_opts/0` which will be passed to `OIDC.Auth.verify_response/3`. 45 | """ 46 | 47 | @behaviour Plug 48 | 49 | alias OIDC.Auth.{ 50 | Challenge, 51 | OPResponseSuccess 52 | } 53 | 54 | alias Plugoid.{ 55 | Session.AuthSession, 56 | Session.StateSession, 57 | Utils 58 | } 59 | 60 | @type opts :: [opt() | OIDC.Auth.verify_opt()] 61 | @type opt :: 62 | {:error_view, module()} 63 | | {:jti_register, module()} 64 | | {:path, String.t()} 65 | | {:token_callback, token_callback()} 66 | 67 | @type token_callback :: 68 | (Plug.Conn.t(), 69 | OPResponseSuccess.t(), 70 | issuer :: String.t(), 71 | client_id :: String.t(), 72 | opts() -> 73 | Plug.Conn.t()) 74 | 75 | defmodule MissingStateParamaterError do 76 | defexception message: "state parameter is missing from OP's response" 77 | end 78 | 79 | defmodule MissingRedirectQueryParamError do 80 | defexception message: "a query param of the requested redirect URI is missing in the response" 81 | end 82 | 83 | defmacro __using__(opts) do 84 | quote do 85 | def plugoid_redirect_uri(endpoint \\ nil) do 86 | endpoint = 87 | if endpoint do 88 | endpoint 89 | else 90 | Module.split(__MODULE__) 91 | |> List.pop_at(-1) 92 | |> elem(1) 93 | |> Kernel.++([Endpoint]) 94 | |> Module.safe_concat() 95 | end 96 | 97 | apply( 98 | Module.concat(__MODULE__, Helpers), 99 | :openid_connect_redirect_uri_url, 100 | [endpoint, :call] 101 | ) 102 | end 103 | 104 | pipeline :oidc_redirect_pipeline do 105 | plug(:accepts, ["html"]) 106 | plug(:fetch_query_params) 107 | plug(Plug.Parsers, parsers: [:urlencoded], pass: ["*/*"]) 108 | end 109 | 110 | scope unquote(opts[:path]) || "/openid_connect_redirect_uri", Plugoid do 111 | pipe_through(:oidc_redirect_pipeline) 112 | 113 | get("/", RedirectURI, :call, 114 | as: :openid_connect_redirect_uri, 115 | private: %{plugoid: unquote(opts)} 116 | ) 117 | 118 | post("/", RedirectURI, :call, 119 | as: :openid_connect_redirect_uri, 120 | private: %{plugoid: unquote(opts)} 121 | ) 122 | end 123 | end 124 | end 125 | 126 | @impl true 127 | def init(opts), do: opts 128 | 129 | @impl true 130 | def call(conn, _opts) do 131 | opts = conn.private[:plugoid] 132 | 133 | with {:ok, op_response} <- extract_params(conn), 134 | %{"state" => state} = op_response, 135 | {:ok, {conn, request}} <- StateSession.get_and_delete_oidc_request(conn, state) do 136 | case OIDC.Auth.verify_response(op_response, request.challenge, opts) do 137 | {:ok, %OPResponseSuccess{} = response} -> 138 | maybe_register_nonce(response.id_token_claims, opts) 139 | 140 | conn 141 | |> maybe_execute_token_callback(response, request.challenge, opts) 142 | |> AuthSession.set_authenticated(request.challenge.issuer, response) 143 | |> Phoenix.Controller.redirect(to: initial_request_path(request, %{redirected: nil})) 144 | 145 | {:error, error} -> 146 | # we compress to the maximum to avoid browser URL length limitations 147 | error_token = 148 | Phoenix.Token.sign( 149 | conn, 150 | "plugoid error token", 151 | :erlang.term_to_binary(error, compressed: 9) 152 | ) 153 | 154 | Phoenix.Controller.redirect(conn, 155 | to: initial_request_path(request, %{oidc_error: error_token}) 156 | ) 157 | end 158 | else 159 | {:error, reason} -> 160 | conn 161 | |> Plug.Conn.put_status(:internal_server_error) 162 | |> Phoenix.Controller.put_view(error_view(conn)) 163 | |> Phoenix.Controller.render(:"500", error: reason) 164 | end 165 | end 166 | 167 | defp initial_request_path(request, additional_params) do 168 | all_params = Map.merge(request.initial_request_params, additional_params) 169 | 170 | if all_params == %{} do 171 | request.initial_request_path 172 | else 173 | request.initial_request_path <> "?" <> Plug.Conn.Query.encode(all_params) 174 | end 175 | end 176 | 177 | @spec extract_params(Plug.Conn.t()) :: {:ok, map()} | {:error, atom()} 178 | defp extract_params(conn) do 179 | case conn.method do 180 | "GET" -> 181 | conn.query_params 182 | 183 | "POST" -> 184 | conn.body_params 185 | end 186 | |> case do 187 | %{"state" => _} = params -> 188 | {:ok, params} 189 | 190 | %{} -> 191 | {:error, %MissingStateParamaterError{}} 192 | end 193 | end 194 | 195 | @spec error_view(Plug.Conn.t()) :: module() 196 | defp error_view(conn), 197 | do: conn.private[:plugoid][:error_view] || Utils.error_view_from_conn(conn) 198 | 199 | @spec maybe_register_nonce(OIDC.id_token_claims(), Keyword.t()) :: any() 200 | defp maybe_register_nonce(%{"nonce" => nonce, "exp" => exp}, opts) do 201 | case opts[:jti_register] do 202 | impl when is_atom(impl) -> 203 | impl.register(nonce, exp) 204 | 205 | _ -> 206 | :ok 207 | end 208 | end 209 | 210 | defp maybe_register_nonce(_, _) do 211 | :ok 212 | end 213 | 214 | @spec maybe_execute_token_callback( 215 | Plug.Conn.t(), 216 | OPResponseSuccess.t(), 217 | Challenge.t(), 218 | opts() 219 | ) :: Plug.Conn.t() 220 | defp maybe_execute_token_callback(conn, response, challenge, opts) do 221 | if opts[:token_callback] do 222 | opts[:token_callback].( 223 | conn, 224 | response, 225 | challenge.issuer, 226 | challenge.client_id, 227 | opts[:token_callback_opts] || [] 228 | ) 229 | else 230 | conn 231 | end 232 | end 233 | end 234 | -------------------------------------------------------------------------------- /lib/plugoid/session/auth_session.ex: -------------------------------------------------------------------------------- 1 | defmodule Plugoid.Session.AuthSession do 2 | @moduledoc false 3 | 4 | alias OIDC.Auth.OPResponseSuccess 5 | 6 | defmodule Info do 7 | @moduledoc false 8 | 9 | @enforce_keys [:sub, :auth_time_monotonic] 10 | 11 | defstruct [:sub, :acr, :auth_time_monotonic] 12 | 13 | @type t :: %__MODULE__{ 14 | sub: String.t(), 15 | acr: String.t() | nil, 16 | auth_time_monotonic: integer() 17 | } 18 | end 19 | 20 | @doc false 21 | @spec set_authenticated( 22 | Plug.Conn.t(), 23 | issuer :: String.t(), 24 | OPResponseSuccess.t() 25 | ) :: Plug.Conn.t() 26 | def set_authenticated(conn, issuer, op_response) do 27 | {cookie_name, cookie_opts, cookie_store, cookie_store_opts} = cookie_config() 28 | 29 | session_info = %Info{ 30 | sub: op_response.id_token_claims["sub"], 31 | acr: op_response.id_token_claims["acr"], 32 | auth_time_monotonic: System.monotonic_time(:second) 33 | } 34 | 35 | conn = Plug.Conn.fetch_cookies(conn) 36 | 37 | sid = conn.req_cookies[cookie_name] 38 | 39 | session_data = 40 | case sid do 41 | nil -> 42 | Map.put(%{}, issuer, session_info) 43 | 44 | sid -> 45 | case cookie_store.get(conn, sid, cookie_store_opts) do 46 | {nil, _} -> 47 | Map.put(%{}, issuer, session_info) 48 | 49 | {_, session_data} -> 50 | Map.put(session_data, issuer, session_info) 51 | end 52 | end 53 | 54 | sid = cookie_store.put(conn, sid, session_data, cookie_store_opts) 55 | 56 | Plug.Conn.put_resp_cookie(conn, cookie_name, sid, cookie_opts) 57 | end 58 | 59 | @spec set_unauthenticated(Plug.Conn.t(), OIDC.issuer()) :: Plug.Conn.t() 60 | def set_unauthenticated(conn, issuer) do 61 | {cookie_name, cookie_opts, cookie_store, cookie_store_opts} = cookie_config() 62 | 63 | conn = Plug.Conn.fetch_cookies(conn) 64 | 65 | sid = conn.req_cookies[cookie_name] 66 | 67 | case sid do 68 | nil -> 69 | conn 70 | 71 | sid -> 72 | case cookie_store.get(conn, sid, cookie_store_opts) do 73 | {nil, _} -> 74 | conn 75 | 76 | {_, session_data} -> 77 | session_data = Map.delete(session_data, issuer) 78 | 79 | sid = cookie_store.put(conn, sid, session_data, cookie_store_opts) 80 | 81 | Plug.Conn.put_resp_cookie(conn, cookie_name, sid, cookie_opts) 82 | end 83 | end 84 | end 85 | 86 | @spec destroy(Plug.Conn.t()) :: Plug.Conn.t() 87 | def destroy(conn) do 88 | {cookie_name, cookie_opts, cookie_store, cookie_store_opts} = cookie_config() 89 | 90 | conn = Plug.Conn.fetch_cookies(conn) 91 | 92 | sid = conn.req_cookies[cookie_name] 93 | 94 | case sid do 95 | nil -> 96 | conn 97 | 98 | sid -> 99 | case cookie_store.get(conn, sid, cookie_store_opts) do 100 | {nil, _} -> 101 | Plug.Conn.delete_resp_cookie(conn, cookie_name, cookie_opts) 102 | 103 | {_, _session_data} -> 104 | cookie_store.delete(conn, sid, cookie_store_opts) 105 | 106 | Plug.Conn.delete_resp_cookie(conn, cookie_name, cookie_opts) 107 | end 108 | end 109 | end 110 | 111 | @spec info(Plug.Conn.t(), issuer :: String.t()) :: %Info{} | nil 112 | def info(conn, issuer) do 113 | {cookie_name, _cookie_opts, cookie_store, cookie_store_opts} = cookie_config() 114 | 115 | conn = Plug.Conn.fetch_cookies(conn) 116 | 117 | sid = conn.req_cookies[cookie_name] 118 | 119 | case sid do 120 | nil -> 121 | nil 122 | 123 | sid -> 124 | case cookie_store.get(conn, sid, cookie_store_opts) do 125 | {nil, _} -> 126 | nil 127 | 128 | {_, session_data} -> 129 | session_data[issuer] 130 | end 131 | end 132 | end 133 | 134 | defp cookie_config() do 135 | name = Application.get_env(:plugoid, :auth_cookie_name, "plugoid_auth") 136 | opts = Application.get_env(:plugoid, :auth_cookie_opts, extra: "SameSite=Lax") 137 | store = Application.get_env(:plugoid, :auth_cookie_store, :ets) |> Plug.Session.Store.get() 138 | 139 | store_opts = 140 | Application.get_env(:plugoid, :auth_cookie_store_opts, table: :plugoid_auth_cookie) 141 | |> store.init() 142 | 143 | {name, opts, store, store_opts} 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /lib/plugoid/session/state_session.ex: -------------------------------------------------------------------------------- 1 | defmodule Plugoid.Session.StateSession do 2 | @moduledoc false 3 | 4 | alias OIDC.Auth.Challenge 5 | alias Plugoid.OIDCRequest 6 | 7 | @type redirect_token :: String.t() 8 | 9 | defmodule CookieNotFoundError do 10 | defexception message: "state cookie was not found" 11 | end 12 | 13 | @spec store_oidc_request(Plug.Conn.t(), OIDCRequest.t(), non_neg_integer()) :: Plug.Conn.t() 14 | def store_oidc_request(conn, oidc_request, max_concurrent_state_session) do 15 | {base_cookie_name, cookie_opts, cookie_store, cookie_store_opts} = cookie_config() 16 | 17 | cookie_name = gen_cookie_name(conn, base_cookie_name) 18 | 19 | conn = Plug.Conn.fetch_cookies(conn) 20 | 21 | # deleting excess cookies in regards to max_concurrent_state_session 22 | conn = 23 | Enum.map(conn.req_cookies, fn {cookie_name, _} -> cookie_name end) 24 | |> Enum.filter(fn cookie_name -> String.starts_with?(cookie_name, base_cookie_name) end) 25 | |> Enum.map(fn cookie_name -> String.trim_leading(cookie_name, base_cookie_name <> "_") end) 26 | |> Enum.sort_by(fn cookie_number -> String.to_integer(cookie_number) end) 27 | |> Enum.split(1 - max_concurrent_state_session) 28 | |> elem(0) 29 | |> Enum.reduce(conn, fn cookie_number, acc -> 30 | cookie_name = base_cookie_name <> "_" <> cookie_number 31 | 32 | cookie_store.delete(acc, cookie_name, cookie_store_opts) 33 | 34 | Plug.Conn.delete_resp_cookie(acc, base_cookie_name <> "_" <> cookie_number) 35 | end) 36 | 37 | sid = cookie_store.put(conn, nil, oidc_request, cookie_store_opts) 38 | 39 | Plug.Conn.put_resp_cookie(conn, cookie_name, sid, cookie_opts) 40 | end 41 | 42 | @spec get_and_delete_oidc_request( 43 | Plug.Conn.t(), 44 | state :: String.t() 45 | ) :: {:ok, {Plug.Conn.t(), OIDCRequest.t()}} | {:error, atom()} 46 | def get_and_delete_oidc_request(conn, state) do 47 | {base_cookie_name, cookie_opts, cookie_store, cookie_store_opts} = cookie_config() 48 | 49 | conn = Plug.Conn.fetch_cookies(conn) 50 | 51 | Enum.find_value( 52 | conn.req_cookies, 53 | {:error, %CookieNotFoundError{}}, 54 | fn {cookie_name, _cookie_value} -> 55 | if String.starts_with?(cookie_name, base_cookie_name <> "_") do 56 | sid = conn.req_cookies[cookie_name] 57 | 58 | case cookie_store.get(conn, sid, cookie_store_opts) do 59 | {sid, %OIDCRequest{challenge: %Challenge{state_param: ^state}} = oidc_request} -> 60 | {:ok, {sid, cookie_name, oidc_request}} 61 | 62 | _ -> 63 | nil 64 | end 65 | end 66 | end 67 | ) 68 | |> case do 69 | {:ok, {sid, cookie_name, oidc_request}} -> 70 | cookie_store.delete(conn, sid, cookie_store_opts) 71 | 72 | conn = Plug.Conn.delete_resp_cookie(conn, cookie_name, cookie_opts) 73 | 74 | {:ok, {conn, oidc_request}} 75 | 76 | {:error, _} = error -> 77 | error 78 | end 79 | end 80 | 81 | @spec gen_cookie_name(Plug.Conn.t(), String.t()) :: String.t() 82 | defp gen_cookie_name(conn, base_cookie_name) do 83 | current_num = 84 | Enum.reduce( 85 | conn.req_cookies, 86 | 0, 87 | fn {cookie_name, _cookie_value}, acc -> 88 | if String.starts_with?(cookie_name, base_cookie_name <> "_") do 89 | cookie_name 90 | |> String.trim_leading(base_cookie_name <> "_") 91 | |> Integer.parse() 92 | |> case do 93 | {num, _} when is_integer(num) and num > acc -> 94 | num 95 | 96 | _ -> 97 | acc 98 | end 99 | else 100 | acc 101 | end 102 | end 103 | ) 104 | 105 | base_cookie_name <> "_" <> to_string(current_num + 1) 106 | end 107 | 108 | defp cookie_config() do 109 | name = Application.get_env(:plugoid, :state_cookie_name, "plugoid_state") 110 | opts = Application.get_env(:plugoid, :state_cookie_opts, secure: true, extra: "SameSite=None") 111 | 112 | store = 113 | Application.get_env(:plugoid, :state_cookie_store, :cookie) |> Plug.Session.Store.get() 114 | 115 | store_opts = Application.get_env(:plugoid, :state_cookie_store_opts, []) |> store.init() 116 | 117 | {name, opts, store, store_opts} 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/plugoid/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Plugoid.Utils do 2 | @moduledoc false 3 | 4 | @spec server_metadata(Plugoid.opts()) :: %{optional(String.t()) => any()} 5 | def server_metadata(opts) do 6 | Oauth2MetadataUpdater.get_metadata(opts[:issuer], opts[:oauth2_metadata_updater_opts] || []) 7 | |> case do 8 | {:ok, loaded_server_metadata} -> 9 | Map.merge(loaded_server_metadata, opts[:server_metadata] || %{}) 10 | 11 | {:error, _} -> 12 | opts[:server_metadata] || %{} 13 | end 14 | end 15 | 16 | @spec op_jwks(Plugoid.opts()) :: [JOSEUtils.JWKS.t()] 17 | def op_jwks(opts) do 18 | if opts[:jwks] do 19 | opts[:jwks] 20 | else 21 | jwks_uri = 22 | if opts[:jwks_uri] do 23 | opts[:jwks_uri] 24 | else 25 | Oauth2MetadataUpdater.get_metadata_value( 26 | opts[:issuer], 27 | "jwks_uri", 28 | opts[:oauth2_metadata_updater_opts] || [] 29 | ) 30 | |> case do 31 | {:ok, jwks_uri} -> 32 | jwks_uri 33 | 34 | {:error, reason} -> 35 | raise "Unable to retrieve JWKS URI (#{inspect(reason)})" 36 | end 37 | end 38 | 39 | case JWKSURIUpdater.get_keys(jwks_uri) do 40 | {:ok, jwks} -> 41 | jwks 42 | 43 | {:error, reason} -> 44 | raise "Unable to retrieve JWKS (#{inspect(reason)})" 45 | end 46 | end 47 | end 48 | 49 | @spec client_jwks(Plugoid.opts()) :: [JOSEUtils.JWKS.t()] 50 | def client_jwks(opts) do 51 | client_config = opts[:client_config_module].(opts[:client_id]) 52 | 53 | cond do 54 | client_config["jwks"] -> 55 | client_config["jwks"] 56 | 57 | client_config["jwks_uri"] -> 58 | case JWKSURIUpdater.get_keys(client_config["jwks_uri"]) do 59 | {:ok, jwks} -> 60 | jwks 61 | 62 | {:error, reason} -> 63 | raise "Unable to retrieve JWKS (#{inspect(reason)})" 64 | end 65 | 66 | true -> 67 | [] 68 | end 69 | end 70 | 71 | @spec error_view_from_conn(Plug.Conn.t()) :: module() 72 | def error_view_from_conn(conn) do 73 | case conn.private[:phoenix_endpoint] do 74 | nil -> 75 | raise "Could not determine error view module, no view set in `conn`" 76 | 77 | endpoint when is_atom(endpoint) -> 78 | base_module = endpoint |> Module.split() |> List.first() 79 | 80 | Module.concat(base_module, ErrorView) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/plugoid_web/templates/preserve_request_params/restore.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | Restoring session... 4 | 48 | 49 | 50 |
51 |
52 | 53 | 54 | -------------------------------------------------------------------------------- /lib/plugoid_web/templates/preserve_request_params/save.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | Redirecting... 4 | 9 | 10 | 11 |

Redirecting...

12 | 13 | 14 | -------------------------------------------------------------------------------- /lib/plugoid_web/views/preserve_request_params_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugoidWeb.PreserveRequestParamsView do 2 | @moduledoc false 3 | 4 | use Phoenix.View, root: "lib/plugoid_web/templates" 5 | 6 | def request_data(conn) do 7 | %{ 8 | method: conn.method, 9 | body_params: conn.body_params, 10 | query_params: URI.encode_query(conn.query_params) 11 | } 12 | |> Jason.encode!() 13 | |> Phoenix.HTML.javascript_escape() 14 | |> Phoenix.HTML.raw() 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Plugoid.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :plugoid, 7 | description: "OpenID Connect Plug for Phoenix", 8 | version: "0.6.0", 9 | elixir: "~> 1.9", 10 | compilers: [:phoenix] ++ Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | docs: [ 13 | main: "readme", 14 | extras: ["README.md", "QUICKSTART.md", "CHANGELOG.md"] 15 | ], 16 | deps: deps(), 17 | package: package(), 18 | source_url: "https://github.com/tanguilp/plugoid" 19 | ] 20 | end 21 | 22 | # Run "mix help compile.app" to learn about applications. 23 | def application do 24 | [ 25 | extra_applications: [:logger] 26 | ] 27 | end 28 | 29 | # Run "mix help deps" to learn about dependencies. 30 | defp deps do 31 | [ 32 | {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, 33 | {:ex_doc, "~> 0.19", only: :dev, runtime: false}, 34 | {:jason, "~> 1.1"}, 35 | {:jwks_uri_updater, "~> 1.1"}, 36 | {:oauth2_metadata_updater, "~> 1.2"}, 37 | {:oauth2_utils, "~> 0.1"}, 38 | {:oidc, "~> 0.5"}, 39 | {:phoenix_html, ">= 2.0.0"}, 40 | {:phoenix, "~> 1.0"} 41 | ] 42 | end 43 | 44 | def package() do 45 | [ 46 | licenses: ["Apache-2.0"], 47 | links: %{"GitHub" => "https://github.com/tanguilp/plugoid"} 48 | ] 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, 3 | "content_type": {:hex, :content_type, "0.1.0", "b95627542e4980dd1e7e07ee8faf7b3a966e6a3be53f838296a57800a00e688b", [:mix], [], "hexpm", "bf01f4cec2ce7f7eeead6acca60c8d8d373122a16d6b2b6784c70fcd5667a84b"}, 4 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 5 | "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.24", "344f8d2a558691d3fcdef3f9400157d7c4b3b8e58ee5063297e9ae593e8326d9", [:mix], [], "hexpm", "1f6451b0116dd270449c8f5b30289940ee9c0a39154c783283a08e55af82ea34"}, 7 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 8 | "ex_doc": {:hex, :ex_doc, "0.28.2", "e031c7d1a9fc40959da7bf89e2dc269ddc5de631f9bd0e326cbddf7d8085a9da", [: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", "51ee866993ffbd0e41c084a7677c570d0fc50cb85c6b5e76f8d936d9587fa719"}, 9 | "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"}, 10 | "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"}, 11 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 12 | "jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"}, 13 | "jose_utils": {:hex, :jose_utils, "0.4.0", "bd27e83fd85a2347b6844f18db55b13bfedb7884ec88f430c58b8ef427107f4c", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.0", [hex: :jose, repo: "hexpm", optional: false]}, {:x509, "~> 0.8.0", [hex: :x509, repo: "hexpm", optional: false]}], "hexpm", "2bb04dc83418da8dfc99f1c2618c5f15b3442580d83c95997e0327b79147e6f1"}, 14 | "jwks_uri_updater": {:hex, :jwks_uri_updater, "1.1.1", "bc4f8b3136efd944a8760e583a0558715d3bf4f4db4430d7307ababc4db5974d", [:mix], [{:jose_utils, "~> 0.1", [hex: :jose_utils, repo: "hexpm", optional: false]}, {:poison, "~> 4.0", [hex: :poison, repo: "hexpm", optional: false]}, {:tesla, "~> 1.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "aee1258317eee3bf123eb2941b2931f80eb4a914d8b944ad0ae5fe9993123c46"}, 15 | "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"}, 16 | "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"}, 17 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 18 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 19 | "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, 20 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 21 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 22 | "oauth2_metadata_updater": {:hex, :oauth2_metadata_updater, "1.2.1", "0e61aa2dd09a72a42f02c2ae2ed6e8a298d7e48eedbdd1d812c92f490b378353", [:mix], [{:content_type, "~> 0.1", [hex: :content_type, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:oauth2_utils, "~> 0.1", [hex: :oauth2_utils, repo: "hexpm", optional: false]}, {:tesla, "~> 1.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "e74cee5ef1bb7ec6491b2f9aae79d34735c4f2c7eaea32f04c779ccd548b6cba"}, 23 | "oauth2_utils": {:hex, :oauth2_utils, "0.1.0", "5a70dc678af7f41d9a8f4ff072a72588eec04b747e70ad7daf2fac0a5726102d", [:mix], [], "hexpm", "ccb27eedc86c9eba8c0b3d3fea18e70f41329d2426b036c133656f1e92f8c3e9"}, 24 | "oidc": {:hex, :oidc, "0.5.0", "a389158e9a16027ff8a314587c872571913d18ed07c9271faaf439422bb33be2", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:jwks_uri_updater, "~> 1.0", [hex: :jwks_uri_updater, repo: "hexpm", optional: false]}, {:oauth2_metadata_updater, "~> 1.0", [hex: :oauth2_metadata_updater, repo: "hexpm", optional: false]}, {:oauth2_utils, "~> 0.1.0", [hex: :oauth2_utils, repo: "hexpm", optional: false]}, {:tesla_oauth2_client_auth, "~> 1.0", [hex: :tesla_oauth2_client_auth, repo: "hexpm", optional: false]}], "hexpm", "6123e1606348c2c532722f41f74a69711f85e992e063ee7e90e8cf501c1bcc4a"}, 25 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 26 | "phoenix": {:hex, :phoenix, "1.6.6", "281c8ce8dccc9f60607346b72cdfc597c3dde134dd9df28dff08282f0b751754", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "807bd646e64cd9dc83db016199715faba72758e6db1de0707eef0a2da4924364"}, 27 | "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, 28 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, 29 | "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, 30 | "plug": {:hex, :plug, "1.13.4", "addb6e125347226e3b11489e23d22a60f7ab74786befb86c14f94fb5f23ca9a4", [: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", "06114c1f2a334212fe3ae567dbb3b1d29fd492c1a09783d52f3d489c1a6f4cf2"}, 31 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 32 | "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, 33 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 34 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, 35 | "tesla": {:hex, :tesla, "1.4.4", "bb89aa0c9745190930366f6a2ac612cdf2d0e4d7fff449861baa7875afd797b2", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d5503a49f9dec1b287567ea8712d085947e247cb11b06bc54adb05bfde466457"}, 36 | "tesla_oauth2_client_auth": {:hex, :tesla_oauth2_client_auth, "1.0.0", "74c4dc1d54973014ed04466c9f3af241253ad39a0e2212f04e181d66b9f9683c", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:jose_utils, "~> 0.2", [hex: :jose_utils, repo: "hexpm", optional: false]}, {:tesla, "~> 1.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "be3f544d95579feb66c1614941df5997bc92b6bbc6a5340b461624ad347e9f66"}, 37 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, 38 | "x509": {:hex, :x509, "0.8.4", "30c3d432a776dd89145ddc3d5f4a510b0ebbb02d895055f213a95fa039357fe1", [:mix], [], "hexpm", "063fa3ac437a2cb2e34f87aa60cd691fa92457d722f503d5cd69711214bfa561"}, 39 | } 40 | -------------------------------------------------------------------------------- /test/plugoid_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugoidTest do 2 | use ExUnit.Case 3 | end 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------