defmodule Pleroma.Web.OAuth.OAuthController do
use Pleroma.Web, :controller
+ alias Pleroma.Helpers.UriHelper
+ alias Pleroma.Registration
alias Pleroma.Repo
alias Pleroma.User
- alias Pleroma.Registration
alias Pleroma.Web.Auth.Authenticator
+ alias Pleroma.Web.ControllerHelper
alias Pleroma.Web.OAuth.App
alias Pleroma.Web.OAuth.Authorization
alias Pleroma.Web.OAuth.Token
+ alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken
+ alias Pleroma.Web.OAuth.Token.Strategy.Revoke, as: RevokeToken
+ alias Pleroma.Web.OAuth.Scopes
- import Pleroma.Web.ControllerHelper, only: [oauth_scopes: 2]
+ require Logger
- if Pleroma.Config.get([:auth, :oauth_consumer_enabled]), do: plug(Ueberauth)
+ if Pleroma.Config.oauth_consumer_enabled?(), do: plug(Ueberauth)
plug(:fetch_session)
plug(:fetch_flash)
action_fallback(Pleroma.Web.OAuth.FallbackController)
- def authorize(conn, params) do
+ @oob_token_redirect_uri "urn:ietf:wg:oauth:2.0:oob"
+
+ # Note: this definition is only called from error-handling methods with `conn.params` as 2nd arg
+ def authorize(%Plug.Conn{} = conn, %{"authorization" => _} = params) do
+ {auth_attrs, params} = Map.pop(params, "authorization")
+ authorize(conn, Map.merge(params, auth_attrs))
+ end
+
+ def authorize(%Plug.Conn{assigns: %{token: %Token{}}} = conn, params) do
+ if ControllerHelper.truthy_param?(params["force_login"]) do
+ do_authorize(conn, params)
+ else
+ handle_existing_authorization(conn, params)
+ end
+ end
+
+ def authorize(%Plug.Conn{} = conn, params), do: do_authorize(conn, params)
+
+ defp do_authorize(%Plug.Conn{} = conn, params) do
app = Repo.get_by(App, client_id: params["client_id"])
available_scopes = (app && app.scopes) || []
- scopes = oauth_scopes(params, nil) || available_scopes
+ scopes = Scopes.fetch_scopes(params, available_scopes)
+ # Note: `params` might differ from `conn.params`; use `@params` not `@conn.params` in template
render(conn, Authenticator.auth_template(), %{
response_type: params["response_type"],
client_id: params["client_id"],
})
end
+ defp handle_existing_authorization(
+ %Plug.Conn{assigns: %{token: %Token{} = token}} = conn,
+ %{"redirect_uri" => @oob_token_redirect_uri}
+ ) do
+ render(conn, "oob_token_exists.html", %{token: token})
+ end
+
+ defp handle_existing_authorization(
+ %Plug.Conn{assigns: %{token: %Token{} = token}} = conn,
+ %{} = params
+ ) do
+ app = Repo.preload(token, :app).app
+
+ redirect_uri =
+ if is_binary(params["redirect_uri"]) do
+ params["redirect_uri"]
+ else
+ default_redirect_uri(app)
+ end
+
+ if redirect_uri in String.split(app.redirect_uris) do
+ redirect_uri = redirect_uri(conn, redirect_uri)
+ url_params = %{access_token: token.token}
+ url_params = UriHelper.append_param_if_present(url_params, :state, params["state"])
+ url = UriHelper.append_uri_params(redirect_uri, url_params)
+ redirect(conn, external: url)
+ else
+ conn
+ |> put_flash(:error, "Unlisted redirect_uri.")
+ |> redirect(external: redirect_uri(conn, redirect_uri))
+ end
+ end
+
def create_authorization(
- conn,
- %{
- "authorization" => %{"redirect_uri" => redirect_uri} = auth_params
- } = params,
+ %Plug.Conn{} = conn,
+ %{"authorization" => _} = params,
opts \\ []
) do
- with {:ok, auth} <-
- (opts[:auth] && {:ok, opts[:auth]}) ||
- do_create_authorization(conn, params, opts[:user]) do
- redirect_uri = redirect_uri(conn, redirect_uri)
+ with {:ok, auth} <- do_create_authorization(conn, params, opts[:user]) do
+ after_create_authorization(conn, auth, params)
+ else
+ error ->
+ handle_create_authorization_error(conn, error, params)
+ end
+ end
- cond do
- redirect_uri == "urn:ietf:wg:oauth:2.0:oob" ->
- render(conn, "results.html", %{
- auth: auth
- })
+ def after_create_authorization(%Plug.Conn{} = conn, %Authorization{} = auth, %{
+ "authorization" => %{"redirect_uri" => @oob_token_redirect_uri}
+ }) do
+ render(conn, "oob_authorization_created.html", %{auth: auth})
+ end
- true ->
- connector = if String.contains?(redirect_uri, "?"), do: "&", else: "?"
- url = "#{redirect_uri}#{connector}"
- url_params = %{:code => auth.token}
+ def after_create_authorization(%Plug.Conn{} = conn, %Authorization{} = auth, %{
+ "authorization" => %{"redirect_uri" => redirect_uri} = auth_attrs
+ }) do
+ app = Repo.preload(auth, :app).app
- url_params =
- if auth_params["state"] do
- Map.put(url_params, :state, auth_params["state"])
- else
- url_params
- end
+ # An extra safety measure before we redirect (also done in `do_create_authorization/2`)
+ if redirect_uri in String.split(app.redirect_uris) do
+ redirect_uri = redirect_uri(conn, redirect_uri)
+ url_params = %{code: auth.token}
+ url_params = UriHelper.append_param_if_present(url_params, :state, auth_attrs["state"])
+ url = UriHelper.append_uri_params(redirect_uri, url_params)
+ redirect(conn, external: url)
+ else
+ conn
+ |> put_flash(:error, "Unlisted redirect_uri.")
+ |> redirect(external: redirect_uri(conn, redirect_uri))
+ end
+ end
- url = "#{url}#{Plug.Conn.Query.encode(url_params)}"
+ defp handle_create_authorization_error(
+ %Plug.Conn{} = conn,
+ {:error, scopes_issue},
+ %{"authorization" => _} = params
+ )
+ when scopes_issue in [:unsupported_scopes, :missing_scopes] do
+ # Per https://github.com/tootsuite/mastodon/blob/
+ # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L39
+ conn
+ |> put_flash(:error, "This action is outside the authorized scopes")
+ |> put_status(:unauthorized)
+ |> authorize(params)
+ end
- redirect(conn, external: url)
- end
- else
- {scopes_issue, _} when scopes_issue in [:unsupported_scopes, :missing_scopes] ->
- # Per https://github.com/tootsuite/mastodon/blob/
- # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L39
- conn
- |> put_flash(:error, "This action is outside the authorized scopes")
- |> put_status(:unauthorized)
- |> authorize(auth_params)
+ defp handle_create_authorization_error(
+ %Plug.Conn{} = conn,
+ {:auth_active, false},
+ %{"authorization" => _} = params
+ ) do
+ # Per https://github.com/tootsuite/mastodon/blob/
+ # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L76
+ conn
+ |> put_flash(:error, "Your login is missing a confirmed e-mail address")
+ |> put_status(:forbidden)
+ |> authorize(params)
+ end
- {:auth_active, false} ->
- # Per https://github.com/tootsuite/mastodon/blob/
- # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L76
- conn
- |> put_flash(:error, "Your login is missing a confirmed e-mail address")
- |> put_status(:forbidden)
- |> authorize(auth_params)
+ defp handle_create_authorization_error(%Plug.Conn{} = conn, error, %{"authorization" => _}) do
+ Authenticator.handle_error(conn, error)
+ end
- error ->
- Authenticator.handle_error(conn, error)
+ @doc "Renew access_token with refresh_token"
+ def token_exchange(
+ %Plug.Conn{} = conn,
+ %{"grant_type" => "refresh_token", "refresh_token" => token} = _params
+ ) do
+ with {:ok, app} <- Token.Utils.fetch_app(conn),
+ {:ok, %{user: user} = token} <- Token.get_by_refresh_token(app, token),
+ {:ok, token} <- RefreshToken.grant(token) do
+ response_attrs = %{created_at: Token.Utils.format_created_at(token)}
+
+ json(conn, Token.Response.build(user, token, response_attrs))
+ else
+ _error ->
+ put_status(conn, 400)
+ |> json(%{error: "Invalid credentials"})
end
end
- def token_exchange(conn, %{"grant_type" => "authorization_code"} = params) do
- with %App{} = app <- get_app_from_request(conn, params),
- fixed_token = fix_padding(params["code"]),
- %Authorization{} = auth <-
- Repo.get_by(Authorization, token: fixed_token, app_id: app.id),
- %User{} = user <- Repo.get(User, auth.user_id),
- {:ok, token} <- Token.exchange_token(app, auth),
- {:ok, inserted_at} <- DateTime.from_naive(token.inserted_at, "Etc/UTC") do
- response = %{
- token_type: "Bearer",
- access_token: token.token,
- refresh_token: token.refresh_token,
- created_at: DateTime.to_unix(inserted_at),
- expires_in: 60 * 10,
- scope: Enum.join(token.scopes, " "),
- me: user.ap_id
- }
-
- json(conn, response)
+ def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "authorization_code"} = params) do
+ with {:ok, app} <- Token.Utils.fetch_app(conn),
+ fixed_token = Token.Utils.fix_padding(params["code"]),
+ {:ok, auth} <- Authorization.get_by_token(app, fixed_token),
+ %User{} = user <- User.get_cached_by_id(auth.user_id),
+ {:ok, token} <- Token.exchange_token(app, auth) do
+ response_attrs = %{created_at: Token.Utils.format_created_at(token)}
+
+ json(conn, Token.Response.build(user, token, response_attrs))
else
_error ->
put_status(conn, 400)
end
def token_exchange(
- conn,
+ %Plug.Conn{} = conn,
%{"grant_type" => "password"} = params
) do
- with {_, {:ok, %User{} = user}} <- {:get_user, Authenticator.get_user(conn, params)},
- %App{} = app <- get_app_from_request(conn, params),
+ with {:ok, %User{} = user} <- Authenticator.get_user(conn),
+ {:ok, app} <- Token.Utils.fetch_app(conn),
{:auth_active, true} <- {:auth_active, User.auth_active?(user)},
- scopes <- oauth_scopes(params, app.scopes),
- [] <- scopes -- app.scopes,
- true <- Enum.any?(scopes),
+ {:user_active, true} <- {:user_active, !user.info.deactivated},
+ {:ok, scopes} <- validate_scopes(app, params),
{:ok, auth} <- Authorization.create_authorization(app, user, scopes),
{:ok, token} <- Token.exchange_token(app, auth) do
- response = %{
- token_type: "Bearer",
- access_token: token.token,
- refresh_token: token.refresh_token,
- expires_in: 60 * 10,
- scope: Enum.join(token.scopes, " "),
- me: user.ap_id
- }
-
- json(conn, response)
+ json(conn, Token.Response.build(user, token))
else
{:auth_active, false} ->
# Per https://github.com/tootsuite/mastodon/blob/
|> put_status(:forbidden)
|> json(%{error: "Your login is missing a confirmed e-mail address"})
+ {:user_active, false} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "Your account is currently disabled"})
+
_error ->
put_status(conn, 400)
|> json(%{error: "Invalid credentials"})
end
def token_exchange(
- conn,
+ %Plug.Conn{} = conn,
%{"grant_type" => "password", "name" => name, "password" => _password} = params
) do
params =
token_exchange(conn, params)
end
- def token_revoke(conn, %{"token" => token} = params) do
- with %App{} = app <- get_app_from_request(conn, params),
- %Token{} = token <- Repo.get_by(Token, token: token, app_id: app.id),
- {:ok, %Token{}} <- Repo.delete(token) do
+ def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "client_credentials"} = _params) do
+ with {:ok, app} <- Token.Utils.fetch_app(conn),
+ {:ok, auth} <- Authorization.create_authorization(app, %User{}),
+ {:ok, token} <- Token.exchange_token(app, auth) do
+ json(conn, Token.Response.build_for_client_credentials(token))
+ else
+ _error ->
+ put_status(conn, 400)
+ |> json(%{error: "Invalid credentials"})
+ end
+ end
+
+ # Bad request
+ def token_exchange(%Plug.Conn{} = conn, params), do: bad_request(conn, params)
+
+ def token_revoke(%Plug.Conn{} = conn, %{"token" => _token} = params) do
+ with {:ok, app} <- Token.Utils.fetch_app(conn),
+ {:ok, _token} <- RevokeToken.revoke(app, params) do
json(conn, %{})
else
_error ->
end
end
- def prepare_request(conn, %{"provider" => provider} = params) do
+ def token_revoke(%Plug.Conn{} = conn, params), do: bad_request(conn, params)
+
+ # Response for bad request
+ defp bad_request(%Plug.Conn{} = conn, _) do
+ conn
+ |> put_status(500)
+ |> json(%{error: "Bad request"})
+ end
+
+ @doc "Prepares OAuth request to provider for Ueberauth"
+ def prepare_request(%Plug.Conn{} = conn, %{
+ "provider" => provider,
+ "authorization" => auth_attrs
+ }) do
scope =
- oauth_scopes(params, [])
- |> Enum.join(" ")
+ auth_attrs
+ |> Scopes.fetch_scopes([])
+ |> Scopes.to_string()
state =
- params
+ auth_attrs
|> Map.delete("scopes")
|> Map.put("scope", scope)
- |> Poison.encode!()
+ |> Jason.encode!()
params =
- params
+ auth_attrs
|> Map.drop(~w(scope scopes client_id redirect_uri))
|> Map.put("state", state)
+ # Handing the request to Ueberauth
redirect(conn, to: o_auth_path(conn, :request, provider, params))
end
- def request(conn, params) do
+ def request(%Plug.Conn{} = conn, params) do
message =
if params["provider"] do
"Unsupported OAuth provider: #{params["provider"]}."
|> redirect(to: "/")
end
- def callback(%{assigns: %{ueberauth_failure: failure}} = conn, params) do
+ def callback(%Plug.Conn{assigns: %{ueberauth_failure: failure}} = conn, params) do
params = callback_params(params)
messages = for e <- Map.get(failure, :errors, []), do: e.message
message = Enum.join(messages, "; ")
|> redirect(external: redirect_uri(conn, params["redirect_uri"]))
end
- def callback(conn, params) do
+ def callback(%Plug.Conn{} = conn, params) do
params = callback_params(params)
- with {:ok, registration} <- Authenticator.get_registration(conn, params) do
- user = Repo.preload(registration, :user).user
-
- auth_params = %{
- "client_id" => params["client_id"],
- "redirect_uri" => params["redirect_uri"],
- "scopes" => oauth_scopes(params, nil)
- }
-
- if user do
- create_authorization(
- conn,
- %{"authorization" => auth_params},
- user: user
- )
- else
- registration_params =
- Map.merge(auth_params, %{
- "nickname" => Registration.nickname(registration),
- "email" => Registration.email(registration)
- })
+ with {:ok, registration} <- Authenticator.get_registration(conn) do
+ auth_attrs = Map.take(params, ~w(client_id redirect_uri scope scopes state))
- conn
- |> put_session(:registration_id, registration.id)
- |> redirect(to: o_auth_path(conn, :registration_details, registration_params))
+ case Repo.get_assoc(registration, :user) do
+ {:ok, user} ->
+ create_authorization(conn, %{"authorization" => auth_attrs}, user: user)
+
+ _ ->
+ registration_params =
+ Map.merge(auth_attrs, %{
+ "nickname" => Registration.nickname(registration),
+ "email" => Registration.email(registration)
+ })
+
+ conn
+ |> put_session_registration_id(registration.id)
+ |> registration_details(%{"authorization" => registration_params})
end
else
- _ ->
+ error ->
+ Logger.debug(inspect(["OAUTH_ERROR", error, conn.assigns]))
+
conn
|> put_flash(:error, "Failed to set up user account.")
|> redirect(external: redirect_uri(conn, params["redirect_uri"]))
end
defp callback_params(%{"state" => state} = params) do
- Map.merge(params, Poison.decode!(state))
+ Map.merge(params, Jason.decode!(state))
end
- def registration_details(conn, params) do
+ def registration_details(%Plug.Conn{} = conn, %{"authorization" => auth_attrs}) do
render(conn, "register.html", %{
- client_id: params["client_id"],
- redirect_uri: params["redirect_uri"],
- scopes: oauth_scopes(params, []),
- nickname: params["nickname"],
- email: params["email"]
+ client_id: auth_attrs["client_id"],
+ redirect_uri: auth_attrs["redirect_uri"],
+ state: auth_attrs["state"],
+ scopes: Scopes.fetch_scopes(auth_attrs, []),
+ nickname: auth_attrs["nickname"],
+ email: auth_attrs["email"]
})
end
- def register(conn, %{"op" => "connect"} = params) do
- create_authorization_params = %{
- "authorization" => Map.merge(params, %{"name" => params["auth_name"]})
- }
-
+ def register(%Plug.Conn{} = conn, %{"authorization" => _, "op" => "connect"} = params) do
with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn),
%Registration{} = registration <- Repo.get(Registration, registration_id),
- {:ok, auth} <- do_create_authorization(conn, create_authorization_params),
+ {_, {:ok, auth}} <-
+ {:create_authorization, do_create_authorization(conn, params)},
%User{} = user <- Repo.preload(auth, :user).user,
{:ok, _updated_registration} <- Registration.bind_to_user(registration, user) do
conn
|> put_session_registration_id(nil)
- |> create_authorization(
- create_authorization_params,
- auth: auth
- )
+ |> after_create_authorization(auth, params)
else
+ {:create_authorization, error} ->
+ {:register, handle_create_authorization_error(conn, error, params)}
+
_ ->
- conn
- |> put_flash(:error, "Unknown error, please try again.")
- |> redirect(to: o_auth_path(conn, :registration_details, params))
+ {:register, :generic_error}
end
end
- def register(conn, params) do
+ def register(%Plug.Conn{} = conn, %{"authorization" => _, "op" => "register"} = params) do
with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn),
%Registration{} = registration <- Repo.get(Registration, registration_id),
- {:ok, user} <- Authenticator.create_from_registration(conn, params, registration) do
+ {:ok, user} <- Authenticator.create_from_registration(conn, registration) do
conn
|> put_session_registration_id(nil)
|> create_authorization(
- %{
- "authorization" => %{
- "client_id" => params["client_id"],
- "redirect_uri" => params["redirect_uri"],
- "scopes" => oauth_scopes(params, nil)
- }
- },
+ params,
user: user
)
else
)
conn
+ |> put_status(:forbidden)
|> put_flash(:error, "Error: #{message}.")
- |> redirect(to: o_auth_path(conn, :registration_details, params))
+ |> registration_details(params)
_ ->
- conn
- |> put_flash(:error, "Unknown error, please try again.")
- |> redirect(to: o_auth_path(conn, :registration_details, params))
+ {:register, :generic_error}
end
end
defp do_create_authorization(
- conn,
+ %Plug.Conn{} = conn,
%{
"authorization" =>
%{
"client_id" => client_id,
"redirect_uri" => redirect_uri
- } = auth_params
- } = params,
+ } = auth_attrs
+ },
user \\ nil
) do
with {_, {:ok, %User{} = user}} <-
- {:get_user, (user && {:ok, user}) || Authenticator.get_user(conn, params)},
+ {:get_user, (user && {:ok, user}) || Authenticator.get_user(conn)},
%App{} = app <- Repo.get_by(App, client_id: client_id),
true <- redirect_uri in String.split(app.redirect_uris),
- scopes <- oauth_scopes(auth_params, []),
- {:unsupported_scopes, []} <- {:unsupported_scopes, scopes -- app.scopes},
- # Note: `scope` param is intentionally not optional in this context
- {:missing_scopes, false} <- {:missing_scopes, scopes == []},
+ {:ok, scopes} <- validate_scopes(app, auth_attrs),
{:auth_active, true} <- {:auth_active, User.auth_active?(user)} do
Authorization.create_authorization(app, user, scopes)
end
end
- # XXX - for whatever reason our token arrives urlencoded, but Plug.Conn should be
- # decoding it. Investigate sometime.
- defp fix_padding(token) do
- token
- |> URI.decode()
- |> Base.url_decode64!(padding: false)
- |> Base.url_encode64(padding: false)
- end
-
- defp get_app_from_request(conn, params) do
- # Per RFC 6749, HTTP Basic is preferred to body params
- {client_id, client_secret} =
- with ["Basic " <> encoded] <- get_req_header(conn, "authorization"),
- {:ok, decoded} <- Base.decode64(encoded),
- [id, secret] <-
- String.split(decoded, ":")
- |> Enum.map(fn s -> URI.decode_www_form(s) end) do
- {id, secret}
- else
- _ -> {params["client_id"], params["client_secret"]}
- end
-
- if client_id && client_secret do
- Repo.get_by(
- App,
- client_id: client_id,
- client_secret: client_secret
- )
- else
- nil
- end
- end
-
# Special case: Local MastodonFE
- defp redirect_uri(conn, "."), do: mastodon_api_url(conn, :login)
+ defp redirect_uri(%Plug.Conn{} = conn, "."), do: mastodon_api_url(conn, :login)
- defp redirect_uri(_conn, redirect_uri), do: redirect_uri
+ defp redirect_uri(%Plug.Conn{}, redirect_uri), do: redirect_uri
- defp get_session_registration_id(conn), do: get_session(conn, :registration_id)
+ defp get_session_registration_id(%Plug.Conn{} = conn), do: get_session(conn, :registration_id)
- defp put_session_registration_id(conn, registration_id),
+ defp put_session_registration_id(%Plug.Conn{} = conn, registration_id),
do: put_session(conn, :registration_id, registration_id)
+
+ @spec validate_scopes(App.t(), map()) ::
+ {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes}
+ defp validate_scopes(app, params) do
+ params
+ |> Scopes.fetch_scopes(app.scopes)
+ |> Scopes.validates(app.scopes)
+ end
+
+ def default_redirect_uri(%App{} = app) do
+ app.redirect_uris
+ |> String.split()
+ |> Enum.at(0)
+ end
end