Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@ NODE_ENV=
PRODUCTION_URL=
WEB_APP_URL=

# Oauth
# Oauth Settings
OAUTH_GITHUB_CLIENT_ID=
OAUTH_GITHUB_CLIENT_SECRET=
OAUTH_GOOGLE_CLIENT_ID=
OAUTH_GOOGLE_CLIENT_SECRET=
OAUTH_PROXY_SECRET=

# Email Settings
RESEND_API_KEY=
RESEND_FROM_EMAIL=

# CORS & Trusted Origins (comma-separated)
ALLOWED_ORIGINS=
6 changes: 6 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@
"auth:generate": "dotenvx run -- bun x @better-auth/cli generate -y --config src/auth/cli.ts --output src/db/models/auth.ts"
},
"dependencies": {
"@better-auth-ui/react": "catalog:auth",
"@better-auth/drizzle-adapter": "1.6.21",
"@better-auth/expo": "catalog:auth",
"@cyrus/connections": "workspace:*",
"@cyrus/schemas": "workspace:*",
"@dotenvx/dotenvx": "catalog:env",
"@orpc/server": "catalog:rpc",
"@react-email/render": "^2.1.0",
"@soorya-u/better-auth-desktop": "catalog:auth",
"@soorya-u/better-auth-ws-ticket": "catalog:auth",
"@t3-oss/env-core": "catalog:env",
Expand All @@ -29,11 +31,15 @@
"evlog": "catalog:observability",
"hono": "^4.12.27",
"partyserver": "^0.5.8",
"react": "catalog:react",
"react-email": "^6.9.1",
"resend": "^6.18.0",
"zod": "catalog:rpc"
},
"devDependencies": {
"@cyrus/typescript": "workspace:*",
"@types/bun": "catalog:core",
"@types/react": "catalog:react",
"drizzle-kit": "catalog:database",
"typescript": "catalog:core",
"vitest": "catalog:testing"
Expand Down
30 changes: 29 additions & 1 deletion apps/server/src/auth/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { exports } from "cloudflare:workers";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { resend } from "../emails";

const worker = exports.default;
const ORIGIN = "https://cyrus.soorya-u.dev";
Expand Down Expand Up @@ -151,4 +152,31 @@ describe("device authorization against D1", () => {
};
expect(tokenBody.access_token).toBeTruthy();
});

test("accepts magic-link sign-in requests", async () => {
const email = `magic-link-${crypto.randomUUID()}@cyrus.test`;
const sendSpy = vi.spyOn(resend.emails, "send").mockResolvedValue({
data: { id: "email_test" },
error: null,
headers: null,
});

try {
const requestMagicLink = await worker.fetch(
"https://cyrus.soorya-u.dev/api/auth/sign-in/magic-link",
{
method: "POST",
headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify({
email,
callbackURL: "https://cyrus.soorya-u.dev/workers",
}),
}
);
expect(requestMagicLink.ok).toBe(true);
expect(sendSpy).toHaveBeenCalled();
} finally {
sendSpy.mockRestore();
}
});
});
29 changes: 18 additions & 11 deletions apps/server/src/auth/options.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import { expo } from "@better-auth/expo";
import { betterAuthDesktop } from "@soorya-u/better-auth-desktop/server";
import { wsTicketPlugin } from "@soorya-u/better-auth-ws-ticket/server";
import { wsTicketPlugin as wsTicket } from "@soorya-u/better-auth-ws-ticket/server";
import type { BetterAuthOptions } from "better-auth";
import { bearer, deviceAuthorization, oAuthProxy } from "better-auth/plugins";
import {
bearer,
deviceAuthorization,
magicLink,
oAuthProxy,
} from "better-auth/plugins";
import { log } from "evlog";
import { env } from "../config/env";
import { sendMagicLinkEmail as sendMagicLink } from "../emails/magic-email";

const emailAndPassword =
env.NODE_ENV === "production"
? {}
: {
emailAndPassword: {
enabled: true,
autoSignIn: true,
},
};
env.NODE_ENV === "production" ? {} : { emailAndPassword: { enabled: true } };

export const authOptions = {
appName: "Cyrus",
Expand All @@ -26,6 +25,10 @@ export const authOptions = {
clientId: env.OAUTH_GITHUB_CLIENT_ID,
clientSecret: env.OAUTH_GITHUB_CLIENT_SECRET,
},
google: {
clientId: env.OAUTH_GOOGLE_CLIENT_ID,
clientSecret: env.OAUTH_GOOGLE_CLIENT_SECRET,
},
},
secret: env.BETTER_AUTH_SECRET,
baseURL: env.WEB_APP_URL,
Expand Down Expand Up @@ -54,8 +57,12 @@ export const authOptions = {
productionURL: env.PRODUCTION_URL,
secret: env.OAUTH_PROXY_SECRET,
}),
magicLink({
disableSignUp: false,
sendMagicLink,
}),
deviceAuthorization({ verificationUri: `${env.WEB_APP_URL}/auth/device` }),
bearer(),
wsTicketPlugin(),
wsTicket(),
],
} satisfies BetterAuthOptions;
4 changes: 4 additions & 0 deletions apps/server/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ export const env = createEnv({
BETTER_AUTH_SECRET: z.string().min(32),
OAUTH_GITHUB_CLIENT_ID: z.string(),
OAUTH_GITHUB_CLIENT_SECRET: z.string(),
OAUTH_GOOGLE_CLIENT_ID: z.string(),
OAUTH_GOOGLE_CLIENT_SECRET: z.string(),
OAUTH_PROXY_SECRET: z.string(),
RESEND_API_KEY: z.string(),
RESEND_FROM_EMAIL: z.email(),
NODE_ENV: z
.enum(["development", "testing", "production"])
.default("development"),
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/emails/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { Resend } from "resend";
import { env } from "../config/env";

export const resend = new Resend(env.RESEND_API_KEY);
73 changes: 73 additions & 0 deletions apps/server/src/emails/magic-email.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, test, vi } from "vitest";
import { env } from "../config/env";
import { resend } from "./index";

describe("magic email", () => {
test("builds a magic-link template with the action URL", async () => {
const { buildMagicLinkEmail } = await import("./magic-email");
const email = "person@cyrus.test";
const url = "https://example.com/sign-in";
const template = await buildMagicLinkEmail({ email, url });
expect(template.subject).toBe("Sign in to Cyrus");
expect(template.html).toContain(url);
expect(template.html).toContain(email);
expect(template.text).toContain(url);
});

test("sends Cyrus-branded payload through Resend", async () => {
const { sendMagicLinkEmail } = await import("./magic-email");
const sendSpy = vi.spyOn(resend.emails, "send").mockResolvedValue({
data: { id: "email_test" },
error: null,
headers: null,
});
const url =
"https://cyrus.soorya-u.dev/api/auth/magic-link/verify?token=123";

await sendMagicLinkEmail({
email: "person@cyrus.test",
url,
});

try {
expect(sendSpy).toHaveBeenCalledTimes(1);
expect(sendSpy).toHaveBeenCalledWith(
expect.objectContaining({
from: env.RESEND_FROM_EMAIL,
to: ["person@cyrus.test"],
subject: "Sign in to Cyrus",
html: expect.stringContaining(url),
text: expect.stringContaining(url),
})
);
expect(sendSpy.mock.calls[0]?.[0]?.html).toContain("person@cyrus.test");
} finally {
sendSpy.mockRestore();
}
});

test("throws when Resend returns an API error", async () => {
const { sendMagicLinkEmail } = await import("./magic-email");
const apiError = {
name: "validation_error" as const,
message: "Invalid from address",
statusCode: 403 as const,
};
const sendSpy = vi.spyOn(resend.emails, "send").mockResolvedValue({
data: null,
error: apiError,
headers: null,
});

try {
await expect(
sendMagicLinkEmail({
email: "person@cyrus.test",
url: "https://example.com/sign-in",
})
).rejects.toEqual(apiError);
} finally {
sendSpy.mockRestore();
}
});
});
50 changes: 50 additions & 0 deletions apps/server/src/emails/magic-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { MagicLinkEmail } from "@better-auth-ui/react/email";
import { render } from "@react-email/render";
import { createElement } from "react";
import { env } from "../config/env";
import { resend } from "./index";

type EmailParams = {
email: string;
url: string;
};

function magicLinkElement(params: EmailParams) {
return createElement(MagicLinkEmail, {
appName: "Cyrus",
darkMode: true,
expirationMinutes: 5,
poweredBy: true,
...params,
});
}

export async function buildMagicLinkEmail(params: EmailParams): Promise<{
subject: string;
html: string;
text: string;
}> {
const element = magicLinkElement(params);
const [html, text] = await Promise.all([
render(element),
render(element, { plainText: true }),
]);

return {
subject: "Sign in to Cyrus",
html,
text,
};
}

export async function sendMagicLinkEmail(params: EmailParams): Promise<void> {
const template = await buildMagicLinkEmail(params);
const { error } = await resend.emails.send({
from: env.RESEND_FROM_EMAIL,
to: [params.email],
...template,
});
if (error) {
throw error;
}
}
8 changes: 6 additions & 2 deletions apps/server/src/worker-configuration.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: 9a12e9879675b42e7c1aa04a72e199f9)
// Generated by Wrangler by running `wrangler types` (hash: c04f1b3f0cb2898b03bf51bbcddb2b2e)
// Runtime types generated with workerd@1.20260623.1 2025-06-01 nodejs_compat
interface __BaseEnv_Env {
DB: D1Database;
Expand All @@ -11,6 +11,10 @@ interface __BaseEnv_Env {
OAUTH_GITHUB_CLIENT_SECRET: string;
OAUTH_PROXY_SECRET: string;
ALLOWED_ORIGINS: string;
OAUTH_GOOGLE_CLIENT_ID: string;
OAUTH_GOOGLE_CLIENT_SECRET: string;
RESEND_API_KEY: string;
RESEND_FROM_EMAIL: string;
HUB: DurableObjectNamespace<import("./index").Hub>;
}
declare namespace Cloudflare {
Expand All @@ -25,7 +29,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "BETTER_AUTH_SECRET" | "NODE_ENV" | "PRODUCTION_URL" | "WEB_APP_URL" | "OAUTH_GITHUB_CLIENT_ID" | "OAUTH_GITHUB_CLIENT_SECRET" | "OAUTH_PROXY_SECRET" | "ALLOWED_ORIGINS">> {}
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "BETTER_AUTH_SECRET" | "NODE_ENV" | "PRODUCTION_URL" | "WEB_APP_URL" | "OAUTH_GITHUB_CLIENT_ID" | "OAUTH_GITHUB_CLIENT_SECRET" | "OAUTH_PROXY_SECRET" | "ALLOWED_ORIGINS" | "OAUTH_GOOGLE_CLIENT_ID" | "OAUTH_GOOGLE_CLIENT_SECRET" | "RESEND_API_KEY" | "RESEND_FROM_EMAIL">> {}
}

// Begin runtime types
Expand Down
4 changes: 2 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@better-auth-ui/core": "^1.6.39",
"@better-auth-ui/react": "^1.6.39",
"@better-auth-ui/core": "catalog:auth",
"@better-auth-ui/react": "catalog:auth",
"@cyrus/connections": "workspace:*",
"@cyrus/constants": "workspace:*",
"@cyrus/errors": "workspace:*",
Expand Down
Loading