# `Attesto.SessionState`
[🔗](https://github.com/XukuLLC/attesto/blob/v2.0.1/lib/attesto/session_state.ex#L1)

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)) <> "." <> salt

  * `client_id` — the RP's client identifier.
  * `origin` — the origin of the Authentication Response's `redirect_uri`
    (see `origin/1`), which is what the browser reports as
    `MessageEvent.origin` when 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 yields `changed`.
  * `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 . mac

  * `random` — 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 fails `browser_state_valid?/3`
    and 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.

# `browser_state_valid?`

```elixir
@spec browser_state_valid?(binary(), binary(), binary()) :: boolean()
```

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_tag` is 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
    RP `session_state` becomes `changed`.

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`

```elixir
@spec compute(String.t(), String.t(), String.t(), String.t()) :: String.t()
```

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.

# `generate_browser_state`

```elixir
@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).

# `generate_salt`

```elixir
@spec generate_salt() :: String.t()
```

A fresh random salt for `compute/4` (unpadded URL-safe Base64, no spaces or dots).

# `mint_browser_state`

```elixir
@spec mint_browser_state(binary(), binary()) :: String.t()
```

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.

# `origin`

```elixir
@spec origin(String.t()) :: {:ok, String.t()} | {:error, :invalid_uri}
```

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/1`
    preserves them (`https://RP.Example`), so both are lowercased here.
  * **IPv6** — the browser serializes an IPv6 host in brackets
    (`https://[::1]`); `URI.parse/1` strips 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).

---

*Consult [api-reference.md](api-reference.md) for complete listing*
