Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ NEXT_PUBLIC_SIWE_DOMAIN=localhost:3000
# 200 characters — invalid values throw a ConfigError at startup.
NEXT_PUBLIC_SIWE_STATEMENT="Sign in to GuildPass Admin"

# Session auth mode. "bearer" (default) persists the SIWE token in
# sessionStorage and sends it as an Authorization header. "cookie" is
# dual-mode readiness for the httpOnly-cookie migration (see
# docs/http-only-cookie-migration.md) — no token is ever read from or
# written to sessionStorage, and no Authorization header is sent. Any value
# other than the literal "cookie" falls back to "bearer".
# NEXT_PUBLIC_AUTH_MODE=cookie


# ── Wallet / Chain Configuration ──────────────────────────────────────────────
# Comma-separated chain names offered to wagmi. Supported values:
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,27 @@ Admin actions are protected by [Sign-In with Ethereum (EIP-4361)](https://eips.e

> In **mock mode** all three endpoints are simulated in-memory — no backend required.

### Dual-mode readiness: httpOnly cookie auth (`NEXT_PUBLIC_AUTH_MODE`)

`NEXT_PUBLIC_AUTH_MODE=cookie` switches the flow above to session-cookie auth
instead of a sessionStorage-persisted bearer token — see
[docs/http-only-cookie-migration.md](./docs/http-only-cookie-migration.md)
for the full design and current implementation status. `bearer` (the
default) is unchanged by this flag. In short, cookie mode:

- Never reads or writes a bearer token to `sessionStorage`.
- Never sends an `Authorization` header — the browser sends the session
cookie automatically (`credentials: 'include'`).
- Hydrates session state on mount via `isSessionActive()`
(`GET /v1/auth/session`, mocked in mock mode) instead of reading storage.
- Broadcasts only `signed-in`/`refreshed`/`signed-out` signals to peer tabs
without a real token.

This is **not yet a full Phase 2 cutover** — `guildpass-core` doesn't
implement `GET /v1/auth/session` yet (mock mode simulates it), and
cookie-mode silent refresh does not yet use the same cross-tab Web-Locks
coordination bearer mode has (see the doc for details).

---

## Environment Variables
Expand All @@ -121,6 +142,7 @@ check automatically via the `predev` script.
| ---- | ------- | ----------- |
| `NEXT_PUBLIC_MOCK_MODE` | No | Set `true` for in-memory mock API; SIWE fully simulated |
| `NEXT_PUBLIC_DEMO_MODE` | No | Alias for `NEXT_PUBLIC_MOCK_MODE` |
| `NEXT_PUBLIC_AUTH_MODE` | No | `bearer` (default) or `cookie` — see [Dual-mode readiness](#dual-mode-readiness-httponly-cookie-auth-next_public_auth_mode) above. Any value other than the literal `cookie` falls back to `bearer`. |
| `NEXT_PUBLIC_CORE_API_URL` | Live mode only (validated) | Base URL of the `guildpass-core` access-api — must be a valid absolute URL in live mode |
| `NEXT_PUBLIC_SIWE_DOMAIN` | No | Domain field in the EIP-4361 message (defaults to `localhost:3000`) |
| `NEXT_PUBLIC_SIWE_STATEMENT` | No | Human-readable statement shown in the signed message |
Expand Down
49 changes: 40 additions & 9 deletions docs/http-only-cookie-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,21 +74,52 @@ The backend still returns `token` in the `/verify` and `/refresh` response
bodies. No frontend changes are needed yet; this phase verifies the cookie
path works end-to-end without risking a regression.

### Phase 2Switch to cookie
### Phase 1.5Dual-mode readiness (implemented)

| Module | Change |
Rather than a hard cutover, the frontend now supports **both** modes behind
`NEXT_PUBLIC_AUTH_MODE` (`bearer` default, `cookie` opt-in — see the
[README](../README.md#dual-mode-readiness-httponly-cookie-auth-next_public_auth_mode)).
`bearer` mode is byte-for-byte unchanged. `cookie` mode implements the target
architecture below, gated behind the flag, entirely against the mock (no
real `guildpass-core` endpoint exists yet):

| Module | What actually changed |
|--------|--------|
| `lib/session.ts` | Remove all `sessionStorage` read/write/clear logic. `storeAuthSession()`, `loadAuthSession()`, `clearAuthSession()` become no-ops or thin wrappers around a new session-status check. `getStoredToken()` returns `null` — the token is no longer accessible to JS. |
| `lib/session.ts` — new | Add `isSessionActive(): Promise<boolean>` that calls `GET /v1/auth/session` (or similar lightweight endpoint that returns `{ authenticated: true/false }`). This replaces the client-side expiry check. |
| `lib/api/live.ts` | Remove `authHeaders()` entirely — the cookie is sent automatically by the browser. `LiveAccessApi` no longer needs the `token` constructor parameter. |
| `lib/api/index.ts` | `getApi()` no longer takes a `token` argument. |
| `lib/wallet/providers.tsx` | `SiweAuthProvider` no longer hydrates from `sessionStorage` on mount. Auth status is determined by calling `isSessionActive()`. Silent refresh still triggers `siweRefresh()` but the new cookie is set by the backend automatically. |
| All call sites | Remove `authSession?.token` from `getApi(...)` calls in `nav.tsx`, `app/admin/*/page.tsx`. The constructor parameter is gone; the cookie is transparent. |
| `lib/config.ts` | New `config.authMode: 'bearer' \| 'cookie'`, parsed from `NEXT_PUBLIC_AUTH_MODE`. Any value other than the literal `cookie` falls back to `bearer`. |
| `lib/session.ts` | `storeAuthSession()`, `loadAuthSession()`, `loadAuthSessionIncludingExpired()`, and the `sessionStorage` branch of `clearAuthSession()` become no-ops in cookie mode — proven by unit tests that spy on `sessionStorage` call counts, not just return values. `getStoredToken()`/`getStoredAddress()` return `null` via the same gate. New `isSessionActive(): Promise<boolean>` calls the endpoint below. |
| `lib/api/types.ts` | New `SessionStatus { authenticated: boolean; address?: string; expiresAt?: string }` / `SessionStatusSchema` — provisional, like `AnalyticsSummary`. Added to `SiweAuthApi.getSessionStatus()`. `siweLogout(token?: string)` — token is now optional so a cookie-mode caller can invoke it with none. |
| `lib/api/live.ts` | `getSessionStatus()` calls `GET /v1/auth/session` (no live backend implements this yet — 404 is treated the same as "no session"). `authHeaders()` never adds `Authorization` when `authMode === 'cookie'`, regardless of whether a token is present in memory. `getJson()` sends `credentials: 'include'` only in cookie mode (needed because the core API can be cross-origin in dev — the browser's `same-origin` default excludes those cookies). The `token` constructor parameter was already optional; it stays. |
| `lib/api/mock.ts` | Mock `getSessionStatus()`/cookie simulation via a non-httpOnly `document.cookie` entry (mock JS cannot set a real httpOnly cookie either) — entirely separate storage from `sessionStorage`, active only when `authMode === 'cookie'`. |
| `lib/wallet/providers.tsx` | Mount hydration calls `getApi().getSessionStatus()` instead of reading `sessionStorage` when in cookie mode. Sign-in/refresh responses still legitimately carry a `token`/`refreshToken` from the backend during the dual-ship window, but the provider scrubs both to empty before the session is stored, dispatched, or broadcast, so a real token is never persisted or sent to a peer tab. |
| Call sites (`nav.tsx`, `app/**/page.tsx`) | **Unchanged.** `getApi(address, authSession?.token, communitySlug)` keeps its exact signature everywhere — in cookie mode `authSession.token` is simply `''`, which `authHeaders()` already ignores. This was a deliberate scope decision to avoid a 20-file diff for this PR; see "Known gaps" below for the follow-up once the backend endpoint ships. |

**Known gaps in this dual-mode-readiness pass** (tracked for the actual
Phase 2 cutover once `guildpass-core` ships `GET /v1/auth/session`):

- Cookie mode's silent refresh does **not** use the same Web-Locks-based
cross-tab mutual exclusion bearer mode has
(`lib/wallet/refresh-coordination.ts`) — it performs its own
`siweRefresh()` call directly. Acceptable for now per the "tab sync
becomes less critical" note below; two tabs racing a refresh at the exact
same moment could redundantly both call the backend. Bearer mode's
coordination is untouched.
- `getSessionStatus()` is unimplemented on the real `guildpass-core`
backend — only the mock simulates it.

### Phase 2 — Switch to cookie (remaining work once the backend ships)

With the dual-mode plumbing above already in place, the remaining Phase 2
work is: `guildpass-core` implements `GET /v1/auth/session` and starts
setting the `gp_session` cookie; the frontend's default flips from `bearer`
to `cookie` (or ops sets `NEXT_PUBLIC_AUTH_MODE=cookie` per-environment);
and the cross-tab refresh coordination gap above gets closed.

### Phase 3 — Cleanup

Remove backward-compat `token` fields from the response body (backend) and
remove the dead code paths in the frontend.
remove the dead code paths in the frontend (the bearer-mode branches in
`lib/session.ts` / `lib/wallet/providers.tsx` / `lib/api/live.ts` once
`bearer` mode is no longer supported).

---

Expand Down
50 changes: 47 additions & 3 deletions lib/api/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
ResourceLookupResult,
Role,
Session,
SessionStatus,
SessionStatusSchema,
SiweAuthSession,
WalletVerification,
BackendSession,
Expand Down Expand Up @@ -523,6 +525,12 @@ async function getJson<T>(path: string, options: RequestOptions = {}): Promise<T
'Content-Type': 'application/json',
...(headers ?? {}),
},
// Cookie mode needs the session cookie sent on cross-origin requests
// (e.g. NEXT_PUBLIC_CORE_API_URL on a different dev port) — the
// browser's 'same-origin' default excludes those. Bearer mode passes
// `undefined`, which is equivalent to omitting the option entirely,
// so its request shape is unchanged.
credentials: config.authMode === 'cookie' ? 'include' : undefined,
signal,
})

Expand Down Expand Up @@ -691,7 +699,10 @@ export class LiveAccessApi implements AccessApi {
const headers: Record<string, string> = {
...(extra as Record<string, string> ?? {})
}
if (this.token) {
// Cookie mode relies exclusively on the httpOnly session cookie (sent
// automatically via `credentials: 'include'` in getJson()) — never send
// Authorization here, even if a token happens to be held in memory.
if (config.authMode !== 'cookie' && this.token) {
headers['Authorization'] = `Bearer ${this.token}`
}
if (this.communityId) {
Expand Down Expand Up @@ -1121,15 +1132,48 @@ export class LiveAccessApi implements AccessApi {
return { isAuthenticated: true, ...data }
}

async siweLogout(token: string): Promise<void> {
async siweLogout(token?: string): Promise<void> {
const headers: Record<string, string> = {}
// Guard against a falsy token producing "Bearer undefined"/"Bearer null"/
// "Bearer " — and never send Authorization at all in cookie mode, where
// the session cookie (sent via credentials: 'include') identifies the
// caller instead.
if (config.authMode !== 'cookie' && token) {
headers['Authorization'] = `Bearer ${token}`
}
await getJson<void>('/v1/auth/siwe/logout', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
headers,
}).catch(() => {
// best-effort logout
})
}

/**
* Lightweight session-status check for httpOnly-cookie auth mode. Never
* sends or receives a bearer token — the cookie is transparent to this
* request (`credentials: 'include'` in getJson()).
*
* "No session" (401/404) resolves to `{ authenticated: false }` rather
* than throwing, so callers can treat it as a steady state. Network/5xx
* failures propagate so a caller can distinguish "definitely signed out"
* from "backend unreachable."
*/
async getSessionStatus(signal?: AbortSignal): Promise<SessionStatus> {
try {
return await getJson<SessionStatus>('/v1/auth/session', {
method: 'GET',
schema: SessionStatusSchema,
signal,
})
} catch (err) {
if (err instanceof ApiError && err.code === 'aborted') throw err
if (err instanceof AuthError) return { authenticated: false }
if (err instanceof ApiError && err.status === 404) return { authenticated: false }
throw err
}
}

// ── Social Graph (Connections / Blocks) ──
async getConnections(address: string, signal?: AbortSignal): Promise<Connection[]> {
const path = `/v1/members/${encodeURIComponent(address)}/connections`
Expand Down
89 changes: 85 additions & 4 deletions lib/api/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
ResourceLookupResult,
Role,
Session,
SessionStatus,
SiweAuthSession,
WalletVerification,
WebhookEventLog,
Expand Down Expand Up @@ -71,13 +72,49 @@ import {
clearPersistedState,
LS_KEY,
} from './mock-storage'
import { config } from '../config'

/** Read once at module load so it is stable across renders. */
const MOCK_SESSION_STATE =
(typeof process !== 'undefined' &&
process.env.NEXT_PUBLIC_MOCK_SESSION_STATE) ||
''

// ── Mock cookie-session simulation (cookie auth mode) ───────────────────────
//
// There is no real backend in mock mode, so a real httpOnly cookie can't be
// set. This uses a plain, non-httpOnly document.cookie entry to simulate
// "the browser is holding a session cookie" — an honest simulation boundary
// (mock JS genuinely cannot set an httpOnly cookie either). It intentionally
// never touches sessionStorage, so cookie-mode session state is provably
// independent of the bearer-token sessionStorage path in lib/session.ts.
// Only ever written/read when config.authMode === 'cookie', so bearer-mode
// mock runs get zero new side effects.

const MOCK_SESSION_COOKIE = 'gp_mock_session'

function setMockSessionCookie(address: string, expiresAt: string): void {
if (typeof document === 'undefined') return
const value = encodeURIComponent(`${address}|${expiresAt}`)
document.cookie = `${MOCK_SESSION_COOKIE}=${value}; path=/; SameSite=Lax`
}

function clearMockSessionCookie(): void {
if (typeof document === 'undefined') return
document.cookie = `${MOCK_SESSION_COOKIE}=; path=/; Max-Age=0; SameSite=Lax`
}

function readMockSessionCookie(): { address: string; expiresAt: string } | null {
if (typeof document === 'undefined') return null
const row = document.cookie
.split('; ')
.find((entry) => entry.startsWith(`${MOCK_SESSION_COOKIE}=`))
if (!row) return null
const raw = decodeURIComponent(row.slice(MOCK_SESSION_COOKIE.length + 1))
const [address, expiresAt] = raw.split('|')
return address && expiresAt ? { address, expiresAt } : null
}

const DEFAULT_COMMUNITY: Community = {
id: 'guildpass-demo',
name: 'GuildPass Demo Community',
Expand Down Expand Up @@ -1434,11 +1471,16 @@ export class MockAccessApi implements AccessApi {
: new Date(Date.now() + 60 * 60 * 1000).toISOString()

const refreshExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
const resolvedAddress = this.address ?? '0x0000000000000000000000000000000000000000'

if (config.authMode === 'cookie') {
setMockSessionCookie(resolvedAddress, expiresAt)
}

return {
isAuthenticated: true,
token: `mock-jwt-${randomHex()}`,
address: this.address ?? '0x0000000000000000000000000000000000000000',
address: resolvedAddress,
expiresAt,
refreshToken: `mock-refresh-${randomHex()}`,
refreshExpiresAt,
Expand All @@ -1462,7 +1504,17 @@ export class MockAccessApi implements AccessApi {
})
}

if (!refreshToken || !refreshToken.startsWith('mock-refresh-')) {
if (config.authMode === 'cookie') {
// Cookie mode has no refresh-token string for the frontend to hold —
// the (mock) session cookie is the only refreshability signal.
if (!readMockSessionCookie()) {
throw new ApiError({
status: 401,
code: 'unauthorized',
safeMessage: 'Invalid refresh token.',
})
}
} else if (!refreshToken || !refreshToken.startsWith('mock-refresh-')) {
throw new ApiError({
status: 401,
code: 'unauthorized',
Expand All @@ -1472,19 +1524,48 @@ export class MockAccessApi implements AccessApi {

const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString()
const refreshExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
const resolvedAddress = this.address ?? '0x0000000000000000000000000000000000000000'

if (config.authMode === 'cookie') {
setMockSessionCookie(resolvedAddress, expiresAt)
}

return {
isAuthenticated: true,
token: `mock-jwt-${randomHex()}`,
address: this.address ?? '0x0000000000000000000000000000000000000000',
address: resolvedAddress,
expiresAt,
refreshToken: `mock-refresh-${randomHex()}`,
refreshExpiresAt,
}
}

async siweLogout(_token: string): Promise<void> {
async siweLogout(_token?: string): Promise<void> {
await initPromise
if (config.authMode === 'cookie') {
clearMockSessionCookie()
}
}

/**
* Mock counterpart to LiveAccessApi.getSessionStatus(). Reads only the
* simulated document.cookie session marker set by siweVerify/siweRefresh —
* never sessionStorage — so cookie-mode session state stays deterministic
* and independent of the bearer-token sessionStorage path.
*/
async getSessionStatus(_signal?: AbortSignal): Promise<SessionStatus> {
await initPromise
if (MOCK_SESSION_STATE === 'unauthenticated') {
return { authenticated: false }
}
const cookie = readMockSessionCookie()
if (!cookie) {
return { authenticated: false }
}
if (MOCK_SESSION_STATE === 'expired' || new Date(cookie.expiresAt).getTime() <= Date.now()) {
return { authenticated: false }
}
return { authenticated: true, address: cookie.address, expiresAt: cookie.expiresAt }
}

async verifyWallet(_address: string, _signal?: AbortSignal): Promise<WalletVerification> {
Expand Down
39 changes: 37 additions & 2 deletions lib/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,27 @@ export interface AdminAccessApi {
analytics: AnalyticsDataSource
}

/**
* Lightweight session-status check for httpOnly-cookie auth mode (dual-mode
* readiness — see docs/http-only-cookie-migration.md). Never carries a
* bearer token; `authenticated` is the only field guaranteed present.
*
* @provisional `GET /v1/auth/session` is not yet implemented in
* guildpass-core. This contract lets the frontend cookie-mode path be built
* and tested against the mock ahead of the backend endpoint shipping.
*/
export interface SessionStatus {
authenticated: boolean
address?: string
expiresAt?: string
}

export const SessionStatusSchema = z.object({
authenticated: z.boolean(),
address: z.string().optional(),
expiresAt: z.string().optional(),
})

/**
* SIWE authentication endpoints.
*/
Expand All @@ -867,9 +888,23 @@ export interface SiweAuthApi {
* signalling that the user must re-sign with their wallet.
*/
siweRefresh(refreshToken: string): Promise<SiweAuthSession>
/** Invalidate the current server-side session (no-op for stateless JWTs). */
siweLogout(token: string): Promise<void>
/**
* Invalidate the current server-side session (no-op for stateless JWTs).
* `token` is optional so cookie-auth-mode callers — which never hold a
* bearer token — can invoke this with no argument; the session cookie
* identifies the caller instead.
*/
siweLogout(token?: string): Promise<void>
verifyWallet(address: string): Promise<WalletVerification>
/**
* Lightweight session-status check for httpOnly-cookie auth mode. Returns
* `{ authenticated: false }` for "no session" (never throws for that
* case); network/server errors propagate so the caller can distinguish an
* unreachable backend from a genuinely absent session.
*
* @provisional See {@link SessionStatus}.
*/
getSessionStatus(signal?: AbortSignal): Promise<SessionStatus>
}

/**
Expand Down
Loading