Skip to content
This repository was archived by the owner on Feb 6, 2026. It is now read-only.

Commit 59c7489

Browse files
authored
Merge pull request #8339 from systeminit/local-dev-without-auth0
feat(auth-stack): Local Auth Mode for Development
2 parents 242f8ae + 48efadf commit 59c7489

10 files changed

Lines changed: 459 additions & 42 deletions

File tree

app/auth-portal/src/store/auth.store.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ export const useAuthStore = defineStore("auth", {
164164
// split from LOAD_USER since it will likely change
165165
// and because this request loading blocks the whole page/app
166166
async CHECK_AUTH() {
167-
return new ApiRequest<{ user: User }>({
167+
const req = new ApiRequest<{ user: User }>({
168168
url: "/whoami",
169169
onSuccess: (response) => {
170170
this.user = response.user;
@@ -173,10 +173,46 @@ export const useAuthStore = defineStore("auth", {
173173
posthog.alias(this.user.id, this.user.email);
174174
}
175175
},
176-
onFail(e) {
176+
onFail: async (e) => {
177177
/* eslint-disable-next-line no-console */
178178
console.log("RESTORE AUTH FAILED!", e);
179-
// trigger logout?
179+
180+
// Try local login - backend will reject if LOCAL_AUTH_MODE is not enabled
181+
// This auto-detects local mode without requiring frontend config
182+
/* eslint-disable-next-line no-console */
183+
console.log("Attempting local auth auto-detection...");
184+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
185+
this.LOCAL_LOGIN();
186+
},
187+
});
188+
return req;
189+
},
190+
191+
/**
192+
* LOCAL AUTH MODE ONLY
193+
* Authenticates with local development auth (no Auth0)
194+
* Backend will reject this if LOCAL_AUTH_MODE is not enabled
195+
*/
196+
async LOCAL_LOGIN(email?: string) {
197+
return new ApiRequest<{ user: User; token: string }>({
198+
method: "post",
199+
url: "/auth/local-login",
200+
params: { email },
201+
onSuccess: (response) => {
202+
this.user = response.user;
203+
posthog.identify(this.user.id);
204+
if (this.user.email) {
205+
posthog.alias(this.user.id, this.user.email);
206+
}
207+
/* eslint-disable-next-line no-console */
208+
console.log("🔧 LOCAL AUTH MODE: Local login successful", {
209+
userId: response.user.id,
210+
email: response.user.email,
211+
});
212+
},
213+
onFail: (e) => {
214+
// Silently fail - this is expected when not in local mode
215+
// Backend returns 403 "LocalAuthDisabled" when LOCAL_AUTH_MODE is not enabled
180216
},
181217
});
182218
},

app/auth-portal/vite.config.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,13 @@ export default defineConfig({
3737
process.env.NODE_ENV !== "production" &&
3838
checkerPlugin({
3939
vueTsc: true,
40-
eslint: {
41-
lintCommand: packageJson.scripts.lint,
42-
// I _think_ we only want to pop up an error on the screen for proper errors
43-
// otherwise we can get a lot of unused var errors when you comment something out temporarily
44-
dev: { logLevel: ["error"] },
45-
},
40+
// NOTE: ESLint checker disabled due to incompatibility between
41+
// vite-plugin-checker@0.12.0 and ESLint 9
42+
// ESLint can still be run manually via: pnpm lint
43+
// eslint: {
44+
// lintCommand: packageJson.scripts.lint,
45+
// dev: { logLevel: ["error"] },
46+
// },
4647
}),
4748

4849
// https://github.com/btd/rollup-plugin-visualizer/issues/176

app/web/.env

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
VITE_SI_ENV=local
66
VITE_API_PROXY_PATH=/api
77

8-
VITE_AUTH_API_URL=https://auth-api.systeminit.com
9-
VITE_AUTH_PORTAL_URL=https://auth.systeminit.com
108
VITE_AUTH0_DOMAIN=systeminit.auth0.com
119
VITE_BACKEND_HOSTS=["/localhost/g","/si.keeb.dev/g","/app.systeminit.com/g","/tools.systeminit.com/g"]
1210

bin/auth-api/src/main.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { httpRequestLoggingMiddleware } from "./lib/request-logger";
1515
import { loadAuthMiddleware, requireWebTokenMiddleware } from "./services/auth.service";
1616
import { detectClientIp } from "./lib/client-ip";
1717
import { CustomAppContext, CustomAppState } from "./custom-state";
18+
import { logLocalAuthWarning } from "./services/auth0-local.service";
1819

1920
import './lib/posthog';
2021

@@ -47,6 +48,10 @@ if (process.env.NODE_ENV !== 'test') {
4748
await routesLoaded;
4849
app.listen(process.env.PORT);
4950
console.log(chalk.green.bold(`Auth API listening on port ${process.env.PORT}`));
51+
52+
// Log warning if local auth mode is enabled
53+
logLocalAuthWarning();
54+
5055
// await prisma.$disconnect();
5156
} catch (err) {
5257
console.log('ERROR!', err);

bin/auth-api/src/routes/auth.routes.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import {
88
getAuth0LogoutUrl,
99
getAuth0UserCredential,
1010
} from "../services/auth0.service";
11+
import {
12+
completeLocalAuth0TokenExchange,
13+
isLocalAuth,
14+
} from "../services/auth0-local.service";
1115
import {
1216
createAuthToken,
1317
createSdfAuthToken,
@@ -49,6 +53,23 @@ const parseCliRedirectPort = (port: string[] | string): number => {
4953
};
5054

5155
router.get("/auth/login", async (ctx) => {
56+
// LOCAL AUTH MODE: bypass Auth0 entirely for local development
57+
if (isLocalAuth()) {
58+
// eslint-disable-next-line no-console
59+
console.log(JSON.stringify({
60+
timestamp: new Date().toISOString(),
61+
level: "info",
62+
type: "local-auth",
63+
action: "login_redirect",
64+
message: "🔧 LOCAL AUTH MODE: Redirecting to local login - NO Auth0 calls",
65+
}));
66+
67+
// Redirect directly to local login success (auto-authenticate)
68+
ctx.redirect(`${process.env.AUTH_PORTAL_URL}/login-success?local=true`);
69+
return;
70+
}
71+
72+
// PRODUCTION MODE: Normal Auth0 OAuth flow
5273
// passing in cli_redir=PORT_NO will begin the auth flow for the si cli
5374
const cliRedirParam = ctx.request.query.cli_redir;
5475
const cliRedirect = cliRedirParam
@@ -178,6 +199,67 @@ router.get("/auth/login-callback", async (ctx) => {
178199
}
179200
});
180201

202+
/**
203+
* LOCAL AUTH MODE ONLY
204+
* Simple login endpoint that bypasses Auth0 entirely
205+
* Creates/updates local user and returns session token
206+
*/
207+
router.post("/auth/local-login", async (ctx) => {
208+
if (!isLocalAuth()) {
209+
throw new ApiError("Forbidden", "LocalAuthDisabled", "Local auth mode is not enabled");
210+
}
211+
212+
// eslint-disable-next-line no-console
213+
console.log(JSON.stringify({
214+
timestamp: new Date().toISOString(),
215+
level: "info",
216+
type: "local-auth",
217+
action: "local_login",
218+
message: "🔧 LOCAL AUTH MODE: Processing local login - NO Auth0 interaction",
219+
}));
220+
221+
const reqBody = validate(
222+
ctx.request.body,
223+
z.object({
224+
email: z.string().email().optional(),
225+
}),
226+
);
227+
228+
// Use local mock Auth0 profile
229+
const { profile } = await completeLocalAuth0TokenExchange(reqBody.email);
230+
const user = await createOrUpdateUserFromAuth0Details(profile);
231+
232+
// eslint-disable-next-line no-console
233+
console.log(JSON.stringify({
234+
timestamp: new Date().toISOString(),
235+
level: "info",
236+
type: "local-auth",
237+
action: "user_authenticated",
238+
userId: user.id,
239+
email: user.email,
240+
message: "🔧 LOCAL AUTH MODE: User authenticated locally",
241+
}));
242+
243+
// Create session token for auth-api communication
244+
const siToken = createAuthToken(user.id);
245+
246+
ctx.cookies.set(SI_COOKIE_NAME, siToken, {
247+
httpOnly: true,
248+
secure: false, // Local development doesn't use HTTPS
249+
});
250+
251+
ctx.body = {
252+
user: {
253+
id: user.id,
254+
email: user.email,
255+
firstName: user.firstName,
256+
lastName: user.lastName,
257+
nickname: user.nickname,
258+
},
259+
token: siToken,
260+
};
261+
});
262+
181263
router.get("/auth/cli-auth-api-token", async (ctx) => {
182264
const nonce = ctx.request.query.nonce;
183265
if (!nonce) {

bin/auth-api/src/routes/user.routes.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
refreshUserAuth0Profile,
2929
saveUser,
3030
} from "../services/users.service";
31+
import { isLocalAuth } from "../services/auth0-local.service";
3132
import { resendAuth0EmailVerification } from "../services/auth0.service";
3233
import { tracker } from "../lib/tracker";
3334
import { createProductionWorkspaceForUser } from "../services/workspaces.service";
@@ -484,6 +485,25 @@ router.post("/users/:userId/dismissFirstTimeModal", async (ctx) => {
484485
router.get("/users/:userId/firstTimeModal", async (ctx) => {
485486
const user = await extractOwnUserIdParam(ctx);
486487

488+
// LOCAL AUTH MODE: Always skip onboarding for local users
489+
if (isLocalAuth()) {
490+
// eslint-disable-next-line no-console
491+
console.log(JSON.stringify({
492+
timestamp: new Date().toISOString(),
493+
level: "info",
494+
type: "local-auth",
495+
action: "check_onboarding",
496+
userId: user.id,
497+
message: "🔧 LOCAL AUTH MODE: Returning firstTimeModal=false to skip onboarding",
498+
}));
499+
500+
ctx.body = {
501+
firstTimeModal: false,
502+
};
503+
return;
504+
}
505+
506+
// PRODUCTION MODE: Return actual value from database
487507
ctx.body = {
488508
firstTimeModal: (user?.onboardingDetails)?.firstTimeModal,
489509
};

bin/auth-api/src/routes/workspace.routes.ts

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { tracker } from "../lib/tracker";
4444
import { posthog } from "../lib/posthog";
4545
import { findLatestTosForUser } from "../services/tos.service";
4646
import { automationApiRouter, extractAuthUser, router } from ".";
47+
import { isLocalAuth } from "../services/auth0-local.service";
4748

4849
// When we send a hubspot email via the posthog event
4950
// if the workspace name is a domain name like string e.g. bing.com
@@ -560,29 +561,44 @@ router.patch("/workspaces/:workspaceId/setHidden", async (ctx) => {
560561
router.get("/workspaces/:workspaceId/go", async (ctx) => {
561562
const { authUser, workspace } = await authorizeWorkspaceRoute(ctx);
562563

563-
// we require the user to have verified their email before they can log into a workspace
564-
if (!authUser.emailVerified) {
565-
// we'll first refresh from auth0 to make sure its actually not verified
566-
await refreshUserAuth0Profile(authUser);
567-
// then throw an error
564+
// LOCAL AUTH MODE: Skip email verification and ToS checks
565+
if (isLocalAuth()) {
566+
// eslint-disable-next-line no-console
567+
console.log(JSON.stringify({
568+
timestamp: new Date().toISOString(),
569+
level: "info",
570+
type: "local-auth",
571+
action: "skip_verification",
572+
userId: authUser.id,
573+
workspaceId: workspace.id,
574+
message: "🔧 LOCAL AUTH MODE: Skipping email verification and ToS checks",
575+
}));
576+
} else {
577+
// PRODUCTION MODE: Enforce email verification and ToS acceptance
578+
// we require the user to have verified their email before they can log into a workspace
568579
if (!authUser.emailVerified) {
580+
// we'll first refresh from auth0 to make sure its actually not verified
581+
await refreshUserAuth0Profile(authUser);
582+
// then throw an error
583+
if (!authUser.emailVerified) {
584+
throw new ApiError(
585+
"Unauthorized",
586+
"EmailNotVerified",
587+
"System Initiative Requires Verified Emails to access Workspaces. Check your registered email for Verification email from SI Auth Portal.",
588+
);
589+
}
590+
}
591+
592+
const latestTos = await findLatestTosForUser(authUser);
593+
if (latestTos > authUser.agreedTosVersion) {
569594
throw new ApiError(
570595
"Unauthorized",
571-
"EmailNotVerified",
572-
"System Initiative Requires Verified Emails to access Workspaces. Check your registered email for Verification email from SI Auth Portal.",
596+
"MissingTosAcceptance",
597+
"Terms of Service have been updated, return to the SI auth portal to accept them.",
573598
);
574599
}
575600
}
576601

577-
const latestTos = await findLatestTosForUser(authUser);
578-
if (latestTos > authUser.agreedTosVersion) {
579-
throw new ApiError(
580-
"Unauthorized",
581-
"MissingTosAcceptance",
582-
"Terms of Service have been updated, return to the SI auth portal to accept them.",
583-
);
584-
}
585-
586602
const { redirect } = validate(
587603
ctx.request.query,
588604
z.object({

0 commit comments

Comments
 (0)