voxa-code 0.1.0__py3-none-any.whl

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.
server/appattest.py ADDED
@@ -0,0 +1,310 @@
1
+ """Verify Apple App Attest attestations and assertions (real cryptography).
2
+
3
+ App Attest binds an anonymous device account to a key generated inside the
4
+ device's Secure Enclave. The flow, per Apple's "Validating Apps That Connect to
5
+ Your Server":
6
+
7
+ * ATTESTATION (once, at registration): the app calls
8
+ `DCAppAttestService.attestKey` with a server-issued challenge and sends us the
9
+ CBOR attestation object. We verify the embedded X.509 chain roots to Apple's
10
+ App Attest root, that the challenge-derived nonce matches the one Apple signed
11
+ into the leaf certificate, that the key id equals the SHA-256 of the attested
12
+ public key, and that the relying-party id hash matches our app id. On success
13
+ we trust and store the device's public key.
14
+
15
+ * ASSERTION (per request, later): the app signs a challenge/payload with the
16
+ same key; we verify the ECDSA signature with the stored public key and that
17
+ the signature counter strictly increases (replay guard).
18
+
19
+ Everything fails CLOSED: any missing field, malformed structure, or mismatch
20
+ returns None. We never trust a leaf signature without rooting the chain to the
21
+ bundled Apple App Attest root (an attacker controls the leaf otherwise).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ import logging
28
+ import os
29
+
30
+ import cbor2
31
+ from cryptography import x509
32
+ from cryptography.exceptions import InvalidSignature
33
+ from cryptography.hazmat.primitives import hashes, serialization
34
+ from cryptography.hazmat.primitives.asymmetric import ec, padding
35
+ from cryptography.x509.oid import ObjectIdentifier
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # Apple's App Attest root, shipped so verification works out of the box. Override
40
+ # with VOXA_APPATTEST_ROOT (e.g. tests point it at a synthetic root).
41
+ _BUNDLED_ROOT = os.path.join(
42
+ os.path.dirname(__file__), "certs", "Apple_App_Attestation_Root_CA.pem"
43
+ )
44
+
45
+ # OID Apple stamps the attestation nonce into on the leaf certificate.
46
+ _NONCE_OID = ObjectIdentifier("1.2.840.113635.100.8.2")
47
+
48
+ # aaguid values Apple sets in authData. Production keys use "appattest" + NULs;
49
+ # development keys use the literal "appattestdevelop".
50
+ _AAGUID_PROD = b"appattest\x00\x00\x00\x00\x00\x00\x00"
51
+ _AAGUID_DEV = b"appattestdevelop"
52
+
53
+
54
+ def _root_path() -> str:
55
+ return os.environ.get("VOXA_APPATTEST_ROOT", "").strip() or _BUNDLED_ROOT
56
+
57
+
58
+ def _der_len(data: bytes, i: int) -> tuple[int, int]:
59
+ """Read a DER length starting at index i; return (length, index_after_length)."""
60
+ first = data[i]
61
+ i += 1
62
+ if first < 0x80:
63
+ return first, i
64
+ n = first & 0x7F
65
+ length = int.from_bytes(data[i : i + n], "big")
66
+ return length, i + n
67
+
68
+
69
+ def _extract_attest_nonce(der: bytes) -> bytes | None:
70
+ """Pull the nonce octet string out of the leaf's App Attest extension.
71
+
72
+ The extension value is DER: SEQUENCE { [1] EXPLICIT OCTET STRING nonce }.
73
+ """
74
+ try:
75
+ i = 0
76
+ if der[i] != 0x30: # SEQUENCE
77
+ return None
78
+ i += 1
79
+ _, i = _der_len(der, i)
80
+ if der[i] != 0xA1: # [1] context-specific, constructed
81
+ return None
82
+ i += 1
83
+ _, i = _der_len(der, i)
84
+ if der[i] != 0x04: # OCTET STRING
85
+ return None
86
+ i += 1
87
+ ln, i = _der_len(der, i)
88
+ return der[i : i + ln]
89
+ except Exception:
90
+ return None
91
+
92
+
93
+ def _verify_chain(chain: list[x509.Certificate]) -> bool:
94
+ """Verify the presented cert chain (leaf -> ... -> top) roots to the pinned
95
+ Apple App Attest root. Every link's signature must check out AND the top of
96
+ the chain must be, or be signed by, our trusted root. Fails closed."""
97
+ root_path = _root_path()
98
+ if not os.path.exists(root_path):
99
+ logger.error("App Attest root cert not found at %s; refusing attestation", root_path)
100
+ return False
101
+ if not chain:
102
+ return False
103
+ try:
104
+ with open(root_path, "rb") as f:
105
+ root = x509.load_pem_x509_certificate(f.read())
106
+
107
+ def signed_by(parent: x509.Certificate, child: x509.Certificate) -> bool:
108
+ pub = parent.public_key()
109
+ try:
110
+ if isinstance(pub, ec.EllipticCurvePublicKey):
111
+ pub.verify(child.signature, child.tbs_certificate_bytes,
112
+ ec.ECDSA(child.signature_hash_algorithm))
113
+ else:
114
+ pub.verify(child.signature, child.tbs_certificate_bytes,
115
+ padding.PKCS1v15(), child.signature_hash_algorithm)
116
+ return True
117
+ except Exception:
118
+ return False
119
+
120
+ for child, parent in zip(chain, chain[1:]):
121
+ if not signed_by(parent, child):
122
+ return False
123
+
124
+ top = chain[-1]
125
+ if top.fingerprint(hashes.SHA256()) == root.fingerprint(hashes.SHA256()):
126
+ return True
127
+ return signed_by(root, top)
128
+ except Exception:
129
+ logger.warning("App Attest chain verification error", exc_info=True)
130
+ return False
131
+
132
+
133
+ def _cose_ec_public_key(cose: bytes):
134
+ """Parse a COSE_Key (EC2/P-256) and return (cryptography EC public key, raw
135
+ uncompressed point 0x04||x||y). Returns None on any mismatch."""
136
+ try:
137
+ key = cbor2.loads(cose)
138
+ if not isinstance(key, dict):
139
+ return None
140
+ if key.get(1) != 2: # kty must be EC2
141
+ return None
142
+ if key.get(-1) != 1: # crv must be P-256
143
+ return None
144
+ x = key.get(-2)
145
+ y = key.get(-3)
146
+ if not isinstance(x, (bytes, bytearray)) or not isinstance(y, (bytes, bytearray)):
147
+ return None
148
+ if len(x) != 32 or len(y) != 32:
149
+ return None
150
+ numbers = ec.EllipticCurvePublicNumbers(
151
+ int.from_bytes(x, "big"), int.from_bytes(y, "big"), ec.SECP256R1())
152
+ pub = numbers.public_key()
153
+ raw = b"\x04" + bytes(x) + bytes(y)
154
+ return pub, raw
155
+ except Exception:
156
+ return None
157
+
158
+
159
+ def verify_attestation(attestation: bytes, key_id: bytes, challenge: bytes,
160
+ app_id: str, *, allow_dev: bool = True) -> dict | None:
161
+ """Verify an App Attest attestation object.
162
+
163
+ Returns {"public_key_pem": <PEM str>, "counter": int, "receipt": bytes|None}
164
+ on success, else None (fails closed on any mismatch).
165
+ """
166
+ try:
167
+ obj = cbor2.loads(attestation)
168
+ except Exception:
169
+ logger.warning("App Attest: attestation is not valid CBOR")
170
+ return None
171
+ if not isinstance(obj, dict) or obj.get("fmt") != "apple-appattest":
172
+ logger.warning("App Attest: unexpected fmt")
173
+ return None
174
+ att_stmt = obj.get("attStmt")
175
+ auth_data = obj.get("authData")
176
+ if not isinstance(att_stmt, dict) or not isinstance(auth_data, (bytes, bytearray)):
177
+ return None
178
+ auth_data = bytes(auth_data)
179
+ x5c = att_stmt.get("x5c")
180
+ if not isinstance(x5c, list) or len(x5c) < 1:
181
+ return None
182
+
183
+ # (1) Build + verify the certificate chain to Apple's root.
184
+ try:
185
+ chain = [x509.load_der_x509_certificate(bytes(c)) for c in x5c]
186
+ except Exception:
187
+ logger.warning("App Attest: could not parse x5c chain")
188
+ return None
189
+ if not _verify_chain(chain):
190
+ logger.warning("App Attest: chain did not root to trusted Apple root; rejecting")
191
+ return None
192
+ cred_cert = chain[0]
193
+
194
+ # (2) nonce = SHA256(authData || SHA256(challenge)).
195
+ client_data_hash = hashlib.sha256(bytes(challenge)).digest()
196
+ nonce = hashlib.sha256(auth_data + client_data_hash).digest()
197
+
198
+ # (3) The leaf must carry exactly this nonce in Apple's extension.
199
+ try:
200
+ ext = cred_cert.extensions.get_extension_for_oid(_NONCE_OID)
201
+ cert_nonce = _extract_attest_nonce(ext.value.value)
202
+ except Exception:
203
+ logger.warning("App Attest: leaf missing nonce extension")
204
+ return None
205
+ if cert_nonce is None or cert_nonce != nonce:
206
+ logger.warning("App Attest: nonce mismatch (wrong challenge or forged attestation)")
207
+ return None
208
+
209
+ # (4) Parse authData.
210
+ if len(auth_data) < 37:
211
+ return None
212
+ rp_id_hash = auth_data[0:32]
213
+ if rp_id_hash != hashlib.sha256(app_id.encode()).digest():
214
+ logger.warning("App Attest: rpIdHash != sha256(app_id)")
215
+ return None
216
+ # flags = auth_data[32] (unused for attestation beyond presence)
217
+ sign_count = int.from_bytes(auth_data[33:37], "big")
218
+ # Attested credential data follows: aaguid(16) credIdLen(2) credId COSEKey.
219
+ if len(auth_data) < 37 + 16 + 2:
220
+ return None
221
+ aaguid = auth_data[37:53]
222
+ if aaguid == _AAGUID_PROD:
223
+ pass
224
+ elif aaguid == _AAGUID_DEV:
225
+ if not allow_dev:
226
+ logger.warning("App Attest: development aaguid rejected (allow_dev off)")
227
+ return None
228
+ else:
229
+ logger.warning("App Attest: unexpected aaguid")
230
+ return None
231
+ cred_id_len = int.from_bytes(auth_data[53:55], "big")
232
+ cred_id_start = 55
233
+ cred_id_end = cred_id_start + cred_id_len
234
+ if len(auth_data) < cred_id_end:
235
+ return None
236
+ cred_id = auth_data[cred_id_start:cred_id_end]
237
+ if cred_id != bytes(key_id):
238
+ logger.warning("App Attest: credId != key_id")
239
+ return None
240
+ cose = auth_data[cred_id_end:]
241
+
242
+ # (5) Extract the P-256 public key and confirm key_id == sha256(raw pubkey).
243
+ parsed = _cose_ec_public_key(cose)
244
+ if parsed is None:
245
+ logger.warning("App Attest: bad COSE public key")
246
+ return None
247
+ pub, raw = parsed
248
+ if hashlib.sha256(raw).digest() != bytes(key_id):
249
+ logger.warning("App Attest: key_id != sha256(public key)")
250
+ return None
251
+
252
+ pem = pub.public_bytes(
253
+ encoding=serialization.Encoding.PEM,
254
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
255
+ ).decode()
256
+ return {
257
+ "public_key_pem": pem,
258
+ "counter": sign_count,
259
+ "receipt": att_stmt.get("receipt"),
260
+ }
261
+
262
+
263
+ def verify_assertion(assertion: bytes, client_data: bytes, public_key_pem: str,
264
+ app_id: str, prev_counter: int) -> int | None:
265
+ """Verify an App Attest assertion signed by a previously attested key.
266
+
267
+ Returns the new (strictly increasing) signature counter, or None if the
268
+ signature is invalid, the rpIdHash is wrong, or the counter did not advance.
269
+ """
270
+ try:
271
+ obj = cbor2.loads(assertion)
272
+ except Exception:
273
+ return None
274
+ if not isinstance(obj, dict):
275
+ return None
276
+ signature = obj.get("signature")
277
+ authenticator_data = obj.get("authenticatorData")
278
+ if not isinstance(signature, (bytes, bytearray)) or \
279
+ not isinstance(authenticator_data, (bytes, bytearray)):
280
+ return None
281
+ signature = bytes(signature)
282
+ authenticator_data = bytes(authenticator_data)
283
+
284
+ # nonce = SHA256(authenticatorData || SHA256(clientData)); the ECDSA signature
285
+ # is over that nonce. Verifying over the concatenation with ES256 is identical
286
+ # (ES256 applies SHA256 first), so let cryptography hash it.
287
+ client_data_hash = hashlib.sha256(bytes(client_data)).digest()
288
+ try:
289
+ pub = serialization.load_pem_public_key(public_key_pem.encode())
290
+ except Exception:
291
+ return None
292
+ if not isinstance(pub, ec.EllipticCurvePublicKey):
293
+ return None
294
+ try:
295
+ pub.verify(signature, authenticator_data + client_data_hash,
296
+ ec.ECDSA(hashes.SHA256()))
297
+ except InvalidSignature:
298
+ return None
299
+ except Exception:
300
+ return None
301
+
302
+ if len(authenticator_data) < 37:
303
+ return None
304
+ rp_id_hash = authenticator_data[0:32]
305
+ if rp_id_hash != hashlib.sha256(app_id.encode()).digest():
306
+ return None
307
+ sign_count = int.from_bytes(authenticator_data[33:37], "big")
308
+ if sign_count <= prev_counter: # replay / non-advancing counter
309
+ return None
310
+ return sign_count
server/appstore.py ADDED
@@ -0,0 +1,141 @@
1
+ """Verify Apple StoreKit 2 signed transactions (JWS).
2
+
3
+ A StoreKit 2 transaction is a JWS whose protected header carries the signing
4
+ certificate chain in `x5c` (leaf -> intermediate -> Apple root). Verification:
5
+ 1. take the leaf cert from x5c, use its public key to check the JWS signature,
6
+ 2. confirm the x5c chain roots to Apple's real root CA (MANDATORY),
7
+ 3. read the payload (productId, transactionId, bundleId, expiresDate).
8
+
9
+ Step 2 is mandatory and fails closed: the leaf cert travels inside the token, so
10
+ leaf-signature-only verification is trivially forgeable (an attacker signs with
11
+ their own key + cert). We ship Apple's public root ("Apple Root CA - G3") in
12
+ `certs/AppleRootCA-G3.pem` and verify the presented chain against it. Override
13
+ with APPLE_ROOT_CERT only to point at a different trusted root (e.g. tests).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import json
20
+ import logging
21
+ import os
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Apple's public StoreKit root, shipped with the package so verification works
26
+ # out of the box (SHA-256 63:34:3A:BF:...:91:79). APPLE_ROOT_CERT overrides it.
27
+ _BUNDLED_ROOT = os.path.join(os.path.dirname(__file__), "certs", "AppleRootCA-G3.pem")
28
+
29
+
30
+ def _root_path() -> str:
31
+ return os.environ.get("APPLE_ROOT_CERT", "").strip() or _BUNDLED_ROOT
32
+
33
+
34
+ def _b64url_json(segment: str) -> dict:
35
+ pad = "=" * (-len(segment) % 4)
36
+ return json.loads(base64.urlsafe_b64decode(segment + pad))
37
+
38
+
39
+ def decode_unverified(jws: str) -> dict:
40
+ """Decode the payload without checking the signature (never trust alone)."""
41
+ try:
42
+ return _b64url_json(jws.split(".")[1])
43
+ except Exception:
44
+ return {}
45
+
46
+
47
+ def _leaf_public_key_pem(jws: str):
48
+ header = _b64url_json(jws.split(".")[0])
49
+ x5c = header.get("x5c") or []
50
+ if not x5c:
51
+ return None
52
+ from cryptography import x509
53
+ der = base64.b64decode(x5c[0])
54
+ cert = x509.load_der_x509_certificate(der)
55
+ from cryptography.hazmat.primitives import serialization
56
+ return cert.public_key().public_bytes(
57
+ encoding=serialization.Encoding.PEM,
58
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
59
+ )
60
+
61
+
62
+ def verify_transaction(jws: str, bundle_id: str | None = None) -> dict | None:
63
+ """Return the verified transaction payload, or None if invalid.
64
+
65
+ Verifies the JWS signature with the leaf certificate embedded in the token AND
66
+ that the embedded chain roots to Apple's trusted root. Both must pass.
67
+ """
68
+ import jwt # PyJWT
69
+
70
+ pem = _leaf_public_key_pem(jws)
71
+ if pem is None:
72
+ logger.warning("StoreKit transaction has no x5c leaf cert")
73
+ return None
74
+ try:
75
+ payload = jwt.decode(jws, pem, algorithms=["ES256"], options={"verify_aud": False})
76
+ except Exception as e:
77
+ logger.warning("StoreKit JWS signature invalid: %s", e)
78
+ return None
79
+
80
+ if bundle_id and payload.get("bundleId") not in (None, bundle_id):
81
+ logger.warning("StoreKit bundleId mismatch: %s != %s", payload.get("bundleId"), bundle_id)
82
+ return None
83
+
84
+ # MANDATORY chain check. The leaf signature above only proves the token was
85
+ # signed by whoever's cert is in x5c[0] — which the sender controls — so without
86
+ # rooting the chain to Apple, anyone can forge a valid-looking transaction.
87
+ if not _verify_chain(jws):
88
+ logger.warning("StoreKit cert chain did not verify to Apple's root; rejecting transaction")
89
+ return None
90
+
91
+ return payload
92
+
93
+
94
+ def _verify_chain(jws: str) -> bool:
95
+ """Verify the x5c chain (leaf -> intermediate -> Apple root) against the trusted
96
+ root. Returns True only when every link's signature checks out AND the top of the
97
+ presented chain is (or is signed by) our pinned Apple root."""
98
+ root_path = _root_path()
99
+ if not os.path.exists(root_path):
100
+ logger.error("Apple root cert not found at %s; refusing to verify purchases", root_path)
101
+ return False
102
+ try:
103
+ from cryptography import x509
104
+ from cryptography.hazmat.primitives import hashes
105
+ from cryptography.hazmat.primitives.asymmetric import ec, padding
106
+
107
+ header = _b64url_json(jws.split(".")[0])
108
+ x5c = header.get("x5c") or []
109
+ if not x5c:
110
+ return False
111
+ chain = [x509.load_der_x509_certificate(base64.b64decode(c)) for c in x5c]
112
+ with open(root_path, "rb") as f:
113
+ root = x509.load_pem_x509_certificate(f.read())
114
+
115
+ def signed_by(parent, child) -> bool:
116
+ pub = parent.public_key()
117
+ try:
118
+ if isinstance(pub, ec.EllipticCurvePublicKey):
119
+ pub.verify(child.signature, child.tbs_certificate_bytes,
120
+ ec.ECDSA(child.signature_hash_algorithm))
121
+ else:
122
+ pub.verify(child.signature, child.tbs_certificate_bytes,
123
+ padding.PKCS1v15(), child.signature_hash_algorithm)
124
+ return True
125
+ except Exception:
126
+ return False
127
+
128
+ # Every presented link must verify: leaf<-intermediate<-...<-top.
129
+ for child, parent in zip(chain, chain[1:]):
130
+ if not signed_by(parent, child):
131
+ return False
132
+
133
+ # The top of the presented chain must anchor to OUR pinned Apple root:
134
+ # either it IS the root (chain includes it), or the root signed it.
135
+ top = chain[-1]
136
+ if top.fingerprint(hashes.SHA256()) == root.fingerprint(hashes.SHA256()):
137
+ return True
138
+ return signed_by(root, top)
139
+ except Exception:
140
+ logger.warning("StoreKit chain verification error", exc_info=True)
141
+ return False
@@ -0,0 +1,60 @@
1
+ """Store of App-Attested device keys.
2
+
3
+ JSON-backed (like UserStore/Billing). Maps a key id (hex of the Secure Enclave
4
+ key's id) to the account it attests, the stored P-256 public key (PEM), and the
5
+ last-seen signature counter (for the assertion replay guard). Once a key is
6
+ bound to a `d-<uuid>` account, that account is considered attested: it may mint a
7
+ free trial and open metered sessions when attestation is required.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import threading
15
+
16
+
17
+ class AttestedStore:
18
+ def __init__(self, path: str | None = None):
19
+ self._path = path or os.environ.get("VOXA_ATTESTED_FILE", "attested.json")
20
+ self._lock = threading.Lock()
21
+ self._data: dict = self._load()
22
+
23
+ def _load(self) -> dict:
24
+ try:
25
+ with open(self._path) as f:
26
+ return json.load(f)
27
+ except (OSError, ValueError):
28
+ return {}
29
+
30
+ def _save(self) -> None:
31
+ tmp = f"{self._path}.tmp"
32
+ with open(tmp, "w") as f:
33
+ json.dump(self._data, f)
34
+ os.replace(tmp, self._path)
35
+
36
+ def get(self, key_id: str) -> dict | None:
37
+ with self._lock:
38
+ rec = self._data.get(key_id)
39
+ return dict(rec) if rec else None
40
+
41
+ def put(self, key_id: str, account: str, public_key_pem: str, counter: int) -> None:
42
+ with self._lock:
43
+ self._data[key_id] = {
44
+ "account": account,
45
+ "public_key_pem": public_key_pem,
46
+ "counter": int(counter),
47
+ }
48
+ self._save()
49
+
50
+ def update_counter(self, key_id: str, counter: int) -> None:
51
+ with self._lock:
52
+ rec = self._data.get(key_id)
53
+ if rec is not None:
54
+ rec["counter"] = int(counter)
55
+ self._save()
56
+
57
+ def account_is_attested(self, account: str) -> bool:
58
+ """True if any stored key is bound to this account."""
59
+ with self._lock:
60
+ return any(rec.get("account") == account for rec in self._data.values())
server/auth.py ADDED
@@ -0,0 +1,70 @@
1
+ """Sign in with Apple verification + auth routes for Voxa Cloud."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import jwt # PyJWT
6
+ from fastapi import FastAPI, Request
7
+ from fastapi.responses import JSONResponse
8
+ from jwt import PyJWKClient
9
+
10
+ from server.users import UserStore, issue_token, verify_token
11
+
12
+ APPLE_ISS = "https://appleid.apple.com"
13
+ APPLE_JWKS_URL = "https://appleid.apple.com/auth/keys"
14
+
15
+
16
+ def verify_apple_identity_token(identity_token: str, bundle_id: str,
17
+ jwks_url: str = APPLE_JWKS_URL) -> dict | None:
18
+ """Verify an Apple identity token (RS256, signed by Apple). Returns the claims
19
+ (sub, optional email) or None. iss must be Apple; aud must be our bundle id."""
20
+ try:
21
+ key = PyJWKClient(jwks_url).get_signing_key_from_jwt(identity_token)
22
+ return jwt.decode(identity_token, key.key, algorithms=["RS256"],
23
+ audience=bundle_id, issuer=APPLE_ISS)
24
+ except Exception:
25
+ return None
26
+
27
+
28
+ def bearer_user(request: Request, secret: str) -> str | None:
29
+ """Return the user_id from a verified `Authorization: Bearer <token>` header."""
30
+ header = request.headers.get("authorization", "")
31
+ if not header.lower().startswith("bearer "):
32
+ return None
33
+ return verify_token(header[7:].strip(), secret)
34
+
35
+
36
+ def add_auth_routes(app: FastAPI, users: UserStore, *, secret: str, bundle_id: str,
37
+ apple_verifier=verify_apple_identity_token,
38
+ billing=None) -> None:
39
+ @app.post("/auth/apple")
40
+ async def auth_apple(request: Request):
41
+ body = await request.json()
42
+ token = (body or {}).get("identity_token", "")
43
+ if not token:
44
+ return JSONResponse({"error": "missing identity_token"}, status_code=400)
45
+ claims = apple_verifier(token, bundle_id)
46
+ if not claims or not claims.get("sub"):
47
+ return JSONResponse({"error": "invalid identity token"}, status_code=401)
48
+ email = (body or {}).get("email") or claims.get("email")
49
+ uid = users.find_or_create_apple_user(claims["sub"], email)
50
+ return {"token": issue_token(uid, secret), "user_id": uid}
51
+
52
+ @app.get("/auth/me")
53
+ async def auth_me(request: Request):
54
+ uid = bearer_user(request, secret)
55
+ if not uid:
56
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
57
+ user = users.get_user(uid) or {}
58
+ return {"user_id": uid, "email": user.get("email")}
59
+
60
+ @app.delete("/auth/account")
61
+ async def delete_account(request: Request):
62
+ """Permanently delete the signed-in account and its data (App Review
63
+ Guideline 5.1.1(v)). Idempotent: succeeds even if already gone."""
64
+ uid = bearer_user(request, secret)
65
+ if not uid:
66
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
67
+ users.delete_user(uid)
68
+ if billing is not None:
69
+ billing.delete_account(uid) # drop the account's minute balance/ledger
70
+ return {"ok": True}