Hex.pm Hexdocs.pm Elixir CI License: MIT Elixir OpenID Certified

An opinionated Phoenix/Ecto OAuth 2.0 / OIDC authorization server on top of attesto.

OpenID Certified

An authorization server built from attesto + attesto_phoenix is OpenID Certified to FAPI 2.0 Security Profile Final — OP, FAPI 2.0 Message Signing Final — OP, FAPI-CIBA — OP, OpenID Connect Basic — OP and Config — OP, RP-Initiated, Back-Channel, and Front-Channel Logout — OP, and Session Management — OP — the first Elixir provider with FAPI 2.0 certification.

FAPI 2.0 Certified FAPI-CIBA Certified OpenID Connect Certified Logout Profiles Certified Session Management Certified

attesto brings the protocol, attesto_phoenix brings transport + persistence; you bring principals, keys, and policy.

attesto is a transport-agnostic library of OAuth/OIDC primitives: JWT access tokens, JWKS/key handling, DPoP, mTLS, PKCE, scope algebra, private-key client assertions, signed request objects, JARM response JWTs, token introspection primitives, and the token-lifecycle building blocks. attesto_phoenix wires those primitives into a running server:

  • HTTP endpoints (authorization, token, PAR, revocation, discovery, JWKS, UserInfo, protected-resource metadata, optional dynamic registration, plus the opt-in CIBA backchannel-authentication, device-authorization, end-session (RP-Initiated Logout), and check-session endpoints) mounted into your router with one macro. The authorization endpoint supports the default query response mode and the JARM JWT response modes; Back-Channel and Front-Channel Logout run alongside the end-session flow.
  • Protected-resource plugs that verify Bearer JWTs and enforce DPoP / mTLS sender-constraint binding.
  • Ecto-backed implementations of every mutable store the OAuth/OIDC flows need — authorization codes, refresh tokens, DPoP nonces, DPoP proof jti replay records, and Pushed Authorization Request (PAR) references — so a clustered or load-balanced deployment keeps no OAuth state per node.

Attesto owns the standards route catalog and protocol controllers; the host chooses route mounts and route pipeline classes declaratively. It deliberately does not own your client registry, principal store, secret hashing, scope catalog, resource-owner authentication, consent, or audit log. Those are application policy and are supplied through neutral configuration callbacks.

What you can build with it

  • An API that AI assistants can connect to. Assistant connectors — ChatGPT, Claude — authorize through OAuth: PKCE, dynamic client registration, pushed authorization requests, sender-constrained tokens, and protected-resource discovery. attesto_phoenix mounts that whole surface with one router macro, so your app can expose tools and data to an assistant without hand-rolling an OAuth server. Pair it with attesto_mcp to protect the MCP endpoint itself as an OAuth resource server — the WWW-Authenticate challenge and protected-resource metadata (RFC 9728) that assistant clients discover.
  • Your own authorization server. Issue short-lived, scoped JWT access tokens and OIDC ID tokens for first-party apps and machine clients, instead of outsourcing to a hosted identity provider.
  • A resource server that resists stolen tokens. Verify access tokens locally — signature, issuer, audience, and DPoP / mTLS sender-constraint — with no token database or introspection call on the hot path, so a leaked bearer token alone can't call the API.

The standards each use case rests on are catalogued below and in the attesto core README; you don't need to track them to use the library.

Positioning vs. attesto core

Concernattesto (core)attesto_phoenix (this package)
JWT mint/verify, JWKS, DPoP, mTLS, PKCE, scopesyesreuses core
private_key_jwt, signed request objects, JARM, token exchange primitivesyeswires into endpoints
Grant orchestration primitivesyesreuses core
HTTP endpoints + router macronoyes
Protected-resource plugscore plug building blocksPhoenix-friendly wrappers
Ecto-backed token storesstore behaviours onlyEcto implementations
Client registry, principals, keys, auditnosupplied via callbacks

If you only need the protocol primitives and want to build your own transport, depend on attesto directly. If you want a batteries-included Phoenix authorization server, use attesto_phoenix.

Contents

Installation

Add attesto_phoenix to your dependencies:

def deps do
  [
    {:attesto_phoenix, "~> 2.0"}
  ]
end

The optional Igniter installer needs igniter available while you run it. It is not a runtime dependency of this package:

def deps do
  [
    {:attesto_phoenix, "~> 2.0"},
    {:igniter, "~> 0.5", only: [:dev], runtime: false}
  ]
end

Quick start

For a new Phoenix app, start with the installer. It is idempotent and writes the host-owned callback modules as stubs rather than guessing your client registry, principal model, or authorization policy.

mix deps.get
mix attesto_phoenix.install
mix attesto_phoenix.gen.migration --repo MyApp.Repo
mix ecto.migrate

Use --oauth-path-prefix when the OAuth endpoints should not live under /oauth:

mix attesto_phoenix.install --oauth-path-prefix /mcp/oauth

After the installer runs, fill in the generated callback modules and configure a keystore. The rest of this README shows the same pieces explicitly so you can review what the installer generated or wire them by hand.

Configuration

All behavior is centralized in AttestoPhoenix.Config. Anything that is inherently application policy is a neutral callback rather than a baked-in assumption.

config :my_app, AttestoPhoenix.Config,
  # --- required ---
  issuer: "https://auth.example.com",
  keystore: MyApp.Keystore,            # implements Attesto.Keystore
  repo: MyApp.Repo,                    # Ecto.Repo for the token stores

  # host policy modules (preferred install surface)
  client_store: MyApp.OAuth.ClientStore,
  principal_store: MyApp.OAuth.PrincipalStore,
  scope_policy: MyApp.OAuth.ScopePolicy,
  consent_policy: MyApp.OAuth.ConsentPolicy,
  claims_provider: MyApp.OIDC.ClaimsProvider,
  event_sink: MyApp.OAuth.Events,

  # --- optional policy ---
  scopes_supported: ["profile", "email", "read:*", "write:*"],
  send_error: &MyApp.OAuthErrors.render/3,
  #   (conn, status, body_map -> conn), optional custom OAuth error envelope
  client_auth_signing_algs: Attesto.SigningAlg.fapi_algs(),
  request_object_policy: Attesto.RequestObject.Policy.generic(),

  # --- optional deployment + features ---
  # Incoming request transport gate only; issuer and advertised endpoint URLs
  # remain HTTPS-only regardless of this value.
  require_https: true,
  trusted_proxies: ["10.0.0.0/8"],     # honor X-Forwarded-* only from these
  access_token_ttl: 900,
  refresh_token_ttl: 1_209_600,
  authorization_code_ttl: 60,
  dpop_enabled: true,
  dpop_nonce_required: false,
  mtls_enabled: false,                 # if true, also set :cert_der
  registration_enabled: false,         # if true, also set registration callbacks

  # RFC 8707 resource indicators (optional; see below)
  resource_indicators: [
    allowed_resources: ["https://api.example.com/a", "https://api.example.com/b"],
    allowed_resources_for: {MyApp.OAuth, :resources_for}  # optional per-client (client -> [uri])
  ]

Build the validated struct wherever you need it:

config = AttestoPhoenix.Config.from_otp_app(:my_app)

Required keys are validated at build time; a missing key (or a missing dependency such as :cert_der when mTLS is enabled) raises immediately so misconfiguration fails fast.

Resource indicators (RFC 8707)

When one authorization server fronts more than one protected resource (say an admin API and an end-user API, or several MCP endpoints), a single fixed aud cannot separate a token meant for one from a token meant for another — only scope would, and scope is application policy, not a cryptographic boundary. RFC 8707 fixes that: a client names the resource it wants with a resource parameter, and the AS mints the token's aud to that identifier, so a token issued for resource A is structurally invalid at sibling resource B.

It works across every grant. A client sends resource on the authorization request (bound to the code) or the token request (client_credentials, token exchange, jwt-bearer); the token endpoint mints aud from it, refresh carries and may narrow it (subset-only), and token exchange cannot widen aud beyond the subject token's. One or more resources are allowed (a multi-resource grant mints a JWT aud array). A requested resource the server does not serve is rejected with invalid_target.

resource_indicators[:allowed_resources] lists the resource identifiers this server is willing to mint for (besides its own :audience, always served); :allowed_resources_for is an optional (client -> [uri]) callback for per-client scoping. With neither set and no resource requested, issuance keeps the single configured :audience — so single-resource deployments need no change. This is the issuer half of the RFC 9728 ↔ RFC 8707 chain: a resource advertises its identifier via protected-resource metadata, the client echoes it as resource, the AS mints that aud, and the resource server validates it (see attesto_mcp for the resource-server half).

Host policy modules

The preferred install surface groups host-owned callbacks by concern:

  • client registry -> :client_store (load_client, verify_client_secret, client_jwks, client metadata)
  • principals -> :principal_store (load_principal, build_principal, principal kinds)
  • scope policy -> :scope_policy (authorize_scope, supported scopes)
  • login / consent -> :consent_policy (authenticate_resource_owner, consent)
  • claims -> :claims_provider (build_userinfo_claims/3, build_id_token_claims/4)
  • audit / telemetry -> :event_sink (on_event)
  • dynamic registration -> :registration (only with registration)

Flat callback keys such as :load_client, :verify_client_secret, :client_jwks, :load_principal, and :authorize_scope are still accepted and take precedence when present. Use them for small installs or targeted overrides; use behaviour modules for production wiring.

Other deployment callbacks remain flat because they are endpoint mechanics, not domain policy: :send_error, :www_authenticate, :no_store, :cert_der, :require_https, and :trusted_proxies.

Mounting the routes

Use the router macro to mount the server endpoints under a scope you choose:

defmodule MyAppWeb.Router do
  use MyAppWeb, :router
  use AttestoPhoenix.Router

  pipeline :oauth do
    plug :accepts, ["json"]
  end

  scope "/" do
    pipe_through :oauth
    attesto_routes()
  end
end

When interactive routes need host session/resource-owner support that protocol clients must not inherit, classify the generated routes without hand-writing the route catalog:

attesto_routes(
  pipeline: :oauth_common,
  route_pipelines: [
    interactive: [:oauth_interactive, :oauth_common]
  ],
  registration: true
)

:metadata covers discovery, OpenID configuration, JWKS, and protected-resource metadata; :interactive covers authorization, device verification, end-session, and check-session; :protocol covers the remaining OAuth/OIDC endpoints. Each override is the complete ordered list for that class, while omitted classes use pipeline:. The host owns the actual session, resource-owner authentication, CSRF, and content-negotiation policy. In particular, do not place externally submitted OAuth POST endpoints behind generic browser CSRF or browser-only Accept handling. Write pipeline names as literal atoms/lists inside the Phoenix scope; module attributes are not available when Phoenix expands the nested route macro.

The OIDC-only local route mounts default on for compatibility. An OAuth authorization server that does not act as an OpenID Provider can retain authorization, token, PAR, revocation, introspection, JWKS, and RFC 8414 metadata while omitting both declarations:

attesto_routes(
  userinfo: false,
  openid_configuration: false
)

These flags are compile-time route-mount controls; metadata is built later from runtime AttestoPhoenix.Config. userinfo: false removes both local UserInfo verbs. openid_configuration: false removes only the OIDC Provider Metadata route; the RFC 8414 authorization-server document remains mounted and its contents are unchanged.

UserInfo metadata keeps explicit host intent separate from a mechanically derived local endpoint:

  • userinfo_endpoint: nil preserves the released behavior and omits the member.
  • userinfo_endpoint: :derived advertises the URL derived from :issuer and :userinfo_path. When userinfo: false, the retained Provider Metadata route suppresses this value only if it is route-equivalent to the removed bundled route.
  • An explicit HTTPS URL is authoritative and always remains advertised, including at the same origin and path. This is the supported form when a host replaces the bundled UserInfo controller or serves UserInfo elsewhere.

For example, a host can remount its own implementation at the canonical path without losing discovery:

scope "/" do
  attesto_routes(userinfo: false)
  get "/oauth/userinfo", MyAppWeb.UserInfoController, :show
end
config :my_app, AttestoPhoenix.Config,
  userinfo_endpoint: "https://issuer.example/oauth/userinfo"

The derived-path comparison models Phoenix/Plug dispatch rather than generic URI cleanup: adapters discard empty path segments, Phoenix decodes each request segment once, and ./.. segments remain significant. It therefore handles leading, repeated, and trailing slashes, percent-encoded request segments, static or dynamic surrounding scopes, non-default ports, and forwarded router mounts without conflating a distinct route with the removed one.

A dynamic macro :prefix is not available to the root Provider Metadata request. Consequently, userinfo: false with retained OpenID configuration rejects a dynamic :prefix at compile time instead of silently advertising a dead derived endpoint. Put the dynamic portion in a surrounding Phoenix scope, where the metadata request realizes the same scope, or also set openid_configuration: false. Static prefixes remain supported.

OIDC conformance for features such as CIBA, logout, and session management relies on Provider Metadata, so those deployments must keep OpenID configuration enabled unless the host serves equivalent metadata separately. A dynamically discovered and dynamically registered OpenID Provider that issues access tokens must still satisfy OIDC's Discovery and UserInfo requirements; these independent macro controls do not make every route combination an OIDC-conformant deployment. If OpenID configuration is disabled, any configured UserInfo endpoint is advertised nowhere unless the host publishes an equivalent Provider Metadata document.

The bundled well-known routes are the standards-derived forms for an origin-only issuer such as https://issuer.example. If the issuer contains a path, OIDC Discovery and RFC 8414 derive two different path-bearing well-known locations; mount those routes explicitly instead of using the macro's fixed root discovery routes. Because the macro always owns its RFC 8414 route, a path-bearing issuer requires a manually declared route catalog rather than adding duplicate discovery routes alongside attesto_routes/1. Derived endpoint URLs are likewise resolved against the issuer origin: the issuer's path is not prepended, so a path-bearing issuer must also set :oauth_path_prefix (or the per-endpoint path overrides) so the advertised endpoints sit under its path.

attesto_routes/1 mounts:

  • GET /.well-known/oauth-authorization-server (RFC 8414 metadata)
  • GET /.well-known/openid-configuration (OIDC Discovery metadata; omitted with openid_configuration: false)
  • GET /.well-known/jwks.json (RFC 7517 JWK Set)
  • GET /.well-known/oauth-protected-resource (RFC 9728 metadata)
  • GET /oauth/authorize and POST /oauth/authorize
  • POST /oauth/token
  • POST /oauth/par (RFC 9126)
  • POST /oauth/revoke (RFC 7009)
  • POST /oauth/introspect (RFC 7662)
  • POST /oauth/register (RFC 7591, only with registration: true)
  • DELETE /oauth/register/:client_id (RFC 7592, with registration)
  • GET /oauth/userinfo (omitted with userinfo: false)
  • POST /oauth/userinfo (omitted with userinfo: false)
  • POST /oauth/bc-authorize (CIBA, only with attesto_routes(ciba: true))
  • POST /oauth/device_authorization (RFC 8628, only with device: true)
  • GET /oauth/device_verification and POST /oauth/device_verification (device user-code entry, with device: true)
  • GET /oauth/end_session and POST /oauth/end_session (RP-Initiated Logout, only with logout: true)
  • GET /oauth/check_session (Session Management check_session_iframe, only with session_management: true)

Discovery and JWKS are public; the token and revocation endpoints authenticate the client via your :load_client / :verify_client_secret callbacks. The token endpoint also accepts private_key_jwt when :client_jwks is wired, and supports authorization-code, refresh-token, client-credentials, OAuth token-exchange, and JWT-assertion (jwt-bearer) grants. The PAR endpoint accepts the same confidential-client secret methods plus private_key_jwt, then stores the authorization request behind a one-time request_uri.

When :request_object_policy is configured, signed request objects are verified at PAR submission and re-verified at /authorize; verified request-object parameters are authoritative over unsigned request body/query values. Set Attesto.RequestObject.Policy.fapi_message_signing/0 to enforce the FAPI 2.0 Message Signing JAR profile.

The authorization endpoint also emits JARM responses when the validated request uses response_mode=jwt, query.jwt, fragment.jwt, or form_post.jwt. Discovery advertises the supported response modes and the server signing algorithms used for authorization response JWTs.

The route plumbing is profile-neutral. A permissive standards-compliant OAuth deployment can admit PKCE-bound public clients and select its supported grants. A FAPI 2.0 Security Profile deployment coordinates policy settings and callbacks that require PAR, PKCE, asymmetric confidential-client authentication, sender-constrained access tokens, and the applicable algorithm constraints. The optional Message Signing profile adds signed request-object enforcement and JARM; Attesto.RequestObject.Policy.fapi_message_signing/0 provides the request-object policy for that profile. These are coordinated settings rather than a single profile switch, and they use the same token, authorization, PAR, discovery, DPoP, and mTLS implementations.

Backchannel authentication (CIBA)

For decoupled authentication — where the device consuming the API is not the device the user approves on, such as a call-center agent's console, a POS terminal, or an AI agent acting on a user's behalf — mount CIBA with attesto_routes(ciba: true) and enable it in AttestoPhoenix.Config (ciba: [enabled: true]). The client calls POST /oauth/bc-authorize to start a flow the user approves out of band on their own phone, then collects the tokens at the token endpoint: in poll mode the client polls until the user approves, and in ping mode the AS calls the client's notification endpoint when the tokens are ready. Signed authentication requests follow the FAPI-CIBA profile.

Device Authorization Grant (RFC 8628)

For sign-in on input-constrained devices — a smart TV, a CLI, an IoT box with no browser or keyboard — mount the device grant with attesto_routes(device: true). POST /oauth/device_authorization returns a device_code and a short human-typable user_code; the user enters that code on a second device at the verification page (/oauth/device_verification), while the device polls the token endpoint with the device_code until the user approves.

Logout and session management

Single-logout across relying parties and browser-session change detection. An ID Token minted for a session records the RPs to notify, and the end-session flow fans out to them:

  • RP-Initiated Logout (certified) — GET/POST /oauth/end_session, mounted with attesto_routes(logout: true). An RP redirects the browser here to end the OP session and return to a registered post_logout_redirect_uri.
  • Back-Channel Logout (certified) — the OP delivers a signed logout token server-to-server to every RP that registered a backchannel_logout_uri, so sessions end even when the user's browser never returns to those RPs.
  • Front-Channel Logout — the end-session page renders each RP's frontchannel_logout_uri in an iframe, so browser-reachable RPs clear their session within the same logout navigation.
  • Session ManagementGET /oauth/check_session serves the check_session_iframe and the authorization endpoint returns session_state, letting an RP detect a change to the OP login session without a full redirect. Mount with attesto_routes(session_management: true).

Protecting resources

pipeline :api_protected do
  plug AttestoPhoenix.Plug.Authenticate
end

scope "/api", MyAppWeb do
  pipe_through [:api, :api_protected]

  scope "/reports" do
    plug AttestoPhoenix.Plug.RequireScopes, "read:reports"
    get "/", ReportController, :index
  end
end

AttestoPhoenix.Plug.Authenticate verifies the Bearer JWT, enforces DPoP and mTLS binding when enabled, resolves the subject via :load_principal, emits neutral :auth_succeeded / :auth_denied events through :on_event, and assigns:

  • conn.assigns.attesto_claims - the verified JWT claims
  • conn.assigns.attesto_principal - the host principal returned by :load_principal
  • conn.assigns.attesto_context - a neutral %{subject, client_id, scope, claims, cnf, principal} map

Bearer credentials default to the Authorization header only, matching bearer_methods_supported: ["header"] in protected-resource metadata. Configure bearer_methods_supported: ["header", "body"] only for resource servers that intentionally accept RFC 6750 form-body access_token credentials.

AttestoPhoenix.Plug.RequireScopes enforces route-level scope authorization using Attesto.Scope grant-form algebra. It accepts either a single scope string or a list of required scopes.

When :resource_metadata is set on the config, a 401 challenge carries that static RFC 9728 resource_metadata pointer, preserving the single-resource default. A host serving several protected resources can instead select the correct pointer per request, or return nil when that surface has no applicable metadata declaration:

resource_metadata: "https://api.example/.well-known/oauth-protected-resource",
resource_metadata_resolver: {MyAppWeb.ResourceMetadata, :for_request}
def for_request(%Plug.Conn{request_path: "/alpha"}) do
  "https://api.example/.well-known/oauth-protected-resource/alpha"
end

def for_request(%Plug.Conn{request_path: "/beta"}) do
  "https://api.example/.well-known/oauth-protected-resource/beta"
end

def for_request(_conn), do: nil

The resolver is authoritative when present; it does not fall back to the static URL when it returns nil. An invalid runtime return is safely omitted rather than turned into a challenge or a request-time exception. A static Config value is validated by AttestoPhoenix.Config.new/1; a non-nil per-plug value is validated when AttestoPhoenix.Plug.Authenticate is initialized (at compile time under Phoenix's default Plug initialization mode). Explicit per-plug nil remains a valid, authoritative omission. Function callbacks must accept one argument, and MFA tuples must export the effective arity (the request plus any extra arguments, which are appended after it). The explicit per-plug option wins on core verification, TLS, revocation, and principal failures and skips the resolver.

The resolver is trusted configuration. Return pinned or allowlisted HTTPS URLs; do not construct a metadata authority from untrusted Host, forwarded, query, or arbitrary header values. The returned URL is never fetched or used as a redirect, and it is validated with the same HTTPS/host/no-fragment rules as the static value before it can enter a quoted challenge. The resolver runs once per protected-resource request — including requests that authenticate successfully, since the pointer must be selected before verification renders any challenge — so keep it fast and total. Resolver exceptions are not rescued: a callback that raises propagates the exception and fails the request (successful ones included) instead of rendering an authentication challenge.

The protected-resource integration still owns the actual RFC 9728 declarations. Publish one document per exact resource identifier, with the path-inserted well-known URI and matching resource member; do not collapse multiple identifiers into a root document. When no resource owns the origin root, use protected_resource_root: false and let the per-resource integration mount only the documents it owns.

For first-party web flows, keep cookie semantics in your app and pass a generic credential extractor to the plug:

plug AttestoPhoenix.Plug.Authenticate,
  credential_from_conn: &MyAppWeb.Auth.access_token_from_cookie/1

The extractor returns {:ok, :bearer, token}, {:ok, :dpop, token}, or :missing. Attesto still verifies the token through the same JWT/DPoP/mTLS path; the cookie format and CSRF policy remain host concerns.

Req DPoP clients

attesto_phoenix is the server-side Phoenix layer. If you also use Req for OAuth clients in tests or internal tooling, req_dpop generates RFC 9449 DPoP proofs that interoperate with AttestoPhoenix.Plug.Authenticate. It is not a runtime dependency of this package; attesto_phoenix uses it only in tests as an external client compatibility check.

Database migration

The generated migration owns the operational tables backing the attesto store behaviours: attesto_authorization_codes, attesto_refresh_tokens, dpop_nonces, dpop_replays, and attesto_pushed_authorization_requests, plus two feature tables — attesto_client_id_metadata (the CIMD client-metadata cache) and attesto_consent_grants (the single-use, request-bound consent-grant primitive). It does not own a clients table (that is yours, behind :load_client).

Generate the migration into your app:

mix attesto_phoenix.gen.migration --repo MyApp.Repo

Then run it:

mix ecto.migrate

Clustering

Every mutable OAuth store has a Postgres-backed implementation, so a clustered or load-balanced deployment holds no OAuth state per node — a request can bounce across machines mid-flow. Access tokens are stateless signed JWTs (any node validates any token against the shared keystore); everything else lives in Postgres with atomic single-use enforcement (DELETE … RETURNING for codes and PAR references, conditional UPDATE for nonces, INSERT … ON CONFLICT for the replay cache, transactional refresh rotation/family revocation).

To be fully clusterable, wire the Ecto stores (the mix attesto_phoenix.install config block does this by default):

code_store:    AttestoPhoenix.Store.EctoCodeStore,
refresh_store: AttestoPhoenix.Store.EctoRefreshStore,
nonce_store:   AttestoPhoenix.Store.EctoNonceStore,
replay_check:  {AttestoPhoenix.Store.EctoReplayCheck, :check_and_record},
par_store:     AttestoPhoenix.Store.EctoPARStore

Single-node deployments may instead leave the defaults (in-memory ETS for nonces, replay, and PAR); the Ecto variants exist for clustered correctness. PAR is the one to watch: its default is single-node ETS, but FAPI 2.0 requires PAR, so a clustered FAPI deployment must set par_store: AttestoPhoenix.Store.EctoPARStore or a pushed request_uri will not resolve on the node that later handles /authorize.

Local HTTPS for development

attesto requires an https issuer (RFC 8414 §2), so a plain http://localhost dev server can't drive the OAuth / MCP flow — and there is deliberately no "disable https" switch. Instead, serve a locally-trusted mkcert certificate so https://localhost works with no tunnel and no downgrade.

Generate the certificate once:

mix attesto_phoenix.gen.dev_https

Then wire it into config/dev.exs in one line:

config :my_app, MyAppWeb.Endpoint,
  https: AttestoPhoenix.DevTLS.https_opts(port: 4443)

Point your issuer at https://localhost:4443 and discovery, DPoP, and the RFC 8707 resource identifiers all line up. AttestoPhoenix.DevTLS.https_opts/1 raises (pointing back at the generator) if the certificate is missing — it never falls back to http. See the Local HTTPS guide for the full walkthrough and the tunnel-vs-mkcert tradeoff.

Guides and examples

Development

mix deps.get
mix precommit
mix test --include ecto   # requires Postgres

License

MIT. See LICENSE.