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
54 changes: 54 additions & 0 deletions lib/plausible/oauth/authorization_code.ex
Original file line number Diff line number Diff line change
@@ -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
93 changes: 93 additions & 0 deletions lib/plausible/oauth/grant.ex
Original file line number Diff line number Diff line change
@@ -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
Loading