Skip to main content

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: false in 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 profile
  • Mail.Send: Send emails (not read)
  • Calendars.ReadWrite: Create events
  • ChannelMessage.Send: Post messages

Troubleshooting

"Token expired" errors

  1. Check tokenExpiresAt in database
  2. Verify refresh token exists
  3. Test token refresh manually

Session not persisting

  1. Check cookie settings
  2. Verify credentials: true in CORS
  3. Check withCredentials: true in Axios

Permission denied from Graph API

  1. Verify scopes in Azure AD
  2. Grant admin consent if required
  3. Re-authenticate to get new token with scopes

Learn More

See the API Reference for endpoint documentation.