code-docs/architecture/auth-system.md
How authentication integrates with Lowdefy.
Lowdefy authentication is built on Auth.js (NextAuth.js) and provides:
auth:
providers:
- id: google
type: GoogleProvider
properties:
clientId:
_secret: GOOGLE_CLIENT_ID
clientSecret:
_secret: GOOGLE_CLIENT_SECRET
adapter:
type: MongoDBAdapter
properties:
connectionString:
_secret: MONGODB_URI
callbacks:
session:
- _function:
__session.user.roles: __token.roles
jwt:
- _function:
__token.roles: __user.roles
pages:
protected: [dashboard, settings]
public: [home, about]
roles:
admin: [admin-panel]
authPages:
signIn: /login
error: /auth/error
session:
strategy: jwt
maxAge: 2592000
File: packages/build/src/build/buildAuth/buildAuth.js
function buildAuth({ components, context }) {
const configured = !type.isNone(components.auth);
components.auth.configured = configured;
validateAuthConfig({ components });
buildApiAuth({ components }); // API endpoint protection
buildPageAuth({ components }); // Page protection
buildAuthPlugins({ components, context });
return components;
}
File: packages/build/src/build/buildAuth/buildPageAuth.js
function buildPageAuth({ components }) {
const protectedPages = getProtectedPages({ components });
const pageRoles = getPageRoles({ components });
components.pages.forEach((page) => {
if (pageRoles[page.id]) {
page.auth = { public: false, roles: pageRoles[page.id] };
} else if (protectedPages.includes(page.id)) {
page.auth = { public: false };
} else {
page.auth = { public: true };
}
});
}
File: packages/build/src/build/buildAuth/buildApiAuth.js
function buildApiAuth({ components }) {
const protectedEndpoints = getProtectedApi({ components });
const apiRoles = getApiRoles({ components });
components.api.forEach((endpoint) => {
if (apiRoles[endpoint.id]) {
endpoint.auth = { public: false, roles: apiRoles[endpoint.id] };
} else if (protectedEndpoints.includes(endpoint.id)) {
endpoint.auth = { public: false };
} else {
endpoint.auth = { public: true };
}
});
}
File: packages/api/src/routes/auth/getNextAuthConfig.js
function getNextAuthConfig({ authJson, logger, plugins, secrets }) {
// Parse operators (_secret support)
const operatorsParser = new ServerParser({
operators: { _secret },
secrets,
});
const { output: authConfig } = operatorsParser.parse({
input: authJson,
location: 'auth',
});
// Build NextAuth options
return {
adapter: createAdapter({ authConfig, plugins }),
callbacks: createCallbacks({ authConfig, plugins }),
events: createEvents({ authConfig, plugins }),
providers: createProviders({ authConfig, plugins }),
pages: authConfig.authPages,
session: authConfig.session,
theme: authConfig.theme,
cookies: authConfig?.advanced?.cookies,
debug: authConfig.debug,
};
}
File: packages/api/src/routes/auth/createProviders.js
function createProviders({ authConfig, plugins }) {
return authConfig.providers.map((providerConfig) =>
plugins.providers[providerConfig.type]({
...providerConfig.properties,
id: providerConfig.id,
})
);
}
File: packages/plugins/plugins/plugin-next-auth/src/auth/providers.js
60+ providers including:
File: packages/api/src/routes/auth/callbacks/createJWTCallback.js
Runs on login and token refresh:
async function jwtCallback({ token, user, account, profile, isNewUser }) {
// Extract OIDC claims
if (profile) {
token = {
id,
sub,
name,
given_name,
family_name,
email,
email_verified,
picture,
...token,
};
}
// Add custom userFields
if (authConfig.userFields) {
addUserFieldsToToken({ authConfig, account, profile, token, user });
}
// Execute custom callback plugins
for (const plugin of jwtCallbackPlugins) {
token = await plugin.fn({ account, profile, token, user });
}
return token;
}
File: packages/api/src/routes/auth/callbacks/createSessionCallback.js
Runs on session updates:
async function sessionCallback({ session, token, user }) {
// Map token to session.user
session.user = {
id, sub, name, given_name, family_name,
email, picture, ...
};
// Add custom userFields
if (authConfig.userFields) {
addUserFieldsToSession({ authConfig, session, token, user });
}
// Execute custom plugins
for (const plugin of sessionCallbackPlugins) {
session = await plugin.fn({ session, token, user });
}
// Validate roles after all sources have written to the session.
// Throws ConfigError if roles is present but not an array of strings.
validateSessionRoles({ session });
// Create anonymized hash for analytics
session.hashed_id = crypto.createHash('sha256')
.update(identifier ?? '')
.digest('base64');
return session;
}
File: packages/api/src/routes/auth/callbacks/createSignInCallback.js
Controls login authorization:
async function signInCallback({ account, credentials, email, profile, user }) {
let allowSignIn = true;
for (const plugin of signInCallbackPlugins) {
allowSignIn = await plugin.fn({
account,
credentials,
email,
profile,
user,
});
if (allowSignIn === false) break;
}
return allowSignIn;
}
Files: addUserFieldsToToken.js, addUserFieldsToSession.js
# Configuration
auth:
userFields:
company: 'profile.company'
department: 'profile.department'
roles: 'profile.roles'
// Implementation
function addUserFieldsToToken({ authConfig, account, profile, token, user }) {
Object.entries(authConfig.userFields).forEach(([fieldName, providerField]) => {
const value = get({ account, profile, user }, providerField);
set(token, fieldName, value);
});
}
File: packages/api/src/context/createAuthorize.js
function createAuthorize({ session }) {
const authenticated = !!session;
const roles = session?.user?.roles ?? [];
// Defense-in-depth: throw if roles bypassed session callback validation.
// A string would cause silent authorization bypass via substring matching.
if (!Array.isArray(roles)) {
throw new ConfigError('session.user.roles must be an array of strings.', {
received: roles,
});
}
function authorize({ auth }) {
if (auth.public === true) return true;
if (auth.public === false) {
if (auth.roles) {
// Role-based: user must have one of the required roles
return authenticated && auth.roles.some((role) => roles.includes(role));
}
// Auth-only: user must be authenticated
return authenticated;
}
throw new ConfigError('auth.public must be true or false.', {
received: auth.public,
configKey: config['~k'],
});
}
return authorize;
}
File: packages/api/src/routes/page/getPageConfig.js
async function getPageConfig({ authorize, readConfigFile }, { pageId }) {
const pageConfig = await readConfigFile(`pages/${pageId}/${pageId}.json`);
if (pageConfig && authorize(pageConfig)) {
const { auth, ...rest } = pageConfig; // Remove auth metadata
return { ...rest };
}
return null; // 404 for unauthorized
}
File: packages/api/src/routes/endpoints/authorizeApiEndpoint.js
function authorizeApiEndpoint({ authorize }, { endpointConfig }) {
if (!authorize(endpointConfig)) {
throw new ConfigurationError('Not authorized');
}
}
File: packages/servers/server/lib/server/serverSidePropsWrapper.js
function serverSidePropsWrapper(handler) {
return async function wrappedHandler(nextContext) {
const context = { ... };
// Initialize auth options
context.authOptions = getAuthOptions(context);
// Fetch server session
context.session = await getServerSession(context);
// Create API context with authorization
createApiContext(context);
return handler({ context, nextContext });
};
}
File: packages/servers/server/lib/client/auth/AuthConfigured.js
function AuthConfigured({ authConfig, children, serverSession }) {
const auth = { authConfig, getSession, signIn, signOut };
return (
<SessionProvider session={serverSession} basePath={basePath}>
<Session>
{(session) => {
auth.session = session;
return children(auth);
}}
</Session>
</SessionProvider>
);
}
File: packages/plugins/operators/operators-js/src/operators/shared/user.js
function _user({ arrayIndices, location, params, user }) {
return getFromObject({
arrayIndices,
location,
object: user,
operator: '_user',
params,
});
}
Usage:
# In block properties
content:
_string:
- 'Welcome, '
- _user: session.user.name
# In request authorization
visible:
_eq:
- _user: session.user.role
- admin
File: packages/servers/server/pages/api/auth/[...nextauth].js
async function handler({ context, req, res }) {
if (authJson.configured !== true) {
return res.status(404).json({ message: 'Auth not configured' });
}
// Corporate email link check
if (req.method === 'HEAD') {
return res.status(200).end();
}
return NextAuth(req, res, context.authOptions);
}
Handles:
/api/auth/signin - Login/api/auth/signout - Logout/api/auth/callback/[provider] - OAuth callbacks/api/auth/session - Session retrieval/api/auth/csrf - CSRF protectionFile: packages/api/src/routes/auth/events/createEvents.js
const events = {
createUser, // First login - user created
linkAccount, // Account linked to user
signIn, // User signed in
signOut, // User signed out
updateUser, // Profile updated
session, // Session events
};
lowdefy.yaml
↓
buildAuth() [BUILD TIME]
├→ validateAuthConfig()
├→ buildPageAuth() → page.auth = { public, roles }
├→ buildApiAuth() → endpoint.auth = { public, roles }
└→ buildAuthPlugins()
↓
auth.json
↓
[RUNTIME - PAGE REQUEST]
↓
serverSidePropsWrapper()
├→ getAuthOptions() → getNextAuthConfig()
│ ├→ createProviders()
│ ├→ createCallbacks()
│ ├→ createEvents()
│ └→ createAdapter()
│
├→ getServerSession()
│
└→ createApiContext() → createAuthorize(session)
↓
Page Handler
└→ getPageConfig() → authorize(pageConfig)
↓
_app.js [CLIENT]
↓
Auth Component (SessionProvider)
↓
Page Component
├→ auth.session
├→ _user operator
└→ auth.signIn/signOut
| Component | File |
|---|---|
| Config Validation | packages/build/src/build/buildAuth/validateAuthConfig.js |
| Page Protection | packages/build/src/build/buildAuth/buildPageAuth.js |
| API Protection | packages/build/src/build/buildAuth/buildApiAuth.js |
| NextAuth Config | packages/api/src/routes/auth/getNextAuthConfig.js |
| Providers | packages/api/src/routes/auth/createProviders.js |
| Session Callback | packages/api/src/routes/auth/callbacks/createSessionCallback.js |
| JWT Callback | packages/api/src/routes/auth/callbacks/createJWTCallback.js |
| Authorization | packages/api/src/context/createAuthorize.js |
| _user Operator | packages/plugins/operators/operators-js/src/operators/shared/user.js |
| API Route | packages/servers/server/pages/api/auth/[...nextauth].js |
The dev server supports mock users for testing, bypassing the login flow.
Environment Variable (takes precedence):
LOWDEFY_DEV_USER='{"sub":"test-user","email":"[email protected]","roles":["admin"]}'
Config File:
auth:
providers:
- id: credentials
type: CredentialsProvider
# ...
dev:
mockUser:
sub: test-user
email: [email protected]
roles:
- admin
File: packages/servers/server-dev/lib/server/auth/getMockSession.js
async function getMockSession() {
// 1. Check env var first (takes precedence)
const mockUserJson = process.env.LOWDEFY_DEV_USER;
let mockUser = mockUserJson ? JSON.parse(mockUserJson) : authJson.dev?.mockUser;
if (!mockUser) return undefined;
// 2. Validate auth is configured
if (authJson.configured !== true) {
throw new Error('Mock user configured but auth is not configured');
}
// 3. Transform through session callback (userFields, custom callbacks apply)
const sessionCallback = createSessionCallback({ authConfig: authJson, plugins: { callbacks } });
const session = await sessionCallback({
session: { user: {} },
token: mockUser,
user: mockUser,
});
return session;
}
getServerSession.js returns mock session before calling NextAuth[...nextauth].js returns mock session for /api/auth/session requestscheckMockUserWarning.js logs "Mock user active - login bypassed"| File | Purpose |
|---|---|
server-dev/lib/server/auth/getMockSession.js | Core mock session logic |
server-dev/lib/server/auth/checkMockUserWarning.js | Startup warning |
server-dev/lib/server/auth/getServerSession.js | Server-side integration |
server-dev/pages/api/auth/[...nextauth].js | Client-side integration |
build/src/lowdefySchema.js | Schema for auth.dev.mockUser |
Mock user is only available in server-dev. The production server (@lowdefy/server) has no mock user code paths.
The e2e server (@lowdefy/server-e2e) provides a separate auth mechanism for Playwright testing, distinct from the dev server's mock user.
| Aspect | Dev Server Mock User | E2E Server Cookie Auth |
|---|---|---|
| Set by | Env var or auth.dev.mockUser | ldf.user() in test code |
| Scope | Global (all requests) | Per browser context |
| Transforms | Runs through session callback | No transforms (direct mapping) |
| Change mid-test | No | Yes (ldf.user(newUser)) |
| Clear mid-test | No | Yes (ldf.user(null)) |
| Server | @lowdefy/server-dev | @lowdefy/server-e2e |
ldf.user({ id, roles }) → base64(JSON) → lowdefy_e2e_user cookie via browserContext.addCookies()getServerSession({ req }) parses the cookie → returns { user }createAuthorize(session) → authorize(pageConfig) — same as productionAuthE2E passes session from SSR props to lowdefy.userReplaces NextAuth's SessionProvider. The signIn and signOut methods throw:
function e2eNotSupported() {
throw new Error('Sign-in and sign-out are not supported in e2e testing.');
}
Since NextAuth middleware doesn't exist in server-e2e, page handlers check auth explicitly:
if (authJson.configured && !session) {
const loginPage = authJson.pages?.public?.[0] ?? '404';
return { redirect: { destination: `/${loginPage}`, permanent: false } };
}
| File | Purpose |
|---|---|
server-e2e/lib/server/auth/getServerSession.js | Reads cookie, returns { user } |
server-e2e/lib/client/auth/AuthE2E.js | Client auth (no NextAuth) |
server-e2e/pages/api/auth/session.js | Returns context.session ?? {} |
e2e-utils/src/core/userCookie.js | Sets/clears cookie via Playwright |
e2e-utils/src/proxy/createPageManager.js | Exposes ldf.user() API |
See server-e2e.md for full server architecture.
hashed_id for privacy-preserving analyticsvalidateSessionRoles in the session callback throws ConfigError if session.user.roles is not an array of strings. Without this, a misconfigured string value (e.g., roles: "admin") causes String.prototype.includes to do substring matching — a silent authorization bypass. createAuthorize has a defense-in-depth guard for the same check._secret for credentials in configauth.advanced.cookies