AshAuthentication.Oauth2Server.Authorize (ash_authentication_oauth2_server v0.1.0)

Copy Markdown View Source

Protocol-pure logic for the /oauth/authorize endpoint.

Controllers in ash_authentication_phoenix are thin wrappers around validate_request/2, consented?/4, grant_consent!/4, and issue_code!/3. None of these functions touch Plug.Conn.

Summary

Types

The validated authorize-request payload. The struct is intentionally small — enough to render a consent screen and ultimately mint an authorization code.

Functions

Has the user already consented to this client at a scope that covers the currently-requested scope?

Record (or refresh) a consent row for (user, client) at the given scope.

Mint a new short-lived authorization code bound to the user, client, scope, PKCE challenge, and resource URI.

Validate an inbound authorize request.

Types

validated()

@type validated() :: %{
  client: Ash.Resource.record(),
  redirect_uri: String.t(),
  code_challenge: String.t(),
  scope: String.t(),
  state: String.t(),
  resource: String.t()
}

The validated authorize-request payload. The struct is intentionally small — enough to render a consent screen and ultimately mint an authorization code.

Functions

consented?(server, user, client, requested_scope)

@spec consented?(
  server :: module(),
  user :: Ash.Resource.record(),
  client :: Ash.Resource.record(),
  requested_scope :: String.t()
) :: boolean()

Has the user already consented to this client at a scope that covers the currently-requested scope?

Returns true ONLY when prior consent exists AND its scope is a superset of requested_scope. This prevents silent privilege expansion when a client later asks for more scopes than the user originally agreed to.

grant_consent!(server, user, client, scope)

@spec grant_consent!(
  server :: module(),
  user :: Ash.Resource.record(),
  client :: Ash.Resource.record(),
  scope :: String.t()
) :: Ash.Resource.record()

Record (or refresh) a consent row for (user, client) at the given scope.

issue_code!(server, user, validated)

@spec issue_code!(
  server :: module(),
  user :: Ash.Resource.record(),
  validated :: validated()
) :: Ash.Resource.record()

Mint a new short-lived authorization code bound to the user, client, scope, PKCE challenge, and resource URI.

validate_request(server, params)

@spec validate_request(server :: module(), params :: map()) ::
  {:ok, validated()}
  | {:error, :bad_redirect_uri}
  | {:error, String.t(), String.t()}

Validate an inbound authorize request.

Returns:

  • {:ok, validated} — request is structurally sound and the client + redirect_uri are known.
  • {:error, :bad_redirect_uri} — redirect_uri is missing or doesn't match a registered URI; per RFC 6749 §4.1.2.1 the controller MUST NOT redirect.
  • {:error, error_code, description} — any other validation error. Controllers redirect these errors back to redirect_uri.

A note on the state parameter

Clients MUST set state to a cryptographically random, unguessable value (RFC 6749 §10.12 / RFC 9700 §4.7). The server echoes it back via the redirect so the client can correlate the response with its pending request — and verify the response didn't come from a CSRF or injection attack.

This means clients should NOT use state as a stash for application-level data like a "return-to" URL or routing hints. That pattern is unsafe — the value travels through the user-agent and query string and is reflected back by the server, so any data put in it can be observed, replayed, or tampered with. Stash that data server-side (keyed by a fresh state), or encode it in a signed cookie.

We don't enforce a shape or entropy minimum here, but anything other than a random per-request value defeats the purpose of state.