← back to Costa Rica
costa-rica: Sign in with Apple (backend) — lib/apple RS256 JWKS verifier, /api/app/auth/apple upsert-by-apple_sub, migration 005; verifier rejects forged tokens — TK-10346
aa3c1e2b834d276f66384c53134fccd31043a116 · 2026-08-07 10:18:51 -0700 · Steve
Files touched
A lib/apple.jsM routes/app.jsA scripts/migrate_005_apple.sql
Diff
commit aa3c1e2b834d276f66384c53134fccd31043a116
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Aug 7 10:18:51 2026 -0700
costa-rica: Sign in with Apple (backend) — lib/apple RS256 JWKS verifier, /api/app/auth/apple upsert-by-apple_sub, migration 005; verifier rejects forged tokens — TK-10346
---
lib/apple.js | 45 +++++++++++++++++++++++++++++++++++++++++++
routes/app.js | 30 +++++++++++++++++++++++++++++
scripts/migrate_005_apple.sql | 4 ++++
3 files changed, 79 insertions(+)
diff --git a/lib/apple.js b/lib/apple.js
new file mode 100644
index 0000000..ebdf092
--- /dev/null
+++ b/lib/apple.js
@@ -0,0 +1,45 @@
+'use strict';
+// Sign in with Apple — verify the identity token (a JWT signed by Apple, RS256)
+// against Apple's published JWKS. Zero external deps (Node crypto + fetch).
+// Docs: https://appleid.apple.com/auth/keys
+const crypto = require('crypto');
+
+const JWKS_URL = 'https://appleid.apple.com/auth/keys';
+const ISS = 'https://appleid.apple.com';
+// Accept our app bundle id (native) — extend if a web/services aud is added.
+const AUD = (process.env.APPLE_AUD || 'com.abrams.costarica').split(',').map(s => s.trim());
+
+let _keys = null, _keysExp = 0;
+async function jwks() {
+ if (_keys && Date.now() < _keysExp) return _keys;
+ const r = await fetch(JWKS_URL);
+ if (!r.ok) throw new Error(`apple jwks HTTP ${r.status}`);
+ const j = await r.json();
+ _keys = new Map(j.keys.map(k => [k.kid, k]));
+ _keysExp = Date.now() + 60 * 60 * 1000; // 1h cache
+ return _keys;
+}
+
+const b64uJson = (s) => JSON.parse(Buffer.from(s, 'base64url').toString());
+
+// Returns { sub, email, email_verified } on success; throws on any invalid token.
+async function verifyIdentityToken(idToken) {
+ if (!idToken || idToken.split('.').length !== 3) throw new Error('malformed identity token');
+ const [h, p, sig] = idToken.split('.');
+ const header = b64uJson(h);
+ const payload = b64uJson(p);
+
+ const key = (await jwks()).get(header.kid);
+ if (!key) throw new Error('apple signing key not found');
+ const pub = crypto.createPublicKey({ key, format: 'jwk' });
+ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${h}.${p}`), pub, Buffer.from(sig, 'base64url'));
+ if (!ok) throw new Error('apple signature invalid');
+
+ if (payload.iss !== ISS) throw new Error('bad iss');
+ if (!AUD.includes(payload.aud)) throw new Error(`bad aud ${payload.aud}`);
+ if (payload.exp && Math.floor(Date.now() / 1000) > payload.exp) throw new Error('token expired');
+
+ return { sub: payload.sub, email: payload.email || null, email_verified: payload.email_verified === 'true' || payload.email_verified === true };
+}
+
+module.exports = { verifyIdentityToken };
diff --git a/routes/app.js b/routes/app.js
index 1356ea4..8d72120 100644
--- a/routes/app.js
+++ b/routes/app.js
@@ -9,6 +9,7 @@ const { computeSplit, nights } = require('../lib/money');
const { getProvider } = require('../lib/payments');
const plaid = require('../lib/plaid');
const wa = require('../lib/whatsapp');
+const apple = require('../lib/apple');
const router = express.Router();
const bookingCode = () => 'CR-' + crypto.randomBytes(3).toString('hex').toUpperCase();
@@ -41,6 +42,35 @@ router.post('/auth/login', async (req, res) => {
user: { id: u.id, email: u.email, full_name: u.full_name, role: u.role, is_host: u.is_host } });
});
+// Sign in with Apple — app sends Apple's identity_token; we verify + upsert.
+router.post('/auth/apple', async (req, res) => {
+ const { identity_token, full_name } = req.body || {};
+ let claims;
+ try { claims = await apple.verifyIdentityToken(identity_token); }
+ catch (e) { return bad(res, 401, `apple verify failed: ${e.message}`); }
+ try {
+ // Link by apple_sub first, else by verified email, else create.
+ let { rows } = await pool.query(`SELECT * FROM app_users WHERE apple_sub=$1`, [claims.sub]);
+ if (!rows[0] && claims.email) {
+ ({ rows } = await pool.query(
+ `UPDATE app_users SET apple_sub=$1, auth_provider='apple' WHERE email=$2 RETURNING *`,
+ [claims.sub, claims.email.toLowerCase()]));
+ }
+ if (!rows[0]) {
+ ({ rows } = await pool.query(
+ `INSERT INTO app_users (apple_sub, email, full_name, auth_provider)
+ VALUES ($1,$2,$3,'apple') RETURNING *`,
+ [claims.sub, claims.email ? claims.email.toLowerCase() : null, full_name || null]));
+ }
+ const u = rows[0];
+ ok(res, { token: signToken({ sub: u.id, role: u.role }),
+ user: { id: u.id, email: u.email, full_name: u.full_name, role: u.role, is_host: u.is_host } });
+ } catch (e) {
+ if (e.code === '23505') return bad(res, 409, 'account conflict');
+ bad(res, 500, e.message);
+ }
+});
+
router.get('/me', authRequired, async (req, res) => {
const { rows } = await pool.query(`SELECT id,email,full_name,phone_e164,role,is_host,wa_opt_in FROM app_users WHERE id=$1`, [req.user.sub]);
if (!rows[0]) return bad(res, 404, 'not found');
diff --git a/scripts/migrate_005_apple.sql b/scripts/migrate_005_apple.sql
new file mode 100644
index 0000000..7f06d3a
--- /dev/null
+++ b/scripts/migrate_005_apple.sql
@@ -0,0 +1,4 @@
+-- Sign in with Apple: stable Apple user id + provider tag on app_users.
+ALTER TABLE app_users
+ ADD COLUMN IF NOT EXISTS apple_sub TEXT UNIQUE,
+ ADD COLUMN IF NOT EXISTS auth_provider TEXT DEFAULT 'password';
← a337830 costa-rica: admin dashboard (host-claim approvals + bookings
·
back to Costa Rica
·
costa-rica: build/ops dashboard (/build, gated) — integratio 1cc9a07 →