Skip to content

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

  1. Prerequisites
  2. Azure Portal Setup
  3. Local Development Setup
  4. Production Deployment
  5. Testing the Integration
  6. Rollout Strategy
  7. Emergency Rollback
  8. 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

  1. Navigate to Azure Portal
  2. Go to https://portal.azure.com
  3. Sign in with Global Administrator credentials

  4. Create App Registration

  5. Navigate to: Azure Active Directory > App registrations > New registration
  6. 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
  7. Click Register

  8. Note the Application Details

  9. After registration, you'll see the Overview page
  10. 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

Step 2: Generate Client Secret

  1. Create Client Secret
  2. In your app registration, navigate to: Certificates & secrets > Client secrets > New client secret
  3. Fill in:
    • Description: Drone Ops Production Secret (or similar)
    • Expires: Choose expiration period (recommended: 24 months)
  4. Click Add

  5. Copy Secret Value

  6. IMPORTANT: Copy the Value field immediately
  7. This value is only shown once and cannot be retrieved later
  8. Store securely (e.g., in password manager)

Step 3: Configure API Permissions

  1. Add Microsoft Graph Permissions
  2. Navigate to: API permissions > Add a permission
  3. Select Microsoft Graph > Delegated permissions
  4. Search for and select: User.Read
  5. Click Add permissions

  6. Grant Admin Consent

  7. Click Grant admin consent for [Your Organisation]
  8. Confirm by clicking Yes
  9. Status should show green checkmarks for all permissions

Step 4: Configure Redirect URIs (Production)

  1. Add Production Redirect URI
  2. Navigate to: Authentication
  3. Under Platform configurations > Web > Redirect URIs
  4. Add production URL: https://drones.napier.ac.uk/auth/callback (replace with actual domain)
  5. Click Save

  6. Configure Token Settings (Optional but recommended)

  7. Under Authentication > Implicit grant and hybrid flows
  8. 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

  1. Copy Example Config

    cp instance/config.py.example instance/config.py
    

  2. 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'
    

  3. IMPORTANT: Add to .gitignore

    echo "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:

  1. Update config:

    DEV_AUTH_BYPASS = False  # Disable dev bypass
    

  2. Ensure redirect URI matches:

  3. Azure Portal redirect URI: http://localhost:5000/auth/callback
  4. Must match exactly (including port)

  5. Test login:

    python run.py
    # Visit: http://localhost:5000/login
    # Should redirect to Microsoft login page
    


Production Deployment

Step 1: Update Production Configuration

  1. Create Production Config
  2. SSH into production server
  3. Create instance/config.py with 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'
  1. 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

  1. Test New User Provisioning
  2. Visit: https://drones.napier.ac.uk/login
  3. Should redirect to: https://login.microsoftonline.com/...
  4. Login with test user (e.g., your Napier email)
  5. Should redirect back to dashboard
  6. Check user created with Pilot role

  7. Verify Audit Logs

    # Check audit logs in admin panel
    # Should see: "New user from Azure AD: test@napier.ac.uk (role: Pilot)"
    

  8. Test Existing User Linking

  9. Login with user that already exists in database (matching email)
  10. Should link Azure OID to existing account
  11. 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

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

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
2. Verify 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

  1. Never commit secrets to git
  2. Use instance/config.py (gitignored)
  3. Never put secrets in environment files committed to repo

  4. Rotate client secrets regularly

  5. Recommended: Every 12 months
  6. Create new secret before deleting old one (zero downtime)

  7. Monitor failed login attempts

  8. Check audit logs for repeated failures
  9. May indicate brute force attack or misconfiguration

  10. Disable dev bypass in production

  11. DEV_AUTH_BYPASS=False in production config
  12. Verify with: /auth/dev-login should return 403

  13. Review new user roles weekly

  14. Check /admin/users/pending-review regularly
  15. Promote users to Responsible Officer only when justified

  16. Database backups

  17. Backup before migration
  18. Backup before role changes
  19. 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