diff --git a/actions/admin/clients/app-ids.ts b/actions/admin/clients/app-ids.ts new file mode 100644 index 0000000..78fe61a --- /dev/null +++ b/actions/admin/clients/app-ids.ts @@ -0,0 +1,109 @@ +"use server"; + +import { db } from "@/lib/db"; +import { oauthClientAppIds } from "@/lib/db/schema"; +import { requireAdmin } from "@/lib/auth/session"; +import { + addClientAppIdSchema, + type AddClientAppIdInput, +} from "@/lib/validations/admin"; +import { eq, and } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import type { OAuthClientAppId } from "@/lib/db/schema"; + +export interface ClientAppIdResult { + success: boolean; + error?: string; + id?: string; +} + +export interface GetClientAppIdsResult { + success: boolean; + error?: string; + data?: OAuthClientAppId[]; +} + +export async function getClientAppIds( + clientId: string +): Promise { + try { + await requireAdmin(); + + const rows = await db.query.oauthClientAppIds.findMany({ + where: eq(oauthClientAppIds.clientId, clientId), + orderBy: (table, { desc }) => [desc(table.createdAt)], + }); + + return { success: true, data: rows }; + } catch (error) { + console.error("Get client app IDs error:", error); + return { success: false, error: "Failed to get client app IDs" }; + } +} + +export async function addClientAppId( + input: AddClientAppIdInput +): Promise { + try { + await requireAdmin(); + + const parsed = addClientAppIdSchema.safeParse(input); + if (!parsed.success) { + return { + success: false, + error: parsed.error.issues[0]?.message || "Invalid input", + }; + } + + const { clientId, appId } = parsed.data; + + const existing = await db.query.oauthClientAppIds.findFirst({ + where: and( + eq(oauthClientAppIds.clientId, clientId), + eq(oauthClientAppIds.appId, appId) + ), + }); + + if (existing) { + return { + success: false, + error: "App ID is already registered for this client", + }; + } + + const id = crypto.randomUUID(); + await db.insert(oauthClientAppIds).values({ + id, + clientId, + appId, + createdAt: new Date(), + }); + + revalidatePath(`/admin/dashboard/clients/${clientId}`); + revalidatePath("/.well-known/apple-app-site-association"); + + return { success: true, id }; + } catch (error) { + console.error("Add client app ID error:", error); + return { success: false, error: "Failed to add app ID" }; + } +} + +export async function removeClientAppId( + id: string, + clientId: string +): Promise { + try { + await requireAdmin(); + + await db.delete(oauthClientAppIds).where(eq(oauthClientAppIds.id, id)); + + revalidatePath(`/admin/dashboard/clients/${clientId}`); + revalidatePath("/.well-known/apple-app-site-association"); + + return { success: true }; + } catch (error) { + console.error("Remove client app ID error:", error); + return { success: false, error: "Failed to remove app ID" }; + } +} diff --git a/app/.well-known/apple-app-site-association/route.ts b/app/.well-known/apple-app-site-association/route.ts new file mode 100644 index 0000000..e875539 --- /dev/null +++ b/app/.well-known/apple-app-site-association/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { oauthClientAppIds } from "@/lib/db/schema"; +import { buildAASA } from "@/lib/aasa/build-aasa"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const rpId = process.env.WEBAUTHN_RP_ID; + if (process.env.NODE_ENV === "production" && (!rpId || rpId === "localhost")) { + console.warn( + "[AASA] WEBAUTHN_RP_ID is unset or 'localhost' in production; passkey domain binding will not work for native apps." + ); + } + + const rows = await db + .select({ appId: oauthClientAppIds.appId }) + .from(oauthClientAppIds); + + const aasa = buildAASA(rows.map((r) => r.appId)); + + return NextResponse.json(aasa, { + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=60", + }, + }); +} diff --git a/app/admin/dashboard/clients/[id]/page.tsx b/app/admin/dashboard/clients/[id]/page.tsx index d2b063f..fd5320a 100644 --- a/app/admin/dashboard/clients/[id]/page.tsx +++ b/app/admin/dashboard/clients/[id]/page.tsx @@ -1,6 +1,10 @@ import { notFound } from "next/navigation"; import { db } from "@/lib/db"; -import { oauthClients, oauthClientEmailWhitelist } from "@/lib/db/schema"; +import { + oauthClients, + oauthClientEmailWhitelist, + oauthClientAppIds, +} from "@/lib/db/schema"; import { ClientDetailTabs } from "@/components/admin/client-detail-tabs"; import { getOpenIDConfiguration } from "@/lib/oauth/discovery"; import { eq } from "drizzle-orm"; @@ -41,6 +45,11 @@ export default async function EditClientPage({ params }: PageProps) { orderBy: (table, { desc }) => [desc(table.createdAt)], }); + const appIds = await db.query.oauthClientAppIds.findMany({ + where: eq(oauthClientAppIds.clientId, id), + orderBy: (table, { desc }) => [desc(table.createdAt)], + }); + const config = getOpenIDConfiguration(); const endpoints = { issuer: config.issuer, @@ -53,6 +62,7 @@ export default async function EditClientPage({ params }: PageProps) { ); diff --git a/app/api/e2e/oauth-client-app-ids/route.ts b/app/api/e2e/oauth-client-app-ids/route.ts new file mode 100644 index 0000000..d039d2b --- /dev/null +++ b/app/api/e2e/oauth-client-app-ids/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { oauthClientAppIds, oauthClients } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; + +// E2E-only: seed Apple app IDs for an OAuth client without going through admin UI. +// Mirrors the pattern in app/api/e2e/password-reset-token/route.ts. + +function e2eGuard() { + if (process.env.E2E_SKIP_EMAIL_VERIFICATION !== "true") { + return NextResponse.json( + { error: "This endpoint is only available in E2E test mode" }, + { status: 403 } + ); + } + return null; +} + +export async function POST(request: NextRequest) { + const blocked = e2eGuard(); + if (blocked) return blocked; + + const body = await request.json().catch(() => null); + if (!body || typeof body.clientId !== "string" || !Array.isArray(body.appIds)) { + return NextResponse.json( + { error: "Expected { clientId: string, appIds: string[] }" }, + { status: 400 } + ); + } + + const client = await db.query.oauthClients.findFirst({ + where: eq(oauthClients.id, body.clientId), + }); + if (!client) { + return NextResponse.json({ error: "Client not found" }, { status: 404 }); + } + + const now = new Date(); + for (const rawAppId of body.appIds) { + if (typeof rawAppId !== "string") continue; + const appId = rawAppId.trim(); + if (!appId) continue; + await db + .insert(oauthClientAppIds) + .values({ + id: crypto.randomUUID(), + clientId: body.clientId, + appId, + createdAt: now, + }) + .onConflictDoNothing(); + } + + return NextResponse.json({ ok: true }); +} + +export async function DELETE() { + const blocked = e2eGuard(); + if (blocked) return blocked; + + await db.delete(oauthClientAppIds); + return NextResponse.json({ ok: true }); +} diff --git a/components/admin/client-app-ids-card.tsx b/components/admin/client-app-ids-card.tsx new file mode 100644 index 0000000..2b00ace --- /dev/null +++ b/components/admin/client-app-ids-card.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { motion } from "framer-motion"; +import { Loader2, Plus, Trash2, Smartphone } from "lucide-react"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + addClientAppId, + removeClientAppId, +} from "@/actions/admin/clients/app-ids"; +import type { OAuthClientAppId } from "@/lib/db/schema"; + +interface ClientAppIdsCardProps { + clientId: string; + initialAppIds: OAuthClientAppId[]; +} + +export function ClientAppIdsCard({ + clientId, + initialAppIds, +}: ClientAppIdsCardProps) { + const [appIds, setAppIds] = useState(initialAppIds); + const [newAppId, setNewAppId] = useState(""); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + const [removingId, setRemovingId] = useState(null); + + const handleAdd = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = newAppId.trim(); + if (!trimmed) return; + + setError(null); + + startTransition(async () => { + const result = await addClientAppId({ clientId, appId: trimmed }); + + if (result.success && result.id) { + setAppIds([ + { + id: result.id, + clientId, + appId: trimmed, + createdAt: new Date(), + }, + ...appIds, + ]); + setNewAppId(""); + } else { + setError(result.error || "Failed to add app ID"); + } + }); + }; + + const handleRemove = (id: string) => { + setRemovingId(id); + setError(null); + + startTransition(async () => { + const result = await removeClientAppId(id, clientId); + + if (result.success) { + setAppIds(appIds.filter((a) => a.id !== id)); + } else { + setError(result.error || "Failed to remove app ID"); + } + setRemovingId(null); + }); + }; + + return ( + + + + + Apple App IDs + + + Native Apple apps (iOS / macOS / watchOS) authorized to use passkeys + for this client. Published in{" "} + + /.well-known/apple-app-site-association + {" "} + under webcredentials.apps. + Format: <TEAMID>.<BUNDLEID>{" "} + (e.g. ABCDE12345.com.example.app). + + + + {error && ( + + {error} + + )} + +
+ setNewAppId(e.target.value)} + disabled={isPending} + data-testid="client-app-id-input" + className="font-mono" + /> + +
+ + {appIds.length === 0 ? ( +

+ No Apple app IDs registered yet +

+ ) : ( +
+ {appIds.map((entry) => ( +
+ {entry.appId} + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/components/admin/client-detail-tabs.tsx b/components/admin/client-detail-tabs.tsx index 08d31fa..9b65e76 100644 --- a/components/admin/client-detail-tabs.tsx +++ b/components/admin/client-detail-tabs.tsx @@ -8,8 +8,12 @@ import { ClientForm } from "@/components/admin/client-form"; import { IconUpload } from "@/components/admin/icon-upload"; import { ClientSignInSettings } from "@/components/admin/client-sign-in-settings"; import { ClientDangerZone } from "@/components/admin/client-danger-zone"; +import { ClientAppIdsCard } from "@/components/admin/client-app-ids-card"; import { OAuthEndpointsCard } from "@/components/admin/oauth-endpoints-card"; -import type { OAuthClientEmailWhitelist } from "@/lib/db/schema"; +import type { + OAuthClientEmailWhitelist, + OAuthClientAppId, +} from "@/lib/db/schema"; interface ClientDetailTabsProps { client: { @@ -23,6 +27,7 @@ interface ClientDetailTabsProps { signInPermission: string; }; whitelistEmails: OAuthClientEmailWhitelist[]; + appIds: OAuthClientAppId[]; endpoints: { issuer: string; authorizationEndpoint: string; @@ -39,7 +44,7 @@ const tabs = [ type TabId = (typeof tabs)[number]["id"]; -export function ClientDetailTabs({ client, whitelistEmails, endpoints }: ClientDetailTabsProps) { +export function ClientDetailTabs({ client, whitelistEmails, appIds, endpoints }: ClientDetailTabsProps) { const [activeTab, setActiveTab] = useState("general"); return ( @@ -112,6 +117,8 @@ export function ClientDetailTabs({ client, whitelistEmails, endpoints }: ClientD /> + + )} diff --git a/e2e/aasa.spec.ts b/e2e/aasa.spec.ts new file mode 100644 index 0000000..bcd4899 --- /dev/null +++ b/e2e/aasa.spec.ts @@ -0,0 +1,84 @@ +import { test, expect } from "@playwright/test"; +import { createOAuthClient, adminLogin } from "./fixtures/test-helpers"; + +const AASA_PATH = "/.well-known/apple-app-site-association"; + +async function clearAppIds(request: import("@playwright/test").APIRequestContext) { + const res = await request.delete("/api/e2e/oauth-client-app-ids"); + expect(res.ok()).toBeTruthy(); +} + +async function seedAppIds( + request: import("@playwright/test").APIRequestContext, + clientId: string, + appIds: string[] +) { + const res = await request.post("/api/e2e/oauth-client-app-ids", { + data: { clientId, appIds }, + }); + expect(res.ok()).toBeTruthy(); +} + +test.describe("apple-app-site-association", () => { + test("returns empty webcredentials.apps when no app IDs registered", async ({ + request, + }) => { + await clearAppIds(request); + + const res = await request.get(AASA_PATH); + expect(res.status()).toBe(200); + expect(res.headers()["content-type"]).toContain("application/json"); + + const body = await res.json(); + expect(body).toEqual({ webcredentials: { apps: [] } }); + }); + + test("returns deduplicated, sorted app IDs across multiple clients", async ({ + page, + request, + }) => { + await clearAppIds(request); + + await adminLogin(page); + + const { clientId: clientA } = await createOAuthClient(page, { + name: `AASA Client A ${Date.now()}`, + redirectUri: "http://localhost:3001/callback", + }); + const { clientId: clientB } = await createOAuthClient(page, { + name: `AASA Client B ${Date.now()}`, + redirectUri: "http://localhost:3001/callback", + }); + + // Client A has two app IDs; Client B has one that overlaps with A + // and one unique. Final union must be deduped and sorted. + await seedAppIds(request, clientA, [ + "ABCDE12345.app.rxlab.macos", + "ABCDE12345.app.rxlab.ios", + ]); + await seedAppIds(request, clientB, [ + "ABCDE12345.app.rxlab.ios", // duplicate of A + "FGHIJ67890.app.rxlab.watch", + ]); + + const res = await request.get(AASA_PATH); + expect(res.status()).toBe(200); + expect(res.headers()["content-type"]).toContain("application/json"); + + const body = await res.json(); + expect(body.webcredentials).toBeDefined(); + const apps = body.webcredentials.apps as string[]; + + expect(apps).toEqual([ + "ABCDE12345.app.rxlab.ios", + "ABCDE12345.app.rxlab.macos", + "FGHIJ67890.app.rxlab.watch", + ]); + + // no duplicates + expect(new Set(apps).size).toBe(apps.length); + + // sorted ascending + expect([...apps].sort()).toEqual(apps); + }); +}); diff --git a/lib/aasa/build-aasa.test.ts b/lib/aasa/build-aasa.test.ts new file mode 100644 index 0000000..76e6fe5 --- /dev/null +++ b/lib/aasa/build-aasa.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "bun:test"; +import { dedupAndSortAppIds, buildAASA } from "./build-aasa"; + +describe("dedupAndSortAppIds", () => { + it("returns [] for empty input", () => { + expect(dedupAndSortAppIds([])).toEqual([]); + }); + + it("sorts lexicographically", () => { + expect( + dedupAndSortAppIds(["ABCDE12345.com.b.app", "ABCDE12345.com.a.app"]) + ).toEqual(["ABCDE12345.com.a.app", "ABCDE12345.com.b.app"]); + }); + + it("removes duplicates", () => { + expect( + dedupAndSortAppIds([ + "ABCDE12345.com.a.app", + "ABCDE12345.com.a.app", + "ABCDE12345.com.b.app", + ]) + ).toEqual(["ABCDE12345.com.a.app", "ABCDE12345.com.b.app"]); + }); + + it("trims whitespace and drops empty entries", () => { + expect( + dedupAndSortAppIds([" ABCDE12345.com.a.app ", "", " "]) + ).toEqual(["ABCDE12345.com.a.app"]); + }); + + it("treats whitespace-only duplicates as one", () => { + expect( + dedupAndSortAppIds([ + "ABCDE12345.com.a.app", + " ABCDE12345.com.a.app ", + ]) + ).toEqual(["ABCDE12345.com.a.app"]); + }); +}); + +describe("buildAASA", () => { + it("wraps app IDs under webcredentials.apps", () => { + expect(buildAASA(["ABCDE12345.com.a.app"])).toEqual({ + webcredentials: { apps: ["ABCDE12345.com.a.app"] }, + }); + }); + + it("returns empty apps array for no input", () => { + expect(buildAASA([])).toEqual({ + webcredentials: { apps: [] }, + }); + }); + + it("dedupes and sorts in the wrapped output", () => { + expect( + buildAASA(["ABCDE12345.com.b.app", "ABCDE12345.com.a.app", "ABCDE12345.com.b.app"]) + ).toEqual({ + webcredentials: { + apps: ["ABCDE12345.com.a.app", "ABCDE12345.com.b.app"], + }, + }); + }); +}); diff --git a/lib/aasa/build-aasa.ts b/lib/aasa/build-aasa.ts new file mode 100644 index 0000000..6c5b397 --- /dev/null +++ b/lib/aasa/build-aasa.ts @@ -0,0 +1,25 @@ +// Apple App Site Association payload builder. +// Apple expects `webcredentials.apps` as a list of `.` entries. + +export interface AASADocument { + webcredentials: { + apps: string[]; + }; +} + +// Deduplicate and lexicographically sort app IDs for a stable AASA payload. +// Whitespace is trimmed; empty strings are dropped. +export function dedupAndSortAppIds(appIds: readonly string[]): string[] { + const cleaned = appIds + .map((id) => id.trim()) + .filter((id) => id.length > 0); + return Array.from(new Set(cleaned)).sort(); +} + +export function buildAASA(appIds: readonly string[]): AASADocument { + return { + webcredentials: { + apps: dedupAndSortAppIds(appIds), + }, + }; +} diff --git a/lib/db/migrations/0006_familiar_mandarin.sql b/lib/db/migrations/0006_familiar_mandarin.sql new file mode 100644 index 0000000..7922f4b --- /dev/null +++ b/lib/db/migrations/0006_familiar_mandarin.sql @@ -0,0 +1,11 @@ +CREATE TABLE `oauth_client_app_ids` ( + `id` text PRIMARY KEY NOT NULL, + `client_id` text NOT NULL, + `app_id` text NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`client_id`) REFERENCES `oauth_clients`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_client_app_ids_client_app_idx` ON `oauth_client_app_ids` (`client_id`,`app_id`);--> statement-breakpoint +CREATE INDEX `oauth_client_app_ids_client_idx` ON `oauth_client_app_ids` (`client_id`);--> statement-breakpoint +CREATE INDEX `oauth_client_app_ids_app_idx` ON `oauth_client_app_ids` (`app_id`); \ No newline at end of file diff --git a/lib/db/migrations/meta/0006_snapshot.json b/lib/db/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..46d499b --- /dev/null +++ b/lib/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,1019 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "64660320-21ed-49ad-a43c-81118462e69d", + "prevId": "unique_0005_easy_avatar", + "tables": { + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + "username" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkeys": { + "name": "passkeys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkeys_user_idx": { + "name": "passkeys_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkeys_user_id_users_id_fk": { + "name": "passkeys_user_id_users_id_fk", + "tableFrom": "passkeys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_verification_tokens": { + "name": "email_verification_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "email_verification_tokens_token_unique": { + "name": "email_verification_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "email_verification_tokens_user_idx": { + "name": "email_verification_tokens_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "email_verification_tokens_token_idx": { + "name": "email_verification_tokens_token_idx", + "columns": [ + "token" + ], + "isUnique": false + } + }, + "foreignKeys": { + "email_verification_tokens_user_id_users_id_fk": { + "name": "email_verification_tokens_user_id_users_id_fk", + "tableFrom": "email_verification_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "password_reset_tokens": { + "name": "password_reset_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "password_reset_tokens_token_unique": { + "name": "password_reset_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "password_reset_tokens_user_idx": { + "name": "password_reset_tokens_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "password_reset_tokens_token_idx": { + "name": "password_reset_tokens_token_idx", + "columns": [ + "token" + ], + "isUnique": false + } + }, + "foreignKeys": { + "password_reset_tokens_user_id_users_id_fk": { + "name": "password_reset_tokens_user_id_users_id_fk", + "tableFrom": "password_reset_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_client_email_whitelist": { + "name": "oauth_client_email_whitelist", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_client_email_whitelist_client_email_idx": { + "name": "oauth_client_email_whitelist_client_email_idx", + "columns": [ + "client_id", + "email" + ], + "isUnique": true + }, + "oauth_client_email_whitelist_client_idx": { + "name": "oauth_client_email_whitelist_client_idx", + "columns": [ + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_client_email_whitelist_client_id_oauth_clients_id_fk": { + "name": "oauth_client_email_whitelist_client_id_oauth_clients_id_fk", + "tableFrom": "oauth_client_email_whitelist", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_clients": { + "name": "oauth_clients", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_type": { + "name": "client_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'confidential'" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allowed_scopes": { + "name": "allowed_scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_first_party": { + "name": "is_first_party", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "sign_in_permission": { + "name": "sign_in_permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'all'" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_clients_name_idx": { + "name": "oauth_clients_name_idx", + "columns": [ + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_client_app_ids": { + "name": "oauth_client_app_ids", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_client_app_ids_client_app_idx": { + "name": "oauth_client_app_ids_client_app_idx", + "columns": [ + "client_id", + "app_id" + ], + "isUnique": true + }, + "oauth_client_app_ids_client_idx": { + "name": "oauth_client_app_ids_client_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "oauth_client_app_ids_app_idx": { + "name": "oauth_client_app_ids_app_idx", + "columns": [ + "app_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_client_app_ids_client_id_oauth_clients_id_fk": { + "name": "oauth_client_app_ids_client_id_oauth_clients_id_fk", + "tableFrom": "oauth_client_app_ids", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_consents": { + "name": "oauth_consents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_consents_user_client_idx": { + "name": "oauth_consents_user_client_idx", + "columns": [ + "user_id", + "client_id" + ], + "isUnique": true + }, + "oauth_consents_user_idx": { + "name": "oauth_consents_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "oauth_consents_client_idx": { + "name": "oauth_consents_client_idx", + "columns": [ + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consents_client_id_oauth_clients_id_fk": { + "name": "oauth_consents_client_id_oauth_clients_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "oauth_refresh_tokens_token_unique": { + "name": "oauth_refresh_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "oauth_refresh_tokens_user_idx": { + "name": "oauth_refresh_tokens_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "oauth_refresh_tokens_token_idx": { + "name": "oauth_refresh_tokens_token_idx", + "columns": [ + "token" + ], + "isUnique": false + }, + "oauth_refresh_tokens_client_idx": { + "name": "oauth_refresh_tokens_client_idx", + "columns": [ + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_refresh_tokens_user_id_users_id_fk": { + "name": "oauth_refresh_tokens_user_id_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_client_id_oauth_clients_id_fk": { + "name": "oauth_refresh_tokens_client_id_oauth_clients_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_passkeys": { + "name": "admin_passkeys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_settings": { + "name": "app_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sign_up_enabled": { + "name": "sign_up_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sign_up_whitelist_enabled": { + "name": "sign_up_whitelist_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_whitelist": { + "name": "email_whitelist", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "email_whitelist_email_idx": { + "name": "email_whitelist_email_idx", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index cefbf6b..b4d0a83 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1772582400000, "tag": "0005_easy_avatar", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1779778803950, + "tag": "0006_familiar_mandarin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/schema/index.ts b/lib/db/schema/index.ts index 91ffdcb..b59b553 100644 --- a/lib/db/schema/index.ts +++ b/lib/db/schema/index.ts @@ -6,6 +6,7 @@ export * from "./password-reset-tokens"; // OAuth-related schemas export * from "./oauth-clients"; +export * from "./oauth-client-app-ids"; export * from "./oauth-consents"; export * from "./oauth-refresh-tokens"; diff --git a/lib/db/schema/oauth-client-app-ids.ts b/lib/db/schema/oauth-client-app-ids.ts new file mode 100644 index 0000000..a345adb --- /dev/null +++ b/lib/db/schema/oauth-client-app-ids.ts @@ -0,0 +1,33 @@ +import { + sqliteTable, + text, + integer, + index, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; +import { oauthClients } from "./oauth-clients"; + +// Apple app identifiers (`.`) registered to an OAuth client. +// Read by the apple-app-site-association route to populate `webcredentials.apps`. +export const oauthClientAppIds = sqliteTable( + "oauth_client_app_ids", + { + id: text("id").primaryKey(), // UUID + clientId: text("client_id") + .notNull() + .references(() => oauthClients.id, { onDelete: "cascade" }), + appId: text("app_id").notNull(), // . + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), + }, + (table) => [ + uniqueIndex("oauth_client_app_ids_client_app_idx").on( + table.clientId, + table.appId + ), + index("oauth_client_app_ids_client_idx").on(table.clientId), + index("oauth_client_app_ids_app_idx").on(table.appId), + ] +); + +export type OAuthClientAppId = typeof oauthClientAppIds.$inferSelect; +export type NewOAuthClientAppId = typeof oauthClientAppIds.$inferInsert; diff --git a/lib/validations/admin.ts b/lib/validations/admin.ts index 0d09ca0..b29b084 100644 --- a/lib/validations/admin.ts +++ b/lib/validations/admin.ts @@ -92,6 +92,23 @@ export const addClientWhitelistEmailSchema = z.object({ clientId: z.string().min(1, "Client ID is required"), }); +// Apple app identifier: . +// - TEAMID: 10-char uppercase alphanumeric Apple Developer Team ID +// - BUNDLEID: reverse-DNS bundle id (letters, digits, dots, dashes) +const appleAppIdRegex = /^[A-Z0-9]{10}\.[A-Za-z0-9][A-Za-z0-9.\-]*$/; + +export const addClientAppIdSchema = z.object({ + clientId: z.string().min(1, "Client ID is required"), + appId: z + .string() + .min(1, "App ID is required") + .max(255, "App ID is too long") + .regex( + appleAppIdRegex, + "Must be in the form . (e.g. ABCDE12345.com.example.app)" + ), +}); + export type UpdateSignUpSettingsInput = z.infer< typeof updateSignUpSettingsSchema >; @@ -99,6 +116,7 @@ export type AddWhitelistEmailInput = z.infer; export type AddClientWhitelistEmailInput = z.infer< typeof addClientWhitelistEmailSchema >; +export type AddClientAppIdInput = z.infer; // User management schemas export const createUserSchema = z.object({