2.4 KiB · text History 4b6fe3b
defmodule OhMyExOhIrcd.Scrobbles.Token do
  @moduledoc """
  API token for the scrobble HTTP endpoint, bound to BOTH a channel and a
  user. Only the SHA-256 hash is persisted; the plaintext is shown to the
  minting user exactly once. Authentication hashes the presented bearer
  token and looks it up by `token_hash`, mirroring
  `OhMyExOhIrcd.Accounts.UserToken`.

  The announced identity (`who`) is fixed on the token at mint time, so a
  client holding the token cannot spoof who is scrobbling — it can only
  post tracks as the bound user/name.
  """

  use Ecto.Schema
  import Ecto.Changeset

  alias OhMyExOhIrcd.Accounts.User
  alias OhMyExOhIrcd.Channels.ChannelRecord

  @hash_algorithm :sha256
  @rand_size 32

  schema "scrobble_tokens" do
    field :token_hash, :binary
    field :who, :string
    field :label, :string
    field :last_used_at, :utc_datetime
    field :revoked_at, :utc_datetime

    belongs_to :channel, ChannelRecord
    belongs_to :user, User

    timestamps(type: :utc_datetime)
  end

  @doc """
  Build a fresh token bound to `channel_id` + `user_id`, announcing as
  `who`. Returns `{display_token, struct}` where `display_token` is the
  URL-safe base64 string to hand to the user (shown once) and `struct`
  carries only the hash for persistence.
  """
  def build(channel_id, user_id, who, label) do
    raw = :crypto.strong_rand_bytes(@rand_size)

    struct = %__MODULE__{
      token_hash: hash(raw),
      who: who,
      label: label,
      channel_id: channel_id,
      user_id: user_id
    }

    {Base.url_encode64(raw, padding: false), struct}
  end

  @doc "Hash a raw (decoded) token for storage/lookup."
  def hash(raw) when is_binary(raw), do: :crypto.hash(@hash_algorithm, raw)

  @doc """
  Hash a presented bearer token (base64url string) for lookup. Returns
  `{:ok, hash}` or `:error` if the token isn't valid base64url.
  """
  def hash_presented(token) when is_binary(token) do
    case Base.url_decode64(token, padding: false) do
      {:ok, raw} -> {:ok, hash(raw)}
      :error -> :error
    end
  end

  def changeset(record, attrs) do
    record
    |> cast(attrs, [:token_hash, :who, :label, :channel_id, :user_id])
    |> validate_required([:token_hash, :who, :channel_id, :user_id])
    |> validate_length(:who, max: 64)
    |> validate_length(:label, max: 100)
    |> unique_constraint(:token_hash)
    |> assoc_constraint(:channel)
    |> assoc_constraint(:user)
  end
end