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
105 changes: 105 additions & 0 deletions app/api/auth/ui-schema/signin/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, expect, test, mock, beforeEach } from "bun:test";

const FIRST_PARTY_CLIENT = {
id: "macos-test-app",
clientType: "public" as const,
secret: null as string | null,
name: "macOS Test App",
description: null,
iconUrl: null,
redirectUris: JSON.stringify(["rxauthswift://callback"]),
allowedScopes: JSON.stringify(["openid", "email", "profile"]),
isFirstParty: true,
signInPermission: "all" as const,
permissions: null,
createdAt: new Date(),
updatedAt: new Date(),
};

const findClient = mock();

mock.module("@/lib/db", () => ({
db: {
query: {
oauthClients: { findFirst: findClient },
},
},
}));

const { GET } = await import("./route");

function makeRequest(search: string = ""): Request {
const url = `https://auth.rxlab.app/api/auth/ui-schema/signin${search ? `?${search}` : ""}`;
return new Request(url);
}

describe("GET /api/auth/ui-schema/signin", () => {
beforeEach(() => {
findClient.mockReset();
});

test("first-party client returns password + passkey methods", async () => {
findClient.mockResolvedValue(FIRST_PARTY_CLIENT);

const res = await GET(makeRequest(`client_id=${FIRST_PARTY_CLIENT.id}`) as never);
expect(res.status).toBe(200);
const body = await res.json();

expect(body.flow).toBe("signin");
expect(body.title).toBe("Sign in to macOS Test App");
expect(body.submitLabel).toBe("Sign in");

const methodIds = body.supportedMethods.map((m: { id: string }) => m.id);
expect(methodIds).toEqual(["password", "passkey"]);

const fieldKeys = body.fields.map((f: { key: string }) => f.key);
expect(fieldKeys).toEqual(["email", "password"]);
});

test("unknown client_id returns 404 invalid_client", async () => {
findClient.mockResolvedValue(undefined);

const res = await GET(makeRequest("client_id=ghost") as never);
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toBe("invalid_client");
});

test("no client_id returns default schema (no methods)", async () => {
const res = await GET(makeRequest() as never);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.title).toBe("Sign in to RxLab");
expect(body.supportedMethods).toEqual([]);
// findClient must not be invoked when client_id is absent.
expect(findClient).not.toHaveBeenCalled();
});

test("response shape is stable", async () => {
findClient.mockResolvedValue(FIRST_PARTY_CLIENT);
const res = await GET(makeRequest(`client_id=${FIRST_PARTY_CLIENT.id}`) as never);
const body = await res.json();
expect(Object.keys(body).sort()).toEqual([
"fields",
"flow",
"links",
"submitLabel",
"supportedMethods",
"title",
]);
for (const field of body.fields) {
expect(Object.keys(field).sort()).toEqual(
[
"autocomplete",
"isPassword",
"key",
"label",
"placeholder",
"required",
"type",
"validation",
].sort(),
);
}
});
});
35 changes: 35 additions & 0 deletions app/api/auth/ui-schema/signin/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { oauthClients } from "@/lib/db/schema";
import { buildSigninSchema, type OAuthClientLite } from "@/lib/ui-schema/build";

// GET /api/auth/ui-schema/signin?client_id=<id>
//
// Public endpoint. Returns a JSON description of the sign-in form so native
// clients (e.g. RxAuthSwift on macOS) can render the UI without re-shipping.
// `client_id` is optional; if omitted we return a sane default schema.
// Unknown `client_id` → 404.
export async function GET(request: NextRequest) {
const clientId = new URL(request.url).searchParams.get("client_id");

let client: OAuthClientLite | null = null;
if (clientId) {
const row = await db.query.oauthClients.findFirst({
where: eq(oauthClients.id, clientId),
});
if (!row) {
return NextResponse.json(
{ error: "invalid_client", error_description: "Client not found" },
{ status: 404 },
);
}
client = {
id: row.id,
name: row.name,
signInPermission: row.signInPermission,
};
}

return NextResponse.json(buildSigninSchema({ client }));
}
135 changes: 135 additions & 0 deletions app/api/auth/ui-schema/signup/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect, test, mock, beforeEach } from "bun:test";

const FIRST_PARTY_CLIENT = {
id: "macos-test-app",
clientType: "public" as const,
secret: null as string | null,
name: "macOS Test App",
description: null,
iconUrl: null,
redirectUris: JSON.stringify(["rxauthswift://callback"]),
allowedScopes: JSON.stringify(["openid", "email", "profile"]),
isFirstParty: true,
signInPermission: "all" as const,
permissions: null,
createdAt: new Date(),
updatedAt: new Date(),
};

const findClient = mock();
const getSignUpStatusMock = mock();

mock.module("@/lib/db", () => ({
db: {
query: {
oauthClients: { findFirst: findClient },
},
},
}));

mock.module("@/lib/settings/sign-up", () => ({
getSignUpStatus: getSignUpStatusMock,
}));

const { GET } = await import("./route");

function makeRequest(search: string = ""): Request {
const url = `https://auth.rxlab.app/api/auth/ui-schema/signup${search ? `?${search}` : ""}`;
return new Request(url);
}

describe("GET /api/auth/ui-schema/signup", () => {
beforeEach(() => {
findClient.mockReset();
getSignUpStatusMock.mockReset();
getSignUpStatusMock.mockResolvedValue({
publicSignUpEnabled: true,
whitelistEnabled: false,
isCompletelyDisabled: false,
});
delete process.env.UI_SCHEMA_PASSKEY_ACCOUNT_CREATION;
});

test("sign-up allowed returns password + passkey_account_creation by default (legacy passkey suppressed)", async () => {
findClient.mockResolvedValue(FIRST_PARTY_CLIENT);

const res = await GET(makeRequest(`client_id=${FIRST_PARTY_CLIENT.id}`) as never);
expect(res.status).toBe(200);
const body = await res.json();

expect(body.flow).toBe("signup");
expect(body.title).toBe("Create your macOS Test App account");
expect(body.submitLabel).toBe("Create account");

const methodIds = body.supportedMethods.map((m: { id: string }) => m.id);
expect(methodIds).toEqual(["password", "passkey_account_creation"]);
expect(methodIds).not.toContain("passkey");

const fieldKeys = body.fields.map((f: { key: string }) => f.key);
expect(fieldKeys).toEqual(["email", "password", "name"]);
});

test("UI_SCHEMA_PASSKEY_ACCOUNT_CREATION=false omits passkey_account_creation", async () => {
process.env.UI_SCHEMA_PASSKEY_ACCOUNT_CREATION = "false";
findClient.mockResolvedValue(FIRST_PARTY_CLIENT);

const res = await GET(makeRequest(`client_id=${FIRST_PARTY_CLIENT.id}`) as never);
const body = await res.json();
const methodIds = body.supportedMethods.map((m: { id: string }) => m.id);
expect(methodIds).toEqual(["password", "passkey"]);
});

test("sign-up disabled omits all methods", async () => {
findClient.mockResolvedValue(FIRST_PARTY_CLIENT);
getSignUpStatusMock.mockResolvedValue({
publicSignUpEnabled: false,
whitelistEnabled: false,
isCompletelyDisabled: true,
});

const res = await GET(makeRequest(`client_id=${FIRST_PARTY_CLIENT.id}`) as never);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.supportedMethods).toEqual([]);
// Fields still present so client can render the form layout for an
// "invite-only / disabled" empty state.
const fieldKeys = body.fields.map((f: { key: string }) => f.key);
expect(fieldKeys).toEqual(["email", "password", "name"]);
});

test("whitelist-only mode still treats sign-up as form-level allowed", async () => {
findClient.mockResolvedValue(FIRST_PARTY_CLIENT);
getSignUpStatusMock.mockResolvedValue({
publicSignUpEnabled: false,
whitelistEnabled: true,
isCompletelyDisabled: false,
});

const res = await GET(makeRequest(`client_id=${FIRST_PARTY_CLIENT.id}`) as never);
const body = await res.json();
const methodIds = body.supportedMethods.map((m: { id: string }) => m.id);
expect(methodIds).toEqual(["password", "passkey_account_creation"]);
});

test("unknown client_id returns 404 invalid_client", async () => {
findClient.mockResolvedValue(undefined);
const res = await GET(makeRequest("client_id=ghost") as never);
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toBe("invalid_client");
});

test("response shape is stable", async () => {
findClient.mockResolvedValue(FIRST_PARTY_CLIENT);
const res = await GET(makeRequest(`client_id=${FIRST_PARTY_CLIENT.id}`) as never);
const body = await res.json();
expect(Object.keys(body).sort()).toEqual([
"fields",
"flow",
"links",
"submitLabel",
"supportedMethods",
"title",
]);
});
});
52 changes: 52 additions & 0 deletions app/api/auth/ui-schema/signup/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { oauthClients } from "@/lib/db/schema";
import { getSignUpStatus } from "@/lib/settings/sign-up";
import { buildSignupSchema, type OAuthClientLite } from "@/lib/ui-schema/build";

// GET /api/auth/ui-schema/signup?client_id=<id>
//
// Public endpoint. Returns a JSON description of the sign-up form. Mirrors
// /signin but the supportedMethods list is gated on the global sign-up
// settings (see lib/settings/sign-up.ts): when sign-up is fully disabled the
// methods list is empty so the client can render its own "sign-up closed" UI
// without hard-coding the rule.
Comment on lines +8 to +14
export async function GET(request: NextRequest) {
const clientId = new URL(request.url).searchParams.get("client_id");

let client: OAuthClientLite | null = null;
if (clientId) {
const row = await db.query.oauthClients.findFirst({
where: eq(oauthClients.id, clientId),
});
if (!row) {
return NextResponse.json(
{ error: "invalid_client", error_description: "Client not found" },
{ status: 404 },
);
}
client = {
id: row.id,
name: row.name,
signInPermission: row.signInPermission,
};
}

const status = await getSignUpStatus();
// Without a candidate email we can't evaluate the whitelist, so treat
// whitelist-enabled as "sign-up is open at the form level" — the actual
// gate runs at POST /api/oauth/signup.
const signUpAllowed = !status.isCompletelyDisabled;

// The iOS 26 / macOS 26 system-sheet account-creation endpoints
// (POST /api/oauth/passkey/account-creation/{options,verify}) are always
// available — feature-gate at the schema level so non-Apple clients that
// never call them simply ignore the entry.
const accountCreationEnabled =
process.env.UI_SCHEMA_PASSKEY_ACCOUNT_CREATION !== "false";

return NextResponse.json(
buildSignupSchema({ client, signUpAllowed, accountCreationEnabled }),
);
}
Loading