From 2420d75a744811af208aca379af73a96355da444 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 26 May 2026 16:26:13 +0800 Subject: [PATCH 1/2] feat: add rxlab sign in api --- app/api/oauth/token/password-grant.test.ts | 217 +++++++++++++++++++++ app/api/oauth/token/route.ts | 160 +++++++++++++++ lib/validations/oauth.test.ts | 66 ++++++- lib/validations/oauth.ts | 9 + 4 files changed, 448 insertions(+), 4 deletions(-) create mode 100644 app/api/oauth/token/password-grant.test.ts 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..ca55281 --- /dev/null +++ b/app/api/oauth/token/password-grant.test.ts @@ -0,0 +1,217 @@ +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, +})); + +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("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..4cd5747 100644 --- a/app/api/oauth/token/route.ts +++ b/app/api/oauth/token/route.ts @@ -17,6 +17,7 @@ import { import { parseBasicAuth } from "@/lib/oauth/basic-auth"; import { tokenRequestSchema } from "@/lib/validations/oauth"; import { eq, and, isNull, or, gt } from "drizzle-orm"; +import { oauthClientEmailWhitelist } from "@/lib/db/schema/oauth-clients"; // Grace period for refresh token rotation (allows concurrent requests) const REFRESH_TOKEN_GRACE_PERIOD_MS = 30 * 1000; // 30 seconds @@ -146,6 +147,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 +462,159 @@ 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, +) { + // Enforce client sign-in permission (mirrors /api/oauth/authorize) + if (client.signInPermission === "none") { + return NextResponse.json( + { + error: "unauthorized_client", + error_description: "Sign-in is disabled for this client", + }, + { 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 }, + ); + } + + // Enforce per-client email whitelist when configured + if (client.signInPermission === "whitelist") { + const whitelistEntry = await db.query.oauthClientEmailWhitelist.findFirst({ + where: and( + eq(oauthClientEmailWhitelist.clientId, client.id), + eq(oauthClientEmailWhitelist.email, user.email.toLowerCase()), + ), + }); + if (!whitelistEntry) { + return NextResponse.json( + { + error: "unauthorized_client", + error_description: + "User is not authorized to sign in to this application", + }, + { 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..d63d2ca 100644 --- a/lib/validations/oauth.test.ts +++ b/lib/validations/oauth.test.ts @@ -158,12 +158,70 @@ 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: "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: "test", - password: "test", + 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); diff --git a/lib/validations/oauth.ts b/lib/validations/oauth.ts index 3ef1695..b7b046d 100644 --- a/lib/validations/oauth.ts +++ b/lib/validations/oauth.ts @@ -39,6 +39,15 @@ 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(), + }), ]); export const revokeRequestSchema = z.object({ From 25e66147f8eaf6ed174ca67360d76b7663086673 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 26 May 2026 16:49:51 +0800 Subject: [PATCH 2/2] feat(oauth): add native password grant + signup endpoint for first-party clients Adds `grant_type=password` to /api/oauth/token (extends tokenRequestSchema with a password branch and routes to a handlePasswordGrant) and a new /api/oauth/signup JSON route that wraps the existing register action so native clients like the macOS RxAuthSwift test app can sign up and sign in without the web UI's server-action RPC. Includes tests for both. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/oauth/signup/route.test.ts | 290 +++++++++++++++++++++ app/api/oauth/signup/route.ts | 255 ++++++++++++++++++ app/api/oauth/token/password-grant.test.ts | 40 +++ app/api/oauth/token/route.ts | 30 +-- lib/validations/oauth.test.ts | 55 +++- lib/validations/oauth.ts | 14 + 6 files changed, 659 insertions(+), 25 deletions(-) create mode 100644 app/api/oauth/signup/route.test.ts create mode 100644 app/api/oauth/signup/route.ts 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 index ca55281..9e0b82d 100644 --- a/app/api/oauth/token/password-grant.test.ts +++ b/app/api/oauth/token/password-grant.test.ts @@ -55,6 +55,7 @@ mock.module("@/lib/db", () => ({ mock.module("@/lib/auth/password", () => ({ verifyPassword: verifyPasswordMock, + hashPassword: mock(), })); mock.module("@/lib/oauth/jwt", () => ({ @@ -199,6 +200,45 @@ describe("POST /api/oauth/token - grant_type=password", () => { 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({ diff --git a/app/api/oauth/token/route.ts b/app/api/oauth/token/route.ts index 4cd5747..de42d10 100644 --- a/app/api/oauth/token/route.ts +++ b/app/api/oauth/token/route.ts @@ -17,7 +17,6 @@ import { import { parseBasicAuth } from "@/lib/oauth/basic-auth"; import { tokenRequestSchema } from "@/lib/validations/oauth"; import { eq, and, isNull, or, gt } from "drizzle-orm"; -import { oauthClientEmailWhitelist } from "@/lib/db/schema/oauth-clients"; // Grace period for refresh token rotation (allows concurrent requests) const REFRESH_TOKEN_GRACE_PERIOD_MS = 30 * 1000; // 30 seconds @@ -474,12 +473,15 @@ async function handlePasswordGrant( }, client: typeof oauthClients.$inferSelect, ) { - // Enforce client sign-in permission (mirrors /api/oauth/authorize) - if (client.signInPermission === "none") { + // 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: "Sign-in is disabled for this client", + error_description: + "password grant is only available for first-party clients", }, { status: 400 }, ); @@ -547,26 +549,6 @@ async function handlePasswordGrant( ); } - // Enforce per-client email whitelist when configured - if (client.signInPermission === "whitelist") { - const whitelistEntry = await db.query.oauthClientEmailWhitelist.findFirst({ - where: and( - eq(oauthClientEmailWhitelist.clientId, client.id), - eq(oauthClientEmailWhitelist.email, user.email.toLowerCase()), - ), - }); - if (!whitelistEntry) { - return NextResponse.json( - { - error: "unauthorized_client", - error_description: - "User is not authorized to sign in to this application", - }, - { status: 400 }, - ); - } - } - const scopeString = requestedScopes.join(" "); const accessToken = await signAccessToken({ diff --git a/lib/validations/oauth.test.ts b/lib/validations/oauth.test.ts index d63d2ca..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", () => { @@ -228,6 +233,54 @@ describe("tokenRequestSchema - unsupported grant types", () => { }); }); +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); + }); +}); + describe("parseScopes", () => { test("should parse space-separated scopes", () => { const scopes = parseScopes("openid read:profile read:email"); diff --git a/lib/validations/oauth.ts b/lib/validations/oauth.ts index b7b046d..3b98e38 100644 --- a/lib/validations/oauth.ts +++ b/lib/validations/oauth.ts @@ -50,6 +50,20 @@ export const tokenRequestSchema = z.discriminatedUnion("grant_type", [ }), ]); +// 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(),