@@ -3,6 +3,7 @@ import type { NextRequest } from "next/server";
33
44const basePath = process . env . BASE_PATH || "" ;
55const SESSION_COOKIE_NAME = "simplens_session" ;
6+ const NS_API_KEY = process . env . NS_API_KEY || "" ;
67
78// Routes that don't require authentication
89const publicRoutes = [
@@ -112,6 +113,61 @@ async function validateSessionFromRequest(request: NextRequest): Promise<{
112113 }
113114}
114115
116+ /**
117+ * Validate an API key from the Authorization: Bearer header.
118+ * Uses timing-safe comparison via HMAC to prevent timing attacks.
119+ */
120+ async function validateApiKeyFromRequest ( request : NextRequest ) : Promise < { isValid : boolean } > {
121+ try {
122+ if ( ! NS_API_KEY ) {
123+ return { isValid : false } ;
124+ }
125+
126+ const authHeader = request . headers . get ( "authorization" ) ;
127+ if ( ! authHeader || ! authHeader . startsWith ( "Bearer " ) ) {
128+ return { isValid : false } ;
129+ }
130+
131+ const providedKey = authHeader . slice ( 7 ) ; // Remove "Bearer " prefix
132+ if ( ! providedKey ) {
133+ return { isValid : false } ;
134+ }
135+
136+ // Timing-safe comparison using HMAC (Edge-compatible via Web Crypto API)
137+ const encoder = new TextEncoder ( ) ;
138+ const key = await crypto . subtle . importKey (
139+ "raw" ,
140+ encoder . encode ( "api-key-compare" ) ,
141+ { name : "HMAC" , hash : "SHA-256" } ,
142+ false ,
143+ [ "sign" ]
144+ ) ;
145+
146+ const [ providedMac , expectedMac ] = await Promise . all ( [
147+ crypto . subtle . sign ( "HMAC" , key , encoder . encode ( providedKey ) ) ,
148+ crypto . subtle . sign ( "HMAC" , key , encoder . encode ( NS_API_KEY ) ) ,
149+ ] ) ;
150+
151+ const providedArr = new Uint8Array ( providedMac ) ;
152+ const expectedArr = new Uint8Array ( expectedMac ) ;
153+
154+ if ( providedArr . length !== expectedArr . length ) {
155+ return { isValid : false } ;
156+ }
157+
158+ let match = true ;
159+ for ( let i = 0 ; i < providedArr . length ; i ++ ) {
160+ if ( providedArr [ i ] !== expectedArr [ i ] ) {
161+ match = false ;
162+ }
163+ }
164+
165+ return { isValid : match } ;
166+ } catch {
167+ return { isValid : false } ;
168+ }
169+ }
170+
115171function isPublicRoute ( pathname : string ) : boolean {
116172 // Remove base path prefix if present
117173 let normalizedPath = pathname ;
@@ -198,7 +254,27 @@ export async function middleware(request: NextRequest) {
198254
199255 // STEP 7: Protected routes - require authentication
200256 if ( ! session . isValid ) {
201- // Redirect to login
257+ // For API routes, fall back to API key (Bearer token) authentication
258+ // This allows programmatic access (e.g. from MCP server) without a session cookie
259+ if ( pathname . startsWith ( "/api/" ) ) {
260+ const apiKeyAuth = await validateApiKeyFromRequest ( request ) ;
261+ if ( apiKeyAuth . isValid ) {
262+ // Valid API key — allow the request through
263+ if ( basePath && request . nextUrl . pathname . startsWith ( basePath ) ) {
264+ const url = request . nextUrl . clone ( ) ;
265+ url . pathname = pathname ;
266+ return NextResponse . rewrite ( url ) ;
267+ }
268+ return NextResponse . next ( ) ;
269+ }
270+ // Invalid or missing API key on an API route — return 401 JSON instead of redirect
271+ return NextResponse . json (
272+ { error : "Unauthorized: valid session or API key required" } ,
273+ { status : 401 }
274+ ) ;
275+ }
276+
277+ // Non-API routes: redirect to login
202278 const loginUrl = new URL ( `${ basePath } /login` , request . url ) ;
203279 loginUrl . searchParams . set ( "callbackUrl" , request . nextUrl . pathname ) ;
204280 return NextResponse . redirect ( loginUrl ) ;
0 commit comments