# SAML/SSO Feature Implementation Plan **Created:** 2026-09-11 **Scope:** Implement M365 SAML authentication with extensible plugin architecture for future auth providers ## Context **Current State:** - Frontend UI complete (AuthenticationSettings.vue) - Settings schema defined (7 SAML settings) - Zero backend implementation - JWT-based local authentication working - Plugin architecture with ~15 hooks for extensibility **Goal:** Implement SAML/SSO authentication following ShopDB plugin patterns, with extension points for future auth providers (LDAP, OAuth, API keys with external validation, etc.) **Architectural Constraints:** - Must follow ADR-001 (stable API surface in shopdb.api) - Must use plugin hook pattern (like get_dashboard_widgets, get_asset_panels) - Must not break existing JWT local authentication - Must work in air-gapped deployment (bundle SAML lib, handle metadata) --- ## Architecture Overview ### Core Principle: Authentication as Service Layer **Core provides:** - Base authentication service (`shopdb/core/services/authentication.py`) - Standard user provisioning interface - JWT token management (existing) - Login route orchestration **Plugins contribute:** - Authentication providers (SAML, LDAP, OAuth, custom) - User attribute mapping logic - Role/permission inference from external groups - Post-login callbacks **Benefits:** - Future auth plugins don't touch core code - Multiple auth methods can coexist - Site-specific auth logic lives in plugins - Testing isolation (mock auth providers) --- ## Phase 1: Core Authentication Service Refactor **Goal:** Extract authentication logic into service layer with provider pattern ### 1.1: Create Authentication Service Base **File:** `shopdb/core/services/authentication.py` ```python from abc import ABC, abstractmethod from typing import Optional, Dict, Any from dataclasses import dataclass @dataclass class AuthenticationResult: """Standard result from any auth provider""" success: bool user_identifier: str # email, username, or external ID attributes: Dict[str, Any] # name, email, groups, etc. provider: str # 'local', 'saml', 'ldap', etc. error_message: Optional[str] = None class AuthenticationProvider(ABC): """Base class for authentication providers""" @property @abstractmethod def provider_name(self) -> str: """Unique identifier for this provider (e.g., 'saml', 'ldap')""" pass @abstractmethod def authenticate(self, credentials: Dict[str, Any]) -> AuthenticationResult: """Authenticate user with given credentials""" pass @abstractmethod def is_enabled(self) -> bool: """Check if this provider is enabled in settings""" pass def get_login_url(self) -> Optional[str]: """Return URL for initiating login (for redirect-based auth like SAML)""" return None def get_metadata_url(self) -> Optional[str]: """Return URL for provider metadata (for SAML SP metadata)""" return None def provision_user(self, auth_result: AuthenticationResult) -> 'User': """ Create or update user from auth result. Default implementation - providers can override for custom logic. """ from shopdb.core.models import User user = User.query.filter_by(username=auth_result.user_identifier).first() if not user and self._auto_create_enabled(): user = self._create_user_from_attributes(auth_result) elif user: self._update_user_from_attributes(user, auth_result) return user def infer_roles(self, auth_result: AuthenticationResult) -> list: """ Infer roles from auth attributes (e.g., SAML groups -> ShopDB roles). Override in subclass for provider-specific logic. """ return [] @abstractmethod def _auto_create_enabled(self) -> bool: """Check if auto-user-creation is enabled""" pass def _create_user_from_attributes(self, auth_result: AuthenticationResult) -> 'User': """Helper to create user from attributes""" # Default implementation pass def _update_user_from_attributes(self, user: 'User', auth_result: AuthenticationResult): """Helper to update user attributes on each login""" # Sync displayname, email, etc from IdP pass class AuthenticationService: """Orchestrates authentication across multiple providers""" def __init__(self): self.providers = {} # provider_name -> AuthenticationProvider def register_provider(self, provider: AuthenticationProvider): """Register an auth provider""" self.providers[provider.provider_name] = provider def get_enabled_providers(self) -> list: """Return list of enabled providers""" return [p for p in self.providers.values() if p.is_enabled()] def authenticate(self, provider_name: str, credentials: Dict[str, Any]) -> AuthenticationResult: """Authenticate using specific provider""" provider = self.providers.get(provider_name) if not provider: return AuthenticationResult( success=False, user_identifier='', attributes={}, provider=provider_name, error_message=f'Unknown provider: {provider_name}' ) if not provider.is_enabled(): return AuthenticationResult( success=False, user_identifier='', attributes={}, provider=provider_name, error_message=f'Provider disabled: {provider_name}' ) return provider.authenticate(credentials) def provision_user(self, auth_result: AuthenticationResult) -> 'User': """Provision user after successful authentication""" provider = self.providers.get(auth_result.provider) return provider.provision_user(auth_result) # Global service instance auth_service = AuthenticationService() ``` ### 1.2: Refactor Local Authentication **File:** `shopdb/core/services/auth/local.py` ```python from shopdb.core.services.authentication import AuthenticationProvider, AuthenticationResult from shopdb.core.models import User, Setting from werkzeug.security import check_password_hash class LocalAuthenticationProvider(AuthenticationProvider): """Username/password authentication""" @property def provider_name(self) -> str: return 'local' def authenticate(self, credentials: Dict[str, Any]) -> AuthenticationResult: username = credentials.get('username') password = credentials.get('password') user = User.query.filter_by(username=username).first() if not user or not check_password_hash(user.password, password): return AuthenticationResult( success=False, user_identifier=username, attributes={}, provider='local', error_message='Invalid username or password' ) if not user.isactive: return AuthenticationResult( success=False, user_identifier=username, attributes={}, provider='local', error_message='Account disabled' ) # Check account lockout, failed attempts, etc. # (existing logic from auth.py) return AuthenticationResult( success=True, user_identifier=username, attributes={ 'email': user.email, 'displayname': user.displayname, 'roles': [r.name for r in user.roles] }, provider='local' ) def is_enabled(self) -> bool: # Local auth always enabled, or check saml_allow_local_login setting saml_enabled = Setting.get_bool('saml_enabled', False) if saml_enabled: return Setting.get_bool('saml_allow_local_login', True) return True def provision_user(self, auth_result: AuthenticationResult) -> User: # Local users already exist - just return them return User.query.filter_by(username=auth_result.user_identifier).first() def _auto_create_enabled(self) -> bool: return False # Local users must be created manually ``` ### 1.3: Update Login Route to Use Service **File:** `shopdb/core/api/auth.py` (modify existing `/login` route) ```python from shopdb.core.services.authentication import auth_service @auth_bp.route('/login', methods=['POST']) def login(): """Universal login endpoint - delegates to auth service""" data = request.json provider_name = data.get('provider', 'local') # Default to local # Credentials vary by provider # Local: {username, password} # SAML: handled by /saml/acs, doesn't hit this route # Future OAuth: {code, state} auth_result = auth_service.authenticate(provider_name, data) if not auth_result.success: return error_response(ErrorCodes.UNAUTHORIZED, auth_result.error_message) # Provision/update user user = auth_service.provision_user(auth_result) if not user: return error_response(ErrorCodes.UNAUTHORIZED, 'User provisioning failed') # Generate JWT tokens (existing logic) access_token = create_access_token(identity=user.userid) refresh_token = create_refresh_token(identity=user.userid) # Audit log audit_log('auth.login', 'User', user.userid, f'Logged in via {auth_result.provider}') return success_response({ 'access_token': access_token, 'refresh_token': refresh_token, 'user': user.to_dict() }) ``` --- ## Phase 2: SAML Provider Implementation ### 2.1: Add SAML Dependency **File:** `requirements.in` ``` # SAML SSO support python3-saml>=1.16.0 ``` **Run:** ```bash uv pip compile requirements.in --universal --generate-hashes > requirements.txt pwsh refresh-bundle-lock.ps1 # MUST commit bundle-lock.json for security review ``` ### 2.2: SAML Provider **File:** `shopdb/core/services/auth/saml.py` ```python from shopdb.core.services.authentication import AuthenticationProvider, AuthenticationResult from shopdb.core.models import Setting from onelogin.saml2.auth import OneLogin_Saml2_Auth from onelogin.saml2.settings import OneLogin_Saml2_Settings import requests class SAMLAuthenticationProvider(AuthenticationProvider): """SAML 2.0 SSO authentication""" @property def provider_name(self) -> str: return 'saml' def authenticate(self, credentials: Dict[str, Any]) -> AuthenticationResult: """ SAML auth happens via redirect, not password. This method processes the SAML assertion. """ # credentials contains SAML response from POST to /saml/acs saml_response = credentials.get('SAMLResponse') auth = self._init_saml_auth(credentials.get('request')) auth.process_response() errors = auth.get_errors() if errors: return AuthenticationResult( success=False, user_identifier='', attributes={}, provider='saml', error_message=f'SAML authentication failed: {", ".join(errors)}' ) if not auth.is_authenticated(): return AuthenticationResult( success=False, user_identifier='', attributes={}, provider='saml', error_message='SAML assertion not authenticated' ) # Extract attributes from SAML assertion saml_attrs = auth.get_attributes() nameid = auth.get_nameid() # Usually email # M365 attribute mappings email = self._get_saml_attribute(saml_attrs, [ 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', 'email', 'mail' ]) displayname = self._get_saml_attribute(saml_attrs, [ 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name', 'displayName', 'name' ]) groups = self._get_saml_attribute(saml_attrs, [ 'http://schemas.microsoft.com/ws/2008/06/identity/claims/groups', 'groups' ], default=[]) return AuthenticationResult( success=True, user_identifier=email or nameid, attributes={ 'email': email, 'displayname': displayname, 'groups': groups, 'nameid': nameid }, provider='saml' ) def is_enabled(self) -> bool: return Setting.get_bool('saml_enabled', False) def get_login_url(self) -> str: return url_for('saml.initiate_login') def get_metadata_url(self) -> str: return url_for('saml.metadata') def infer_roles(self, auth_result: AuthenticationResult) -> list: """Map SAML groups to ShopDB roles""" groups = auth_result.attributes.get('groups', []) admin_group = Setting.get('saml_admin_group', '') roles = [] if admin_group and admin_group in groups: from shopdb.core.models import Role admin_role = Role.query.filter_by(name='admin').first() if admin_role: roles.append(admin_role) # Allow plugins to contribute additional group->role mappings # (see Phase 3 for plugin extension points) return roles def _auto_create_enabled(self) -> bool: return Setting.get_bool('saml_auto_create_users', True) def _init_saml_auth(self, flask_request) -> OneLogin_Saml2_Auth: """Initialize SAML auth with settings from database""" saml_settings = self._get_saml_settings() # Prepare Flask request dict for python3-saml request_data = { 'https': 'on' if flask_request.is_secure else 'off', 'http_host': flask_request.host, 'server_port': flask_request.environ.get('SERVER_PORT'), 'script_name': flask_request.path, 'get_data': flask_request.args.copy(), 'post_data': flask_request.form.copy() } return OneLogin_Saml2_Auth(request_data, saml_settings) def _get_saml_settings(self) -> dict: """Load SAML configuration from settings table""" entity_id = Setting.get('saml_entity_id') acs_url = Setting.get('saml_acs_url') idp_metadata_url = Setting.get('saml_idp_metadata_url') # Fetch IdP metadata (cache this in production!) idp_metadata = self._fetch_idp_metadata(idp_metadata_url) return { 'strict': True, 'debug': current_app.config.get('DEBUG', False), 'sp': { 'entityId': entity_id, 'assertionConsumerService': { 'url': acs_url, 'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' }, 'singleLogoutService': { 'url': url_for('saml.sls', _external=True), 'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' }, 'NameIDFormat': 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', }, 'idp': idp_metadata, 'security': { 'nameIdEncrypted': False, 'authnRequestsSigned': False, 'logoutRequestSigned': False, 'logoutResponseSigned': False, 'signMetadata': False, 'wantMessagesSigned': False, 'wantAssertionsSigned': True, # M365 signs assertions 'wantNameId': True, 'wantNameIdEncrypted': False, 'wantAssertionsEncrypted': False, 'signatureAlgorithm': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', 'digestAlgorithm': 'http://www.w3.org/2001/04/xmlenc#sha256' } } def _fetch_idp_metadata(self, metadata_url: str) -> dict: """ Fetch and parse IdP metadata. In air-gapped deployments, cache this or allow manual XML upload. """ # TODO: Implement caching (Redis or database) # TODO: Handle air-gap case (upload metadata XML via UI) response = requests.get(metadata_url, timeout=10) response.raise_for_status() # Parse XML metadata into python3-saml format from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser return OneLogin_Saml2_IdPMetadataParser.parse(response.text) def _get_saml_attribute(self, attributes: dict, possible_names: list, default=None): """Helper to extract SAML attribute with fallback names""" for name in possible_names: if name in attributes and attributes[name]: value = attributes[name] # SAML attributes are lists return value[0] if isinstance(value, list) else value return default ``` ### 2.3: SAML Routes Blueprint **File:** `shopdb/core/api/saml.py` ```python from flask import Blueprint, request, redirect, url_for, make_response from shopdb.core.services.authentication import auth_service from shopdb.utils.responses import success_response, error_response from shopdb.utils.audit import audit_log from flask_jwt_extended import create_access_token, create_refresh_token saml_bp = Blueprint('saml', __name__, url_prefix='/api/auth/saml') @saml_bp.route('/login', methods=['GET']) def initiate_login(): """Initiate SP-initiated SAML login""" saml_provider = auth_service.providers.get('saml') if not saml_provider or not saml_provider.is_enabled(): return error_response(ErrorCodes.BAD_REQUEST, 'SAML authentication not enabled') auth = saml_provider._init_saml_auth(request) return redirect(auth.login()) @saml_bp.route('/acs', methods=['POST']) def assertion_consumer_service(): """Handle SAML assertion from IdP""" # Process SAML response auth_result = auth_service.authenticate('saml', { 'SAMLResponse': request.form.get('SAMLResponse'), 'request': request }) if not auth_result.success: # Redirect to login with error return redirect(url_for('auth.login', error=auth_result.error_message)) # Provision user user = auth_service.provision_user(auth_result) if not user: return redirect(url_for('auth.login', error='User provisioning failed')) # Apply role inference saml_provider = auth_service.providers.get('saml') inferred_roles = saml_provider.infer_roles(auth_result) # Sync user roles (additive - don't remove manually-assigned roles) for role in inferred_roles: if role not in user.roles: user.roles.append(role) from shopdb.extensions import db db.session.commit() # Generate JWT tokens access_token = create_access_token(identity=user.userid) refresh_token = create_refresh_token(identity=user.userid) # Audit log audit_log('auth.saml_login', 'User', user.userid, f'Logged in via SAML (NameID: {auth_result.attributes.get("nameid")})') # Redirect to frontend with tokens (or use session) # Option 1: Set HTTP-only cookies # Option 2: Redirect to /auth/callback?token=... response = make_response(redirect('/')) response.set_cookie('access_token', access_token, httponly=True, secure=True) response.set_cookie('refresh_token', refresh_token, httponly=True, secure=True) return response @saml_bp.route('/metadata', methods=['GET']) def metadata(): """SP metadata for IdP configuration""" saml_provider = auth_service.providers.get('saml') if not saml_provider: return error_response(ErrorCodes.BAD_REQUEST, 'SAML not configured') auth = saml_provider._init_saml_auth(request) settings = auth.get_settings() metadata = settings.get_sp_metadata() errors = settings.validate_metadata(metadata) if errors: return error_response(ErrorCodes.INTERNAL_ERROR, f'Invalid SP metadata: {", ".join(errors)}') response = make_response(metadata) response.headers['Content-Type'] = 'text/xml' return response @saml_bp.route('/sls', methods=['GET', 'POST']) def single_logout_service(): """Handle SAML Single Logout (optional - implement later)""" # TODO: Implement SLO if needed return redirect(url_for('auth.logout')) ``` ### 2.4: Register SAML Provider at Startup **File:** `shopdb/__init__.py` (in `create_app()`) ```python def create_app(config_name='development'): # ... existing setup ... # Register authentication providers from shopdb.core.services.authentication import auth_service from shopdb.core.services.auth.local import LocalAuthenticationProvider from shopdb.core.services.auth.saml import SAMLAuthenticationProvider auth_service.register_provider(LocalAuthenticationProvider()) auth_service.register_provider(SAMLAuthenticationProvider()) # Register blueprints from shopdb.core.api.saml import saml_bp app.register_blueprint(saml_bp) # ... rest of setup ... ``` --- ## Phase 3: Plugin Extension Points **Goal:** Allow plugins to extend authentication without modifying core ### 3.1: Add Plugin Hooks to BasePlugin **File:** `shopdb/plugins/base.py` ```python class BasePlugin: # ... existing hooks ... def get_authentication_providers(self) -> list: """ Contribute custom authentication providers. Returns list of AuthenticationProvider instances. Example: def get_authentication_providers(self): from myplugin.auth import LDAPProvider return [LDAPProvider()] """ return [] def on_user_authenticated(self, user: 'User', auth_result: 'AuthenticationResult'): """ Hook called after successful authentication, before JWT issued. Use cases: - Log authentication to plugin-specific table - Sync additional attributes - Trigger workflows - Send notifications Args: user: ShopDB User object (may be newly created) auth_result: AuthenticationResult from provider """ pass def map_external_groups_to_roles(self, groups: list, provider: str) -> list: """ Map external groups to ShopDB roles. Args: groups: List of group names/IDs from auth provider provider: Provider name ('saml', 'ldap', etc.) Returns: List of Role objects to assign to user Example: def map_external_groups_to_roles(self, groups, provider): if provider == 'saml' and 'IT-Team' in groups: return [Role.query.filter_by(name='technician').first()] return [] """ return [] def provision_user_attributes(self, user: 'User', auth_result: 'AuthenticationResult'): """ Customize user provisioning logic. Called during user creation or update after authentication. Plugin can set custom attributes, link to plugin-specific data, etc. Args: user: User being provisioned (may be unsaved if new) auth_result: Authentication result with IdP attributes """ pass ``` ### 3.2: Update AuthenticationService to Collect Plugin Providers **File:** `shopdb/core/services/authentication.py` ```python class AuthenticationService: # ... existing methods ... def discover_plugin_providers(self, plugin_manager): """ Discover and register auth providers from plugins. Called at app startup after plugins loaded. """ for plugin in plugin_manager.get_all().values(): try: providers = plugin.get_authentication_providers() for provider in providers: self.register_provider(provider) current_app.logger.info( f'Registered auth provider {provider.provider_name} ' f'from plugin {plugin.name}' ) except Exception as e: current_app.logger.error( f'Failed to load auth providers from {plugin.name}: {e}' ) def provision_user(self, auth_result: AuthenticationResult) -> 'User': """Enhanced provisioning with plugin hooks""" provider = self.providers.get(auth_result.provider) user = provider.provision_user(auth_result) # Call plugin hooks for custom provisioning from shopdb.extensions import plugin_manager for plugin in plugin_manager.get_all().values(): try: plugin.provision_user_attributes(user, auth_result) except Exception as e: current_app.logger.error( f'Plugin {plugin.name} provision_user_attributes failed: {e}' ) # Map external groups to roles via plugins groups = auth_result.attributes.get('groups', []) for plugin in plugin_manager.get_all().values(): try: roles = plugin.map_external_groups_to_roles( groups, auth_result.provider ) for role in roles: if role and role not in user.roles: user.roles.append(role) except Exception as e: current_app.logger.error( f'Plugin {plugin.name} map_external_groups_to_roles failed: {e}' ) # Trigger post-authentication hooks for plugin in plugin_manager.get_all().values(): try: plugin.on_user_authenticated(user, auth_result) except Exception as e: current_app.logger.error( f'Plugin {plugin.name} on_user_authenticated failed: {e}' ) return user ``` ### 3.3: Update App Initialization **File:** `shopdb/__init__.py` ```python def create_app(config_name='development'): # ... after plugins loaded ... from shopdb.core.services.authentication import auth_service # Register core providers from shopdb.core.services.auth.local import LocalAuthenticationProvider from shopdb.core.services.auth.saml import SAMLAuthenticationProvider auth_service.register_provider(LocalAuthenticationProvider()) auth_service.register_provider(SAMLAuthenticationProvider()) # Discover plugin-contributed providers auth_service.discover_plugin_providers(plugin_manager) # ... rest of setup ... ``` --- ## Phase 4: Example Plugin Auth Extensions **Demonstrate plugin extensibility with concrete examples** ### Example 1: LDAP Plugin **File:** `plugins/ldap/plugin.py` ```python from shopdb.plugins.base import BasePlugin from shopdb.core.services.authentication import AuthenticationProvider, AuthenticationResult class LDAPAuthenticationProvider(AuthenticationProvider): """Active Directory / LDAP authentication""" @property def provider_name(self) -> str: return 'ldap' def authenticate(self, credentials: Dict[str, Any]) -> AuthenticationResult: username = credentials.get('username') password = credentials.get('password') # Connect to LDAP server # Query user, verify password # Extract attributes (email, groups, etc.) return AuthenticationResult( success=True, user_identifier=username, attributes={ 'email': ldap_email, 'displayname': ldap_displayname, 'groups': ldap_groups }, provider='ldap' ) def is_enabled(self) -> bool: from shopdb.core.models import Setting return Setting.get_bool('ldap_enabled', False) # ... rest of implementation ... class LDAPPlugin(BasePlugin): name = 'ldap' version = '1.0.0' description = 'LDAP/Active Directory authentication' def get_authentication_providers(self) -> list: """Contribute LDAP auth provider""" return [LDAPAuthenticationProvider()] def map_external_groups_to_roles(self, groups: list, provider: str) -> list: """Map AD groups to ShopDB roles""" if provider != 'ldap': return [] from shopdb.core.models import Setting, Role roles = [] # Map from settings (ldap_admin_group, ldap_operator_group, etc.) admin_group = Setting.get('ldap_admin_group', '') if admin_group and admin_group in groups: roles.append(Role.query.filter_by(name='admin').first()) return roles ``` ### Example 2: Custom Role Mapping Plugin **File:** `plugins/authrules/plugin.py` ```python from shopdb.plugins.base import BasePlugin class AuthRulesPlugin(BasePlugin): """Site-specific authentication rules""" name = 'authrules' version = '1.0.0' description = 'Custom authentication rules for site-specific requirements' def on_user_authenticated(self, user, auth_result): """Log all authentications for compliance""" from plugins.authrules.models import AuthLog from shopdb.extensions import db log_entry = AuthLog( userid=user.userid, provider=auth_result.provider, source_ip=request.remote_addr, success=auth_result.success, attributes_json=json.dumps(auth_result.attributes) ) db.session.add(log_entry) db.session.commit() def map_external_groups_to_roles(self, groups, provider): """Site-specific group mappings""" # Example: Any SAML user in group 'Shop-Floor-Leads' gets 'operator' role if provider == 'saml' and 'Shop-Floor-Leads' in groups: from shopdb.core.models import Role return [Role.query.filter_by(name='operator').first()] return [] def provision_user_attributes(self, user, auth_result): """Sync custom user attributes""" # Example: Extract employee ID from SAML and link to Employee record if auth_result.provider == 'saml': employee_id = auth_result.attributes.get('employeeID') if employee_id: user.employeeid = employee_id ``` ### Example 3: API Key Plugin with External Validation **File:** `plugins/apikeys/plugin.py` ```python from shopdb.plugins.base import BasePlugin from shopdb.core.services.authentication import AuthenticationProvider, AuthenticationResult import requests class ExternalAPIKeyProvider(AuthenticationProvider): """Validate API keys against external service""" @property def provider_name(self) -> str: return 'external_api_key' def authenticate(self, credentials: Dict[str, Any]) -> AuthenticationResult: api_key = credentials.get('api_key') # Call external validation service validation_url = Setting.get('external_apikey_validation_url') response = requests.post(validation_url, json={'api_key': api_key}, timeout=5) if response.status_code != 200: return AuthenticationResult( success=False, user_identifier='', attributes={}, provider='external_api_key', error_message='API key validation failed' ) data = response.json() return AuthenticationResult( success=True, user_identifier=data['user_id'], attributes=data.get('attributes', {}), provider='external_api_key' ) def is_enabled(self) -> bool: return Setting.get_bool('external_apikey_enabled', False) class APIKeysPlugin(BasePlugin): name = 'apikeys' version = '1.0.0' def get_authentication_providers(self) -> list: return [ExternalAPIKeyProvider()] ``` --- ## Phase 5: Frontend Integration ### 5.1: Update Login Page **File:** `frontend/src/views/Login.vue` ```vue ``` ### 5.2: Add Auth Settings to Public API **File:** `shopdb/core/api/settings.py` ```python # Add to PUBLIC_SETTING_KEYS PUBLIC_SETTING_KEYS = [ # ... existing keys ... 'saml_enabled', 'saml_allow_local_login', # Note: Do NOT expose saml_idp_metadata_url, saml_entity_id, etc. ] ``` --- ## Phase 6: Testing Strategy ### 6.1: Unit Tests **File:** `tests/test_authentication_service.py` ```python def test_local_authentication(): """Test local auth provider""" auth_result = auth_service.authenticate('local', { 'username': 'testuser', 'password': 'password123' }) assert auth_result.success assert auth_result.provider == 'local' def test_saml_attribute_extraction(): """Test SAML attribute parsing""" # Mock SAML response # Verify attribute extraction logic pass def test_plugin_auth_provider_registration(): """Test plugin can contribute auth provider""" # Create mock plugin with get_authentication_providers() # Verify provider gets registered pass def test_role_inference_from_groups(): """Test SAML group -> role mapping""" auth_result = AuthenticationResult( success=True, user_identifier='user@example.com', attributes={'groups': ['ShopDB-Admins']}, provider='saml' ) # Mock saml_admin_group setting Setting.set('saml_admin_group', 'ShopDB-Admins') saml_provider = auth_service.providers['saml'] roles = saml_provider.infer_roles(auth_result) assert len(roles) == 1 assert roles[0].name == 'admin' ``` ### 6.2: Integration Tests **File:** `tests/test_saml_flow.py` ```python def test_saml_login_flow(): """End-to-end SAML login test""" # 1. Request /api/auth/saml/login # 2. Mock IdP response with SAML assertion # 3. POST to /api/auth/saml/acs # 4. Verify user created and JWT returned pass def test_saml_auto_create_user(): """Test user auto-creation from SAML""" # Enable saml_auto_create_users # Authenticate with new user # Verify User record created with correct attributes pass def test_saml_group_to_role_mapping(): """Test group-based role assignment""" # Configure saml_admin_group # Authenticate user with that group in SAML assertion # Verify user has admin role pass ``` ### 6.3: Manual Testing Checklist **ops Environment Testing:** - [ ] Local login still works when SAML disabled - [ ] SAML login redirects to M365 - [ ] SAML ACS processes assertion correctly - [ ] User auto-created with correct email, displayname - [ ] Admin group mapping grants admin role - [ ] Local login disabled when `saml_allow_local_login=false` - [ ] SP metadata endpoint returns valid XML - [ ] M365 Enterprise App accepts SP metadata - [ ] JWT tokens work after SAML login - [ ] Logout works - [ ] Token refresh works --- ## Phase 7: Documentation ### 7.1: Admin Guide **File:** `docs/SAML-SETUP-GUIDE.md` ```markdown # SAML/SSO Setup Guide ## Prerequisites - ShopDB v0.13.0 or higher - M365 Enterprise App admin access - HTTPS enabled on ShopDB (required for SAML) ## Step 1: Create M365 Enterprise Application 1. Go to Azure AD / Entra ID → Enterprise Applications 2. New Application → Create your own 3. Name: "ShopDB Production" 4. Click Assign users and groups ## Step 2: Configure SAML in Azure 1. In Enterprise App, go to Single sign-on → SAML 2. Basic SAML Configuration: - Identifier (Entity ID): `https://shopdb.yourdomain.com` - Reply URL (ACS): `https://shopdb.yourdomain.com/api/auth/saml/acs` 3. Attributes & Claims: - Ensure these claims are mapped: - emailaddress (user.mail) - name (user.displayname) - groups (user.groups) - if using group-based admin assignment 4. Save configuration 5. Copy Federation Metadata URL from Section 3 ## Step 3: Configure ShopDB 1. Login to ShopDB as admin 2. Settings → Authentication 3. Enable SAML SSO toggle 4. Paste Federation Metadata URL from Azure 5. Set Entity ID: `https://shopdb.yourdomain.com` 6. Set ACS URL: `https://shopdb.yourdomain.com/api/auth/saml/acs` 7. (Optional) Set Admin Group Name if using group-based assignment 8. Enable Auto-Create Users if you want users provisioned on first login 9. Save settings ## Step 4: Test 1. Open incognito browser 2. Navigate to ShopDB login page 3. Click "Sign in with Microsoft" 4. Login with M365 credentials 5. Verify redirect back to ShopDB with successful login ## Troubleshooting - Check Flask logs for SAML errors - Verify Federation Metadata URL is accessible from ShopDB server - Ensure Entity ID and ACS URL match exactly between Azure and ShopDB - Test SP metadata: `https://shopdb.yourdomain.com/api/auth/saml/metadata` ``` ### 7.2: Plugin Developer Guide **File:** `docs/PLUGIN-AUTH-EXTENSION.md` ```markdown # Extending Authentication with Plugins ## Overview Plugins can contribute custom authentication providers and extend user provisioning logic. ## Adding a Custom Auth Provider ### 1. Implement AuthenticationProvider ```python from shopdb.core.services.authentication import AuthenticationProvider, AuthenticationResult class MyAuthProvider(AuthenticationProvider): @property def provider_name(self) -> str: return 'myauth' def authenticate(self, credentials): # Your auth logic here return AuthenticationResult(...) def is_enabled(self): from shopdb.core.models import Setting return Setting.get_bool('myauth_enabled', False) ``` ### 2. Register via Plugin Hook ```python class MyPlugin(BasePlugin): def get_authentication_providers(self): return [MyAuthProvider()] ``` ## Extending User Provisioning ### Hook: on_user_authenticated() Called after successful authentication. ```python def on_user_authenticated(self, user, auth_result): # Log, sync attributes, trigger workflows, etc. pass ``` ### Hook: map_external_groups_to_roles() Map IdP groups to ShopDB roles. ```python def map_external_groups_to_roles(self, groups, provider): if 'My-Custom-Group' in groups: return [Role.query.filter_by(name='operator').first()] return [] ``` ### Hook: provision_user_attributes() Customize user creation/update. ```python def provision_user_attributes(self, user, auth_result): # Link to Employee record, set custom fields, etc. pass ``` ``` --- ## Phase 8: Deployment Plan ### 8.1: Bundle Creation ```bash # On connected PC cd flask-shopdb # Add SAML dependency echo "python3-saml>=1.16.0" >> requirements.in uv pip compile requirements.in --universal --generate-hashes > requirements.txt # Update bundle lock pwsh refresh-bundle-lock.ps1 # Commit changes git add requirements.in requirements.txt bundle-lock.json git commit -m "Add SAML authentication support" # Create bundle .\pull-shopdb-bundle.cmd wj ``` ### 8.2: ops Environment Rollout 1. Transfer bundle across air gap 2. Deploy to ops environment 3. Configure M365 Enterprise App (test tenant) 4. Configure SAML settings in ShopDB 5. Test with 5 pilot users 6. Monitor logs for 1 week 7. Verify auto-user-creation, group mapping, token flow ### 8.3: Production Rollout 1. Create production M365 Enterprise App 2. Transfer bundle to production 3. Deploy during maintenance window 4. Configure SAML settings 5. Test with admin accounts first 6. Gradually enable for all users 7. Monitor authentication logs ### 8.4: Rollback Plan If SAML breaks: 1. Disable SAML in settings UI (admin with local account) 2. Users fall back to local login 3. No code changes needed - toggle only If complete failure: 1. Roll back to previous Docker image 2. Restore database from pre-upgrade backup 3. Users continue with local accounts --- ## Summary: Extension Points for Future Auth Methods **What we've built:** 1. **Authentication Service Layer** - Provider pattern (local, SAML, future: LDAP, OAuth, custom) - Standard `AuthenticationResult` interface - Provider registration at startup 2. **Plugin Hooks for Auth** - `get_authentication_providers()` - Contribute new auth methods - `on_user_authenticated()` - Post-auth callbacks - `map_external_groups_to_roles()` - Custom group→role logic - `provision_user_attributes()` - Custom user provisioning 3. **Core SAML Implementation** - M365 integration ready - Metadata handling - Group-based role assignment - Auto-user-creation **Future plugins can add:** - LDAP/Active Directory provider - OAuth2/OIDC providers (Google, GitHub, etc.) - Hardware token authentication - Biometric authentication - Custom API key validation - Site-specific auth rules **No core code changes needed** - plugins just implement the interfaces we've defined. --- ## Files Modified/Created ### Core Files (Phase 1-2) - `shopdb/core/services/authentication.py` (new) - `shopdb/core/services/auth/local.py` (new) - `shopdb/core/services/auth/saml.py` (new) - `shopdb/core/api/auth.py` (modify login route) - `shopdb/core/api/saml.py` (new) - `shopdb/__init__.py` (register providers) ### Plugin System (Phase 3) - `shopdb/plugins/base.py` (add 4 new hooks) ### Frontend (Phase 5) - `frontend/src/views/Login.vue` (add SAML button) - `shopdb/core/api/settings.py` (expose public auth settings) ### Dependencies - `requirements.in` (add python3-saml) - `requirements.txt` (regenerated) - `bundle-lock.json` (refresh) ### Documentation (Phase 7) - `docs/SAML-SETUP-GUIDE.md` - `docs/PLUGIN-AUTH-EXTENSION.md` ### Tests (Phase 6) - `tests/test_authentication_service.py` - `tests/test_saml_flow.py` --- ## Timeline Estimate | Phase | Effort | Dependencies | |-------|--------|--------------| | Phase 1: Core refactor | 1 day | None | | Phase 2: SAML implementation | 2 days | Phase 1 | | Phase 3: Plugin hooks | 0.5 days | Phase 1 | | Phase 4: Example plugins | 0.5 days | Phase 3 (optional, can be concurrent) | | Phase 5: Frontend integration | 0.5 days | Phase 2 | | Phase 6: Testing | 1 day | Phase 2, 5 | | Phase 7: Documentation | 0.5 days | All phases | | Phase 8: Deployment | 0.5 days | All phases | | **Total** | **6-7 days** | Serial implementation | **With parallel work:** - Developer A: Phase 1-2 (core + SAML) - Developer B: Phase 3-4 (plugin hooks + examples) - Timeline: **4-5 days** with 2 developers --- ## Success Criteria ✅ **Functional Requirements:** - [ ] M365 SAML login works end-to-end - [ ] Auto-user-creation provisions users correctly - [ ] Group-based admin assignment works - [ ] Local login still works as fallback - [ ] JWT tokens work after SAML auth - [ ] Plugin can contribute custom auth provider - [ ] Plugin can extend user provisioning ✅ **Non-Functional Requirements:** - [ ] No breaking changes to existing auth - [ ] Follows ShopDB plugin architecture patterns - [ ] Works in air-gapped deployment - [ ] < 500ms auth latency (excluding IdP redirect) - [ ] Comprehensive error handling - [ ] Audit logging for all auth events ✅ **Documentation:** - [ ] Admin setup guide complete - [ ] Plugin extension guide complete - [ ] ADR documenting auth service architecture --- **End of Plan** This plan provides: 1. M365 SAML integration (your immediate need) 2. Extensible architecture (future auth methods via plugins) 3. Follows established ShopDB patterns (hooks, stable API, ADR compliance) 4. No breaking changes (local auth continues working) 5. Clear path for plugins to contribute auth providers **Ready to implement?** Let me know if you want me to start with Phase 1 (core refactor) or if you have questions about any phase.