Packages
attesto_phoenix
2.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/controller/check_session_controller.ex
defmodule AttestoPhoenix.Controller.CheckSessionController do
@moduledoc """
The `check_session_iframe` (OpenID Connect Session Management 1.0 §3.3).
Serves the OP iframe an RP embeds (invisibly) to poll the End-User's login
state at the OP without network traffic. The RP's own iframe posts
`client_id + " " + session_state` here (§3.1); this page's script recomputes
the `session_state` from the message's `client_id`, the **sender's origin**
(`MessageEvent.origin` — so a message from the wrong origin can never
compare equal), the current OP browser-state cookie, and the salt carried in
the received value, then posts back exactly one of (§3.2):
* `unchanged` — the recomputed value equals the received one;
* `changed` — it does not (the user logged out, or the state rotated);
* `error` — the message is syntactically malformed.
The computation is the mirror image of `Attesto.SessionState.compute/4`
(lowercase-hex SHA-256 over `client_id <> " " <> origin <> " " <> opbs <>
" " <> salt`, dot, salt); the hash runs on `crypto.subtle`, which is always
available here because the page is served from the OP's HTTPS origin. The
browser-state cookie is the JavaScript-readable cookie maintained by the
authorization endpoint (set at login) and the end-session endpoint (expired
at logout) — see `AttestoPhoenix.BrowserState`.
The page embeds no per-user data (the cookie is read client-side at message
time), so it is served cacheable like the discovery document. It is mounted
by `AttestoPhoenix.Router`'s `session_management: true` option and answers
404 unless the host enabled `session_management: [enabled: true]`, so a
mounted-but-unconfigured route never advertises a capability that is off.
"""
use Phoenix.Controller, formats: [:html, :json]
import Plug.Conn
alias AttestoPhoenix.Config
# The page is static for a given configuration; cache like the discovery
# document (the login-state answer is computed in the browser per message,
# never baked into the page).
@cache_max_age_seconds 3600
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()
def show(conn, _params) do
config = resolve_config()
if Config.session_management_enabled?(config) do
conn
|> put_resp_header("cache-control", "public, max-age=#{@cache_max_age_seconds}")
|> put_resp_content_type("text/html")
|> send_resp(200, page_html(Config.browser_state_cookie(config)))
else
conn |> put_status(404) |> json(%{error: "not_found"})
end
end
# The §3.2 OP iframe. The cookie name is configuration (not user input) and
# rides into the script as a JSON string literal, so it cannot break out of
# the JavaScript context.
defp page_html(cookie_name) do
cookie_literal = JSON.encode!(cookie_name)
"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OP session check</title>
</head>
<body>
<script>
(function () {
var COOKIE = #{cookie_literal};
function browserState() {
var prefix = COOKIE + "=";
var parts = document.cookie ? document.cookie.split("; ") : [];
for (var i = 0; i < parts.length; i++) {
if (parts[i].indexOf(prefix) === 0) {
return decodeURIComponent(parts[i].substring(prefix.length));
}
}
return "";
}
function sha256Hex(text) {
var bytes = new TextEncoder().encode(text);
return crypto.subtle.digest("SHA-256", bytes).then(function (digest) {
var view = new Uint8Array(digest);
var hex = "";
for (var i = 0; i < view.length; i++) {
hex += view[i].toString(16).padStart(2, "0");
}
return hex;
});
}
function reply(event, message) {
if (event.source) {
event.source.postMessage(message, event.origin);
}
}
window.addEventListener("message", function (event) {
var data = event.data;
if (typeof data !== "string") { return; }
// Session Management 1.0 section 3.1: "client_id + ' ' + session_state".
var separator = data.lastIndexOf(" ");
if (separator < 1 || separator === data.length - 1) {
reply(event, "error");
return;
}
var clientId = data.substring(0, separator);
var sessionState = data.substring(separator + 1);
var dot = sessionState.indexOf(".");
if (dot < 1 || dot === sessionState.length - 1) {
reply(event, "error");
return;
}
var salt = sessionState.substring(dot + 1);
var text = clientId + " " + event.origin + " " + browserState() + " " + salt;
sha256Hex(text).then(function (hash) {
reply(event, (hash + "." + salt) === sessionState ? "unchanged" : "changed");
}).catch(function () {
reply(event, "error");
});
}, false);
})();
</script>
</body>
</html>
"""
end
defp resolve_config do
otp_app = Application.get_env(:attesto_phoenix, :otp_app)
Config.from_otp_app(otp_app, Config)
end
end