Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,12 +330,14 @@ short version:
| `bun run db:studio` | Prisma Studio |
| `bun run --filter=api trpc:generate` | Regenerate the `AppRouter` type |
| `bun run --filter=api dev:session` | Print a session cookie for a local user |
| `bun run --filter=api dev:session -- --url` | Same, plus a one-click login URL for the app |

Scope any of them with a Turborepo filter: `bun run dev --filter=api`.

Because sign-in goes through an identity provider, there is no way to get a session from a terminal —
`dev:session` writes the rows Better Auth would have written and prints the cookie it
would have set. It refuses to run with `NODE_ENV=production`.
would have set. Pass `--url` to also print a link that sets the cookie in your browser
and opens the app. It refuses to run with `NODE_ENV=production`.

## Deploying

Expand Down
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"scripts": {
"build": "bun build src/main.ts --target=bun --outdir dist --packages=external --sourcemap",
"check-types": "tsc --noEmit",
"dev": "concurrently -n api,trpc -c blue,magenta \"bun --watch src/main.ts\" \"bun run dev:trpc\"",
"dev": "concurrently -n api,trpc -c blue,magenta \"NODE_ENV=${NODE_ENV:-development} bun --watch src/main.ts\" \"bun run dev:trpc\"",
"dev:trpc": "nestjs-trpc watch -e src/app.module.ts -r \"**/*.router.ts\" -o src/generated",
"dev:session": "bun scripts/dev-session.ts",
"lint": "biome check .",
Expand Down
46 changes: 23 additions & 23 deletions apps/api/scripts/dev-session.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { AUTH_COOKIE_PREFIX } from "@crm/auth/cookies";
import { appUrl } from "@crm/auth";
import { db } from "@crm/db";

const COOKIE_NAME = `${AUTH_COOKIE_PREFIX}.session_token`;
const SESSION_DAYS = 7;
import {
DEV_SESSION_COOKIE_NAME,
DEV_SESSION_DAYS,
devSessionLoginUrl,
isDevSessionRouteEnabled,
signDevSessionCookieValue,
} from "../src/dev/dev-session.util";

if (process.env.NODE_ENV === "production") {
throw new Error(
Expand All @@ -15,25 +19,15 @@ if (!secret) {
throw new Error("BETTER_AUTH_SECRET is not set — run this from apps/api.");
}

const email = process.argv[2] ?? "dev@localhost";
const name = email.split("@")[0] ?? "Developer";

async function signCookieValue(value: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(value),
const args = process.argv.slice(2);
const printUrl = args.includes("--url");
if (printUrl && !isDevSessionRouteEnabled()) {
throw new Error(
"dev:session --url needs the API on NODE_ENV=development. Start it with bun run dev or unset NODE_ENV in this shell.",
);
const base64 = btoa(String.fromCharCode(...new Uint8Array(signature)));
return encodeURIComponent(`${value}.${base64}`);
}
const email = args.find((arg) => arg !== "--url") ?? "dev@localhost";
const name = email.split("@")[0] ?? "Developer";

const user = await db.user.upsert({
where: { email },
Expand All @@ -48,7 +42,7 @@ const user = await db.user.upsert({
});

const token = `dev-session-${user.id}`;
const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
const expiresAt = new Date(Date.now() + DEV_SESSION_DAYS * 24 * 60 * 60 * 1000);

await db.session.upsert({
where: { token },
Expand All @@ -62,6 +56,12 @@ await db.session.upsert({
update: { expiresAt },
});

console.log(`${COOKIE_NAME}=${await signCookieValue(token)}`);
const signedValue = await signDevSessionCookieValue(token, secret);

console.log(`${DEV_SESSION_COOKIE_NAME}=${signedValue}`);

if (printUrl) {
console.log(devSessionLoginUrl(appUrl, signedValue));
}

await db.$disconnect();
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { DashboardModule } from "./dashboard/dashboard.module";
import { DatabaseModule } from "./database/database.module";
import { DealsModule } from "./deals/deals.module";
import { EnrichmentModule } from "./enrichment/enrichment.module";
import { DevSessionModule } from "./dev/dev-session.module";
import { FieldsModule } from "./fields/fields.module";
import { GoogleModule } from "./google/google.module";
import { HealthModule } from "./health/health.module";
Expand Down Expand Up @@ -79,6 +80,7 @@ import { WorkspaceModule } from "./workspace/workspace.module";
TrackingModule,
ArchiveModule,
SavedViewsModule,
DevSessionModule,
],
})
export class AppModule {}
61 changes: 61 additions & 0 deletions apps/api/src/dev/dev-session.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { appUrl } from "@crm/auth";
import { db } from "@crm/db";
import {
Controller,
Get,
NotFoundException,
Query,
Res,
UnauthorizedException,
} from "@nestjs/common";
import { AllowAnonymous } from "@thallesp/nestjs-better-auth";
import type { Response } from "express";
import {
DEV_SESSION_COOKIE_NAME,
DEV_SESSION_DAYS,
isDevSessionRouteEnabled,
verifyDevSessionCookieValue,
} from "./dev-session.util";

@Controller("api/dev")
export class DevSessionController {
@Get("session-login")
@AllowAnonymous()
async sessionLogin(
@Query("session") session: string | undefined,
@Res() response: Response,
) {
if (!isDevSessionRouteEnabled()) {
throw new NotFoundException();
}

const secret = process.env.BETTER_AUTH_SECRET;
if (!secret) {
throw new NotFoundException();
}

if (!session) {
throw new UnauthorizedException("Missing session.");
}

let token: string;
try {
token = await verifyDevSessionCookieValue(session, secret);
} catch {
throw new UnauthorizedException("Invalid session.");
}
const stored = await db.session.findUnique({ where: { token } });
if (!stored || stored.expiresAt.getTime() <= Date.now()) {
throw new UnauthorizedException("Session is missing or expired.");
}

response.cookie(DEV_SESSION_COOKIE_NAME, decodeURIComponent(session), {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: false,
maxAge: DEV_SESSION_DAYS * 24 * 60 * 60 * 1000,
});
response.redirect(302, appUrl);
}
}
7 changes: 7 additions & 0 deletions apps/api/src/dev/dev-session.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { DevSessionController } from "./dev-session.controller";

@Module({
controllers: [DevSessionController],
})
export class DevSessionModule {}
55 changes: 55 additions & 0 deletions apps/api/src/dev/dev-session.util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "bun:test";
import {
devSessionLoginUrl,
isDevSessionRouteEnabled,
signDevSessionCookieValue,
verifyDevSessionCookieValue,
} from "./dev-session.util";

const secret = "test-secret-at-least-32-characters-long";

describe("dev-session util", () => {
it("enables the login route only in development", () => {
const previous = process.env.NODE_ENV;
try {
delete process.env.NODE_ENV;
expect(isDevSessionRouteEnabled()).toBe(true);

process.env.NODE_ENV = "development";
expect(isDevSessionRouteEnabled()).toBe(true);

process.env.NODE_ENV = "test";
expect(isDevSessionRouteEnabled()).toBe(false);

process.env.NODE_ENV = "production";
expect(isDevSessionRouteEnabled()).toBe(false);
} finally {
if (previous === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = previous;
}
}
});

it("signs and verifies a session token", async () => {
const signed = await signDevSessionCookieValue("dev-session-user", secret);
await expect(verifyDevSessionCookieValue(signed, secret)).resolves.toBe(
"dev-session-user",
);
});

it("rejects a tampered session token", async () => {
const signed = await signDevSessionCookieValue("dev-session-user", secret);
await expect(
verifyDevSessionCookieValue(`${signed}x`, secret),
).rejects.toThrow("Invalid dev session cookie.");
});

it("builds a login URL on the app origin", async () => {
const signed = await signDevSessionCookieValue("dev-session-user", secret);
expect(devSessionLoginUrl("http://localhost:3000", signed)).toBe(
`http://localhost:3000/api/dev/session-login?session=${signed}`,
);
});
});
64 changes: 64 additions & 0 deletions apps/api/src/dev/dev-session.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { AUTH_COOKIE_PREFIX } from "@crm/auth/cookies";

export const DEV_SESSION_COOKIE_NAME = `${AUTH_COOKIE_PREFIX}.session_token`;
export const DEV_SESSION_DAYS = 7;

export function isDevSessionRouteEnabled(): boolean {
return (process.env.NODE_ENV ?? "development") === "development";
}

async function signToken(value: string, secret: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(value),
);
return btoa(String.fromCharCode(...new Uint8Array(signature)));
}

export async function signDevSessionCookieValue(
value: string,
secret: string,
): Promise<string> {
const signature = await signToken(value, secret);
return encodeURIComponent(`${value}.${signature}`);
}

export async function verifyDevSessionCookieValue(
signedValue: string,
secret: string,
): Promise<string> {
const decoded = decodeURIComponent(signedValue);
const separator = decoded.lastIndexOf(".");
if (separator <= 0) {
throw new Error("Invalid dev session cookie.");
}

const value = decoded.slice(0, separator);
const providedSignature = decoded.slice(separator + 1);
const expectedSignature = await signToken(value, secret);

if (providedSignature !== expectedSignature) {
throw new Error("Invalid dev session cookie.");
}

return value;
}

export function devSessionLoginPath(signedValue: string): string {
return `/api/dev/session-login?session=${signedValue}`;
}

export function devSessionLoginUrl(
appUrl: string,
signedValue: string,
): string {
return new URL(devSessionLoginPath(signedValue), appUrl).toString();
}
8 changes: 8 additions & 0 deletions apps/api/test/auth.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,12 @@ describe("Auth (e2e)", () => {

expect(response.status).toBe(401);
});

it("returns 404 for the dev session login route in test mode", async () => {
const response = await request(app.getHttpServer()).get(
"/api/dev/session-login",
);

expect(response.status).toBe(404);
});
});
Loading