diff --git a/lib/plausible/oauth/authorization_code.ex b/lib/plausible/oauth/authorization_code.ex new file mode 100644 index 000000000000..9e12a45ee166 --- /dev/null +++ b/lib/plausible/oauth/authorization_code.ex @@ -0,0 +1,54 @@ +defmodule Plausible.OAuth.AuthorizationCode do + @moduledoc """ + Short-lived, single-use OAuth 2.1 authorization code. + """ + + use Ecto.Schema + import Ecto.Changeset + + @type t() :: %__MODULE__{} + + @code_challenge_methods ["S256"] + + @required [ + :code_hash, + :client_id, + :redirect_uri, + :code_challenge, + :code_challenge_method, + :resource, + :expires_at, + :user_id, + :team_id + ] + @optional [:scopes, :client_name] + + schema "oauth_authorization_codes" do + field :code_hash, :string + field :client_id, :string + field :client_name, :string + field :redirect_uri, :string + field :resource, :string + + field :code_challenge, :string + field :code_challenge_method, :string + field :scopes, {:array, :string}, default: [] + field :expires_at, :naive_datetime + + belongs_to :user, Plausible.Auth.User + belongs_to :team, Plausible.Teams.Team + + timestamps(updated_at: false) + end + + @spec changeset(map()) :: Ecto.Changeset.t() + def changeset(attrs) do + %__MODULE__{} + |> cast(attrs, @required ++ @optional) + |> validate_required(@required) + |> validate_inclusion(:code_challenge_method, @code_challenge_methods) + |> validate_length(:client_name, max: 255) + |> validate_length(:client_id, max: 2048, count: :bytes) + |> unique_constraint(:code_hash) + end +end diff --git a/lib/plausible/oauth/grant.ex b/lib/plausible/oauth/grant.ex new file mode 100644 index 000000000000..daf6efc455a0 --- /dev/null +++ b/lib/plausible/oauth/grant.ex @@ -0,0 +1,93 @@ +defmodule Plausible.OAuth.Grant do + @moduledoc """ + A connection between one of a user's teams and an OAuth client, together with + the access/refresh token pair currently issued against it. + + Refreshing rewrites both credentials on the same row, so the row's id is + stable for the life of the connection and identifies the whole token family. + """ + + use Ecto.Schema + import Ecto.Changeset + + @type t() :: %__MODULE__{} + + @required [ + :user_id, + :team_id, + :client_id, + :access_token_hash, + :access_token_hint, + :access_token_expires_at, + :resource, + :refresh_token_hash, + :refresh_token_hint, + :refresh_token_expires_at + ] + @optional [ + :client_name, + :scopes, + :previous_refresh_token_hash, + :rotated_at, + :revoked_at, + :last_used_at + ] + + schema "oauth_grants" do + # A CIMD URL, e.g. `https://claude.ai/oauth/claude-code-client-metadata` + field :client_id, :string + # Copied verbatim from the remote metadata document, e.g. `Claude Code` + field :client_name, :string + # The granted scopes, e.g. `["stats:read:*","sites:read:*"]` + field :scopes, {:array, :string}, default: [] + # The resource this grant is for, e.g. `https://plausible.io/mcp` + field :resource, :string + + field :access_token_hash, :string + field :access_token_hint, :string + field :access_token_expires_at, :naive_datetime + + # Every grant is issued a refresh token. + field :refresh_token_hash, :string + field :refresh_token_hint, :string + field :refresh_token_expires_at, :naive_datetime + + # The refresh token displaced by the most recent rotation, and when it was + # displaced. It's null until the grant has been rotated at least once. + # + # It's stored to react appropriately on seeing it presented again by a client. + # + # There's a short grace period, measured from `rotated_at`. + # + # Outside the grace period, on seeing the refresh token again, we consider it to be compromised + # 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. + # Grace is needed to prevent a connection timeout from stopping a working integration. + field :previous_refresh_token_hash, :string + field :rotated_at, :naive_datetime + + # Revocation is separate from time-based expiry. + field :revoked_at, :naive_datetime + + # Needed for a "connected applications" view. + field :last_used_at, :naive_datetime + + belongs_to :user, Plausible.Auth.User + belongs_to :team, Plausible.Teams.Team + + timestamps() + end + + @spec changeset(map()) :: Ecto.Changeset.t() + def changeset(attrs) do + %__MODULE__{} + |> cast(attrs, @required ++ @optional) + |> validate_required(@required) + |> validate_length(:client_name, max: 255) + |> validate_length(:client_id, max: 2048, count: :bytes) + |> unique_constraint(:access_token_hash) + |> unique_constraint(:refresh_token_hash) + end +end