ADR LMS 004: User Authentication & Identity Management
ADR-LMS-004: User Authentication & Identity Management
Status: Proposed Date: 2025-12-11 Phase: Phase 2 - Core LMS Infrastructure Deciders: Hal Casteel (Founder/CEO/CTO), CODITECT Core Team Technical Story: Enable secure user authentication, identity management, and single sign-on for the CODITECT Learning Management System
Context and Problem Statement
The current CODITECT LMS (Phase 1) uses a simple user_id TEXT field for tracking progress, with no actual authentication or identity verification:
- No User Login - Users are identified by arbitrary text strings
- No Password Protection - Anyone can claim any user_id
- No Identity Verification - Certificates have no verified identity
- No SSO Support - Enterprise users cannot use corporate credentials
- No Session Management - No concept of logged-in sessions
- No Multi-Device Sync - Progress tied to local user_id strings
The Problem: How do we implement secure user authentication while maintaining the CLI-first philosophy and supporting both individual users and enterprise teams?
Decision Drivers
Technical Requirements
- R1: Secure password hashing (bcrypt/argon2)
- R2: JWT-based session tokens for CLI authentication
- R3: OAuth 2.0 / OpenID Connect support for SSO
- R4: API key authentication for automation
- R5: Role-based access control (RBAC)
- R6: Multi-factor authentication (MFA) option
- R7: Session management with revocation
User Experience Goals
- UX1: CLI-friendly authentication (
/login,/logout) - UX2: Remember me / persistent sessions
- UX3: Seamless SSO for enterprise users
- UX4: API keys for CI/CD integration
Security Requirements
- S1: OWASP authentication best practices
- S2: Secure token storage (keyring integration)
- S3: Rate limiting on auth endpoints
- S4: Audit logging of auth events
Enterprise Requirements
- E1: SAML 2.0 support for enterprise SSO
- E2: LDAP/Active Directory integration
- E3: Organization-level user management
- E4: Compliance with SOC2/GDPR requirements
Decision Outcome
Chosen Solution: Hybrid authentication system with local SQLite users, JWT sessions, and pluggable OAuth/SAML providers for enterprise SSO.
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ Authentication Layer │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Local │ │ OAuth │ │ SAML │ │
│ │ Users │ │ 2.0/OIDC │ │ 2.0 │ │
│ │ (SQLite) │ │ (Google, │ │ (Enterprise │ │
│ │ │ │ GitHub, │ │ IdPs) │ │
│ │ │ │ Okta) │ │ │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └───────────────────┼──────────────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ Auth Service │ │
│ │ (FastAPI) │ │
│ └────────┬────────┘ │
│ │ │
│ ┌───────────────────┼───────────────────┐ │
│ │ │ │ │
│ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │
│ │ JWT │ │ API Key │ │ Session │ │
│ │ Tokens │ │ Auth │ │ Store │ │
│ │ │ │ │ │ (Redis) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Implementation Details
1. Database Schema
-- User accounts
CREATE TABLE auth_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT UNIQUE NOT NULL, -- UUID v4
email TEXT UNIQUE NOT NULL,
email_verified BOOLEAN DEFAULT 0,
password_hash TEXT, -- NULL for SSO-only users
display_name TEXT,
avatar_url TEXT,
auth_provider TEXT DEFAULT 'local', -- local, google, github, okta, saml
provider_user_id TEXT, -- External provider's user ID
mfa_enabled BOOLEAN DEFAULT 0,
mfa_secret TEXT, -- TOTP secret (encrypted)
failed_login_attempts INTEGER DEFAULT 0,
locked_until TEXT,
last_login_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT 1
);
CREATE INDEX idx_auth_users_email ON auth_users(email);
CREATE INDEX idx_auth_users_provider ON auth_users(auth_provider, provider_user_id);
-- API keys for automation
CREATE TABLE auth_api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key_id TEXT UNIQUE NOT NULL, -- Prefix: cdt_key_
key_hash TEXT NOT NULL, -- SHA-256 hash of actual key
user_id TEXT NOT NULL,
name TEXT NOT NULL,
scopes TEXT, -- JSON array of permissions
last_used_at TEXT,
expires_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES auth_users(user_id) ON DELETE CASCADE
);
CREATE INDEX idx_auth_api_keys_user ON auth_api_keys(user_id);
CREATE INDEX idx_auth_api_keys_key_id ON auth_api_keys(key_id);
-- Active sessions
CREATE TABLE auth_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT UNIQUE NOT NULL, -- UUID v4
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL, -- SHA-256 hash of JWT
device_info TEXT, -- JSON: user_agent, ip, device_type
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT NOT NULL,
last_active_at TEXT DEFAULT CURRENT_TIMESTAMP,
is_revoked BOOLEAN DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES auth_users(user_id) ON DELETE CASCADE
);
CREATE INDEX idx_auth_sessions_user ON auth_sessions(user_id);
CREATE INDEX idx_auth_sessions_expires ON auth_sessions(expires_at);
-- OAuth provider configurations
CREATE TABLE auth_oauth_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_key TEXT UNIQUE NOT NULL, -- google, github, okta, azure
provider_name TEXT NOT NULL,
client_id TEXT NOT NULL,
client_secret_encrypted TEXT NOT NULL,
authorization_url TEXT NOT NULL,
token_url TEXT NOT NULL,
userinfo_url TEXT NOT NULL,
scopes TEXT NOT NULL, -- JSON array
org_id INTEGER, -- NULL = platform-wide
is_active BOOLEAN DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (org_id) REFERENCES learning_organizations(id) ON DELETE CASCADE
);
-- Roles and permissions
CREATE TABLE auth_roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role_key TEXT UNIQUE NOT NULL, -- admin, instructor, learner, api_only
role_name TEXT NOT NULL,
description TEXT,
permissions TEXT NOT NULL, -- JSON array of permission strings
is_system_role BOOLEAN DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
-- User role assignments
CREATE TABLE auth_user_roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
role_id INTEGER NOT NULL,
org_id INTEGER, -- NULL = platform role
granted_by TEXT,
granted_at TEXT DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT,
FOREIGN KEY (user_id) REFERENCES auth_users(user_id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES auth_roles(id) ON DELETE CASCADE,
FOREIGN KEY (org_id) REFERENCES learning_organizations(id) ON DELETE CASCADE,
UNIQUE(user_id, role_id, org_id)
);
-- Audit log for authentication events
CREATE TABLE auth_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL, -- login, logout, password_change, mfa_enable, etc.
user_id TEXT,
ip_address TEXT,
user_agent TEXT,
success BOOLEAN,
failure_reason TEXT,
metadata TEXT, -- JSON additional data
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_auth_audit_user ON auth_audit_log(user_id, created_at);
CREATE INDEX idx_auth_audit_type ON auth_audit_log(event_type, created_at);
2. Authentication Flow
Local Authentication
from passlib.hash import argon2
import jwt
from datetime import datetime, timedelta
def register_user(email: str, password: str, display_name: str) -> dict:
"""
Register new local user with secure password hashing.
"""
# Validate email format
if not validate_email(email):
raise ValueError("Invalid email format")
# Check email not already registered
if get_user_by_email(email):
raise ValueError("Email already registered")
# Hash password with Argon2id
password_hash = argon2.hash(password)
# Generate unique user_id
user_id = str(uuid.uuid4())
# Insert user
db.execute("""
INSERT INTO auth_users (user_id, email, password_hash, display_name)
VALUES (?, ?, ?, ?)
""", (user_id, email, password_hash, display_name))
# Assign default role
learner_role = db.execute("SELECT id FROM auth_roles WHERE role_key = 'learner'").fetchone()
db.execute("""
INSERT INTO auth_user_roles (user_id, role_id)
VALUES (?, ?)
""", (user_id, learner_role['id']))
# Log registration
log_auth_event('register', user_id, success=True)
return {'user_id': user_id, 'email': email}
def login(email: str, password: str) -> dict:
"""
Authenticate user and return JWT token.
"""
user = get_user_by_email(email)
if not user:
log_auth_event('login', None, success=False, reason='user_not_found')
raise ValueError("Invalid credentials")
# Check if account is locked
if user['locked_until'] and datetime.fromisoformat(user['locked_until']) > datetime.now():
log_auth_event('login', user['user_id'], success=False, reason='account_locked')
raise ValueError("Account temporarily locked")
# Verify password
if not argon2.verify(password, user['password_hash']):
# Increment failed attempts
failed_attempts = user['failed_login_attempts'] + 1
lock_until = None
if failed_attempts >= 5:
lock_until = (datetime.now() + timedelta(minutes=15)).isoformat()
db.execute("""
UPDATE auth_users
SET failed_login_attempts = ?, locked_until = ?
WHERE user_id = ?
""", (failed_attempts, lock_until, user['user_id']))
log_auth_event('login', user['user_id'], success=False, reason='invalid_password')
raise ValueError("Invalid credentials")
# Reset failed attempts on successful login
db.execute("""
UPDATE auth_users
SET failed_login_attempts = 0, locked_until = NULL, last_login_at = ?
WHERE user_id = ?
""", (datetime.now().isoformat(), user['user_id']))
# Check MFA if enabled
if user['mfa_enabled']:
return {'requires_mfa': True, 'user_id': user['user_id']}
# Generate JWT token
token = generate_jwt_token(user)
# Create session
create_session(user['user_id'], token)
log_auth_event('login', user['user_id'], success=True)
return {
'access_token': token,
'token_type': 'Bearer',
'expires_in': 86400, # 24 hours
'user': {
'user_id': user['user_id'],
'email': user['email'],
'display_name': user['display_name']
}
}
def generate_jwt_token(user: dict, expires_hours: int = 24) -> str:
"""
Generate JWT token with user claims.
"""
payload = {
'sub': user['user_id'],
'email': user['email'],
'roles': get_user_roles(user['user_id']),
'iat': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(hours=expires_hours),
'jti': str(uuid.uuid4()) # JWT ID for revocation
}
return jwt.encode(payload, JWT_SECRET, algorithm='HS256')
def verify_jwt_token(token: str) -> dict:
"""
Verify JWT token and return payload.
"""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
# Check if session is revoked
if is_session_revoked(payload['jti']):
raise ValueError("Session revoked")
return payload
except jwt.ExpiredSignatureError:
raise ValueError("Token expired")
except jwt.InvalidTokenError:
raise ValueError("Invalid token")
OAuth 2.0 Flow
from authlib.integrations.requests_client import OAuth2Session
def initiate_oauth_login(provider: str, redirect_uri: str) -> str:
"""
Initiate OAuth login flow, return authorization URL.
"""
config = get_oauth_provider_config(provider)
oauth = OAuth2Session(
client_id=config['client_id'],
client_secret=decrypt(config['client_secret_encrypted']),
scope=json.loads(config['scopes'])
)
authorization_url, state = oauth.create_authorization_url(
config['authorization_url'],
redirect_uri=redirect_uri
)
# Store state for CSRF protection
store_oauth_state(state, provider, redirect_uri)
return authorization_url
def complete_oauth_login(provider: str, code: str, state: str) -> dict:
"""
Complete OAuth flow, create or link user account.
"""
# Verify state for CSRF protection
stored_state = get_oauth_state(state)
if not stored_state or stored_state['provider'] != provider:
raise ValueError("Invalid OAuth state")
config = get_oauth_provider_config(provider)
oauth = OAuth2Session(
client_id=config['client_id'],
client_secret=decrypt(config['client_secret_encrypted'])
)
# Exchange code for token
token = oauth.fetch_token(
config['token_url'],
code=code,
redirect_uri=stored_state['redirect_uri']
)
# Get user info from provider
user_info = oauth.get(config['userinfo_url']).json()
# Find or create user
user = db.execute("""
SELECT * FROM auth_users
WHERE auth_provider = ? AND provider_user_id = ?
""", (provider, user_info['sub'])).fetchone()
if not user:
# Check if email exists (link account)
user = get_user_by_email(user_info['email'])
if user:
# Link existing account to OAuth provider
db.execute("""
UPDATE auth_users
SET auth_provider = ?, provider_user_id = ?, email_verified = 1
WHERE user_id = ?
""", (provider, user_info['sub'], user['user_id']))
else:
# Create new user
user_id = str(uuid.uuid4())
db.execute("""
INSERT INTO auth_users (
user_id, email, email_verified, display_name,
avatar_url, auth_provider, provider_user_id
)
VALUES (?, ?, 1, ?, ?, ?, ?)
""", (
user_id,
user_info['email'],
user_info.get('name', user_info['email'].split('@')[0]),
user_info.get('picture'),
provider,
user_info['sub']
))
user = get_user(user_id)
# Assign default role
assign_role(user_id, 'learner')
# Generate JWT token
jwt_token = generate_jwt_token(user)
create_session(user['user_id'], jwt_token)
log_auth_event('oauth_login', user['user_id'], success=True,
metadata={'provider': provider})
return {
'access_token': jwt_token,
'token_type': 'Bearer',
'user': {
'user_id': user['user_id'],
'email': user['email'],
'display_name': user['display_name']
}
}
3. CLI Authentication Commands
# Registration
/auth register --email user@example.com --name "John Doe"
# Prompts for password securely
# Login (local)
/auth login --email user@example.com
# Prompts for password
# Login (OAuth)
/auth login --provider google
# Opens browser for OAuth flow, returns to CLI
# Login with API key
/auth login --api-key cdt_key_abc123xyz
# Logout
/auth logout
# Session management
/auth sessions # List active sessions
/auth sessions --revoke SESSION_ID
/auth sessions --revoke-all
# API key management
/auth api-keys # List API keys
/auth api-keys --create --name "CI/CD" --scopes read,write
/auth api-keys --revoke KEY_ID
# MFA management
/auth mfa enable # Setup TOTP MFA
/auth mfa disable
/auth mfa status
# Password management
/auth password change
/auth password reset --email user@example.com
# Profile
/auth profile # View profile
/auth profile --update --name "New Name"
# Current user
/auth whoami
4. Token Storage (CLI)
import keyring
import json
from pathlib import Path
# Store tokens in system keyring
SERVICE_NAME = "coditect-lms"
def store_token(token: str, user_id: str):
"""Store auth token in system keyring."""
keyring.set_password(SERVICE_NAME, "access_token", token)
keyring.set_password(SERVICE_NAME, "user_id", user_id)
def get_stored_token() -> str:
"""Retrieve stored auth token."""
return keyring.get_password(SERVICE_NAME, "access_token")
def clear_stored_token():
"""Clear stored auth token (logout)."""
keyring.delete_password(SERVICE_NAME, "access_token")
keyring.delete_password(SERVICE_NAME, "user_id")
# Fallback for systems without keyring
def store_token_file(token: str, user_id: str):
"""Store token in encrypted file (fallback)."""
config_dir = Path.home() / ".coditect"
config_dir.mkdir(exist_ok=True)
auth_file = config_dir / "auth.json"
auth_data = {
"access_token": encrypt_token(token),
"user_id": user_id,
"stored_at": datetime.now().isoformat()
}
auth_file.write_text(json.dumps(auth_data))
auth_file.chmod(0o600) # Read/write only for owner
5. Default Roles and Permissions
-- Seed default roles
INSERT INTO auth_roles (role_key, role_name, description, permissions, is_system_role) VALUES
('admin', 'Administrator', 'Full system access', '["*"]', 1),
('instructor', 'Instructor', 'Can manage courses and learners',
'["courses:read", "courses:write", "learners:read", "analytics:read", "certificates:issue"]', 1),
('learner', 'Learner', 'Can access learning content',
'["courses:read", "progress:write", "certificates:read"]', 1),
('api_only', 'API User', 'API access only',
'["api:read", "api:write"]', 1),
('org_admin', 'Organization Admin', 'Manage organization users and content',
'["org:manage", "users:manage", "courses:read", "courses:write", "analytics:read"]', 1);
Security Considerations
Password Requirements
- Minimum 12 characters
- Mix of uppercase, lowercase, numbers, symbols
- Not in common password lists (haveibeenpwned check)
- bcrypt or Argon2id hashing
Token Security
- JWT tokens expire in 24 hours (configurable)
- Refresh tokens with 30-day expiry
- Token rotation on refresh
- Session revocation capability
Rate Limiting
- 5 failed login attempts = 15-minute lockout
- 100 requests/minute per IP for auth endpoints
- CAPTCHA after 3 failed attempts
Audit Logging
- All auth events logged
- IP address and user agent recorded
- 90-day retention
- Exportable for compliance
Migration Strategy
Phase 2.1: Local Users
- Create auth_users table
- Migrate existing learning_users.user_id to auth_users
- Add password_hash for existing users (prompt on next access)
- Enable local registration/login
Phase 2.2: OAuth Providers
- Create auth_oauth_providers table
- Configure Google OAuth (default)
- Add GitHub OAuth
- Document custom OAuth setup
Phase 2.3: Enterprise SSO
- Add SAML 2.0 support
- LDAP/AD connector
- Organization-level SSO configuration
- Just-in-time provisioning
Consequences
Positive
- P1: Secure user identity for certificates
- P2: Enterprise SSO support
- P3: API key automation
- P4: Audit trail for compliance
- P5: Multi-device session management
Negative
- N1: Increased complexity vs. simple user_id
- N2: OAuth dependency for external providers
- N3: Token management overhead in CLI
Risks
- Risk 1: Password storage breach
- Mitigation: Argon2id hashing, no plaintext storage
- Risk 2: JWT secret compromise
- Mitigation: Key rotation capability, short token expiry
- Risk 3: OAuth provider outage
- Mitigation: Multiple providers, local fallback
Related Documents
- ADR-030-lms-phase-1.md - Phase 1 LMS foundation
- ADR-031-lms-phase-2.md - Phase 2 overview
- ADR-033-lms-certificates.md - Certificate system
- ADR-004-user-registration-authentication.md - Project Intelligence auth
Status: Proposed - Phase 2 Core Infrastructure Last Updated: 2025-12-11 Version: 1.0.0