Azure AD SSO Deployment Guide¶
This guide provides step-by-step instructions for deploying Azure AD Single Sign-On authentication to the Drone Operations Management System.
Table of Contents¶
- Prerequisites
- Azure Portal Setup
- Local Development Setup
- Production Deployment
- Testing the Integration
- Rollout Strategy
- Emergency Rollback
- Troubleshooting
Prerequisites¶
Required:
- Access to Edinburgh Napier University Azure AD tenant
- Global Administrator or Application Administrator role in Azure AD
- MariaDB database with migration applied (see Database Migration section)
- Python 3.12+ with msal==1.26.0 installed
Recommended: - Staging environment for testing before production deployment - Database backup before running migrations
Azure Portal Setup¶
Step 1: Register Application in Azure AD¶
- Navigate to Azure Portal
- Go to https://portal.azure.com
-
Sign in with Global Administrator credentials
-
Create App Registration
- Navigate to: Azure Active Directory > App registrations > New registration
- Fill in the following:
- Name:
Drone Operations Management System(or your preferred name) - Supported account types: Select
Accounts in this organisational directory only (Single tenant) - Redirect URI:
- Platform: Web
- URI (Development):
http://localhost:5000/auth/callback - URI (Production):
https://your-production-domain.com/auth/callback
- Name:
-
Click Register
-
Note the Application Details
- After registration, you'll see the Overview page
- Copy and save the following values (you'll need them later):
- Application (client) ID:
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - Directory (tenant) ID:
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
- Application (client) ID:
Step 2: Generate Client Secret¶
- Create Client Secret
- In your app registration, navigate to: Certificates & secrets > Client secrets > New client secret
- Fill in:
- Description:
Drone Ops Production Secret(or similar) - Expires: Choose expiration period (recommended: 24 months)
- Description:
-
Click Add
-
Copy Secret Value
- IMPORTANT: Copy the Value field immediately
- This value is only shown once and cannot be retrieved later
- Store securely (e.g., in password manager)
Step 3: Configure API Permissions¶
- Add Microsoft Graph Permissions
- Navigate to: API permissions > Add a permission
- Select Microsoft Graph > Delegated permissions
- Search for and select:
User.Read -
Click Add permissions
-
Grant Admin Consent
- Click Grant admin consent for [Your Organisation]
- Confirm by clicking Yes
- Status should show green checkmarks for all permissions
Step 4: Configure Redirect URIs (Production)¶
- Add Production Redirect URI
- Navigate to: Authentication
- Under Platform configurations > Web > Redirect URIs
- Add production URL:
https://drones.napier.ac.uk/auth/callback(replace with actual domain) -
Click Save
-
Configure Token Settings (Optional but recommended)
- Under Authentication > Implicit grant and hybrid flows
- Ensure all boxes are unchecked (we use authorisation code flow only)
Database Migration¶
Before deploying, run the database migration to add Azure AD support fields:
# Development
conda activate drones
flask db upgrade
# Production (Docker)
docker-compose exec web flask db upgrade
Migration adds:
- user.azure_oid (VARCHAR(36), unique, indexed)
- user.auth_method (VARCHAR(20), default='local')
- user.created_date (DATETIME, default=CURRENT_TIMESTAMP)
- Makes user.password nullable
Verification:
# Check migration applied successfully
flask db current
# Should show: a1f2e3d4c5b6 (head)
Local Development Setup¶
Step 1: Install Dependencies¶
# Activate conda environment
conda activate drones
# Install MSAL
pip install msal==1.26.0
# Or reinstall all dependencies
pip install -r requirements.txt
Step 2: Create Azure Configuration¶
-
Copy Example Config
cp instance/config.py.example instance/config.py -
Edit
instance/config.py# Azure AD Configuration AZURE_AD_CLIENT_ID = 'your-client-id-here' # From Azure Portal Step 1 AZURE_AD_CLIENT_SECRET = 'your-client-secret-here' # From Azure Portal Step 2 AZURE_AD_TENANT_ID = 'your-tenant-id-here' # From Azure Portal Step 1 AZURE_AD_SCOPE = ['User.Read'] AZURE_AD_ENABLED = True # Development bypass - ENABLES dev login dropdown DEV_AUTH_BYPASS = True # Default role for new users DEFAULT_ROLE = 'Pilot' -
IMPORTANT: Add to
.gitignoreecho "instance/config.py" >> .gitignore git add .gitignore git commit -m "Ignore instance config with Azure secrets"
Step 3: Test Development Login¶
# Run development server
python run.py
# Visit: http://localhost:5000/login
# You should see redirect to: http://localhost:5000/auth/dev-login
# Select a user from dropdown to login (no Azure required)
Dev Login Features:
- Dropdown shows all users with their roles
- No password required
- Logs show "(dev bypass)" in audit trail
- NEVER enable in production (set DEV_AUTH_BYPASS=False)
Step 4: Test Real Azure AD (Optional)¶
To test actual Azure AD flow in development:
-
Update config:
DEV_AUTH_BYPASS = False # Disable dev bypass -
Ensure redirect URI matches:
- Azure Portal redirect URI:
http://localhost:5000/auth/callback -
Must match exactly (including port)
-
Test login:
python run.py # Visit: http://localhost:5000/login # Should redirect to Microsoft login page
Production Deployment¶
Step 1: Update Production Configuration¶
- Create Production Config
- SSH into production server
- Create
instance/config.pywith production values:
# Azure AD Configuration (PRODUCTION)
AZURE_AD_CLIENT_ID = 'your-production-client-id'
AZURE_AD_CLIENT_SECRET = 'your-production-client-secret'
AZURE_AD_TENANT_ID = 'your-production-tenant-id'
AZURE_AD_SCOPE = ['User.Read']
AZURE_AD_ENABLED = True
# Development bypass - MUST BE FALSE IN PRODUCTION
DEV_AUTH_BYPASS = False # CRITICAL: Never set to True in production
# Default role for new users
DEFAULT_ROLE = 'Pilot'
- Verify Permissions
chmod 600 instance/config.py # Only owner can read/write chown www-data:www-data instance/config.py # Web server owner
Step 2: Deploy Code¶
# Pull latest code
git pull origin master
# Install dependencies (if using Docker, rebuild)
docker-compose build web
# Run database migration
docker-compose exec web flask db upgrade
# Restart services
docker-compose restart web
Step 3: Verify Deployment¶
# Check logs for startup errors
docker-compose logs web
# Check Azure config loaded
docker-compose exec web python -c "from app import app; print('Azure Enabled:', app.config.get('AZURE_AD_ENABLED'))"
# Should print: Azure Enabled: True
# Check dev bypass disabled
docker-compose exec web python -c "from app import app; print('Dev Bypass:', app.config.get('DEV_AUTH_BYPASS'))"
# Should print: Dev Bypass: False
Step 4: Test Production Login¶
- Test New User Provisioning
- Visit:
https://drones.napier.ac.uk/login - Should redirect to:
https://login.microsoftonline.com/... - Login with test user (e.g., your Napier email)
- Should redirect back to dashboard
-
Check user created with Pilot role
-
Verify Audit Logs
# Check audit logs in admin panel # Should see: "New user from Azure AD: test@napier.ac.uk (role: Pilot)" -
Test Existing User Linking
- Login with user that already exists in database (matching email)
- Should link Azure OID to existing account
- Check audit logs: "Linked Azure AD: user@napier.ac.uk"
Testing the Integration¶
Test Checklist¶
Azure AD Login Flow: - [ ] New user can login via Azure AD - [ ] New user auto-provisioned with Pilot role - [ ] Existing user (matching email) auto-linked to Azure - [ ] Linked user auth_method updated to 'both' - [ ] Azure-only user has password=NULL - [ ] Audit logs record azure_provision and azure_link events
Role Management:
- [ ] Admin can view pending users at /admin/users/pending-review
- [ ] Pending users badge shows count in admin sidebar
- [ ] Admin can promote user from Pilot to Responsible Officer
- [ ] Promoted user can access admin panel
Password Management:
- [ ] Azure-only user cannot access /settings/change-password
- [ ] Azure-only user redirected to Azure password portal
- [ ] Local-only user can still change password locally
- [ ] Hybrid user can change local password
Security:
- [ ] DEV_AUTH_BYPASS is False in production
- [ ] /auth/dev-login returns 403 in production
- [ ] CSRF state token validated in callback
- [ ] Logout redirects to Azure logout endpoint
- [ ] Session cleared after logout
Error Handling: - [ ] Invalid state token shows error message - [ ] Azure error (access_denied) shows user-friendly message - [ ] Token exchange failure handled gracefully - [ ] Missing email in token shows error
Rollout Strategy¶
Immediate Cutover (Recommended)¶
Timeline: 1 Day
Day 0 (Preparation): 1. Test in staging environment 2. Backup production database 3. Notify users of upcoming change via email 4. Prepare emergency admin account
Day 1 (Deployment): 1. 8:00 AM: Deploy to production (low traffic time) 2. 8:30 AM: Test with team members 3. 9:00 AM: Monitor audit logs for login activity 4. 12:00 PM: Check for users unable to login (email mismatches) 5. 5:00 PM: Day 1 review meeting
Week 1 (Monitoring):
- Monitor audit logs daily for Azure login success/failures
- Check for users unable to login (email mismatches)
- Admin manually links accounts if needed
- Emergency: Set AZURE_AD_ENABLED=False to rollback
Week 2 (Role Review): - Admin reviews all new Azure-provisioned users - Promote appropriate users to Responsible Officer - Document role assignment decisions
Alternative: Gradual Rollout (Not Recommended)¶
Problem: Flask-Login doesn't easily support dual authentication modes simultaneously.
If required:
1. Keep DEV_AUTH_BYPASS=True in production temporarily
2. Manually provision Azure OIDs for pilot group
3. Once tested, switch to DEV_AUTH_BYPASS=False
Not recommended because: - Dev bypass is insecure in production - More complex testing required - Confusion between auth methods
Emergency Rollback¶
Quick Rollback (5 minutes)¶
If Azure AD fails completely and users cannot login:
Option 1: Disable Azure AD (keeps data)
# SSH to production server
docker-compose exec web bash
# Edit config
nano instance/config.py
# Change: AZURE_AD_ENABLED = False
# Restart
docker-compose restart web
# Users now use legacy /login route (local passwords)
# Azure-linked users can still login with their password
Option 2: Enable Dev Bypass (EMERGENCY ONLY)
# Edit config
nano instance/config.py
# Change: DEV_AUTH_BYPASS = True
# Restart
docker-compose restart web
# Admin can now use /auth/dev-login to impersonate users
# FIX AZURE ISSUES IMMEDIATELY AND DISABLE DEV BYPASS
Full Rollback (30 minutes)¶
To completely remove Azure AD integration:
# 1. Disable Azure
# Edit instance/config.py: AZURE_AD_ENABLED = False
# 2. Rollback database migration
docker-compose exec web flask db downgrade
# 3. Restore old routes_auth.py from git
git checkout HEAD~1 app/routes_auth.py
# 4. Restart
docker-compose restart web
WARNING: Full rollback will: - Remove azure_oid, auth_method, created_date fields - Break users who only have Azure login (no password) - Require password resets for Azure-only users
Troubleshooting¶
Problem: "Authentication failed: invalid state token"¶
Cause: Session lost between login redirect and callback
Solutions: 1. Check Flask session configuration:
# app/__init__.py
app.config['SESSION_TYPE'] = 'filesystem'
app.config['SESSION_PERMANENT'] = True
flask_session/ directory exists and is writable
3. Check browser cookies enabled
4. Check session timeout (default: 5 minutes)
Problem: "Could not retrieve email from Azure AD"¶
Cause: ID token missing email claim
Solutions: 1. Check Azure AD user profile has email set 2. Verify User.Read permission granted 3. Check token claims in Azure Portal (Token configuration) 4. Try alternative claim sources (upn, preferred_username)
Problem: "Default role 'Pilot' not found"¶
Cause: Database not seeded with default roles
Solution:
# Run database seeding
python create_db.py
# Or manually create roles
docker-compose exec web flask shell
>>> from app.models import Role, db
>>> pilot = Role(title='Pilot', description='Default pilot role', power=20)
>>> db.session.add(pilot)
>>> db.session.commit()
Problem: Dev login returns 403 in development¶
Cause: DEV_AUTH_BYPASS=False in development config
Solution:
# instance/config.py (development only)
DEV_AUTH_BYPASS = True
Problem: Azure logout doesn't clear session¶
Cause: Only local session cleared, not Azure session
Solution: - This is expected behaviour - Azure logout URL clears Microsoft SSO session - User must logout of Azure separately if on shared computer
Problem: Username conflicts when provisioning¶
Cause: Username generated from email already exists
Solution:
- System automatically appends random suffix (e.g., user123_a3f9b2)
- No action needed, works automatically
Monitoring and Maintenance¶
Audit Log Monitoring¶
Check regularly:
-- Failed Azure logins
SELECT * FROM audit_log
WHERE action IN ('azure_provision', 'azure_link')
AND timestamp > NOW() - INTERVAL 1 DAY
ORDER BY timestamp DESC;
-- New Azure users
SELECT u.email, u.created_date, u.auth_method, r.title
FROM user u
JOIN role r ON u.role_id = r.id
WHERE u.auth_method IN ('azure_ad', 'both')
AND u.created_date > NOW() - INTERVAL 7 DAY
ORDER BY u.created_date DESC;
Client Secret Expiration¶
Azure client secrets expire (typically 12-24 months)
To renew:
1. Azure Portal > App registrations > Your App > Certificates & secrets
2. Create new client secret
3. Update instance/config.py with new secret
4. Restart web server
5. Delete old secret from Azure
Set calendar reminder 2 months before expiry
Security Best Practices¶
- Never commit secrets to git
- Use
instance/config.py(gitignored) -
Never put secrets in environment files committed to repo
-
Rotate client secrets regularly
- Recommended: Every 12 months
-
Create new secret before deleting old one (zero downtime)
-
Monitor failed login attempts
- Check audit logs for repeated failures
-
May indicate brute force attack or misconfiguration
-
Disable dev bypass in production
DEV_AUTH_BYPASS=Falsein production config-
Verify with:
/auth/dev-loginshould return 403 -
Review new user roles weekly
- Check
/admin/users/pending-reviewregularly -
Promote users to Responsible Officer only when justified
-
Database backups
- Backup before migration
- Backup before role changes
- Keep backups for 30 days minimum
Support Contacts¶
Azure AD Issues: - Edinburgh Napier University IT Support: support@napier.ac.uk - Azure AD Admin: [Contact your Azure administrator]
Application Issues: - Developer: Brian Davison - GitHub Issues: [Your repo]/issues
Appendix: Configuration Reference¶
Full Production Config Template¶
# instance/config.py (PRODUCTION)
# ============================================================
# AZURE AD CONFIGURATION (PRODUCTION)
# ============================================================
# Application Registration Details (from Azure Portal)
AZURE_AD_CLIENT_ID = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
AZURE_AD_CLIENT_SECRET = 'your-client-secret-value-here'
AZURE_AD_TENANT_ID = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
# OAuth Scope (do not change)
AZURE_AD_SCOPE = ['User.Read']
# Enable Azure AD authentication
AZURE_AD_ENABLED = True
# ============================================================
# SECURITY SETTINGS (PRODUCTION)
# ============================================================
# CRITICAL: Must be False in production
# Setting to True allows anyone to login as any user without password
DEV_AUTH_BYPASS = False
# ============================================================
# USER PROVISIONING
# ============================================================
# Default role for new Azure users
# Users are auto-assigned this role on first login
# Admin can promote to 'Responsible Officer' via admin panel
DEFAULT_ROLE = 'Pilot'
Environment Variables (Alternative to instance/config.py)¶
If using environment variables instead:
# .env (production)
AZURE_AD_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
AZURE_AD_CLIENT_SECRET=your-secret-here
AZURE_AD_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
AZURE_AD_ENABLED=True
DEV_AUTH_BYPASS=False
DEFAULT_ROLE=Pilot
Then update app/__init__.py to read from environment instead of config file.
Document Version: 1.0 Last Updated: 2026-02-19 Author: Brian Davison