Zoo

← back to the zoo

3. Sign out everywhere

Signing out at account signs you out of every app in this browser.

rc_sign_out() sends the browser to account, which ends the one session behind every app's token. No app has to be told.

This is the code serving the zoo right now: read from disk for this request, from commit 0c1c124a02 (live). The zoo's own self-check fetches this page and compares it byte for byte with the file it runs.

exhibits/03-sign-out-everywhere.php sha256 f8b218ae5285 · raw

1<?php
2// Exhibit 3. rc_sign_out() forgets this app's token and sends the visitor to account's
3// "sign out everywhere". Every other app drops its token on its next request, because the hint
4// cookie account set on the parent domain is gone.
5//
6// After signing out you are a visitor, so the zoo cannot ask auth_user() who you were. It keeps
7// that in a short-lived signed cookie, only to let you confirm "every card said visitor".
8
9function signout_mark(): string {
10    if (meta("signout_key") === null) meta_set("signout_key", bin2hex(random_bytes(16)));
11    return meta("signout_key");
12}
13
14return [
15    "n" => 3, "wing" => "Identity", "kind" => "human",
16    "title" => "Sign out everywhere",
17    "promise" => "Signing out at account signs you out of every app in this browser.",
18    "block" => "rc_sign_out() sends the browser to account, which ends the one session behind every app's token. No app has to be told.",
19    "show" => function (?array $me): string {
20        if ($me) return '<p>Press this, confirm at account, and come back: every "who am I" card (here and on '
21            . '<a href="' . h(rc_app_url("zoo-broken")) . '/" target="_blank" rel="noopener">the second app</a>) should say visitor.</p>'
22            . '<p><a href="/api/3/signout"><button class="primary">Sign out everywhere</button></a></p>';
23        [$uid, $t, $sig] = array_pad(explode(".", (string)($_COOKIE["__Host-zoo_out"] ?? "")), 3, "");
24        if ($sig !== "" && hash_equals(hash_hmac("sha256", "$uid.$t", signout_mark()), $sig) && time() - (int)$t < 3600) {
25            $u = rc_users([(int)$uid])[(int)$uid] ?? null;
26            return '<p>You signed out as <b>@' . h($u["username"] ?? "?") . '</b>. Does every card say visitor, here and on <a href="'
27                . h(rc_app_url("zoo-broken")) . '/" target="_blank" rel="noopener">the second app</a>?</p>'
28                . '<p><button class="seen-out primary">Yes, every card says visitor</button></p>';
29        }
30        return '<p>Sign in first; then this button signs you out of every app at once.</p>';
31    },
32    "api" => function (string $do, ?array $me, array $in, bool $post): ?array {
33        if ($do === "signout") {
34            if ($me) {
35                $t = time();
36                setcookie("__Host-zoo_out", "{$me['id']}.$t." . hash_hmac("sha256", "{$me['id']}.$t", signout_mark()),
37                    ["expires" => $t + 3600, "path" => "/", "secure" => true, "httponly" => true, "samesite" => "Lax"]);
38            }
39            rc_sign_out("https://" . env("RC_HOST") . "/#e3");
40        }
41        if ($do === "confirm" && $post) {
42            [$uid, $t, $sig] = array_pad(explode(".", (string)($_COOKIE["__Host-zoo_out"] ?? "")), 3, "");
43            if ($me) return ["error" => "you are signed in again: this card says your name", "status" => 409];
44            if ($sig === "" || !hash_equals(hash_hmac("sha256", "$uid.$t", signout_mark()), $sig) || time() - (int)$t > 3600) return ["error" => "no recent sign-out from this browser", "status" => 400];
45            seen(3, ["id" => (int)$uid]);
46            setcookie("__Host-zoo_out", "", ["expires" => 1, "path" => "/", "secure" => true, "httponly" => true, "samesite" => "Lax"]);
47            return ["ok" => true];
48        }
49        return null;
50    },
51    "script" => <<<'JS'
52document.querySelector(".seen-out")?.addEventListener("click", async (ev) => {
53  ev.target.disabled = true;
54  const j = await zoo.call("/api/3/confirm", {});
55  ev.target.textContent = j.error || "Recorded, thank you";
56  if (!j.error) setTimeout(() => location.reload(), 600);
57});
58
59JS,
60];