# SPDX-FileCopyrightText: 2026 ash_authentication_oauth2_server 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