Files
shopdb-flask/.claude/plans/SAML-FEATURE-PLAN.md
Proudlock a03fd3de4f Fix CI failures: remove unicode and fix lean build
- Replace all unicode (emojis, checkmarks, arrows, em-dashes) with ASCII in .claude/ docs
- Comment out OperatingSystemsList route in core.js - breaks lean builds without computers plugin
- Comment out Operating Systems tab in ModelCatalogHub
- TODO: Plugins should dynamically register their settings routes

Fixes #2 CI check failures on wj branch

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-11 16:42:19 -04:00

45 KiB

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

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

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)

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:

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

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

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())

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

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

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

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

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

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

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

<template>
  <div class="login-page">
    <!-- Existing local login form -->
    <form v-if="localLoginEnabled" @submit.prevent="loginLocal">
      <input v-model="username" type="text" placeholder="Username">
      <input v-model="password" type="password" placeholder="Password">
      <button type="submit">Sign In</button>
    </form>
    
    <!-- SSO button if SAML enabled -->
    <div v-if="samlEnabled" class="sso-section">
      <div class="divider">or</div>
      <button @click="loginSAML" class="sso-button">
        Sign in with Microsoft
      </button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { settingsApi, authApi } from '@/api'

const router = useRouter()

const username = ref('')
const password = ref('')
const samlEnabled = ref(false)
const localLoginEnabled = ref(true)

onMounted(async () => {
  // Fetch public auth settings
  const response = await settingsApi.getPublic()
  samlEnabled.value = response.saml_enabled
  localLoginEnabled.value = response.saml_allow_local_login || !response.saml_enabled
})

async function loginLocal() {
  try {
    const response = await authApi.login({
      provider: 'local',
      username: username.value,
      password: password.value
    })
    
    // Store tokens
    localStorage.setItem('access_token', response.access_token)
    localStorage.setItem('refresh_token', response.refresh_token)
    
    router.push('/')
  } catch (error) {
    // Handle error
  }
}

function loginSAML() {
  // Redirect to SAML initiate endpoint
  window.location.href = '/api/auth/saml/login'
}
</script>

5.2: Add Auth Settings to Public API

File: shopdb/core/api/settings.py

# 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

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

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

# 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

# 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

class MyPlugin(BasePlugin):
    def get_authentication_providers(self):
        return [MyAuthProvider()]

Extending User Provisioning

Hook: on_user_authenticated()

Called after successful authentication.

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.

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.

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

[X] 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

[X] 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

[X] 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.