@redeemid/emdash 0.0.0-stage → 0.0.2

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/src/guard.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Keeps members out of EmDash's own APIs and admin.
3
+ *
4
+ * EmDash's Subscriber role may read every published entry through
5
+ * /_emdash/api/content. With RedeemID, access comes from Shopify tags only, so
6
+ * a member with one level could otherwise read another level's posts there.
7
+ * This middleware runs after EmDash's auth and refuses Subscribers on every
8
+ * /_emdash path except the few public pages need.
9
+ */
10
+
11
+ import { defineMiddleware } from "astro:middleware";
12
+
13
+ import { siteConfig } from "./site-config.js";
14
+
15
+ const SUBSCRIBER = 10;
16
+
17
+ /** Paths signed-in members still need. Everything else under /_emdash is refused. */
18
+ export const MEMBER_PATHS = [
19
+ "/_emdash/api/auth/redeemid/",
20
+ "/_emdash/api/auth/logout",
21
+ "/_emdash/api/media/file/",
22
+ "/_emdash/api/comments",
23
+ "/_emdash/api/plugins/",
24
+ ];
25
+
26
+ export function memberMayUse(path: string, extra: string[] = []): boolean {
27
+ if (!path.startsWith("/_emdash")) return true;
28
+ return [...MEMBER_PATHS, ...extra].some((p) => path === p.replace(/\/$/, "") || path.startsWith(p));
29
+ }
30
+
31
+ export const onRequest = defineMiddleware(async ({ locals, url }, next) => {
32
+ const user = (locals as { user?: { role?: number } | null }).user;
33
+ if (!user || user.role !== SUBSCRIBER) return next();
34
+
35
+ const config = siteConfig((locals as { emdash?: { config?: unknown } }).emdash?.config);
36
+ if (memberMayUse(url.pathname, config?.memberPaths)) return next();
37
+
38
+ if (url.pathname.startsWith("/_emdash/admin")) {
39
+ return new Response(null, { status: 302, headers: { location: "/", "cache-control": "private, no-store" } });
40
+ }
41
+ return new Response(JSON.stringify({ error: { code: "FORBIDDEN", message: "Members can't use this API." } }), {
42
+ status: 403,
43
+ headers: { "content-type": "application/json", "cache-control": "private, no-store" },
44
+ });
45
+ });
package/src/index.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * RedeemID for EmDash.
3
+ *
4
+ * Shopify runs the store and the customer account; EmDash runs the content.
5
+ * Add to astro.config.mjs:
6
+ *
7
+ * integrations: [
8
+ * emdash({ authProviders: [redeemid({ ... })] }),
9
+ * redeemidGuard(),
10
+ * ]
11
+ *
12
+ * In full:
13
+ *
14
+ * emdash({
15
+ * authProviders: [
16
+ * redeemid({
17
+ * shop: "my-store.myshopify.com",
18
+ * levels: [{ slug: "member", name: "Member", tags: ["member"] }],
19
+ * rules: [{ collection: "posts", taxonomy: "category", term: "members", levels: ["member"] }],
20
+ * }),
21
+ * ],
22
+ * })
23
+ *
24
+ * Secrets come from the environment (see ENV in config.ts):
25
+ * REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID and, for confidential clients,
26
+ * REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET (the Headless storefront's
27
+ * Customer Account API client), and REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET
28
+ * (the client secret of the Shopify app whose webhooks the site receives).
29
+ */
30
+
31
+ import type { AstroIntegration } from "astro";
32
+ import type { AuthProviderDescriptor } from "emdash";
33
+
34
+ import { PROVIDER_ID, type RedeemIDConfig } from "./config.js";
35
+ import { STORAGE_CONFIG } from "./storage.js";
36
+
37
+ export type { LevelConfig, RedeemIDConfig, RuleConfig } from "./config.js";
38
+ export type { MemberRecord } from "./storage.js";
39
+ // Page-time helpers live in "@redeemid/emdash/access": they read the site's
40
+ // build-time config module, which astro.config.mjs can't load.
41
+
42
+ const BASE = "/_emdash/api/auth/redeemid";
43
+
44
+ export function redeemid(config: RedeemIDConfig): AuthProviderDescriptor {
45
+ if (!config) throw new Error("RedeemID: pass a config object.");
46
+ return {
47
+ id: PROVIDER_ID,
48
+ label: config.label ?? "Sign in",
49
+ config,
50
+ // No adminEntry: members sign in from the site, not the EmDash login page.
51
+ routes: [
52
+ { pattern: `${BASE}/login`, entrypoint: "@redeemid/emdash/routes/login.ts" },
53
+ { pattern: `${BASE}/callback`, entrypoint: "@redeemid/emdash/routes/callback.ts" },
54
+ { pattern: `${BASE}/logout`, entrypoint: "@redeemid/emdash/routes/logout.ts" },
55
+ { pattern: `${BASE}/webhook`, entrypoint: "@redeemid/emdash/routes/webhook.ts" },
56
+ { pattern: `${BASE}/buy`, entrypoint: "@redeemid/emdash/routes/buy.ts" },
57
+ ],
58
+ publicRoutes: [`${BASE}/`],
59
+ storage: STORAGE_CONFIG,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Astro integration that keeps members (EmDash Subscribers) out of EmDash's
65
+ * content API and admin, so only RedeemID's checks decide who reads what.
66
+ */
67
+ export function redeemidGuard(): AstroIntegration {
68
+ return {
69
+ name: "@redeemid/emdash/guard",
70
+ hooks: {
71
+ "astro:config:setup": ({ addMiddleware }) => {
72
+ addMiddleware({ entrypoint: "@redeemid/emdash/guard.ts", order: "post" });
73
+ },
74
+ },
75
+ };
76
+ }
77
+
78
+ export default redeemid;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * POST /_emdash/api/auth/redeemid/buy: start a Shopify checkout for one plan.
3
+ *
4
+ * Takes a product handle, looks its variant up again on Shopify (never
5
+ * trusting a posted variant or price) and sends the buyer to checkout. A
6
+ * signed-in member's email is passed along so the order lands on the same
7
+ * customer. Tags, and so access, still only change when Shopify says so.
8
+ */
9
+
10
+ import type { APIRoute } from "astro";
11
+
12
+ import { readConfig, storeDomain } from "../config.js";
13
+ import { safeReturn } from "../access.js";
14
+ import { memberForUser, stores } from "../storage.js";
15
+ import { checkoutUrl, plan } from "../storefront.js";
16
+ import { fail } from "./shared.js";
17
+
18
+ export const prerender = false;
19
+
20
+ export const POST: APIRoute = async ({ request, locals, session, redirect }) => {
21
+ const form = await request.formData().catch(() => null);
22
+ const returnTo = safeReturn(String(form?.get("return") ?? "/"));
23
+ const handle = String(form?.get("plan") ?? "").trim();
24
+ const emdash = (locals as { emdash?: { config?: unknown; db?: unknown } }).emdash;
25
+ const config = readConfig(emdash?.config);
26
+ if (!config || !storeDomain(config)) return fail(redirect, returnTo, "not_configured");
27
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(handle)) return fail(redirect, returnTo, "plan_unavailable");
28
+
29
+ try {
30
+ const p = await plan(config, handle);
31
+ if (!p?.variantId || !p.availableForSale) return fail(redirect, returnTo, "plan_unavailable");
32
+ // Provider routes are public, so read the member from the session.
33
+ const user = (await session?.get("user")) as { id?: string } | undefined;
34
+ const member = user?.id && emdash?.db ? await memberForUser(stores(emdash.db), user.id) : null;
35
+ const to = await checkoutUrl(
36
+ config,
37
+ { variantId: p.variantId, sellingPlanId: p.sellingPlanId },
38
+ { email: member?.email ?? null, buyerIp: request.headers.get("cf-connecting-ip") },
39
+ );
40
+ return redirect(to, 303);
41
+ } catch {
42
+ return fail(redirect, returnTo, "checkout_failed");
43
+ }
44
+ };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * GET /_emdash/api/auth/redeemid/callback
3
+ *
4
+ * Finishes Shopify customer account sign-in: checks state, exchanges the code,
5
+ * verifies the ID token, reads the customer, links or creates an EmDash
6
+ * Subscriber, mirrors the customer and starts the session.
7
+ *
8
+ * Refused: staff accounts (any role above Subscriber), disabled users, sites
9
+ * whose setup is not finished, and customers without a verified email.
10
+ */
11
+
12
+ import { findOrCreateOAuthUser, OAuthError, Role } from "@emdash-cms/auth";
13
+ import { createKyselyAdapter, type AuthTables } from "@emdash-cms/auth/adapters/kysely";
14
+ import type { APIRoute } from "astro";
15
+ import { OptionsRepository } from "emdash/api/route-utils";
16
+ import type { Kysely } from "kysely";
17
+
18
+ import { env, ENV, PROVIDER_ID, readConfig, storeDomain } from "../config.js";
19
+ import { discover, exchangeCode, identity, ShopifyError, verifyIdToken } from "../shopify.js";
20
+ import { emptyMember, stores } from "../storage.js";
21
+ import { callbackUrl, fail, FLOW_TTL_MS, SESSION_FLOW, SESSION_ID_TOKEN, type Flow } from "./shared.js";
22
+
23
+ export const prerender = false;
24
+
25
+ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
26
+ const url = new URL(request.url);
27
+ const emdash = (locals as { emdash?: { db?: unknown; config?: unknown } }).emdash;
28
+ const flow = (await session?.get(SESSION_FLOW)) as Flow | undefined;
29
+ session?.delete(SESSION_FLOW);
30
+ const returnTo = flow?.returnTo ?? "/";
31
+
32
+ const config = readConfig(emdash?.config);
33
+ const clientId = env(ENV.clientId);
34
+ if (!config || !storeDomain(config) || !clientId || !emdash?.db || !session) return fail(redirect, returnTo, "not_configured");
35
+
36
+ if (url.searchParams.get("error")) return fail(redirect, returnTo, "sign_in_cancelled");
37
+
38
+ const state = url.searchParams.get("state");
39
+ const code = url.searchParams.get("code");
40
+ if (!flow || !state || state !== flow.state || !code || Date.now() - flow.at > FLOW_TTL_MS) {
41
+ return fail(redirect, returnTo, "sign_in_expired");
42
+ }
43
+
44
+ try {
45
+ const options = new OptionsRepository(emdash.db as ConstructorParameters<typeof OptionsRepository>[0]);
46
+ const setup = await options.get("emdash:setup_complete");
47
+ // Never let a customer become the first (admin) user.
48
+ if (setup !== true && setup !== "true") return fail(redirect, returnTo, "site_not_ready");
49
+
50
+ const d = await discover(config);
51
+ const tokens = await exchangeCode(d, {
52
+ clientId,
53
+ clientSecret: env(ENV.clientSecret),
54
+ redirectUri: callbackUrl(url, emdash.config),
55
+ origin: new URL(callbackUrl(url, emdash.config)).origin,
56
+ code,
57
+ verifier: flow.verifier,
58
+ });
59
+ const claims = await verifyIdToken(d, tokens.idToken, { clientId, nonce: flow.nonce });
60
+ const who = await identity(d, tokens.accessToken);
61
+
62
+ const email = (typeof claims.email === "string" ? claims.email : who.email)?.toLowerCase() ?? null;
63
+ if (!email) return fail(redirect, returnTo, "email_required");
64
+ const emailVerified = claims.email_verified === true;
65
+
66
+ const adapter = createKyselyAdapter(emdash.db as Kysely<AuthTables>);
67
+
68
+ // Staff accounts never sign in through the store, even with the same email.
69
+ const linked = await adapter.getOAuthAccount(PROVIDER_ID, who.customerId);
70
+ const existing = linked ? await adapter.getUserById(linked.userId) : await adapter.getUserByEmail(email);
71
+ if (existing && existing.role > Role.SUBSCRIBER) return fail(redirect, returnTo, "staff_account");
72
+
73
+ const name = [who.firstName, who.lastName].filter(Boolean).join(" ") || null;
74
+ const user = await findOrCreateOAuthUser(
75
+ adapter,
76
+ PROVIDER_ID,
77
+ { id: who.customerId, email, name, avatarUrl: null, emailVerified },
78
+ async () => ({ allowed: true, role: Role.SUBSCRIBER }),
79
+ { requireVerifiedEmail: true },
80
+ );
81
+ if (user.disabled) return fail(redirect, returnTo, "account_disabled");
82
+ if (user.role > Role.SUBSCRIBER) return fail(redirect, returnTo, "staff_account");
83
+
84
+ const s = stores(emdash.db);
85
+ const member = (await s.members.get(who.customerId)) ?? emptyMember(who.customerId);
86
+ await s.members.put(who.customerId, {
87
+ ...member,
88
+ userId: user.id,
89
+ email,
90
+ firstName: who.firstName,
91
+ lastName: who.lastName,
92
+ tags: who.tags,
93
+ syncedAt: new Date().toISOString(),
94
+ source: "sign-in",
95
+ });
96
+
97
+ session.regenerate?.();
98
+ session.set("user", { id: user.id });
99
+ session.set(SESSION_ID_TOKEN, tokens.idToken);
100
+ return redirect(returnTo);
101
+ } catch (e) {
102
+ const code = e instanceof ShopifyError ? e.code : e instanceof OAuthError ? "signup_not_allowed" : "sign_in_failed";
103
+ console.error("[redeemid] sign-in failed:", (e as Error).message);
104
+ return fail(redirect, returnTo, code);
105
+ }
106
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * GET /_emdash/api/auth/redeemid/login?return=/path
3
+ *
4
+ * Starts Shopify customer account sign-in (authorization code + PKCE).
5
+ */
6
+
7
+ import type { APIRoute } from "astro";
8
+
9
+ import { safeReturn } from "../access.js";
10
+ import { env, ENV, readConfig, storeDomain } from "../config.js";
11
+ import { authorizeUrl, challengeFor, discover, randomToken } from "../shopify.js";
12
+ import { callbackUrl, fail, SESSION_FLOW, type Flow } from "./shared.js";
13
+
14
+ export const prerender = false;
15
+
16
+ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
17
+ const url = new URL(request.url);
18
+ const returnTo = safeReturn(url.searchParams.get("return"));
19
+ const emdash = (locals as { emdash?: { config?: unknown } }).emdash;
20
+ const config = readConfig(emdash?.config);
21
+ const clientId = env(ENV.clientId);
22
+
23
+ if (!config || !storeDomain(config) || !clientId || !session) return fail(redirect, returnTo, "not_configured");
24
+
25
+ try {
26
+ const d = await discover(config);
27
+ const flow: Flow = {
28
+ state: randomToken(),
29
+ nonce: randomToken(),
30
+ verifier: randomToken(48),
31
+ returnTo,
32
+ at: Date.now(),
33
+ };
34
+ session.set(SESSION_FLOW, flow);
35
+ return redirect(
36
+ authorizeUrl(d, {
37
+ clientId,
38
+ redirectUri: callbackUrl(url, emdash?.config),
39
+ state: flow.state,
40
+ nonce: flow.nonce,
41
+ challenge: await challengeFor(flow.verifier),
42
+ locale: request.headers.get("accept-language")?.split(",")[0]?.trim(),
43
+ }),
44
+ );
45
+ } catch (e) {
46
+ console.error("[redeemid] sign-in start failed:", (e as Error).message);
47
+ return fail(redirect, returnTo, "sign_in_unavailable");
48
+ }
49
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * POST /_emdash/api/auth/redeemid/logout?return=/path
3
+ *
4
+ * Ends the site session, then the Shopify customer session when the store
5
+ * publishes an end-session endpoint. After the store session ends the member
6
+ * lands on the site root (the logout URI registered in Shopify); without a
7
+ * store session they go straight back to `return`. POST only, so a link elsewhere can't
8
+ * sign members out.
9
+ */
10
+
11
+ import type { APIRoute } from "astro";
12
+ import { getPublicOrigin } from "emdash/api/route-utils";
13
+
14
+ import { safeReturn } from "../access.js";
15
+ import { readConfig } from "../config.js";
16
+ import { discover, logoutUrl } from "../shopify.js";
17
+ import { SESSION_ID_TOKEN } from "./shared.js";
18
+
19
+ export const prerender = false;
20
+
21
+ export const POST: APIRoute = async ({ request, locals, session, redirect }) => {
22
+ const url = new URL(request.url);
23
+ const returnTo = safeReturn(url.searchParams.get("return"));
24
+ const emdash = (locals as { emdash?: { config?: unknown } }).emdash;
25
+ const idToken = (await session?.get(SESSION_ID_TOKEN)) as string | undefined;
26
+ session?.destroy();
27
+
28
+ const config = readConfig(emdash?.config);
29
+ if (config && idToken) {
30
+ try {
31
+ const origin = getPublicOrigin(url, emdash?.config as Parameters<typeof getPublicOrigin>[1]);
32
+ // Shopify only returns to a registered logout URI, so register the
33
+ // site root and come back there rather than to the page.
34
+ const to = logoutUrl(await discover(config), idToken, `${origin}/`);
35
+ if (to) return redirect(to);
36
+ } catch {
37
+ // The site session is already gone; Shopify's ends on its own.
38
+ }
39
+ }
40
+ return redirect(returnTo);
41
+ };
@@ -0,0 +1,26 @@
1
+ import { getPublicOrigin } from "emdash/api/route-utils";
2
+
3
+ export const SESSION_FLOW = "redeemid:flow";
4
+ export const SESSION_ID_TOKEN = "redeemid:id_token";
5
+ /** A sign-in started more than this long ago is refused. */
6
+ export const FLOW_TTL_MS = 10 * 60 * 1000;
7
+
8
+ export interface Flow {
9
+ state: string;
10
+ nonce: string;
11
+ verifier: string;
12
+ returnTo: string;
13
+ at: number;
14
+ }
15
+
16
+ export const CALLBACK_PATH = "/_emdash/api/auth/redeemid/callback";
17
+
18
+ export function callbackUrl(url: URL, emdashConfig: unknown): string {
19
+ return `${getPublicOrigin(url, emdashConfig as Parameters<typeof getPublicOrigin>[1])}${CALLBACK_PATH}`;
20
+ }
21
+
22
+ /** Back to where the member was, with a notice code the theme can show. */
23
+ export function fail(redirect: (to: string) => Response, returnTo: string, code: string): Response {
24
+ const sep = returnTo.includes("?") ? "&" : "?";
25
+ return redirect(`${returnTo}${sep}redeemid_notice=${encodeURIComponent(code)}`);
26
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * POST /_emdash/api/auth/redeemid/webhook
3
+ *
4
+ * Shopify webhooks. Verifies the HMAC over the raw body, drops duplicate
5
+ * deliveries, and updates the member mirror. Storage errors answer 500 so
6
+ * Shopify retries; a failed delivery is never recorded as seen.
7
+ */
8
+
9
+ import type { APIRoute } from "astro";
10
+
11
+ import { env, ENV } from "../config.js";
12
+ import { stores } from "../storage.js";
13
+ import { apply, kindOf, verifyHmac } from "../webhooks.js";
14
+
15
+ export const prerender = false;
16
+
17
+ const MAX_BYTES = 1024 * 1024;
18
+
19
+ export const POST: APIRoute = async ({ request, locals }) => {
20
+ const emdash = (locals as { emdash?: { db?: unknown } }).emdash;
21
+ const secret = env(ENV.webhookSecret);
22
+ if (!emdash?.db || !secret) return new Response("Not configured", { status: 503 });
23
+
24
+ const body = new Uint8Array(await request.arrayBuffer());
25
+ if (body.byteLength > MAX_BYTES) return new Response("Too large", { status: 413 });
26
+
27
+ const ok = await verifyHmac(body, request.headers.get("x-shopify-hmac-sha256") ?? "", secret);
28
+ if (!ok) return new Response("Bad signature", { status: 401 });
29
+
30
+ const topic = (request.headers.get("x-shopify-topic") ?? "").toLowerCase();
31
+ const delivery = request.headers.get("x-shopify-event-id") || request.headers.get("x-shopify-webhook-id");
32
+ if (!kindOf(topic)) return new Response("Ignored", { status: 200 });
33
+
34
+ let payload: Record<string, unknown>;
35
+ try {
36
+ payload = JSON.parse(new TextDecoder().decode(body)) as Record<string, unknown>;
37
+ } catch {
38
+ return new Response("Bad JSON", { status: 400 });
39
+ }
40
+
41
+ const s = stores(emdash.db);
42
+ if (delivery) {
43
+ const claimed = await s.deliveries.compareAndSet(delivery, null, {
44
+ topic,
45
+ receivedAt: new Date().toISOString(),
46
+ });
47
+ if (!claimed.applied) return new Response("Duplicate", { status: 200 });
48
+ }
49
+
50
+ try {
51
+ const outcome = await apply(s, topic, payload);
52
+ return new Response(outcome, { status: 200 });
53
+ } catch (e) {
54
+ console.error("[redeemid] webhook failed:", topic, (e as Error).message);
55
+ if (delivery) await s.deliveries.delete(delivery).catch(() => undefined);
56
+ return new Response("Retry", { status: 500 });
57
+ }
58
+ };
package/src/shopify.ts ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Shopify Customer Account API: discovery, PKCE, token exchange, ID token
3
+ * checks and the identity query. Web APIs only, so it runs on Node and Workers.
4
+ */
5
+
6
+ import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
7
+
8
+ import { shopOrigin, type RedeemIDConfig } from "./config.js";
9
+
10
+ /** Customer Account API version, pinned on the discovered GraphQL URL. */
11
+ export const API_VERSION = "2026-10";
12
+ export const SCOPES = "openid email customer-account-api:full";
13
+
14
+ export interface Discovery {
15
+ issuer: string;
16
+ authorizationEndpoint: string;
17
+ tokenEndpoint: string;
18
+ endSessionEndpoint: string;
19
+ jwksUri: string;
20
+ graphqlApi: string;
21
+ }
22
+
23
+ const DISCOVERY_TTL_MS = 60 * 60 * 1000;
24
+ const discoveryCache = new Map<string, { at: number; value: Discovery }>();
25
+ const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
26
+
27
+ export class ShopifyError extends Error {
28
+ constructor(
29
+ public readonly code: string,
30
+ message: string,
31
+ ) {
32
+ super(message);
33
+ }
34
+ }
35
+
36
+ /** Point the discovered GraphQL URL at the pinned version. */
37
+ export function pinVersion(url: string): string {
38
+ return url.replace(/\/api\/(?:\d{4}-\d{2}|unstable)\/graphql(?:\.json)?$/, `/api/${API_VERSION}/graphql`);
39
+ }
40
+
41
+ export async function discover(config: RedeemIDConfig, fetcher: typeof fetch = fetch): Promise<Discovery> {
42
+ const origin = shopOrigin(config);
43
+ const hit = discoveryCache.get(origin);
44
+ if (hit && Date.now() - hit.at < DISCOVERY_TTL_MS) return hit.value;
45
+
46
+ const [openid, api] = await Promise.all([
47
+ getJson(fetcher, `${origin}/.well-known/openid-configuration`),
48
+ getJson(fetcher, `${origin}/.well-known/customer-account-api`),
49
+ ]);
50
+ const str = (o: Record<string, unknown>, k: string) => (typeof o[k] === "string" ? (o[k] as string) : "");
51
+
52
+ const value: Discovery = {
53
+ issuer: str(openid, "issuer"),
54
+ authorizationEndpoint: str(openid, "authorization_endpoint"),
55
+ tokenEndpoint: str(openid, "token_endpoint"),
56
+ endSessionEndpoint: str(openid, "end_session_endpoint"),
57
+ jwksUri: str(openid, "jwks_uri"),
58
+ graphqlApi: pinVersion(str(api, "graphql_api")),
59
+ };
60
+ if (!value.authorizationEndpoint || !value.tokenEndpoint || !value.issuer || !value.jwksUri) {
61
+ throw new ShopifyError("discovery_failed", "The store did not publish customer account sign-in.");
62
+ }
63
+ discoveryCache.set(origin, { at: Date.now(), value });
64
+ return value;
65
+ }
66
+
67
+ async function getJson(fetcher: typeof fetch, url: string): Promise<Record<string, unknown>> {
68
+ const res = await fetcher(url, { headers: { Accept: "application/json" } });
69
+ if (!res.ok) throw new ShopifyError("discovery_failed", `Discovery failed (${res.status}) for ${url}`);
70
+ return (await res.json()) as Record<string, unknown>;
71
+ }
72
+
73
+ // ---------------------------------------------------------------- PKCE
74
+
75
+ export function randomToken(bytes = 32): string {
76
+ const buf = new Uint8Array(bytes);
77
+ crypto.getRandomValues(buf);
78
+ return base64url(buf);
79
+ }
80
+
81
+ export async function challengeFor(verifier: string): Promise<string> {
82
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
83
+ return base64url(new Uint8Array(digest));
84
+ }
85
+
86
+ export function base64url(bytes: Uint8Array): string {
87
+ let s = "";
88
+ for (const b of bytes) s += String.fromCharCode(b);
89
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
90
+ }
91
+
92
+ export function authorizeUrl(
93
+ d: Discovery,
94
+ args: { clientId: string; redirectUri: string; state: string; nonce: string; challenge: string; locale?: string },
95
+ ): string {
96
+ const url = new URL(d.authorizationEndpoint);
97
+ url.searchParams.set("scope", SCOPES);
98
+ url.searchParams.set("client_id", args.clientId);
99
+ url.searchParams.set("response_type", "code");
100
+ url.searchParams.set("redirect_uri", args.redirectUri);
101
+ url.searchParams.set("state", args.state);
102
+ url.searchParams.set("nonce", args.nonce);
103
+ url.searchParams.set("code_challenge", args.challenge);
104
+ url.searchParams.set("code_challenge_method", "S256");
105
+ if (args.locale) url.searchParams.set("ui_locales", args.locale);
106
+ return url.toString();
107
+ }
108
+
109
+ // ---------------------------------------------------------------- tokens
110
+
111
+ export interface Tokens {
112
+ accessToken: string;
113
+ idToken: string;
114
+ }
115
+
116
+ export async function exchangeCode(
117
+ d: Discovery,
118
+ args: { clientId: string; clientSecret: string; redirectUri: string; origin?: string; code: string; verifier: string },
119
+ fetcher: typeof fetch = fetch,
120
+ ): Promise<Tokens> {
121
+ const body = new URLSearchParams({
122
+ grant_type: "authorization_code",
123
+ client_id: args.clientId,
124
+ redirect_uri: args.redirectUri,
125
+ code: args.code,
126
+ code_verifier: args.verifier,
127
+ });
128
+ const headers: Record<string, string> = {
129
+ "Content-Type": "application/x-www-form-urlencoded",
130
+ Accept: "application/json",
131
+ };
132
+ // Public clients are checked against their registered JavaScript origins.
133
+ if (args.origin) headers.Origin = args.origin;
134
+ if (args.clientSecret) headers.Authorization = `Basic ${btoa(`${args.clientId}:${args.clientSecret}`)}`;
135
+
136
+ const res = await fetcher(d.tokenEndpoint, { method: "POST", headers, body });
137
+ const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
138
+ if (!res.ok || typeof data.access_token !== "string" || typeof data.id_token !== "string") {
139
+ throw new ShopifyError("token_failed", `Token exchange failed (${res.status}).`);
140
+ }
141
+ return { accessToken: data.access_token, idToken: data.id_token };
142
+ }
143
+
144
+ /** Verify the ID token's signature, issuer, audience, expiry and nonce. */
145
+ export async function verifyIdToken(
146
+ d: Discovery,
147
+ idToken: string,
148
+ args: { clientId: string; nonce: string },
149
+ ): Promise<JWTPayload> {
150
+ let jwks = jwksCache.get(d.jwksUri);
151
+ if (!jwks) {
152
+ jwks = createRemoteJWKSet(new URL(d.jwksUri));
153
+ jwksCache.set(d.jwksUri, jwks);
154
+ }
155
+ try {
156
+ const { payload } = await jwtVerify(idToken, jwks, {
157
+ issuer: d.issuer,
158
+ audience: args.clientId,
159
+ clockTolerance: 60,
160
+ });
161
+ if (payload.nonce !== args.nonce) throw new ShopifyError("bad_nonce", "ID token nonce mismatch.");
162
+ return payload;
163
+ } catch (e) {
164
+ if (e instanceof ShopifyError) throw e;
165
+ throw new ShopifyError("bad_id_token", `ID token rejected: ${(e as Error).message}`);
166
+ }
167
+ }
168
+
169
+ // ---------------------------------------------------------------- identity
170
+
171
+ export interface Identity {
172
+ customerId: string;
173
+ email: string | null;
174
+ firstName: string | null;
175
+ lastName: string | null;
176
+ tags: string[];
177
+ }
178
+
179
+ export const IDENTITY_QUERY = `query RedeemIDIdentity {
180
+ customer {
181
+ id
182
+ firstName
183
+ lastName
184
+ emailAddress { emailAddress }
185
+ tags
186
+ }
187
+ }`;
188
+
189
+ export async function identity(d: Discovery, accessToken: string, fetcher: typeof fetch = fetch): Promise<Identity> {
190
+ if (!d.graphqlApi) throw new ShopifyError("identity_failed", "The store did not publish the Customer Account API.");
191
+ const res = await fetcher(d.graphqlApi, {
192
+ method: "POST",
193
+ // The Customer Account API takes the bare access token, without "Bearer".
194
+ headers: { "Content-Type": "application/json", Authorization: accessToken },
195
+ body: JSON.stringify({ query: IDENTITY_QUERY }),
196
+ });
197
+ const data = (await res.json().catch(() => ({}))) as {
198
+ data?: { customer?: Record<string, unknown> | null };
199
+ };
200
+ const c = data.data?.customer;
201
+ const gid = typeof c?.id === "string" ? c.id : "";
202
+ const m = /^gid:\/\/shopify\/Customer\/(\d+)$/.exec(gid);
203
+ if (!res.ok || !c || !m?.[1]) throw new ShopifyError("identity_failed", "Shopify did not return a customer.");
204
+ const s = (v: unknown) => (typeof v === "string" && v !== "" ? v : null);
205
+ const email = (c.emailAddress as { emailAddress?: unknown } | null)?.emailAddress;
206
+ return {
207
+ customerId: m[1],
208
+ email: s(email)?.toLowerCase() ?? null,
209
+ firstName: s(c.firstName),
210
+ lastName: s(c.lastName),
211
+ tags: Array.isArray(c.tags) ? c.tags.filter((t): t is string => typeof t === "string") : [],
212
+ };
213
+ }
214
+
215
+ export function logoutUrl(d: Discovery, idToken: string, after: string): string | null {
216
+ if (!d.endSessionEndpoint) return null;
217
+ const url = new URL(d.endSessionEndpoint);
218
+ url.searchParams.set("id_token_hint", idToken);
219
+ url.searchParams.set("post_logout_redirect_uri", after);
220
+ return url.toString();
221
+ }
222
+
223
+ /** For tests: forget cached discovery and keys. */
224
+ export function resetCaches(): void {
225
+ discoveryCache.clear();
226
+ jwksCache.clear();
227
+ }