Skip to main content

lib/ash_authentication/oauth2_server/jwt.ex

# SPDX-FileCopyrightText: 2026 ash_authentication_oauth2_server contributors <https://github.com/ash-project/ash_authentication_oauth2_server/graphs/contributors>
#
# SPDX-License-Identifier: MIT

defmodule AshAuthentication.Oauth2Server.Jwt do
  @moduledoc """
  Mint and verify OAuth 2.1 access tokens.

  Uses HS256 with a shared secret resolved through the
  `AshAuthentication.Secret` behaviour.

  ## Why this exists alongside `AshAuthentication.Jwt`

  Both modules wrap Joken. They are kept separate because:

    * **Audience binding (RFC 8707)** — every minted token carries an `aud`
      matching the configured `resource_url`, and `verify/2` rejects tokens
      whose `aud` doesn't match. `AshAuthentication.Jwt`'s `aud` is
      hardcoded to a version constraint and is not customizable per-token.
    * **Hot-path verify** — the resource server validates a token on every
      protected request. Verify here is signature + claims only, no user
      load. The bearer plug controls when the user record is fetched.
    * **Decoupling** — these tokens identify a user by their primary key
      (`sub`), not via an AshAuthentication strategy, so we don't require
      the user resource to declare `authentication.tokens.enabled? true`.
  """

  @signer_alg "HS256"

  @doc """
  Mint a new access token.

  Required keys: `:sub`, `:client_id`, `:scope`.
  Optional:

    * `:ttl` — seconds; defaults to the server's `access_token_lifetime`.
    * `:tenant` — when present (and non-nil), baked into the token as a
      `"tenant"` claim so the resource server can re-set the Ash tenant
      on the conn via `BearerPlug`. Multi-tenant deployments need this;
      single-tenant deployments can ignore it.
  """
  @spec mint(server :: module(), keyword()) ::
          {:ok, String.t(), map()} | {:error, term()}
  def mint(server, opts) do
    sub = Keyword.fetch!(opts, :sub)
    client_id = Keyword.fetch!(opts, :client_id)
    scope = Keyword.fetch!(opts, :scope)
    ttl = Keyword.get(opts, :ttl, server.access_token_lifetime())
    tenant = opts[:tenant]
    secret_context = secret_context(tenant)
    now = System.system_time(:second)

    claims =
      %{
        "iss" => server.issuer_url(secret_context),
        "sub" => to_string(sub),
        "aud" => server.resource_url(secret_context),
        "client_id" => to_string(client_id),
        "scope" => scope,
        "iat" => now,
        "nbf" => now,
        "exp" => now + ttl,
        "jti" => generate_jti()
      }
      |> maybe_put_tenant(tenant, server.user_resource())

    signer = Joken.Signer.create(@signer_alg, server.signing_secret(secret_context))

    case Joken.encode_and_sign(claims, signer) do
      {:ok, token, _} -> {:ok, token, claims}
      {:error, reason} -> {:error, reason}
    end
  end

  defp secret_context(nil), do: %{}
  defp secret_context(tenant), do: %{tenant: tenant}

  # Normalize via `Ash.ToTenant` — the same protocol Ash itself uses to
  # canonicalize tenants. For attribute-based multitenancy this is
  # typically a passthrough; for resource-record tenants it extracts the
  # tenant attribute. Mirrors `AshAuthentication.Jwt.Config.maybe_add_tenant_claim/3`.
  defp maybe_put_tenant(claims, nil, _resource), do: claims

  defp maybe_put_tenant(claims, tenant, resource) do
    case Ash.ToTenant.to_tenant(tenant, resource) do
      nil -> claims
      normalized -> Map.put(claims, "tenant", normalized)
    end
  end

  @doc """
  Verify a token's signature, issuer, audience, expiry, and not-before.

  Per RFC 7519 §4.1.4 we allow a small leeway on `exp` and `nbf` (the
  server-configured `clock_skew_seconds`, default 30s) so tokens minted
  by an AS whose clock is slightly off from the resource server still
  verify.

  Returns `{:ok, claims}` on success or `{:error, reason}` on failure.
  """
  @spec verify(server :: module(), String.t()) :: {:ok, map()} | {:error, term()}
  def verify(server, token) when is_binary(token) do
    signer = Joken.Signer.create(@signer_alg, server.signing_secret())
    skew = server.clock_skew_seconds()

    with {:ok, claims} <- Joken.verify(token, signer),
         secret_context <- secret_context(claims["tenant"]),
         :ok <- check_iss(claims, server, secret_context),
         :ok <- check_aud(claims, server, secret_context),
         :ok <- check_nbf(claims, skew),
         :ok <- check_exp(claims, skew) do
      {:ok, claims}
    end
  end

  def verify(_, _), do: {:error, :invalid_token}

  defp check_iss(%{"iss" => iss}, server, secret_context) do
    if iss == server.issuer_url(secret_context), do: :ok, else: {:error, :invalid_issuer}
  end

  defp check_iss(_, _, _), do: {:error, :invalid_issuer}

  defp check_aud(%{"aud" => aud}, server, secret_context) do
    expected = server.resource_url(secret_context)

    cond do
      aud == expected -> :ok
      is_list(aud) and expected in aud -> :ok
      true -> {:error, :invalid_audience}
    end
  end

  defp check_aud(_, _, _), do: {:error, :invalid_audience}

  defp check_exp(%{"exp" => exp}, skew) when is_integer(exp) do
    if System.system_time(:second) < exp + skew, do: :ok, else: {:error, :expired}
  end

  defp check_exp(_, _), do: {:error, :missing_exp}

  # `nbf` is optional per RFC 7519 §4.1.5 — only verify when present.
  defp check_nbf(%{"nbf" => nbf}, skew) when is_integer(nbf) do
    if System.system_time(:second) + skew >= nbf, do: :ok, else: {:error, :not_yet_valid}
  end

  defp check_nbf(_, _), do: :ok

  defp generate_jti do
    if Code.ensure_loaded?(Ash.UUIDv7) and function_exported?(Ash.UUIDv7, :generate, 0) do
      Ash.UUIDv7.generate()
    else
      Ash.UUID.generate()
    end
  end
end