Skip to content

Commit e313e60

Browse files
committed
feat: add API key authentication middleware
- Add FastAPI middleware checking X-API-Key header on all routes except / and /health - Store API key as Key Vault secret, referenced by Container Apps via managed identity - Add Next.js server-side proxy (/api/backend/) to inject API key for frontend requests - Update frontend api-client to route through proxy instead of direct backend calls - Update MCP page to show auth_required and X-API-Key header in config examples - Configure both backend (API_KEY) and frontend (BACKEND_API_KEY) env vars in Bicep
1 parent ef9a2af commit e313e60

8 files changed

Lines changed: 144 additions & 15 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,4 @@ test-output/
154154

155155
# Mac
156156
.DS_Store
157+
demo/.DS_Store
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
3+
const BACKEND_URL = process.env.BACKEND_URL || process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
4+
const API_KEY = process.env.BACKEND_API_KEY || ''
5+
6+
/**
7+
* Catch-all proxy route that forwards requests to the backend with the API key.
8+
* Browser calls /api/backend/... → this route adds X-API-Key → forwards to backend.
9+
*/
10+
async function proxyRequest(
11+
request: NextRequest,
12+
{ params }: { params: Promise<{ path: string[] }> }
13+
) {
14+
const { path } = await params
15+
const backendPath = '/' + path.join('/')
16+
const searchParams = request.nextUrl.searchParams.toString()
17+
const url = `${BACKEND_URL}${backendPath}${searchParams ? `?${searchParams}` : ''}`
18+
19+
const headers = new Headers()
20+
// Forward relevant headers from the original request
21+
const contentType = request.headers.get('content-type')
22+
if (contentType) {
23+
headers.set('Content-Type', contentType)
24+
}
25+
const accept = request.headers.get('accept')
26+
if (accept) {
27+
headers.set('Accept', accept)
28+
}
29+
30+
// Add API key (server-side only — never exposed to browser)
31+
if (API_KEY) {
32+
headers.set('X-API-Key', API_KEY)
33+
}
34+
35+
const fetchOptions: RequestInit = {
36+
method: request.method,
37+
headers,
38+
}
39+
40+
// Forward body for methods that have one
41+
if (request.method !== 'GET' && request.method !== 'HEAD') {
42+
// Check if it's a form/multipart upload
43+
if (contentType?.includes('multipart/form-data')) {
44+
fetchOptions.body = await request.arrayBuffer()
45+
// Let fetch set the correct content-type with boundary
46+
headers.delete('Content-Type')
47+
headers.set('Content-Type', contentType)
48+
} else {
49+
fetchOptions.body = await request.arrayBuffer()
50+
}
51+
}
52+
53+
try {
54+
const response = await fetch(url, fetchOptions)
55+
56+
// Stream the response back
57+
const responseHeaders = new Headers()
58+
response.headers.forEach((value, key) => {
59+
// Skip headers that Next.js manages
60+
if (!['transfer-encoding', 'connection'].includes(key.toLowerCase())) {
61+
responseHeaders.set(key, value)
62+
}
63+
})
64+
65+
return new NextResponse(response.body, {
66+
status: response.status,
67+
statusText: response.statusText,
68+
headers: responseHeaders,
69+
})
70+
} catch (error) {
71+
console.error('Backend proxy error:', error)
72+
return NextResponse.json(
73+
{ detail: 'Failed to reach backend service' },
74+
{ status: 502 }
75+
)
76+
}
77+
}
78+
79+
export const GET = proxyRequest
80+
export const POST = proxyRequest
81+
export const PUT = proxyRequest
82+
export const PATCH = proxyRequest
83+
export const DELETE = proxyRequest

frontend-next/src/app/mcp/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ interface MCPInfo {
4747
description: string
4848
version: string
4949
transport: string
50+
auth_required?: boolean
5051
endpoints: {
5152
mcp: string
5253
}
@@ -983,6 +984,7 @@ export default function MCPPage() {
983984
mcpServers: {
984985
argus: {
985986
url: mcpInfo?.configuration_example?.mcpServers?.argus?.url || "<backend-url-loading...>",
987+
...(mcpInfo?.auth_required ? { headers: { "X-API-Key": "<your-api-key>" } } : {}),
986988
},
987989
},
988990
},
@@ -1007,6 +1009,7 @@ export default function MCPPage() {
10071009
mcpServers: {
10081010
argus: {
10091011
url: mcpInfo?.configuration_example?.mcpServers?.argus?.url || "<backend-url-loading...>",
1012+
...(mcpInfo?.auth_required ? { headers: { "X-API-Key": "<your-api-key>" } } : {}),
10101013
},
10111014
},
10121015
},

frontend-next/src/lib/api-client.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,11 @@ export interface MCPInfo {
126126
mcpServers: {
127127
argus: {
128128
url: string
129+
headers?: Record<string, string>
129130
}
130131
}
131132
}
133+
auth_required?: boolean
132134
}
133135

134136
// Settings types
@@ -239,7 +241,7 @@ class BackendClient {
239241
private initPromise: Promise<void> | null = null
240242

241243
constructor() {
242-
// Base URL will be fetched lazily from /api/config
244+
// All requests are proxied through Next.js API routes to keep the API key server-side
243245
}
244246

245247
/**
@@ -256,18 +258,8 @@ class BackendClient {
256258
}
257259

258260
this.initPromise = (async () => {
259-
try {
260-
// Fetch backend URL from the Next.js API route
261-
const response = await fetch('/api/config')
262-
if (response.ok) {
263-
const config = await response.json()
264-
this.baseUrl = config.backendUrl || ''
265-
}
266-
} catch (error) {
267-
console.warn('Failed to fetch backend config, using fallback:', error)
268-
// Fallback to environment variable or empty string
269-
this.baseUrl = process.env.NEXT_PUBLIC_API_URL || process.env.NEXT_PUBLIC_BACKEND_URL || ''
270-
}
261+
// Route all requests through the Next.js proxy to keep the API key server-side
262+
this.baseUrl = '/api/backend'
271263
this.initialized = true
272264
})()
273265

infra/main.bicep

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ module containerApps 'modules/container-apps.bicep' = {
177177
aiServicesEndpoint: aiServices.outputs.aiServicesEndpoint
178178
azureOpenaiModelDeploymentName: azureOpenaiModelDeploymentName
179179
keyVaultUri: keyVault.outputs.keyVaultUri
180+
apiKeySecretUri: keyVault.outputs.apiKeySecretUri
180181
containerAppsSubnetId: network.outputs.containerAppsSubnetId
181182
}
182183
}

infra/modules/container-apps.bicep

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ param azureOpenaiModelDeploymentName string
4040
// Key Vault
4141
param keyVaultUri string
4242

43+
// API Key (Key Vault secret URI)
44+
param apiKeySecretUri string
45+
4346
// VNet
4447
param containerAppsSubnetId string
4548

@@ -84,6 +87,13 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = {
8487
maxAge: 3600
8588
}
8689
}
90+
secrets: [
91+
{
92+
name: 'api-key'
93+
keyVaultUrl: apiKeySecretUri
94+
identity: userManagedIdentityId
95+
}
96+
]
8797
registries: [
8898
{
8999
server: containerRegistryLoginServer
@@ -101,6 +111,7 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = {
101111
memory: '2Gi'
102112
}
103113
env: [
114+
{ name: 'API_KEY', secretRef: 'api-key' }
104115
{ name: 'STORAGE_ACCOUNT_NAME', value: storageAccountName }
105116
{ name: 'BLOB_ACCOUNT_URL', value: blobEndpoint }
106117
{ name: 'CONTAINER_NAME', value: containerName }
@@ -165,6 +176,13 @@ resource frontendApp 'Microsoft.App/containerApps@2024-03-01' = {
165176
external: true
166177
targetPort: 3000
167178
}
179+
secrets: [
180+
{
181+
name: 'api-key'
182+
keyVaultUrl: apiKeySecretUri
183+
identity: userManagedIdentityId
184+
}
185+
]
168186
registries: [
169187
{
170188
server: containerRegistryLoginServer
@@ -182,6 +200,7 @@ resource frontendApp 'Microsoft.App/containerApps@2024-03-01' = {
182200
memory: '2Gi'
183201
}
184202
env: [
203+
{ name: 'BACKEND_API_KEY', secretRef: 'api-key' }
185204
{ name: 'BLOB_ACCOUNT_URL', value: blobEndpoint }
186205
{ name: 'CONTAINER_NAME', value: containerName }
187206
{ name: 'COSMOS_URL', value: cosmosEndpoint }

infra/modules/key-vault.bicep

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ resource kvDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@202
6161
}
6262
}
6363

64+
// API key for backend authentication
65+
// Uses a deterministic but hard-to-guess value derived from resource group identity
66+
resource apiKeySecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {
67+
parent: keyVault
68+
name: 'argus-api-key'
69+
properties: {
70+
value: uniqueString(keyVault.id, resourceGroup().id, 'argus-api-key')
71+
}
72+
}
73+
6474
output keyVaultId string = keyVault.id
6575
output keyVaultName string = keyVault.name
6676
output keyVaultUri string = keyVault.properties.vaultUri
77+
output apiKeySecretUri string = apiKeySecret.properties.secretUri

src/containerapp/main.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from fastapi import FastAPI, Request, BackgroundTasks
1010
from fastapi.middleware.cors import CORSMiddleware
11+
from fastapi.responses import JSONResponse
1112
from starlette.types import Receive, Scope, Send
1213

1314
from dependencies import initialize_azure_clients, cleanup_azure_clients
@@ -89,6 +90,22 @@ async def lifespan(_app: FastAPI): # noqa: ARG001
8990
expose_headers=["Mcp-Session-Id"], # Required for MCP Streamable HTTP transport
9091
)
9192

93+
# API Key authentication middleware
94+
API_KEY = os.getenv("API_KEY")
95+
96+
# Paths that don't require authentication
97+
PUBLIC_PATHS = {"/", "/health"}
98+
99+
100+
@app.middleware("http")
101+
async def api_key_auth(request: Request, call_next):
102+
"""Verify API key for all requests except health checks."""
103+
if API_KEY and request.url.path not in PUBLIC_PATHS:
104+
provided_key = request.headers.get("X-API-Key")
105+
if not provided_key or provided_key != API_KEY:
106+
return JSONResponse(status_code=401, content={"detail": "Invalid or missing API key"})
107+
return await call_next(request)
108+
92109

93110
# Health check endpoints
94111
@app.get("/")
@@ -315,10 +332,12 @@ async def mcp_info(request: Request):
315332
"configuration_example": {
316333
"mcpServers": {
317334
"argus": {
318-
"url": mcp_url
335+
"url": mcp_url,
336+
**({"headers": {"X-API-Key": "<your-api-key>"}} if API_KEY else {})
319337
}
320338
}
321-
}
339+
},
340+
"auth_required": bool(API_KEY)
322341
}
323342

324343

0 commit comments

Comments
 (0)