PageSourceSearch

https://www.subzero-wolf.com/scripts/auth/sso-session.js

js subzero-wolf.com collected 2026-10-02 03:31:02 UTC 10,296 bytes, 221 lines download raw bytes

1import { events } from '@dropins/tools/event-bus.js';
2
3import { getCookie, isAccountRedirectEnabled } from '../configs.js';
4import { ACCOUNT_REGISTRATION_COMPLETE_KEY, REVEAL_PROJECTS_CACHE_KEY } from '../constants.js';
5import buildSsoLoginUrl from './sso-config.js';
6
7const SSO_COOKIE_NAMES = ['sso_id_token', 'sso_refresh_token'];
8const DROPIN_AUTH_COOKIE_NAMES = ['auth_dropin_user_token', 'auth_dropin_firstname'];
9const ALL_AUTH_COOKIE_NAMES = [...DROPIN_AUTH_COOKIE_NAMES, ...SSO_COOKIE_NAMES];
10
11// Cart state the Cart drop-in persists per session (the nav cart icon reads CART__DATA
12// to decide badged-vs-plain). Cleared on logout too, so a logged-out tab doesn't keep
13// showing the previous customer's cart count until the drop-in next reloads.
14const CART_SESSION_KEYS = ['DROPIN__CART__CART__DATA', 'DROPIN__CART__SHIPPING__DATA', 'DROPINS_CART_ID', 'nav-cart-count'];
15
16// The Cart drop-in keys guest-vs-customer solely on this cookie. Clear it on auth
17// invalidation too, so a logged-out (or expired) tab doesn't keep mutating the previous
18// session's cart id; the next login re-seeds the customer cart (see initializers/cart.js).
19const CART_COOKIE_NAMES = ['DROPIN__CART__CART-ID'];
20
21// Cookies are now set host-only (see scripts/auth/sso-callback.js). We still delete the
22// Domain-scoped variant alongside the host-only one to clean up any pre-existing
23// domain cookies from older sessions before this fix landed — a host-only delete only
24// removes the host-only variant, leaving a domain-scoped duplicate behind.
25function deleteCookies(names) {
26  const domainAttr = window.location.hostname !== 'localhost'
27    ? ` domain=${window.location.hostname};`
28    : '';
29  names.forEach((name) => {
30    document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
31    if (domainAttr) {
32      document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;${domainAttr}`;
33    }
34  });
35}
36
37// `atob` returns a binary string where each character is a single byte (Latin-1
38// interpretation). JWT payloads are UTF-8, so multi-byte sequences (e.g. "José",
39// "Müller") would render garbled if we returned the binary string directly. Route
40// through Uint8Array → TextDecoder('utf-8') so non-ASCII claims decode correctly.
41function base64UrlDecode(input) {
42  const normalized = input.replace(/-/g, '+').replace(/_/g, '/');
43  const padding = (4 - (normalized.length % 4)) % 4;
44  const binary = atob(normalized + '='.repeat(padding));
45  const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
46  return new TextDecoder('utf-8').decode(bytes);
47}
48
49// Tri-state cache:
50//   undefined = not yet decoded this page load
51//   null      = decoded but no valid token / claim
52//   object    = parsed payload / customer data
53let payloadCache;
54let customerCache;
55
56function invalidateCache() {
57  payloadCache = undefined;
58  customerCache = undefined;
59  // Cleared on every auth-invalidation path so an expired/cleared session can't
60  // leave stale per-user data for the next user in this tab — the "registration
61  // complete" flag and the cached Reveal projects list.
62  try { sessionStorage.removeItem(ACCOUNT_REGISTRATION_COMPLETE_KEY); } catch (e) { /* ignore */ }
63  try { sessionStorage.removeItem(REVEAL_PROJECTS_CACHE_KEY); } catch (e) { /* ignore */ }
64  CART_SESSION_KEYS.forEach((key) => {
65    try { sessionStorage.removeItem(key); } catch (e) { /* ignore */ }
66  });
67  deleteCookies(CART_COOKIE_NAMES);
68}
69
70/**
71 * Clear all four auth cookies (dropin tokens + SSO tokens) and invalidate the decode
72 * cache. Shared by the session-invalidation listener and the post-logout return handler
73 * (scripts/auth/sso-logout.js) so they use one cookie list + delete pattern.
74 */
75export function clearAuthCookies() {
76  deleteCookies(ALL_AUTH_COOKIE_NAMES);
77  invalidateCache();
78}
79
80/** True when any auth cookie (drop-in token or SSO token) is still present. */
81function hasAnyAuthCookie() {
82  return ALL_AUTH_COOKIE_NAMES.some((name) => !!getCookie(name));
83}
84
85function decodeSsoIdToken() {
86  if (payloadCache !== undefined) return payloadCache;
87  const token = getCookie('sso_id_token');
88  if (!token) { payloadCache = null; return null; }
89  const parts = token.split('.');
90  if (parts.length !== 3) { payloadCache = null; return null; }
91  try {
92    payloadCache = JSON.parse(base64UrlDecode(parts[1]));
93  } catch {
94    payloadCache = null;
95  }
96  return payloadCache;
97}
98
99/**
100 * Pre-gate validity check: if `sso_id_token` is a JWT whose `exp` claim has passed,
101 * clear all auth cookies so the auth gate downstream treats the user as unauthed
102 * (and redirects to SSO on /account/*). Silently no-ops if the cookie is missing,
103 * not a JWT, or has no `exp` — the post-render verifyToken pipeline still catches
104 * revocations.
105 */
106export function handleExpiredSsoToken() {
107  const payload = decodeSsoIdToken();
108  if (!payload) return;
109  if (typeof payload.exp !== 'number') return;
110  if (payload.exp * 1000 > Date.now()) return;
111  deleteCookies(ALL_AUTH_COOKIE_NAMES);
112  invalidateCache();
113}
114
115/**
116 * Return `{ firstName, lastName }` extracted from the `sso_id_token` JWT for display
117 * purposes. Returns `null` if there's no token, it isn't a JWT, or neither name claim
118 * is present. Memoized for the page load (cookies don't change without a navigation;
119 * the cache is invalidated when this module clears cookies itself).
120 *
121 * SECURITY: the JWT signature is NOT verified client-side. Use ONLY for cosmetic
122 * display (greetings, labels). Never use these claims for authorization decisions —
123 * those must go through server-validated GraphQL via the Bearer token.
124 *
125 * @returns {{firstName: string|null, lastName: string|null} | null}
126 */
127export function getSsoCustomer() {
128  if (customerCache !== undefined) return customerCache;
129  const payload = decodeSsoIdToken();
130  if (!payload) { customerCache = null; return null; }
131  const firstName = payload.given_name || null;
132  const lastName = payload.family_name || null;
133  if (!firstName && !lastName) { customerCache = null; return null; }
134  customerCache = { firstName, lastName };
135  return customerCache;
136}
137
138/**
139 * Return `{ userId, email, accountType, primaryPhone, companyName }` extracted from
140 * the `sso_id_token` JWT. `userId` is the stable subject id (`oid`, falling back to
141 * `sub`). Returns `null` if there's no token, it isn't a JWT, or all claims are missing.
142 *
143 * SECURITY: same caveat as `getSsoCustomer()` — signature is NOT verified
144 * client-side. Cosmetic display / analytics only, never authorization.
145 *
146 * @returns {{userId: string|null, email: string|null, accountType: string|null, primaryPhone: string|null, companyName: string|null} | null}
147 */
148export function getSsoClaims() {
149  const payload = decodeSsoIdToken();
150  if (!payload) return null;
151
152  const userId = payload.oid || payload.sub || null;
153  const email = payload.email || null;
154  const accountType = payload.extension_accountType || null;
155  const primaryPhone = payload.mobile || null;
156  const companyName = payload.extension_companyName || null;
157  if (!userId && !email && !accountType && !primaryPhone && !companyName) return null;
158  return {
159    userId, email, accountType, primaryPhone, companyName,
160  };
161}
162
163/** @deprecated use `getSsoClaims()` — kept for existing importers */
164export const getSsoEmailAndAccountType = getSsoClaims;
165
166let attached = false;
167
168/**
169 * Subscribe once to the dropin's `authenticated` event so a revoked or expired session is
170 * fully torn down. When verifyToken detects an invalid token it clears
171 * `auth_dropin_user_token` (host-only) and emits `false`; we clear all four auth cookies
172 * (dropin tokens + SSO tokens) via the host-only + Domain-scoped dual delete so legacy
173 * domain-scoped cookies the dropin's delete couldn't reach also go. Without this,
174 * `checkIsAuthenticated()` could keep returning `true` after invalidation and the user
175 * would get stuck. If the user is on an /account/* page, bounce them back through SSO.
176 *
177 * Guarded on a session having existed: verifyToken emits `authenticated: false` on every
178 * page load when no `auth_dropin_user_token` is present — not only on revocation — so
179 * anonymous visitors reach this listener too. Unguarded, the teardown wiped
180 * `DROPIN__CART__CART-ID` on each guest page load and the Cart drop-in then mounted with
181 * no cart, so the accessories cart rendered empty right after an add (AB#259775). Guests
182 * on /account/* are already bounced by the auth gate in loadEager, so no redirect is lost.
183 *
184 * Prerender-safe: if the event fires during a Chrome speculation-rule prerender
185 * (e.g. when verifyToken emits `false` from the ghost tab because no cookie is
186 * present), the cleanup + redirect is deferred to `prerenderingchange` so we
187 * don't race the user's real navigation from the ghost context. Mirrors the
188 * `!document.prerendering` guard in the auth gate (scripts/scripts.js).
189 */
190export function setupSessionInvalidationListener() {
191  if (attached) return;
192  attached = true;
193  // Sampled before the dropin's first verifyToken (this runs earlier in initializeDropins),
194  // then latched by any later sign-in — the dropin emits `authenticated: true` once the
195  // token cookie is written — so a subsequent invalidation still tears the session down.
196  let hadAuthSession = hasAnyAuthCookie();
197  events.on('authenticated', (state) => {
198    if (state) {
199      hadAuthSession = true;
200      return;
201    }
202    // Cookie re-check is a fail-safe, not a live path: every cookie writer today runs before
203    // this listener attaches. If one ever lands after, err towards clearing a real session
204    // rather than stranding the user logged-in against a token the server has rejected.
205    if (!hadAuthSession && !hasAnyAuthCookie()) return;
206    hadAuthSession = false;
207    const handle = () => {
208      clearAuthCookies();
209      // Honours the same `sso.account-redirect-enabled` kill-switch as the loadEager gate,
210      // otherwise an invalidated session would still bounce to SSO with the flag off.
211      if (window.location.pathname.startsWith('/account') && isAccountRedirectEnabled()) {
212        window.location.replace(buildSsoLoginUrl());
213      }
214    };
215    if (document.prerendering) {
216      document.addEventListener('prerenderingchange', handle, { once: true });
217    } else {
218      handle();
219    }
220  });
221}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.