Skill: dyagil/dyagil-magic-link-bridge (version 1.0.0)

--- name: dyagil-magic-link-bridge description: Generate Supabase magic-links that land directly on a custom portal subpath (e.g. `/portal/`) instead of being silently rewritten to the project Site URL by Supabase's redirect whitelist. Use whenever a customer reports the login link sent them to the homepage instead of their personal area, or when designing a new magic-link flow on a subpath, or to work around in-app browser bugs (WhatsApp/Instagram WebView) that drop URL hash fragments. version: 1.0.0 license: MIT author: dyagil --- # Magic-Link Bridge (token_hash flow) ## Problem This Solves Supabase's standard magic-link flow: 1. Server calls `auth.admin.generate_link({ type: 'magiclink', email, options: { redirect_to: 'https://site/portal/' } })`. 2. Customer clicks the resulting `https://<ref>.supabase.co/auth/v1/verify?...` URL. 3. Supabase verifies, then 302-redirects to `redirect_to` **with** `#access_token=<jwt>&type=magiclink` in the URL fragment. This breaks in two real-world scenarios: - **Redirect not whitelisted.** If `https://site/portal/` is not in the project's Redirect URL allow-list, Supabase silently rewrites the redirect to the Site URL (`https://site/`). The customer lands on the marketing homepage with a hash full of tokens — and your portal code never sees them. - **WhatsApp / Instagram / FB Messenger in-app browsers.** WebViews routinely strip URL fragments across navigations, so even when the redirect IS whitelisted the tokens vanish before your JS can read them. ## Fix Don't go through Supabase's `/auth/v1/verify` endpoint at all. Generate the link manually so it lands directly on the portal page, then have the portal exchange the token via `auth.verifyOtp({ token_hash, type })`. The final link looks like: ``` https://site/portal/?token_hash=<hashed_token>&type=magiclink ``` `hashed_token` comes from the same `admin/generate_link` response — Supabase returns it alongside `action_link`. ## Server (API endpoint) ```js // POST /api/send-portal-link (Vercel serverless or any backend) const gen = await fetch( `${SUPABASE_URL}/auth/v1/admin/generate_link`, { method: 'POST', headers: { [REDACTED_SECRET], Authorization: 'Bearer ' + SUPABASE_SERVICE_ROLE_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'magiclink', email: customer.email }), } ).then(r => r.json()); const u = new URL('https://site/portal/'); u.searchParams.set('token_hash', gen.hashed_token); u.searchParams.set('type', gen.verification_type || 'magiclink'); const magicLink = u.toString(); // Send magicLink via WhatsApp / SMS / email. The portal page handles the rest. ``` Keep a fallback to `gen.action_link` if `hashed_token` is missing — some older Supabase auth versions don't return it. ## Client (portal page) The portal page must: 1. Detect `?token_hash=...&type=...` on load. 2. Call `verifyOtp({ token_hash, type })`. 3. Strip the params from the URL (they're single-use; a refresh would replay them). 4. Handle `?error=...` / `?error_description=...` with a friendly alert. See [scripts/portal-bridge.js](scripts/portal-bridge.js) for a drop-in snippet. The Supabase client itself should be configured with: ```js window.supabase.createClient(URL, ANON_KEY, { auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: true, // picks up hash-fragment flows automatically flowType: 'pkce', // ?code=... flow as well storageKey: 'site-portal-auth', }, }); ``` ## Why Also Keep a Bridge in `/index.html`? Even after deploying this skill, an older email/SMS may still hold a Supabase-style verify URL that was minted before the fix. Add a safety net at the site root that detects either `#access_token=` (hash flow) or `?code=`/`?error=` (PKCE / error redirects) and forwards to `/portal/`. See [scripts/index-redirect.js](scripts/index-redirect.js). ## Testing 1. Generate a link as admin (curl with `service_role`). 2. Open it headless (curl / Playwright) and confirm it lands on `/portal/` with the user logged in (Supabase session present in `localStorage`). 3. If the page shows `#access_token=...` on the homepage instead — the bridge in `index.html` is missing or stale, not this server-side fix. ## Common Pitfalls - **One-time tokens replay on refresh.** Always strip the query params after `verifyOtp` succeeds, with `history.replaceState`. Otherwise refreshing the page tries to verify a now-expired token and shows an error. - **PKCE state must match.** `flowType: 'pkce'` requires the same browser to generate and consume the code — so `?code=` flows only work when the link is opened in the same browser that initiated the auth. Magic links opened in a different browser must use the `token_hash` path (default). - **Hash + query both present.** When forwarding from `index.html`, concatenate as `'/portal/' + window.location.search + window.location.hash` so neither half is lost.

path: scripts/index-redirect.js
content: /**
 * Safety-net redirect for the site root (`/index.html`).
 *
 * If a customer ever lands on the marketing homepage carrying a Supabase auth
 * payload — either in the URL hash (`#access_token=…`) or as a PKCE
 * query (`?code=…`) — forward them to the portal so the auth handshake can
 * complete there. Without this, tokens get stuck on the homepage and the
 * customer sees marketing content instead of their account.
 *
 * Drop this inline in `<head>` of the homepage, BEFORE any analytics or
 * heavy scripts, so it runs before the page is fully rendered.
 */
(function () {
  var h = window.location.hash || '';
  var q = window.location.search || '';

  var hasHashAuth =
    h.indexOf('access_token=')  !== -1 ||
    h.indexOf('type=magiclink') !== -1 ||
    h.indexOf('type=recovery')  !== -1 ||
    h.indexOf('type=invite')    !== -1 ||
    h.indexOf('type=signup')    !== -1;

  var hasQueryAuth =
    q.indexOf('code=')              !== -1 ||
    q.indexOf('token_hash=')        !== -1 ||
    q.indexOf('error=')             !== -1 ||
    q.indexOf('error_code=')        !== -1 ||
    q.indexOf('error_description=') !== -1;

  if (hasHashAuth || hasQueryAuth) {
    // Preserve BOTH the query string and the hash. The portal handler reads
    // both: ?code= / ?token_hash= / ?error= from search, #access_token=…
    // from hash.
    window.location.replace('/portal/' + q + h);
  }
})();


path: scripts/portal-bridge.js
content: /**
 * Drop-in magic-link entry-point for a Supabase-powered portal.
 *
 * Place this inside your portal IIFE, AFTER `sb` (the Supabase client) is
 * created with `detectSessionInUrl: true` and `flowType: 'pkce'`.
 *
 * Handles three cases that arrive on URL load:
 *   1) ?token_hash=<h>&type=<t>   — the recommended path (this skill)
 *   2) #access_token=<jwt>&...    — Supabase's legacy hash redirect
 *   3) ?error_description=...     — auth server returned an error
 *
 * For (1) it calls verifyOtp manually; (2) is handled implicitly by
 * detectSessionInUrl; (3) surfaces a user-visible alert. In all cases the
 * URL is cleaned afterwards so a refresh does not replay a single-use token.
 */
(function () {
  try {
    var q = new URLSearchParams(window.location.search);

    // (3) error first — short-circuit and don't try to verify anything.
    var err = q.get('error_description') || q.get('error') || q.get('error_code');
    if (err) {
      setTimeout(function () {
        alert('הקישור פג או כבר נוצל. בקש מהסוכן לשלוח קישור חדש.\n\n('
              + decodeURIComponent(err) + ')');
      }, 300);
      history.replaceState(null, '', window.location.pathname);
      return;
    }

    // (1) token_hash flow — explicit verifyOtp.
    var tokenHash = q.get('token_hash');
    var type      = q.get('type');
    if (tokenHash && type) {
      sb.auth.verifyOtp({ token_hash: tokenHash, type: type })
        .then(function (res) {
          if (res.error) {
            console.warn('verifyOtp failed:', res.error.message);
            setTimeout(function () {
              alert('הקישור פג או כבר נוצל. בקש מהסוכן לשלוח קישור חדש.');
            }, 300);
          }
        })
        .finally(function () {
          // Strip the one-time token from the URL so a refresh doesn't replay.
          history.replaceState(null, '', window.location.pathname);
        });
    }
    // (2) hash-fragment flow is handled automatically by detectSessionInUrl
    //     on the supabase client — no code needed here.
  } catch (e) {
    console.warn('magic-link bridge error:', e);
  }
})();


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: clean (LOW)