# 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