[object Object]

← back to CelebritySignatures

celeb: add Sign in with Apple backend (web OAuth + WebView handoff bridge) — /auth/apple/login+callback verify Apple, upsert user by appleSub, cs_sess session; /auth/handoff one-time cookie bridge; /api/auth-config gates the button. Dormant until Services ID/key configured (v1.1)

ee2b355d385383025d25697bf5de6027e748d5ef · 2026-08-07 10:05:19 -0700 · Steve

Files touched

Diff

commit ee2b355d385383025d25697bf5de6027e748d5ef
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 7 10:05:19 2026 -0700

    celeb: add Sign in with Apple backend (web OAuth + WebView handoff bridge) — /auth/apple/login+callback verify Apple, upsert user by appleSub, cs_sess session; /auth/handoff one-time cookie bridge; /api/auth-config gates the button. Dormant until Services ID/key configured (v1.1)
---
 server.js | 131 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 130 insertions(+), 1 deletion(-)

diff --git a/server.js b/server.js
index 0d0f799..7f0082a 100644
--- a/server.js
+++ b/server.js
@@ -6,7 +6,7 @@ import { createServer } from 'node:http';
 import { readFile, writeFile, appendFile, mkdir, unlink } from 'node:fs/promises';
 import { extname, join } from 'node:path';
 import { fileURLToPath } from 'node:url';
-import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
+import { scryptSync, randomBytes, timingSafeEqual, createHash, sign } from 'node:crypto';
 import { readFileSync } from 'node:fs';
 
 const ROOT = fileURLToPath(new URL('.', import.meta.url));
@@ -212,6 +212,75 @@ const setCookie = tok => `${COOKIE}=${tok}; HttpOnly; Path=/; SameSite=Lax; Max-
 const clearCookie = `${COOKIE}=; HttpOnly; Path=/; Max-Age=0`;
 const pubUser = u => ({ email: u.email, name: u.name || '' });
 
+// ---- Sign in with Apple (web OAuth bridged into the app's WebView) ----------
+// Celeb's iOS app is a hybrid WebView; Apple/Google reject OAuth INSIDE an
+// embedded WebView, so the app runs sign-in in ASWebAuthenticationSession and
+// replays a one-time token at /auth/handoff so the cs_sess cookie lands in the
+// WebView's jar (the proven Charge & Explore pattern). Fully DORMANT until the
+// Apple Services ID + sign-in key are configured — /api/auth-config reports it,
+// and the site hides the button until then, so nothing shows broken.
+const APPLE_TEAM_ID = process.env.APPLE_TEAM_ID || '';
+const APPLE_SERVICES_ID = process.env.APPLE_SERVICES_ID || ''; // e.g. com.abrams.celebsignatures.signin
+const APPLE_KEY_ID = process.env.APPLE_KEY_ID || '';
+const APPLE_PRIVATE_KEY = process.env.APPLE_PRIVATE_KEY
+  || (process.env.APPLE_PRIVATE_KEY_PATH
+    ? (() => { try { return readFileSync(process.env.APPLE_PRIVATE_KEY_PATH, 'utf8'); } catch { return ''; } })()
+    : '');
+const APPLE_REDIRECT_URI = process.env.APPLE_REDIRECT_URI || 'https://celebsignatures.com/auth/apple/callback';
+const APPLE_CONFIGURED = !!(APPLE_TEAM_ID && APPLE_SERVICES_ID && APPLE_KEY_ID && APPLE_PRIVATE_KEY);
+const APP_SCHEME = 'celebsignatures';
+const b64u = s => Buffer.from(s).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
+
+// Apple client_secret: a short-lived ES256 JWT signed with the .p8 sign-in key.
+function appleClientSecret() {
+  const now = Math.floor(Date.now() / 1000);
+  const head = b64u(JSON.stringify({ alg: 'ES256', kid: APPLE_KEY_ID }));
+  const body = b64u(JSON.stringify({ iss: APPLE_TEAM_ID, iat: now, exp: now + 60 * 30, aud: 'https://appleid.apple.com', sub: APPLE_SERVICES_ID }));
+  const sig = sign('sha256', Buffer.from(`${head}.${body}`), { key: APPLE_PRIVATE_KEY, dsaEncoding: 'ieee-p1363' });
+  return `${head}.${body}.${b64u(sig)}`;
+}
+const appleStates = new Map();   // state -> { app, exp } — short-lived CSRF/flow state
+const handoffTokens = new Map(); // token -> { setCookie, location, exp } — single-use native bridge
+const HANDOFF_TTL_MS = 5 * 60 * 1000;
+
+// Finish an Apple login: native app flow → stash the Set-Cookie under a one-time
+// token + deep-link it back; web flow → set the cookie + 302.
+function completeAppleLogin(res, cookieHeader, appFlow, location = '/') {
+  if (appFlow) {
+    const token = randomBytes(24).toString('hex');
+    handoffTokens.set(token, { setCookie: cookieHeader, location, exp: Date.now() + HANDOFF_TTL_MS });
+    res.writeHead(302, { location: `${APP_SCHEME}://auth?token=${token}` }); res.end(); return;
+  }
+  res.writeHead(302, { 'Set-Cookie': cookieHeader, location }); res.end();
+}
+function readRaw(req) {
+  return new Promise((resolve, reject) => {
+    let raw = '';
+    req.on('data', c => { raw += c; if (raw.length > 2e5) reject(new Error('too large')); });
+    req.on('end', () => resolve(raw));
+    req.on('error', reject);
+  });
+}
+// Find-or-create a Celeb account from a verified Apple identity (keyed by sub).
+async function upsertAppleUser(sub, email, name) {
+  const users = await load('users.json', []);
+  const appleSub = `apple:${sub}`;
+  let u = users.find(x => x.appleSub === appleSub) || (email && users.find(x => x.email === email));
+  if (!u) {
+    u = {
+      id: createHash('sha256').update(appleSub + Date.now()).digest('hex').slice(0, 16),
+      email: email || `${sub}@privaterelay.appleid.com`,
+      name: name || '',
+      appleSub,
+      at: new Date().toISOString(),
+    };
+    users.push(u); await store('users.json', users);
+  } else if (name && !u.name) {
+    u.name = name; await store('users.json', users);
+  }
+  return u;
+}
+
 createServer(async (req, res) => {
   try {
     const url = new URL(req.url, 'http://x');
@@ -287,6 +356,66 @@ createServer(async (req, res) => {
       return sendJSON(res, 200, { ok: true }, { 'Set-Cookie': clearCookie });
     }
 
+    // ===== SIGN IN WITH APPLE (web OAuth, bridged into the app's WebView) =====
+    // Which sign-in methods are live — the site hides the Apple button until its
+    // creds are configured, so a dormant deploy never shows a broken button.
+    if (path === '/api/auth-config' && M === 'GET') {
+      return sendJSON(res, 200, { ok: true, apple: APPLE_CONFIGURED });
+    }
+    // Kick off Apple sign-in. The iOS app appends ?app=1 and runs this in
+    // ASWebAuthenticationSession; the web appends nothing and runs it inline.
+    if (path === '/auth/apple/login' && M === 'GET') {
+      if (!APPLE_CONFIGURED) return sendJSON(res, 503, { ok: false, error: 'Sign in with Apple is not configured yet' });
+      const state = randomBytes(16).toString('hex');
+      appleStates.set(state, { app: url.searchParams.get('app') === '1', exp: Date.now() + 10 * 60 * 1000 });
+      const a = new URL('https://appleid.apple.com/auth/authorize');
+      a.searchParams.set('response_type', 'code');
+      a.searchParams.set('client_id', APPLE_SERVICES_ID);
+      a.searchParams.set('redirect_uri', APPLE_REDIRECT_URI);
+      a.searchParams.set('scope', 'name email');
+      a.searchParams.set('response_mode', 'form_post');
+      a.searchParams.set('state', state);
+      res.writeHead(302, { location: a.toString() }); return res.end();
+    }
+    // Apple posts the code back here (form_post). Exchange it, verify the
+    // identity, upsert the account, start a cs_sess session, finish the login.
+    if (path === '/auth/apple/callback' && M === 'POST') {
+      const form = new URLSearchParams(await readRaw(req));
+      const code = form.get('code'); const state = form.get('state');
+      const st = state ? appleStates.get(state) : undefined;
+      if (state) appleStates.delete(state);
+      if (!code || !st || st.exp < Date.now()) return sendJSON(res, 400, { ok: false, error: 'invalid or expired apple state' });
+      try {
+        const body = new URLSearchParams({
+          grant_type: 'authorization_code', code,
+          client_id: APPLE_SERVICES_ID, client_secret: appleClientSecret(),
+          redirect_uri: APPLE_REDIRECT_URI,
+        });
+        const tr = await fetch('https://appleid.apple.com/auth/token', {
+          method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body,
+        });
+        const tok = await tr.json();
+        if (!tok.id_token) return sendJSON(res, 502, { ok: false, error: 'apple token exchange failed' });
+        const claims = JSON.parse(Buffer.from(tok.id_token.split('.')[1] || '', 'base64url').toString());
+        if (!claims.sub) return sendJSON(res, 502, { ok: false, error: 'apple id_token missing subject' });
+        const email = String(claims.email || '').toLowerCase();
+        // Apple sends the human name ONLY on the first authorization (a `user` field).
+        let name = '';
+        try { const uf = form.get('user'); if (uf) { const j = JSON.parse(uf); name = `${j?.name?.firstName || ''} ${j?.name?.lastName || ''}`.trim(); } } catch { /* */ }
+        const u = await upsertAppleUser(String(claims.sub), email, name);
+        const tokc = await newSession(u.id);
+        return completeAppleLogin(res, setCookie(tokc), !!st.app, '/');
+      } catch { return sendJSON(res, 502, { ok: false, error: 'apple callback failed' }); }
+    }
+    // Native-app handoff: the app replays the one-time token HERE, inside its own
+    // WebView, so the Set-Cookie lands in the WebView's cookie jar. Single-use.
+    if (path === '/auth/handoff' && M === 'GET') {
+      const token = url.searchParams.get('token') || '';
+      const rec = handoffTokens.get(token); if (rec) handoffTokens.delete(token);
+      if (!rec || rec.exp < Date.now()) { res.writeHead(302, { location: '/?signin=expired' }); return res.end(); }
+      res.writeHead(302, { 'Set-Cookie': rec.setCookie, location: rec.location }); return res.end();
+    }
+
     // ===== GAME LEADERBOARD (public, no account needed) =====
     // Top-20 per game+difficulty. HARDENED (Cody gate, yoloforever C2):
     //  - game/diff must be from a fixed WHITELIST → no arbitrary-key disk-fill

← 6a34cf3 chore: lint, refactor, v1.0.1 (session close)  ·  back to CelebritySignatures  ·  celeb mobile: add Sign in with Apple button to AccountScreen 5c6df61 →