Packages
attesto_phoenix
0.19.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/controller/end_session_controller.ex
defmodule AttestoPhoenix.Controller.EndSessionController do
@moduledoc """
End-session endpoint (OpenID Connect RP-Initiated Logout 1.0 §2 +
Back-Channel Logout 1.0).
Where a Relying Party sends the End-User's browser to log out. This
controller owns the protocol — it verifies the `id_token_hint`, validates the
`post_logout_redirect_uri` against the RP's registered set, fans a
`logout_token` out to every other RP holding the session, and either
redirects to the validated return URI or hands off to the host's logged-out
page — while the host owns the browser session and the HTML through two
callbacks:
* `:terminate_session` (REQUIRED when logout is enabled) —
`(conn, context -> {:ok, conn} | {:ok, conn, session} | {:halt, conn})`.
Clears the host's browser login session. `context` is `%{subject, sid,
client_id}` carrying the `id_token_hint`'s values (any may be nil). The
host is the authority on the session:
* `{:ok, conn}` — the current session was cleared; run front-channel
logout only (no back-channel fan-out).
* `{:ok, conn, %{sid: ..., subject: ...}}` — the current session was
cleared; fan out a `logout_token` to the RPs of **this**
host-confirmed session. The fan-out scope comes from here, NOT from
the request's `id_token_hint`, so a replayed or stolen ID Token cannot
force-log-out an arbitrary session.
* `{:halt, conn}` — the host has taken over the response entirely (e.g.
to render a logout-confirmation step); nothing further runs.
* `:render_logged_out` (optional) — `(conn, context -> conn)`. Renders the
"you are now logged out" page when the request asked for no
`post_logout_redirect_uri`. A minimal 200 is sent when unset.
## Host responsibility: session binding + CSRF (REQUIRED)
RP-Initiated Logout is a state-changing action reachable by GET (the RP
redirects the browser here). The `id_token_hint` is **not** an authenticator —
it is signed by the OP and any party that holds a copy (a malicious RP, a
leaked token) can present it. The library therefore makes the host the session
authority: `:terminate_session` MUST verify that the request corresponds to
the **current** OP browser session (its cookie) before clearing it and before
returning an `{:ok, conn, session}` that drives back-channel fan-out. A host
that wants an explicit confirmation step returns `{:halt, conn}` and renders
its own page. The controller additionally requires HTTPS. Without this
binding, `/end_session` is a logout-CSRF / forced-logout primitive.
## Redirect safety
A `post_logout_redirect_uri` is honored only when it **exactly** matches one
the client registered (RP-Initiated Logout §2/§3); the RP is identified from
the verified `id_token_hint`'s `aud` (or the `client_id` parameter). A request
that names an unregistered URI — or supplies one with no way to identify the
client — is refused before any session is touched, so the endpoint cannot be
turned into an open redirector.
## Back-Channel Logout (Back-Channel Logout 1.0 §2.5)
When `:terminate_session` returns a confirmed session, the OP atomically takes
(enumerates-and-deletes) that session's recorded `(sid|subject)` rows and
POSTs a signed `logout_token` to each RP's `backchannel_logout_uri` (recorded
at mint time, see `Attesto.LogoutSessionStore`). The take is atomic so
concurrent logouts cannot double-deliver. Delivery is best-effort: a slow or
failing RP is logged, never allowed to stall the user's logout. Requires a
`:logout_session_store`; without one, only RP-Initiated (front-channel) logout
runs.
"""
use Phoenix.Controller, formats: [:html, :json]
alias Attesto.EndSession
alias Attesto.LogoutToken
alias AttestoPhoenix.{Callback, Config, RequestContext}
require Logger
@spec end_session(Plug.Conn.t(), map()) :: Plug.Conn.t()
def end_session(conn, params) do
config = resolve_config()
with :ok <- require_enabled(config),
:ok <- check_https(conn, config),
{:ok, request} <- parse(config, params),
{:ok, redirect} <- confirm_redirect(config, request) do
logout(conn, config, request, redirect)
else
{:error, status, body} -> conn |> put_status(status) |> json(body)
end
end
# ── pipeline steps ───────────────────────────────────────────────────────
defp require_enabled(config) do
if Config.logout_enabled?(config), do: :ok, else: {:error, 404, %{error: "not_found"}}
end
defp check_https(conn, config) do
case RequestContext.check_https(conn, config) do
:ok -> :ok
{:error, :insecure_transport} -> {:error, 400, error_body("TLS required")}
end
end
defp parse(config, params) do
case EndSession.parse(Config.to_attesto_config(config), params) do
{:ok, request} -> {:ok, request}
{:error, :invalid_id_token_hint} -> {:error, 400, error_body("invalid id_token_hint")}
{:error, :client_id_mismatch} -> {:error, 400, error_body("client_id does not match id_token_hint")}
end
end
# Load the RP's registered post-logout URIs (empty when the client could not
# be identified) and validate the requested return URI against them.
defp confirm_redirect(config, request) do
registered = registered_post_logout_uris(config, request.client_id)
case EndSession.confirm_redirect(request, registered) do
{:ok, target} -> {:ok, target}
{:error, :invalid_post_logout_redirect_uri} -> {:error, 400, error_body("invalid post_logout_redirect_uri")}
end
end
defp registered_post_logout_uris(_config, nil), do: []
defp registered_post_logout_uris(config, client_id) do
case Config.client_store_load(config, client_id) do
{:ok, client} -> Config.client_post_logout_redirect_uris(config, client)
_ -> []
end
end
# ── perform the logout ───────────────────────────────────────────────────
defp logout(conn, config, request, redirect) do
context = %{subject: request.subject, sid: request.sid, client_id: request.client_id}
case terminate_session(conn, config, context) do
{:error, status, body} ->
conn |> put_status(status) |> json(body)
{:halt, conn} ->
conn
{:ok, conn, session} ->
# The fan-out scope is the host-confirmed session, NOT the request's
# id_token_hint — a replayed token cannot force-log-out another session.
fan_out_back_channel(config, session)
finish(conn, config, redirect, context)
end
end
# The host is the session authority. A missing callback is fail-closed: a
# logout endpoint that cannot clear the session must NOT report success, fan
# out, or redirect — that would be a false-success logout.
defp terminate_session(conn, config, context) do
case config.terminate_session do
nil ->
Logger.error("end_session: no :terminate_session callback configured")
{:error, 500, %{error: "server_error", error_description: "logout is not fully configured"}}
callback ->
case Callback.invoke(callback, [conn, context]) do
{:halt, halted} -> {:halt, halted}
{:ok, %Plug.Conn{} = updated, session} when is_map(session) -> {:ok, updated, session}
{:ok, %Plug.Conn{} = updated} -> {:ok, updated, %{}}
%Plug.Conn{} = updated -> {:ok, updated, %{}}
_ -> {:error, 500, %{error: "server_error", error_description: "logout failed"}}
end
end
end
defp finish(conn, _config, redirect, _context) when is_binary(redirect) do
redirect(conn, external: redirect)
end
defp finish(conn, config, :no_redirect, context) do
case config.render_logged_out do
nil -> conn |> put_status(200) |> json(%{status: "logged_out"})
callback -> Callback.invoke(callback, [conn, context])
end
end
# ── Back-Channel Logout fan-out (Back-Channel Logout 1.0 §2.5) ────────────
# POST a signed logout_token to every RP holding the host-confirmed session.
# The rows are taken atomically (enumerated-and-deleted in one statement), so a
# session is logged out exactly once even under concurrent end-session calls.
# `session` is the `%{sid|subject}` the host attested it terminated.
defp fan_out_back_channel(config, session) do
store = Config.logout_session_store(config)
criteria = logout_criteria(session)
if not is_nil(store) and map_size(criteria) > 0 do
http = Config.backchannel_logout_http(config)
attesto_config = Config.to_attesto_config(config)
criteria
|> store.take_targets()
|> Enum.each(&deliver(attesto_config, http, session, &1))
end
rescue
e -> Logger.warning("back-channel logout fan-out failed: #{inspect(e)}")
end
defp deliver(attesto_config, http, session, target) do
opts =
[]
|> put_opt(:sub, Map.get(session, :subject))
|> put_opt(:sid, target.sid)
with {:ok, token} <- LogoutToken.mint(attesto_config, target.client_id, opts),
:ok <- http.post(target.backchannel_logout_uri, token) do
:ok
else
{:error, reason} ->
Logger.warning("back-channel logout to #{target.client_id} failed: #{inspect(reason)}")
:error
end
end
# `:sid` scopes the fan-out to one session; `:subject` (no sid) to all of the
# subject's sessions. A session map with neither identifies nothing.
defp logout_criteria(%{sid: sid}) when is_binary(sid) and sid != "", do: %{sid: sid}
defp logout_criteria(%{subject: sub}) when is_binary(sub) and sub != "", do: %{subject: sub}
defp logout_criteria(_session), do: %{}
defp put_opt(opts, _key, nil), do: opts
defp put_opt(opts, _key, ""), do: opts
defp put_opt(opts, key, value), do: Keyword.put(opts, key, value)
defp error_body(description), do: %{error: "invalid_request", error_description: description}
defp resolve_config do
otp_app = Application.get_env(:attesto_phoenix, :otp_app)
Config.from_otp_app(otp_app, Config)
end
end