OpenID Connect Session Management 1.0 — the session_state value.
Session Management lets a Relying Party poll, from JavaScript, whether the
End-User's login state at the OP has changed without a network round trip:
the RP posts client_id + " " + session_state to the OP's
check_session_iframe, which recomputes the value from the current OP
browser state and answers unchanged or changed (§3.1/§3.2).
session_state is returned to the client as an additional authorization
response parameter (§2) and is computed as (§3.2):
hex(SHA256(client_id <> " " <> origin <> " " <> op_browser_state <> " " <> salt)) <> "." <> saltclient_id— the RP's client identifier.origin— the origin of the Authentication Response'sredirect_uri(seeorigin/1), which is what the browser reports asMessageEvent.originwhen the RP's iframe posts to the OP iframe.op_browser_state— the OP User Agent state: an opaque value stored in a JavaScript-readable cookie at the OP origin that changes on login/logout, so a recomputation with a stale value yieldschanged.salt— a random per-response salt carried in cleartext after the.so the OP iframe can recompute the hash.
The hash is lowercase hex, matching the §3.2 example's CryptoJS.SHA256
string form, so the OP iframe's JavaScript recomputation compares equal. The
value contains no space character (§2).
Like the rest of attesto core this module is pure: the host owns the browser
state cookie and the check_session_iframe page; this module owns the
computation both sides must agree on.
OP-owned, login-bound browser state
The session_state recipe treats op_browser_state as an opaque string, so
the OP is free to give that string internal structure. Session Management
requires the OP browser state to be OP-owned (an RP-visible / injected
value must not be able to forge unchanged) and to change when the
End-User's login state changes (§3.2). mint_browser_state/2 and
browser_state_valid?/3 give the value both properties without changing what
the iframe hashes:
random . login_tag . macrandom— fresh 128-bit entropy, unguessable cross-origin;login_tag—HMAC(secret, login_binding), a stable fingerprint of the current End-User login state (subject / auth_time / sid); a re-auth or account switch changes it, so a stale cookie failsbrowser_state_valid?/3and the OP rotates;mac—HMAC(secret, random . login_tag), so a value the OP did not mint (a cookie injected by a sibling/parent-domain origin) cannot verify.
Only the OP knows secret. The whole random . login_tag . mac string is
still what compute/4 hashes as op_browser_state, so the iframe recipe is
unchanged.
Summary
Functions
Whether value is an OP browser-state string this OP minted (under secret)
for the current login_binding.
Compute the session_state for an authorization response (§3.2).
A fresh OP browser state value.
A fresh random salt for compute/4 (unpadded URL-safe Base64, no spaces or dots).
Mint an OP browser-state value that is OP-owned and bound to the End-User's current login state (see the module doc).
The browser-form origin of uri (RFC 6454): scheme://host, with the port
appended only when it is not the scheme's default — exactly the string the
browser reports as MessageEvent.origin for a page loaded from uri.
Functions
Whether value is an OP browser-state string this OP minted (under secret)
for the current login_binding.
Returns false — so the caller mints a fresh value — when either:
- the value is malformed or its MAC does not verify: a value the OP never minted (a forged / cross-origin-injected cookie), which must not be trusted as authoritative browser state; or
- the MAC verifies but the embedded
login_tagis for a different login state: the End-User re-authenticated or switched accounts, so the OP browser state MUST rotate (Session Management 1.0 §3.2) and any earlier RPsession_statebecomeschanged.
Both comparisons go through Attesto.SecureCompare.equal?/2, so neither
reveals HOW the presented value differs - wrong length, or right length and
wrong bytes, take the same path. The segments come from an attacker-supplied
string and are not length-validated first, so the time taken still varies
with how much was submitted; see that module for the distinction.
Raises ArgumentError if secret is shorter than the 32-byte floor
mint_browser_state/2 enforces (RFC 2104 §3).
Compute the session_state for an authorization response (§3.2).
origin must be a browser-form origin (scheme://host[:port], default port
omitted) — derive it from the response's redirect_uri with origin/1.
salt defaults to a fresh generate_salt/0.
Raises ArgumentError if an explicit salt is empty or contains a space
(§2 forbids a space in session_state) or a . (the hash "." salt
delimiter). generate_salt/0 always satisfies this.
@spec generate_browser_state() :: String.t()
A fresh OP browser state value.
The host stores it in a JavaScript-readable cookie at the OP origin (§3.2 —
the check_session_iframe script must read it, so HttpOnly cannot be set)
and changes it when the End-User's login state changes (login/logout).
@spec generate_salt() :: String.t()
A fresh random salt for compute/4 (unpadded URL-safe Base64, no spaces or dots).
Mint an OP browser-state value that is OP-owned and bound to the End-User's current login state (see the module doc).
secret is an OP-only HMAC key; login_binding is a caller-chosen string
that captures the current login state (e.g. subject, auth_time, and sid
joined together). The returned random . login_tag . mac string contains no
space or .-ambiguity in its parts (each part is base64url-no-pad), so it is
a valid op_browser_state for compute/4 and splits back cleanly in
browser_state_valid?/3.
Raises ArgumentError if secret is shorter than 32 bytes — an enforced
key-length floor (RFC 2104 §3) for the HMAC that makes the value OP-owned.
browser_state_valid?/3 enforces the same floor.
The browser-form origin of uri (RFC 6454): scheme://host, with the port
appended only when it is not the scheme's default — exactly the string the
browser reports as MessageEvent.origin for a page loaded from uri.
The value must equal the browser's WHATWG MessageEvent.origin, or the iframe
recomputation compares unequal and the OP answers a permanent, false
changed (a fail-safe error - it over-reports change, never under-reports).
URI.parse/1 (RFC 3986) diverges from the browser; this closes the two
divergences a registered redirect_uri realistically hits:
- case — the browser lowercases the scheme and host;
URI.parse/1preserves them (https://RP.Example), so both are lowercased here. - IPv6 — the browser serializes an IPv6 host in brackets
(
https://[::1]);URI.parse/1strips them (host: "::1"), so a literal host (one containing:) is re-bracketed here.
This is NOT a full WHATWG serializer. The input is expected to be an
already-validated, canonical HTTP(S) redirect_uri; noncanonical forms are
left as-is and would still diverge (each yielding only the fail-safe false
changed, never a security bypass):
- a non-canonical IPv6 spelling (
[0:0:0:0:0:0:0:1]vs the browser's compressed[::1]) or a non-dotted-decimal IPv4 (0177.0.0.1); - a raw non-ASCII host — full IDNA Unicode→punycode mapping is out of scope (a registered redirect_uri is already ASCII/punycode);
- a non-HTTP(S) scheme, whose browser origin is the opaque
null.
Returns {:ok, origin} or {:error, :invalid_uri} for a URI with no
scheme/host (a session_state computed over a malformed origin could never
compare equal in the browser, so fail closed instead).