- 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>
1457 lines
45 KiB
Markdown
1457 lines
45 KiB
Markdown
# 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
|
|
<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`
|
|
|
|
```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
|
|
|
|
[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.
|