Clear ExchangeDocs Live demo

Accounts, sign-in and domain verification

The web product has three areas:

AreaPathLogin
Landing page/No
Live demo (agent console, operator and publisher views)/appNo
Dashboard/dashboard (sign-in at /login)Yes, Google

The API, including the admin-token endpoints under /v1/admin/*, is unchanged: agents keep using their API keys and signatures, and operators keep using ADMIN_TOKEN. A signed-in dashboard user is not an admin.

Signing in with Google

Anyone with a Google account can sign in. The server runs the OAuth 2.0 authorization code flow with PKCE (src/auth/google.ts):

  1. GET /auth/google creates a random state, OpenID Connect nonce and PKCE verifier (S256 challenge), keeps them server-side for ten minutes, sets the state in a short-lived HttpOnly cookie to tie the flow to this browser, and redirects to Google.
  2. Google redirects to /auth/google/callback?code=…&state=…. The server checks the state matches both the cookie and a pending flow (each is used once), exchanges the code at Google's token endpoint with the PKCE verifier and the client secret, and checks the ID token's issuer, audience, expiry, nonce and verified email. The ID token comes straight from Google's token endpoint over TLS, which OpenID Connect Core 3.1.3.7 accepts in place of a signature check.
  3. The user is created on first sign-in (keyed on Google's stable sub), a session starts, and the browser goes to /dashboard.

The client secret never reaches the browser. A failed or cancelled sign-in lands on /login?error=… with no session.

The session is 32 random bytes in an HttpOnly, SameSite=Lax, Path=/ cookie that lasts seven days. When PUBLIC_BASE_URL is https (production) it is also Secure and named __Host-cx_session (no Domain, so it belongs to one host only). Lax, not Strict, because the cookie has to come back on the redirect from Google. The server keeps only the SHA-256 of the session id in accounts.jsonl (in VAR_DIR), so sign-ins survive a deploy and the file cannot be used to sign in. POST /auth/logout ends the session on the server.

Writes (POST, PUT, DELETE) take JSON only and are refused if the browser says the request came from another site (Origin or Sec-Fetch-Site).

Redirect URI and base URL

The redirect URI is PUBLIC_BASE_URL plus /auth/google/callback; by default https://clear-exchange.fly.dev/auth/google/callback, which is the one registered with Google. To serve sign-in from a custom domain, add https://app.clearexchange.ai/auth/google/callback as an authorised redirect URI in Google Cloud Console, then set PUBLIC_BASE_URL=https://app.clearexchange.ai in fly.toml. Sign-in started on another hostname is sent to the base URL's host first, because the state cookie has to be on the host Google returns to. The site hostname hands /login, /dashboard, /auth/* and /api/* to the app hostname.

See Configuration and deploy for GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET. Without them the server runs, and /auth/google answers 503.

Who you represent

On first sign-in the dashboard asks for the organisation name and the website domain the user represents (POST /api/signup). The domain is stored as a claim, with its own verification token. More domains can be claimed (up to ten) with POST /api/domains. A claim proves nothing by itself.

Proving you control a domain

Like Bing Webmaster Tools, a user can change things for a publisher only after proving they control its domain. Each claim has a token, and any one of these works (src/auth/domain-verify.ts):

MethodWhat to publish
DNSA TXT record on _clearexchange.<domain> (or on the domain itself) with the value clear-exchange-verification=<token>
Meta tag<meta name="clear-exchange-verification" content="<token>"> in the <head> of the home page
Filehttps://<domain>/.well-known/clear-exchange-verification.txt containing only the token

The dashboard shows the exact values and a Verify button (POST /api/domains/<domain>/verify, optionally {"method":"dns"|"meta"|"file"}). Web checks try https, then http. Verification is per user: another user who claims the same domain has a different token. Attempts are limited to ten a minute per user.

Read-only until verified. Until at least one of their domains is verified, a user sees the dashboard read-only. Everything that changes data for a publisher goes through AccountStore.canModify(user, domain), which is true only for a verified claim; the one write today is the publisher profile (PUT /api/domains/<domain>/profile: display name, contact email, description), which answers 403 domain_not_verified otherwise. New publisher-facing writes must use the same check.

Fetching a user's website safely (SSRF)

The web checks fetch URLs a user chose, so they go through src/auth/safe-fetch.ts:

  • http and https only, no credentials in the URL, ports 80 and 443 only;
  • the host is resolved by the server and every address must be public: loopback, private, link-local (including the cloud metadata address), CGNAT, documentation, multicast and reserved ranges are refused, including IPv4 hidden in IPv6 (mapped, NAT64, 6to4). The connection is pinned to the address that was checked, so DNS cannot change between the check and the connect;
  • redirects are followed by hand (three at most), each hop checked the same way, and only between the domain and its www form, so a page on another site that carries the token proves nothing;
  • a five second overall timeout, and the body is cut off at 256 KB.

DNS checks only read TXT records.

Endpoints

GET /login, GET /dashboardPages. /dashboard redirects to /login without a session
GET /auth/google, GET /auth/google/callback, POST /auth/logoutSign-in and sign-out
GET /api/auth/status{ "google": true } when sign-in is configured
GET /api/meThe user, organisation, domains (with the values to publish for unverified ones) and read_only
POST /api/signup{ organisation, domain }, once
POST /api/domains, DELETE /api/domains/<domain>Claim or drop a domain
POST /api/domains/<domain>/verifyRun the checks; returns each method's result
PUT /api/domains/<domain>/profileVerified domains only

Errors use the usual { "error": { "code", "message" } } shape.