diff --git a/.env.example b/.env.example index 472e2bb..310120b 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,8 @@ APP_BASE_URL=https://xartaudio.example.com # Better Auth BETTER_AUTH_SECRET=replace-me BETTER_AUTH_BASE_PATH=/api/auth -BETTER_AUTH_DEV_PASSWORD=replace-me +X_OAUTH_CLIENT_ID=replace-me +X_OAUTH_CLIENT_SECRET=replace-me INTERNAL_API_TOKEN=replace-me # Convex diff --git a/README.md b/README.md index b60b73a..9cffe19 100644 --- a/README.md +++ b/README.md @@ -346,8 +346,8 @@ This repository now contains a deployable production-style app (single container ### Authentication model 1. Browser flow is powered by Better Auth under `/api/auth/*`. -2. `/auth/dev-login` bootstraps a Better Auth session for local/dev testing. -3. API calls also support `x-user-id` header for scripted usage/testing. +2. Supported sign-in methods are Email/Password and X OAuth. +3. All authenticated browser sessions are resolved from Better Auth session cookies. ### Runtime endpoints 1. Public: @@ -355,7 +355,9 @@ This repository now contains a deployable production-style app (single container - `GET /login` - `GET /audio/:id` 2. Browser actions: -- `POST /auth/dev-login` +- `POST /auth/email/sign-in` +- `POST /auth/email/sign-up` +- `POST /auth/x` - `POST /auth/logout` - `POST /app/actions/topup` - `POST /app/actions/simulate-mention` @@ -392,7 +394,8 @@ Use `.env.example` as the source of truth. 2. Auth + state: - `BETTER_AUTH_SECRET` - `BETTER_AUTH_BASE_PATH` -- `BETTER_AUTH_DEV_PASSWORD` +- `X_OAUTH_CLIENT_ID` +- `X_OAUTH_CLIENT_SECRET` - `INTERNAL_API_TOKEN` - `CONVEX_DEPLOYMENT_URL` - `CONVEX_AUTH_TOKEN` @@ -448,7 +451,7 @@ Use `.env.example` as the source of truth. ## Production Checklist -1. Replace `/auth/dev-login` with direct Better Auth UI/OAuth sign-in for public launch. +1. Configure Better Auth credentials for Email auth and X OAuth (`X_OAUTH_CLIENT_ID` / `X_OAUTH_CLIENT_SECRET`). 2. Populate integration keys in Coolify environment for X, Polar, Qwen3 TTS, MinIO, and Convex. 3. Implement Convex functions named by `CONVEX_STATE_QUERY` and `CONVEX_STATE_MUTATION`. - This repository includes `convex/state.ts` and `convex/schema.ts` for defaults: diff --git a/src/app.js b/src/app.js index c192f41..981a26f 100644 --- a/src/app.js +++ b/src/app.js @@ -24,7 +24,8 @@ const { const { FixedWindowRateLimiter } = require("./lib/rate-limit"); const { PolarWebhookPayloadSchema, - LoginFormSchema, + EmailSignInFormSchema, + EmailSignUpFormSchema, TopUpFormSchema, SimulateMentionFormSchema, parseOrThrow, @@ -103,7 +104,8 @@ function buildApp({ appBaseUrl: config.appBaseUrl, basePath: config.betterAuthBasePath, secret: config.betterAuthSecret, - devPassword: config.betterAuthDevPassword, + xOAuthClientId: config.xOAuthClientId, + xOAuthClientSecret: config.xOAuthClientSecret, logger, }); const generationService = audioGenerationService || createAudioGenerationService({ @@ -520,7 +522,7 @@ function buildApp({ })); } - if (method === "POST" && path === "/auth/dev-login") { + if (method === "POST" && path === "/auth/email/sign-in") { const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login"); if (rateLimited) { return rateLimited; @@ -528,9 +530,13 @@ function buildApp({ const form = parseFormUrlEncoded(rawBody); try { - const login = parseOrThrow(LoginFormSchema, form); + const login = parseOrThrow(EmailSignInFormSchema, form); const nextPath = sanitizeReturnTo(login.returnTo, "/app"); - const authSession = await auth.signInDevUser(login.userId); + const authSession = await auth.signInWithEmail({ + email: login.email, + password: login.password, + callbackURL: `${config.appBaseUrl}${nextPath}`, + }); return redirect(nextPath, { "set-cookie": authSession.setCookie, }); @@ -542,6 +548,62 @@ function buildApp({ } } + if (method === "POST" && path === "/auth/email/sign-up") { + const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login"); + if (rateLimited) { + return rateLimited; + } + + const form = parseFormUrlEncoded(rawBody); + try { + const signUp = parseOrThrow(EmailSignUpFormSchema, form); + const nextPath = sanitizeReturnTo(signUp.returnTo, "/app"); + const authSession = await auth.signUpWithEmail({ + name: signUp.name, + email: signUp.email, + password: signUp.password, + }); + return redirect(nextPath, { + "set-cookie": authSession.setCookie, + }); + } catch (error) { + return redirect(withQuery("/login", { + returnTo: sanitizeReturnTo(form.returnTo, "/app"), + error: error.message, + })); + } + } + + if (method === "POST" && path === "/auth/x") { + const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login"); + if (rateLimited) { + return rateLimited; + } + + const form = parseFormUrlEncoded(rawBody); + const nextPath = sanitizeReturnTo(form.returnTo, "/app"); + if (!auth.isXOAuthConfigured || !auth.isXOAuthConfigured()) { + return redirect(withQuery("/login", { + returnTo: nextPath, + error: "X sign-in is not configured", + })); + } + + try { + const signIn = await auth.getXAuthorizationUrl({ + callbackURL: `${config.appBaseUrl}${nextPath}`, + }); + return redirect(signIn.url, signIn.setCookie + ? { "set-cookie": signIn.setCookie } + : undefined); + } catch (error) { + return redirect(withQuery("/login", { + returnTo: nextPath, + error: error.message, + })); + } + } + if (method === "POST" && path === "/auth/logout") { const signOut = await auth.signOut(safeHeaders); return redirect("/", signOut.setCookie diff --git a/src/config.js b/src/config.js index d9e5c3c..ee61991 100644 --- a/src/config.js +++ b/src/config.js @@ -54,7 +54,8 @@ const parsed = { appBaseUrl: strFromEnv("APP_BASE_URL", "http://localhost:3000"), betterAuthSecret: strFromEnv("BETTER_AUTH_SECRET", "dev-better-auth-secret"), betterAuthBasePath: strFromEnv("BETTER_AUTH_BASE_PATH", "/api/auth"), - betterAuthDevPassword: strFromEnv("BETTER_AUTH_DEV_PASSWORD", "xartaudio-dev-password"), + xOAuthClientId: strFromEnv("X_OAUTH_CLIENT_ID", ""), + xOAuthClientSecret: strFromEnv("X_OAUTH_CLIENT_SECRET", ""), internalApiToken: strFromEnv("INTERNAL_API_TOKEN", ""), convexDeploymentUrl: strFromEnv("CONVEX_DEPLOYMENT_URL", strFromEnv("CONVEX_URL", "")), convexAuthToken: strFromEnv("CONVEX_AUTH_TOKEN", ""), @@ -105,7 +106,8 @@ const ConfigSchema = z.object({ appBaseUrl: z.string().min(1), betterAuthSecret: z.string().min(1), betterAuthBasePath: z.string().min(1), - betterAuthDevPassword: z.string().min(8), + xOAuthClientId: z.string(), + xOAuthClientSecret: z.string(), internalApiToken: z.string(), convexDeploymentUrl: z.string(), convexAuthToken: z.string(), diff --git a/src/integrations/better-auth.js b/src/integrations/better-auth.js index ad02206..6b94d0a 100644 --- a/src/integrations/better-auth.js +++ b/src/integrations/better-auth.js @@ -1,37 +1,5 @@ "use strict"; -function sanitizeUserId(userId) { - return String(userId || "") - .trim() - .toLowerCase() - .replace(/[^a-z0-9._-]/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, ""); -} - -function resolveEmailFromUserId(userId) { - const raw = String(userId || "").trim(); - if (raw.includes("@")) { - return raw.toLowerCase(); - } - - const safe = sanitizeUserId(raw) || "user"; - return `${safe}@xartaudio.local`; -} - -function extractLegacyUserCookie(cookieHeader) { - const match = String(cookieHeader || "").match(/(?:^|;\s*)xartaudio_user=([^;]+)/); - if (!match) { - return null; - } - - try { - return decodeURIComponent(match[1]); - } catch { - return match[1]; - } -} - function headersFromObject(rawHeaders = {}) { const headers = new Headers(); for (const [key, value] of Object.entries(rawHeaders)) { @@ -72,11 +40,17 @@ function responseHeadersToObject(responseHeaders) { return headers; } +async function parseResponseErrorDetails(response) { + const text = await response.text().catch(() => ""); + return text || "request_failed"; +} + function createBetterAuthAdapter({ appBaseUrl, basePath = "/api/auth", secret, - devPassword = "xartaudio-dev-password", + xOAuthClientId = "", + xOAuthClientSecret = "", authHandler, logger = console, } = {}) { @@ -102,6 +76,14 @@ function createBetterAuthAdapter({ import("better-auth/adapters/memory"), ]); + const socialProviders = {}; + if (xOAuthClientId && xOAuthClientSecret) { + socialProviders.twitter = { + clientId: xOAuthClientId, + clientSecret: xOAuthClientSecret, + }; + } + const auth = betterAuth({ appName: "XArtAudio", baseURL: appBaseUrl, @@ -115,6 +97,7 @@ function createBetterAuthAdapter({ requireEmailVerification: false, minPasswordLength: 8, }, + socialProviders, }); return auth.handler; @@ -148,6 +131,10 @@ function createBetterAuthAdapter({ return Boolean(appBaseUrl && secret); }, + isXOAuthConfigured() { + return Boolean(xOAuthClientId && xOAuthClientSecret); + }, + handlesPath(path) { return path === normalizedBasePath || path.startsWith(`${normalizedBasePath}/`); }, @@ -164,10 +151,6 @@ function createBetterAuthAdapter({ }, async getAuthenticatedUserId(headers = {}) { - if (headers["x-user-id"]) { - return String(headers["x-user-id"]); - } - const cookieHeader = headers.cookie || ""; if (!cookieHeader) { return null; @@ -190,52 +173,33 @@ function createBetterAuthAdapter({ const payload = await response.json().catch(() => null); const user = payload && payload.user ? payload.user : null; if (!user) { - return extractLegacyUserCookie(cookieHeader); + return null; } return user.name || user.email || user.id || null; } catch { - return extractLegacyUserCookie(cookieHeader); + return null; } }, - async signInDevUser(userId) { - const email = resolveEmailFromUserId(userId); - const signInBody = JSON.stringify({ - email, - password: devPassword, - rememberMe: true, - }); - - let response = await invoke({ + async signInWithEmail({ email, password, callbackURL } = {}) { + const response = await invoke({ method: "POST", path: `${normalizedBasePath}/sign-in/email`, headers: { "content-type": "application/json", accept: "application/json", }, - rawBody: signInBody, + rawBody: JSON.stringify({ + email, + password, + callbackURL, + rememberMe: true, + }), }); if (!response.ok) { - response = await invoke({ - method: "POST", - path: `${normalizedBasePath}/sign-up/email`, - headers: { - "content-type": "application/json", - accept: "application/json", - }, - rawBody: JSON.stringify({ - name: String(userId), - email, - password: devPassword, - rememberMe: true, - }), - }); - } - - if (!response.ok) { - const details = await response.text().catch(() => ""); + const details = await parseResponseErrorDetails(response); throw new Error(`auth_sign_in_failed:${response.status}:${details}`); } @@ -249,6 +213,73 @@ function createBetterAuthAdapter({ }; }, + async signUpWithEmail({ name, email, password } = {}) { + const response = await invoke({ + method: "POST", + path: `${normalizedBasePath}/sign-up/email`, + headers: { + "content-type": "application/json", + accept: "application/json", + }, + rawBody: JSON.stringify({ + name, + email, + password, + rememberMe: true, + }), + }); + + if (!response.ok) { + const details = await parseResponseErrorDetails(response); + throw new Error(`auth_sign_up_failed:${response.status}:${details}`); + } + + const responseHeaders = responseHeadersToObject(response.headers); + if (!responseHeaders["set-cookie"]) { + throw new Error("auth_set_cookie_missing"); + } + + return { + setCookie: responseHeaders["set-cookie"], + }; + }, + + async getXAuthorizationUrl({ callbackURL } = {}) { + if (!xOAuthClientId || !xOAuthClientSecret) { + throw new Error("x_oauth_not_configured"); + } + + const response = await invoke({ + method: "POST", + path: `${normalizedBasePath}/sign-in/social`, + headers: { + "content-type": "application/json", + accept: "application/json", + }, + rawBody: JSON.stringify({ + provider: "twitter", + callbackURL, + disableRedirect: true, + }), + }); + + if (!response.ok) { + const details = await parseResponseErrorDetails(response); + throw new Error(`auth_x_sign_in_failed:${response.status}:${details}`); + } + + const payload = await response.json().catch(() => null); + if (!payload || typeof payload.url !== "string" || !payload.url) { + throw new Error("auth_x_url_missing"); + } + + const responseHeaders = responseHeadersToObject(response.headers); + return { + url: payload.url, + setCookie: responseHeaders["set-cookie"] || null, + }; + }, + async signOut(headers = {}) { const response = await invoke({ method: "POST", diff --git a/src/lib/auth.js b/src/lib/auth.js index a4db957..7a3bcf0 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -39,11 +39,6 @@ function clearUserCookie() { function getAuthenticatedUserId(headers) { const safeHeaders = headers || {}; - - if (safeHeaders["x-user-id"]) { - return safeHeaders["x-user-id"]; - } - const cookies = parseCookies(safeHeaders.cookie || ""); return cookies[COOKIE_NAME] || null; } diff --git a/src/lib/validation.js b/src/lib/validation.js index 7dca980..657eb08 100644 --- a/src/lib/validation.js +++ b/src/lib/validation.js @@ -2,8 +2,6 @@ const { z } = require("zod"); -const usernameRegex = /^[a-zA-Z0-9_-]{2,40}$/; - const XWebhookPayloadSchema = z.object({ mentionPostId: z.string().min(1), callerUserId: z.string().min(1), @@ -16,8 +14,16 @@ const PolarWebhookPayloadSchema = z.object({ eventId: z.string().min(1), }); -const LoginFormSchema = z.object({ - userId: z.string().regex(usernameRegex, "Username must be 2-40 characters using letters, numbers, _ or -"), +const EmailSignInFormSchema = z.object({ + email: z.string().trim().email("Enter a valid email address"), + password: z.string().min(8, "Password must be at least 8 characters").max(128), + returnTo: z.string().optional(), +}); + +const EmailSignUpFormSchema = z.object({ + name: z.string().trim().min(2, "Name must be at least 2 characters").max(80), + email: z.string().trim().email("Enter a valid email address"), + password: z.string().min(8, "Password must be at least 8 characters").max(128), returnTo: z.string().optional(), }); @@ -42,7 +48,8 @@ function parseOrThrow(schema, payload, errorMessage) { module.exports = { XWebhookPayloadSchema, PolarWebhookPayloadSchema, - LoginFormSchema, + EmailSignInFormSchema, + EmailSignUpFormSchema, TopUpFormSchema, SimulateMentionFormSchema, parseOrThrow, diff --git a/src/views/pages.js b/src/views/pages.js index 1ac94cb..14a47a9 100644 --- a/src/views/pages.js +++ b/src/views/pages.js @@ -182,22 +182,31 @@ function renderLoginPage({ returnTo = "/app", error = null }) {