← 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 →