feat: Implement email/password and X OAuth authentication, replacing the dev-login mechanism.

This commit is contained in:
Codex
2026-02-18 14:54:28 +00:00
parent c92032eb72
commit 76f991e690
15 changed files with 410 additions and 147 deletions

View File

@@ -5,6 +5,63 @@ const assert = require("node:assert/strict");
const { buildApp } = require("../src/app");
const { hmacSHA256Hex } = require("../src/lib/signature");
function getTestCookieValue(cookieHeader, name) {
const parts = String(cookieHeader || "").split(";").map((part) => part.trim());
const prefix = `${name}=`;
const valuePart = parts.find((part) => part.startsWith(prefix));
if (!valuePart) {
return null;
}
try {
return decodeURIComponent(valuePart.slice(prefix.length));
} catch {
return valuePart.slice(prefix.length);
}
}
function createTestAuthAdapter() {
return {
isConfigured() {
return true;
},
isXOAuthConfigured() {
return true;
},
handlesPath() {
return false;
},
async handleRoute() {
return { status: 404, headers: {}, body: "not_found" };
},
async getAuthenticatedUserId(headers = {}) {
return getTestCookieValue(headers.cookie, "xartaudio_user");
},
async signInWithEmail({ email }) {
return {
setCookie: `xartaudio_user=${encodeURIComponent(String(email))}; Path=/; HttpOnly`,
};
},
async signUpWithEmail({ email }) {
return {
setCookie: `xartaudio_user=${encodeURIComponent(String(email))}; Path=/; HttpOnly`,
};
},
async getXAuthorizationUrl() {
return {
url: "https://x.com/i/oauth2/authorize?state=test",
setCookie: "xartaudio_oauth_state=test; Path=/; HttpOnly",
};
},
async signOut() {
return {
ok: true,
setCookie: "xartaudio_user=; Path=/; Max-Age=0",
};
},
};
}
function createApp(options = {}) {
const baseConfig = {
xWebhookSecret: "x-secret",
@@ -17,7 +74,8 @@ function createApp(options = {}) {
appBaseUrl: "http://localhost:3000",
betterAuthSecret: "test-better-auth-secret",
betterAuthBasePath: "/api/auth",
betterAuthDevPassword: "xartaudio-dev-password",
xOAuthClientId: "x-client-id",
xOAuthClientSecret: "x-client-secret",
internalApiToken: "",
convexDeploymentUrl: "",
convexAuthToken: "",
@@ -75,6 +133,9 @@ function createApp(options = {}) {
const appOptions = { ...options };
delete appOptions.config;
if (!appOptions.authAdapter) {
appOptions.authAdapter = createTestAuthAdapter();
}
return buildApp({
config: mergedConfig,
@@ -108,7 +169,7 @@ test("GET / renders landing page", async () => {
const app = createApp();
const response = await call(app, { method: "GET", path: "/" });
assert.equal(response.status, 200);
assert.match(response.body, /From X Article to audiobook in one mention/);
assert.match(response.body, /From X Article to audiobook/);
});
test("GET /assets/styles.css serves compiled stylesheet", async () => {
@@ -127,17 +188,17 @@ test("unauthenticated /app redirects to /login with returnTo", async () => {
assert.match(response.headers.location, /returnTo=%2Fapp/);
});
test("POST /auth/dev-login sets cookie and redirects", async () => {
test("POST /auth/email/sign-in sets cookie and redirects", async () => {
const app = createApp();
const response = await call(app, {
method: "POST",
path: "/auth/dev-login",
body: "userId=matiss&returnTo=%2Fapp",
path: "/auth/email/sign-in",
body: "email=matiss%40example.com&password=password123&returnTo=%2Fapp",
});
assert.equal(response.status, 303);
assert.equal(response.headers.location, "/app");
assert.match(String(response.headers["set-cookie"]), /HttpOnly/);
assert.match(String(response.headers["set-cookie"]), /xartaudio_user=matiss%40example\.com/);
});
test("authenticated dashboard topup + simulate mention flow", async () => {
@@ -954,15 +1015,15 @@ test("rate limits repeated login attempts from same IP", async () => {
const first = await call(app, {
method: "POST",
path: "/auth/dev-login",
path: "/auth/email/sign-in",
headers: { "x-forwarded-for": "5.5.5.5" },
body: "userId=alice&returnTo=%2Fapp",
body: "email=alice%40example.com&password=password123&returnTo=%2Fapp",
});
const second = await call(app, {
method: "POST",
path: "/auth/dev-login",
path: "/auth/email/sign-in",
headers: { "x-forwarded-for": "5.5.5.5" },
body: "userId=alice&returnTo=%2Fapp",
body: "email=alice%40example.com&password=password123&returnTo=%2Fapp",
});
assert.equal(first.status, 303);

View File

@@ -30,13 +30,12 @@ test("clearUserCookie expires session cookie", () => {
assert.match(cookie, /Max-Age=0/);
});
test("getAuthenticatedUserId prefers x-user-id header", () => {
test("getAuthenticatedUserId resolves from cookie only", () => {
const userId = getAuthenticatedUserId({
"x-user-id": "header-user",
cookie: "xartaudio_user=cookie-user",
});
assert.equal(userId, "header-user");
assert.equal(userId, "cookie-user");
});
test("getAuthenticatedUserId falls back to cookie", () => {

View File

@@ -5,20 +5,13 @@ const assert = require("node:assert/strict");
const { createBetterAuthAdapter } = require("../src/integrations/better-auth");
function createMockAuthHandler() {
const signedIn = new Set();
return async function handler(request) {
const url = new URL(request.url);
const path = url.pathname;
if (path.endsWith("/sign-in/email")) {
return new Response(JSON.stringify({ ok: false }), { status: 401 });
}
if (path.endsWith("/sign-up/email")) {
const body = await request.json();
const userId = String(body.name);
signedIn.add(userId);
const userId = String(body.email);
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
@@ -28,6 +21,28 @@ function createMockAuthHandler() {
});
}
if (path.endsWith("/sign-up/email")) {
const body = await request.json();
const userId = String(body.email);
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
"content-type": "application/json",
"set-cookie": `xartaudio_better_auth=${userId}; Path=/; HttpOnly`,
},
});
}
if (path.endsWith("/sign-in/social")) {
return new Response(JSON.stringify({ url: "https://x.com/i/oauth2/authorize?state=test" }), {
status: 200,
headers: {
"content-type": "application/json",
"set-cookie": "xartaudio_oauth_state=test; Path=/; HttpOnly",
},
});
}
if (path.endsWith("/get-session")) {
const cookie = request.headers.get("cookie") || "";
const match = cookie.match(/xartaudio_better_auth=([^;]+)/);
@@ -62,15 +77,33 @@ function createMockAuthHandler() {
};
}
test("signInDevUser signs up and returns cookie when user does not exist", async () => {
test("signInWithEmail returns session cookie", async () => {
const adapter = createBetterAuthAdapter({
appBaseUrl: "http://localhost:3000",
secret: "test-secret",
authHandler: createMockAuthHandler(),
});
const result = await adapter.signInDevUser("alice");
assert.match(String(result.setCookie), /xartaudio_better_auth=alice/);
const result = await adapter.signInWithEmail({
email: "alice@example.com",
password: "password123",
});
assert.match(String(result.setCookie), /xartaudio_better_auth=alice@example\.com/);
});
test("signUpWithEmail returns session cookie", async () => {
const adapter = createBetterAuthAdapter({
appBaseUrl: "http://localhost:3000",
secret: "test-secret",
authHandler: createMockAuthHandler(),
});
const result = await adapter.signUpWithEmail({
name: "Alice",
email: "alice@example.com",
password: "password123",
});
assert.match(String(result.setCookie), /xartaudio_better_auth=alice@example\.com/);
});
test("getAuthenticatedUserId resolves session user from better-auth endpoint", async () => {
@@ -86,6 +119,23 @@ test("getAuthenticatedUserId resolves session user from better-auth endpoint", a
assert.equal(userId, "bob");
});
test("getXAuthorizationUrl returns provider url when x oauth is configured", async () => {
const adapter = createBetterAuthAdapter({
appBaseUrl: "http://localhost:3000",
secret: "test-secret",
xOAuthClientId: "x-client-id",
xOAuthClientSecret: "x-client-secret",
authHandler: createMockAuthHandler(),
});
const result = await adapter.getXAuthorizationUrl({
callbackURL: "http://localhost:3000/app",
});
assert.match(result.url, /^https:\/\/x\.com\/i\/oauth2\/authorize/);
assert.match(String(result.setCookie), /xartaudio_oauth_state=test/);
});
test("handleRoute proxies requests into better-auth handler", async () => {
const adapter = createBetterAuthAdapter({
appBaseUrl: "http://localhost:3000",

View File

@@ -48,6 +48,8 @@ test("config uses defaults when env is missing", () => {
assert.equal(config.logLevel, "info");
assert.equal(config.appBaseUrl, "http://localhost:3000");
assert.equal(config.betterAuthBasePath, "/api/auth");
assert.equal(config.xOAuthClientId, "");
assert.equal(config.xOAuthClientSecret, "");
assert.equal(config.internalApiToken, "");
assert.equal(config.qwenTtsModel, "qwen-tts-latest");
assert.equal(config.minioSignedUrlTtlSec, 3600);
@@ -66,7 +68,8 @@ test("config reads convex/qwen/minio overrides", () => {
APP_BASE_URL: "https://xartaudio.app",
BETTER_AUTH_SECRET: "prod-secret",
BETTER_AUTH_BASE_PATH: "/api/auth",
BETTER_AUTH_DEV_PASSWORD: "xartaudio-dev-password",
X_OAUTH_CLIENT_ID: "x-client-id",
X_OAUTH_CLIENT_SECRET: "x-client-secret",
INTERNAL_API_TOKEN: "internal-token",
CONVEX_DEPLOYMENT_URL: "https://example.convex.cloud",
CONVEX_URL: "https://should-not-win.convex.cloud",
@@ -89,6 +92,8 @@ test("config reads convex/qwen/minio overrides", () => {
assert.equal(config.logLevel, "debug");
assert.equal(config.appBaseUrl, "https://xartaudio.app");
assert.equal(config.betterAuthSecret, "prod-secret");
assert.equal(config.xOAuthClientId, "x-client-id");
assert.equal(config.xOAuthClientSecret, "x-client-secret");
assert.equal(config.internalApiToken, "internal-token");
assert.equal(config.convexDeploymentUrl, "https://example.convex.cloud");
assert.equal(config.convexAuthToken, "convex-token");

View File

@@ -16,7 +16,8 @@ function createRuntimeConfig() {
appBaseUrl: "http://localhost:3000",
betterAuthSecret: "test-better-auth-secret",
betterAuthBasePath: "/api/auth",
betterAuthDevPassword: "xartaudio-dev-password",
xOAuthClientId: "",
xOAuthClientSecret: "",
internalApiToken: "",
convexDeploymentUrl: "",
convexAuthToken: "",
@@ -132,10 +133,19 @@ test("createRuntime falls back to in-memory state when initial load fails", asyn
assert.equal(warnings.length, 1);
assert.match(String(warnings[0].message), /falling back to in-memory state/);
const signUp = await runtime.app.handleRequest({
method: "POST",
path: "/auth/email/sign-up",
headers: {},
rawBody: "name=User+One&email=u1%40example.com&password=password123&returnTo=%2Fapp",
query: {},
});
assert.equal(signUp.status, 303);
const response = await runtime.app.handleRequest({
method: "POST",
path: "/app/actions/topup",
headers: { "x-user-id": "u1" },
headers: { cookie: signUp.headers["set-cookie"] },
rawBody: "amount=3",
query: {},
});

View File

@@ -5,7 +5,8 @@ const assert = require("node:assert/strict");
const {
XWebhookPayloadSchema,
PolarWebhookPayloadSchema,
LoginFormSchema,
EmailSignInFormSchema,
EmailSignUpFormSchema,
TopUpFormSchema,
SimulateMentionFormSchema,
parseOrThrow,
@@ -31,13 +32,24 @@ test("validates Polar webhook payload with numeric coercion", () => {
assert.equal(parsed.credits, 12);
});
test("rejects invalid login username", () => {
test("rejects invalid email sign-in payload", () => {
assert.throws(
() => parseOrThrow(LoginFormSchema, { userId: "!" }),
/Username must be 2-40 characters using letters, numbers, _ or -/,
() => parseOrThrow(EmailSignInFormSchema, { email: "not-an-email", password: "12345678" }),
/Enter a valid email address/,
);
});
test("validates email sign-up payload", () => {
const parsed = parseOrThrow(EmailSignUpFormSchema, {
name: "Alice",
email: "alice@example.com",
password: "password123",
});
assert.equal(parsed.name, "Alice");
assert.equal(parsed.email, "alice@example.com");
});
test("validates topup amount range", () => {
assert.throws(() => parseOrThrow(TopUpFormSchema, { amount: "999" }), /Too big/);

View File

@@ -19,15 +19,17 @@ test("shell includes daisyui and pwa tags", () => {
test("landing page renders hero and flow sections", () => {
const html = renderLandingPage({ authenticated: false, userId: null });
assert.match(html, /From X Article to audiobook in one mention/);
assert.match(html, /From X Article to audiobook/);
assert.match(html, /id="how"/);
assert.match(html, /id="pricing"/);
});
test("login page renders username form", () => {
test("login page renders email and x auth forms", () => {
const html = renderLoginPage({ returnTo: "/audio/1" });
assert.match(html, /action="\/auth\/dev-login"/);
assert.match(html, /name="userId"/);
assert.match(html, /action="\/auth\/x"/);
assert.match(html, /action="\/auth\/email\/sign-in"/);
assert.match(html, /action="\/auth\/email\/sign-up"/);
assert.match(html, /name="email"/);
assert.match(html, /value="\/audio\/1"/);
});