diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4369df4..08a3cc3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,7 +21,7 @@ permissions: jobs: deploy: name: Deploy - runs-on: self-hosted + runs-on: [self-hosted, drumee-in] timeout-minutes: 15 env: SGP_USER: debian diff --git a/acl/apple.json b/acl/apple.json index 4e349c9..4beabc5 100644 --- a/acl/apple.json +++ b/acl/apple.json @@ -16,6 +16,22 @@ "src": "anonymous", "fast_check": "public-api" } + }, + "native_nonce": { + "doc": "Mint the nonce a native (iOS) Sign in with Apple request must carry; bound to the calling session, single use.", + "scope": "hub", + "permission": { + "src": "anonymous", + "fast_check": "public-api" + } + }, + "native_signin": { + "doc": "Verify a native (iOS) Apple identity token against this deployment's bundle id and sign the calling session in.", + "scope": "hub", + "permission": { + "src": "anonymous", + "fast_check": "public-api" + } } }, "modules": { diff --git a/acl/oauth.json b/acl/oauth.json index dff3f13..91b1e94 100644 --- a/acl/oauth.json +++ b/acl/oauth.json @@ -1,5 +1,13 @@ { "services": { + "claim": { + "doc": "Redeem the one-time hand-off code a mobile OAuth callback issued; signs in the calling session when it is the one that started the flow.", + "scope": "hub", + "permission": { + "src": "anonymous", + "fast_check": "public-api" + } + }, "verify_otp": { "doc": "Verify the 2FA OTP for a pending OAuth sign-in and finalize the session.", "scope": "hub", diff --git a/acl/onboarding.json b/acl/onboarding.json index 5183d13..792c995 100644 --- a/acl/onboarding.json +++ b/acl/onboarding.json @@ -5,126 +5,152 @@ "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" }, "save_signup_info": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Pre-auth signup step: by definition there is no user yet." }, "save_usage_plan": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" }, "save_industry": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" }, "save_role": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" }, "save_team_size": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" }, "save_intent": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" }, "save_challenges": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" + }, + "save_invites": { + "scope": "hub", + "permission": { + "src": "anonymous", + "fast_check": "public-api" + }, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" }, "save_tools": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" }, "save_privacy": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" }, "get_response": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" }, "check_completion": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" }, "mark_complete": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" }, "get_countries": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Static reference list used by the signup country picker." }, "reset": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" }, "update_profile": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" }, "get_env": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Static client config (db_name / xlink). No user data." }, "get_onboarding_invite_link": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" }, "send_onboarding_invites": { "scope": "hub", @@ -132,18 +158,20 @@ "src": "anonymous", "fast_check": "public-api" }, - "log": true + "log": true, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" }, "get_activation_status": { "scope": "hub", "permission": { "src": "anonymous", "fast_check": "public-api" - } + }, + "doc": "Reachability only - authentication is enforced in service/onboarding.js by _identity(), which rejects anonymous callers (ID_NOBODY) with 401 and keys every row on the caller's uid. Do NOT set src:owner here: onboarding requests carry no hub_id, so the ACL resolves them against the endpoint's own hub, and a user in onboarding never owns that hub - it denies every call with PERMISSION_DENIED. (contact.invite can use src:owner only because it passes hub_id: Visitor.id, i.e. the caller's own hub.)" } }, "modules": { "private": "service/onboarding", "public": "service/onboarding" } -} \ No newline at end of file +} diff --git a/offline/test/apple-native-token.test.js b/offline/test/apple-native-token.test.js new file mode 100644 index 0000000..a22fc73 --- /dev/null +++ b/offline/test/apple-native-token.test.js @@ -0,0 +1,191 @@ +#!/usr/bin/env node + +/** + * Verification of a native Sign in with Apple identity token. + * + * WHY THIS EXISTS. iOS hands the app a token audienced to the APP BUNDLE ID, + * with a nonce the server minted beforehand; the web flow's token is audienced + * to the Services ID and carries no nonce. lib/apple-token.js checks both the + * same way, so every rejection the plan names is pinned here against tokens + * signed with a local RSA key standing in for Apple's JWKS. + * + * Standalone runner (no test framework in this repo): `node `. + */ + +const assert = require("assert"); +const { generateKeyPairSync, createHash } = require("crypto"); +const { readFileSync } = require("fs"); +const { join } = require("path"); +const jwt = require("jsonwebtoken"); + +const ROOT = join(__dirname, "../.."); +const read = (p) => readFileSync(join(ROOT, p), "utf8"); + +const { + APPLE_ISSUER, NONCE_SHAPE, appleBool, nonceDigest, verifyAppleIdentityToken, +} = require("../../service/lib/apple-token"); + +let failures = 0; +function test(name, fn) { + return Promise.resolve() + .then(fn) + .then(() => console.log(" ok " + name)) + .catch((e) => { failures++; console.log(" FAIL " + name + "\n " + e.message); }); +} + +// ── a stand-in for Apple's JWKS ──────────────────────────────────────── +const apple = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const stranger = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const KID = "test-kid"; +const getSigningKey = async (kid) => { + if (kid !== KID) throw new Error("unknown kid"); + return { getPublicKey: () => apple.publicKey.export({ type: "spki", format: "pem" }) }; +}; + +const BUNDLE = "com.drumee.app.stage"; +const SERVICE = "com.drumee.web"; +const NONCE = "0123456789abcdef0123456789abcdef"; +const NOW = 1_800_000_000; + +function sign(claims, { key = apple.privateKey, kid = KID } = {}) { + const payload = { + iss: APPLE_ISSUER, aud: BUNDLE, sub: "001234.abcdef", iat: NOW - 10, exp: NOW + 600, + email: "relay@privaterelay.appleid.com", email_verified: "true", is_private_email: "true", + nonce: nonceDigest(NONCE), ...claims, + }; + // `claim: undefined` means "omit the claim", which jsonwebtoken refuses to + // sign for exp; strip those so the token really lacks it. + for (const k of Object.keys(payload)) if (payload[k] === undefined) delete payload[k]; + return jwt.sign(payload, key.export({ type: "pkcs8", format: "pem" }), { + algorithm: "RS256", keyid: kid, noTimestamp: true, + }); +} + +const verify = (token, over = {}) => + verifyAppleIdentityToken(token, { getSigningKey, audience: BUNDLE, nonce: NONCE, now: NOW, ...over }); + +async function rejects(promise, code) { + try { await promise; } catch (e) { assert.strictEqual(e.code, code, `got ${e.code}: ${e.message}`); return; } + assert.fail(`accepted; expected ${code}`); +} + +(async () => { + await test("a valid native token verifies and is normalised", async () => { + const v = await verify(sign({})); + assert.strictEqual(v.sub, "001234.abcdef"); + assert.strictEqual(v.email, "relay@privaterelay.appleid.com"); + assert.strictEqual(v.is_private_email, 1); + }); + + await test("booleans and their string forms read alike", async () => { + const v = await verify(sign({ email_verified: true, is_private_email: false })); + assert.strictEqual(v.is_private_email, 0); + assert.strictEqual(appleBool("false"), false); + assert.strictEqual(appleBool(undefined), false); + }); + + await test("a token without email still verifies (the caller falls back by sub)", async () => { + const v = await verify(sign({ email: undefined })); + assert.strictEqual(v.email, null); + }); + + await test("the web audience (Services ID) is refused on the native path, and vice versa", async () => { + await rejects(verify(sign({ aud: SERVICE })), "invalid_token"); + await rejects(verifyAppleIdentityToken(sign({}), { getSigningKey, audience: SERVICE, now: NOW }), "invalid_token"); + }); + + await test("another deployment's bundle id is refused", async () => { + await rejects(verify(sign({ aud: "com.drumee.app.prelive" })), "invalid_token"); + }); + + await test("an audience LIST that merely contains ours is refused", async () => { + // jsonwebtoken would accept this; Apple mints one audience and so do we. + await rejects(verify(sign({ aud: [BUNDLE, "com.evil.app"] })), "invalid_token"); + }); + + await test("wrong issuer, expiry (present or absent) and a stranger's key are refused", async () => { + await rejects(verify(sign({ iss: "https://accounts.google.com" })), "invalid_token"); + await rejects(verify(sign({ exp: NOW - 1 })), "invalid_token"); + await rejects(verify(sign({ exp: undefined })), "invalid_token"); + await rejects(verify(sign({}, { key: stranger.privateKey })), "invalid_token"); + }); + + await test("an unknown key id or an unreachable key set is key_unavailable, not a bad token", async () => { + await rejects(verify(sign({}, { kid: "other" })), "key_unavailable"); + await rejects(verify(sign({}), { getSigningKey: async () => { throw new Error("Apple JWKS timeout"); } }), + "key_unavailable"); + }); + + await test("a non-string email claim reads as absent", async () => { + const v = await verify(sign({ email: { evil: 1 } })); + assert.strictEqual(v.email, null); + }); + + await test("a nonce mismatch is its own rejection", async () => { + await rejects(verify(sign({ nonce: nonceDigest("f".repeat(32)) })), "invalid_nonce"); + // The raw nonce in the claim is not the digest. + await rejects(verify(sign({ nonce: NONCE })), "invalid_nonce"); + await rejects(verify(sign({}), { nonce: "short" }), "invalid_nonce"); + }); + + await test("the web flow passes no nonce and is not asked for one", async () => { + const v = await verifyAppleIdentityToken(sign({ aud: SERVICE, nonce: undefined }), { + getSigningKey, audience: SERVICE, now: NOW, + }); + assert.strictEqual(v.sub, "001234.abcdef"); + }); + + await test("an unverified email and a missing subject are refused", async () => { + await rejects(verify(sign({ email_verified: "false" })), "email_unverified"); + await rejects(verify(sign({ sub: undefined })), "invalid_token"); + }); + + await test("no audience configured reads as credentials_missing; garbage is not a JWT", async () => { + await rejects(verifyAppleIdentityToken(sign({}), { getSigningKey, audience: "" }), "credentials_missing"); + await rejects(verify("not.a.jwt.at.all"), "invalid_token"); + await rejects(verify(""), "invalid_token"); + }); + + await test("the nonce digest is SHA-256 hex of the raw value", () => { + assert.strictEqual(nonceDigest(NONCE), createHash("sha256").update(NONCE).digest("hex")); + assert.ok(NONCE_SHAPE.test(NONCE)); + }); + + // ── the service wires it the way the plan says ────────────────────── + await test("native_signin verifies against ONE bundle id and spends the nonce after verifying", () => { + const src = read("service/apple.js"); + assert.ok(/audience: APPLECREDS\.bundle_id,\s*nonce,/.test(src), "native path does not pin the bundle id + nonce"); + assert.ok(!/bundle_ids/.test(src), "an audience LIST is back — one deployment, one audience"); + const verifyAt = src.indexOf("await verifyAppleIdentityToken(identityToken"); + const consumeAt = src.indexOf("await this._consumeNonce(nonce, sid)"); + assert.ok(verifyAt > 0 && consumeAt > verifyAt, "the nonce is spent before the token verified"); + assert.ok(/state = \? AND session_id = \? AND ctime > UNIX_TIMESTAMP\(\) - \?/.test(src), + "the nonce row is not bound to the caller's session"); + assert.ok(/DELETE FROM oauth_state WHERE state = \? AND session_id = \?/.test(src), + "the nonce delete is not scoped to the session"); + assert.ok(/ins\.affectedRows !== 1/.test(src), "native_nonce does not check that the row was stored"); + const nonceFn = /async native_nonce\(\) \{([\s\S]*?)\n \}/.exec(src)[1]; + assert.ok(!/sweepOauthTables/.test(nonceFn), "native_nonce sweeps on an anonymous mint path"); + }); + + await test("the web verifier delegates to the same function with the Services ID", () => { + const src = read("service/apple.js"); + assert.ok(/_verifyAppleIdToken\(id_token, audience = APPLECREDS\.service_id\)/.test(src)); + assert.ok(!/jwt\.verify\(/.test(src), "apple.js verifies tokens inline again — two verifiers to keep in sync"); + }); + + await test("initiate guards on a field, not on the frozen object", () => { + assert.ok(/if \(!APPLECREDS\.service_id\) \{\s*return this\.output\.data\(\{ status: 'error', error: 'credentials_missing' \}\)/.test(read("service/apple.js"))); + }); + + await test("the two native services are exposed anonymously like their siblings", () => { + const acl = JSON.parse(read("acl/apple.json")); + for (const svc of ["native_nonce", "native_signin"]) { + assert.deepStrictEqual(acl.services[svc].permission, { src: "anonymous", fast_check: "public-api" }, svc); + assert.strictEqual(acl.services[svc].scope, "hub"); + } + }); + + console.log(failures ? `\n${failures} failed\n` : "\nall passed\n"); + process.exit(failures ? 1 : 0); +})(); diff --git a/offline/test/mobile-handoff.test.js b/offline/test/mobile-handoff.test.js new file mode 100644 index 0000000..675705a --- /dev/null +++ b/offline/test/mobile-handoff.test.js @@ -0,0 +1,260 @@ +#!/usr/bin/env node + +/** + * The mobile hand-off for the browser OAuth flow. + * + * WHY THIS EXISTS. A mobile client cannot receive the web landing page: the + * provider redirect ends inside a system auth sheet that only closes when it + * is sent to the app's own URL scheme. So the callback has to know WHICH + * client started the flow without a database row (the row may be gone exactly + * when an error must be reported), WHERE to send it, and WHAT the URL may + * carry. lib/oauth-mobile.js decides all three; these cases pin them. + * + * Also pinned: the shape of the callback code in google.js / apple.js that + * makes the decision reachable from every exit, and the escaping of the web + * error template, which is the only template sendOauthError renders. + * + * Standalone runner (no test framework in this repo): `node `. + */ + +const assert = require("assert"); +const { readFileSync } = require("fs"); +const { join } = require("path"); +const { template } = require("lodash"); + +const ROOT = join(__dirname, "../.."); +const read = (p) => readFileSync(join(ROOT, p), "utf8"); + +const { + MOBILE_CLIENTS, RETURN_ERRORS, HANDOFF_TTL, + normaliseClient, oauthStateFor, clientFromState, statePrefixFor, + mobileReturnUrl, mintHandoffCode, claimDecision, +} = require("../../service/lib/oauth-mobile"); + +let failures = 0; +function test(name, fn) { + try { fn(); console.log(" ok " + name); } + catch (e) { failures++; console.log(" FAIL " + name + "\n " + e.message); } +} + +// ── which client ─────────────────────────────────────────────────────── +test("every whitelisted client maps to a drumee- scheme", () => { + for (const [client, scheme] of Object.entries(MOBILE_CLIENTS)) { + assert.ok(/^mobile-[a-z]+$/.test(client), `${client} is not mobile-`); + assert.strictEqual(scheme, `drumee-${client.slice("mobile-".length)}`); + } +}); + +test("normaliseClient accepts the whitelist and nothing else", () => { + assert.strictEqual(normaliseClient("mobile-stage"), "mobile-stage"); + assert.strictEqual(normaliseClient(" Mobile-Test "), "mobile-test"); + for (const bad of ["mobile", "mobile-prod", "web", "", null, undefined, 1, "drumee-stage"]) { + assert.strictEqual(normaliseClient(bad), null, `accepted ${JSON.stringify(bad)}`); + } +}); + +test("a mobile state carries the client as a suffix and stays under the column", () => { + const s = oauthStateFor("g", "mobile-prelive"); + assert.ok(/^g_[0-9a-f-]{36}~mobile-prelive$/.test(s), s); + assert.ok(s.length <= 64, "oauth_state.state is VARCHAR(64)"); + assert.strictEqual(clientFromState(s), "mobile-prelive"); +}); + +test("a web state has no suffix and reads as no client", () => { + const s = oauthStateFor("a", null); + assert.ok(/^a_[0-9a-f-]{36}$/.test(s), s); + assert.strictEqual(clientFromState(s), null); +}); + +test("clientFromState refuses what it did not write", () => { + for (const bad of [ + "g_not-a-uuid~mobile-stage", + "g_" + "0".repeat(36) + "~mobile-prod", + "g_" + "0".repeat(36) + "~mobile-stage~mobile-test", + "n_" + "0".repeat(32), + "", null, undefined, + ]) { + assert.strictEqual(clientFromState(bad), null, `accepted ${JSON.stringify(bad)}`); + } +}); + +test("each provider owns one state prefix", () => { + assert.strictEqual(statePrefixFor("google"), "g_"); + assert.strictEqual(statePrefixFor("apple"), "a_"); + assert.strictEqual(statePrefixFor("nonce"), null); +}); + +// ── where it returns to, and what it may carry ───────────────────────── +const CODE = "0123456789abcdef0123456789abcdef"; + +test("a success URL carries provider and code only", () => { + assert.strictEqual( + mobileReturnUrl("mobile-stage", "google", { code: CODE }), + `drumee-stage://oauth/return?provider=google&code=${CODE}`); +}); + +test("an error URL carries provider, status and a known error", () => { + assert.strictEqual( + mobileReturnUrl("mobile-test", "apple", { error: "access_denied" }), + "drumee-test://oauth/return?provider=apple&status=error&error=access_denied"); +}); + +test("an unknown error is replaced, never interpolated", () => { + const url = mobileReturnUrl("mobile-stage", "google", { error: "x');alert(1)//" }); + assert.strictEqual(url, "drumee-stage://oauth/return?provider=google&status=error&error=oauth_failed"); + for (const e of RETURN_ERRORS) { + assert.ok(/^[a-z_]+$/.test(e), `${e} is not a bare identifier`); + } +}); + +test("a malformed code, unknown client or unknown provider throws", () => { + assert.throws(() => mobileReturnUrl("mobile-stage", "google", { code: "short" })); + assert.throws(() => mobileReturnUrl("mobile-stage", "google", { code: CODE.toUpperCase() })); + assert.throws(() => mobileReturnUrl("web", "google", { code: CODE })); + assert.throws(() => mobileReturnUrl("mobile-stage", "facebook", { code: CODE })); + // Object.prototype keys are not clients. + assert.throws(() => mobileReturnUrl("constructor", "google", { code: CODE })); +}); + +// ── the claim rules, executed ────────────────────────────────────────── +const NOW = 1_800_000_000; +const SID = "s".repeat(32); +const PROFILE = { provider: "google", provider_id: "sub-1", email: "a@b.c" }; +const freshRow = (over = {}) => ({ + code: CODE, session_id: SID, provider: "google", profile: PROFILE, ctime: NOW - 5, ...over, +}); + +test("a fresh row claimed by the initiating session is accepted", () => { + const d = claimDecision({ code: CODE, sid: SID, row: freshRow(), now: NOW }); + assert.deepStrictEqual(d, { ok: true, profile: PROFILE }); +}); + +test("the parked profile may arrive as a JSON string", () => { + const d = claimDecision({ code: CODE, sid: SID, row: freshRow({ profile: JSON.stringify(PROFILE) }), now: NOW }); + assert.deepStrictEqual(d, { ok: true, profile: PROFILE }); +}); + +test("another session cannot redeem the code", () => { + const d = claimDecision({ code: CODE, sid: "t".repeat(32), row: freshRow(), now: NOW }); + assert.deepStrictEqual(d, { error: "session_mismatch" }); +}); + +test("an expired hand-off reads as no code", () => { + const d = claimDecision({ code: CODE, sid: SID, row: freshRow({ ctime: NOW - HANDOFF_TTL - 1 }), now: NOW }); + assert.deepStrictEqual(d, { error: "invalid_code" }); +}); + +test("no row, no session or a malformed code all read as no code", () => { + assert.deepStrictEqual(claimDecision({ code: CODE, sid: SID, row: null, now: NOW }), { error: "invalid_code" }); + assert.deepStrictEqual(claimDecision({ code: CODE, sid: SID, row: {}, now: NOW }), { error: "invalid_code" }); + assert.deepStrictEqual(claimDecision({ code: CODE, sid: "", row: freshRow(), now: NOW }), { error: "invalid_code" }); + assert.deepStrictEqual(claimDecision({ code: "nope", sid: SID, row: freshRow(), now: NOW }), { error: "invalid_code" }); + // Session check happens before profile parsing, so a wrong session never + // learns whether the parked profile is well-formed. + assert.deepStrictEqual( + claimDecision({ code: CODE, sid: "t".repeat(32), row: freshRow({ profile: "{" }), now: NOW }), + { error: "session_mismatch" }); +}); + +test("a parked profile without provider identity is refused", () => { + for (const profile of ["{", "null", JSON.stringify({ email: "a@b.c" }), JSON.stringify({ provider: "google" })]) { + const d = claimDecision({ code: CODE, sid: SID, row: freshRow({ profile }), now: NOW }); + assert.deepStrictEqual(d, { error: "unexpected_error" }, `accepted ${profile}`); + } +}); + +test("hand-off codes are 32 hex and distinct", () => { + const a = mintHandoffCode(), b = mintHandoffCode(); + assert.ok(/^[0-9a-f]{32}$/.test(a) && /^[0-9a-f]{32}$/.test(b)); + assert.notStrictEqual(a, b); +}); + +// ── the callbacks can reach the decision from every exit ─────────────── +test("both callbacks resolve the client before the try and use it in the catch", () => { + for (const f of ["service/google.js", "service/apple.js"]) { + const src = read(f); + const cb = /async callback\(\) \{([\s\S]*?)\n \}\n/.exec(src); + assert.ok(cb, `${f}: callback not found`); + const body = cb[1]; + const tryAt = body.indexOf("try {"); + const clientAt = body.indexOf("const client = this.clientFromState(stateParam)"); + assert.ok(clientAt !== -1 && clientAt < tryAt, `${f}: client is not resolved before the try`); + const catchAt = body.lastIndexOf("} catch (e) {"); + const afterCatch = body.slice(catchAt); + assert.ok(/sendOauthError\('oauth_failed', client, '(google|apple)'\)/.test(afterCatch), + `${f}: the catch cannot send a mobile client back to its app`); + assert.ok(/sendOauthError\('access_denied', client, '(google|apple)'\)/.test(body), + `${f}: a cancelled consent does not return to the app`); + assert.ok(/await this\.resolveOAuthState\(stateParam, '(google|apple)'\);\s*return this\.sendOauthError\('access_denied'/.test(body), + `${f}: a cancelled consent leaves its state row replayable`); + assert.ok(/await this\.resolveOAuthState\(stateParam, '(google|apple)'\)\.catch\(\(\) => null\);\s*this\.sendOauthError\('oauth_failed'/.test(afterCatch), + `${f}: a failed exchange leaves its state row replayable`); + } +}); + +test("the web initiate path never touches the hand-off table", () => { + for (const f of ["service/google.js", "service/apple.js"]) { + assert.ok(/if \(client\) await this\.sweepOauthTables\(\);/.test(read(f)), + `${f}: the sweep runs for web callers too — a web sign-in now depends on oauth_handoff existing`); + } + const oauth = read("service/oauth.js"); + assert.ok(!/sweepOauthTables/.test(oauth), "claim sweeps on an anonymous read path"); +}); + +test("the mobile branch parks and redirects, never signs in", () => { + for (const f of ["service/google.js", "service/apple.js"]) { + const src = read(f); + const branch = /if \(client\) \{([\s\S]*?)\n \}/.exec(src); + assert.ok(branch, `${f}: no mobile branch`); + assert.ok(branch[1].includes("stashOauthHandoff"), `${f}: mobile branch does not park the profile`); + assert.ok(branch[1].includes("sendMobileReturn"), `${f}: mobile branch does not redirect to the app`); + assert.ok(!branch[1].includes("handleOAuthCallback") && !branch[1].includes("sendHtml"), + `${f}: the mobile branch signs in or sets a cookie`); + } +}); + +test("the state row is read and deleted in exactly one place", () => { + const loby = read("service/lib/loby.js"); + assert.strictEqual((loby.match(/FROM oauth_state s WHERE state = \?/g) || []).length, 1); + assert.strictEqual((loby.match(/DELETE FROM oauth_state WHERE state = \?/g) || []).length, 1); + assert.ok(/del\.affectedRows !== 1/.test(loby), "the delete is not checked — two callbacks could both win"); +}); + +test("the claim decides through claimDecision and spends the code once", () => { + const src = read("service/oauth.js"); + assert.ok(/claimDecision\(\{ code, sid, row, now/.test(src), "claim does not use the tested decision"); + assert.ok(/DELETE FROM oauth_handoff WHERE code = \?/.test(src), "claim does not consume the code"); + assert.ok(/del\.affectedRows !== 1/.test(src), "claim does not check the delete"); + assert.ok(/completeOAuthSignin\(profile, \{\s*session_id: sid/.test(src), + "claim signs in a session other than the caller's"); +}); + +test("resend mints before it prunes, and prunes the right side", () => { + const src = read("service/oauth.js"); + const mintAt = src.indexOf("await this._send2faOtp(pending.uid, pending.email)"); + const pruneOld = src.indexOf("DELETE FROM otp WHERE uid = ? AND sys_id <= ?"); + const pruneNew = src.indexOf("DELETE FROM otp WHERE uid = ? AND sys_id > ?"); + assert.ok(mintAt > 0 && pruneOld > mintAt && pruneNew > mintAt, + "a failed send would destroy the code already in the inbox"); +}); + +test("verify_otp counts attempts and drops the sign-in at the limit", () => { + const src = read("service/oauth.js"); + assert.ok(/pending\.failed >= OTP_MAX_ATTEMPTS/.test(src)); + assert.ok(/too_many_attempts/.test(src)); + assert.ok(/UPDATE cookie SET failed = failed \+ 1 WHERE id = \? AND status = 'otp_pending'/.test(src)); +}); + +// ── the web error template cannot be broken out of ───────────────────── +test("oauth-error does not interpolate its redirect raw", () => { + const HOSTILE = "https://x/-/#/welcome/signin?oauth_error=a');alert(1);//"; + const html = template(read("service/templates/oauth-error.html"))({ redirect: HOSTILE }); + const line = html.split("\n").find((l) => l.includes("location.replace")); + assert.ok(line, "the redirect is gone"); + assert.ok(!line.includes("replace('"), + "the URL is inside a single-quoted literal — one apostrophe reaches script context"); + assert.ok(line.includes('replace("https://x/-/#/welcome/signin?oauth_error=a\');alert(1);//")')); +}); + +console.log(failures ? `\n${failures} failed\n` : "\nall passed\n"); +process.exit(failures ? 1 : 0); diff --git a/offline/test/oauth-dest.test.js b/offline/test/oauth-dest.test.js new file mode 100644 index 0000000..a58c0df --- /dev/null +++ b/offline/test/oauth-dest.test.js @@ -0,0 +1,273 @@ +#!/usr/bin/env node + +/** + * The OAuth destination — the value that survives the bounce to the provider. + * + * WHY THIS EXISTS. A campaign CTA names where the visitor is going: + * + * #/desk/billing?plan=team&cycle=monthly&tab=checkout&promo=EMAILMKT270826_2 + * + * ui-team parks that in sessionStorage before the signin plugin rewrites the + * hash, which carries an email/password sign-in and not an OAuth one: this + * callback is server-side, it rebuilds the landing URL from scratch, and a URL + * fragment is never sent to a server in the first place. So the destination + * rides on `oauth_state` beside `ref` and utm_*, and comes back out on `home`. + * + * TWO THINGS ARE PINNED HERE, and the second is why this file is not optional: + * + * 1. _sanitiseDest accepts exactly the destinations the campaign can name and + * refuses everything else. It REBUILDS rather than passes through, so a + * value can only ever be one this function could have written. + * + * 2. The landing templates do not interpolate that URL raw. lib/loby.js uses + * LODASH, whose equals-delimiter is the RAW one — the reverse of EJS — so + * `location.replace('')` put request-derived text straight into a + * JS string literal on the page that runs immediately after + * authentication. Both halves are asserted: the sanitiser refuses quotes, + * AND the template escapes. Either alone is one edit away from an XSS. + * + * Standalone runner (no test framework in this repo): `node `. + */ + +const assert = require("assert"); +const { readFileSync } = require("fs"); +const { join } = require("path"); +const { template } = require("lodash"); + +const ROOT = join(__dirname, "../.."); +const read = (p) => readFileSync(join(ROOT, p), "utf8"); +const LOBY = read("service/lib/loby.js"); + +let failures = 0; +function test(name, fn) { + try { fn(); console.log(" ok " + name); } + catch (e) { failures++; console.log(" FAIL " + name + "\n " + e.message); } +} + +/** + * The real _sanitiseDest, lifted and compiled. + * + * Out of the source rather than restated here, so these cases cannot pass + * against a rule the service does not actually apply. + */ +const sanitiseSrc = /_sanitiseDest\(raw\) \{([\s\S]*?)\n \}/.exec(LOBY); +assert(sanitiseSrc, "_sanitiseDest not found in service/lib/loby.js"); +const sanitise = new Function("raw", sanitiseSrc[1]); + +const CAMPAIGN_DEST = + "/desk/billing?plan=team&cycle=monthly&tab=checkout&promo=EMAILMKT270826_2"; + +// ── what it accepts ──────────────────────────────────────────────────── +test("the campaign's own destination survives byte for byte", () => { + assert.strictEqual(sanitise(CAMPAIGN_DEST), CAMPAIGN_DEST); +}); + +test("the bare billing path is a destination", () => { + assert.strictEqual(sanitise("/desk/billing"), "/desk/billing"); +}); + +test("params are rebuilt in a fixed order", () => { + // Two links meaning the same thing must produce the same string: the value is + // compared and stored, and an order-dependent one would look like two. + assert.strictEqual( + sanitise("/desk/billing?tab=checkout&plan=team"), + "/desk/billing?plan=team&tab=checkout"); +}); + +test("whitespace around the value is tolerated", () => { + assert.strictEqual(sanitise(` ${CAMPAIGN_DEST} `), CAMPAIGN_DEST); +}); + +// ── what it refuses ──────────────────────────────────────────────────── +const REFUSED = { + "a foreign path": "/desk/wm/open/123", + "a path outside the desk": "/welcome/signin", + "a protocol-relative url": "//evil.example/", + "an absolute url": "https://evil.example/desk/billing", + "a quote (the XSS vector)": "/desk/billing?plan=team');alert(1);//", + "a double quote": '/desk/billing?plan=team"x', + "a backslash": "/desk/billing?plan=team\\x", + "an angle bracket": "/desk/billing?plan= + + + <% } %> + <% if (typeof is_new !== 'undefined' && is_new) { %> + + <% } %> <% if (typeof is_new !== 'undefined' && is_new) { %> + Discover your Drumee desk diff --git a/service/templates/oauth-error.html b/service/templates/oauth-error.html index d6b76bf..1c97f2d 100644 --- a/service/templates/oauth-error.html +++ b/service/templates/oauth-error.html @@ -4,7 +4,10 @@ Sign-in interrupted - + +