oauth/oidc
This commit is contained in:
90
lib/sso_bsn_web/controllers/oauth/authorize_controller.ex
Normal file
90
lib/sso_bsn_web/controllers/oauth/authorize_controller.ex
Normal file
@@ -0,0 +1,90 @@
|
||||
defmodule SsoBsnWeb.Oauth.AuthorizeController do
|
||||
@behaviour Boruta.Oauth.AuthorizeApplication
|
||||
|
||||
use SsoBsnWeb, :controller
|
||||
|
||||
alias Boruta.Oauth.AuthorizeResponse
|
||||
alias Boruta.Oauth.Error
|
||||
alias Boruta.Oauth.ResourceOwner
|
||||
alias SsoBsnWeb.OauthView
|
||||
|
||||
def oauth_module, do: Application.get_env(:sso_bsn, :oauth_module, Boruta.Oauth)
|
||||
|
||||
def authorize(%Plug.Conn{} = conn, _params) do
|
||||
current_user = conn.assigns[:current_user]
|
||||
conn = store_user_return_to(conn)
|
||||
|
||||
authorize_response(
|
||||
conn,
|
||||
current_user
|
||||
)
|
||||
end
|
||||
|
||||
defp authorize_response(conn, %_{} = current_user) do
|
||||
conn
|
||||
|> oauth_module().authorize(
|
||||
%ResourceOwner{sub: to_string(current_user.id), username: current_user.email},
|
||||
__MODULE__
|
||||
)
|
||||
end
|
||||
|
||||
defp authorize_response(conn, _params) do
|
||||
redirect_to_login(conn)
|
||||
end
|
||||
|
||||
@impl Boruta.Oauth.AuthorizeApplication
|
||||
def authorize_success(
|
||||
conn,
|
||||
%AuthorizeResponse{} = response
|
||||
) do
|
||||
redirect(conn, external: AuthorizeResponse.redirect_to_url(response))
|
||||
end
|
||||
|
||||
@impl Boruta.Oauth.AuthorizeApplication
|
||||
def authorize_error(
|
||||
%Plug.Conn{} = conn,
|
||||
%Error{status: :unauthorized}
|
||||
) do
|
||||
redirect_to_login(conn)
|
||||
end
|
||||
|
||||
def authorize_error(
|
||||
conn,
|
||||
%Error{format: format} = error
|
||||
)
|
||||
when not is_nil(format) do
|
||||
conn
|
||||
|> redirect(external: Error.redirect_to_url(error))
|
||||
end
|
||||
|
||||
def authorize_error(
|
||||
conn,
|
||||
%Error{status: status, error: error, error_description: error_description}
|
||||
) do
|
||||
conn
|
||||
|> put_status(status)
|
||||
|> put_view(OauthView)
|
||||
|> render("error.html", error: error, error_description: error_description)
|
||||
end
|
||||
|
||||
@impl Boruta.Oauth.AuthorizeApplication
|
||||
def preauthorize_success(_conn, _response), do: :ok
|
||||
|
||||
@impl Boruta.Oauth.AuthorizeApplication
|
||||
def preauthorize_error(_conn, _response), do: :ok
|
||||
|
||||
defp store_user_return_to(conn) do
|
||||
conn
|
||||
|> put_session(
|
||||
:user_return_to,
|
||||
current_path(conn)
|
||||
)
|
||||
end
|
||||
|
||||
defp redirect_to_login(_conn) do
|
||||
raise """
|
||||
Here occurs the login process. After login, user may be redirected to
|
||||
get_session(conn, :user_return_to)
|
||||
"""
|
||||
end
|
||||
end
|
||||
64
lib/sso_bsn_web/controllers/oauth/introspect_controller.ex
Normal file
64
lib/sso_bsn_web/controllers/oauth/introspect_controller.ex
Normal file
@@ -0,0 +1,64 @@
|
||||
defmodule SsoBsnWeb.Oauth.IntrospectController do
|
||||
@behaviour Boruta.Oauth.IntrospectApplication
|
||||
|
||||
use SsoBsnWeb, :controller
|
||||
|
||||
alias Boruta.Oauth.Error
|
||||
alias Boruta.Oauth.IntrospectResponse
|
||||
alias SsoBsnWeb.OauthView
|
||||
|
||||
def oauth_module, do: Application.get_env(:sso_bsn, :oauth_module, Boruta.Oauth)
|
||||
|
||||
def introspect(%Plug.Conn{} = conn, _params) do
|
||||
conn |> oauth_module().introspect(__MODULE__)
|
||||
end
|
||||
|
||||
@impl Boruta.Oauth.IntrospectApplication
|
||||
def introspect_success(conn, %IntrospectResponse{} = response) do
|
||||
conn
|
||||
|> put_view(OauthView)
|
||||
|> introspect(response: response)
|
||||
end
|
||||
|
||||
def introspect(%{
|
||||
response: %IntrospectResponse{
|
||||
active: active,
|
||||
client_id: client_id,
|
||||
username: username,
|
||||
scope: scope,
|
||||
sub: sub,
|
||||
iss: iss,
|
||||
exp: exp,
|
||||
iat: iat
|
||||
}
|
||||
}) do
|
||||
case active do
|
||||
true ->
|
||||
%{
|
||||
active: true,
|
||||
client_id: client_id,
|
||||
username: username,
|
||||
scope: scope,
|
||||
sub: sub,
|
||||
iss: iss,
|
||||
exp: exp,
|
||||
iat: iat
|
||||
}
|
||||
|
||||
false ->
|
||||
%{active: false}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@impl Boruta.Oauth.IntrospectApplication
|
||||
def introspect_error(conn, %Error{
|
||||
status: status,
|
||||
error: error,
|
||||
error_description: error_description
|
||||
}) do
|
||||
conn
|
||||
|> put_status(status)
|
||||
|> json(%{error: error, error_description: error_description})
|
||||
end
|
||||
end
|
||||
31
lib/sso_bsn_web/controllers/oauth/revoke_controller.ex
Normal file
31
lib/sso_bsn_web/controllers/oauth/revoke_controller.ex
Normal file
@@ -0,0 +1,31 @@
|
||||
defmodule SsoBsnWeb.Oauth.RevokeController do
|
||||
@behaviour Boruta.Oauth.RevokeApplication
|
||||
|
||||
use SsoBsnWeb, :controller
|
||||
|
||||
alias Boruta.Oauth.Error
|
||||
alias SsoBsnWeb.OauthView
|
||||
|
||||
def oauth_module, do: Application.get_env(:sso_bsn, :oauth_module, Boruta.Oauth)
|
||||
|
||||
def revoke(%Plug.Conn{} = conn, _params) do
|
||||
conn |> oauth_module().revoke(__MODULE__)
|
||||
end
|
||||
|
||||
@impl Boruta.Oauth.RevokeApplication
|
||||
def revoke_success(%Plug.Conn{} = conn) do
|
||||
send_resp(conn, 200, "")
|
||||
end
|
||||
|
||||
@impl Boruta.Oauth.RevokeApplication
|
||||
def revoke_error(conn, %Error{
|
||||
status: status,
|
||||
error: error,
|
||||
error_description: error_description
|
||||
}) do
|
||||
conn
|
||||
|> put_status(status)
|
||||
|> put_view(OauthView)
|
||||
|> json(%{error: error, error_description: error_description})
|
||||
end
|
||||
end
|
||||
50
lib/sso_bsn_web/controllers/oauth/token_controller.ex
Normal file
50
lib/sso_bsn_web/controllers/oauth/token_controller.ex
Normal file
@@ -0,0 +1,50 @@
|
||||
defmodule SsoBsnWeb.Oauth.TokenController do
|
||||
@behaviour Boruta.Oauth.TokenApplication
|
||||
|
||||
use SsoBsnWeb, :controller
|
||||
|
||||
alias Boruta.Oauth.Error
|
||||
alias Boruta.Oauth.TokenResponse
|
||||
|
||||
def oauth_module, do: Application.get_env(:sso_bsn, :oauth_module, Boruta.Oauth)
|
||||
|
||||
def token(%Plug.Conn{} = conn, _params) do
|
||||
conn |> oauth_module().token(__MODULE__)
|
||||
end
|
||||
|
||||
@impl Boruta.Oauth.TokenApplication
|
||||
def token_success(conn, %TokenResponse{
|
||||
token_type: token_type,
|
||||
access_token: access_token,
|
||||
expires_in: expires_in,
|
||||
refresh_token: refresh_token,
|
||||
id_token: id_token
|
||||
}) do
|
||||
conn
|
||||
|> put_resp_header("pragma", "no-cache")
|
||||
|> put_resp_header("cache-control", "no-store")
|
||||
|> json(
|
||||
%{
|
||||
token_type: token_type,
|
||||
access_token: access_token,
|
||||
expires_in: expires_in,
|
||||
refresh_token: refresh_token,
|
||||
id_token: id_token
|
||||
}
|
||||
|> Enum.filter(
|
||||
fn
|
||||
{_key, nil} -> false
|
||||
_ -> true
|
||||
end
|
||||
)
|
||||
|> Enum.into(%{})
|
||||
)
|
||||
end
|
||||
|
||||
@impl Boruta.Oauth.TokenApplication
|
||||
def token_error(conn, %Error{status: status, error: error, error_description: error_description}) do
|
||||
conn
|
||||
|> put_status(status)
|
||||
|> json(%{error: error, error_description: error_description})
|
||||
end
|
||||
end
|
||||
159
lib/sso_bsn_web/controllers/openid/authorize_controller.ex
Normal file
159
lib/sso_bsn_web/controllers/openid/authorize_controller.ex
Normal file
@@ -0,0 +1,159 @@
|
||||
defmodule SsoBsnWeb.Openid.AuthorizeController do
|
||||
@behaviour Boruta.Oauth.AuthorizeApplication
|
||||
|
||||
use SsoBsnWeb, :controller
|
||||
|
||||
alias Boruta.Oauth.AuthorizeResponse
|
||||
alias Boruta.Oauth.Error
|
||||
alias Boruta.Oauth.ResourceOwner
|
||||
alias SsoBsnWeb.UserAuth
|
||||
alias SsoBsnWeb.Openid.AuthorizeView
|
||||
|
||||
def oauth_module, do: Application.get_env(:sso_bsn, :oauth_module, Boruta.Oauth)
|
||||
|
||||
def authorize(%Plug.Conn{} = conn, _params) do
|
||||
conn =
|
||||
conn
|
||||
|> store_user_return_to()
|
||||
|> put_unsigned_request()
|
||||
|
||||
resource_owner = get_resource_owner(conn)
|
||||
|
||||
with {:unchanged, conn} <- prompt_redirection(conn),
|
||||
{:unchanged, conn} <- max_age_redirection(conn, resource_owner),
|
||||
{:unchanged, conn} <- login_redirection(conn) do
|
||||
oauth_module().authorize(conn, resource_owner, __MODULE__)
|
||||
end
|
||||
end
|
||||
|
||||
@impl Boruta.Oauth.AuthorizeApplication
|
||||
def authorize_success(
|
||||
conn,
|
||||
%AuthorizeResponse{} = response
|
||||
) do
|
||||
redirect(conn, external: AuthorizeResponse.redirect_to_url(response))
|
||||
end
|
||||
|
||||
@impl Boruta.Oauth.AuthorizeApplication
|
||||
def authorize_error(
|
||||
%Plug.Conn{} = conn,
|
||||
%Error{status: :unauthorized, error: :login_required} = error
|
||||
) do
|
||||
redirect(conn, external: Error.redirect_to_url(error))
|
||||
end
|
||||
|
||||
def authorize_error(
|
||||
%Plug.Conn{} = conn,
|
||||
%Error{status: :unauthorized, error: :invalid_resource_owner}
|
||||
) do
|
||||
redirect_to_login(conn)
|
||||
end
|
||||
|
||||
def authorize_error(
|
||||
conn,
|
||||
%Error{
|
||||
format: format
|
||||
} = error
|
||||
)
|
||||
when not is_nil(format) do
|
||||
redirect(conn, external: Error.redirect_to_url(error))
|
||||
end
|
||||
|
||||
def authorize_error(
|
||||
conn,
|
||||
%Error{status: status, error_description: error_description}
|
||||
) do
|
||||
conn
|
||||
|> put_status(status)
|
||||
|> text(error_description)
|
||||
end
|
||||
|
||||
defp put_unsigned_request(%Plug.Conn{query_params: query_params} = conn) do
|
||||
unsigned_request_params =
|
||||
with request <- Map.get(query_params, "request", ""),
|
||||
{:ok, params} <- Joken.peek_claims(request) do
|
||||
params
|
||||
else
|
||||
_ -> %{}
|
||||
end
|
||||
|
||||
query_params = Map.merge(query_params, unsigned_request_params)
|
||||
|
||||
%{conn | query_params: query_params}
|
||||
end
|
||||
|
||||
defp store_user_return_to(conn) do
|
||||
# remove prompt and max_age params affecting redirections
|
||||
conn
|
||||
|> put_session(
|
||||
:user_return_to,
|
||||
current_path(conn)
|
||||
|> String.replace(~r/prompt=(login|none)/, "")
|
||||
|> String.replace(~r/max_age=(\d+)/, "")
|
||||
)
|
||||
end
|
||||
|
||||
defp prompt_redirection(%Plug.Conn{query_params: %{"prompt" => "login"}} = conn), do: log_out_user(conn)
|
||||
defp prompt_redirection(%Plug.Conn{} = conn), do: {:unchanged, conn}
|
||||
|
||||
defp max_age_redirection(
|
||||
%Plug.Conn{query_params: %{"max_age" => max_age}} = conn,
|
||||
%ResourceOwner{} = resource_owner
|
||||
) do
|
||||
case login_expired?(resource_owner, max_age) do
|
||||
true ->
|
||||
log_out_user(conn)
|
||||
|
||||
false ->
|
||||
{:unchanged, conn}
|
||||
end
|
||||
end
|
||||
|
||||
defp max_age_redirection(%Plug.Conn{} = conn, _resource_owner), do: {:unchanged, conn}
|
||||
|
||||
defp login_expired?(%ResourceOwner{last_login_at: last_login_at}, max_age) do
|
||||
now = DateTime.utc_now() |> DateTime.to_unix()
|
||||
|
||||
with "" <> max_age <- max_age,
|
||||
{max_age, _} <- Integer.parse(max_age),
|
||||
true <- now - DateTime.to_unix(last_login_at) >= max_age do
|
||||
true
|
||||
else
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp login_redirection(%Plug.Conn{assigns: %{current_user: _current_user}} = conn) do
|
||||
{:unchanged, conn}
|
||||
end
|
||||
|
||||
defp login_redirection(%Plug.Conn{query_params: %{"prompt" => "none"}} = conn) do
|
||||
{:unchanged, conn}
|
||||
end
|
||||
|
||||
defp login_redirection(%Plug.Conn{} = conn) do
|
||||
redirect_to_login(conn)
|
||||
end
|
||||
|
||||
defp get_resource_owner(conn) do
|
||||
case conn.assigns[:current_user] do
|
||||
nil ->
|
||||
%ResourceOwner{sub: nil}
|
||||
|
||||
current_user ->
|
||||
%ResourceOwner{
|
||||
sub: to_string(current_user.id),
|
||||
username: current_user.username,
|
||||
last_login_at: current_user.last_login_at
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp redirect_to_login(conn) do
|
||||
conn |> redirect(to: ~p"/users/log_in")
|
||||
end
|
||||
|
||||
defp log_out_user(conn) do
|
||||
UserAuth.log_out_user(conn)
|
||||
end
|
||||
end
|
||||
11
lib/sso_bsn_web/controllers/openid/configuration.ex
Normal file
11
lib/sso_bsn_web/controllers/openid/configuration.ex
Normal file
@@ -0,0 +1,11 @@
|
||||
defmodule SsoBsnWeb.Openid.ConfigurationController do
|
||||
use SsoBsnWeb, :controller
|
||||
|
||||
def config(conn, _params) do
|
||||
conn |> json(%{
|
||||
issuer: url(~p"/"),
|
||||
authorization_endpoint: url(~p"/openid/authorize"),
|
||||
token_endpoint: url(~p"/oauth/token")
|
||||
})
|
||||
end
|
||||
end
|
||||
16
lib/sso_bsn_web/controllers/openid/jwks_controller.ex
Normal file
16
lib/sso_bsn_web/controllers/openid/jwks_controller.ex
Normal file
@@ -0,0 +1,16 @@
|
||||
defmodule SsoBsnWeb.Openid.JwksController do
|
||||
@behaviour Boruta.Openid.JwksApplication
|
||||
|
||||
use SsoBsnWeb, :controller
|
||||
|
||||
def openid_module, do: Application.get_env(:sso_bsn, :openid_module, Boruta.Openid)
|
||||
|
||||
def jwks_index(conn, _params) do
|
||||
openid_module().jwks(conn, __MODULE__)
|
||||
end
|
||||
|
||||
@impl Boruta.Openid.JwksApplication
|
||||
def jwk_list(conn, jwk_keys) do
|
||||
conn |> json(%{keys: jwk_keys})
|
||||
end
|
||||
end
|
||||
30
lib/sso_bsn_web/controllers/openid/userinfo_controller.ex
Normal file
30
lib/sso_bsn_web/controllers/openid/userinfo_controller.ex
Normal file
@@ -0,0 +1,30 @@
|
||||
defmodule SsoBsnWeb.Openid.UserinfoController do
|
||||
@behaviour Boruta.Openid.UserinfoApplication
|
||||
|
||||
use SsoBsnWeb, :controller
|
||||
|
||||
alias Boruta.Openid.UserinfoResponse
|
||||
|
||||
def openid_module, do: Application.get_env(:sso_bsn, :openid_module, Boruta.Openid)
|
||||
|
||||
def userinfo(conn, _params) do
|
||||
openid_module().userinfo(conn, __MODULE__)
|
||||
end
|
||||
|
||||
@impl Boruta.Openid.UserinfoApplication
|
||||
def userinfo_fetched(conn, userinfo_response) do
|
||||
conn
|
||||
|> put_resp_header("content-type", UserinfoResponse.content_type(userinfo_response))
|
||||
|> json(UserinfoResponse.payload(userinfo_response))
|
||||
end
|
||||
|
||||
@impl Boruta.Openid.UserinfoApplication
|
||||
def unauthorized(conn, error) do
|
||||
conn
|
||||
|> put_resp_header(
|
||||
"www-authenticate",
|
||||
"error=\"#{error.error}\", error_description=\"#{error.error_description}\""
|
||||
)
|
||||
|> send_resp(:unauthorized, "")
|
||||
end
|
||||
end
|
||||
39
lib/sso_bsn_web/resource_owners.ex
Normal file
39
lib/sso_bsn_web/resource_owners.ex
Normal file
@@ -0,0 +1,39 @@
|
||||
defmodule SsoBsnWeb.ResourceOwners do
|
||||
@behaviour Boruta.Oauth.ResourceOwners
|
||||
|
||||
alias Boruta.Oauth.ResourceOwner
|
||||
alias SsoBsn.Accounts.User
|
||||
alias SsoBsn.Accounts
|
||||
alias SsoBsn.Repo
|
||||
|
||||
@impl Boruta.Oauth.ResourceOwners
|
||||
def get_by(username: username) do
|
||||
with %User{ id: id, username: username, last_login_at: last_login_at } <- Accounts.get_user_by_username(username) do
|
||||
{:ok, %ResourceOwner{sub: to_string(id), username: username, last_login_at: last_login_at}}
|
||||
else
|
||||
_ -> {:error, "User not found."}
|
||||
end
|
||||
end
|
||||
|
||||
def get_by(sub: sub) do
|
||||
with %User{id: id, username: username, last_login_at: last_login_at} <- Accounts.get_user(sub) do
|
||||
{:ok, %ResourceOwner{sub: to_string(id), username: username, last_login_at: last_login_at}}
|
||||
else
|
||||
_ -> {:error, "User not found."}
|
||||
end
|
||||
end
|
||||
|
||||
@impl Boruta.Oauth.ResourceOwners
|
||||
def check_password(_resource_owner, _password) do
|
||||
raise """
|
||||
Password auth is not supported
|
||||
"""
|
||||
end
|
||||
|
||||
@impl Boruta.Oauth.ResourceOwners
|
||||
def authorized_scopes(%ResourceOwner{}), do: ["openid", "email", "profile"] |> Enum.map(&%{name: &1, id: &1})
|
||||
|
||||
|
||||
@impl Boruta.Oauth.ResourceOwners
|
||||
def claims(_resource_owner, _scope), do: %{}
|
||||
end
|
||||
@@ -73,4 +73,40 @@ defmodule SsoBsnWeb.Router do
|
||||
|
||||
delete "/users/log_out", UserSessionController, :delete
|
||||
end
|
||||
|
||||
|
||||
# OIDC
|
||||
scope "/oauth", SsoBsnWeb.Oauth do
|
||||
pipe_through :api
|
||||
|
||||
post "/revoke", RevokeController, :revoke
|
||||
post "/token", TokenController, :token
|
||||
post "/introspect", IntrospectController, :introspect
|
||||
end
|
||||
|
||||
|
||||
scope "/openid", SsoBsnWeb.Openid do
|
||||
pipe_through :api
|
||||
|
||||
get "/userinfo", UserinfoController, :userinfo
|
||||
post "/userinfo", UserinfoController, :userinfo
|
||||
get "/jwks", JwksController, :jwks_index
|
||||
end
|
||||
|
||||
scope "/oauth", SsoBsnWeb.Oauth do
|
||||
pipe_through [:browser, :fetch_current_user]
|
||||
|
||||
get "/authorize", AuthorizeController, :authorize
|
||||
end
|
||||
|
||||
scope "/openid", SsoBsnWeb.Openid do
|
||||
pipe_through [:browser, :fetch_current_user]
|
||||
|
||||
get "/authorize", AuthorizeController, :authorize
|
||||
end
|
||||
|
||||
scope "/.well-known", SsoBsnWeb.Openid do
|
||||
pipe_through :api
|
||||
get "/openid-configuration", ConfigurationController, :config
|
||||
end
|
||||
end
|
||||
|
||||
1
lib/sso_bsn_web/templates/oauth/error.html.eex
Normal file
1
lib/sso_bsn_web/templates/oauth/error.html.eex
Normal file
@@ -0,0 +1 @@
|
||||
<h2><%= @error_description %></h2>
|
||||
@@ -27,6 +27,7 @@ defmodule SsoBsnWeb.UserAuth do
|
||||
"""
|
||||
def log_in_user(conn, user, params \\ %{}) do
|
||||
token = Accounts.generate_user_session_token(user)
|
||||
Accounts.update_login_time(user)
|
||||
user_return_to = get_session(conn, :user_return_to)
|
||||
|
||||
conn
|
||||
@@ -81,7 +82,7 @@ defmodule SsoBsnWeb.UserAuth do
|
||||
conn
|
||||
|> renew_session()
|
||||
|> delete_resp_cookie(@remember_me_cookie)
|
||||
|> redirect(to: ~p"/")
|
||||
|> redirect(to: ~p"/users/log_in")
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
||||
Reference in New Issue
Block a user