Skip to content

Commit 91a7e11

Browse files
committed
fix(init): verify preserved Apple configuration
1 parent 819b23f commit 91a7e11

2 files changed

Lines changed: 168 additions & 5 deletions

File tree

packages/cli-core/src/commands/init/ios/native-apple.test.ts

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ function statefulAPI(
110110
failActual?: unknown;
111111
malformedDryRun?: boolean;
112112
replaceProjection?: boolean;
113+
dryRunProjectionOverride?: Record<string, unknown>;
114+
actualProjectionOverride?: Record<string, unknown>;
115+
persistedActualState?: AppleConnection;
113116
persistActual?: boolean;
114117
} = {},
115118
): {
@@ -173,12 +176,20 @@ function statefulAPI(
173176
? { ...(update as Record<string, unknown>) }
174177
: { ...current, ...(update as Record<string, unknown>) }
175178
) as AppleConnection;
179+
const projectionOverride = patchOptions.dryRun
180+
? options.dryRunProjectionOverride
181+
: options.actualProjectionOverride;
182+
if (projectionOverride) Object.assign(after, structuredClone(projectionOverride));
176183
if (patchOptions.dryRun && options.malformedDryRun) {
177184
return { config_version: version, dry_run: true, before: {}, after: {} };
178185
}
179186
if (!patchOptions.dryRun) {
180187
writes += 1;
181-
if (options.persistActual !== false) current = after;
188+
if (options.persistActual !== false) {
189+
current = options.persistedActualState
190+
? structuredClone(options.persistedActualState)
191+
: after;
192+
}
182193
version = NEXT_CONFIG_VERSION;
183194
}
184195
return {
@@ -590,6 +601,100 @@ describe("native Sign in with Apple remote setup", () => {
590601
expect(captured.err).not.toContain(PRIVATE_KEY);
591602
});
592603

604+
test("rejects a dry-run projection that changes a nested preserved field", async () => {
605+
const harness = statefulAPI({
606+
initial: connection(false, false, {
607+
unrelated_provider_setting: {
608+
nested: { mode: "keep", secret: PRIVATE_KEY },
609+
},
610+
}),
611+
dryRunProjectionOverride: {
612+
unrelated_provider_setting: {
613+
nested: { mode: "changed", secret: PRIVATE_KEY },
614+
},
615+
},
616+
});
617+
const prepared = await prepareIOSNativeAppleConnection(baseOptions(), {
618+
api: harness.api,
619+
prompts: unexpectedPrompts(),
620+
});
621+
if (prepared.status !== "ready") throw new Error("expected ready plan");
622+
623+
await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toThrow(
624+
"could not safely validate native Sign in with Apple",
625+
);
626+
expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true]);
627+
expect(harness.actualWrites()).toBe(0);
628+
expect(captured.err).not.toContain(PRIVATE_KEY);
629+
});
630+
631+
test("rejects an actual-write projection that changes a preserved credential value", async () => {
632+
const changedSecret = `${PRIVATE_KEY}_CHANGED`;
633+
const harness = statefulAPI({
634+
initial: connection(false, false, { client_secret: PRIVATE_KEY }),
635+
actualProjectionOverride: { client_secret: changedSecret },
636+
});
637+
const prepared = await prepareIOSNativeAppleConnection(baseOptions(), {
638+
api: harness.api,
639+
prompts: unexpectedPrompts(),
640+
});
641+
if (prepared.status !== "ready") throw new Error("expected ready plan");
642+
643+
let thrown: unknown;
644+
try {
645+
await applyIOSNativeAppleConnection(prepared, harness.api);
646+
} catch (error) {
647+
thrown = error;
648+
}
649+
expect(thrown).toMatchObject({
650+
code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE,
651+
message: expect.stringContaining("removed or changed existing fields"),
652+
});
653+
expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true, false]);
654+
expect(harness.actualWrites()).toBe(1);
655+
expect(String(thrown)).not.toContain(PRIVATE_KEY);
656+
expect(String(thrown)).not.toContain(changedSecret);
657+
expect(captured.err).not.toContain(PRIVATE_KEY);
658+
expect(captured.err).not.toContain(changedSecret);
659+
});
660+
661+
test("rejects a final state that drops a secret despite preserving projections", async () => {
662+
const initial = connection(false, true, {
663+
client_id: SERVICES_ID,
664+
client_secret: PRIVATE_KEY,
665+
unrelated_provider_setting: { nested: { mode: "keep" } },
666+
});
667+
const harness = statefulAPI({
668+
initial,
669+
persistedActualState: connection(true, true, {
670+
bundle_id: BUNDLE_IDENTIFIER,
671+
client_id: SERVICES_ID,
672+
unrelated_provider_setting: { nested: { mode: "keep" } },
673+
}),
674+
});
675+
const prepared = await prepareIOSNativeAppleConnection(baseOptions(), {
676+
api: harness.api,
677+
prompts: unexpectedPrompts(),
678+
});
679+
if (prepared.status !== "ready") throw new Error("expected ready plan");
680+
681+
let thrown: unknown;
682+
try {
683+
await applyIOSNativeAppleConnection(prepared, harness.api);
684+
} catch (error) {
685+
thrown = error;
686+
}
687+
expect(thrown).toMatchObject({
688+
code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED,
689+
message: expect.stringContaining("did not pass final verification"),
690+
});
691+
expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true, false]);
692+
expect(harness.actualWrites()).toBe(1);
693+
expect(String(thrown)).not.toContain(PRIVATE_KEY);
694+
expect(JSON.stringify(prepared)).not.toContain(PRIVATE_KEY);
695+
expect(captured.err).not.toContain(PRIVATE_KEY);
696+
});
697+
593698
test("rereads final state and rejects a write that did not persist", async () => {
594699
const harness = statefulAPI({ persistActual: false });
595700
const prepared = await prepareIOSNativeAppleConnection(baseOptions(), {

packages/cli-core/src/commands/init/ios/native-apple.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isDeepStrictEqual } from "node:util";
12
import { dim, yellow } from "../../../lib/color.ts";
23
import {
34
ApiError,
@@ -19,6 +20,7 @@ import { withSpinner } from "../../../lib/spinner.ts";
1920

2021
const APPLE_CONNECTION_KEY = "connection_oauth_apple";
2122
const CONFIG_VERSION_PATTERN = /^v1_[0-9a-f]{8}$/;
23+
const NATIVE_APPLE_PATCH_FIELDS = new Set(["enabled", "authenticatable", "bundle_id"]);
2224

2325
function iosAppleError(
2426
message: string,
@@ -78,6 +80,11 @@ export type IOSNativeAppleSkipped = {
7880

7981
export type IOSNativeApplePreparation = IOSNativeApplePlan | IOSNativeAppleSkipped;
8082

83+
const preservedAppleFieldFingerprints = new WeakMap<
84+
IOSNativeApplePlan,
85+
ReadonlyMap<string, string>
86+
>();
87+
8188
export interface IOSNativeApplePatchOptions {
8289
dryRun: boolean;
8390
/** Forwarded only by clients which explicitly advertise support. */
@@ -163,6 +170,50 @@ function isRecord(value: unknown): value is Record<string, unknown> {
163170
return typeof value === "object" && value !== null && !Array.isArray(value);
164171
}
165172

173+
function canonicalConfigValue(value: unknown): string | undefined {
174+
if (value === null) return "null";
175+
if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
176+
if (typeof value === "number") return Number.isFinite(value) ? JSON.stringify(value) : undefined;
177+
if (Array.isArray(value)) {
178+
const items = value.map(canonicalConfigValue);
179+
return items.some((item) => item == null) ? undefined : `[${items.join(",")}]`;
180+
}
181+
if (!isRecord(value)) return undefined;
182+
183+
const entries: string[] = [];
184+
for (const key of Object.keys(value).sort()) {
185+
const item = canonicalConfigValue(value[key]);
186+
if (item == null) return undefined;
187+
entries.push(`${JSON.stringify(key)}:${item}`);
188+
}
189+
return `{${entries.join(",")}}`;
190+
}
191+
192+
function preservedFieldFingerprints(
193+
container: Record<string, unknown>,
194+
): ReadonlyMap<string, string> | undefined {
195+
const connection = container[APPLE_CONNECTION_KEY];
196+
if (!isRecord(connection)) return undefined;
197+
198+
const fingerprints = new Map<string, string>();
199+
for (const [key, value] of Object.entries(connection)) {
200+
if (NATIVE_APPLE_PATCH_FIELDS.has(key)) continue;
201+
const canonical = canonicalConfigValue(value);
202+
if (canonical == null) return undefined;
203+
fingerprints.set(key, new Bun.CryptoHasher("sha256").update(canonical).digest("hex"));
204+
}
205+
return fingerprints;
206+
}
207+
208+
function preservedFieldsMatch(before: IOSNativeApplePlan, after: IOSNativeApplePlan): boolean {
209+
const beforeFingerprints = preservedAppleFieldFingerprints.get(before);
210+
const afterFingerprints = preservedAppleFieldFingerprints.get(after);
211+
if (!beforeFingerprints || !afterFingerprints) return false;
212+
return [...beforeFingerprints].every(
213+
([key, fingerprint]) => afterFingerprints.get(key) === fingerprint,
214+
);
215+
}
216+
166217
function blocker(code: IOSNativeAppleBlockerCode, message: string): IOSNativeAppleBlocker {
167218
return { code, message };
168219
}
@@ -319,7 +370,7 @@ export function buildIOSNativeApplePlan(
319370
]
320371
: [];
321372

322-
return {
373+
const plan: IOSNativeApplePlan = {
323374
schemaVersion: 1,
324375
kind: "clerk-ios-native-apple-connection",
325376
status,
@@ -334,6 +385,9 @@ export function buildIOSNativeApplePlan(
334385
actions,
335386
blockers,
336387
};
388+
const fingerprints = preservedFieldFingerprints(options.config);
389+
if (fingerprints) preservedAppleFieldFingerprints.set(plan, fingerprints);
390+
return plan;
337391
}
338392

339393
export async function auditIOSNativeAppleConnection(
@@ -422,10 +476,14 @@ function validatePatchProjection(
422476
if (
423477
!isRecord(beforeConnection) ||
424478
!isRecord(afterConnection) ||
425-
Object.keys(beforeConnection).some((key) => !Object.hasOwn(afterConnection, key))
479+
Object.entries(beforeConnection).some(
480+
([key, value]) =>
481+
!Object.hasOwn(afterConnection, key) ||
482+
(!NATIVE_APPLE_PATCH_FIELDS.has(key) && !isDeepStrictEqual(afterConnection[key], value)),
483+
)
426484
) {
427485
throw iosAppleError(
428-
"Clerk returned an Apple configuration projection that removed existing fields.",
486+
"Clerk returned an Apple configuration projection that removed or changed existing fields.",
429487
ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE,
430488
);
431489
}
@@ -659,7 +717,7 @@ export async function applyIOSNativeAppleConnection(
659717
ERROR_CODE.IOS_REMOTE_VERIFY_FAILED,
660718
);
661719
}
662-
if (finalPlan.status !== "satisfied") {
720+
if (finalPlan.status !== "satisfied" || !preservedFieldsMatch(current, finalPlan)) {
663721
throw iosAppleError(
664722
"Native Sign in with Apple did not pass final verification. Rerun clerk init to reconcile the remote state safely.",
665723
ERROR_CODE.IOS_REMOTE_VERIFY_FAILED,

0 commit comments

Comments
 (0)