Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
17 changes: 17 additions & 0 deletions apps/server/src/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,21 @@ 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 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);
});
});
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);
48 changes: 48 additions & 0 deletions apps/server/src/emails/magic-email.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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")
.mockImplementation(
async () => ({}) as Awaited<ReturnType<typeof resend.emails.send>>
);
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();
}
});
});
47 changes: 47 additions & 0 deletions apps/server/src/emails/magic-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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);
await resend.emails.send({
from: env.RESEND_FROM_EMAIL,
to: [params.email],
...template,
});
}
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
76 changes: 0 additions & 76 deletions apps/web/src/auth/two-factor-methods.ts

This file was deleted.

Loading