Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cc2b3ab
feat(auth): add WorkOS 2FA and passkey support
janburzinski Sep 11, 2026
a293f55
refactor(auth): simplify 2FA flow after code quality review
janburzinski Sep 12, 2026
0010942
refactor(auth): remove passkey support, keep 2FA
janburzinski Sep 14, 2026
8b1b5a0
chore: merge main into workos 2fa branch
janburzinski Sep 14, 2026
06c1e17
fix(auth): address React Doctor findings and make backup-codes migrat…
janburzinski Sep 14, 2026
a4b3141
style(auth): one secondary action per enrollment step and more room a…
janburzinski Sep 14, 2026
1efe19c
feat(auth): optional name for authenticator factors, nested factor list
janburzinski Sep 14, 2026
5d36f7d
fix(auth): drop stale backup codes and names when WorkOS forces re-en…
janburzinski Sep 14, 2026
bc9cfa0
refactor(auth): reorganize MFA actions and shared types
janburzinski Sep 14, 2026
af4206f
fix(auth): harden the MFA flow after Greptile review
janburzinski Sep 15, 2026
b39dfce
fix(auth): isolate pending MFA redirects
janburzinski Sep 16, 2026
e0268a5
fix(auth): close the MFA review findings
janburzinski Sep 16, 2026
69c1b53
feat(auth): use the six-slot code input for email verification
janburzinski Sep 16, 2026
ec3b98b
refactor(auth): fold 2FA into account settings, enroll in a stacked d…
mezotv Sep 22, 2026
e510602
chore: merge main into workos 2fa branch
mezotv Sep 22, 2026
dac8785
feat(auth): regenerate the backup codes migration and dim the canvas …
mezotv Sep 22, 2026
c082859
fix(auth): resolve the Greptile and Cubic review findings
mezotv Sep 22, 2026
6bf3e71
fix(dashboard): stop the auth playground backup code updater recursing
mezotv Sep 22, 2026
25f5e54
fix(auth): address the remaining Cubic findings
mezotv Sep 22, 2026
cd36dca
refactor(auth): burn backup codes after the change they authorize, dr…
mezotv Sep 23, 2026
419550b
fix(auth): keep the factor row busy until the list refreshes
mezotv Sep 23, 2026
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
186 changes: 186 additions & 0 deletions apps/dashboard/scripts/create-dev-auth-account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { db } from "@notra/db/drizzle";
import {
members,
organizations,
userBackupCodes,
users,
} from "@notra/db/schema";
import { WorkOS } from "@workos-inc/node";
import { and, eq } from "drizzle-orm";

const EMAIL = process.env.DEV_AUTH_EMAIL ?? "mfa-demo@demo.notra.dev";
const PASSWORD = process.env.DEV_AUTH_PASSWORD ?? "MfaDemo-2026!";
const ORG_SLUG = process.env.DEV_AUTH_ORG_SLUG ?? "mfa-demo";
const ORG_NAME = "MFA Demo";
const FIRST_NAME = "MFA";
const LAST_NAME = "Demo";
const RESET_MFA = process.argv.includes("--reset-mfa");

const apiKey = process.env.WORKOS_API_KEY;
const clientId = process.env.WORKOS_CLIENT_ID;
if (!(apiKey && clientId)) {
throw new Error("WORKOS_API_KEY and WORKOS_CLIENT_ID must be set");
}
if (!apiKey.startsWith("sk_test")) {
throw new Error("Refusing to run against a non-test WorkOS API key");
}

const workos = new WorkOS(apiKey, { clientId });

async function ensureWorkOSUser() {
const existing = await workos.userManagement.listUsers({ email: EMAIL });
const found = existing.data[0];
if (found) {
await workos.userManagement.updateUser({
userId: found.id,
password: PASSWORD,
emailVerified: true,
firstName: FIRST_NAME,
lastName: LAST_NAME,
});
console.log(`WorkOS user exists: ${found.id} (password reset)`);
return found;
}
const created = await workos.userManagement.createUser({
email: EMAIL,
password: PASSWORD,
emailVerified: true,
firstName: FIRST_NAME,
lastName: LAST_NAME,
});
console.log(`WorkOS user created: ${created.id}`);
return created;
}

async function resetFactors(workosUserId: string) {
const factors = await workos.multiFactorAuth.listUserAuthFactors({
userId: workosUserId,
});
const totpFactors = factors.data.filter((factor) => factor.type === "totp");
await Promise.all(
totpFactors.map(async (factor) => {
await workos.multiFactorAuth.deleteFactor(factor.id);
console.log(`Removed ${factor.type} factor ${factor.id}`);
})
);
if (totpFactors.length === 0) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
console.log("No TOTP factors to remove");
}
const skipped = factors.data.length - totpFactors.length;
if (skipped > 0) {
console.warn(
`Left ${skipped} non-TOTP factor(s) in place; they still block password sign-in`
);
}
}

async function ensureLocalUser(workosUserId: string) {
const byWorkosId = await db.query.users.findFirst({
where: eq(users.workosUserId, workosUserId),
});
if (byWorkosId) {
return byWorkosId;
}
const byEmail = await db.query.users.findFirst({
where: eq(users.email, EMAIL),
});
if (byEmail) {
const [linked] = await db
.update(users)
.set({ workosUserId, emailVerified: true })
.where(eq(users.id, byEmail.id))
.returning();
console.log(`Linked local user ${byEmail.id} to WorkOS`);
return linked ?? byEmail;
}
const [created] = await db
.insert(users)
.values({
id: crypto.randomUUID(),
name: `${FIRST_NAME} ${LAST_NAME}`,
email: EMAIL,
emailVerified: true,
workosUserId,
})
.returning();
if (!created) {
throw new Error("Failed to insert local user");
}
console.log(`Local user created: ${created.id}`);
return created;
}

async function ensureOrganization(userId: string) {
let organization = await db.query.organizations.findFirst({
where: eq(organizations.slug, ORG_SLUG),
});
let createdHere = false;
if (!organization) {
createdHere = true;
const [created] = await db
.insert(organizations)
.values({
id: crypto.randomUUID(),
name: ORG_NAME,
slug: ORG_SLUG,
createdAt: new Date(),
onboardingCompleted: true,
onboardingDismissed: true,
onboardingAgentRan: true,
})
.returning();
organization = created;
console.log(`Organization created: ${ORG_SLUG}`);
}
if (!organization) {
throw new Error("Failed to create organization");
}
const membership = await db.query.members.findFirst({
where: and(
eq(members.organizationId, organization.id),
eq(members.userId, userId)
),
});
if (!membership && !createdHere) {
throw new Error(
`Organization "${ORG_SLUG}" already exists and the dev account is not a member. Pick another DEV_AUTH_ORG_SLUG rather than joining someone else's organization.`
);
}
if (!membership) {
await db.insert(members).values({
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
id: crypto.randomUUID(),
organizationId: organization.id,
userId,
role: "owner",
createdAt: new Date(),
});
console.log("Owner membership created");
}
return organization;
}

const workosUser = await ensureWorkOSUser();
const localUser = await ensureLocalUser(workosUser.id);
if (RESET_MFA) {
await resetFactors(workosUser.id);
await db
.delete(userBackupCodes)
.where(eq(userBackupCodes.userId, localUser.id));
console.log("Cleared backup codes");
}
if (workosUser.externalId !== localUser.id) {
await workos.userManagement.updateUser({
userId: workosUser.id,
externalId: localUser.id,
});
}
const organization = await ensureOrganization(localUser.id);

console.log("\nDev auth account ready");
console.log(` Email: ${EMAIL}`);
console.log(` Password: ${PASSWORD}`);
console.log(` Org: /${organization.slug}`);
console.log(
"\nTest: sign in at /login, open Settings → Security, set up the authenticator, sign out, sign in again."
);
process.exit(0);
84 changes: 84 additions & 0 deletions apps/dashboard/scripts/mfa-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { AuthenticationException, WorkOS } from "@workos-inc/node";

import { generateTotpCode } from "../src/lib/auth/dev-totp";

const EMAIL = process.env.DEV_AUTH_EMAIL ?? "mfa-demo@demo.notra.dev";
const PASSWORD = process.env.DEV_AUTH_PASSWORD ?? "MfaDemo-2026!";

const apiKey = process.env.WORKOS_API_KEY;
const clientId = process.env.WORKOS_CLIENT_ID;
if (!(apiKey && clientId)) {
throw new Error("WORKOS_API_KEY and WORKOS_CLIENT_ID must be set");
}
if (!apiKey.startsWith("sk_test")) {
throw new Error("Refusing to run against a non-test WorkOS API key");
}

const workos = new WorkOS(apiKey, { clientId });

const user = (await workos.userManagement.listUsers({ email: EMAIL })).data[0];
if (!user) {
throw new Error(
`No WorkOS user for ${EMAIL}; run create-dev-auth-account.ts`
);
}

const enrollment = await workos.multiFactorAuth.createUserAuthFactor({
userId: user.id,
type: "totp",
totpIssuer: "Notra",
totpUser: EMAIL,
});
const factorId = enrollment.authenticationFactor.id;
const secret = enrollment.authenticationFactor.totp.secret;
console.log(`1. Enrolled factor ${factorId}`);

try {
const verification = await workos.multiFactorAuth.verifyChallenge({
authenticationChallengeId: enrollment.authenticationChallenge.id,
code: await generateTotpCode(secret),
});
console.log(`2. Enrollment challenge valid: ${verification.valid}`);

let pendingToken: string | undefined;
try {
await workos.userManagement.authenticateWithPassword({
clientId,
email: EMAIL,
password: PASSWORD,
});
console.log(
"3. Password sign-in succeeded WITHOUT an MFA challenge → MFA is not enabled for this WorkOS environment (Dashboard → Authentication → Multi-Factor Auth)."
);
} catch (error) {
if (!(error instanceof AuthenticationException)) {
throw error;
}
console.log(`3. Password sign-in → ${error.code}`);
if (error.code !== "mfa_challenge") {
throw error;
}
pendingToken = error.pendingAuthenticationToken;
const factors = error.rawData.authentication_factors ?? [];
console.log(` factors in error: ${JSON.stringify(factors)}`);
}

if (pendingToken) {
const challenge = await workos.multiFactorAuth.challengeFactor({
authenticationFactorId: factorId,
});
const response = await workos.userManagement.authenticateWithTotp({
clientId,
pendingAuthenticationToken: pendingToken,
authenticationChallengeId: challenge.id,
code: await generateTotpCode(secret),
});
console.log(
`4. authenticateWithTotp → session for ${response.user.email} via ${response.authenticationMethod}`
);
}
} finally {
await workos.multiFactorAuth.deleteFactor(factorId);
console.log(`5. Cleaned up factor ${factorId}`);
}
process.exit(0);
5 changes: 3 additions & 2 deletions apps/dashboard/src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { Suspense } from "react";

import { LoginContent } from "@/components/auth/login-content";
import { LoginFormSkeleton } from "@/components/auth/login-form-skeleton";
import type { LoginPageProps } from "@/types/auth/login-page";

export const instant = true;

export default function Login() {
export default function Login({ searchParams }: LoginPageProps) {
return (
<div className="mx-auto w-full max-w-md rounded-md p-6 lg:px-8 lg:py-10">
<Suspense fallback={<LoginFormSkeleton />}>
<LoginContent />
<LoginContent searchParams={searchParams} />
</Suspense>
</div>
);
Expand Down
Loading
Loading