- 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>
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
- Transfer bundle across air gap
- Deploy to ops environment
- Configure M365 Enterprise App (test tenant)
- Configure SAML settings in ShopDB
- Test with 5 pilot users
- Monitor logs for 1 week
- Verify auto-user-creation, group mapping, token flow
8.3: Production Rollout
- Create production M365 Enterprise App
- Transfer bundle to production
- Deploy during maintenance window
- Configure SAML settings
- Test with admin accounts first
- Gradually enable for all users
- Monitor authentication logs
8.4: Rollback Plan
If SAML breaks:
- Disable SAML in settings UI (admin with local account)
- Users fall back to local login
- No code changes needed - toggle only
If complete failure:
- Roll back to previous Docker image
- Restore database from pre-upgrade backup
- Users continue with local accounts
Summary: Extension Points for Future Auth Methods
What we've built:
-
Authentication Service Layer
- Provider pattern (local, SAML, future: LDAP, OAuth, custom)
- Standard
AuthenticationResultinterface - Provider registration at startup
-
Plugin Hooks for Auth
get_authentication_providers()- Contribute new auth methodson_user_authenticated()- Post-auth callbacksmap_external_groups_to_roles()- Custom group->role logicprovision_user_attributes()- Custom user provisioning
-
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.mddocs/PLUGIN-AUTH-EXTENSION.md
Tests (Phase 6)
tests/test_authentication_service.pytests/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:
- M365 SAML integration (your immediate need)
- Extensible architecture (future auth methods via plugins)
- Follows established ShopDB patterns (hooks, stable API, ADR compliance)
- No breaking changes (local auth continues working)
- 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.