Skip to main content

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:

  1. No User Login - Users are identified by arbitrary text strings
  2. No Password Protection - Anyone can claim any user_id
  3. No Identity Verification - Certificates have no verified identity
  4. No SSO Support - Enterprise users cannot use corporate credentials
  5. No Session Management - No concept of logged-in sessions
  6. 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

  1. Create auth_users table
  2. Migrate existing learning_users.user_id to auth_users
  3. Add password_hash for existing users (prompt on next access)
  4. Enable local registration/login

Phase 2.2: OAuth Providers

  1. Create auth_oauth_providers table
  2. Configure Google OAuth (default)
  3. Add GitHub OAuth
  4. Document custom OAuth setup

Phase 2.3: Enterprise SSO

  1. Add SAML 2.0 support
  2. LDAP/AD connector
  3. Organization-level SSO configuration
  4. 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


Status: Proposed - Phase 2 Core Infrastructure Last Updated: 2025-12-11 Version: 1.0.0