Authentication
ConsultFlow uses Azure AD OAuth 2.0 with MSAL (Microsoft Authentication Library).
Authentication Flow
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Frontend │ │ Backend │ │ Azure AD │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ 1. Click Login │ │
│───────────────────>│ │
│ │ │
│ 2. Redirect URL │ │
│<───────────────────│ │
│ │ │
│ 3. Redirect to Azure AD │
│────────────────────────────────────────>│
│ │ │
│ 4. User authenticates │
│ │ │
│ 5. Redirect with auth code │
│<────────────────────────────────────────│
│ │ │
│ 6. Send auth code │ │
│───────────────────>│ │
│ │ │
│ │ 7. Exchange code │
│ │───────────────────>│
│ │ │
│ │ 8. Access + Refresh│
│ │<───────────────────│
│ │ │
│ 9. Set session │ │
│<───────────────────│ │
│ │ │
│ 10. Redirect to dashboard │
│ │ │
Implementation Details
1. Login Initiation
// Backend: GET /auth/login
export const login = async (req: Request, res: Response) => {
const authService = new AuthService()
const authUrl = await authService.getAuthCodeUrl()
res.redirect(authUrl)
}
2. Auth URL Generation
// AuthService.getAuthCodeUrl()
async getAuthCodeUrl(): Promise<string> {
return this.msalClient.getAuthCodeUrl({
scopes: [
'User.Read',
'Mail.Send',
'Calendars.ReadWrite',
'ChannelMessage.Send',
'offline_access', // Required for refresh tokens
],
redirectUri: process.env.AZURE_AD_REDIRECT_URI!,
prompt: 'select_account', // Allow account selection
})
}
3. Callback Handler
// Backend: GET /auth/callback
export const callback = async (req: Request, res: Response) => {
const { code } = req.query
if (!code) {
return res.redirect(`${FRONTEND_URL}/login?error=no_code`)
}
try {
const authService = new AuthService()
const tokenResponse = await authService.acquireTokenByCode(code as string)
// Create or update user
const user = await User.findOneAndUpdate(
{ microsoftId: tokenResponse.account!.homeAccountId },
{
microsoftId: tokenResponse.account!.homeAccountId,
email: tokenResponse.account!.username,
name: tokenResponse.account!.name || 'User',
accessToken: tokenResponse.accessToken,
refreshToken: tokenResponse.refreshToken,
tokenExpiresAt: tokenResponse.expiresOn,
},
{ upsert: true, new: true }
)
// Set session
req.session.userId = user._id.toString()
res.redirect(`${FRONTEND_URL}/dashboard`)
} catch (error) {
console.error('Auth callback error:', error)
res.redirect(`${FRONTEND_URL}/login?error=auth_failed`)
}
}
4. Token Exchange
// AuthService.acquireTokenByCode()
async acquireTokenByCode(code: string) {
return this.msalClient.acquireTokenByCode({
code,
scopes: [
'User.Read',
'Mail.Send',
'Calendars.ReadWrite',
'ChannelMessage.Send',
'offline_access',
],
redirectUri: process.env.AZURE_AD_REDIRECT_URI!,
})
}
Token Management
Access Token Storage
Tokens are stored in the User document (encrypted at application level in production):
{
accessToken: "eyJ0eXAiOiJKV1Qi...",
refreshToken: "0.AVEA...",
tokenExpiresAt: ISODate("2024-12-26T16:00:00.000Z")
}
Token Refresh
// middleware/auth.ts
export const requireAuth = async (req, res, next) => {
const user = await User.findById(req.session.userId)
.select('+accessToken +refreshToken')
// Check if token is expired
if (user.tokenExpiresAt && user.tokenExpiresAt < new Date()) {
if (user.refreshToken) {
try {
const authService = new AuthService()
const newToken = await authService.acquireTokenByRefreshToken(
user.refreshToken
)
user.accessToken = newToken.accessToken
user.refreshToken = newToken.refreshToken || user.refreshToken
user.tokenExpiresAt = newToken.expiresOn
await user.save()
} catch (error) {
return res.status(401).json({ error: 'Token refresh failed' })
}
} else {
return res.status(401).json({ error: 'Token expired' })
}
}
req.user = user
next()
}
Refresh Token Implementation
// AuthService.acquireTokenByRefreshToken()
async acquireTokenByRefreshToken(refreshToken: string) {
return this.msalClient.acquireTokenByRefreshToken({
refreshToken,
scopes: [
'User.Read',
'Mail.Send',
'Calendars.ReadWrite',
'ChannelMessage.Send',
'offline_access',
],
})
}
Session Management
Session Configuration
// src/index.ts
app.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
httpOnly: true, // Prevents XSS
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: 'lax', // CSRF protection
},
}))
Session Storage
By default, sessions are stored in memory. For production, use a session store:
import MongoStore from 'connect-mongo'
app.use(session({
// ...other options
store: MongoStore.create({
mongoUrl: process.env.MONGODB_URI,
ttl: 24 * 60 * 60, // 24 hours
}),
}))
Logout
// Backend: POST /auth/logout
export const logout = async (req: Request, res: Response) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' })
}
res.clearCookie('connect.sid')
res.json({ success: true })
})
}
Security Considerations
1. Token Security
- Access tokens stored with
select: falsein schema - Never exposed to frontend
- Refresh tokens enable long-lived sessions
2. Session Security
- HTTPOnly cookies (no JavaScript access)
- Secure flag in production (HTTPS only)
- SameSite protection against CSRF
3. CORS Configuration
app.use(cors({
origin: process.env.FRONTEND_URL, // Specific origin only
credentials: true, // Allow cookies
}))
4. Permission Scopes
Only request necessary permissions:
User.Read: User profileMail.Send: Send emails (not read)Calendars.ReadWrite: Create eventsChannelMessage.Send: Post messages
Troubleshooting
"Token expired" errors
- Check
tokenExpiresAtin database - Verify refresh token exists
- Test token refresh manually
Session not persisting
- Check cookie settings
- Verify
credentials: truein CORS - Check
withCredentials: truein Axios
Permission denied from Graph API
- Verify scopes in Azure AD
- Grant admin consent if required
- Re-authenticate to get new token with scopes
Learn More
See the API Reference for endpoint documentation.