Authentication Authorization - jra3/mulm GitHub Wiki
This guide explains how authentication and authorization work in the Mulm platform.
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
- Authentication Flow
- Password Authentication
- OAuth Authentication
- Session Management
- Authorization
- Password Reset
- Security Considerations
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)
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();
};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
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');
}
};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)
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)
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)
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
// 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
});
}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.viewerexport 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();
};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 |
// 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, ... }
};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
};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
};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
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
);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!
}✅ 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
✅ 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
✅ 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.
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)
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);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 });
};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- Passkey Authentication - WebAuthn/Passkey implementation details
- Security Best Practices - Development security guidelines
- Security Overview - Current security posture
- Database Schema - Auth tables structure
- Development Setup - OAuth configuration
**Last Updated: November 2025