plugoid v0.1.0 Plugoid View Source
Basic use
defmodule MyAppWeb.Router do
use MyAppWeb, :router
use Plugoid.RedirectURI
pipeline :oidc_auth do
plug Plugoid,
issuer: "https://repentant-brief-fishingcat.gigalixirapp.com",
client_id: "client1",
client_config: PlugoidDemo.OpenIDConnect.Client
end
scope "/private", MyAppWeb do
pipe_through :browser
pipe_through :oidc_auth
get "/", PageController, :index
post "/", PageController, :index
end
end
Plug options
Mandatory plug options
:client_id
[Mandatory]: the client id to be used for interaction with the OpenID Provider (OP):client_config
[Mandatory]: a module that implements theOIDC.ClientConfig
behaviour and returns the client configuration:issuer
[Mandatory]: the OpenID Provider (OP) issuer. Server metadata and keys are automatically retrieved from it if the OP supports it
Additional plug options
:acr_values
: one of:nil
[Default]: no acr values requested[String.t()]
: a list of acr values
:acr_values_callback
: aopt_callback/0
that dynamically returns a list of ACRs. Called only if:acr_values
is not set:claims
: the"claims"
parameter:claims_callback
: aopt_callback/0
that dynamically returns the claim parameter. Called only if:claims
is not set:display
: display parameter. Mostly unused. Defaults tonil
:error_view
: the error view to be called in case of error. See the Error handling section bellow. If not set, it will be automatically set toMyApp.ErrorView
whereMyApp
is the base module name of the application:id_token_iat_max_time_gap
: max time gap to accept an ID token, in seconds. Defaults to30
:login_hint_callback
: aopt_callback/0
that dynamically returns the login hint parameter:max_age
: the OIDC max age (non_neg_integer()
) parameter:max_concurrent_state_session
: maximum of state sessions stored concurrently. Defaults to4
, set tonil
for no limits. See On state cookies:oauth2_metadata_updater_opts
: options that will be passed toOauth2MetadataUpdater
. Some authorization server do not follow standards when forming the metadata's URI. In such a case, you might need to use the:url_construction
option ofOauth2MetadataUpdater
:on_unauthenticated
: action to be taken when the request is not authenticated. One of::auth
[Default]: redirects to the authorization endpoint of the OP:fail
: returns an HTTP 401 error:pass
: hands over the request to the next plug. The request is unauthenticated (this can be checked using theauthenticated?/1
function)
:on_unauthorized
: action to be taken when the user is not authorized, because of invalid ACR. One of::auth
[Default]: redirects to the authorization endpoint of the OP:fail
: returns an HTTP 403 error
:preserve_initial_request
: a boolean. Defaults tofalse
. See further Preserving request parameters:prompt
: one of the standard values ("none"
,"login"
,"consent"
, or"select_account"
):prompt_callback
: aopt_callback/0
that dynamically returns the prompt parameter. Called only if:prompt
is not set:redirect_uri
: the redirect URI the OP has to use for redirect. If not set, defaults toMyapp.Router.Helpers.openid_connect_redirect_uri(Myapp.Endpoint, :call)
which asumes that such a route was installed. See alsoPlugoid.RedirectURI
for automatic installation of this route:response_mode
: one of:"query"
"fragment"
"form_post"
:response_mode_callback
: aopt_callback/0
that dynamically returns the response mode for the request. Called only if:response_mode
is not set:response_type
: one of:"code"
(code flow)"id_token"
(implicit flow)"id_token token"
(implicit flow)"code token"
(hybrid flow)"code id_token"
(hybrid flow)"code id_token token"
(hybrid flow)
:response_type_callback
: aopt_callback/0
that dynamically returns the response type for the request. Called only if:response_type
is not set:session_lifetime
: the local session duration in seconds. After this time interval, the user is considered unauthenticated and is redirected again to the OP. Defaults to3600
:scope
: a list of scopes ([String.t()]
) to be requested. The"openid"
scope is automatically requested. The"offline_access"
scope is to be added here if one wants OAuth2 tokens to remain active after the user's logout from the OP:server_metadata
: at:OIDC.server_metadata()
of server metadata that will take precedence over those of the issuer (published on the"issuer/.well-known/openid-configuration"
URI). Useful to override one or more server metadata fieldsui_locales
: a list of UI locales:use_nonce
: one of::when_mandatory
[Default]: a nonce is included when using the implicit and hybrid flows:always
: always include a nonce (i.e. also in the code flow in which it is optional)
Cookie configuration
Plugoid uses 2 cookies, different from the Phoenix session cookie (which allows more control over the security properties of these cookies):
- authentication cookie: stores the information about authenticated session, after being successfully redirected from the OP
- state session: store the information about the in-flight requests to the OP. It is set before redirecting to the OP, and then used and deleted when coming back from it
It uses the standard Plug.Session.Store
behaviour: any existing plug session stores can
work with Plugoid.
Plugoid cookies use the following application environment options that can be configured
under the :plugoid
key:
authentication cookie:
:auth_cookie_name
: the name of the authentication cookie. Defaults to"plugoid_auth"
:auth_cookie_opts
:opts
arg ofPlug.Conn.put_resp_cookie/4
. Defaults to[extra: "SameSite=Lax"]
:auth_cookie_store
: a module implementing thePlug.Session.Store
behaviour. Defaults to:ets
(which isPlug.Session.ETS
):auth_cookie_store_opts
: options for the:auth_cookie_store
. Defaults to[table: :plugoid_auth_cookie]
. Note that the:plugoid_auth_cookie_store
ETS table is expected to exist, i.e. to be created beforehand. It is also not suitable for production, as cookies are never deleted
state cookie:
:state_cookie_name
: the base name of the state cookie. Defaults to"plugoid_state"
:state_cookie_opts
:opts
arg ofPlug.Conn.put_resp_cookie/4
. Defaults to[extra: "SameSite=None"]
.SameSite
is set toNone
because OpenID Connect can redirect with a HTTP post request ("form_post"
response mode) and cross-domain cookies are not sent except with this setting:state_cookie_store
: a module implementing thePlug.Session.Store
behaviour. Defaults to:cookie
(which isPlug.Session.COOKIE
):state_cookie_store_opts
: options for the:state_cookie_store
. Defaults to[]
Note that by default, :http_only
is set to true
as well as the :secure
cookie flag if
the connection is using https.
On state cookies
Plugoid allows having several in-flight requests to one or more OPs, because a user could inadvertently open 2 pages for authentication, or authenticate in parallel to several OPs (social network OIDC providers, for instance).
Also, as state cookies are by definition created by unauthenticated users, it is easy for an attacker to generate a lot of state sessions and overwhelm a relying party (the site using Plugoid), especially if the sessions are stored in the backend.
This is why it is safer to store state session on the client side. By default, Plugoid uses
the :cookie
session store for state sessions: in-flight OIDC requests are stored in the
browser's cookies. Note that the secret key base must be set in the connection.
This, however, has the some limitations:
- cookies are limited to 4kb of data
- header size is also limited by web servers. Cowboy (Phoenix's web server) limits headers to 4kb as well
To deal with the first problem, Plugoid:
- limits the amount of information stored in the state session to the minimum
- uses different cookies for different OIDC requests (
"plugoid_state_1"
,"plugoid_state_2"
,"plugoid_state_3"
,"plugoid_state_4"
and so on) - limits the number of concurrent requests and deletes the older ones when needed, with the
:max_concurrent_state_session
option
However, the 4kb limit is still low and only a few state cookies can be stored concurrently.
It is recommended to test it in your application before releasing it in production to find
the right :max_concurrent_state_session
. Also note that it is possible to raise this limit
in Cowboy (see Configure max http header size in Elixir Phoenix).
Preserving request parameters
When set to true
through the :preserve_initial_request
option, query and body parameters
are replayed when redirected back from the OP.
Like for state session, it cannot be stored on server side because it would expose the server to DOS attacks (even more, as query and body parameters can be way larger). Therefore, these parameters are stored in the browser's session storage. The flow is as follows:
- the user is not authenticated and hits a Plugoid-protected page
- Plugoid displays a special blank page with javascript code. The javascript code stores the parameters in the session storage
- the user is redirected to the OP (via javascript), authenticates, and is redirected to Plugoid's redirect URI
- OIDC response is checked and, if valid, Plugoid's redirect URI plug redirects the user to the initial page
Plugoid displays a blank page containing javascript code, which:
- redirects to the initial page with query parameters if the initial request was a
GET
request - builds an HTML form with initial body parameters and post it to the initial page (with
query parameters as well) if the initial request was a
POST
request
- redirects to the initial page with query parameters if the initial request was a
Note that request data is stored unencrypted in the browser. If your forms may contain
sensitive data, consider not using this feature. This is why this option is set to false
by default.
Limitations:
- The body must be parsed (
Plug.Parsers
) before reaching the Plugoid plug - The body's encoding must be
application/x-www-form-urlencoded
. File upload using themultipart/form-data
as the encoding is not supported, and cannot be replayed - Only
GET
andPOST
request are supported ; in other cases Plugoid will fail restoring state silently
Client authentication
Upon registration, a client registers a unique authentication scheme to be used by itself to authenticate to the OP. In other words, a client cannot use different authentication schemes on different endpoints.
OAuth2 REST endpoints usually demand client authentication. Client authentication is handled
by the TeslaOAuth2ClientAuth
library. The authentication middleware to be used is
determined based on the client configuration. For instance, to authenticate to the
token endpoint, the "token_endpoint_auth_method"
is used to determine which authentication
middleware to use.
Thus, to configure a client for Basic authentication, the client configuration callback must return a configuration like:
%{
"client_id" => "some_client_id_provided_by_the_OP",
"token_endpoint_auth_method" => "client_secret_basic",
"client_secret" => "<the client secret>",
...
}
However, the default value for the token endpoint auth method is "client_secret_basic"
, thus
the following is enough:
%{
"client_id" => "some_client_id_provided_by_the_OP",
"client_secret" => "<the client secret>",
...
}
Also note that the implicit flow does not require client authentication.
Default responses type and mode
By default and if supported by the OP, these values are set to:
- response mode:
"form_post"
- response type:
"id_token"
These values allows direct authentication without additional roundtrip to the server, at the expense of:
- not receiving access tokens, which is fine if only authentication is needed
- slightly lesser security: the ID token can be replayed, while an authorization code cannot. This can be mitigated using a JTI register (see the Security considerations) section.
Otherwise it falls back to the "code"
response type.
Session
When using OpenID Connect, the OP is authoritative to determine whether the user is authenticated or not. There are 2 ways for or Relying Party (the site using a library like Plugoid) to determine it:
- using OpenID Connect Session Management, which is unsupported by Plugoid
- periodically redirecting to the OP to check for authentication. If the user is authenticated
on the OP, he's not asked to reauthenticate (in the browser it materializes by being swiftly
redirected to the OP and back to the relying party (the site using
Plugoid
)).
By default, Plugoid cookies have no timeout, and are therefore session cookies. When the user closes his browser, there are destroyed.
However, another parameter is taken into account: the :session_lifetime
parameter, which
defaults to 1 hour. This ensures that a user can not remain indefinitely authenticated, and
prevents an attacker from using a stolen cookie for too long.
That is, authenticated session cookie's lifetime is not correlated from the :session_lifetime
and keeping this cookie as a session cookie is fine - it's the OP's work to handle long-lived
authenticated sessions.
Logout
Plugoid does not support OpenID Connect logout. However, the functions:
allow loging out a user locally by removing authenticated session data or the whole authentication cookie and session.
Note that, however, the user will be redirected again to the OP (and might be seamlessly authenticated, if his session is active on the OP) when reaching a path protected by Plugoid.
Error handling
Errors can occur:
- when redirected back from the OP. This is an OP error (for instance the user denied the authorization to share his personal information)
- when analyzing the request back from the OP, if an error occurs (for instance, the ID token was expired)
- ACR is no sufficient (user is authenticated, but not authorized)
- when
:on_unauthenticated
or:on_unauthorized
are set to:fail
Depending on the case, Plugoid renders one of the following templates:
:"401"
:"403"
:"500"
It also sets the @error
assign in them to an exception, one of Plugoid or one of the
OIDC
library.
When the error occured on the OP, the :401
error template is called with an
OIDC.Auth.OPResponseError
exception.
Security considerations
- Consider renaming the cookies to make it harder to detect which library is used
- Consider setting the
:domain
and:path
settings of the cookies - When using the implicit or hybrid flow, consider setting a JTI register to prevent replay
attacks of ID tokens. This is configured in the
Plugoid.RedirectURI
plug - Consider filtering Phoenix's parameters in the logs. To do so, add in the configuration
file
config/config.exs
the following line:
config :phoenix, :filter_parameters, ["id_token", "code", "token"]
Link to this section Summary
Functions
Returns true
if the connection is authenticated with Plugoid
, false
otherwise
Returns the issuer which has authenticated the current authenticated user, or nil
if
the user is unauthenticated
Logs out a user from all issuers
Logs out a user from an issuer
Returns true
if the current request happens after a redirection from the OP, false
otherwise
Returns the subject (OP's "user id") of current authenticated user, or nil
if
the user is unauthenticated
Link to this section Types
Specs
opt() :: {:acr_values_callback, opt_callback()} | {:claims_callback, opt_callback()} | {:error_view, module()} | {:id_token_hint_callback, opt_callback()} | {:login_hint_callback, opt_callback()} | {:max_concurrent_state_session, non_neg_integer() | nil} | {:on_unauthenticated, :auth | :fail | :pass} | {:on_unauthorized, :auth | :fail} | {:prompt_callback, opt_callback()} | {:redirect_uri, String.t()} | {:redirect_uri_callback, opt_callback()} | {:response_mode_callback, opt_callback()} | {:response_type_callback, opt_callback()} | {:session_lifetime, non_neg_integer()}
Specs
opt_callback() :: (Plug.Conn.t(), opts() -> any())
Specs
opts() :: [opt() | OIDC.Auth.challenge_opt()]
Link to this section Functions
Specs
authenticated?(Plug.Conn.t()) :: boolean()
Returns true
if the connection is authenticated with Plugoid
, false
otherwise
Specs
issuer(Plug.Conn.t()) :: String.t() | nil
Returns the issuer which has authenticated the current authenticated user, or nil
if
the user is unauthenticated
Specs
logout(Plug.Conn.t()) :: Plug.Conn.t()
Logs out a user from all issuers
The connection should be eventually sent to have the cookie unset
Specs
logout(Plug.Conn.t(), OIDC.issuer()) :: Plug.Conn.t()
Logs out a user from an issuer
The connection should be eventually sent to have the cookie updated
Specs
redirected_from_OP?(Plug.Conn.t()) :: boolean()
Returns true
if the current request happens after a redirection from the OP, false
otherwise
Specs
subject(Plug.Conn.t()) :: String.t() | nil
Returns the subject (OP's "user id") of current authenticated user, or nil
if
the user is unauthenticated