Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions actions/admin/clients/app-ids.ts
Original file line number Diff line number Diff line change
@@ -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<GetClientAppIdsResult> {
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<ClientAppIdResult> {
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<ClientAppIdResult> {
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" };
}
}
28 changes: 28 additions & 0 deletions app/.well-known/apple-app-site-association/route.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
12 changes: 11 additions & 1 deletion app/admin/dashboard/clients/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -53,6 +62,7 @@ export default async function EditClientPage({ params }: PageProps) {
<ClientDetailTabs
client={client}
whitelistEmails={whitelistEmails}
appIds={appIds}
endpoints={endpoints}
/>
);
Expand Down
63 changes: 63 additions & 0 deletions app/api/e2e/oauth-client-app-ids/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading