diff --git a/app/api/oauth/signup/route.test.ts b/app/api/oauth/signup/route.test.ts
new file mode 100644
index 0000000..41cf876
--- /dev/null
+++ b/app/api/oauth/signup/route.test.ts
@@ -0,0 +1,290 @@
+import { describe, expect, test, mock, beforeEach } from "bun:test";
+
+const VALID_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 findExistingUser = mock();
+const checkSignUpAllowedMock = mock();
+const hashPasswordMock = mock();
+const signAccessTokenMock = mock();
+const signIdTokenMock = mock();
+const generateRefreshTokenMock = mock();
+const generateAvatarSeedMock = mock();
+const sendEmailMock = mock();
+const insertValues = mock();
+const transactionMock = mock();
+
+mock.module("@/lib/db", () => ({
+ db: {
+ query: {
+ oauthClients: { findFirst: findClient },
+ users: { findFirst: findExistingUser },
+ },
+ insert: () => ({ values: insertValues }),
+ transaction: transactionMock,
+ },
+}));
+
+mock.module("@/lib/auth/password", () => ({
+ hashPassword: hashPasswordMock,
+ verifyPassword: mock(),
+}));
+
+mock.module("@/lib/oauth/jwt", () => ({
+ signAccessToken: signAccessTokenMock,
+ signIdToken: signIdTokenMock,
+ generateRefreshToken: generateRefreshTokenMock,
+}));
+
+mock.module("@/lib/settings/sign-up", () => ({
+ checkSignUpAllowed: checkSignUpAllowedMock,
+}));
+
+mock.module("@/lib/identicon/generate", () => ({
+ generateAvatarSeed: generateAvatarSeedMock,
+}));
+
+mock.module("@/lib/email/resend", () => ({
+ sendEmail: sendEmailMock,
+}));
+
+mock.module("@/lib/email/templates", () => ({
+ getVerificationEmailHtml: () => "
",
+ getVerificationEmailText: () => "text",
+}));
+
+const { POST } = await import("./route");
+
+function makeRequest(body: Record): Request {
+ return new Request("https://auth.rxlab.app/api/oauth/signup", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+describe("POST /api/oauth/signup", () => {
+ beforeEach(() => {
+ findClient.mockReset();
+ findExistingUser.mockReset();
+ checkSignUpAllowedMock.mockReset();
+ hashPasswordMock.mockReset();
+ signAccessTokenMock.mockReset();
+ signIdTokenMock.mockReset();
+ generateRefreshTokenMock.mockReset();
+ generateAvatarSeedMock.mockReset();
+ sendEmailMock.mockReset();
+ insertValues.mockReset();
+ transactionMock.mockReset();
+
+ findClient.mockResolvedValue(VALID_CLIENT);
+ findExistingUser.mockResolvedValue(undefined);
+ checkSignUpAllowedMock.mockResolvedValue({ allowed: true });
+ hashPasswordMock.mockResolvedValue("argon2-hash");
+ signAccessTokenMock.mockResolvedValue("access-token-stub");
+ signIdTokenMock.mockResolvedValue("id-token-stub");
+ generateRefreshTokenMock.mockReturnValue("refresh-token-stub");
+ generateAvatarSeedMock.mockReturnValue("seed-stub");
+ sendEmailMock.mockResolvedValue(undefined);
+ insertValues.mockResolvedValue(undefined);
+ // Make tx wrapper invoke the callback with a tx that proxies to insertValues.
+ transactionMock.mockImplementation(async (cb: (tx: unknown) => unknown) => {
+ const tx = { insert: () => ({ values: insertValues }) };
+ await cb(tx);
+ });
+
+ process.env.E2E_SKIP_EMAIL_VERIFICATION = "true";
+ });
+
+ test("E2E mode happy path: creates verified user and returns tokens", async () => {
+ const res = await POST(
+ makeRequest({
+ client_id: VALID_CLIENT.id,
+ username: "new@example.com",
+ password: "correct-horse",
+ name: "New User",
+ scope: "openid email",
+ }) as never,
+ );
+
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.access_token).toBe("access-token-stub");
+ expect(body.refresh_token).toBe("refresh-token-stub");
+ expect(body.id_token).toBe("id-token-stub");
+ expect(body.token_type).toBe("Bearer");
+ expect(body.scope).toBe("openid email");
+ expect(hashPasswordMock).toHaveBeenCalledWith("correct-horse");
+ // 2 inserts: users + oauthRefreshTokens
+ expect(insertValues).toHaveBeenCalledTimes(2);
+ });
+
+ test("non-E2E mode: creates unverified user, sends email, returns user_id", async () => {
+ process.env.E2E_SKIP_EMAIL_VERIFICATION = "false";
+
+ const res = await POST(
+ makeRequest({
+ client_id: VALID_CLIENT.id,
+ username: "new@example.com",
+ password: "correct-horse",
+ }) as never,
+ );
+
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.email_verification_required).toBe(true);
+ expect(body.email).toBe("new@example.com");
+ expect(typeof body.user_id).toBe("string");
+ expect(body.access_token).toBeUndefined();
+ expect(sendEmailMock).toHaveBeenCalled();
+ expect(transactionMock).toHaveBeenCalled();
+ });
+
+ test("invalid_request: rejects malformed JSON body", async () => {
+ const res = await POST(
+ new Request("https://auth.rxlab.app/api/oauth/signup", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{not-json",
+ }) as never,
+ );
+
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error).toBe("invalid_request");
+ });
+
+ test("invalid_request: rejects non-email username", async () => {
+ const res = await POST(
+ makeRequest({
+ client_id: VALID_CLIENT.id,
+ username: "not-an-email",
+ password: "correct-horse",
+ }) as never,
+ );
+
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error).toBe("invalid_request");
+ });
+
+ test("invalid_client: rejects unknown client", async () => {
+ findClient.mockResolvedValue(undefined);
+
+ const res = await POST(
+ makeRequest({
+ client_id: "ghost-client",
+ username: "new@example.com",
+ password: "correct-horse",
+ }) as never,
+ );
+
+ expect(res.status).toBe(401);
+ const body = await res.json();
+ expect(body.error).toBe("invalid_client");
+ });
+
+ test("unauthorized_client: rejects client with signInPermission != all", async () => {
+ findClient.mockResolvedValue({
+ ...VALID_CLIENT,
+ signInPermission: "whitelist",
+ });
+
+ const res = await POST(
+ makeRequest({
+ client_id: VALID_CLIENT.id,
+ username: "new@example.com",
+ password: "correct-horse",
+ }) as never,
+ );
+
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error).toBe("unauthorized_client");
+ });
+
+ test("user_exists: rejects duplicate email", async () => {
+ findExistingUser.mockResolvedValue({ id: "existing", email: "new@example.com" });
+
+ const res = await POST(
+ makeRequest({
+ client_id: VALID_CLIENT.id,
+ username: "new@example.com",
+ password: "correct-horse",
+ }) as never,
+ );
+
+ expect(res.status).toBe(409);
+ const body = await res.json();
+ expect(body.error).toBe("user_exists");
+ });
+
+ test("access_denied: rejects when sign-up disabled", async () => {
+ checkSignUpAllowedMock.mockResolvedValue({
+ allowed: false,
+ reason: "disabled",
+ });
+
+ const res = await POST(
+ makeRequest({
+ client_id: VALID_CLIENT.id,
+ username: "new@example.com",
+ password: "correct-horse",
+ }) as never,
+ );
+
+ expect(res.status).toBe(403);
+ const body = await res.json();
+ expect(body.error).toBe("access_denied");
+ expect(body.error_description).toContain("disabled");
+ });
+
+ test("access_denied: rejects when email not on whitelist", async () => {
+ checkSignUpAllowedMock.mockResolvedValue({
+ allowed: false,
+ reason: "not_whitelisted",
+ });
+
+ const res = await POST(
+ makeRequest({
+ client_id: VALID_CLIENT.id,
+ username: "new@example.com",
+ password: "correct-horse",
+ }) as never,
+ );
+
+ expect(res.status).toBe(403);
+ const body = await res.json();
+ expect(body.error).toBe("access_denied");
+ expect(body.error_description).toContain("approved list");
+ });
+
+ test("invalid_scope: rejects scopes outside client's allowed_scopes", async () => {
+ const res = await POST(
+ makeRequest({
+ client_id: VALID_CLIENT.id,
+ username: "new@example.com",
+ password: "correct-horse",
+ scope: "openid admin:all",
+ }) as never,
+ );
+
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error).toBe("invalid_scope");
+ });
+});
diff --git a/app/api/oauth/signup/route.ts b/app/api/oauth/signup/route.ts
new file mode 100644
index 0000000..da28ab2
--- /dev/null
+++ b/app/api/oauth/signup/route.ts
@@ -0,0 +1,255 @@
+import { NextRequest, NextResponse } from "next/server";
+import { eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import {
+ oauthClients,
+ oauthRefreshTokens,
+ users,
+ emailVerificationTokens,
+} from "@/lib/db/schema";
+import { hashPassword } from "@/lib/auth/password";
+import {
+ signAccessToken,
+ signIdToken,
+ generateRefreshToken,
+} from "@/lib/oauth/jwt";
+import { signupRequestSchema } from "@/lib/validations/oauth";
+import { checkSignUpAllowed } from "@/lib/settings/sign-up";
+import { generateAvatarSeed } from "@/lib/identicon/generate";
+import { sendEmail } from "@/lib/email/resend";
+import {
+ getVerificationEmailHtml,
+ getVerificationEmailText,
+} from "@/lib/email/templates";
+
+// POST /api/oauth/signup
+//
+// Native registration endpoint for first-party clients (e.g. the macOS test app).
+// Body (JSON): { client_id, username (email), password, name?, scope? }
+//
+// Two-mode response:
+// * E2E_SKIP_EMAIL_VERIFICATION=true → user created verified + tokens issued
+// immediately (shape matches POST /api/oauth/token).
+// * otherwise → user created unverified, verification email sent, returns
+// { user_id, email, email_verification_required: true }.
+export async function POST(request: NextRequest) {
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json(
+ { error: "invalid_request", error_description: "Body must be JSON" },
+ { status: 400 },
+ );
+ }
+
+ const parsed = signupRequestSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ {
+ error: "invalid_request",
+ error_description: parsed.error.issues[0]?.message,
+ },
+ { status: 400 },
+ );
+ }
+
+ const data = parsed.data;
+ const email = data.username.toLowerCase();
+
+ // Validate client
+ const client = await db.query.oauthClients.findFirst({
+ where: eq(oauthClients.id, data.client_id),
+ });
+ if (!client) {
+ return NextResponse.json(
+ { error: "invalid_client", error_description: "Client not found" },
+ { status: 401 },
+ );
+ }
+
+ // First-party / open sign-in clients only. Whitelist clients shouldn't
+ // accept native sign-up since the user doesn't exist yet to whitelist.
+ if (client.signInPermission !== "all") {
+ return NextResponse.json(
+ {
+ error: "unauthorized_client",
+ error_description:
+ "Native sign-up is only available for first-party clients",
+ },
+ { status: 400 },
+ );
+ }
+
+ // Scope validation (against client's allowed_scopes).
+ const allowedScopes: string[] = JSON.parse(client.allowedScopes);
+ let requestedScopes: string[];
+ if (data.scope) {
+ requestedScopes = data.scope.split(" ").filter(Boolean);
+ const invalidScopes = requestedScopes.filter(
+ (s) => !allowedScopes.includes(s),
+ );
+ if (invalidScopes.length > 0) {
+ return NextResponse.json(
+ {
+ error: "invalid_scope",
+ error_description: `Requested scope(s) not allowed: ${invalidScopes.join(", ")}`,
+ },
+ { status: 400 },
+ );
+ }
+ } else {
+ requestedScopes = allowedScopes;
+ }
+
+ // Global / whitelist sign-up gate.
+ const signUpCheck = await checkSignUpAllowed(email);
+ if (!signUpCheck.allowed) {
+ return NextResponse.json(
+ {
+ error: "access_denied",
+ error_description:
+ signUpCheck.reason === "disabled"
+ ? "Sign-up is currently disabled"
+ : "Sign-up is restricted. Your email is not on the approved list",
+ },
+ { status: 403 },
+ );
+ }
+
+ // Reject duplicate email.
+ const existing = await db.query.users.findFirst({
+ where: eq(users.email, email),
+ });
+ if (existing) {
+ return NextResponse.json(
+ {
+ error: "user_exists",
+ error_description: "An account with this email already exists",
+ },
+ { status: 409 },
+ );
+ }
+
+ const userId = crypto.randomUUID();
+ const avatarSeed = generateAvatarSeed();
+ const now = new Date();
+ const passwordHash = await hashPassword(data.password);
+ const displayName = data.name || email.split("@")[0];
+
+ const e2eMode = process.env.E2E_SKIP_EMAIL_VERIFICATION === "true";
+
+ try {
+ if (e2eMode) {
+ // E2E: create verified, no email.
+ await db.insert(users).values({
+ id: userId,
+ email,
+ passwordHash,
+ displayName,
+ avatarSeed,
+ emailVerified: true,
+ createdAt: now,
+ updatedAt: now,
+ });
+ } else {
+ // Production native flow: create unverified user + send verification email.
+ // Mirrors actions/auth/register.ts (the email-verification branch).
+ const token = crypto.randomUUID();
+ const tokenId = crypto.randomUUID();
+ const tokenExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
+
+ await db.transaction(async (tx) => {
+ await tx.insert(users).values({
+ id: userId,
+ email,
+ passwordHash,
+ displayName,
+ avatarSeed,
+ emailVerified: false,
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ await tx.insert(emailVerificationTokens).values({
+ id: tokenId,
+ userId,
+ token,
+ expiresAt: tokenExpiresAt,
+ createdAt: now,
+ });
+
+ await sendEmail({
+ to: email,
+ subject: "Verify your email address",
+ html: getVerificationEmailHtml(token),
+ text: getVerificationEmailText(token),
+ });
+ });
+
+ return NextResponse.json(
+ {
+ user_id: userId,
+ email,
+ email_verification_required: true,
+ },
+ { status: 201 },
+ );
+ }
+ } catch (error) {
+ console.error("Signup error:", error);
+ return NextResponse.json(
+ { error: "server_error", error_description: "Failed to create account" },
+ { status: 500 },
+ );
+ }
+
+ // E2E branch only: issue tokens identical to the authorization_code grant.
+ const scopeString = requestedScopes.join(" ");
+
+ const accessToken = await signAccessToken({
+ sub: userId,
+ client_id: client.id,
+ scope: scopeString,
+ });
+
+ let idToken: string | undefined;
+ if (requestedScopes.includes("openid")) {
+ idToken = await signIdToken(
+ {
+ sub: userId,
+ email: requestedScopes.includes("email") ? email : undefined,
+ email_verified: requestedScopes.includes("email") ? true : undefined,
+ name: displayName,
+ picture: `${process.env.OAUTH_ISSUER_URL}/api/avatar/${avatarSeed}`,
+ auth_time: Math.floor(Date.now() / 1000),
+ },
+ client.id,
+ );
+ }
+
+ const refreshToken = generateRefreshToken();
+ const refreshExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
+
+ await db.insert(oauthRefreshTokens).values({
+ id: crypto.randomUUID(),
+ token: refreshToken,
+ userId,
+ clientId: client.id,
+ scopes: JSON.stringify(requestedScopes),
+ expiresAt: refreshExpiresAt,
+ createdAt: now,
+ });
+
+ return NextResponse.json(
+ {
+ access_token: accessToken,
+ token_type: "Bearer",
+ expires_in: 3600,
+ ...(idToken ? { id_token: idToken } : {}),
+ scope: scopeString,
+ refresh_token: refreshToken,
+ },
+ { status: 201 },
+ );
+}
diff --git a/app/api/oauth/token/password-grant.test.ts b/app/api/oauth/token/password-grant.test.ts
new file mode 100644
index 0000000..9e0b82d
--- /dev/null
+++ b/app/api/oauth/token/password-grant.test.ts
@@ -0,0 +1,257 @@
+import { describe, expect, test, mock, beforeEach } from "bun:test";
+
+// Test fixtures
+const VALID_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 VALID_USER = {
+ id: "user-1",
+ email: "user@example.com",
+ emailVerified: true,
+ passwordHash: "argon2-hash-stub",
+ username: "user1",
+ displayName: "Test User",
+ avatarSeed: null,
+ avatarUrl: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+};
+
+// Mocks
+const findClient = mock();
+const findUser = mock();
+const findConsent = mock();
+const findWhitelistEntry = mock();
+const verifyPasswordMock = mock();
+const signAccessTokenMock = mock();
+const signIdTokenMock = mock();
+const generateRefreshTokenMock = mock();
+const insertValues = mock();
+
+mock.module("@/lib/db", () => ({
+ db: {
+ query: {
+ oauthClients: { findFirst: findClient },
+ users: { findFirst: findUser },
+ oauthConsents: { findFirst: findConsent },
+ oauthClientEmailWhitelist: { findFirst: findWhitelistEntry },
+ },
+ insert: () => ({ values: insertValues }),
+ },
+}));
+
+mock.module("@/lib/auth/password", () => ({
+ verifyPassword: verifyPasswordMock,
+ hashPassword: mock(),
+}));
+
+mock.module("@/lib/oauth/jwt", () => ({
+ signAccessToken: signAccessTokenMock,
+ signIdToken: signIdTokenMock,
+ generateRefreshToken: generateRefreshTokenMock,
+}));
+
+mock.module("@/lib/redis", () => ({
+ getOAuthCode: mock(),
+ deleteOAuthCode: mock(),
+}));
+
+const { POST } = await import("./route");
+
+function makeRequest(body: Record): Request {
+ const form = new URLSearchParams(body);
+ return new Request("https://auth.rxlab.app/api/oauth/token", {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ body: form.toString(),
+ });
+}
+
+describe("POST /api/oauth/token - grant_type=password", () => {
+ beforeEach(() => {
+ findClient.mockReset();
+ findUser.mockReset();
+ findConsent.mockReset();
+ findWhitelistEntry.mockReset();
+ verifyPasswordMock.mockReset();
+ signAccessTokenMock.mockReset();
+ signIdTokenMock.mockReset();
+ generateRefreshTokenMock.mockReset();
+ insertValues.mockReset();
+
+ findClient.mockResolvedValue(VALID_CLIENT);
+ findUser.mockResolvedValue(VALID_USER);
+ findConsent.mockResolvedValue(null);
+ findWhitelistEntry.mockResolvedValue(null);
+ verifyPasswordMock.mockResolvedValue(true);
+ signAccessTokenMock.mockResolvedValue("access-token-stub");
+ signIdTokenMock.mockResolvedValue("id-token-stub");
+ generateRefreshTokenMock.mockReturnValue("refresh-token-stub");
+ insertValues.mockResolvedValue(undefined);
+ });
+
+ test("happy path: issues access + refresh tokens for valid credentials", async () => {
+ const res = await POST(
+ makeRequest({
+ grant_type: "password",
+ username: "user@example.com",
+ password: "correct-horse",
+ client_id: VALID_CLIENT.id,
+ scope: "openid email",
+ }) as never,
+ );
+
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.access_token).toBe("access-token-stub");
+ expect(body.refresh_token).toBe("refresh-token-stub");
+ expect(body.id_token).toBe("id-token-stub");
+ expect(body.token_type).toBe("Bearer");
+ expect(body.scope).toBe("openid email");
+ expect(verifyPasswordMock).toHaveBeenCalledWith(
+ VALID_USER.passwordHash,
+ "correct-horse",
+ );
+ expect(insertValues).toHaveBeenCalled();
+ });
+
+ test("happy path: omits id_token when openid scope not requested", async () => {
+ const res = await POST(
+ makeRequest({
+ grant_type: "password",
+ username: "user@example.com",
+ password: "correct-horse",
+ client_id: VALID_CLIENT.id,
+ scope: "email",
+ }) as never,
+ );
+
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.access_token).toBe("access-token-stub");
+ expect(body.id_token).toBeUndefined();
+ expect(signIdTokenMock).not.toHaveBeenCalled();
+ });
+
+ test("invalid_grant: returns 400 when password does not match", async () => {
+ verifyPasswordMock.mockResolvedValue(false);
+
+ const res = await POST(
+ makeRequest({
+ grant_type: "password",
+ username: "user@example.com",
+ password: "wrong",
+ client_id: VALID_CLIENT.id,
+ }) as never,
+ );
+
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error).toBe("invalid_grant");
+ expect(body.error_description).toBe("Invalid username or password");
+ expect(insertValues).not.toHaveBeenCalled();
+ });
+
+ test("invalid_grant: returns 400 when user does not exist", async () => {
+ findUser.mockResolvedValue(undefined);
+
+ const res = await POST(
+ makeRequest({
+ grant_type: "password",
+ username: "ghost@example.com",
+ password: "whatever",
+ client_id: VALID_CLIENT.id,
+ }) as never,
+ );
+
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error).toBe("invalid_grant");
+ expect(body.error_description).toBe("Invalid username or password");
+ });
+
+ test("invalid_grant: returns 400 when account is passkey-only (no passwordHash)", async () => {
+ findUser.mockResolvedValue({ ...VALID_USER, passwordHash: null });
+
+ const res = await POST(
+ makeRequest({
+ grant_type: "password",
+ username: "user@example.com",
+ password: "anything",
+ client_id: VALID_CLIENT.id,
+ }) as never,
+ );
+
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error).toBe("invalid_grant");
+ });
+
+ test("unauthorized_client: rejects client with signInPermission != all", async () => {
+ findClient.mockResolvedValue({
+ ...VALID_CLIENT,
+ signInPermission: "whitelist",
+ });
+
+ const res = await POST(
+ makeRequest({
+ grant_type: "password",
+ username: "user@example.com",
+ password: "correct-horse",
+ client_id: VALID_CLIENT.id,
+ }) as never,
+ );
+
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error).toBe("unauthorized_client");
+ });
+
+ test("invalid_grant: rejects user with unverified email", async () => {
+ delete process.env.E2E_SKIP_EMAIL_VERIFICATION;
+ findUser.mockResolvedValue({ ...VALID_USER, emailVerified: false });
+
+ const res = await POST(
+ makeRequest({
+ grant_type: "password",
+ username: "user@example.com",
+ password: "correct-horse",
+ client_id: VALID_CLIENT.id,
+ }) as never,
+ );
+
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error).toBe("invalid_grant");
+ expect(body.error_description).toContain("not verified");
+ });
+
+ test("invalid_scope: rejects scopes outside client's allowed_scopes", async () => {
+ const res = await POST(
+ makeRequest({
+ grant_type: "password",
+ username: "user@example.com",
+ password: "correct-horse",
+ client_id: VALID_CLIENT.id,
+ scope: "openid admin:all",
+ }) as never,
+ );
+
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error).toBe("invalid_scope");
+ });
+});
diff --git a/app/api/oauth/token/route.ts b/app/api/oauth/token/route.ts
index 3b5926e..de42d10 100644
--- a/app/api/oauth/token/route.ts
+++ b/app/api/oauth/token/route.ts
@@ -146,6 +146,9 @@ export async function POST(request: NextRequest) {
} else if (data.grant_type === "client_credentials") {
console.log("Handling client credentials grant");
return handleClientCredentialsGrant(data, client);
+ } else if (data.grant_type === "password") {
+ console.log("Handling password grant");
+ return handlePasswordGrant(data, client);
}
console.log("Unsupported grant type:", data);
@@ -458,3 +461,142 @@ async function handleClientCredentialsGrant(
scope: scopeString,
});
}
+
+async function handlePasswordGrant(
+ data: {
+ grant_type: "password";
+ username: string;
+ password: string;
+ client_id: string;
+ client_secret?: string;
+ scope?: string;
+ },
+ client: typeof oauthClients.$inferSelect,
+) {
+ // Gate to first-party clients with open sign-in only.
+ // password grant bypasses the web consent UI, so we refuse for any
+ // client that has restricted sign-in (none/whitelist).
+ if (client.signInPermission !== "all") {
+ return NextResponse.json(
+ {
+ error: "unauthorized_client",
+ error_description:
+ "password grant is only available for first-party clients",
+ },
+ { status: 400 },
+ );
+ }
+
+ // Resolve allowed scopes; default to client's allowed scopes when omitted
+ const allowedScopes: string[] = JSON.parse(client.allowedScopes);
+ let requestedScopes: string[];
+ if (data.scope) {
+ requestedScopes = data.scope.split(" ").filter(Boolean);
+ const invalidScopes = requestedScopes.filter(
+ (s) => !allowedScopes.includes(s),
+ );
+ if (invalidScopes.length > 0) {
+ return NextResponse.json(
+ {
+ error: "invalid_scope",
+ error_description: `Requested scope(s) not allowed: ${invalidScopes.join(", ")}`,
+ },
+ { status: 400 },
+ );
+ }
+ } else {
+ requestedScopes = allowedScopes;
+ }
+
+ // Look up user by email (preferred) or username field
+ const normalized = data.username.toLowerCase();
+ const user = await db.query.users.findFirst({
+ where: or(eq(users.email, normalized), eq(users.username, data.username)),
+ });
+
+ if (!user || !user.passwordHash) {
+ return NextResponse.json(
+ {
+ error: "invalid_grant",
+ error_description: "Invalid username or password",
+ },
+ { status: 400 },
+ );
+ }
+
+ const passwordValid = await verifyPassword(user.passwordHash, data.password);
+ if (!passwordValid) {
+ return NextResponse.json(
+ {
+ error: "invalid_grant",
+ error_description: "Invalid username or password",
+ },
+ { status: 400 },
+ );
+ }
+
+ // Enforce email verification (mirrors actions/auth/login.ts)
+ if (
+ !user.emailVerified &&
+ process.env.E2E_SKIP_EMAIL_VERIFICATION !== "true"
+ ) {
+ return NextResponse.json(
+ {
+ error: "invalid_grant",
+ error_description: "Email address is not verified",
+ },
+ { status: 400 },
+ );
+ }
+
+ const scopeString = requestedScopes.join(" ");
+
+ const accessToken = await signAccessToken({
+ sub: user.id,
+ client_id: client.id,
+ scope: scopeString,
+ });
+
+ // Issue id_token when openid scope was granted
+ let idToken: string | undefined;
+ if (requestedScopes.includes("openid")) {
+ idToken = await signIdToken(
+ {
+ sub: user.id,
+ email: requestedScopes.includes("email") ? user.email : undefined,
+ email_verified: requestedScopes.includes("email")
+ ? (user.emailVerified ?? false)
+ : undefined,
+ name: user.displayName ?? undefined,
+ preferred_username: user.username ?? undefined,
+ picture:
+ user.avatarUrl ||
+ `${process.env.OAUTH_ISSUER_URL}/api/avatar/${user.avatarSeed || user.id}`,
+ auth_time: Math.floor(Date.now() / 1000),
+ },
+ client.id,
+ );
+ }
+
+ const refreshToken = generateRefreshToken();
+ const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
+
+ await db.insert(oauthRefreshTokens).values({
+ id: crypto.randomUUID(),
+ token: refreshToken,
+ userId: user.id,
+ clientId: client.id,
+ scopes: JSON.stringify(requestedScopes),
+ expiresAt,
+ createdAt: new Date(),
+ });
+
+ return NextResponse.json({
+ access_token: accessToken,
+ token_type: "Bearer",
+ expires_in: 3600,
+ ...(idToken ? { id_token: idToken } : {}),
+ scope: scopeString,
+ refresh_token: refreshToken,
+ });
+}
diff --git a/lib/validations/oauth.test.ts b/lib/validations/oauth.test.ts
index 0cab308..4be100b 100644
--- a/lib/validations/oauth.test.ts
+++ b/lib/validations/oauth.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, test } from "bun:test";
-import { tokenRequestSchema, parseScopes, validateScopes } from "./oauth";
+import {
+ tokenRequestSchema,
+ signupRequestSchema,
+ parseScopes,
+ validateScopes,
+} from "./oauth";
describe("tokenRequestSchema - client_credentials", () => {
test("should accept valid client_credentials request", () => {
@@ -158,14 +163,120 @@ describe("tokenRequestSchema - refresh_token", () => {
});
});
-describe("tokenRequestSchema - unsupported grant types", () => {
- test("should reject unsupported grant type", () => {
+describe("tokenRequestSchema - password", () => {
+ test("should accept valid password grant request", () => {
const result = tokenRequestSchema.safeParse({
grant_type: "password",
- username: "test",
- password: "test",
+ username: "user@example.com",
+ password: "hunter2",
+ client_id: "test-client",
});
+ expect(result.success).toBe(true);
+ });
+
+ test("should accept password grant with optional scope and client_secret", () => {
+ const result = tokenRequestSchema.safeParse({
+ grant_type: "password",
+ username: "user@example.com",
+ password: "hunter2",
+ client_id: "test-client",
+ client_secret: "test-secret",
+ scope: "openid email",
+ });
+
+ expect(result.success).toBe(true);
+ if (result.success && result.data.grant_type === "password") {
+ expect(result.data.scope).toBe("openid email");
+ }
+ });
+
+ test("should reject password grant missing username", () => {
+ const result = tokenRequestSchema.safeParse({
+ grant_type: "password",
+ password: "hunter2",
+ client_id: "test-client",
+ });
+
+ expect(result.success).toBe(false);
+ });
+
+ test("should reject password grant missing password", () => {
+ const result = tokenRequestSchema.safeParse({
+ grant_type: "password",
+ username: "user@example.com",
+ client_id: "test-client",
+ });
+
+ expect(result.success).toBe(false);
+ });
+
+ test("should reject password grant missing client_id", () => {
+ const result = tokenRequestSchema.safeParse({
+ grant_type: "password",
+ username: "user@example.com",
+ password: "hunter2",
+ });
+
+ expect(result.success).toBe(false);
+ });
+});
+
+describe("tokenRequestSchema - unsupported grant types", () => {
+ test("should reject unknown grant type", () => {
+ const result = tokenRequestSchema.safeParse({
+ grant_type: "device_code",
+ client_id: "test-client",
+ });
+
+ expect(result.success).toBe(false);
+ });
+});
+
+describe("signupRequestSchema", () => {
+ test("should accept valid signup request", () => {
+ const result = signupRequestSchema.safeParse({
+ client_id: "macos-test-app",
+ username: "user@example.com",
+ password: "correct-horse",
+ });
+ expect(result.success).toBe(true);
+ });
+
+ test("should accept optional name and scope", () => {
+ const result = signupRequestSchema.safeParse({
+ client_id: "macos-test-app",
+ username: "user@example.com",
+ password: "correct-horse",
+ name: "Test User",
+ scope: "openid email",
+ });
+ expect(result.success).toBe(true);
+ });
+
+ test("should reject non-email username", () => {
+ const result = signupRequestSchema.safeParse({
+ client_id: "macos-test-app",
+ username: "not-an-email",
+ password: "correct-horse",
+ });
+ expect(result.success).toBe(false);
+ });
+
+ test("should reject password shorter than 8 chars", () => {
+ const result = signupRequestSchema.safeParse({
+ client_id: "macos-test-app",
+ username: "user@example.com",
+ password: "short",
+ });
+ expect(result.success).toBe(false);
+ });
+
+ test("should reject missing client_id", () => {
+ const result = signupRequestSchema.safeParse({
+ username: "user@example.com",
+ password: "correct-horse",
+ });
expect(result.success).toBe(false);
});
});
diff --git a/lib/validations/oauth.ts b/lib/validations/oauth.ts
index 3ef1695..3b98e38 100644
--- a/lib/validations/oauth.ts
+++ b/lib/validations/oauth.ts
@@ -39,8 +39,31 @@ export const tokenRequestSchema = z.discriminatedUnion("grant_type", [
client_secret: z.string().min(1).optional(),
scope: z.string().optional(),
}),
+ // Resource Owner Password Credentials grant (public clients don't need client_secret)
+ z.object({
+ grant_type: z.literal("password"),
+ username: z.string().min(1, "username is required"),
+ password: z.string().min(1, "password is required"),
+ client_id: z.string().min(1, "client_id is required"),
+ client_secret: z.string().optional(),
+ scope: z.string().optional(),
+ }),
]);
+// Native sign-up request for first-party clients (POST /api/oauth/signup)
+export const signupRequestSchema = z.object({
+ client_id: z.string().min(1, "client_id is required"),
+ username: z.string().email("username must be a valid email address"),
+ password: z
+ .string()
+ .min(8, "Password must be at least 8 characters")
+ .max(128, "Password must be less than 128 characters"),
+ name: z.string().min(1).max(64).optional(),
+ scope: z.string().optional(),
+});
+
+export type SignupRequest = z.infer;
+
export const revokeRequestSchema = z.object({
token: z.string().min(1, "token is required"),
token_type_hint: z.enum(["access_token", "refresh_token"]).optional(),