feat(auth): direct OAuth/social login with Google, GitHub, Apple, and NEAR wallet (#1798)

* feat(auth): add direct OAuth/social login with Google and GitHub (#1771)

Add optional OAuth authentication so users can sign in directly via
Google or GitHub without requiring admin-created tokens or a reverse-proxy
SSO setup. On successful OAuth, the system creates or links a user via
the existing UserStore, issues an API token, and sets it as an HttpOnly
cookie — reusing the existing DbAuthenticator for subsequent requests.

Key changes:
- user_identities table (PostgreSQL V15 + libSQL migration) for linking
  external provider accounts to internal users
- IdentityStore trait with dual-backend implementations
- OAuthProvider trait with Google (OIDC id_token) and GitHub (API-based)
  provider implementations
- In-memory CSRF + PKCE state store with TTL and capacity bounds
- Cookie-based session extraction in auth middleware
- User resolution: existing identity → email linking → new account creation
- All behind OAUTH_ENABLED=true flag; existing auth paths unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(auth): add email domain restrictions for OAuth and OIDC login (#1771)

Add configurable email domain restrictions so admins can limit OAuth
and OIDC login to specific organizations:

- OAUTH_ALLOWED_DOMAINS: comma-separated list of allowed email domains,
  applied to all OAuth providers and OIDC (e.g., company.com,partner.org)
- GOOGLE_ALLOWED_HD: restrict Google login to a specific Workspace domain
  via the `hd` authorization parameter + server-side validation
- Domain check enforced in both the OAuth callback handler and the OIDC
  JWT middleware path (extracts email claim from validated JWT)
- Add setup documentation in .env.example with step-by-step instructions
  for configuring Google and GitHub OAuth credentials

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): address PR review — security hardening and cleanup (#1798)

Fixes from Gemini and Copilot review:

Security (critical/high):
- Validate `aud` claim in Google id_token to prevent token substitution
- Sanitize `redirect_after` to relative paths only (prevent open redirects)

Correctness (medium):
- Remove orphaned token: replace `create_user_with_identity_and_token` with
  `create_user_with_identity` — single token created in callback handler
- Logout now revokes the API token (not just clears cookie)
- Session tokens expire after 30 days (matching cookie lifetime)
- Store decoded Google claims in raw_profile (not JWT string)
- Propagate GitHub email fetch errors instead of swallowing
- Fix `list_identities_for_user` to propagate row iteration errors

Cleanup (low):
- Extract `SESSION_COOKIE_NAME` constant
- Add Secure flag to logout cookie clearing
- Add single-quote escaping in error_page HTML
- Fix garbled unicode in auth.rs comment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(auth): add Apple Sign In provider (#1807)

Add Apple Sign In as an OAuth provider alongside Google and GitHub.

Apple-specific handling:
- JWT client_secret generation (ES256-signed, team_id/key_id/private_key)
- response_mode=form_post — Apple POSTs the callback instead of GET
- POST callback route added alongside existing GET route
- User name extracted from Apple's `user` form field (sent only on
  first authorization) and merged into the profile
- id_token decoded with aud + issuer validation
- email_verified handles both boolean and string "true"/"false" formats

Configuration:
- APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID
- APPLE_PRIVATE_KEY_PATH (file) or APPLE_PRIVATE_KEY_PEM (inline)
- Setup instructions added to .env.example

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(auth): add NEAR wallet login via NEP-413 signature verification (#1807)

Add NEAR wallet authentication as a fourth login method alongside
Google, GitHub, and Apple. Unlike OAuth, NEAR uses a challenge-response
flow with Ed25519 signature verification.

Backend:
- GET /auth/near/challenge — generate a random nonce (32 bytes hex)
- POST /auth/near/verify — verify Ed25519 signature + NEAR RPC access
  key check, then issue session token via existing user resolution pipeline
- NearNonceStore: in-memory nonce store with 5-min TTL and replay protection
- Supports both base58 (NEAR standard) and hex key/signature encoding
- New dependency: bs58 0.5 for base58 decoding

Frontend:
- Login screen discovers enabled providers via GET /auth/providers
- Shows social login buttons (Google, GitHub, Apple, NEAR) dynamically
- NEAR button loads @hot-labs/near-connect via ESM CDN import
- Wallet connection → signMessage → POST to /auth/near/verify → session
- OAuth cookie-based sessions auto-detected on page load (existing flow)

Configuration:
- NEAR_AUTH_ENABLED=true
- NEAR_AUTH_NETWORK=mainnet|testnet (defaults to mainnet)
- NEAR_AUTH_RPC_URL (auto-detected from network)

Closes #1807

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): address second round of PR review comments (#1798)

- Fix early return in with_oauth() that skipped NEAR setup and OIDC
  domain restrictions when no OAuth redirect providers were configured
- Add active-status check before linking identity by verified email
  (prevents linking to suspended/deactivated accounts)
- Remove inline onclick handlers from login buttons (CSP compliance)
- Export SESSION_COOKIE_NAME from auth.rs, reuse in handlers and middleware
- NEAR challenge returns structured message ("Sign in to IronClaw\nNonce: {nonce}")
  that both client and server use for signature verification

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): address human reviewer security findings (#1798)

Six fixes from serrrfirat's review:

1. Domain check now requires email_verified=true before trusting the
   email for domain restriction — prevents unverified emails from
   bypassing access control (e.g., GitHub unverified fallback)

2. First-user bootstrap race documented — concurrent first logins may
   both see has_any_users()=false, but the second gets member role.
   Acceptable tradeoff; unique constraint prevents identity duplication.

3. NEP-413 payload mismatch fixed — server now builds the exact
   borsh-serialized NEP-413 payload (tag + message + nonce + recipient)
   that the wallet signs, instead of raw message bytes

4. Token extraction priority fixed — explicit ?token= query param now
   takes precedence over session cookie, preventing SSE/WS user mismatch
   when a browser has both a cookie and a query-param token

5. NEAR domain suffix check hardened — requires exact match or dotted
   subdomain boundary (alice.company.near passes, evilcompany.near does not)

6. NEAR network surfaced to frontend — /auth/providers response includes
   near_network field, frontend wallet connector uses it instead of
   hardcoded mainnet

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): improve login page UX when OAuth providers are enabled

When OAUTH_ENABLED=true with providers configured, the login screen now
shows social login buttons (Google, GitHub, Apple, NEAR) as the primary
action. The token input is collapsed behind a clickable "or use a token"
divider for API users.

Without OAuth: unchanged — token input is the only option.
With OAuth: social buttons first, token input expandable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): hide token form until providers are discovered

The token input was visible by default, causing it to flash before
OAuth buttons appeared. Now:

- Token form starts hidden (display:none)
- /auth/providers fetch determines what to show
- With providers: social buttons shown, token form behind "or use a token"
- Without providers (or fetch fails): token form shown as fallback
- After OAuth redirect: cookie-based autoAuth skips login screen entirely

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(web): replace Connected indicator with user avatar + account menu

Replace the "Connected" status indicator in the header with a user
avatar button that shows connection status via an overlay dot. Clicking
the avatar opens a dropdown with:

- Display name, email, and role
- Connection status (green/red dot + text) with gateway stats
- Sign out button (calls POST /auth/logout, clears session, reloads)

Avatar source:
- OAuth logins: profile photo from Google/GitHub/Apple (avatar_url)
- Token logins: initials from display_name (colored circle)

Backend: profile_get_handler now queries user_identities for avatar_url
from linked OAuth accounts.

Frontend: social buttons are primary when OAuth is enabled, token input
collapsed behind "or use a token" divider.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): remove Connected section from dropdown, fix avatar loading

- Remove the connection status section from the user dropdown (was
  redundant with the avatar dot)
- Add update_identity_profile() to IdentityStore — updates display_name
  and avatar_url on re-login so avatars load for accounts created before
  the avatar field was wired
- Call update_identity_profile() in resolve_user() when an existing
  identity is found (re-login path)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): restore gateway stats in dropdown, add avatar debug logging

- Bring back gateway stats section in user dropdown (without the word
  "Connected" — just the server stats like uptime, model, channels)
- Add debug tracing to Google provider (logs picture claim from id_token)
  and profile handler (logs identity count + avatar_url) to diagnose
  why avatar isn't loading for Google OAuth accounts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): fix Google avatar not loading, restore gateway stats

- Add referrerpolicy="no-referrer" to avatar img — Google's
  lh3.googleusercontent.com returns 403 when Referer header is sent
  from a different origin
- Add crossorigin="anonymous" for CORS
- Use display:block explicitly instead of empty string
- Add onerror fallback to initials if image fails to load
- Restore gateway stats section in dropdown (without "Connected" text)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): fix squeezed avatar image in header

Add min-width/min-height and flex-shrink:0 to both the avatar button
and the img element so they don't get compressed by the tab-bar flex
layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): position avatar img and initials absolutely inside button

Both children were competing for flex space, causing 0px width. Now both
are position:absolute inside the 32px button, layered on top of each
other. The JS toggles display:block/none to show the right one.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): rewrite avatar loading — CSS src selector + onload/onerror

Previous approach: inline style display:none toggled by JS. Failed
because display:none prevented image fetch in some browsers, and
position:absolute elements competed for z-index.

New approach:
- No inline style on img — CSS hides it via .user-avatar-img (display:none)
- CSS .user-avatar-img[src] shows it (display:block, z-index:1)
- JS sets src, onload hides initials, onerror removes src as fallback
- Initials always rendered first as the base layer
- Removed crossorigin="anonymous" which can cause CORS failures

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): prefetch avatar with new Image() before showing

Use a throwaway Image() to prefetch the avatar URL. Only when onload
fires, set src on the real <img> and unhide it. This avoids all CSS
display/src selector issues — the real img element only gets a src
after the image is confirmed loadable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): set avatar src directly, set referrerPolicy in JS

The new Image() prefetch was failing because the programmatic Image
object didn't have referrerPolicy set. Simplify: set referrerPolicy
and src directly on the real <img> element, unhide it immediately,
and use onload to hide initials.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): explicit display:block on avatar img, removeAttribute hidden

- Add display:block to .user-avatar-img CSS (img elements default to
  inline which can cause rendering issues with position:absolute)
- Bump z-index to 2 to ensure img renders above initials
- Use removeAttribute('hidden') instead of hidden=false
- Use style.display='none' on initials instead of hidden attribute

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): swap img/initials DOM order so img paints on top

Put <img> after <span> in DOM order. With both position:absolute,
later elements paint on top. Combined with z-index:2 on img vs z-index:0
on initials, the avatar photo should now reliably cover the initials
circle when loaded.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): add OAuth avatar domains to Content-Security-Policy

The CSP had img-src 'self' data: which blocked Google and GitHub
avatar images from loading. Added:

- img-src: *.googleusercontent.com, avatars.githubusercontent.com
- script-src: esm.sh (for near-connect dynamic import)
- connect-src: esm.sh, *.near.org (for NEAR RPC)
- form-action: Google, GitHub, Apple OAuth endpoints

This was the root cause of avatar images not rendering despite
correct src URLs — the browser silently blocked them via CSP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): show welcome card for new OAuth users with no threads

New OAuth users have no assistant thread yet, so switchToAssistant()
was never called, and loadHistory() never ran to show the welcome card.
Now explicitly show the welcome card when there's no current thread
and no assistant thread (brand-new user).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): persist bootstrap greeting for new OAuth users on workspace creation

New OAuth users saw an empty chat because the bootstrap greeting was
only persisted when the agent loop processed the first message
(take_bootstrap_pending check in agent_loop.rs:1314). But OAuth users
land on the web UI without sending any message.

Fix: WorkspacePool now checks take_bootstrap_pending() after
seed_if_empty() and persists the GREETING.md content into the
assistant conversation immediately. This runs in a background task
so it doesn't block the workspace creation. The greeting is in the
DB before the frontend loads threads/history.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): persist bootstrap greeting synchronously, not in background

Move greeting persistence from tokio::spawn to the same await chain
as seed_if_empty() so the greeting is guaranteed to be in the DB
before the workspace is returned to the caller.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): seed bootstrap greeting in chat_threads_handler for new users

The WorkspacePool approach didn't work because the workspace pool is
only accessed by memory handlers — chat_threads_handler runs first
when a new user loads the page.

Move the greeting seed to chat_threads_handler: after
get_or_create_assistant_conversation, check if the conversation has
zero messages and inject the GREETING.md content. This guarantees
the greeting is in the DB before the thread list is returned to the
frontend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(agent): consolidate bootstrap greeting into chat_threads_handler

Remove three redundant greeting insertion paths from agent_loop.rs:
1. Single-user startup (Agent::run bootstrap_thread_id)
2. Single-user SSE broadcast after startup
3. Multi-tenant message handler (take_bootstrap_pending on first msg)

Also remove the dead WorkspacePool greeting code in server.rs.

The single source of truth is now chat_threads_handler: when the
assistant conversation is created with zero messages, GREETING.md is
inserted. This works for all auth modes (token, OAuth, OIDC) and both
single-user and multi-tenant deployments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: downgrade workspace seed log from info to debug

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): address zmanian's blocking review items

1. Add Google iss validation — set_issuer(&["https://accounts.google.com"])
   to match Apple's issuer check. Prevents cross-provider id_token acceptance.

2. Convert oauth_rate_limiter from global RateLimiter to per-IP
   PerUserRateLimiter(20, 60). Extracts client IP from X-Forwarded-For
   header. One user retrying no longer locks out all OAuth for everyone.

Also:
- sanitize_redirect now rejects backslash (/\) open redirect vector
- All auth handlers extract headers for per-IP rate limiting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): stop inserting greeting on every page load

The previous check used list_conversations_with_preview with limit=1
and defaulted to is_empty=true when the assistant thread wasn't in the
result (unwrap_or(true)). This caused the greeting to be inserted on
every chat_threads_handler call.

Fix: use list_conversation_messages_paginated(assistant_id, None, 1)
to directly check if the assistant conversation has any messages.
Only insert the greeting when the message list is truly empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add integration tests for bootstrap greeting and cookie auth

Five tests covering the greeting behavior and OAuth session auth:

1. test_greeting_inserted_once_for_new_user — verifies greeting
   appears exactly once and is not duplicated on second page load
2. test_greeting_not_duplicated_on_rapid_calls — 5 concurrent
   /api/chat/threads requests produce exactly 1 greeting
3. test_each_user_gets_own_greeting — multi-user: Alice and Bob
   each get their own assistant thread with separate greetings
4. test_cookie_auth_works_for_threads — cookie-based auth
   (ironclaw_session=token) works for protected endpoints
5. test_existing_conversation_no_greeting — pre-populated
   conversations are not overwritten with the greeting

These tests would have caught the unwrap_or(true) bug that caused
greeting re-insertion on every page load.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): fix near-connect CDN URL (package is v0.x, not v1)

The @hot-labs/near-connect package is version 0.11.1 — there is no
v1 release. The @1 version specifier returned 404 from esm.sh.
Changed to @0.11 which resolves correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): support base64 encoding for NEAR wallet signatures

NEAR wallets (e.g. HOT) may return signatures and public keys in
base64 format, not just base58/hex. Added base64 standard and
URL-safe decoding to decode_multiformat(), which is used by both
decode_near_public_key and decode_near_signature.

Also:
- Added debug logging to near_verify_handler to trace credential formats
- Updated CSP img-src to allow wallet logos (raw.githubusercontent.com,
  jsdelivr.net, near.org, pages.near.org)
- Added CSP frame-src for near-connect wallet sandboxes (iframes)
- Added blob: to img-src for inline wallet icons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): widen CSP connect-src and img-src for NEAR wallet resources

near-connect fetches wallet manifests from raw.githubusercontent.com
and cdn.jsdelivr.net, and wallet logos from app.hot-labs.org. These
were blocked by the restrictive connect-src and img-src policies.

- connect-src: added raw.githubusercontent.com, *.jsdelivr.net, *.cloudflare.com
- img-src: added *.hot-labs.org

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): try both NEP-413 and raw message for NEAR signature verification

Different NEAR wallets may sign the full NEP-413 borsh payload or
just the raw message string. Try NEP-413 first, fall back to raw
message bytes. This makes verification work with HOT wallet and
other wallets that may not implement the full NEP-413 serialization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): fix NEP-413 field order and try both payload layouts

The NEP-413 borsh payload field order was wrong. Our implementation had
tag → message → nonce → recipient → callback_url, but the NEAR docs
(docs.near.org/web3-apps/backend-login) show tag → message → recipient → nonce.

Now tries both field orderings (v1 and v2), plus SHA256 variants, plus
raw message bytes — covering all known wallet implementations.

Tests updated: verify_near_signature tested with raw message, NEP-413 v2,
and wrong-key rejection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): relax CSP for wallet ecosystem — allow all HTTPS for connect/img/frame

The NEAR wallet ecosystem spans dozens of domains (intear.tech,
hot-labs.org, meteorwallet.app, herewallet.app, etc.) that change
as new wallets are added. Whitelisting each one is a losing game.

Relax CSP to allow all HTTPS for:
- connect-src: wallet manifests, wallet JS modules, RPC endpoints
- img-src: wallet logos from various CDNs
- frame-src: wallet sandbox iframes

script-src remains restricted to specific CDNs (jsdelivr, cloudflare,
esm.sh) — this is the security-critical directive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): allow unsafe-inline scripts for NEAR wallet sandbox iframes

NEAR wallet sandboxes (MeteorWallet, etc.) use inline scripts inside
their iframe sandboxes. The CSP script-src blocked these, preventing
wallets from loading. Added 'unsafe-inline' to script-src.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): relax CSP style-src and font-src for wallet iframes

NEAR wallet sandboxes load fonts from rsms.me, cdnfonts.com, and
embed data: font URIs. Relaxed style-src and font-src to allow all
HTTPS sources and data: URIs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): address review round 4 — 14 fixes

serrrfirat (high/medium):
1. decode_multiformat ambiguity: replaced with context-aware decoders.
   NEAR pubkeys enforce ed25519: prefix + base58 (unambiguous).
   Signatures try base64 first (most wallets), then base58.
2. UTF-8 slicing panic: use safe_truncate() with char_indices()
3. NEAR pubkey → RPC format: re-encode decoded bytes as ed25519:{base58}
   for the canonical format expected by view_access_key
4. GitHub redirect_uri: now included in token exchange form body

Copilot (medium/low):
5. near_network stored explicitly in GatewayState (not inferred from URL)
6. NEAR verify sets HttpOnly session cookie (consistent with OAuth flow)
7. Reuse reqwest::Client via LazyLock (no per-request allocation)
8. OAuth module doc updated to list all 4 providers
9. Rate limiter comment fixed (was stale "10 requests")
10. Profile identity error logged at warn (not silently swallowed)
11. Config doc updated for Apple/NEAR requirements
12. Test file doc comment updated to match actual coverage
13. RPC status check before JSON parse
14. GitHub token exchange includes redirect_uri

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): address PR feedback on cookie auth and NEAR sessions

* fix(auth): address code review — tighten CSP, fix races, improve security

- Tighten CSP: narrow connect-src/img-src/frame-src to specific origins
  instead of blanket `https:` (prevents data exfiltration)
- Fix greeting race: add atomic add_conversation_message_if_empty using
  INSERT...WHERE NOT EXISTS (both PostgreSQL and libSQL)
- Case-insensitive email matching: use LOWER() in identity lookups and
  normalize emails to lowercase on storage
- Add X-Real-IP fallback for rate limit key when X-Forwarded-For missing
- Add OAuthError::SignatureVerification variant (was misusing ProfileFetch)
- Fix dead branch in with_oauth (has_near check inside !has_near block)
- Fix _user → user in logout_handler (variable is actually used)
- Add partial index WHERE email IS NOT NULL to libSQL (match PostgreSQL)
- Downgrade noisy tracing::debug to trace in profile handler
- Add i18n for "Sign out" button (en + zh-CN)
- Remove duplicate test_session_cookie_auth_passes test
- Update E2E bootstrap tests to match new DB-based greeting architecture

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): remove garbled unicode character in section comment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): address review round 5 — admin race, 303 redirect, OIDC email_verified

- Atomic first-user admin: create_user_with_identity now promotes to
  admin inside the DB transaction with UPDATE...WHERE COUNT(*)=1,
  eliminating the TOCTOU race where two concurrent first logins both
  get admin role (both PostgreSQL and libSQL)
- Apple callback redirect: use 303 See Other instead of 307 Temporary
  so POST form_post callbacks are converted to GET on redirect
- OIDC domain restriction: now requires email_verified=true before
  checking domain allowlist, preventing unverified emails from
  bypassing the restriction
- Postgres add_conversation_message_if_empty: call touch_conversation
  after insert to match libSQL behavior and keep last_activity current
- Greeting seeding: log errors instead of silently discarding with let _

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): advisory lock for admin election, skip empty query tokens

- Postgres first-user admin: add pg_advisory_xact_lock before the
  COUNT(*)=1 promotion to serialize concurrent transactions under
  READ COMMITTED isolation (prevents two admins on concurrent signup)
- Empty ?token= query parameter no longer overrides a valid session
  cookie — trimmed empty tokens return None from query_token()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): address zmanian security review — redirect, sweep, NEAR sigs

Blocking issues from security review:

1. redirect_after hardened: strict URL-safe char allowlist in
   sanitize_redirect (blocks /%09/ and encoded separators), plus
   re-validation before use in handle_callback (defense in depth)

2. Sweep tasks shutdown-aware: OAuth state store and NEAR nonce store
   sweep loops now select on a watch channel and exit when the
   sender is dropped (stored in GatewayState.oauth_sweep_shutdown)

3. NEAR signature verification tightened: removed raw-message-bytes
   and SHA256-of-raw fallbacks that lacked nonce binding (replay risk).
   Only NEP-413 structured payloads (v1 + v2) are accepted.
   Added test_verify_near_signature_rejects_raw_message regression test.

Non-blocking:

4. NEAR RPC client timeout: set 10s timeout on the static reqwest
   client to prevent indefinite hangs on slow RPC endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): percent-decode redirect_after before validation

is_safe_redirect now percent-decodes the URL and re-validates against
the // and /\ guards, preventing smuggling via %2f%2f or %5c. Added
5 regression tests covering normal paths, protocol-relative, absolute
URLs, encoded smuggling, and sanitize_redirect filtering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): case-insensitive get_user_by_email, require OIDC iss/aud claims

- get_user_by_email now uses LOWER() in both PostgreSQL and libSQL,
  matching the case-insensitive identity lookup. This ensures
  admin-created users with different email casing are correctly linked
  during OAuth account resolution.

- OIDC validation now adds iss/aud to required_spec_claims when
  configured, rejecting JWTs that omit these claims entirely (not just
  mismatches). Updated two tests from assert-passes to assert-rejects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): normalize UserRecord.email to lowercase, add aria-label to avatar

- UserRecord.email now lowercased on create (matching identity records),
  preventing case-mismatched duplicates against the UNIQUE constraint
- Avatar button: added aria-label with i18n (en + zh-CN) for screen readers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Firat Sertgoz <f@nuff.tech>
This commit is contained in:
Illia Polosukhin
2026-04-02 09:11:58 -07:00
committed by GitHub
parent db5903fe2a
commit 5c35b58ff1
42 changed files with 4649 additions and 212 deletions

View File

@@ -225,5 +225,63 @@ SAFETY_INJECTION_CHECK_ENABLED=true
# IRONCLAW_RESTART_DELAY=5 # default wait before exit (seconds, range: 1-30)
# IRONCLAW_MAX_FAILURES=10 # max consecutive failures before container exits
# ─── OAuth / Social Login ────────────────────────────────────────────────
# Enable direct OAuth login (Google, GitHub). Disabled by default.
# OAUTH_ENABLED=true
# Base URL for OAuth callback URLs. Defaults to http://localhost:{GATEWAY_PORT}.
# Set this to your public URL in production (e.g., https://myapp.example.com).
# OAUTH_BASE_URL=https://myapp.example.com
# Restrict OAuth login to specific email domains (comma-separated).
# When set, only users with verified emails from these domains can log in.
# Applies to all OAuth providers and OIDC. Leave unset to allow all domains.
# OAUTH_ALLOWED_DOMAINS=company.com,partner.org
# Google OAuth — Create credentials at https://console.cloud.google.com/apis/credentials
# 1. Create an OAuth 2.0 Client ID (Web application type)
# 2. Add authorized redirect URI: {OAUTH_BASE_URL}/auth/callback/google
# 3. Copy Client ID and Client Secret below
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# Restrict Google login to a specific Workspace (G Suite) domain.
# Adds the `hd` parameter to the authorization URL and validates server-side.
# GOOGLE_ALLOWED_HD=company.com
# Apple Sign In — Configure in https://developer.apple.com/account/resources/identifiers
# 1. Register a Services ID (e.g. com.example.myapp) under Identifiers
# 2. Enable "Sign In with Apple" and configure the return URL: {OAUTH_BASE_URL}/auth/callback/apple
# 3. Create a key (Keys section), enable "Sign In with Apple", download the .p8 file
# 4. Note your Team ID (top right of developer portal) and Key ID
# APPLE_CLIENT_ID=com.example.myapp
# APPLE_TEAM_ID=XXXXXXXXXX
# APPLE_KEY_ID=YYYYYYYYYY
# APPLE_PRIVATE_KEY_PATH=/path/to/AuthKey_YYYYYYYYYY.p8
# Or inline: APPLE_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
# GitHub OAuth — Create an OAuth App at https://github.com/settings/developers
# 1. Create a new OAuth App
# 2. Set Authorization callback URL to: {OAUTH_BASE_URL}/auth/callback/github
# 3. Copy Client ID and generate a Client Secret below
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
# NEAR Wallet — No external setup needed. Users sign in with any NEAR wallet
# (HOT, Meteor, MyNearWallet, etc.) via the near-connect SDK.
# NEAR_AUTH_ENABLED=true
# NEAR_AUTH_NETWORK=mainnet # or testnet
# NEAR_AUTH_RPC_URL=https://rpc.mainnet.near.org # auto-detected from network
# ─── OIDC / SSO (Okta, Cognito, etc.) ──────────────────────────────────
# For reverse-proxy SSO (e.g., AWS ALB + Okta). The gateway validates JWTs
# from the configured header. See also OAUTH_ALLOWED_DOMAINS above, which
# applies to OIDC logins too.
# GATEWAY_OIDC_ENABLED=true
# GATEWAY_OIDC_JWKS_URL=https://your-idp.example.com/.well-known/jwks.json
# GATEWAY_OIDC_HEADER=x-amzn-oidc-data
# GATEWAY_OIDC_ISSUER=https://your-idp.example.com
# GATEWAY_OIDC_AUDIENCE=your-client-id
# Logging
RUST_LOG=ironclaw=debug,tower_http=debug