@marktiderman/genesis-supabase 0.2.0 → 0.3.0
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 +367 -23
- package/dist/blocks.d.ts +19 -0
- package/dist/blocks.js +3 -0
- package/dist/blocks.js.map +1 -0
- package/dist/chunk-FAA2FTF4.js +110 -0
- package/dist/chunk-FAA2FTF4.js.map +1 -0
- package/dist/chunk-T2HGFC4C.js +37 -0
- package/dist/chunk-T2HGFC4C.js.map +1 -0
- package/dist/index-BSGz3RGk.d.ts +317 -0
- package/dist/index.d.ts +4 -103
- package/dist/index.js +60 -3
- package/dist/index.js.map +1 -1
- package/dist/native.d.ts +90 -6
- package/dist/native.js +70 -2
- package/dist/native.js.map +1 -1
- package/functions/genesis-apple-revoke/apple.ts +237 -0
- package/functions/genesis-apple-revoke/handler.ts +227 -0
- package/functions/genesis-apple-revoke/index.ts +55 -0
- package/functions/genesis-push-send/expo.ts +117 -0
- package/functions/genesis-push-send/handler.ts +462 -0
- package/functions/genesis-push-send/index.ts +54 -0
- package/functions/genesis-support-cleanup/handler.ts +80 -0
- package/functions/genesis-support-cleanup/index.ts +39 -0
- package/functions/genesis-support-intake/contract.ts +193 -0
- package/functions/genesis-support-intake/handler.ts +170 -0
- package/functions/genesis-support-intake/index.ts +47 -0
- package/functions/genesis-support-notify/handler.ts +167 -0
- package/functions/genesis-support-notify/index.ts +15 -0
- package/functions/tests/genesis-apple-revoke_test.ts +81 -0
- package/functions/tests/genesis-push-send_test.ts +53 -0
- package/migrations/0003_genesis_apple_token_revocation.sql +205 -0
- package/migrations/0004_genesis_tenant_features.sql +333 -0
- package/migrations/0005_genesis_push.sql +524 -0
- package/migrations/0006_genesis_support.sql +846 -0
- package/migrations/tests/0003_genesis_apple_token_revocation.test.sql +230 -0
- package/migrations/tests/0004_genesis_tenant_features.test.sql +418 -0
- package/migrations/tests/0005_genesis_push.test.sql +407 -0
- package/migrations/tests/0006_genesis_support.test.sql +552 -0
- package/package.json +91 -4
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign in with Apple's REST API, for the genesis-apple-revoke Edge Function:
|
|
3
|
+
* the client secret (an ES256 JWT signed with your .p8 key), the
|
|
4
|
+
* authorization-code exchange (/auth/token) and revocation (/auth/revoke).
|
|
5
|
+
*
|
|
6
|
+
* Plain TypeScript over WebCrypto and fetch, with no Deno or Node APIs, so the
|
|
7
|
+
* same file runs in the Edge Function and in the package's unit tests.
|
|
8
|
+
* Nothing here logs or returns a token, a key or a client secret.
|
|
9
|
+
*
|
|
10
|
+
* Apple's docs: developer.apple.com/documentation/signinwithapplerestapi
|
|
11
|
+
* (generate-and-validate-tokens, revoke-tokens) and
|
|
12
|
+
* developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const APPLE_ISSUER = "https://appleid.apple.com";
|
|
16
|
+
export const APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token";
|
|
17
|
+
export const APPLE_REVOKE_URL = "https://appleid.apple.com/auth/revoke";
|
|
18
|
+
|
|
19
|
+
/** Apple caps a client secret's lifetime at 15777000 seconds (six months). Each call signs a fresh one, so five minutes is plenty. */
|
|
20
|
+
export const CLIENT_SECRET_TTL_SECONDS = 300;
|
|
21
|
+
const CLIENT_SECRET_MAX_TTL_SECONDS = 15_777_000;
|
|
22
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
23
|
+
const CLOCK_SKEW_SECONDS = 30;
|
|
24
|
+
|
|
25
|
+
/** Apple's error codes (ErrorResponse.error). Anything else reads as "unknown". */
|
|
26
|
+
const APPLE_ERRORS = ["invalid_request", "invalid_client", "invalid_grant", "unauthorized_client", "unsupported_grant_type", "invalid_scope"] as const;
|
|
27
|
+
export type AppleErrorCode = (typeof APPLE_ERRORS)[number] | "unknown" | "network" | "bad_response";
|
|
28
|
+
|
|
29
|
+
export interface AppleConfig {
|
|
30
|
+
/** APPLE_TEAM_ID: your 10-character Team ID (the secret's `iss`). */
|
|
31
|
+
teamId: string;
|
|
32
|
+
/** APPLE_KEY_ID: the 10-character id of your Sign in with Apple key (the secret's `kid`). */
|
|
33
|
+
keyId: string;
|
|
34
|
+
/** APPLE_PRIVATE_KEY: the .p8 file's contents (PKCS#8 PEM). Escaped `\n` are accepted. */
|
|
35
|
+
privateKey: string;
|
|
36
|
+
/** APPLE_CLIENT_IDS: the bundle ids (App IDs) or Services IDs a code may be exchanged for, comma-separated. The first is the default. */
|
|
37
|
+
clientIds: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const TEN_CHARS = /^[A-Z0-9]{10}$/;
|
|
41
|
+
const CLIENT_ID = /^[A-Za-z0-9][A-Za-z0-9.-]{0,254}$/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reads the Apple settings from the environment. Returns the names that are
|
|
45
|
+
* missing or malformed (never their values), so the function can fail loud.
|
|
46
|
+
*/
|
|
47
|
+
export function readAppleConfig(env: (name: string) => string | undefined): { config: AppleConfig; problems?: undefined } | { config?: undefined; problems: string[] } {
|
|
48
|
+
const problems: string[] = [];
|
|
49
|
+
const teamId = (env("APPLE_TEAM_ID") ?? "").trim();
|
|
50
|
+
const keyId = (env("APPLE_KEY_ID") ?? "").trim();
|
|
51
|
+
const privateKey = normalizePem(env("APPLE_PRIVATE_KEY") ?? "");
|
|
52
|
+
const clientIds = (env("APPLE_CLIENT_IDS") ?? "")
|
|
53
|
+
.split(",")
|
|
54
|
+
.map((s) => s.trim())
|
|
55
|
+
.filter(Boolean);
|
|
56
|
+
if (!TEN_CHARS.test(teamId)) problems.push("APPLE_TEAM_ID (10 capital letters or digits)");
|
|
57
|
+
if (!TEN_CHARS.test(keyId)) problems.push("APPLE_KEY_ID (10 capital letters or digits)");
|
|
58
|
+
if (!/-----BEGIN PRIVATE KEY-----[\s\S]+-----END PRIVATE KEY-----/.test(privateKey)) problems.push("APPLE_PRIVATE_KEY (the .p8 file's PEM text)");
|
|
59
|
+
if (clientIds.length === 0 || !clientIds.every((id) => CLIENT_ID.test(id))) problems.push("APPLE_CLIENT_IDS (comma-separated bundle ids or Services IDs)");
|
|
60
|
+
return problems.length ? { problems } : { config: { teamId, keyId, privateKey, clientIds } };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Accepts a PEM pasted with real newlines, escaped `\n`, or surrounding quotes. */
|
|
64
|
+
function normalizePem(raw: string): string {
|
|
65
|
+
return raw.trim().replace(/^"|"$/g, "").replace(/\\n/g, "\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const encoder = new TextEncoder();
|
|
69
|
+
|
|
70
|
+
export function base64url(bytes: Uint8Array | string): string {
|
|
71
|
+
const data = typeof bytes === "string" ? encoder.encode(bytes) : bytes;
|
|
72
|
+
let binary = "";
|
|
73
|
+
for (const b of data) binary += String.fromCharCode(b);
|
|
74
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function base64urlDecode(text: string): string {
|
|
78
|
+
const b64 = text.replace(/-/g, "+").replace(/_/g, "/");
|
|
79
|
+
const binary = atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4));
|
|
80
|
+
return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const keyCache = new Map<string, Promise<CryptoKey>>();
|
|
84
|
+
|
|
85
|
+
function signingKey(pem: string): Promise<CryptoKey> {
|
|
86
|
+
let key = keyCache.get(pem);
|
|
87
|
+
if (!key) {
|
|
88
|
+
const body = pem.replace(/-----(BEGIN|END) PRIVATE KEY-----/g, "").replace(/\s+/g, "");
|
|
89
|
+
const der = Uint8Array.from(atob(body), (c) => c.charCodeAt(0));
|
|
90
|
+
key = crypto.subtle.importKey("pkcs8", der, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
|
|
91
|
+
// A bad key must not stay cached as a rejected promise.
|
|
92
|
+
key.catch(() => keyCache.delete(pem));
|
|
93
|
+
keyCache.set(pem, key);
|
|
94
|
+
}
|
|
95
|
+
return key;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The `client_secret` for one client id: a JWT with header `{ alg: "ES256", kid }`
|
|
100
|
+
* and claims `{ iss: teamId, iat, exp, aud: "https://appleid.apple.com", sub: clientId }`,
|
|
101
|
+
* signed with ECDSA P-256 / SHA-256. WebCrypto returns the signature as r‖s
|
|
102
|
+
* (64 bytes), which is the JWS encoding ES256 needs.
|
|
103
|
+
*/
|
|
104
|
+
export async function appleClientSecret(config: AppleConfig, clientId: string, options: { now?: number; ttlSeconds?: number } = {}): Promise<string> {
|
|
105
|
+
// Backdated a little, so a clock slightly ahead of Apple's doesn't issue a secret "from the future".
|
|
106
|
+
const iat = Math.floor((options.now ?? Date.now()) / 1000) - CLOCK_SKEW_SECONDS;
|
|
107
|
+
const ttl = Math.min(Math.max(1, options.ttlSeconds ?? CLIENT_SECRET_TTL_SECONDS), CLIENT_SECRET_MAX_TTL_SECONDS);
|
|
108
|
+
const header = base64url(JSON.stringify({ alg: "ES256", kid: config.keyId }));
|
|
109
|
+
const payload = base64url(JSON.stringify({ iss: config.teamId, iat, exp: iat + ttl, aud: APPLE_ISSUER, sub: clientId }));
|
|
110
|
+
const input = `${header}.${payload}`;
|
|
111
|
+
const signature = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, await signingKey(config.privateKey), encoder.encode(input));
|
|
112
|
+
return `${input}.${base64url(new Uint8Array(signature))}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The payload of a JWT, unverified, or null if it isn't one. */
|
|
116
|
+
export function decodeJwtPayload(jwt: string): Record<string, unknown> | null {
|
|
117
|
+
const parts = jwt.split(".");
|
|
118
|
+
if (parts.length !== 3 || !parts[1]) return null;
|
|
119
|
+
try {
|
|
120
|
+
const payload: unknown = JSON.parse(base64urlDecode(parts[1]));
|
|
121
|
+
return payload && typeof payload === "object" && !Array.isArray(payload) ? (payload as Record<string, unknown>) : null;
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function appleError(res: Response): Promise<AppleErrorCode> {
|
|
128
|
+
try {
|
|
129
|
+
const body: unknown = await res.json();
|
|
130
|
+
const code = body && typeof body === "object" ? (body as { error?: unknown }).error : undefined;
|
|
131
|
+
return (APPLE_ERRORS as readonly unknown[]).includes(code) ? (code as AppleErrorCode) : "unknown";
|
|
132
|
+
} catch {
|
|
133
|
+
return "unknown";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function postForm(fetchImpl: typeof fetch, url: string, form: Record<string, string>): Promise<Response | null> {
|
|
138
|
+
try {
|
|
139
|
+
return await fetchImpl(url, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
142
|
+
body: new URLSearchParams(form).toString(),
|
|
143
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
144
|
+
});
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type ExchangeResult =
|
|
151
|
+
| { ok: true; refreshToken: string; appleUserId: string }
|
|
152
|
+
/** `mintedRefreshToken`: Apple issued a token the checks then refused; revoke it rather than drop it. */
|
|
153
|
+
| { ok: false; error: AppleErrorCode; status?: number; mintedRefreshToken?: string };
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Exchanges a sign-in's single-use authorization code (valid for five
|
|
157
|
+
* minutes) for Apple's refresh token: `grant_type=authorization_code` with
|
|
158
|
+
* `client_id`, `client_secret` and `code`. A native sign-in sends no
|
|
159
|
+
* `redirect_uri` (Apple wants one only if the authorization request had one).
|
|
160
|
+
*
|
|
161
|
+
* Also checks the returned id token names Apple as `iss` and this client as
|
|
162
|
+
* `aud`, and returns its `sub` (the person's Apple user id) so the caller can
|
|
163
|
+
* tie the token to the right login. The id token comes straight from Apple
|
|
164
|
+
* over TLS in this response, so its claims are read without checking its
|
|
165
|
+
* signature (OpenID Connect Core 3.1.3.7).
|
|
166
|
+
*/
|
|
167
|
+
export async function exchangeAuthorizationCode(config: AppleConfig, clientId: string, code: string, fetchImpl: typeof fetch = fetch): Promise<ExchangeResult> {
|
|
168
|
+
const res = await postForm(fetchImpl, APPLE_TOKEN_URL, {
|
|
169
|
+
client_id: clientId,
|
|
170
|
+
client_secret: await appleClientSecret(config, clientId),
|
|
171
|
+
code,
|
|
172
|
+
grant_type: "authorization_code",
|
|
173
|
+
});
|
|
174
|
+
if (!res) return { ok: false, error: "network" };
|
|
175
|
+
if (!res.ok) return { ok: false, error: await appleError(res), status: res.status };
|
|
176
|
+
let body: { refresh_token?: unknown; id_token?: unknown };
|
|
177
|
+
try {
|
|
178
|
+
body = (await res.json()) as typeof body;
|
|
179
|
+
} catch {
|
|
180
|
+
return { ok: false, error: "bad_response", status: res.status };
|
|
181
|
+
}
|
|
182
|
+
const claims = typeof body.id_token === "string" ? decodeJwtPayload(body.id_token) : null;
|
|
183
|
+
const aud = claims?.aud;
|
|
184
|
+
const audOk = aud === clientId || (Array.isArray(aud) && aud.includes(clientId));
|
|
185
|
+
const minted = typeof body.refresh_token === "string" && body.refresh_token ? body.refresh_token : undefined;
|
|
186
|
+
if (!minted) return { ok: false, error: "bad_response", status: res.status };
|
|
187
|
+
if (!claims || claims.iss !== APPLE_ISSUER || !audOk || typeof claims.sub !== "string" || !claims.sub) {
|
|
188
|
+
return { ok: false, error: "bad_response", status: res.status, mintedRefreshToken: minted };
|
|
189
|
+
}
|
|
190
|
+
return { ok: true, refreshToken: minted, appleUserId: claims.sub };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Checks a refresh token is still live with this client's secret:
|
|
195
|
+
* `grant_type=refresh_token` at /auth/token (Apple returns new access and id
|
|
196
|
+
* tokens, which are dropped). This is the proof a revocation needs, because
|
|
197
|
+
* /auth/revoke answers 200 to a token it doesn't know even when the client
|
|
198
|
+
* secret is garbage: a 200 here means the secret is right and the token is
|
|
199
|
+
* live; `invalid_grant` means the token is already dead (nothing left to
|
|
200
|
+
* revoke); `invalid_client` means the secrets are wrong.
|
|
201
|
+
*/
|
|
202
|
+
export async function checkRefreshToken(config: AppleConfig, clientId: string, refreshToken: string, fetchImpl: typeof fetch = fetch): Promise<RevokeResult> {
|
|
203
|
+
const res = await postForm(fetchImpl, APPLE_TOKEN_URL, {
|
|
204
|
+
client_id: clientId,
|
|
205
|
+
client_secret: await appleClientSecret(config, clientId),
|
|
206
|
+
grant_type: "refresh_token",
|
|
207
|
+
refresh_token: refreshToken,
|
|
208
|
+
});
|
|
209
|
+
if (!res) return { ok: false, error: "network" };
|
|
210
|
+
if (res.status === 200) {
|
|
211
|
+
await res.body?.cancel().catch(() => {});
|
|
212
|
+
return { ok: true };
|
|
213
|
+
}
|
|
214
|
+
return { ok: false, error: await appleError(res), status: res.status };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export type RevokeResult = { ok: true } | { ok: false; error: AppleErrorCode; status?: number };
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Revokes a refresh token: `client_id`, `client_secret`, `token` and
|
|
221
|
+
* `token_type_hint=refresh_token`. Apple answers 200 with no body when it
|
|
222
|
+
* revoked the token, and also when the token was already invalid. It also
|
|
223
|
+
* answered 200 to an unknown token with a garbage client secret (seen
|
|
224
|
+
* 2026-10-24), so a 200 alone proves nothing: check the token with
|
|
225
|
+
* {@link checkRefreshToken} first.
|
|
226
|
+
*/
|
|
227
|
+
export async function revokeRefreshToken(config: AppleConfig, clientId: string, refreshToken: string, fetchImpl: typeof fetch = fetch): Promise<RevokeResult> {
|
|
228
|
+
const res = await postForm(fetchImpl, APPLE_REVOKE_URL, {
|
|
229
|
+
client_id: clientId,
|
|
230
|
+
client_secret: await appleClientSecret(config, clientId),
|
|
231
|
+
token: refreshToken,
|
|
232
|
+
token_type_hint: "refresh_token",
|
|
233
|
+
});
|
|
234
|
+
if (!res) return { ok: false, error: "network" };
|
|
235
|
+
if (res.status === 200) return { ok: true };
|
|
236
|
+
return { ok: false, error: await appleError(res), status: res.status };
|
|
237
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The genesis-apple-revoke Edge Function's request handling, with its
|
|
3
|
+
* dependencies passed in (index.ts wires the real ones), so it is tested
|
|
4
|
+
* without Deno, a database or Apple.
|
|
5
|
+
*
|
|
6
|
+
* POST { "action": "store", "code": "<authorizationCode>", "clientId"?: "<bundle id>" }
|
|
7
|
+
* Authorization: Bearer <the person's access token>
|
|
8
|
+
* Exchanges the code from a Sign in with Apple at /auth/token and keeps
|
|
9
|
+
* Apple's refresh token against the caller, server-side only.
|
|
10
|
+
*
|
|
11
|
+
* POST { "action": "revoke" }
|
|
12
|
+
* Revokes the tokens of deleted accounts that are due (queued by the
|
|
13
|
+
* delete trigger, retried with backoff). It needs no caller: it only
|
|
14
|
+
* works through the queue, a bounded batch at a time, and answers
|
|
15
|
+
* `{ ok: true }` (202, working in the background, when the runtime can)
|
|
16
|
+
* whatever was in it. The app calls it after `delete_my_account`; a
|
|
17
|
+
* schedule calls it to retry.
|
|
18
|
+
*
|
|
19
|
+
* Responses never carry a token, a secret, or Apple's response body: only an
|
|
20
|
+
* `error` code.
|
|
21
|
+
*/
|
|
22
|
+
import { checkRefreshToken, exchangeAuthorizationCode, readAppleConfig, revokeRefreshToken, type AppleConfig } from "./apple.ts";
|
|
23
|
+
|
|
24
|
+
/** The signed-in caller, as the auth server reports it. */
|
|
25
|
+
export interface CallerUser {
|
|
26
|
+
id: string;
|
|
27
|
+
/** The `sub` of each Apple identity linked to this login (Apple's user id). */
|
|
28
|
+
appleUserIds: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DueRevocation {
|
|
32
|
+
id: number;
|
|
33
|
+
client_id: string;
|
|
34
|
+
refresh_token: string;
|
|
35
|
+
attempts: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The SQL functions from migration 0003, called with the service role. */
|
|
39
|
+
export interface TokenStore {
|
|
40
|
+
/** `genesis_apple_token_save`: "stored", or "queued" when the login is already gone. */
|
|
41
|
+
save(userId: string, clientId: string, refreshToken: string): Promise<"stored" | "queued">;
|
|
42
|
+
/** `genesis_apple_revocations_claim`: due rows, each leased until its next retry. */
|
|
43
|
+
claim(limit: number): Promise<DueRevocation[]>;
|
|
44
|
+
/** `genesis_apple_revocation_done`: forgets a revoked token. */
|
|
45
|
+
done(id: number): Promise<void>;
|
|
46
|
+
/** `genesis_apple_revocation_failed`: keeps the row for its next retry ("retry"), or drops its token after the last attempt ("gave_up"). */
|
|
47
|
+
failed(id: number, error: string): Promise<"retry" | "gave_up">;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface HandlerDeps {
|
|
51
|
+
env: (name: string) => string | undefined;
|
|
52
|
+
fetch: typeof fetch;
|
|
53
|
+
/** The user behind an access token (checked with the auth server), or null. */
|
|
54
|
+
getUser(accessToken: string): Promise<CallerUser | null>;
|
|
55
|
+
store: TokenStore;
|
|
56
|
+
log: (message: string) => void;
|
|
57
|
+
/** Keeps work running after the response (`EdgeRuntime.waitUntil`). Without it, the revoke action answers when the batch is done. */
|
|
58
|
+
background?: (work: Promise<unknown>) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const REVOKE_BATCH = 20;
|
|
62
|
+
const MAX_BODY_BYTES = 4096;
|
|
63
|
+
const MAX_REFRESH_TOKEN = 4096;
|
|
64
|
+
const CODE = /^[A-Za-z0-9._-]{1,1024}$/;
|
|
65
|
+
|
|
66
|
+
const CORS = {
|
|
67
|
+
"access-control-allow-origin": "*",
|
|
68
|
+
"access-control-allow-headers": "authorization, x-client-info, apikey, content-type",
|
|
69
|
+
"access-control-allow-methods": "POST, OPTIONS",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function json(status: number, body: Record<string, unknown>): Response {
|
|
73
|
+
return new Response(JSON.stringify(body), { status, headers: { ...CORS, "content-type": "application/json", "cache-control": "no-store" } });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readBody(req: Request): Promise<Record<string, unknown> | null> {
|
|
77
|
+
if (Number(req.headers.get("content-length") ?? 0) > MAX_BODY_BYTES) return null;
|
|
78
|
+
const text = await req.text().catch(() => null);
|
|
79
|
+
if (text === null || text.length > MAX_BODY_BYTES) return null;
|
|
80
|
+
try {
|
|
81
|
+
const body: unknown = JSON.parse(text);
|
|
82
|
+
return body && typeof body === "object" && !Array.isArray(body) ? (body as Record<string, unknown>) : null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function bearer(req: Request): string | null {
|
|
89
|
+
const match = /^Bearer\s+(\S+)$/i.exec(req.headers.get("authorization") ?? "");
|
|
90
|
+
return match?.[1] ?? null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const describe = (error: unknown) => (error instanceof Error ? `${error.name}: ${error.message}` : "unknown error");
|
|
94
|
+
|
|
95
|
+
export async function handle(req: Request, deps: HandlerDeps): Promise<Response> {
|
|
96
|
+
try {
|
|
97
|
+
return await route(req, deps);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// e.g. APPLE_PRIVATE_KEY isn't a P-256 key, or the database is unreachable.
|
|
100
|
+
deps.log(`genesis-apple-revoke: failed: ${describe(error)}`);
|
|
101
|
+
return json(500, { error: "internal" });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function route(req: Request, deps: HandlerDeps): Promise<Response> {
|
|
106
|
+
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
|
|
107
|
+
if (req.method !== "POST") return json(405, { error: "method_not_allowed" });
|
|
108
|
+
const body = await readBody(req);
|
|
109
|
+
if (!body) return json(400, { error: "bad_request" });
|
|
110
|
+
|
|
111
|
+
const apple = readAppleConfig(deps.env);
|
|
112
|
+
if (!apple.config) {
|
|
113
|
+
// Loud in the logs, by name only: a missing secret must not pass for a revoked token.
|
|
114
|
+
deps.log(`genesis-apple-revoke: not configured; set ${apple.problems.join(", ")}`);
|
|
115
|
+
return json(500, { error: "not_configured" });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (body.action === "store") return store(req, body, apple.config, deps);
|
|
119
|
+
if (body.action === "revoke") {
|
|
120
|
+
const work = revokeDue(apple.config, deps);
|
|
121
|
+
if (deps.background) {
|
|
122
|
+
// The app waits on this right after deleting the account: answer now, finish the batch after.
|
|
123
|
+
deps.background(work);
|
|
124
|
+
return json(202, { ok: true });
|
|
125
|
+
}
|
|
126
|
+
await work;
|
|
127
|
+
return json(200, { ok: true });
|
|
128
|
+
}
|
|
129
|
+
return json(400, { error: "bad_request" });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function store(req: Request, body: Record<string, unknown>, config: AppleConfig, deps: HandlerDeps): Promise<Response> {
|
|
133
|
+
const token = bearer(req);
|
|
134
|
+
const user = token ? await deps.getUser(token).catch(() => null) : null;
|
|
135
|
+
if (!user) return json(401, { error: "not_signed_in" });
|
|
136
|
+
|
|
137
|
+
const code = body.code;
|
|
138
|
+
if (typeof code !== "string" || !CODE.test(code)) return json(400, { error: "bad_request" });
|
|
139
|
+
const clientId = body.clientId === undefined || body.clientId === null ? config.clientIds[0]! : body.clientId;
|
|
140
|
+
if (typeof clientId !== "string" || !config.clientIds.includes(clientId)) {
|
|
141
|
+
deps.log(`genesis-apple-revoke: a store named a client id outside APPLE_CLIENT_IDS; add every bundle id the app signs in with (each build variant too)`);
|
|
142
|
+
return json(400, { error: "unknown_client" });
|
|
143
|
+
}
|
|
144
|
+
if (user.appleUserIds.length === 0) return json(403, { error: "no_apple_identity" });
|
|
145
|
+
|
|
146
|
+
const exchanged = await exchangeAuthorizationCode(config, clientId, code, deps.fetch);
|
|
147
|
+
if (!exchanged.ok) {
|
|
148
|
+
deps.log(`genesis-apple-revoke: code exchange failed for ${clientId}: ${exchanged.error}${exchanged.status ? ` (HTTP ${exchanged.status})` : ""}`);
|
|
149
|
+
// Apple minted a token whose id token we refused: revoke it rather than drop it.
|
|
150
|
+
if (exchanged.mintedRefreshToken) await revokeMinted(config, clientId, exchanged.mintedRefreshToken, deps);
|
|
151
|
+
return json(502, { error: "exchange_failed", apple: exchanged.error });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The code must belong to this login's Apple identity: otherwise someone
|
|
155
|
+
// could file another person's token under their own account.
|
|
156
|
+
if (!user.appleUserIds.includes(exchanged.appleUserId)) {
|
|
157
|
+
deps.log("genesis-apple-revoke: a code for another Apple account was refused");
|
|
158
|
+
await revokeMinted(config, clientId, exchanged.refreshToken, deps);
|
|
159
|
+
return json(403, { error: "apple_identity_mismatch" });
|
|
160
|
+
}
|
|
161
|
+
if (exchanged.refreshToken.length > MAX_REFRESH_TOKEN) {
|
|
162
|
+
// The column would refuse it (and a refused row's text can reach the database's log).
|
|
163
|
+
deps.log("genesis-apple-revoke: Apple's refresh token is longer than the table allows");
|
|
164
|
+
await revokeMinted(config, clientId, exchanged.refreshToken, deps);
|
|
165
|
+
return json(502, { error: "exchange_failed", apple: "bad_response" });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const saved = await deps.store.save(user.id, clientId, exchanged.refreshToken);
|
|
169
|
+
// The login was deleted while this ran: the token went to the queue, so revoke now.
|
|
170
|
+
if (saved === "queued") await revokeDue(config, deps);
|
|
171
|
+
return json(200, { ok: true });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Revokes a token this request minted and won't keep (it was just issued with these secrets, so a 200 is meaningful). */
|
|
175
|
+
async function revokeMinted(config: AppleConfig, clientId: string, refreshToken: string, deps: HandlerDeps): Promise<void> {
|
|
176
|
+
const revoked = await revokeRefreshToken(config, clientId, refreshToken, deps.fetch);
|
|
177
|
+
if (!revoked.ok) deps.log(`genesis-apple-revoke: couldn't revoke a token it refused to keep: ${revoked.error}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Works through due revocations. Each token is first checked at /auth/token
|
|
182
|
+
* (`checkRefreshToken`), because /auth/revoke's 200 proves nothing on its
|
|
183
|
+
* own: live → revoke, and only a 200 then forgets the row; already dead
|
|
184
|
+
* (`invalid_grant`) → nothing to revoke, forget it; anything else (wrong
|
|
185
|
+
* secrets, Apple down) → retry later. Returns the counts, for logs and tests.
|
|
186
|
+
*/
|
|
187
|
+
export async function revokeDue(config: AppleConfig, deps: HandlerDeps): Promise<{ revoked: number; gone: number; failed: number }> {
|
|
188
|
+
const counts = { revoked: 0, gone: 0, failed: 0 };
|
|
189
|
+
let due: DueRevocation[];
|
|
190
|
+
try {
|
|
191
|
+
due = await deps.store.claim(REVOKE_BATCH);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
deps.log(`genesis-apple-revoke: couldn't read the queue: ${describe(error)}`);
|
|
194
|
+
return counts;
|
|
195
|
+
}
|
|
196
|
+
for (const row of due) {
|
|
197
|
+
// One row's trouble never stops the rest; a row whose result wasn't
|
|
198
|
+
// recorded comes round again when its lease ends.
|
|
199
|
+
try {
|
|
200
|
+
const check = await checkRefreshToken(config, row.client_id, row.refresh_token, deps.fetch);
|
|
201
|
+
if (!check.ok && check.error === "invalid_grant") {
|
|
202
|
+
await deps.store.done(row.id);
|
|
203
|
+
counts.gone++;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const result = check.ok ? await revokeRefreshToken(config, row.client_id, row.refresh_token, deps.fetch) : check;
|
|
207
|
+
if (result.ok) {
|
|
208
|
+
await deps.store.done(row.id);
|
|
209
|
+
counts.revoked++;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const why = `${check.ok ? "revoke" : "check"}: ${result.error}${result.status ? ` (HTTP ${result.status})` : ""}`;
|
|
213
|
+
counts.failed++;
|
|
214
|
+
const next = await deps.store.failed(row.id, why);
|
|
215
|
+
deps.log(
|
|
216
|
+
next === "gave_up"
|
|
217
|
+
? `genesis-apple-revoke: GAVE UP on revocation ${row.id} for ${row.client_id} after ${row.attempts} attempts (${why}); its token is dropped and the row kept as a record`
|
|
218
|
+
: `genesis-apple-revoke: revocation ${row.id} for ${row.client_id} failed (attempt ${row.attempts}): ${why}; it will be retried`,
|
|
219
|
+
);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
counts.failed++;
|
|
222
|
+
deps.log(`genesis-apple-revoke: revocation ${row.id} couldn't be recorded: ${describe(error)}; it will be retried`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (due.length) deps.log(`genesis-apple-revoke: ${counts.revoked} revoked, ${counts.gone} already invalid, ${counts.failed} failed`);
|
|
226
|
+
return counts;
|
|
227
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// genesis-apple-revoke: keeps Sign in with Apple refresh tokens server-side and
|
|
2
|
+
// revokes them when the account is deleted (App Store Review Guideline 5.1.1(v)).
|
|
3
|
+
// A template from @marktiderman/genesis-supabase: copy this folder to your app's
|
|
4
|
+
// `supabase/functions/`, beside migration 0003 (see the package README).
|
|
5
|
+
//
|
|
6
|
+
// Secrets (`supabase secrets set …`): APPLE_TEAM_ID, APPLE_KEY_ID,
|
|
7
|
+
// APPLE_PRIVATE_KEY (the .p8 file's text) and APPLE_CLIENT_IDS (your bundle ids,
|
|
8
|
+
// comma-separated). SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are set by
|
|
9
|
+
// Supabase. Deploy with `--no-verify-jwt` (or `verify_jwt = false` in
|
|
10
|
+
// config.toml): the store action checks the caller itself, and the revoke
|
|
11
|
+
// action must run after the caller's login is gone.
|
|
12
|
+
import { createClient } from "npm:@supabase/supabase-js@2.118.0";
|
|
13
|
+
import { handle, type CallerUser, type DueRevocation, type TokenStore } from "./handler.ts";
|
|
14
|
+
|
|
15
|
+
const url = Deno.env.get("SUPABASE_URL") ?? "";
|
|
16
|
+
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
|
17
|
+
const admin = createClient(url, serviceKey, { auth: { persistSession: false, autoRefreshToken: false } });
|
|
18
|
+
|
|
19
|
+
async function rpc<T>(fn: string, args: Record<string, unknown>): Promise<T> {
|
|
20
|
+
const { data, error } = await admin.rpc(fn, args);
|
|
21
|
+
if (error) throw new Error(`${fn}: ${error.code ?? ""} ${error.message}`);
|
|
22
|
+
return data as T;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const store: TokenStore = {
|
|
26
|
+
save: (userId, clientId, refreshToken) =>
|
|
27
|
+
rpc<"stored" | "queued">("genesis_apple_token_save", { p_user_id: userId, p_client_id: clientId, p_refresh_token: refreshToken }),
|
|
28
|
+
claim: (limit) => rpc<DueRevocation[]>("genesis_apple_revocations_claim", { p_limit: limit }),
|
|
29
|
+
done: (id) => rpc<void>("genesis_apple_revocation_done", { p_id: id }),
|
|
30
|
+
failed: (id, error) => rpc<"retry" | "gave_up">("genesis_apple_revocation_failed", { p_id: id, p_error: error }),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
async function getUser(accessToken: string): Promise<CallerUser | null> {
|
|
34
|
+
const { data, error } = await admin.auth.getUser(accessToken);
|
|
35
|
+
if (error || !data.user) return null;
|
|
36
|
+
const appleUserIds = (data.user.identities ?? [])
|
|
37
|
+
.filter((i) => i.provider === "apple")
|
|
38
|
+
.map((i) => (typeof i.identity_data?.sub === "string" ? i.identity_data.sub : i.id))
|
|
39
|
+
.filter((sub): sub is string => typeof sub === "string" && sub.length > 0);
|
|
40
|
+
return { id: data.user.id, appleUserIds };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const runtime = (globalThis as { EdgeRuntime?: { waitUntil(work: Promise<unknown>): void } }).EdgeRuntime;
|
|
44
|
+
|
|
45
|
+
Deno.serve((req) =>
|
|
46
|
+
handle(req, {
|
|
47
|
+
env: (name) => Deno.env.get(name),
|
|
48
|
+
fetch,
|
|
49
|
+
getUser,
|
|
50
|
+
store,
|
|
51
|
+
log: (message) => console.error(message),
|
|
52
|
+
// Supabase's edge runtime keeps the worker alive for this after the response.
|
|
53
|
+
background: runtime?.waitUntil ? (work) => runtime.waitUntil(work) : undefined,
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expo's push service, as the genesis-push-send function uses it:
|
|
3
|
+
* https://docs.expo.dev/push-notifications/sending-notifications/
|
|
4
|
+
*
|
|
5
|
+
* POST /--/api/v2/push/send up to 100 messages; one ticket each, in order
|
|
6
|
+
* POST /--/api/v2/push/getReceipts up to 1000 ticket ids; a receipt for each one ready
|
|
7
|
+
*
|
|
8
|
+
* Nothing here logs: a caller that logs uses `describeExpoError`, which
|
|
9
|
+
* names a status and Expo's error code, never a token or a message body.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const EXPO_SEND_URL = "https://exp.host/--/api/v2/push/send";
|
|
13
|
+
export const EXPO_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
|
|
14
|
+
/** Expo takes up to 100 messages per send request. */
|
|
15
|
+
export const EXPO_SEND_BATCH = 100;
|
|
16
|
+
/** And up to 1000 ticket ids per receipts request. */
|
|
17
|
+
export const EXPO_RECEIPTS_BATCH = 1000;
|
|
18
|
+
const CALL_TIMEOUT_MS = 15_000;
|
|
19
|
+
|
|
20
|
+
/** One message for Expo. `data` reaches the app (`notification.request.content.data`), not the screen. */
|
|
21
|
+
export interface ExpoMessage {
|
|
22
|
+
to: string;
|
|
23
|
+
title?: string;
|
|
24
|
+
subtitle?: string;
|
|
25
|
+
body?: string;
|
|
26
|
+
data?: Record<string, unknown>;
|
|
27
|
+
sound?: "default" | null;
|
|
28
|
+
badge?: number;
|
|
29
|
+
/** Android: the notification channel (the app creates one per category). */
|
|
30
|
+
channelId?: string;
|
|
31
|
+
/** Seconds Apple and Google keep trying an offline phone. */
|
|
32
|
+
ttl?: number;
|
|
33
|
+
priority?: "default" | "normal" | "high";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Expo's answer for one message. */
|
|
37
|
+
export type ExpoTicket =
|
|
38
|
+
| { status: "ok"; id: string }
|
|
39
|
+
| { status: "error"; message?: string; details?: { error?: string } };
|
|
40
|
+
|
|
41
|
+
/** Expo's later word on one ticket. */
|
|
42
|
+
export type ExpoReceipt = { status: "ok" } | { status: "error"; message?: string; details?: { error?: string } };
|
|
43
|
+
|
|
44
|
+
/** Expo answered a whole request with an error status. */
|
|
45
|
+
export class ExpoHttpError extends Error {
|
|
46
|
+
constructor(readonly status: number) {
|
|
47
|
+
super(`Expo push answered HTTP ${status}`);
|
|
48
|
+
this.name = "ExpoHttpError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Expo refused the request itself (a 4xx other than 429), as it does for a
|
|
54
|
+
* batch that mixes projects. A timeout, a 429 or a 5xx may have been accepted
|
|
55
|
+
* or may recur, so those are never resent one by one.
|
|
56
|
+
*/
|
|
57
|
+
export function refusedOutright(error: unknown): boolean {
|
|
58
|
+
return error instanceof ExpoHttpError && error.status >= 400 && error.status < 500 && error.status !== 429;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** An Expo error code (`DeviceNotRegistered`), safe to store and log; anything else becomes `Unknown`. */
|
|
62
|
+
export function errorCode(value: { details?: { error?: string } } | undefined): string {
|
|
63
|
+
const code = value?.details?.error;
|
|
64
|
+
return typeof code === "string" && /^[A-Za-z_]{1,64}$/.test(code) ? code : "Unknown";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function headers(accessToken: string | undefined): Record<string, string> {
|
|
68
|
+
return {
|
|
69
|
+
accept: "application/json",
|
|
70
|
+
"content-type": "application/json",
|
|
71
|
+
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function post(fetchFn: typeof fetch, url: string, body: unknown, accessToken: string | undefined): Promise<unknown> {
|
|
76
|
+
const response = await fetchFn(url, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: headers(accessToken),
|
|
79
|
+
body: JSON.stringify(body),
|
|
80
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
|
81
|
+
});
|
|
82
|
+
// The body of an error is never read: it can echo the request back.
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
await response.body?.cancel().catch(() => {});
|
|
85
|
+
throw new ExpoHttpError(response.status);
|
|
86
|
+
}
|
|
87
|
+
return response.json();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Sends one batch (at most 100) and returns a ticket per message, in order. */
|
|
91
|
+
export async function sendBatch(fetchFn: typeof fetch, messages: ExpoMessage[], accessToken?: string): Promise<ExpoTicket[]> {
|
|
92
|
+
if (messages.length > EXPO_SEND_BATCH) throw new Error(`sendBatch: at most ${EXPO_SEND_BATCH} messages`);
|
|
93
|
+
const json = (await post(fetchFn, EXPO_SEND_URL, messages, accessToken)) as { data?: unknown };
|
|
94
|
+
const tickets = Array.isArray(json?.data) ? (json.data as ExpoTicket[]) : [];
|
|
95
|
+
// A short or malformed answer: the messages without a ticket count as failed, never as sent.
|
|
96
|
+
return messages.map((_, i) => {
|
|
97
|
+
const ticket = tickets[i];
|
|
98
|
+
if (ticket && ticket.status === "ok" && typeof ticket.id === "string" && ticket.id.length > 0 && ticket.id.length <= 128) return ticket;
|
|
99
|
+
if (ticket && ticket.status === "error") return ticket;
|
|
100
|
+
return { status: "error", details: { error: "NoTicket" } };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Receipts for up to 1000 ticket ids; ids Expo has no receipt for yet are left out. */
|
|
105
|
+
export async function getReceipts(fetchFn: typeof fetch, ids: string[], accessToken?: string): Promise<Record<string, ExpoReceipt>> {
|
|
106
|
+
if (ids.length > EXPO_RECEIPTS_BATCH) throw new Error(`getReceipts: at most ${EXPO_RECEIPTS_BATCH} ids`);
|
|
107
|
+
const json = (await post(fetchFn, EXPO_RECEIPTS_URL, { ids }, accessToken)) as { data?: unknown };
|
|
108
|
+
const data = json?.data;
|
|
109
|
+
return data && typeof data === "object" && !Array.isArray(data) ? (data as Record<string, ExpoReceipt>) : {};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A failed call, for a log line: the status or the error's name, never the request. */
|
|
113
|
+
export function describeExpoError(error: unknown): string {
|
|
114
|
+
if (error instanceof ExpoHttpError) return `HTTP ${error.status}`;
|
|
115
|
+
if (error instanceof Error) return error.name === "TimeoutError" || error.name === "AbortError" ? "timeout" : error.name;
|
|
116
|
+
return "unknown error";
|
|
117
|
+
}
|