-
Notifications
You must be signed in to change notification settings - Fork 49
feat(auth): add WorkOS two-factor authentication with backup codes #1024
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 a293f55
refactor(auth): simplify 2FA flow after code quality review
janburzinski 0010942
refactor(auth): remove passkey support, keep 2FA
janburzinski 8b1b5a0
chore: merge main into workos 2fa branch
janburzinski 06c1e17
fix(auth): address React Doctor findings and make backup-codes migrat…
janburzinski a4b3141
style(auth): one secondary action per enrollment step and more room a…
janburzinski 1efe19c
feat(auth): optional name for authenticator factors, nested factor list
janburzinski 5d36f7d
fix(auth): drop stale backup codes and names when WorkOS forces re-en…
janburzinski bc9cfa0
refactor(auth): reorganize MFA actions and shared types
janburzinski af4206f
fix(auth): harden the MFA flow after Greptile review
janburzinski b39dfce
fix(auth): isolate pending MFA redirects
janburzinski e0268a5
fix(auth): close the MFA review findings
janburzinski 69c1b53
feat(auth): use the six-slot code input for email verification
janburzinski ec3b98b
refactor(auth): fold 2FA into account settings, enroll in a stacked d…
mezotv e510602
chore: merge main into workos 2fa branch
mezotv dac8785
feat(auth): regenerate the backup codes migration and dim the canvas …
mezotv c082859
fix(auth): resolve the Greptile and Cubic review findings
mezotv 6bf3e71
fix(dashboard): stop the auth playground backup code updater recursing
mezotv 25f5e54
fix(auth): address the remaining Cubic findings
mezotv cd36dca
refactor(auth): burn backup codes after the change they authorize, dr…
mezotv 419550b
fix(auth): keep the factor row busy until the list refreshes
mezotv File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) { | ||
| 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({ | ||
|
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); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.