@ramonclaudio/create-vexpo 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/dist/index.js +8 -7
- package/dist/templates/default/.eas/workflows/asc-events.yml +9 -6
- package/dist/templates/default/.eas/workflows/deploy-production.yml +28 -15
- package/dist/templates/default/.eas/workflows/e2e-tests.yml +3 -2
- package/dist/templates/default/.eas/workflows/pr-preview.yml +12 -21
- package/dist/templates/default/.eas/workflows/release.yml +3 -7
- package/dist/templates/default/.eas/workflows/rollback.yml +54 -28
- package/dist/templates/default/.eas/workflows/rollout.yml +27 -33
- package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +1 -5
- package/dist/templates/default/.eas/workflows/testflight.yml +3 -7
- package/dist/templates/default/.github/workflows/check.yml +20 -12
- package/dist/templates/default/.maestro/launch.yaml +19 -10
- package/dist/templates/default/AGENTS.md +25 -8
- package/dist/templates/default/DESIGN.md +14 -10
- package/dist/templates/default/README.md +83 -78
- package/dist/templates/default/SETUP.md +159 -152
- package/dist/templates/default/__tests__/convex/_auth-harness.test.ts +112 -0
- package/dist/templates/default/__tests__/convex/_harness.ts +132 -0
- package/dist/templates/default/__tests__/convex/appAttest.test.ts +172 -0
- package/dist/templates/default/__tests__/convex/appAttestStore.test.ts +48 -0
- package/dist/templates/default/__tests__/convex/pushTokens-remove.test.ts +106 -0
- package/dist/templates/default/__tests__/convex/pushTokens-upsert.test.ts +146 -0
- package/dist/templates/default/__tests__/convex/users-deleteAccount.test.ts +140 -0
- package/dist/templates/default/__tests__/convex/users-deleteAvatar.test.ts +104 -0
- package/dist/templates/default/__tests__/convex/users-getMe.test.ts +98 -0
- package/dist/templates/default/__tests__/convex/users-getUser.test.ts +120 -0
- package/dist/templates/default/__tests__/convex/users-hardDeleteExpired.test.ts +67 -0
- package/dist/templates/default/__tests__/convex/users-restoreAccount.test.ts +96 -0
- package/dist/templates/default/__tests__/convex/users-updateAvatar.test.ts +92 -0
- package/dist/templates/default/__tests__/convex/users-updateProfile.test.ts +126 -0
- package/dist/templates/default/__tests__/convex/webhook.test.ts +31 -0
- package/dist/templates/default/__tests__/lib/deep-link.test.ts +51 -6
- package/dist/templates/default/__tests__/lib/schemas.test.ts +205 -0
- package/dist/templates/default/__tests__/lib/text-style.test.ts +31 -0
- package/dist/templates/default/_env.example +7 -7
- package/dist/templates/default/_gitattributes +1 -1
- package/dist/templates/default/_gitignore +17 -2
- package/dist/templates/default/_npmrc +7 -0
- package/dist/templates/default/_oxlintrc.json +1 -1
- package/dist/templates/default/app-store/accessibility.config.json +20 -0
- package/dist/templates/default/app-store/privacy.config.json +27 -0
- package/dist/templates/default/app.config.ts +105 -33
- package/dist/templates/default/app.json +1 -9
- package/dist/templates/default/convex/_generated/api.d.ts +12 -0
- package/dist/templates/default/convex/admin.ts +0 -13
- package/dist/templates/default/convex/appAttest.ts +467 -0
- package/dist/templates/default/convex/appAttestStore.ts +141 -0
- package/dist/templates/default/convex/apple.ts +53 -0
- package/dist/templates/default/convex/auth.ts +6 -45
- package/dist/templates/default/convex/constants.ts +2 -7
- package/dist/templates/default/convex/crons.ts +12 -5
- package/dist/templates/default/convex/email.ts +4 -24
- package/dist/templates/default/convex/env.ts +0 -4
- package/dist/templates/default/convex/errors.ts +0 -7
- package/dist/templates/default/convex/functions.ts +0 -26
- package/dist/templates/default/convex/http.ts +3 -5
- package/dist/templates/default/convex/log.ts +2 -25
- package/dist/templates/default/convex/pushSender.ts +145 -0
- package/dist/templates/default/convex/pushTokens.ts +110 -13
- package/dist/templates/default/convex/rateLimit.ts +8 -39
- package/dist/templates/default/convex/schema.ts +48 -5
- package/dist/templates/default/convex/tsconfig.json +1 -0
- package/dist/templates/default/convex/users.ts +143 -61
- package/dist/templates/default/convex/validators.ts +1 -38
- package/dist/templates/default/convex/webhook.ts +1 -31
- package/dist/templates/default/convex.json +1 -2
- package/dist/templates/default/metro.config.js +9 -1
- package/dist/templates/default/package.json +67 -70
- package/dist/templates/default/plugins/README.md +5 -1
- package/dist/templates/default/scripts/README.md +9 -9
- package/dist/templates/default/scripts/_run.mjs +3 -20
- package/dist/templates/default/scripts/clean.ts +81 -69
- package/dist/templates/default/scripts/gen-update-cert.mjs +98 -0
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home)/index.tsx +21 -6
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home,search)/_layout.tsx +9 -8
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/(search)/index.tsx +26 -24
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/_layout.tsx +3 -4
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/_layout.tsx +10 -6
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/index.tsx +81 -51
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/preferences.tsx +72 -12
- package/dist/templates/default/src/app/(app)/_layout.tsx +147 -0
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/_layout.tsx +4 -5
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/forgot-password.tsx +15 -9
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/reset-password.tsx +88 -14
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-in.tsx +65 -35
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-up.tsx +131 -196
- package/dist/templates/default/src/app/(app)/debug.tsx +479 -0
- package/dist/templates/default/{app → src/app}/(app)/help.tsx +76 -64
- package/dist/templates/default/{app → src/app}/(app)/linked.tsx +21 -27
- package/dist/templates/default/{app → src/app}/(app)/privacy.tsx +35 -8
- package/dist/templates/default/src/app/(app)/profile/change-password.tsx +264 -0
- package/dist/templates/default/{app/(app)/profile.tsx → src/app/(app)/profile/index.tsx} +179 -255
- package/dist/templates/default/src/app/(app)/restore-account.tsx +192 -0
- package/dist/templates/default/src/app/(app)/sessions.tsx +287 -0
- package/dist/templates/default/src/app/(app)/welcome.tsx +194 -0
- package/dist/templates/default/src/app/+native-intent.tsx +25 -0
- package/dist/templates/default/src/app/+not-found.tsx +43 -0
- package/dist/templates/default/{app → src/app}/_layout.tsx +28 -37
- package/dist/templates/default/src/components/auth/apple-button.tsx +51 -0
- package/dist/templates/default/{components → src/components}/auth/otp-verification.tsx +79 -58
- package/dist/templates/default/{components → src/components}/auth/password-field.tsx +74 -18
- package/dist/templates/default/src/components/auth/segmented-toggle.tsx +71 -0
- package/dist/templates/default/src/components/ui/content-unavailable.tsx +81 -0
- package/dist/templates/default/src/components/ui/convex-error.tsx +21 -0
- package/dist/templates/default/src/components/ui/error-boundary.tsx +89 -0
- package/dist/templates/default/{components → src/components}/ui/loading-screen.tsx +5 -4
- package/dist/templates/default/{components → src/components}/ui/material.tsx +50 -17
- package/dist/templates/default/src/components/ui/offline-banner.tsx +59 -0
- package/dist/templates/default/{components → src/components}/ui/prominent-button.tsx +8 -11
- package/dist/templates/default/{components → src/components}/ui/skeleton.tsx +31 -13
- package/dist/templates/default/src/components/ui/status-text.tsx +64 -0
- package/dist/templates/default/src/components/ui/update-banner.tsx +85 -0
- package/dist/templates/default/{constants → src/constants}/layout.ts +0 -6
- package/dist/templates/default/{constants → src/constants}/theme.ts +49 -64
- package/dist/templates/default/{constants → src/constants}/ui.ts +13 -4
- package/dist/templates/default/src/hooks/use-debounce.ts +12 -0
- package/dist/templates/default/src/hooks/use-deep-link.ts +51 -0
- package/dist/templates/default/src/hooks/use-delete-account.ts +35 -0
- package/dist/templates/default/src/hooks/use-motion-screen-options.ts +13 -0
- package/dist/templates/default/src/hooks/use-network.ts +34 -0
- package/dist/templates/default/{hooks → src/hooks}/use-notifications.ts +39 -30
- package/dist/templates/default/src/hooks/use-reduce-transparency.ts +30 -0
- package/dist/templates/default/{hooks → src/hooks}/use-theme.ts +0 -5
- package/dist/templates/default/src/lib/appAttest.ts +78 -0
- package/dist/templates/default/src/lib/assets.ts +9 -0
- package/dist/templates/default/src/lib/deep-link.ts +82 -0
- package/dist/templates/default/{lib → src/lib}/dev-menu.ts +0 -4
- package/dist/templates/default/{lib → src/lib}/device.ts +1 -13
- package/dist/templates/default/{lib → src/lib}/dynamic-font.ts +13 -10
- package/dist/templates/default/src/lib/dynamic-symbol-size.ts +33 -0
- package/dist/templates/default/src/lib/masks.ts +21 -0
- package/dist/templates/default/src/lib/native-state.ts +20 -0
- package/dist/templates/default/{lib → src/lib}/notifications.ts +7 -45
- package/dist/templates/default/{lib → src/lib}/preferences.ts +0 -2
- package/dist/templates/default/{lib → src/lib}/schemas.ts +19 -16
- package/dist/templates/default/src/lib/text-style.ts +20 -0
- package/dist/templates/default/{lib → src/lib}/updates.ts +0 -7
- package/dist/templates/default/store.config.json +1 -1
- package/dist/templates/default/tsconfig.json +3 -1
- package/dist/templates/default/vitest.config.ts +8 -1
- package/package.json +5 -5
- package/dist/templates/default/app/(app)/_layout.tsx +0 -73
- package/dist/templates/default/app/(app)/debug.tsx +0 -389
- package/dist/templates/default/app/(app)/sessions.tsx +0 -191
- package/dist/templates/default/app/(app)/welcome.tsx +0 -140
- package/dist/templates/default/app/+native-intent.tsx +0 -14
- package/dist/templates/default/app/+not-found.tsx +0 -51
- package/dist/templates/default/bun.lock +0 -1860
- package/dist/templates/default/components/auth/segmented-toggle.tsx +0 -47
- package/dist/templates/default/components/ui/convex-error.tsx +0 -32
- package/dist/templates/default/components/ui/error-boundary.tsx +0 -57
- package/dist/templates/default/components/ui/offline-banner.tsx +0 -58
- package/dist/templates/default/components/ui/status-text.tsx +0 -49
- package/dist/templates/default/components/ui/update-banner.tsx +0 -82
- package/dist/templates/default/fingerprint.config.js +0 -9
- package/dist/templates/default/hooks/use-debounce.ts +0 -20
- package/dist/templates/default/hooks/use-deep-link.ts +0 -43
- package/dist/templates/default/hooks/use-network.ts +0 -11
- package/dist/templates/default/lib/assets.ts +0 -17
- package/dist/templates/default/lib/deep-link.ts +0 -71
- package/dist/templates/default/patches/PR-368.patch +0 -91
- package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-navigation-tracking.ts +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-onboarding.ts +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-reduced-motion.ts +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-updates.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/a11y.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/app.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/auth-client.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/convex-auth.tsx +0 -0
- /package/dist/templates/default/{lib → src/lib}/env.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/haptics.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/storage.ts +0 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"use node";
|
|
2
|
+
|
|
3
|
+
import { X509Certificate, createHash, createPublicKey, randomBytes, verify } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
import { decode as cborDecode } from "cbor-x";
|
|
6
|
+
import { v } from "convex/values";
|
|
7
|
+
|
|
8
|
+
import { internal } from "./_generated/api";
|
|
9
|
+
import { internalAction } from "./_generated/server";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Apple App Attest verifier.
|
|
13
|
+
*
|
|
14
|
+
* Implements every step of Apple's attestation + assertion protocol so a
|
|
15
|
+
* Convex action can prove that incoming requests came from a real, unmodified
|
|
16
|
+
* vexpo binary running on a real iOS device with a Secure Enclave.
|
|
17
|
+
*
|
|
18
|
+
* Protocol (per Apple's "Validating Apps That Connect to Your Server"):
|
|
19
|
+
*
|
|
20
|
+
* ATTESTATION (one-time per device)
|
|
21
|
+
* 1. Server issues a 32-byte random `challenge`.
|
|
22
|
+
* 2. Client calls `generateKeyAsync()` → keyId.
|
|
23
|
+
* 3. Client calls `attestKeyAsync(keyId, sha256(challenge))` → CBOR
|
|
24
|
+
* attestation containing `{ fmt: 'apple-appattest', attStmt: { x5c,
|
|
25
|
+
* receipt }, authData }`.
|
|
26
|
+
* 4. Server (this module) verifies:
|
|
27
|
+
* a. cert chain `x5c[0]` → `x5c[1]` → Apple App Attest Root CA
|
|
28
|
+
* b. compute nonce = sha256(authData || sha256(challenge)); verify
|
|
29
|
+
* it matches the leaf cert's `1.2.840.113635.100.8.2` extension
|
|
30
|
+
* c. hash the leaf cert's public key; verify it matches the
|
|
31
|
+
* `credentialId` portion of authData
|
|
32
|
+
* d. authData.rpIdHash == sha256("<TEAM_ID>.<BUNDLE_ID>")
|
|
33
|
+
* e. authData.aaguid matches the expected production or dev value
|
|
34
|
+
* f. authData.counter == 0
|
|
35
|
+
* 5. Server stores `{ keyId, publicKey, counter: 0, environment }`.
|
|
36
|
+
*
|
|
37
|
+
* ASSERTION (per signed request)
|
|
38
|
+
* 1. Client calls `generateAssertionAsync(keyId, sha256(payload))` →
|
|
39
|
+
* CBOR assertion containing `{ signature, authenticatorData }`.
|
|
40
|
+
* 2. Server (this module) verifies:
|
|
41
|
+
* a. `signature` is a valid ECDSA-P256-SHA256 signature over
|
|
42
|
+
* sha256(authenticatorData || sha256(payload)) using the stored
|
|
43
|
+
* public key
|
|
44
|
+
* b. authenticatorData.rpIdHash matches the expected value
|
|
45
|
+
* c. authenticatorData.counter > stored counter
|
|
46
|
+
* 3. Server bumps the stored counter.
|
|
47
|
+
*
|
|
48
|
+
* https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
// Apple App Attest Root CA, distributed by Apple at
|
|
52
|
+
// https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem
|
|
53
|
+
// Pinned in source so a compromised CDN can't replace it.
|
|
54
|
+
const APPLE_ROOT_CA = `-----BEGIN CERTIFICATE-----
|
|
55
|
+
MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw
|
|
56
|
+
JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK
|
|
57
|
+
QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa
|
|
58
|
+
Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv
|
|
59
|
+
biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y
|
|
60
|
+
bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh
|
|
61
|
+
NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au
|
|
62
|
+
Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/
|
|
63
|
+
MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw
|
|
64
|
+
CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn
|
|
65
|
+
53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV
|
|
66
|
+
oyFraWVIyd/dganmrduC1bmTBGwD
|
|
67
|
+
-----END CERTIFICATE-----`;
|
|
68
|
+
|
|
69
|
+
const APPLE_ROOT_X509 = new X509Certificate(APPLE_ROOT_CA);
|
|
70
|
+
|
|
71
|
+
const APPLE_NONCE_OID = "1.2.840.113635.100.8.2";
|
|
72
|
+
|
|
73
|
+
// AAGUID is 16 bytes. Production runs ship "appattest" + 7 NULs. Development
|
|
74
|
+
// (simulator-style attestation, only valid when Xcode debugger is attached
|
|
75
|
+
// to the app) ships "appattestdevelop". A production app should never see
|
|
76
|
+
// the dev AAGUID; we reject it unless the deployment is explicitly running
|
|
77
|
+
// in development mode.
|
|
78
|
+
const AAGUID_PRODUCTION = Buffer.from("appattest\0\0\0\0\0\0\0", "binary");
|
|
79
|
+
const AAGUID_DEVELOPMENT = Buffer.from("appattestdevelop", "binary");
|
|
80
|
+
|
|
81
|
+
// Challenges TTL'd so a captured nonce can't be replayed indefinitely.
|
|
82
|
+
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Single-use: each nonce is consumed on the first verification that
|
|
86
|
+
* references it. Expired nonces are swept by `cleanupAppAttestChallenges`.
|
|
87
|
+
*/
|
|
88
|
+
export const issueChallenge = internalAction({
|
|
89
|
+
args: {},
|
|
90
|
+
returns: v.object({ nonce: v.string(), expiresAt: v.number() }),
|
|
91
|
+
handler: async (ctx) => {
|
|
92
|
+
const nonce = randomBytes(32).toString("base64url");
|
|
93
|
+
const expiresAt = Date.now() + CHALLENGE_TTL_MS;
|
|
94
|
+
await ctx.runMutation(internal.appAttestStore.createChallenge, { nonce, expiresAt });
|
|
95
|
+
return { nonce, expiresAt };
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export const verifyAttestation = internalAction({
|
|
100
|
+
args: {
|
|
101
|
+
keyId: v.string(),
|
|
102
|
+
attestation: v.string(),
|
|
103
|
+
challenge: v.string(),
|
|
104
|
+
bundleId: v.optional(v.string()),
|
|
105
|
+
teamId: v.optional(v.string()),
|
|
106
|
+
userId: v.optional(v.id("users")),
|
|
107
|
+
},
|
|
108
|
+
returns: v.object({
|
|
109
|
+
keyId: v.string(),
|
|
110
|
+
publicKey: v.string(),
|
|
111
|
+
environment: v.union(v.literal("development"), v.literal("production")),
|
|
112
|
+
}),
|
|
113
|
+
handler: async (ctx, args) => {
|
|
114
|
+
const consumed = await ctx.runMutation(internal.appAttestStore.consumeChallenge, {
|
|
115
|
+
nonce: args.challenge,
|
|
116
|
+
now: Date.now(),
|
|
117
|
+
});
|
|
118
|
+
if (!consumed) {
|
|
119
|
+
throw new Error("app-attest: challenge unknown, expired, or already consumed");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const bundleId = args.bundleId ?? process.env.APP_BUNDLE_ID;
|
|
123
|
+
const teamId = args.teamId ?? process.env.APPLE_TEAM_ID;
|
|
124
|
+
if (!bundleId || !teamId) {
|
|
125
|
+
throw new Error("app-attest: APP_BUNDLE_ID and APPLE_TEAM_ID must be set");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const environment = decideEnvironment();
|
|
129
|
+
const result = verifyAttestationBytes({
|
|
130
|
+
keyId: args.keyId,
|
|
131
|
+
attestation: Buffer.from(args.attestation, "base64"),
|
|
132
|
+
challenge: args.challenge,
|
|
133
|
+
bundleId,
|
|
134
|
+
teamId,
|
|
135
|
+
environment,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await ctx.runMutation(internal.appAttestStore.storeKey, {
|
|
139
|
+
keyId: args.keyId,
|
|
140
|
+
publicKey: result.publicKey,
|
|
141
|
+
environment: result.environment,
|
|
142
|
+
userId: args.userId,
|
|
143
|
+
now: Date.now(),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return { keyId: args.keyId, publicKey: result.publicKey, environment: result.environment };
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
export const verifyAssertion = internalAction({
|
|
151
|
+
args: {
|
|
152
|
+
keyId: v.string(),
|
|
153
|
+
assertion: v.string(),
|
|
154
|
+
payload: v.string(),
|
|
155
|
+
bundleId: v.optional(v.string()),
|
|
156
|
+
teamId: v.optional(v.string()),
|
|
157
|
+
},
|
|
158
|
+
returns: v.object({ counter: v.number() }),
|
|
159
|
+
handler: async (ctx, args) => {
|
|
160
|
+
const key = await ctx.runQuery(internal.appAttestStore.findKey, { keyId: args.keyId });
|
|
161
|
+
if (!key) throw new Error("app-attest: unknown keyId");
|
|
162
|
+
|
|
163
|
+
const bundleId = args.bundleId ?? process.env.APP_BUNDLE_ID;
|
|
164
|
+
const teamId = args.teamId ?? process.env.APPLE_TEAM_ID;
|
|
165
|
+
if (!bundleId || !teamId) {
|
|
166
|
+
throw new Error("app-attest: APP_BUNDLE_ID and APPLE_TEAM_ID must be set");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const newCounter = verifyAssertionBytes({
|
|
170
|
+
assertion: Buffer.from(args.assertion, "base64"),
|
|
171
|
+
payload: args.payload,
|
|
172
|
+
bundleId,
|
|
173
|
+
teamId,
|
|
174
|
+
publicKey: Buffer.from(key.publicKey, "base64url"),
|
|
175
|
+
storedCounter: key.counter,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await ctx.runMutation(internal.appAttestStore.bumpCounter, {
|
|
179
|
+
keyId: args.keyId,
|
|
180
|
+
counter: newCounter,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return { counter: newCounter };
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
type AttestationInputs = {
|
|
188
|
+
keyId: string;
|
|
189
|
+
attestation: Buffer;
|
|
190
|
+
challenge: string;
|
|
191
|
+
bundleId: string;
|
|
192
|
+
teamId: string;
|
|
193
|
+
environment: "development" | "production";
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
type AttestationResult = {
|
|
197
|
+
publicKey: string;
|
|
198
|
+
environment: "development" | "production";
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export function verifyAttestationBytes(inputs: AttestationInputs): AttestationResult {
|
|
202
|
+
const decoded = cborDecode(inputs.attestation) as {
|
|
203
|
+
fmt?: string;
|
|
204
|
+
attStmt?: { x5c?: Buffer[]; receipt?: Buffer };
|
|
205
|
+
authData?: Buffer;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
if (decoded.fmt !== "apple-appattest") {
|
|
209
|
+
throw new Error(`app-attest: unexpected fmt '${decoded.fmt}'`);
|
|
210
|
+
}
|
|
211
|
+
const x5c = decoded.attStmt?.x5c;
|
|
212
|
+
const authData = decoded.authData;
|
|
213
|
+
if (!x5c || x5c.length < 2 || !authData) {
|
|
214
|
+
throw new Error("app-attest: missing x5c or authData");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Step 1: cert chain. credCert is x5c[0], intermediate is x5c[1] (and
|
|
218
|
+
// possibly more), root is Apple's root CA pinned above.
|
|
219
|
+
const credCert = new X509Certificate(x5c[0]);
|
|
220
|
+
const intermediate = new X509Certificate(x5c[1]);
|
|
221
|
+
if (!intermediate.verify(APPLE_ROOT_X509.publicKey)) {
|
|
222
|
+
throw new Error("app-attest: intermediate cert is not signed by Apple App Attest Root CA");
|
|
223
|
+
}
|
|
224
|
+
if (!credCert.verify(intermediate.publicKey)) {
|
|
225
|
+
throw new Error("app-attest: credential cert is not signed by the intermediate");
|
|
226
|
+
}
|
|
227
|
+
assertCertValidity(credCert);
|
|
228
|
+
|
|
229
|
+
// Step 2-3: compute the expected nonce.
|
|
230
|
+
const clientDataHash = sha256(Buffer.from(inputs.challenge, "utf8"));
|
|
231
|
+
const expectedNonce = sha256(Buffer.concat([authData, clientDataHash]));
|
|
232
|
+
|
|
233
|
+
// Step 4: read the leaf cert's nonce extension (1.2.840.113635.100.8.2)
|
|
234
|
+
// and check it matches the expected nonce.
|
|
235
|
+
const credExtNonce = extractAppleNonceExtension(credCert);
|
|
236
|
+
if (!credExtNonce || !timingSafeEqual(credExtNonce, expectedNonce)) {
|
|
237
|
+
throw new Error("app-attest: nonce mismatch");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Step 5: the credential public key hash (SHA-256 of the DER
|
|
241
|
+
// SubjectPublicKeyInfo) should equal the credentialId portion of
|
|
242
|
+
// authData. We extract the key from the leaf cert, hash it, and
|
|
243
|
+
// compare to the keyId the client sent.
|
|
244
|
+
const credPubKeyDer = credCert.publicKey.export({ type: "spki", format: "der" }) as Buffer;
|
|
245
|
+
const credPubKeyHash = sha256(credPubKeyDer);
|
|
246
|
+
if (credPubKeyHash.toString("base64") !== inputs.keyId) {
|
|
247
|
+
throw new Error("app-attest: leaf cert public-key hash does not match keyId");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Step 6-9: rpIdHash, AAGUID, counter, credId checks within authData.
|
|
251
|
+
const ad = parseAuthData(authData);
|
|
252
|
+
|
|
253
|
+
const expectedRpIdHash = sha256(Buffer.from(`${inputs.teamId}.${inputs.bundleId}`, "utf8"));
|
|
254
|
+
if (!timingSafeEqual(ad.rpIdHash, expectedRpIdHash)) {
|
|
255
|
+
throw new Error("app-attest: rpIdHash does not match TEAM_ID.BUNDLE_ID");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (ad.counter !== 0) {
|
|
259
|
+
throw new Error("app-attest: attestation counter must be 0");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const aaguidOk =
|
|
263
|
+
inputs.environment === "production"
|
|
264
|
+
? timingSafeEqual(ad.aaguid, AAGUID_PRODUCTION)
|
|
265
|
+
: timingSafeEqual(ad.aaguid, AAGUID_DEVELOPMENT) ||
|
|
266
|
+
timingSafeEqual(ad.aaguid, AAGUID_PRODUCTION);
|
|
267
|
+
if (!aaguidOk) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`app-attest: AAGUID does not match expected for environment '${inputs.environment}'`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!ad.credId || ad.credId.toString("base64") !== inputs.keyId) {
|
|
274
|
+
throw new Error("app-attest: credentialId in authData does not match keyId");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
publicKey: credPubKeyDer.toString("base64url"),
|
|
279
|
+
environment: inputs.environment,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
type AssertionInputs = {
|
|
284
|
+
assertion: Buffer;
|
|
285
|
+
payload: string;
|
|
286
|
+
bundleId: string;
|
|
287
|
+
teamId: string;
|
|
288
|
+
publicKey: Buffer; // SPKI DER
|
|
289
|
+
storedCounter: number;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
export function verifyAssertionBytes(inputs: AssertionInputs): number {
|
|
293
|
+
const decoded = cborDecode(inputs.assertion) as {
|
|
294
|
+
signature?: Buffer;
|
|
295
|
+
authenticatorData?: Buffer;
|
|
296
|
+
};
|
|
297
|
+
const signature = decoded.signature;
|
|
298
|
+
const authData = decoded.authenticatorData;
|
|
299
|
+
if (!signature || !authData) {
|
|
300
|
+
throw new Error("app-attest: assertion missing signature or authenticatorData");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Reconstruct what the device signed: SHA256(authData || SHA256(payload))
|
|
304
|
+
const clientDataHash = sha256(Buffer.from(inputs.payload, "utf8"));
|
|
305
|
+
const dataToSign = Buffer.concat([authData, clientDataHash]);
|
|
306
|
+
const hashedData = sha256(dataToSign);
|
|
307
|
+
|
|
308
|
+
// The public key in storage is DER SPKI; convert to a KeyObject for
|
|
309
|
+
// crypto.verify.
|
|
310
|
+
const pubKey = createPublicKey({ key: inputs.publicKey, format: "der", type: "spki" });
|
|
311
|
+
|
|
312
|
+
// Apple emits ECDSA in IEEE-P1363 r||s form, but Node's `verify` defaults
|
|
313
|
+
// to DER. Apple's actual output is DER though, per their docs ("This is a
|
|
314
|
+
// DER-encoded ASN.1 sequence"). Both formats are possible across iOS
|
|
315
|
+
// versions, so try DER first and fall back to IEEE-P1363.
|
|
316
|
+
const ok =
|
|
317
|
+
verify(null, hashedData, pubKey, signature) ||
|
|
318
|
+
verify(null, hashedData, { key: pubKey, dsaEncoding: "ieee-p1363" }, signature);
|
|
319
|
+
if (!ok) {
|
|
320
|
+
throw new Error("app-attest: assertion signature failed verification");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// rpIdHash and counter checks reuse the attestation parser; the layout
|
|
324
|
+
// is identical except attestedCredentialData isn't present.
|
|
325
|
+
const ad = parseAuthData(authData, { hasCredential: false });
|
|
326
|
+
|
|
327
|
+
const expectedRpIdHash = sha256(Buffer.from(`${inputs.teamId}.${inputs.bundleId}`, "utf8"));
|
|
328
|
+
if (!timingSafeEqual(ad.rpIdHash, expectedRpIdHash)) {
|
|
329
|
+
throw new Error("app-attest: assertion rpIdHash mismatch");
|
|
330
|
+
}
|
|
331
|
+
if (ad.counter <= inputs.storedCounter) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`app-attest: assertion counter ${ad.counter} not strictly greater than stored ${inputs.storedCounter}`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
return ad.counter;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function sha256(input: Buffer): Buffer {
|
|
340
|
+
return createHash("sha256").update(input).digest();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function timingSafeEqual(a: Buffer, b: Buffer): boolean {
|
|
344
|
+
if (a.length !== b.length) return false;
|
|
345
|
+
let diff = 0;
|
|
346
|
+
for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
|
|
347
|
+
return diff === 0;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function assertCertValidity(cert: X509Certificate): void {
|
|
351
|
+
const now = Date.now();
|
|
352
|
+
const notBefore = Date.parse(cert.validFrom);
|
|
353
|
+
const notAfter = Date.parse(cert.validTo);
|
|
354
|
+
if (Number.isFinite(notBefore) && now < notBefore) {
|
|
355
|
+
throw new Error("app-attest: credential cert not yet valid");
|
|
356
|
+
}
|
|
357
|
+
if (Number.isFinite(notAfter) && now > notAfter) {
|
|
358
|
+
throw new Error("app-attest: credential cert expired");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Read the Apple App Attest nonce extension. The extension value is a
|
|
364
|
+
* DER-encoded ASN.1 sequence:
|
|
365
|
+
* SEQUENCE { [1] OCTET STRING { <nonce bytes> } }
|
|
366
|
+
*
|
|
367
|
+
* Node's `X509Certificate` exposes the raw extension DER through `raw`,
|
|
368
|
+
* but not individual extensions. We pull the cert's DER bytes and walk
|
|
369
|
+
* to the extension by OID.
|
|
370
|
+
*/
|
|
371
|
+
function extractAppleNonceExtension(cert: X509Certificate): Buffer | null {
|
|
372
|
+
const der = cert.raw;
|
|
373
|
+
const oidBytes = encodeOid(APPLE_NONCE_OID);
|
|
374
|
+
const start = der.indexOf(oidBytes);
|
|
375
|
+
if (start < 0) return null;
|
|
376
|
+
// After the OID, the extension is `[critical BOOLEAN] OCTET STRING { ... }`.
|
|
377
|
+
// We skip the OID, any optional BOOLEAN, and parse the OCTET STRING wrapper.
|
|
378
|
+
let i = start + oidBytes.length;
|
|
379
|
+
// Optional critical BOOLEAN.
|
|
380
|
+
if (der[i] === 0x01 && der[i + 1] === 0x01) i += 3;
|
|
381
|
+
// OCTET STRING wrapper holding the actual extension DER.
|
|
382
|
+
if (der[i] !== 0x04) return null;
|
|
383
|
+
const outerLen = readDerLength(der, i + 1);
|
|
384
|
+
if (!outerLen) return null;
|
|
385
|
+
let cursor = i + 1 + outerLen.headerBytes;
|
|
386
|
+
// Inner SEQUENCE.
|
|
387
|
+
if (der[cursor] !== 0x30) return null;
|
|
388
|
+
const innerLen = readDerLength(der, cursor + 1);
|
|
389
|
+
if (!innerLen) return null;
|
|
390
|
+
cursor += 1 + innerLen.headerBytes;
|
|
391
|
+
// Context-specific [1] tag = 0xa1, holding the OCTET STRING with the nonce.
|
|
392
|
+
if (der[cursor] !== 0xa1) return null;
|
|
393
|
+
const ctxLen = readDerLength(der, cursor + 1);
|
|
394
|
+
if (!ctxLen) return null;
|
|
395
|
+
cursor += 1 + ctxLen.headerBytes;
|
|
396
|
+
if (der[cursor] !== 0x04) return null;
|
|
397
|
+
const nonceLen = readDerLength(der, cursor + 1);
|
|
398
|
+
if (!nonceLen) return null;
|
|
399
|
+
cursor += 1 + nonceLen.headerBytes;
|
|
400
|
+
return Buffer.from(der.subarray(cursor, cursor + nonceLen.value));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function readDerLength(buf: Buffer, offset: number): { value: number; headerBytes: number } | null {
|
|
404
|
+
const first = buf[offset];
|
|
405
|
+
if (first === undefined) return null;
|
|
406
|
+
if (first < 0x80) return { value: first, headerBytes: 1 };
|
|
407
|
+
const lengthBytes = first & 0x7f;
|
|
408
|
+
if (lengthBytes === 0 || lengthBytes > 4) return null;
|
|
409
|
+
let value = 0;
|
|
410
|
+
for (let i = 0; i < lengthBytes; i++) value = (value << 8) | buf[offset + 1 + i]!;
|
|
411
|
+
return { value, headerBytes: 1 + lengthBytes };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function encodeOid(oid: string): Buffer {
|
|
415
|
+
// Minimal OID encoder: produces the DER value bytes (without the type
|
|
416
|
+
// tag). Used to locate the OID inside the cert's raw DER.
|
|
417
|
+
const parts = oid.split(".").map((p) => parseInt(p, 10));
|
|
418
|
+
if (parts.length < 2) throw new Error(`invalid OID: ${oid}`);
|
|
419
|
+
const bytes: number[] = [40 * parts[0]! + parts[1]!];
|
|
420
|
+
for (let i = 2; i < parts.length; i++) {
|
|
421
|
+
let n = parts[i]!;
|
|
422
|
+
const buf: number[] = [];
|
|
423
|
+
do {
|
|
424
|
+
buf.unshift(n & 0x7f);
|
|
425
|
+
n >>= 7;
|
|
426
|
+
} while (n > 0);
|
|
427
|
+
for (let j = 0; j < buf.length - 1; j++) buf[j]! |= 0x80;
|
|
428
|
+
bytes.push(...buf);
|
|
429
|
+
}
|
|
430
|
+
return Buffer.from([0x06, bytes.length, ...bytes]);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
type ParsedAuthData = {
|
|
434
|
+
rpIdHash: Buffer;
|
|
435
|
+
flags: number;
|
|
436
|
+
counter: number;
|
|
437
|
+
aaguid: Buffer;
|
|
438
|
+
credId: Buffer | null;
|
|
439
|
+
credentialPublicKey: Buffer | null;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
function parseAuthData(buf: Buffer, opts: { hasCredential?: boolean } = {}): ParsedAuthData {
|
|
443
|
+
const hasCredential = opts.hasCredential ?? true;
|
|
444
|
+
if (buf.length < 37) throw new Error("app-attest: authData too short");
|
|
445
|
+
const rpIdHash = Buffer.from(buf.subarray(0, 32));
|
|
446
|
+
const flags = buf[32]!;
|
|
447
|
+
const counter = buf.readUInt32BE(33);
|
|
448
|
+
let aaguid = Buffer.alloc(16);
|
|
449
|
+
let credId: Buffer | null = null;
|
|
450
|
+
let credentialPublicKey: Buffer | null = null;
|
|
451
|
+
if (hasCredential) {
|
|
452
|
+
if (buf.length < 55) throw new Error("app-attest: authData missing credential block");
|
|
453
|
+
aaguid = Buffer.from(buf.subarray(37, 53));
|
|
454
|
+
const credIdLen = buf.readUInt16BE(53);
|
|
455
|
+
if (buf.length < 55 + credIdLen) throw new Error("app-attest: authData credentialId overruns");
|
|
456
|
+
credId = Buffer.from(buf.subarray(55, 55 + credIdLen));
|
|
457
|
+
credentialPublicKey = Buffer.from(buf.subarray(55 + credIdLen));
|
|
458
|
+
}
|
|
459
|
+
return { rpIdHash, flags, counter, aaguid, credId, credentialPublicKey };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function decideEnvironment(): "development" | "production" {
|
|
463
|
+
// Fail closed: production is the default so a missing or misconfigured env var
|
|
464
|
+
// never silently downgrades to accepting the weaker development AAGUID. Opt
|
|
465
|
+
// into development explicitly (e.g. local `convex dev` testing a dev build).
|
|
466
|
+
return process.env.APP_ATTEST_ENVIRONMENT === "development" ? "development" : "production";
|
|
467
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
|
|
3
|
+
import { internal } from "./_generated/api";
|
|
4
|
+
import { internalMutation, internalQuery } from "./_generated/server";
|
|
5
|
+
|
|
6
|
+
const CLEANUP_BATCH = 200;
|
|
7
|
+
|
|
8
|
+
export const createChallenge = internalMutation({
|
|
9
|
+
args: { nonce: v.string(), expiresAt: v.number() },
|
|
10
|
+
returns: v.id("appAttestChallenges"),
|
|
11
|
+
handler: async (ctx, args) => {
|
|
12
|
+
return ctx.db.insert("appAttestChallenges", {
|
|
13
|
+
nonce: args.nonce,
|
|
14
|
+
expiresAt: args.expiresAt,
|
|
15
|
+
used: false,
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Single-use challenge consumption. Returns true if the nonce existed,
|
|
22
|
+
* hadn't been used, and hadn't expired. Marks it consumed atomically
|
|
23
|
+
* inside the mutation so a concurrent attempt can't double-spend it.
|
|
24
|
+
*/
|
|
25
|
+
export const consumeChallenge = internalMutation({
|
|
26
|
+
args: { nonce: v.string(), now: v.number() },
|
|
27
|
+
returns: v.boolean(),
|
|
28
|
+
handler: async (ctx, args) => {
|
|
29
|
+
const row = await ctx.db
|
|
30
|
+
.query("appAttestChallenges")
|
|
31
|
+
.withIndex("by_nonce", (q) => q.eq("nonce", args.nonce))
|
|
32
|
+
.unique();
|
|
33
|
+
if (!row) return false;
|
|
34
|
+
if (row.used) return false;
|
|
35
|
+
if (row.expiresAt < args.now) return false;
|
|
36
|
+
await ctx.db.patch(row._id, { used: true });
|
|
37
|
+
return true;
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const storeKey = internalMutation({
|
|
42
|
+
args: {
|
|
43
|
+
keyId: v.string(),
|
|
44
|
+
publicKey: v.string(),
|
|
45
|
+
environment: v.union(v.literal("development"), v.literal("production")),
|
|
46
|
+
userId: v.optional(v.id("users")),
|
|
47
|
+
now: v.number(),
|
|
48
|
+
},
|
|
49
|
+
returns: v.id("appAttestKeys"),
|
|
50
|
+
handler: async (ctx, args) => {
|
|
51
|
+
const existing = await ctx.db
|
|
52
|
+
.query("appAttestKeys")
|
|
53
|
+
.withIndex("by_keyId", (q) => q.eq("keyId", args.keyId))
|
|
54
|
+
.unique();
|
|
55
|
+
if (existing) {
|
|
56
|
+
await ctx.db.patch(existing._id, {
|
|
57
|
+
publicKey: args.publicKey,
|
|
58
|
+
environment: args.environment,
|
|
59
|
+
userId: args.userId ?? existing.userId,
|
|
60
|
+
counter: 0,
|
|
61
|
+
attestedAt: args.now,
|
|
62
|
+
});
|
|
63
|
+
return existing._id;
|
|
64
|
+
}
|
|
65
|
+
return ctx.db.insert("appAttestKeys", {
|
|
66
|
+
keyId: args.keyId,
|
|
67
|
+
publicKey: args.publicKey,
|
|
68
|
+
environment: args.environment,
|
|
69
|
+
userId: args.userId,
|
|
70
|
+
counter: 0,
|
|
71
|
+
attestedAt: args.now,
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export const findKey = internalQuery({
|
|
77
|
+
args: { keyId: v.string() },
|
|
78
|
+
returns: v.union(
|
|
79
|
+
v.object({
|
|
80
|
+
keyId: v.string(),
|
|
81
|
+
publicKey: v.string(),
|
|
82
|
+
counter: v.number(),
|
|
83
|
+
environment: v.union(v.literal("development"), v.literal("production")),
|
|
84
|
+
userId: v.optional(v.id("users")),
|
|
85
|
+
}),
|
|
86
|
+
v.null(),
|
|
87
|
+
),
|
|
88
|
+
handler: async (ctx, { keyId }) => {
|
|
89
|
+
const row = await ctx.db
|
|
90
|
+
.query("appAttestKeys")
|
|
91
|
+
.withIndex("by_keyId", (q) => q.eq("keyId", keyId))
|
|
92
|
+
.unique();
|
|
93
|
+
if (!row) return null;
|
|
94
|
+
return {
|
|
95
|
+
keyId: row.keyId,
|
|
96
|
+
publicKey: row.publicKey,
|
|
97
|
+
counter: row.counter,
|
|
98
|
+
environment: row.environment,
|
|
99
|
+
userId: row.userId,
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export const bumpCounter = internalMutation({
|
|
105
|
+
args: { keyId: v.string(), counter: v.number() },
|
|
106
|
+
returns: v.null(),
|
|
107
|
+
handler: async (ctx, { keyId, counter }) => {
|
|
108
|
+
const row = await ctx.db
|
|
109
|
+
.query("appAttestKeys")
|
|
110
|
+
.withIndex("by_keyId", (q) => q.eq("keyId", keyId))
|
|
111
|
+
.unique();
|
|
112
|
+
if (!row) throw new Error("app-attest: bumpCounter against unknown keyId");
|
|
113
|
+
// Re-check monotonicity inside the mutation transaction so two
|
|
114
|
+
// assertions racing through different action invocations can't
|
|
115
|
+
// both win.
|
|
116
|
+
if (counter <= row.counter) {
|
|
117
|
+
throw new Error("app-attest: assertion counter regressed");
|
|
118
|
+
}
|
|
119
|
+
await ctx.db.patch(row._id, { counter });
|
|
120
|
+
return null;
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export const cleanupChallenges = internalMutation({
|
|
125
|
+
args: {},
|
|
126
|
+
returns: v.number(),
|
|
127
|
+
handler: async (ctx) => {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
const batch = await ctx.db
|
|
130
|
+
.query("appAttestChallenges")
|
|
131
|
+
.withIndex("by_expiresAt")
|
|
132
|
+
.order("asc")
|
|
133
|
+
.take(CLEANUP_BATCH);
|
|
134
|
+
const expired = batch.filter((r) => r.expiresAt < now);
|
|
135
|
+
await Promise.all(expired.map((r) => ctx.db.delete(r._id)));
|
|
136
|
+
if (batch.length === CLEANUP_BATCH && expired.length > 0) {
|
|
137
|
+
await ctx.scheduler.runAfter(0, internal.appAttestStore.cleanupChallenges, {});
|
|
138
|
+
}
|
|
139
|
+
return expired.length;
|
|
140
|
+
},
|
|
141
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
|
|
3
|
+
import { internalAction } from "./_generated/server";
|
|
4
|
+
|
|
5
|
+
const REVOKE_URL = "https://appleid.apple.com/auth/revoke";
|
|
6
|
+
|
|
7
|
+
// Revokes a Sign in with Apple refresh token via Apple's REST API. Scheduled
|
|
8
|
+
// from `users.deleteAccount` so app authorization is removed from Apple's
|
|
9
|
+
// side, per Apple's account-deletion requirement:
|
|
10
|
+
//
|
|
11
|
+
// "If people used Sign in with Apple to create an account within your app,
|
|
12
|
+
// you revoke the associated tokens when they delete their account."
|
|
13
|
+
// developer.apple.com/documentation/SigninwithAppleRESTAPI/Revoke-tokens
|
|
14
|
+
//
|
|
15
|
+
// `APPLE_CLIENT_ID` is the SIWA Services ID and `APPLE_CLIENT_SECRET` is the
|
|
16
|
+
// ES256 JWT that the `rotate-apple-jwt` EAS workflow refreshes every 90 days
|
|
17
|
+
// (180-day Apple cap). Best-effort: a failed revoke logs and returns rather
|
|
18
|
+
// than throwing, because the user has already confirmed account deletion and
|
|
19
|
+
// the local rows are about to be wiped anyway.
|
|
20
|
+
export const revokeRefreshToken = internalAction({
|
|
21
|
+
args: { refreshToken: v.string() },
|
|
22
|
+
returns: v.null(),
|
|
23
|
+
handler: async (_ctx, { refreshToken }) => {
|
|
24
|
+
const clientId = process.env.APPLE_CLIENT_ID;
|
|
25
|
+
const clientSecret = process.env.APPLE_CLIENT_SECRET;
|
|
26
|
+
if (!clientId || !clientSecret) {
|
|
27
|
+
console.warn("[apple] revoke skipped: APPLE_CLIENT_ID or APPLE_CLIENT_SECRET unset");
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const body = new URLSearchParams({
|
|
32
|
+
client_id: clientId,
|
|
33
|
+
client_secret: clientSecret,
|
|
34
|
+
token: refreshToken,
|
|
35
|
+
token_type_hint: "refresh_token",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(REVOKE_URL, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
42
|
+
body: body.toString(),
|
|
43
|
+
});
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const text = await res.text();
|
|
46
|
+
console.warn(`[apple] revoke failed ${res.status}: ${text}`);
|
|
47
|
+
}
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.warn(`[apple] revoke threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
},
|
|
53
|
+
});
|