Packages
attesto_phoenix
1.0.0
2.1.0
2.0.2
2.0.1
2.0.0
1.4.0
1.3.0
1.2.0
1.1.0
1.0.0
0.20.0
0.19.1
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.2
0.14.1
0.14.0
0.13.5
0.13.4
0.13.3
0.13.2
0.13.1
0.13.0
0.12.0
0.11.0
0.10.0
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.0
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
Phoenix/Ecto OAuth 2.0 / OIDC authorization server layer over attesto: authorization, token, PAR, revocation, discovery, JWKS, UserInfo, protected-resource plugs, and Ecto-backed token stores.
Current section
Files
Jump to
Current section
Files
lib/attesto_phoenix/open_api/token_endpoint.ex
if Code.ensure_loaded?(OpenApiSpex) do
defmodule AttestoPhoenix.OpenAPI.TokenEndpoint do
@moduledoc """
OpenApiSpex operation and schema values for the OAuth 2.0 token endpoint.
This module is available only when the host depends on `:open_api_spex`.
`attesto_phoenix` declares that dependency as optional, so authorization
servers that do not publish an OpenAPI document do not compile or ship
OpenApiSpex.
The first documented request is the RFC 6749 §4.4 `client_credentials`
exchange, because it is the common machine-to-machine token endpoint
integration. The response and error schemas cover Bearer tokens, DPoP-bound
tokens, and the OAuth / DPoP error envelope emitted by
`AttestoPhoenix.Controller.TokenController`.
## Host wiring
Add `operation/1` to the host's `OpenApiSpex.PathItem` for `POST
/oauth/token` and merge `schemas/0` into the host's components.
"""
alias OpenApiSpex.{Header, MediaType, Operation, Parameter, Reference, RequestBody, Response, Schema}
@form_urlencoded "application/x-www-form-urlencoded"
@json "application/json"
@request_schema "AttestoTokenClientCredentialsRequest"
@token_response_schema "AttestoTokenResponse"
@bearer_response_schema "AttestoBearerTokenResponse"
@dpop_response_schema "AttestoDPoPTokenResponse"
@error_schema "AttestoTokenError"
@doc """
Returns the OpenApiSpex operation for `POST /oauth/token`.
Options:
* `:tags` - operation tags, defaulting to `["OAuth 2.0"]`.
* `:operation_id` - operation id, defaulting to
`"attestoPhoenixTokenCreate"`.
* `:summary` - summary text.
* `:description` - description text.
* `:security` - OpenAPI security requirements supplied by the host.
The operation intentionally does not name host security-scheme components.
Client authentication is described in the request body and prose, while a
host that defines HTTP Basic or other client-auth security schemes can pass
`security: ...`.
"""
@spec operation(keyword()) :: Operation.t()
def operation(opts \\ []) do
%Operation{
tags: Keyword.get(opts, :tags, ["OAuth 2.0"]),
operationId: Keyword.get(opts, :operation_id, "attestoPhoenixTokenCreate"),
summary: Keyword.get(opts, :summary, "Issue an OAuth access token"),
description: Keyword.get(opts, :description, operation_description()),
parameters: [dpop_header_parameter()],
requestBody: request_body(),
responses: responses(),
security: Keyword.get(opts, :security)
}
end
@doc """
Returns reusable component schemas referenced by `operation/1`.
"""
@spec schemas() :: %{String.t() => Schema.t()}
def schemas do
%{
@request_schema => client_credentials_request_schema(),
@token_response_schema => token_response_schema(),
@bearer_response_schema => bearer_token_response_schema(),
@dpop_response_schema => dpop_token_response_schema(),
@error_schema => token_error_schema()
}
end
@doc """
Returns the token request body for the media types accepted by the token
controller.
"""
@spec request_body() :: RequestBody.t()
def request_body do
%RequestBody{
description: "OAuth 2.0 token request body (RFC 6749 §3.2).",
required: true,
content: %{
@form_urlencoded => %MediaType{schema: schema_ref(@request_schema)},
@json => %MediaType{schema: schema_ref(@request_schema)}
}
}
end
@doc """
Returns token endpoint responses keyed by HTTP status.
"""
@spec responses() :: %{integer() => Response.t()}
def responses do
%{
200 => token_success_response(),
400 => token_error_response("OAuth token endpoint error.", %{"DPoP-Nonce" => dpop_nonce_header()}),
401 =>
token_error_response("Client authentication or DPoP challenge.", %{
"WWW-Authenticate" => www_authenticate_header(),
"DPoP-Nonce" => dpop_nonce_header()
})
}
end
defp operation_description do
"""
Issues an OAuth 2.0 access token. This operation documents the
`client_credentials` grant request, including HTTP Basic client
authentication, body client credentials, `private_key_jwt`, optional
`scope`, and an optional DPoP proof header.
"""
end
defp dpop_header_parameter do
%Parameter{
name: :DPoP,
in: :header,
required: false,
description: "DPoP proof JWT for sender-constrained token requests (RFC 9449 §4.2).",
schema: %Schema{type: :string}
}
end
defp token_success_response do
%Response{
description: "Access token response (RFC 6749 §5.1; RFC 9449 for DPoP).",
content: %{
@json => %MediaType{schema: schema_ref(@token_response_schema)}
}
}
end
defp token_error_response(description, headers) do
%Response{
description: description,
headers: headers,
content: %{
@json => %MediaType{schema: schema_ref(@error_schema)}
}
}
end
defp client_credentials_request_schema do
%Schema{
title: @request_schema,
type: :object,
required: [:grant_type],
properties: %{
grant_type: %Schema{
type: :string,
enum: ["client_credentials"],
description: "OAuth grant type for RFC 6749 §4.4."
},
scope: %Schema{
type: :string,
description: "Space-delimited requested scope values (RFC 6749 §3.3)."
},
client_id: %Schema{
type: :string,
description: "Client identifier when using body client authentication."
},
client_secret: %Schema{
type: :string,
writeOnly: true,
description: "Client secret when using client_secret_post."
},
client_assertion_type: %Schema{
type: :string,
enum: ["urn:ietf:params:oauth:client-assertion-type:jwt-bearer"],
description: "Client assertion type for private_key_jwt."
},
client_assertion: %Schema{
type: :string,
writeOnly: true,
description: "Signed client assertion JWT for private_key_jwt."
}
},
example: %{
"grant_type" => "client_credentials",
"scope" => "read",
"client_id" => "client-123",
"client_secret" => "secret"
}
}
end
defp token_response_schema do
%Schema{
title: @token_response_schema,
oneOf: [
schema_ref(@bearer_response_schema),
schema_ref(@dpop_response_schema)
]
}
end
defp bearer_token_response_schema do
%Schema{
title: @bearer_response_schema,
type: :object,
required: [:access_token, :token_type],
properties: token_response_properties("Bearer"),
example: %{
"access_token" => "eyJhbGciOi...",
"token_type" => "Bearer",
"expires_in" => 3600,
"scope" => "read"
}
}
end
defp dpop_token_response_schema do
%Schema{
title: @dpop_response_schema,
type: :object,
required: [:access_token, :token_type, :cnf],
properties:
Map.put(token_response_properties("DPoP"), :cnf, %Schema{
type: :object,
required: [:jkt],
properties: %{
jkt: %Schema{
type: :string,
description: "JWK SHA-256 thumbprint binding the token to the DPoP proof key."
}
}
}),
example: %{
"access_token" => "eyJhbGciOi...",
"token_type" => "DPoP",
"expires_in" => 3600,
"scope" => "read",
"cnf" => %{"jkt" => "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"}
}
}
end
defp token_response_properties(token_type) do
%{
access_token: %Schema{
type: :string,
description: "Issued access token."
},
token_type: %Schema{
type: :string,
enum: [token_type],
description: "Token type returned by the authorization server."
},
expires_in: %Schema{
type: :integer,
minimum: 0,
description: "Lifetime in seconds."
},
scope: %Schema{
type: :string,
description: "Space-delimited granted scope values."
}
}
end
defp token_error_schema do
%Schema{
title: @error_schema,
type: :object,
required: [:error],
properties: %{
error: %Schema{
type: :string,
enum: [
"invalid_request",
"invalid_client",
"invalid_grant",
"unauthorized_client",
"unsupported_grant_type",
"invalid_scope",
"server_error",
"temporarily_unavailable",
"invalid_dpop_proof",
"use_dpop_nonce"
],
description: "OAuth 2.0 / DPoP error code."
},
error_description: %Schema{
type: :string,
description: "Human-readable diagnostic message."
}
},
example: %{
"error" => "invalid_request",
"error_description" => "grant_type must be sent in the request body, not the query string"
}
}
end
defp www_authenticate_header do
%Header{
description: "Client-authentication challenge for 401 errors.",
schema: %Schema{type: :string}
}
end
defp dpop_nonce_header do
%Header{
description: "Fresh nonce returned with `use_dpop_nonce` (RFC 9449 §8).",
schema: %Schema{type: :string}
}
end
defp schema_ref(name), do: %Reference{"$ref": "#/components/schemas/#{name}"}
end
end