Authentication Authorization - jra3/mulm GitHub Wiki

Authentication & Authorization

This guide explains how authentication and authorization work in the Mulm platform.

Overview

Mulm uses cookie-based session authentication with support for:

  • Email/Password authentication (scrypt password hashing)
  • OAuth 2.0 (Google Sign-In)
  • Passkeys (WebAuthn) (passwordless biometric authentication) - See Passkey Authentication
  • Password reset (time-limited tokens)

Authorization is role-based with two roles:

  • Member - Can manage own submissions and profile
  • Admin - Can approve submissions, manage members, access admin features

Table of Contents

  1. Authentication Flow
  2. Password Authentication
  3. OAuth Authentication
  4. Session Management
  5. Authorization
  6. Password Reset
  7. Security Considerations

Authentication Flow

Sign Up Flow

sequenceDiagram
    participant User
    participant Browser
    participant App
    participant DB

    User->>Browser: Fill signup form
    Browser->>App: POST /auth/signup
    App->>App: Validate with Zod
    App->>App: Hash password (scrypt)
    App->>DB: INSERT member + password
    DB-->>App: member_id
    App->>App: Create session
    App->>DB: INSERT session
    App-->>Browser: Set session cookie
    Browser-->>User: Redirect to homepage (logged in)
Loading

Implementation:

// src/routes/auth.ts
export const signup = async (req: MulmRequest, res: Response) => {
  // 1. Validate form
  const parsed = signupSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.render('account/signup', { errors: parsed.error });
  }

  // 2. Create member with password
  const memberId = await createMember(
    parsed.data.email,
    parsed.data.display_name,
    { password: parsed.data.password }  // Hashed internally
  );

  // 3. Create session
  await createUserSession(req, res, memberId);

  // 4. Redirect home
  res.set('HX-redirect', '/').send();
};

Login Flow

sequenceDiagram
    participant User
    participant Browser
    participant App
    participant DB

    User->>Browser: Enter email/password
    Browser->>App: POST /auth/login
    App->>DB: SELECT password hash for email
    DB-->>App: Password entry
    App->>App: Verify password (scrypt)
    alt Password valid
        App->>DB: INSERT session
        App-->>Browser: Set session cookie
        Browser-->>User: Redirect home (logged in)
    else Password invalid
        App-->>Browser: Error message
        Browser-->>User: "Incorrect email or password"
    end
Loading

Implementation:

export const passwordLogin = async (req: MulmRequest, res: Response) => {
  // 1. Get member by email
  const member = await getMemberByEmail(data.email);
  if (!member) {
    return res.send('Incorrect email or password');
  }

  // 2. Get stored password hash
  const storedPassword = await getMemberPassword(member.id);

  // 3. Verify password
  if (await checkPassword(storedPassword, data.password)) {
    // 4. Create session
    await createUserSession(req, res, member.id);
    res.set('HX-Redirect', '/').send();
  } else {
    res.send('Incorrect email or password');
  }
};

Password Authentication

Password Hashing (scrypt)

Hash generation:

// src/auth.ts
export async function makePasswordEntry(password: string): Promise<ScryptPassword> {
  const salt = randomBytes(16);  // 128-bit random salt
  const scryptOptions = {
    N: 16384,  // CPU/memory cost (2^14)
    r: 8,      // Block size
    p: 1,      // Parallelization
  };

  const hash = await scrypt(password, salt, 32, scryptOptions);  // 32-byte key

  return {
    ...scryptOptions,
    salt: salt.toString('base64'),
    hash: hash.toString('base64'),
  };
}

Storage:

CREATE TABLE password_account (
  member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
  N INTEGER NOT NULL,        -- scrypt cost parameter
  r INTEGER NOT NULL,        -- scrypt block size
  p INTEGER NOT NULL,        -- scrypt parallelization
  salt TEXT NOT NULL,        -- base64-encoded salt
  hash TEXT NOT NULL         -- base64-encoded hash
);

Why these parameters?

  • N=16384 - Recommended value for interactive logins (OWASP 2023)
  • r=8, p=1 - Standard parameters
  • Hashing time: ~100-200ms (acceptable for login, too slow for brute force)

Password Verification

export async function checkPassword(
  passwordEntry: ScryptPassword | undefined,
  clearPassword: string
): Promise<boolean> {
  if (!passwordEntry) return false;

  // Hash the input password with same parameters
  const hash = await scrypt(
    clearPassword,
    Buffer.from(passwordEntry.salt, 'base64'),
    32,
    { N: passwordEntry.N, r: passwordEntry.r, p: passwordEntry.p }
  );

  // Constant-time comparison
  return hash.toString('base64') === passwordEntry.hash;
}

Security features:

  • Unique salt per password (prevents rainbow tables)
  • Stored parameters allow future cost increases
  • Constant-time comparison (prevents timing attacks)

OAuth Authentication

Google Sign-In Flow

sequenceDiagram
    participant User
    participant Browser
    participant App
    participant Google
    participant DB

    User->>Browser: Click "Sign in with Google"
    Browser->>Google: Redirect to Google OAuth
    Google-->>User: Google login page
    User->>Google: Authorize app
    Google-->>Browser: Redirect to /oauth/google?code=...
    Browser->>App: GET /oauth/google?code=...
    App->>Google: Exchange code for tokens
    Google-->>App: Access token + ID token
    App->>Google: Get user profile
    Google-->>App: {sub, email, name}
    App->>DB: Find or create member by google_sub
    DB-->>App: member_id
    App->>DB: INSERT session
    App-->>Browser: Set session cookie
    Browser-->>User: Redirect home (logged in)
Loading

Implementation:

// src/routes/auth.ts
export const googleOAuthCallback = async (req: MulmRequest, res: Response) => {
  // 1. Exchange authorization code for tokens
  const { access_token } = await translateGoogleOAuthCode(req.query.code);

  // 2. Get user info from Google
  const googleUser = await getGoogleUser(access_token);

  // 3. Find or create member
  let googleAccount = await getGoogleAccount(googleUser.sub);

  if (!googleAccount) {
    // New Google user - create member
    const memberId = await createMember(googleUser.email, googleUser.name);
    await createGoogleAccount({
      google_sub: googleUser.sub,
      google_email: googleUser.email,
      member_id: memberId
    });
    googleAccount = { member_id: memberId };
  }

  // 4. Create session
  await createUserSession(req, res, googleAccount.member_id);

  res.redirect('/');
};

OAuth Configuration:

{
  "googleClientId": "123456789.apps.googleusercontent.com",
  "googleClientSecret": "actual-secret-here",
  "domain": "https://bap.basny.org"
}

Redirect URI (registered with Google):

  • Development: http://localhost:4200/oauth/google
  • Production: https://bap.basny.org/oauth/google

Session Management

Session Creation

// src/sessions.ts
export async function createUserSession(
  req: Request,
  res: Response,
  memberId: number
): Promise<void> {
  // 1. Generate cryptographically random session ID
  const sessionId = generateRandomCode(64);  // 512 bits

  // 2. Calculate expiration (180 days)
  const expiry = new Date(Date.now() + (180 * 86400 * 1000));

  // 3. Store in database
  await db.run(
    'INSERT INTO sessions (session_id, member_id, expires_on) VALUES (?, ?, ?)',
    [sessionId, memberId, expiry.toISOString()]
  );

  // 4. Set cookie
  res.cookie('session_id', sessionId, {
    httpOnly: true,     // JavaScript cannot access
    secure: process.env.NODE_ENV === 'production',  // HTTPS only in prod
    sameSite: 'lax',    // CSRF protection
    maxAge: 180 * 86400 * 1000,  // 180 days
  });
}

Session Validation

Middleware runs on every request:

// src/sessions.ts
export async function sessionMiddleware(
  req: MulmRequest,
  _res: Response,
  next: NextFunction
): Promise<void> {
  const sessionId = req.cookies.session_id;

  if (sessionId) {
    // Load user from session
    req.viewer = await getLoggedInUser(sessionId);
  }

  next();
}

async function getLoggedInUser(sessionId: string): Promise<Viewer | undefined> {
  const now = new Date().toISOString();

  const results = await query<Viewer>(`
    SELECT
      members.id,
      members.display_name,
      members.contact_email,
      members.is_admin,
      members.fish_level,
      members.plant_level,
      members.coral_level
    FROM sessions
    JOIN members ON sessions.member_id = members.id
    WHERE sessions.session_id = ?
      AND sessions.expires_on > ?
  `, [sessionId, now]);

  return results[0];  // undefined if session invalid/expired
}

Registered in Express:

// src/index.ts
import { sessionMiddleware } from './sessions';

app.use(cookieParser());
app.use(sessionMiddleware);  // Runs on every request

// Now all routes have access to req.viewer

Session Destruction

export async function destroyUserSession(
  req: MulmRequest,
  res: Response
): Promise<void> {
  // 1. Clear cookie
  res.cookie('session_id', null);

  // 2. Delete from database
  const sessionId = req.cookies.session_id;
  if (sessionId) {
    await db.run('DELETE FROM sessions WHERE session_id = ?', [sessionId]);
  }
}

// Logout route
export const logout = async (req: MulmRequest, res: Response) => {
  await destroyUserSession(req, res);
  res.set('HX-Redirect', '/').send();
};

Authorization

Role-Based Access Control

Two roles:

Role Permissions
Member (is_admin=0) View own profile, create/edit own submissions, view public data
Admin (is_admin=1) All member permissions + approve submissions, manage members, access admin queues

Checking Authentication

// In route handler
export const myRoute = async (req: MulmRequest, res: Response) => {
  // req.viewer is populated by sessionMiddleware

  if (!req.viewer) {
    // Not logged in
    return res.redirect('/auth/signin');
  }

  // Logged in - proceed
  const user = req.viewer;  // { id, display_name, contact_email, is_admin, ... }
};

Checking Authorization

Require admin:

// Middleware approach
import { requireAdmin } from './routes/admin';

router.use('/admin/*', requireAdmin);

// Implementation:
export function requireAdmin(req: MulmRequest, res: Response, next: NextFunction) {
  if (!req.viewer) {
    return res.status(401).send('Unauthorized');
  }

  if (!req.viewer.is_admin) {
    return res.status(403).send('Forbidden');
  }

  next();
}

Inline check:

export const sensitiveRoute = async (req: MulmRequest, res: Response) => {
  if (!req.viewer?.is_admin) {
    return res.status(403).send('Forbidden');
  }

  // Admin-only logic
};

Resource Ownership

Check user owns resource:

export const editSubmission = async (req: MulmRequest, res: Response) => {
  const submission = await getSubmissionById(req.params.id);

  // Check ownership OR admin
  const canEdit = req.viewer
    && (req.viewer.id === submission.member_id || req.viewer.is_admin);

  if (!canEdit) {
    return res.status(403).send('Forbidden');
  }

  // Allow edit
};

Password Reset

Reset Request Flow

sequenceDiagram
    participant User
    participant App
    participant DB
    participant Email

    User->>App: POST /auth/forgot-password (email)
    App->>DB: Find member by email
    alt Member exists
        App->>App: Generate random code
        App->>DB: INSERT auth_code (expires 1 hour)
        App->>Email: Send reset link
        Email-->>User: Email with reset link
    else Member not found
        Note over App: Return success anyway (prevent email enumeration)
        App-->>User: "Check your email"
    end

    User->>App: Click reset link (GET /auth/reset-password?code=...)
    App->>DB: Validate code
    alt Code valid and not expired
        App-->>User: Show password reset form
        User->>App: POST new password
        App->>App: Hash password (scrypt)
        App->>DB: UPDATE password_account
        App->>DB: DELETE auth_code (single-use)
        App->>DB: INSERT session
        App-->>User: Redirect home (logged in)
    else Code invalid/expired
        App-->>User: "Reset link expired"
    end
Loading

Reset Token Security

Tokens are:

  • Cryptographically random (24 bytes = 192 bits)
  • Time-limited (1 hour expiration)
  • Single-use (deleted after use)
  • Tied to specific member
  • URL-safe (base64url encoding)

Storage:

CREATE TABLE auth_codes (
  code TEXT PRIMARY KEY,            -- Random base64url string
  member_id INTEGER NOT NULL,       -- FK to members
  purpose TEXT NOT NULL,            -- 'password_reset' or 'email_verification'
  expires_on DATETIME NOT NULL      -- 1 hour from creation
);

Email Enumeration Prevention

Don't reveal if email exists:

// ✅ Good - Always show success
const member = await getMemberByEmail(email);

if (!member) {
  // Still show success to prevent enumeration
  errors.set('success', 'Check your email for a reset link.');
  return renderDialog();
}

// Send actual reset email
await sendResetEmail(member.contact_email, code);
errors.set('success', 'Check your email for a reset link.');

❌ Bad - Reveals if email exists:

const member = await getMemberByEmail(email);

if (!member) {
  return res.send('Email not found');  // Leaks information!
}

Security Considerations

Password Security

✅ Secure practices:

  • scrypt hashing (memory-hard, GPU-resistant)
  • Unique salt per password
  • Cost parameter stored (allows future increases)
  • Minimum 8 character requirement
  • Password reset tokens expire quickly

🔒 Additional hardening (future):

  • Password strength meter
  • Pwned password check (HaveIBeenPwned API)
  • Rate limiting on login attempts
  • Account lockout after failed attempts

Session Security

✅ Secure practices:

  • Cryptographically random session IDs (64 bytes)
  • HttpOnly cookies (prevent XSS)
  • Secure flag in production (HTTPS only)
  • SameSite=lax (CSRF protection)
  • Database-backed (can revoke server-side)
  • Expiration checked on every request

🔒 Additional hardening (future):

  • Sliding expiration (extend on activity)
  • "Remember me" separate from normal sessions
  • IP binding (detect session hijacking)
  • Concurrent session limits

OAuth Security

✅ Secure practices:

  • Authorization code flow (not implicit)
  • State parameter (CSRF protection for OAuth)
  • HTTPS-only redirect URIs in production
  • Token exchange server-side (never expose client secret)

OAuth scopes requested:

  • openid - Basic authentication
  • profile - Name and picture
  • email - Email address

No excessive permissions requested.

Multi-Account Linking

Current behavior:

CREATE TABLE google_account (
  google_sub TEXT PRIMARY KEY,      -- Google user ID (unique)
  google_email TEXT,                -- Google email
  member_id INTEGER UNIQUE          -- One Google account per member
);

Rules:

  • One Google account can link to one member account
  • One member account can have one Google login
  • Member can have BOTH password AND Google login
  • Unlinking Google account doesn't delete member (password auth still works)

Authorization Patterns

Middleware Pattern

Reusable auth check:

// src/middleware/auth.ts
export function requireAuth(req: MulmRequest, res: Response, next: NextFunction) {
  if (!req.viewer) {
    return res.redirect('/auth/signin');
  }
  next();
}

export function requireAdmin(req: MulmRequest, res: Response, next: NextFunction) {
  if (!req.viewer) {
    return res.status(401).send('Unauthorized');
  }

  if (!req.viewer.is_admin) {
    return res.status(403).send('Forbidden');
  }

  next();
}

// Usage:
router.get('/account', requireAuth, accountSettings);
router.use('/admin/*', requireAdmin);

Route-Level Checks

Inline authorization:

export const viewSubmission = async (req: MulmRequest, res: Response) => {
  const submission = await getSubmissionById(req.params.id);

  // Public can view approved submissions
  if (submission.approved_on) {
    return res.render('submission/review', { submission });
  }

  // Only owner or admin can view unapproved
  const canView = req.viewer
    && (req.viewer.id === submission.member_id || req.viewer.is_admin);

  if (!canView) {
    return res.status(403).send('Forbidden');
  }

  res.render('submission/review', { submission });
};

Template-Level Checks

Conditional rendering:

//- Show different UI based on viewer
if viewer
  if viewer.is_admin
    //- Admin controls
    a(href="/admin/queue") Admin Queue
  else
    //- Member controls
    a(href="/submissions/new") Submit Breeding

  //- Both see
  a(href="/me") My Profile
  a(href="/auth/logout") Logout
else
  //- Not logged in
  a(href="/auth/signin") Sign In

Related Documentation


**Last Updated: November 2025

⚠️ **GitHub.com Fallback** ⚠️