Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 170 additions & 25 deletions lib/plausible/oauth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ defmodule Plausible.OAuth do
`Plausible.OAuth.PKCE`, `Plausible.OAuth.CIMD` and
`Plausible.OAuth.ProtectedResources`.

Only the `authorization_code` grant is implemented at the token endpoint;
redeeming a refresh token is not wired up yet.
The token endpoint implements the `authorization_code` and `refresh_token`
grants. Every refresh rotates both tokens.
"""

use Plausible.Repo
Expand All @@ -20,6 +20,7 @@ defmodule Plausible.OAuth do
@authorization_code_ttl_seconds 600
@access_token_ttl_seconds 3600
@refresh_token_ttl_seconds 30 * 24 * 3600
@refresh_grace_period_seconds 60

@roles_with_oauth Membership.roles!([:owner, :admin, :editor, :billing, :viewer])

Expand All @@ -37,6 +38,9 @@ defmodule Plausible.OAuth do
@spec refresh_token_ttl_seconds() :: pos_integer()
def refresh_token_ttl_seconds(), do: @refresh_token_ttl_seconds

@spec refresh_grace_period_seconds() :: pos_integer()
def refresh_grace_period_seconds(), do: @refresh_grace_period_seconds

@doc """
Creates a single-use authorization code bound to the given user and team.

Expand Down Expand Up @@ -104,7 +108,7 @@ defmodule Plausible.OAuth do
{:ok, AuthorizationCode.t()} | {:error, :invalid_grant}
def consume_authorization_code(code, presented) when is_binary(code) do
with {:ok, auth_code} <- delete_and_fetch_code(Token.hash(code)),
:ok <- check_not_expired(auth_code.expires_at),
:ok <- check_not_expired(auth_code.expires_at, now()),
:ok <-
PKCE.verify(
auth_code.code_challenge,
Expand Down Expand Up @@ -145,38 +149,179 @@ defmodule Plausible.OAuth do
@spec issue_token(AuthorizationCode.t()) ::
{:ok, token_response()} | {:error, Ecto.Changeset.t()}
def issue_token(%AuthorizationCode{} = auth_code) do
access = Token.generate(:access)
refresh = Token.generate(:refresh)
now = now()
{grant_changes, response} = generate_tokens(auth_code.scopes, now())

changeset =
Grant.changeset(%{
grant_changes
|> Map.merge(%{
client_id: auth_code.client_id,
client_name: auth_code.client_name,
scopes: auth_code.scopes,
resource: auth_code.resource,
user_id: auth_code.user_id,
team_id: auth_code.team_id,
access_token_hash: access.hash,
access_token_hint: access.hint,
access_token_expires_at: NaiveDateTime.add(now, @access_token_ttl_seconds, :second),
refresh_token_hash: refresh.hash,
refresh_token_hint: refresh.hint,
refresh_token_expires_at: NaiveDateTime.add(now, @refresh_token_ttl_seconds, :second)
team_id: auth_code.team_id
})
|> Grant.changeset()

with {:ok, _grant} <- Repo.insert(changeset), do: {:ok, response}
end

@doc """
Exchanges a refresh token for a new access and refresh token pair on the same
grant. The presented token stops working, and so does the access token issued
with it.

`presented` may carry `:client_id`, `:resource` and `:scope`. Each one that is
present must match the grant. `:scope` can't narrow the grant, since the grant
holds one scope set for both tokens.

The token displaced by the latest rotation is still recognised. Presented
within #{@refresh_grace_period_seconds} seconds of that rotation, it is
exchanged again, so a client that lost the response can recover. The token it
replaces becomes the recognised one, without restarting the window. Presented
later, it means the token leaked, and the whole grant is revoked.
"""
@type presented_refresh() :: %{
optional(:client_id) => String.t() | nil,
optional(:resource) => String.t() | nil,
optional(:scope) => String.t() | nil
}

@spec refresh_token(String.t(), presented_refresh()) ::
{:ok, token_response()} | {:error, :invalid_grant | :invalid_scope}
def refresh_token(raw_refresh, presented) when is_binary(raw_refresh) do
hash = Token.hash(raw_refresh)

{:ok, result} =
Repo.transaction(fn ->
now = now()

case lock_grant_by_refresh_hash(hash) do
%Grant{refresh_token_hash: ^hash} = grant ->
exchange_current(grant, presented, now)

%Grant{previous_refresh_token_hash: ^hash} = grant ->
handle_replay(grant, presented, now)

nil ->
{:error, :invalid_grant}
end
end)

result
end

def refresh_token(_raw_refresh, _presented), do: {:error, :invalid_grant}

defp exchange_current(grant, presented, now) do
with :ok <- validate_grant_refreshable(grant, presented, now) do
{grant_changes, response} = generate_tokens(grant.scopes, now)

grant
|> Ecto.Changeset.change(grant_changes)
|> Ecto.Changeset.change(
previous_refresh_token_hash: grant.refresh_token_hash,
rotated_at: now
)
|> Repo.update!()

{:ok, response}
end
end

defp handle_replay(grant, presented, now) do
if in_grace_period?(grant, now) do
with :ok <- validate_grant_refreshable(grant, presented, now) do
{grant_changes, response} = generate_tokens(grant.scopes, now)

with {:ok, _grant} <- Repo.insert(changeset) do
{:ok,
%{
access_token: access.raw,
refresh_token: refresh.raw,
token_type: "Bearer",
expires_in: @access_token_ttl_seconds,
scope: Enum.join(auth_code.scopes, " ")
}}
grant
|> Ecto.Changeset.change(grant_changes)
|> Ecto.Changeset.change(previous_refresh_token_hash: grant.refresh_token_hash)
|> Repo.update!()

{:ok, response}
end
else
revoke_grant(grant)
{:error, :invalid_grant}
end
end

defp lock_grant_by_refresh_hash(hash) do
Repo.one(
from(g in Grant,
where: g.refresh_token_hash == ^hash or g.previous_refresh_token_hash == ^hash,
lock: "FOR UPDATE"
)
)
end

defp in_grace_period?(grant, now) do
NaiveDateTime.diff(now, grant.rotated_at, :second) < @refresh_grace_period_seconds
end

defp validate_grant_refreshable(grant, presented, now) do
with :ok <- check_not_revoked(grant),
:ok <- check_not_expired(grant.refresh_token_expires_at, now),
# client_id must match when sent, see the
# [token endpoint extension](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1#name-token-endpoint-extension-3)
:ok <- match_if_present(grant.client_id, presented[:client_id]),
# Tokens stay restricted to the resource server they were granted for, see
# [access token privilege restriction](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1#name-access-token-privilege-rest).
# MCP clients must send this with every token request, see
# [resource parameter implementation](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#resource-parameter-implementation)
:ok <- match_if_present(grant.resource, presented[:resource]),
:ok <- check_same_scopes(grant, presented[:scope]) do
case validate_role(Memberships.team_role(team_id: grant.team_id, user_id: grant.user_id)) do
:ok -> :ok
{:error, :not_a_member} -> {:error, :invalid_grant}
end
end
end

defp check_not_revoked(%Grant{revoked_at: nil}), do: :ok
defp check_not_revoked(_grant), do: {:error, :invalid_grant}

defp match_if_present(_expected, nil), do: :ok
defp match_if_present(expected, presented), do: match(expected, presented)

defp check_same_scopes(_grant, nil), do: :ok

defp check_same_scopes(grant, scope) do
with {:ok, resource} <- ProtectedResources.get_by_url(grant.resource),
{:ok, scopes} <-
ProtectedResources.normalize_granted_scopes(String.split(scope), resource),
true <- scopes == grant.scopes do
:ok
else
_ -> {:error, :invalid_scope}
end
end

defp generate_tokens(scopes, now) do
access_token = Token.generate(:access)
refresh_token = Token.generate(:refresh)

grant_changes = %{
access_token_hash: access_token.hash,
access_token_hint: access_token.hint,
access_token_expires_at: NaiveDateTime.add(now, @access_token_ttl_seconds, :second),
refresh_token_hash: refresh_token.hash,
refresh_token_hint: refresh_token.hint,
refresh_token_expires_at: NaiveDateTime.add(now, @refresh_token_ttl_seconds, :second)
}

response = %{
access_token: access_token.raw,
refresh_token: refresh_token.raw,
token_type: "Bearer",
expires_in: @access_token_ttl_seconds,
scope: Enum.join(scopes, " ")
}

{grant_changes, response}
end

@doc """
Looks up the grant a raw access token belongs to, preloading the bound user, team,
and their current role in the team. Returns the grant and role.
Expand Down Expand Up @@ -257,8 +402,8 @@ defmodule Plausible.OAuth do
end
end

defp check_not_expired(expires_at) do
if NaiveDateTime.compare(expires_at, now()) == :gt do
defp check_not_expired(expires_at, now) do
if NaiveDateTime.compare(expires_at, now) == :gt do
:ok
else
{:error, :invalid_grant}
Expand Down
5 changes: 4 additions & 1 deletion lib/plausible/oauth/grant.ex
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ defmodule Plausible.OAuth.Grant do
# and we revoke the whole grant, locking out the one that exchanged it first
# and the one trying to exchange it now. We can't know which one of them was malicious.
#
# Inside the grace period, we return the current pair again idempotently.
# Inside the grace period, we issue a fresh pair, replacing the current one.
# Only hashes are stored, so the current pair can't be returned again.
# The replaced token becomes the previous one and `rotated_at` stays, so
# presenting it after the grace period revokes the grant too.
# Grace is needed to prevent a connection timeout from stopping a working integration.
field :previous_refresh_token_hash, :string
field :rotated_at, :naive_datetime
Expand Down
2 changes: 1 addition & 1 deletion lib/plausible_web/controllers/oauth/metadata_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ defmodule PlausibleWeb.OAuth.MetadataController do
authorization_endpoint: Endpoint.url() <> @authorization_endpoint,
token_endpoint: Endpoint.url() <> @token_endpoint,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
client_id_metadata_document_supported: true
Expand Down
43 changes: 39 additions & 4 deletions lib/plausible_web/controllers/oauth/token_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,7 @@ defmodule PlausibleWeb.OAuth.TokenController do
resource: resource
}),
{:ok, token} <- OAuth.issue_token(auth_code) do
conn
|> put_resp_header("cache-control", "no-store")
|> put_resp_header("pragma", "no-cache")
|> json(token)
send_token(conn, token)
else
{:error, :missing_param, _name} ->
send_error(conn, "invalid_request")
Expand All @@ -50,6 +47,30 @@ defmodule PlausibleWeb.OAuth.TokenController do
end
end

defp grant(%Plug.Conn{body_params: %{"grant_type" => "refresh_token"} = params} = conn) do
with {:ok, refresh_token} <- require_param(params, "refresh_token"),
{:ok, client_id} <- check_optional_param(params, "client_id"),
{:ok, resource} <- check_optional_param(params, "resource"),
{:ok, scope} <- check_optional_param(params, "scope"),
{:ok, token} <-
OAuth.refresh_token(refresh_token, %{
client_id: client_id,
resource: resource,
scope: scope
}) do
send_token(conn, token)
else
{:error, :missing_param, _name} ->
send_error(conn, "invalid_request")

{:error, :invalid_scope} ->
send_error(conn, "invalid_scope")

{:error, _} ->
send_error(conn, "invalid_grant")
end
end

defp grant(%Plug.Conn{body_params: %{"grant_type" => _unsupported_grant_type}} = conn) do
send_error(conn, "unsupported_grant_type")
end
Expand All @@ -65,6 +86,20 @@ defmodule PlausibleWeb.OAuth.TokenController do
end
end

defp check_optional_param(params, name) do
case params[name] do
nil -> {:ok, nil}
_ -> require_param(params, name)
end
end

defp send_token(conn, token) do
conn
|> put_resp_header("cache-control", "no-store")
|> put_resp_header("pragma", "no-cache")
|> json(token)
end

defp send_error(conn, error) do
conn
|> put_status(400)
Expand Down
16 changes: 13 additions & 3 deletions lib/plausible_web/endpoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ defmodule PlausibleWeb.Endpoint do
use Plausible
use Sentry.PlugCapture
use Phoenix.Endpoint, otp_app: :plausible
use PlausibleWeb.VerifiedRoutes

on_ce do
plug :maybe_handle_acme_challenge
Expand Down Expand Up @@ -79,7 +78,7 @@ defmodule PlausibleWeb.Endpoint do
json_decoder: Phoenix.json_library()
)

plug(Sentry.PlugContext)
plug(Sentry.PlugContext, body_scrubber: {__MODULE__, :scrub_sentry_body})

plug(Plug.MethodOverride)
plug(Plug.Head)
Expand All @@ -99,13 +98,24 @@ defmodule PlausibleWeb.Endpoint do

# CORS must not be supported at the [authorization endpoint](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1#name-authorization-endpoint)
def cors(conn, _opts) do
if conn.request_path == ~p"/login/oauth/authorize" do
if conn.path_info == ["login", "oauth", "authorize"] do
conn
else
CORSPlug.call(conn, @cors_opts)
end
end

# OAuth token requests carry live credentials in the body
@sentry_scrubbed_keys Sentry.Scrubber.default_param_keys() ++
["code", "code_verifier", "access_token", "refresh_token"]

# The event ingestion endpoint is high traffic, so its body is reported unscrubbed
def scrub_sentry_body(%{path_info: ["api", "event"]} = conn), do: conn.params

def scrub_sentry_body(conn) do
Comment thread
zoldar marked this conversation as resolved.
Resolved
Sentry.Scrubber.scrub(conn.params, keys: @sentry_scrubbed_keys)
end

def runtime_session(conn, _opts) do
Plug.run(conn, [{Plug.Session, runtime_session_opts()}])
end
Expand Down
Loading
Loading