Skip to content

Commit 32c631f

Browse files
manekinekkoCopilot
andauthored
Require authentication on API routes; upgrade Next.js (#16)
API routes now check the caller in middleware and again in each route handler. AUTH_MODE controls the check: - unset: anonymous access only under `next dev`; production builds return 401 - easyauth: requires the principal header set by Azure Container Apps built-in authentication - anonymous: no check Unknown values are rejected. Upgrade next and eslint-config-next to 15.5.25 and update the config for Next.js 15 (top-level output file tracing options, ES2017 target, async route params). Keep .env*.local and .azure out of the Docker build context. Document how to turn on sign-in for a deployment. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 5bef935 commit 32c631f

14 files changed

Lines changed: 611 additions & 154 deletions

File tree

‎.dockerignore‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@ npm-debug.log
55
README.md
66
.next
77
docker
8-
.git
8+
.git
9+
.env*.local
10+
.azure

‎README.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ This template, the application code and configuration it contains, has been buil
4545
- [Local environment](#local-environment)
4646
- [Deploying](#deploying)
4747
- [Deploying again](#deploying-again)
48+
- [Authentication](#authentication)
49+
- [Turn on sign-in for your deployment](#turn-on-sign-in-for-your-deployment)
4850
- [Running the development server](#running-the-development-server)
4951
- [Using Docker (optional)](#using-docker-optional)
5052
- [Running application at no cost](#running-application-at-no-cost)
@@ -204,6 +206,33 @@ If you've changed the infrastructure files ([`infra`](./infra/) folder or [`azur
204206
azd up
205207
```
206208

209+
## Authentication
210+
211+
Every API route (`/api/chat`, `/api/chat/upload`, `/api/chat/config`, `/api/chat/config/llamacloud`, `/api/files/*`) checks the caller before it reads files, touches the index, or calls Azure OpenAI. The `AUTH_MODE` environment variable controls the check:
212+
213+
| `AUTH_MODE` | Behavior |
214+
| ----------- | -------- |
215+
| _unset_ | `npm run dev` allows anonymous access so local development keeps working. `npm start` and the container image reject every API request with `401`. |
216+
| `easyauth` | Requires the `X-MS-CLIENT-PRINCIPAL-ID` header that [Azure Container Apps built-in authentication](https://learn.microsoft.com/azure/container-apps/authentication) sets for signed-in users. Only set this when built-in authentication is enabled and requires sign-in. Container Apps strips that header from client requests only when built-in authentication is on. |
217+
| `anonymous` | No authentication. Don't use this on a public endpoint. It's only for local testing or when another gateway authenticates every request. |
218+
219+
### Turn on sign-in for your deployment
220+
221+
A fresh `azd up` deploys an app whose API returns `401` until you turn on sign-in:
222+
223+
1. In the Azure portal, open the container app in the `rg-<env-name>` resource group.
224+
2. Go to **Settings > Authentication > Add identity provider**, pick **Microsoft**, and let it create a new app registration. Keep **Require authentication** and **HTTP 302 Found redirect**.
225+
3. Tell the app to trust the signed-in user headers:
226+
227+
```shell
228+
az containerapp list --resource-group rg-<env-name> --query "[].name" -o tsv
229+
az containerapp update --name <container-app-name> --resource-group rg-<env-name> --set-env-vars AUTH_MODE=easyauth
230+
```
231+
232+
`azd provision` and `azd up` reset the container's environment variables, so run step 3 again after either one. The authentication settings from step 2 stay in place.
233+
234+
Set `AUTH_MODE` on the container app, not in `.env`. The deployment copies `.env` into the container image, so an `AUTH_MODE` line there applies in Azure whenever the container app doesn't set its own value. To run the container locally, pass `-e AUTH_MODE=anonymous` to `docker run`.
235+
207236
## Running the development server
208237

209238
You can run a development server locally **after** having successfully run the `azd up` (or simply `azd provision`) command. If you haven't yet, follow the [deploying](#deploying) steps above.

‎app/api/auth.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { NextResponse } from "next/server";
2+
3+
/**
4+
* Inbound authentication gate for every API route.
5+
*
6+
* AUTH_MODE controls how callers are authenticated:
7+
* - "easyauth": Azure Container Apps built-in authentication (EasyAuth) is
8+
* enabled in front of the app. Requests must carry the
9+
* X-MS-CLIENT-PRINCIPAL-ID header, which the platform sets for signed-in
10+
* callers and strips from any client-supplied request.
11+
* - "anonymous": no authentication. Only use this for local development or
12+
* when another trusted gateway authenticates every request.
13+
*
14+
* When AUTH_MODE is unset, a development build (`next dev`) allows anonymous
15+
* access for local development, while a production build (`next start`, the
16+
* container image) fails closed and rejects every API request. Unknown values
17+
* also fail closed.
18+
*/
19+
export type AuthMode = "easyauth" | "anonymous" | "unconfigured";
20+
21+
const PRINCIPAL_ID_HEADER = "x-ms-client-principal-id";
22+
23+
// `next build` inlines NODE_ENV, so a production build can't be switched back
24+
// to the anonymous development fallback by setting NODE_ENV at runtime.
25+
const IS_DEV_BUILD = process.env.NODE_ENV === "development";
26+
27+
let warned = false;
28+
29+
export function resolveAuthMode(
30+
env: Record<string, string | undefined> = process.env,
31+
): AuthMode {
32+
const mode = env.AUTH_MODE?.trim().toLowerCase();
33+
if (mode === "easyauth") return "easyauth";
34+
if (mode === "anonymous") return "anonymous";
35+
if (!mode && IS_DEV_BUILD) return "anonymous";
36+
return "unconfigured";
37+
}
38+
39+
function warnOnce(message: string) {
40+
if (!warned) {
41+
warned = true;
42+
console.warn(`[auth] ${message}`);
43+
}
44+
}
45+
46+
/**
47+
* Returns a 401 response when the request is not authenticated, or null when
48+
* the request may proceed.
49+
*/
50+
export function checkAuth(headers: Headers): NextResponse | null {
51+
const mode = resolveAuthMode();
52+
53+
if (mode === "anonymous") {
54+
if (!IS_DEV_BUILD) {
55+
warnOnce(
56+
"AUTH_MODE=anonymous: API routes are reachable without authentication.",
57+
);
58+
}
59+
return null;
60+
}
61+
62+
if (mode === "easyauth" && headers.get(PRINCIPAL_ID_HEADER)) {
63+
return null;
64+
}
65+
66+
if (mode === "unconfigured") {
67+
warnOnce(
68+
"Rejecting API requests because authentication is not configured. " +
69+
"Enable Azure Container Apps authentication and set AUTH_MODE=easyauth.",
70+
);
71+
}
72+
73+
return NextResponse.json(
74+
{ error: "Unauthorized" },
75+
{ status: 401, headers: { "Cache-Control": "no-store" } },
76+
);
77+
}

‎app/api/chat/config/llamacloud/route.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { LLamaCloudFileService } from "llamaindex";
2-
import { NextResponse } from "next/server";
2+
import { NextRequest, NextResponse } from "next/server";
3+
import { checkAuth } from "../../../auth";
34

45
/**
56
* This API is to get config from the backend envs and expose them to the frontend
67
*/
7-
export async function GET() {
8+
export async function GET(request: NextRequest) {
9+
const denied = checkAuth(request.headers);
10+
if (denied) return denied;
11+
812
if (!process.env.LLAMA_CLOUD_API_KEY) {
913
return NextResponse.json(
1014
{

‎app/api/chat/config/route.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
import { NextResponse } from "next/server";
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { checkAuth } from "../../auth";
23

34
/**
45
* This API is to get config from the backend envs and expose them to the frontend
56
*/
6-
export async function GET() {
7+
export async function GET(request: NextRequest) {
8+
const denied = checkAuth(request.headers);
9+
if (denied) return denied;
10+
711
const config = {
812
starterQuestions: process.env.CONVERSATION_STARTERS?.trim().split("\n"),
913
};

‎app/api/chat/route.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { initObservability } from "@/app/observability";
22
import { LlamaIndexAdapter, Message, StreamData } from "ai";
33
import { ChatMessage, Settings } from "llamaindex";
44
import { NextRequest, NextResponse } from "next/server";
5+
import { checkAuth } from "../auth";
56
import { createChatEngine } from "./engine/chat";
67
import { initSettings } from "./engine/settings";
78
import {
@@ -24,6 +25,9 @@ async function setVectorStoreInstance() {
2425
}
2526

2627
export async function POST(request: NextRequest) {
28+
const denied = checkAuth(request.headers);
29+
if (denied) return denied;
30+
2731
// Init Vercel AI StreamData and timeout
2832
const vercelStreamData = new StreamData();
2933

‎app/api/chat/upload/route.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NextRequest, NextResponse } from "next/server";
2+
import { checkAuth } from "../../auth";
23
import { getDataSource } from "../engine";
34
import { initSettings } from "../engine/settings";
45
import { uploadDocument } from "../llamaindex/documents/upload";
@@ -9,6 +10,9 @@ export const runtime = "nodejs";
910
export const dynamic = "force-dynamic";
1011

1112
export async function POST(request: NextRequest) {
13+
const denied = checkAuth(request.headers);
14+
if (denied) return denied;
15+
1216
try {
1317
const {
1418
name,

‎app/api/files/[...slug]/route.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { readFile } from "fs/promises";
22
import { NextRequest, NextResponse } from "next/server";
3+
import { checkAuth } from "../../auth";
34
import path from "path";
45
import { DATA_DIR } from "../../chat/engine/loader";
56

@@ -8,10 +9,13 @@ import { DATA_DIR } from "../../chat/engine/loader";
89
* It receives path slug and response file data like serve static file
910
*/
1011
export async function GET(
11-
_request: NextRequest,
12-
{ params }: { params: { slug: string[] } },
12+
request: NextRequest,
13+
{ params }: { params: Promise<{ slug: string[] }> },
1314
) {
14-
const slug = params.slug;
15+
const denied = checkAuth(request.headers);
16+
if (denied) return denied;
17+
18+
const slug = (await params).slug;
1519

1620
if (!slug) {
1721
return NextResponse.json({ detail: "Missing file slug" }, { status: 400 });
@@ -21,7 +25,7 @@ export async function GET(
2125
return NextResponse.json({ detail: "Invalid file path" }, { status: 400 });
2226
}
2327

24-
const [folder, ...pathTofile] = params.slug; // data, file.pdf
28+
const [folder, ...pathTofile] = slug; // data, file.pdf
2529
const allowedFolders = ["data", "output"];
2630

2731
if (!allowedFolders.includes(folder)) {

‎app/components/header.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import Image from "next/image";
2+
import Link from "next/link";
23

34
export default function Header() {
45
return (
56
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">
67
<div className="fixed bottom-0 left-0 mb-4 flex h-auto w-full items-end justify-center bg-gradient-to-t from-white via-white dark:from-black dark:via-black lg:static lg:w-auto lg:bg-none lg:mb-0">
7-
<a
8+
<Link
89
href="/"
910
className="flex items-center justify-center font-nunito text-lg font-bold gap-2"
1011
>
@@ -17,7 +18,7 @@ export default function Header() {
1718
priority
1819
/>
1920
<span>LlamaIndex & Azure AI Vector Search</span>
20-
</a>
21+
</Link>
2122
</div>
2223
</div>
2324
);

‎middleware.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { checkAuth } from "./app/api/auth";
3+
4+
export function middleware(request: NextRequest) {
5+
return checkAuth(request.headers) ?? NextResponse.next();
6+
}
7+
8+
export const config = {
9+
matcher: "/api/:path*",
10+
// Node.js runtime so AUTH_MODE is read at runtime, not inlined at build time.
11+
runtime: "nodejs",
12+
};

0 commit comments

Comments
 (0)