Files
OpenNoodl/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-007-integrated-backend/TASK-007G-authentication.md

2110 lines
56 KiB
Markdown

# TASK-007G: Authentication System
## Overview
Implement a complete authentication system for the local backend, including user management, session handling, and frontend nodes that mirror the existing Parse auth nodes, ensuring backward compatibility for existing projects.
**Parent Task:** TASK-007 (Integrated Local Backend)
**Phase:** G (Authentication)
**Effort:** 10-14 hours
**Priority:** HIGH
**Depends On:** TASK-007A (LocalSQL Adapter), TASK-007B (Backend Server)
---
## Objectives
1. Implement secure user storage with password hashing (bcrypt)
2. Create authentication endpoints (signup, login, logout, session validation)
3. Implement JWT-based session management
4. Build frontend auth nodes (Sign Up, Log In, Log Out, Current User, Update User)
5. Add user context injection to all authenticated requests
6. Support optional ACL (Access Control List) for record-level permissions
7. Maintain API compatibility with existing Parse auth nodes
---
## Architecture
### Auth Flow Overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Frontend (Viewer) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Sign Up Node │ │ Log In Node │ │ Log Out Node │ │ Current User │ │
│ │ │ │ │ │ │ │ Node │ │
│ │ email ────┐ │ │ email ────┐ │ │ │ │ │ │
│ │ password ─┼──│──│ password ─┼──│──│ ──────────┐ │ │ ┌─→ user │ │
│ │ │ │ │ │ │ │ │ │ │ │ isLoggedIn │ │
│ │ success ←─┘ │ │ success ←─┘ │ │ success ←─┘ │ │ │ │ │
│ │ failure │ │ failure │ │ failure │ │ └───────────────┘ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
└─────────┼─────────────────┼─────────────────┼──────────────────────────────┘
│ │ │
↓ ↓ ↓
┌─────────────────────────────────────────────────────────────────────────────┐
│ Local Backend Server │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ Auth Middleware │ │
│ │ • Extract JWT from Authorization header │ │
│ │ • Validate token, attach user to request │ │
│ │ • Pass through for public routes │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────┼──────────────────────────┐ │
│ ↓ ↓ ↓ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ POST │ │ POST │ │ POST │ │
│ │ /auth/signup│ │ /auth/login │ │ /auth/logout │ │
│ │ │ │ │ │ │ │
│ │ • Validate │ │ • Find user │ │ • Invalidate │ │
│ │ • Hash pwd │ │ • Verify pwd│ │ session │ │
│ │ • Create │ │ • Issue JWT │ │ • Clear token │ │
│ │ • Issue JWT │ │ • Return │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ _User Table │ │
│ │ objectId | email | passwordHash | username | sessionToken | ... │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Token Flow
```
1. Sign Up / Log In
Client ──POST /auth/login──→ Server
Client ←── { user, token } ── Server
2. Store Token
Client stores token in localStorage/memory
3. Authenticated Requests
Client ──GET /api/todos──→ Server
Authorization: Bearer <token>
Server validates token, attaches user context
Server ←── { results } ── Client
4. Token Refresh (optional)
Client ──POST /auth/refresh──→ Server
Client ←── { token } ────────── Server
```
---
## Implementation Steps
### Step 1: User Schema and Password Utilities (2 hours)
**File:** `packages/noodl-editor/src/main/src/local-backend/auth/password-utils.ts`
```typescript
import * as crypto from 'crypto';
import * as bcrypt from 'bcrypt';
const SALT_ROUNDS = 12;
/**
* Hash a password using bcrypt
*/
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
/**
* Verify a password against a hash
*/
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
/**
* Generate a secure random session token
*/
export function generateSessionToken(): string {
return crypto.randomBytes(32).toString('hex');
}
/**
* Generate a password reset token (shorter-lived)
*/
export function generateResetToken(): string {
return crypto.randomBytes(20).toString('hex');
}
/**
* Validate password strength
*/
export function validatePassword(password: string): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters');
}
// Optional: Add more rules as needed
// if (!/[A-Z]/.test(password)) errors.push('Must contain uppercase letter');
// if (!/[a-z]/.test(password)) errors.push('Must contain lowercase letter');
// if (!/[0-9]/.test(password)) errors.push('Must contain a number');
return { valid: errors.length === 0, errors };
}
/**
* Validate email format
*/
export function validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
```
**File:** `packages/noodl-editor/src/main/src/local-backend/auth/jwt-utils.ts`
```typescript
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as jwt from 'jsonwebtoken';
// Secret key management
let JWT_SECRET: string;
/**
* Get or create JWT secret for this installation
*/
export function getJWTSecret(backendsPath: string): string {
if (JWT_SECRET) return JWT_SECRET;
const secretPath = path.join(backendsPath, '.jwt-secret');
try {
JWT_SECRET = fs.readFileSync(secretPath, 'utf-8').trim();
} catch (e) {
// Generate new secret
JWT_SECRET = crypto.randomBytes(64).toString('hex');
fs.writeFileSync(secretPath, JWT_SECRET, { mode: 0o600 });
}
return JWT_SECRET;
}
export interface TokenPayload {
userId: string;
email: string;
sessionId: string;
iat?: number;
exp?: number;
}
/**
* Generate a JWT token
*/
export function generateToken(
payload: Omit<TokenPayload, 'iat' | 'exp'>,
secret: string,
expiresIn: string = '7d'
): string {
return jwt.sign(payload, secret, { expiresIn });
}
/**
* Verify and decode a JWT token
*/
export function verifyToken(token: string, secret: string): TokenPayload | null {
try {
return jwt.verify(token, secret) as TokenPayload;
} catch (e) {
return null;
}
}
/**
* Decode token without verification (for debugging)
*/
export function decodeToken(token: string): TokenPayload | null {
try {
return jwt.decode(token) as TokenPayload;
} catch (e) {
return null;
}
}
```
**File:** `packages/noodl-editor/src/main/src/local-backend/auth/user-schema.ts`
```typescript
import type { TableSchema } from '@noodl/runtime/src/api/adapters/cloudstore-adapter';
/**
* Built-in _User table schema
*/
export const UserTableSchema: TableSchema = {
name: '_User',
columns: [
{ name: 'email', type: 'String', required: true },
{ name: 'username', type: 'String', required: false },
{ name: 'passwordHash', type: 'String', required: true },
{ name: 'emailVerified', type: 'Boolean', required: false },
{ name: 'sessionToken', type: 'String', required: false },
{ name: 'lastLogin', type: 'Date', required: false },
{ name: 'resetToken', type: 'String', required: false },
{ name: 'resetTokenExpiry', type: 'Date', required: false }
// Custom fields can be added dynamically
]
};
/**
* Built-in _Session table schema (for session tracking/invalidation)
*/
export const SessionTableSchema: TableSchema = {
name: '_Session',
columns: [
{ name: 'userId', type: 'Pointer', targetClass: '_User', required: true },
{ name: 'token', type: 'String', required: true },
{ name: 'expiresAt', type: 'Date', required: true },
{ name: 'deviceInfo', type: 'String', required: false },
{ name: 'ipAddress', type: 'String', required: false }
]
};
/**
* Sanitize user object for client response (remove sensitive fields)
*/
export function sanitizeUser(user: any): any {
if (!user) return null;
const { passwordHash, resetToken, resetTokenExpiry, sessionToken, ...safeUser } = user;
return safeUser;
}
```
---
### Step 2: Auth Routes (3 hours)
**File:** `packages/noodl-editor/src/main/src/local-backend/auth/auth-routes.ts`
```typescript
import { Router, Request, Response } from 'express';
import { LocalSQLAdapter } from '@noodl/runtime/src/api/adapters/local-sql/LocalSQLAdapter';
import { generateToken, verifyToken, getJWTSecret } from './jwt-utils';
import { hashPassword, verifyPassword, generateSessionToken, validatePassword, validateEmail } from './password-utils';
import { UserTableSchema, SessionTableSchema, sanitizeUser } from './user-schema';
export function createAuthRouter(adapter: LocalSQLAdapter, backendsPath: string): Router {
const router = Router();
const jwtSecret = getJWTSecret(backendsPath);
// Ensure auth tables exist
ensureAuthTables(adapter);
/**
* POST /auth/signup
* Create a new user account
*/
router.post('/signup', async (req: Request, res: Response) => {
try {
const { email, password, username, ...customFields } = req.body;
// Validate email
if (!email || !validateEmail(email)) {
return res.status(400).json({
error: 'Invalid email address',
code: 'INVALID_EMAIL'
});
}
// Validate password
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return res.status(400).json({
error: passwordValidation.errors.join(', '),
code: 'WEAK_PASSWORD'
});
}
// Check if email already exists
const existing = await adapter.query({
collection: '_User',
where: { email: { equalTo: email.toLowerCase() } },
limit: 1
});
if (existing.results.length > 0) {
return res.status(400).json({
error: 'An account with this email already exists',
code: 'EMAIL_EXISTS'
});
}
// Check if username already exists (if provided)
if (username) {
const existingUsername = await adapter.query({
collection: '_User',
where: { username: { equalTo: username } },
limit: 1
});
if (existingUsername.results.length > 0) {
return res.status(400).json({
error: 'This username is already taken',
code: 'USERNAME_EXISTS'
});
}
}
// Hash password
const passwordHash = await hashPassword(password);
// Create user
const user = await adapter.create({
collection: '_User',
data: {
email: email.toLowerCase(),
username: username || null,
passwordHash,
emailVerified: false,
lastLogin: new Date().toISOString(),
...customFields
}
});
// Generate session token
const sessionId = generateSessionToken();
const token = generateToken({ userId: user.objectId, email: user.email, sessionId }, jwtSecret);
// Store session
await adapter.create({
collection: '_Session',
data: {
userId: user.objectId,
token: sessionId,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
ipAddress: req.ip
}
});
// Update user's session token
await adapter.save({
collection: '_User',
objectId: user.objectId,
data: { sessionToken: sessionId }
});
res.status(201).json({
user: sanitizeUser(user),
token
});
} catch (error: any) {
console.error('[Auth] Signup error:', error);
res.status(500).json({ error: 'Failed to create account', code: 'SIGNUP_FAILED' });
}
});
/**
* POST /auth/login
* Authenticate user and return token
*/
router.post('/login', async (req: Request, res: Response) => {
try {
const { email, password, username } = req.body;
// Find user by email or username
const whereClause = email ? { email: { equalTo: email.toLowerCase() } } : { username: { equalTo: username } };
const users = await adapter.query({
collection: '_User',
where: whereClause,
limit: 1
});
if (users.results.length === 0) {
return res.status(401).json({
error: 'Invalid email or password',
code: 'INVALID_CREDENTIALS'
});
}
const user = users.results[0];
// Verify password
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) {
return res.status(401).json({
error: 'Invalid email or password',
code: 'INVALID_CREDENTIALS'
});
}
// Generate new session
const sessionId = generateSessionToken();
const token = generateToken({ userId: user.objectId, email: user.email, sessionId }, jwtSecret);
// Store session
await adapter.create({
collection: '_Session',
data: {
userId: user.objectId,
token: sessionId,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
ipAddress: req.ip
}
});
// Update last login and session token
await adapter.save({
collection: '_User',
objectId: user.objectId,
data: {
lastLogin: new Date().toISOString(),
sessionToken: sessionId
}
});
res.json({
user: sanitizeUser(user),
token
});
} catch (error: any) {
console.error('[Auth] Login error:', error);
res.status(500).json({ error: 'Login failed', code: 'LOGIN_FAILED' });
}
});
/**
* POST /auth/logout
* Invalidate current session
*/
router.post('/logout', async (req: Request, res: Response) => {
try {
const user = (req as any).user;
const sessionId = (req as any).sessionId;
if (user && sessionId) {
// Delete session
const sessions = await adapter.query({
collection: '_Session',
where: {
userId: { equalTo: user.objectId },
token: { equalTo: sessionId }
},
limit: 1
});
for (const session of sessions.results) {
await adapter.delete({
collection: '_Session',
objectId: session.objectId
});
}
// Clear user's session token
await adapter.save({
collection: '_User',
objectId: user.objectId,
data: { sessionToken: null }
});
}
res.json({ success: true });
} catch (error: any) {
console.error('[Auth] Logout error:', error);
res.status(500).json({ error: 'Logout failed', code: 'LOGOUT_FAILED' });
}
});
/**
* GET /auth/me
* Get current authenticated user
*/
router.get('/me', async (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) {
return res.status(401).json({
error: 'Not authenticated',
code: 'NOT_AUTHENTICATED'
});
}
res.json({ user: sanitizeUser(user) });
});
/**
* PUT /auth/me
* Update current user's profile
*/
router.put('/me', async (req: Request, res: Response) => {
try {
const user = (req as any).user;
if (!user) {
return res.status(401).json({
error: 'Not authenticated',
code: 'NOT_AUTHENTICATED'
});
}
const { email, password, username, ...customFields } = req.body;
const updates: any = { ...customFields };
// Handle email change
if (email && email !== user.email) {
if (!validateEmail(email)) {
return res.status(400).json({
error: 'Invalid email address',
code: 'INVALID_EMAIL'
});
}
// Check if new email is taken
const existing = await adapter.query({
collection: '_User',
where: { email: { equalTo: email.toLowerCase() } },
limit: 1
});
if (existing.results.length > 0) {
return res.status(400).json({
error: 'Email already in use',
code: 'EMAIL_EXISTS'
});
}
updates.email = email.toLowerCase();
updates.emailVerified = false;
}
// Handle password change
if (password) {
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return res.status(400).json({
error: passwordValidation.errors.join(', '),
code: 'WEAK_PASSWORD'
});
}
updates.passwordHash = await hashPassword(password);
}
// Handle username change
if (username && username !== user.username) {
const existingUsername = await adapter.query({
collection: '_User',
where: { username: { equalTo: username } },
limit: 1
});
if (existingUsername.results.length > 0) {
return res.status(400).json({
error: 'Username already taken',
code: 'USERNAME_EXISTS'
});
}
updates.username = username;
}
const updatedUser = await adapter.save({
collection: '_User',
objectId: user.objectId,
data: updates
});
res.json({ user: sanitizeUser(updatedUser) });
} catch (error: any) {
console.error('[Auth] Update user error:', error);
res.status(500).json({ error: 'Failed to update profile', code: 'UPDATE_FAILED' });
}
});
/**
* POST /auth/request-password-reset
* Request a password reset email
*/
router.post('/request-password-reset', async (req: Request, res: Response) => {
try {
const { email } = req.body;
if (!email) {
return res.status(400).json({ error: 'Email is required' });
}
const users = await adapter.query({
collection: '_User',
where: { email: { equalTo: email.toLowerCase() } },
limit: 1
});
// Always return success to prevent email enumeration
if (users.results.length === 0) {
return res.json({ success: true });
}
const user = users.results[0];
const resetToken = generateSessionToken();
const resetExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await adapter.save({
collection: '_User',
objectId: user.objectId,
data: {
resetToken,
resetTokenExpiry: resetExpiry.toISOString()
}
});
// In a real implementation, send email here
// For local development, log the token
console.log(`[Auth] Password reset token for ${email}: ${resetToken}`);
res.json({ success: true });
} catch (error: any) {
console.error('[Auth] Password reset request error:', error);
res.status(500).json({ error: 'Failed to process request' });
}
});
/**
* POST /auth/reset-password
* Reset password using token
*/
router.post('/reset-password', async (req: Request, res: Response) => {
try {
const { token, password } = req.body;
if (!token || !password) {
return res.status(400).json({ error: 'Token and password are required' });
}
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return res.status(400).json({
error: passwordValidation.errors.join(', '),
code: 'WEAK_PASSWORD'
});
}
const users = await adapter.query({
collection: '_User',
where: { resetToken: { equalTo: token } },
limit: 1
});
if (users.results.length === 0) {
return res.status(400).json({
error: 'Invalid or expired reset token',
code: 'INVALID_TOKEN'
});
}
const user = users.results[0];
// Check if token is expired
if (new Date(user.resetTokenExpiry) < new Date()) {
return res.status(400).json({
error: 'Reset token has expired',
code: 'EXPIRED_TOKEN'
});
}
// Update password and clear reset token
const passwordHash = await hashPassword(password);
await adapter.save({
collection: '_User',
objectId: user.objectId,
data: {
passwordHash,
resetToken: null,
resetTokenExpiry: null
}
});
// Invalidate all existing sessions
const sessions = await adapter.query({
collection: '_Session',
where: { userId: { equalTo: user.objectId } }
});
for (const session of sessions.results) {
await adapter.delete({
collection: '_Session',
objectId: session.objectId
});
}
res.json({ success: true });
} catch (error: any) {
console.error('[Auth] Password reset error:', error);
res.status(500).json({ error: 'Failed to reset password' });
}
});
return router;
}
/**
* Ensure _User and _Session tables exist
*/
async function ensureAuthTables(adapter: LocalSQLAdapter): Promise<void> {
try {
const schema = await adapter.getSchema();
const tableNames = schema.tables.map((t) => t.name);
if (!tableNames.includes('_User')) {
await adapter.createTable(UserTableSchema);
console.log('[Auth] Created _User table');
}
if (!tableNames.includes('_Session')) {
await adapter.createTable(SessionTableSchema);
console.log('[Auth] Created _Session table');
}
} catch (error) {
console.error('[Auth] Failed to create auth tables:', error);
}
}
```
---
### Step 3: Auth Middleware (1 hour)
**File:** `packages/noodl-editor/src/main/src/local-backend/auth/auth-middleware.ts`
```typescript
import { Request, Response, NextFunction } from 'express';
import { LocalSQLAdapter } from '@noodl/runtime/src/api/adapters/local-sql/LocalSQLAdapter';
import { verifyToken, getJWTSecret } from './jwt-utils';
declare global {
namespace Express {
interface Request {
user?: any;
sessionId?: string;
isAuthenticated: boolean;
}
}
}
/**
* Create auth middleware that validates JWT tokens
*/
export function createAuthMiddleware(adapter: LocalSQLAdapter, backendsPath: string) {
const jwtSecret = getJWTSecret(backendsPath);
return async (req: Request, res: Response, next: NextFunction) => {
req.isAuthenticated = false;
// Extract token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return next();
}
const token = authHeader.substring(7);
try {
// Verify JWT
const payload = verifyToken(token, jwtSecret);
if (!payload) {
return next();
}
// Validate session still exists and hasn't expired
const sessions = await adapter.query({
collection: '_Session',
where: {
userId: { equalTo: payload.userId },
token: { equalTo: payload.sessionId }
},
limit: 1
});
if (sessions.results.length === 0) {
return next();
}
const session = sessions.results[0];
if (new Date(session.expiresAt) < new Date()) {
// Session expired, clean it up
await adapter.delete({
collection: '_Session',
objectId: session.objectId
});
return next();
}
// Get user
const user = await adapter.fetch({
collection: '_User',
objectId: payload.userId
});
if (user) {
req.user = user;
req.sessionId = payload.sessionId;
req.isAuthenticated = true;
}
} catch (error) {
console.error('[Auth Middleware] Error:', error);
}
next();
};
}
/**
* Middleware that requires authentication
*/
export function requireAuth(req: Request, res: Response, next: NextFunction) {
if (!req.isAuthenticated) {
return res.status(401).json({
error: 'Authentication required',
code: 'AUTH_REQUIRED'
});
}
next();
}
/**
* Middleware that optionally uses auth but doesn't require it
*/
export function optionalAuth(req: Request, res: Response, next: NextFunction) {
// Auth middleware already ran, just continue
next();
}
```
---
### Step 4: Frontend Auth Nodes (3 hours)
**File:** `packages/noodl-viewer-cloud/src/nodes/auth/sign-up.ts`
```typescript
export const node = {
name: 'noodl.auth.signUp',
displayNodeName: 'Sign Up',
docs: 'https://docs.nodegex.com/nodes/auth/sign-up',
category: 'Authentication',
color: 'data',
initialize() {
this._internal.customFields = {};
},
getInspectInfo() {
if (this._internal.error) {
return { type: 'text', value: `Error: ${this._internal.error}` };
}
if (this._internal.user) {
return { type: 'text', value: `Signed up: ${this._internal.user.email}` };
}
return { type: 'text', value: 'Ready' };
},
inputs: {
email: {
type: 'string',
displayName: 'Email',
group: 'Credentials',
set(value: string) {
this._internal.email = value;
}
},
password: {
type: 'string',
displayName: 'Password',
group: 'Credentials',
set(value: string) {
this._internal.password = value;
}
},
username: {
type: 'string',
displayName: 'Username',
group: 'Profile',
set(value: string) {
this._internal.username = value;
}
},
customFields: {
type: { name: 'stringlist', allowEditOnly: true },
displayName: 'Custom Fields',
group: 'Profile',
set(value: string) {
this._internal.customFieldNames = value
?.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
},
signUp: {
type: 'signal',
displayName: 'Sign Up',
group: 'Actions',
valueChangedToTrue() {
this.doSignUp();
}
}
},
outputs: {
user: {
type: 'object',
displayName: 'User',
group: 'Result',
getter() {
return this._internal.user;
}
},
userId: {
type: 'string',
displayName: 'User ID',
group: 'Result',
getter() {
return this._internal.user?.objectId;
}
},
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Events',
getter() {
return this._internal.error;
}
},
errorCode: {
type: 'string',
displayName: 'Error Code',
group: 'Events',
getter() {
return this._internal.errorCode;
}
}
},
methods: {
async doSignUp() {
try {
const authService = this.context.getAuthService();
const payload: any = {
email: this._internal.email,
password: this._internal.password
};
if (this._internal.username) {
payload.username = this._internal.username;
}
// Add custom fields
for (const fieldName of this._internal.customFieldNames || []) {
if (this._internal.customFields[fieldName] !== undefined) {
payload[fieldName] = this._internal.customFields[fieldName];
}
}
const result = await authService.signUp(payload);
this._internal.user = result.user;
this._internal.error = null;
this._internal.errorCode = null;
this.flagOutputDirty('user');
this.flagOutputDirty('userId');
this.flagOutputDirty('error');
this.flagOutputDirty('errorCode');
this.sendSignalOnOutput('success');
} catch (e: any) {
this._internal.error = e.message || 'Sign up failed';
this._internal.errorCode = e.code || 'SIGNUP_FAILED';
this._internal.user = null;
this.flagOutputDirty('error');
this.flagOutputDirty('errorCode');
this.flagOutputDirty('user');
this.sendSignalOnOutput('failure');
}
},
setCustomField(name: string, value: any) {
this._internal.customFields[name] = value;
},
registerInputIfNeeded(name: string) {
if (this.hasInput(name)) return;
if (name.startsWith('cf-')) {
this.registerInput(name, {
set: this.setCustomField.bind(this, name.substring(3))
});
}
}
},
dynamicports: [
{
name: 'conditionalports/extended',
condition: 'customFields',
inputs: (parameters: any) => {
const fields =
parameters.customFields
?.split(',')
.map((s: string) => s.trim())
.filter(Boolean) || [];
return fields.map((f: string) => ({
name: 'cf-' + f,
displayName: f,
type: '*',
group: 'Custom Fields'
}));
}
}
]
};
```
**File:** `packages/noodl-viewer-cloud/src/nodes/auth/log-in.ts`
```typescript
export const node = {
name: 'noodl.auth.logIn',
displayNodeName: 'Log In',
docs: 'https://docs.nodegex.com/nodes/auth/log-in',
category: 'Authentication',
color: 'data',
getInspectInfo() {
if (this._internal.error) {
return { type: 'text', value: `Error: ${this._internal.error}` };
}
if (this._internal.user) {
return { type: 'text', value: `Logged in: ${this._internal.user.email}` };
}
return { type: 'text', value: 'Ready' };
},
inputs: {
email: {
type: 'string',
displayName: 'Email',
group: 'Credentials',
set(value: string) {
this._internal.email = value;
}
},
password: {
type: 'string',
displayName: 'Password',
group: 'Credentials',
set(value: string) {
this._internal.password = value;
}
},
username: {
type: 'string',
displayName: 'Username (alternative)',
group: 'Credentials',
set(value: string) {
this._internal.username = value;
}
},
logIn: {
type: 'signal',
displayName: 'Log In',
group: 'Actions',
valueChangedToTrue() {
this.doLogIn();
}
}
},
outputs: {
user: {
type: 'object',
displayName: 'User',
group: 'Result',
getter() {
return this._internal.user;
}
},
userId: {
type: 'string',
displayName: 'User ID',
group: 'Result',
getter() {
return this._internal.user?.objectId;
}
},
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Events',
getter() {
return this._internal.error;
}
},
errorCode: {
type: 'string',
displayName: 'Error Code',
group: 'Events',
getter() {
return this._internal.errorCode;
}
}
},
methods: {
async doLogIn() {
try {
const authService = this.context.getAuthService();
const result = await authService.logIn({
email: this._internal.email,
password: this._internal.password,
username: this._internal.username
});
this._internal.user = result.user;
this._internal.error = null;
this._internal.errorCode = null;
this.flagOutputDirty('user');
this.flagOutputDirty('userId');
this.flagOutputDirty('error');
this.flagOutputDirty('errorCode');
this.sendSignalOnOutput('success');
} catch (e: any) {
this._internal.error = e.message || 'Login failed';
this._internal.errorCode = e.code || 'LOGIN_FAILED';
this._internal.user = null;
this.flagOutputDirty('error');
this.flagOutputDirty('errorCode');
this.flagOutputDirty('user');
this.sendSignalOnOutput('failure');
}
}
}
};
```
**File:** `packages/noodl-viewer-cloud/src/nodes/auth/log-out.ts`
```typescript
export const node = {
name: 'noodl.auth.logOut',
displayNodeName: 'Log Out',
docs: 'https://docs.nodegex.com/nodes/auth/log-out',
category: 'Authentication',
color: 'data',
inputs: {
logOut: {
type: 'signal',
displayName: 'Log Out',
group: 'Actions',
valueChangedToTrue() {
this.doLogOut();
}
}
},
outputs: {
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Events',
getter() {
return this._internal.error;
}
}
},
methods: {
async doLogOut() {
try {
const authService = this.context.getAuthService();
await authService.logOut();
this._internal.error = null;
this.flagOutputDirty('error');
this.sendSignalOnOutput('success');
} catch (e: any) {
this._internal.error = e.message || 'Logout failed';
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
}
}
}
};
```
**File:** `packages/noodl-viewer-cloud/src/nodes/auth/current-user.ts`
```typescript
export const node = {
name: 'noodl.auth.currentUser',
displayNodeName: 'Current User',
docs: 'https://docs.nodegex.com/nodes/auth/current-user',
category: 'Authentication',
color: 'data',
initialize() {
// Subscribe to auth state changes
const authService = this.context.getAuthService();
this._internal.unsubscribe = authService.onAuthStateChange((user: any) => {
this._internal.user = user;
this.flagOutputDirty('user');
this.flagOutputDirty('userId');
this.flagOutputDirty('email');
this.flagOutputDirty('username');
this.flagOutputDirty('isLoggedIn');
if (user) {
this.sendSignalOnOutput('loggedIn');
} else {
this.sendSignalOnOutput('loggedOut');
}
});
// Get initial state
this._internal.user = authService.getCurrentUser();
},
destroy() {
if (this._internal.unsubscribe) {
this._internal.unsubscribe();
}
},
getInspectInfo() {
if (this._internal.user) {
return { type: 'text', value: `Logged in: ${this._internal.user.email}` };
}
return { type: 'text', value: 'Not logged in' };
},
inputs: {
fetch: {
type: 'signal',
displayName: 'Fetch',
group: 'Actions',
valueChangedToTrue() {
this.fetchUser();
}
}
},
outputs: {
user: {
type: 'object',
displayName: 'User',
group: 'User Data',
getter() {
return this._internal.user;
}
},
userId: {
type: 'string',
displayName: 'User ID',
group: 'User Data',
getter() {
return this._internal.user?.objectId;
}
},
email: {
type: 'string',
displayName: 'Email',
group: 'User Data',
getter() {
return this._internal.user?.email;
}
},
username: {
type: 'string',
displayName: 'Username',
group: 'User Data',
getter() {
return this._internal.user?.username;
}
},
isLoggedIn: {
type: 'boolean',
displayName: 'Is Logged In',
group: 'Status',
getter() {
return !!this._internal.user;
}
},
loggedIn: {
type: 'signal',
displayName: 'Logged In',
group: 'Events'
},
loggedOut: {
type: 'signal',
displayName: 'Logged Out',
group: 'Events'
}
},
methods: {
async fetchUser() {
const authService = this.context.getAuthService();
this._internal.user = await authService.fetchCurrentUser();
this.flagOutputDirty('user');
this.flagOutputDirty('userId');
this.flagOutputDirty('email');
this.flagOutputDirty('username');
this.flagOutputDirty('isLoggedIn');
}
}
};
```
**File:** `packages/noodl-viewer-cloud/src/nodes/auth/update-user.ts`
```typescript
export const node = {
name: 'noodl.auth.updateUser',
displayNodeName: 'Update User',
docs: 'https://docs.nodegex.com/nodes/auth/update-user',
category: 'Authentication',
color: 'data',
initialize() {
this._internal.updates = {};
},
inputs: {
email: {
type: 'string',
displayName: 'Email',
group: 'Update Fields',
set(value: string) {
this._internal.email = value;
}
},
password: {
type: 'string',
displayName: 'New Password',
group: 'Update Fields',
set(value: string) {
this._internal.password = value;
}
},
username: {
type: 'string',
displayName: 'Username',
group: 'Update Fields',
set(value: string) {
this._internal.username = value;
}
},
customFields: {
type: { name: 'stringlist', allowEditOnly: true },
displayName: 'Custom Fields',
group: 'Update Fields',
set(value: string) {
this._internal.customFieldNames = value
?.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
},
save: {
type: 'signal',
displayName: 'Save',
group: 'Actions',
valueChangedToTrue() {
this.doUpdate();
}
}
},
outputs: {
user: {
type: 'object',
displayName: 'Updated User',
group: 'Result',
getter() {
return this._internal.updatedUser;
}
},
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Events',
getter() {
return this._internal.error;
}
}
},
methods: {
async doUpdate() {
try {
const authService = this.context.getAuthService();
const updates: any = {};
if (this._internal.email) updates.email = this._internal.email;
if (this._internal.password) updates.password = this._internal.password;
if (this._internal.username) updates.username = this._internal.username;
// Add custom fields
for (const fieldName of this._internal.customFieldNames || []) {
if (this._internal.updates[fieldName] !== undefined) {
updates[fieldName] = this._internal.updates[fieldName];
}
}
const result = await authService.updateCurrentUser(updates);
this._internal.updatedUser = result.user;
this._internal.error = null;
this.flagOutputDirty('user');
this.flagOutputDirty('error');
this.sendSignalOnOutput('success');
} catch (e: any) {
this._internal.error = e.message || 'Update failed';
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
}
},
setCustomField(name: string, value: any) {
this._internal.updates[name] = value;
},
registerInputIfNeeded(name: string) {
if (this.hasInput(name)) return;
if (name.startsWith('cf-')) {
this.registerInput(name, {
set: this.setCustomField.bind(this, name.substring(3))
});
}
}
},
dynamicports: [
{
name: 'conditionalports/extended',
condition: 'customFields',
inputs: (parameters: any) => {
const fields =
parameters.customFields
?.split(',')
.map((s: string) => s.trim())
.filter(Boolean) || [];
return fields.map((f: string) => ({
name: 'cf-' + f,
displayName: f,
type: '*',
group: 'Custom Fields'
}));
}
}
]
};
```
---
### Step 5: Auth Service for Frontend (2 hours)
**File:** `packages/noodl-runtime/src/api/auth/AuthService.ts`
```typescript
import { EventEmitter } from 'events';
export interface AuthUser {
objectId: string;
email: string;
username?: string;
emailVerified?: boolean;
createdAt: string;
updatedAt: string;
[key: string]: any;
}
export interface AuthResult {
user: AuthUser;
token: string;
}
export class AuthService extends EventEmitter {
private baseUrl: string;
private token: string | null = null;
private currentUser: AuthUser | null = null;
private storageKey = 'noodl_auth_token';
constructor(baseUrl: string) {
super();
this.baseUrl = baseUrl.replace(/\/$/, '');
this.loadStoredSession();
}
/**
* Load session from storage on init
*/
private async loadStoredSession(): Promise<void> {
if (typeof localStorage === 'undefined') return;
const storedToken = localStorage.getItem(this.storageKey);
if (storedToken) {
this.token = storedToken;
try {
await this.fetchCurrentUser();
} catch (e) {
// Token invalid, clear it
this.clearSession();
}
}
}
/**
* Sign up a new user
*/
async signUp(data: { email: string; password: string; username?: string; [key: string]: any }): Promise<AuthResult> {
const response = await this.request('/auth/signup', {
method: 'POST',
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new AuthError(error.error, error.code);
}
const result = await response.json();
this.setSession(result.token, result.user);
return result;
}
/**
* Log in an existing user
*/
async logIn(data: { email?: string; password: string; username?: string }): Promise<AuthResult> {
const response = await this.request('/auth/login', {
method: 'POST',
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new AuthError(error.error, error.code);
}
const result = await response.json();
this.setSession(result.token, result.user);
return result;
}
/**
* Log out current user
*/
async logOut(): Promise<void> {
if (this.token) {
try {
await this.request('/auth/logout', {
method: 'POST'
});
} catch (e) {
// Ignore logout errors
}
}
this.clearSession();
}
/**
* Get current user
*/
getCurrentUser(): AuthUser | null {
return this.currentUser;
}
/**
* Fetch current user from server
*/
async fetchCurrentUser(): Promise<AuthUser | null> {
if (!this.token) return null;
const response = await this.request('/auth/me', {
method: 'GET'
});
if (!response.ok) {
this.clearSession();
return null;
}
const { user } = await response.json();
this.currentUser = user;
this.emit('authStateChange', user);
return user;
}
/**
* Update current user
*/
async updateCurrentUser(updates: {
email?: string;
password?: string;
username?: string;
[key: string]: any;
}): Promise<AuthResult> {
const response = await this.request('/auth/me', {
method: 'PUT',
body: JSON.stringify(updates)
});
if (!response.ok) {
const error = await response.json();
throw new AuthError(error.error, error.code);
}
const { user } = await response.json();
this.currentUser = user;
this.emit('authStateChange', user);
return { user, token: this.token! };
}
/**
* Request password reset
*/
async requestPasswordReset(email: string): Promise<void> {
const response = await this.request('/auth/request-password-reset', {
method: 'POST',
body: JSON.stringify({ email })
});
if (!response.ok) {
const error = await response.json();
throw new AuthError(error.error, error.code);
}
}
/**
* Reset password with token
*/
async resetPassword(token: string, password: string): Promise<void> {
const response = await this.request('/auth/reset-password', {
method: 'POST',
body: JSON.stringify({ token, password })
});
if (!response.ok) {
const error = await response.json();
throw new AuthError(error.error, error.code);
}
}
/**
* Subscribe to auth state changes
*/
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void {
this.on('authStateChange', callback);
return () => this.off('authStateChange', callback);
}
/**
* Get auth token for requests
*/
getToken(): string | null {
return this.token;
}
/**
* Check if user is logged in
*/
isLoggedIn(): boolean {
return this.token !== null && this.currentUser !== null;
}
// Private methods
private setSession(token: string, user: AuthUser): void {
this.token = token;
this.currentUser = user;
if (typeof localStorage !== 'undefined') {
localStorage.setItem(this.storageKey, token);
}
this.emit('authStateChange', user);
}
private clearSession(): void {
this.token = null;
this.currentUser = null;
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(this.storageKey);
}
this.emit('authStateChange', null);
}
private async request(path: string, options: RequestInit = {}): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((options.headers as Record<string, string>) || {})
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
return fetch(`${this.baseUrl}${path}`, {
...options,
headers
});
}
}
export class AuthError extends Error {
code: string;
constructor(message: string, code: string = 'AUTH_ERROR') {
super(message);
this.name = 'AuthError';
this.code = code;
}
}
```
---
### Step 6: Integration with Backend Server (1 hour)
**File:** Modifications to `packages/noodl-editor/src/main/src/local-backend/LocalBackendServer.ts`
```typescript
// Add imports
import { createAuthRouter } from './auth/auth-routes';
import { createAuthMiddleware } from './auth/auth-middleware';
// In LocalBackendServer constructor, after setupMiddleware():
private setupAuth(): void {
// Add auth middleware (runs on every request)
this.app.use(createAuthMiddleware(this.adapter!, this.config.backendsPath));
// Add auth routes
this.app.use('/auth', createAuthRouter(this.adapter!, this.config.backendsPath));
}
// In start() method, after adapter.connect():
async start(): Promise<void> {
// ... existing code ...
await this.adapter.connect();
// Set up authentication
this.setupAuth();
// ... rest of existing code ...
}
```
---
## Files to Create
```
packages/noodl-editor/src/main/src/local-backend/auth/
├── password-utils.ts
├── jwt-utils.ts
├── user-schema.ts
├── auth-routes.ts
├── auth-middleware.ts
└── index.ts
packages/noodl-viewer-cloud/src/nodes/auth/
├── sign-up.ts
├── log-in.ts
├── log-out.ts
├── current-user.ts
├── update-user.ts
└── index.ts
packages/noodl-runtime/src/api/auth/
├── AuthService.ts
└── index.ts
```
## Files to Modify
```
packages/noodl-editor/src/main/src/local-backend/LocalBackendServer.ts
- Add auth middleware and routes
packages/noodl-viewer-cloud/src/nodes/index.ts
- Register auth nodes
packages/noodl-runtime/src/api/cloudstore.js
- Inject AuthService into context
- Auto-attach auth token to requests
packages/noodl-editor/package.json
- Add bcrypt and jsonwebtoken dependencies
```
---
## API Reference
### Auth Endpoints
| Method | Endpoint | Description | Auth Required |
| ------ | ------------------------------ | ------------------- | ------------- |
| POST | `/auth/signup` | Create new account | No |
| POST | `/auth/login` | Authenticate user | No |
| POST | `/auth/logout` | Invalidate session | Yes |
| GET | `/auth/me` | Get current user | Yes |
| PUT | `/auth/me` | Update current user | Yes |
| POST | `/auth/request-password-reset` | Request reset email | No |
| POST | `/auth/reset-password` | Reset with token | No |
### Request/Response Examples
**Sign Up:**
```javascript
// Request
POST /auth/signup
{
"email": "user@example.com",
"password": "securepass123",
"username": "johndoe",
"displayName": "John Doe" // custom field
}
// Response
{
"user": {
"objectId": "abc123",
"email": "user@example.com",
"username": "johndoe",
"displayName": "John Doe",
"emailVerified": false,
"createdAt": "2024-01-15T10:00:00Z"
},
"token": "eyJhbGciOiJIUzI1NiIs..."
}
```
**Log In:**
```javascript
// Request
POST /auth/login
{
"email": "user@example.com",
"password": "securepass123"
}
// Response
{
"user": { ... },
"token": "eyJhbGciOiJIUzI1NiIs..."
}
```
### Error Codes
| Code | Description |
| --------------------- | ---------------------------------- |
| `INVALID_EMAIL` | Email format is invalid |
| `WEAK_PASSWORD` | Password doesn't meet requirements |
| `EMAIL_EXISTS` | Email already registered |
| `USERNAME_EXISTS` | Username already taken |
| `INVALID_CREDENTIALS` | Wrong email/password |
| `NOT_AUTHENTICATED` | No valid session |
| `AUTH_REQUIRED` | Endpoint requires auth |
| `INVALID_TOKEN` | Reset token invalid |
| `EXPIRED_TOKEN` | Reset token expired |
---
## Testing Checklist
### Password Utilities
- [ ] Password hashing works correctly
- [ ] Password verification works
- [ ] Weak passwords rejected
- [ ] Email validation works
### JWT Utilities
- [ ] Token generation works
- [ ] Token verification works
- [ ] Expired tokens rejected
- [ ] Secret persists across restarts
### Auth Routes
- [ ] Sign up creates user
- [ ] Sign up rejects duplicate email
- [ ] Sign up rejects weak password
- [ ] Login succeeds with valid credentials
- [ ] Login fails with wrong password
- [ ] Login fails with unknown email
- [ ] Logout invalidates session
- [ ] /me returns current user
- [ ] /me returns 401 when not logged in
- [ ] Update user changes email
- [ ] Update user changes password
- [ ] Password reset flow works
### Auth Middleware
- [ ] Extracts token from header
- [ ] Validates token signature
- [ ] Checks session exists
- [ ] Attaches user to request
- [ ] Passes through without token
### Frontend Nodes
- [ ] Sign Up node works
- [ ] Log In node works
- [ ] Log Out node works
- [ ] Current User shows correct state
- [ ] Current User updates on auth change
- [ ] Update User saves changes
- [ ] Custom fields work
### Integration
- [ ] Auth state persists after page refresh
- [ ] Authenticated requests include token
- [ ] 401 response triggers logout
- [ ] Multiple sessions work
---
## Success Criteria
1. Users can sign up, log in, log out via visual nodes
2. Session persists across page refreshes
3. Password stored securely (bcrypt hash)
4. Token expiry and refresh work correctly
5. API compatible with existing Parse auth nodes
6. Custom user fields supported
---
## Security Considerations
1. **Password Storage**: bcrypt with cost factor 12
2. **Token Security**: JWT with 7-day expiry, stored in localStorage
3. **Session Tracking**: Server-side session validation
4. **Rate Limiting**: Should add to prevent brute force (future enhancement)
5. **HTTPS**: Recommend for production deployments
6. **Email Enumeration**: Signup/reset responses don't reveal if email exists
---
## Dependencies
**NPM packages to add:**
- `bcrypt` - Password hashing
- `jsonwebtoken` - JWT generation/verification
**Internal:**
- TASK-007A (LocalSQLAdapter)
- TASK-007B (LocalBackendServer)
**Blocks:**
- ACL/permissions system (future enhancement)
---
## Estimated Session Breakdown
| Session | Focus | Hours |
| --------- | -------------------------------------------- | ------ |
| 1 | Password/JWT utilities + User schema | 2 |
| 2 | Auth routes (signup, login, logout) | 2 |
| 3 | Auth routes (me, update, reset) + middleware | 2 |
| 4 | Frontend auth nodes | 3 |
| 5 | AuthService + integration | 2 |
| 6 | Testing + edge cases | 1 |
| **Total** | | **12** |
---
## Future Enhancements
These are out of scope for initial implementation but could be added later:
1. **Email Verification**: Send verification emails, verify endpoint
2. **OAuth/Social Login**: Google, GitHub, etc.
3. **Two-Factor Authentication**: TOTP support
4. **Rate Limiting**: Prevent brute force attacks
5. **ACL System**: Record-level permissions
6. **Role-Based Access**: Admin, User, etc. roles
7. **Session Management UI**: View/revoke active sessions
8. **Audit Log**: Track auth events