From 07c8b54913ee430442dce1efb3b6577653b7c7d4 Mon Sep 17 00:00:00 2001 From: Tom Kaminski Date: Tue, 24 Feb 2026 14:39:49 +0200 Subject: [PATCH 1/6] fix(middleware): update API base URL and cache settings - Changed API base URL from 'https://api.descope.com' to 'https://api.descope.org' for preview environments. - Updated fetch cache option from 'force-cache' to 'no-store' to ensure fresh data retrieval. feat(vercel): add cache control headers - Introduced Cache-Control headers in vercel.json to prevent caching at various levels. --- middleware.ts | 4 ++-- vercel.json | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/middleware.ts b/middleware.ts index 3f2e75692..e1c053371 100644 --- a/middleware.ts +++ b/middleware.ts @@ -8,7 +8,7 @@ const getConfigBaseUrl = (url: URL): string => { // the .well-known endpoint doesn't exist on the Vercel origin. // Fall back to the production API for the configuration check. if (url.hostname.endsWith('.preview.descope.org')) { - return 'https://api.descope.com'; + return 'https://api.descope.org'; } return url.origin; }; @@ -30,7 +30,7 @@ const middleware = async (request: Request) => { const configUrl = `${baseUrl}/.well-known/project-configuration/${projectId}`; const response = await fetch(configUrl, { signal: controller.signal, - cache: 'force-cache' + cache: 'no-store' }); if (response.ok) { const projectConfig = await response.json(); diff --git a/vercel.json b/vercel.json index 38cff75bd..773763192 100644 --- a/vercel.json +++ b/vercel.json @@ -1,4 +1,23 @@ { + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "no-store, no-cache, must-revalidate, max-age=0" + }, + { + "key": "CDN-Cache-Control", + "value": "no-store" + }, + { + "key": "Vercel-CDN-Cache-Control", + "value": "no-store" + } + ] + } + ], "rewrites": [ { "source": "/login/:path*", From b0f9bd07ff9f8f1f7c630cc4196730bd12a7f949 Mon Sep 17 00:00:00 2001 From: Tom Kaminski Date: Tue, 24 Feb 2026 15:03:24 +0200 Subject: [PATCH 2/6] refactor(middleware): enhance static asset handling and cache control - Added regex for static file extensions to bypass middleware for asset requests. - Implemented no-cache headers for static assets and project configuration responses to improve security and data freshness. - Updated the default response headers to include no-cache settings when iframe embedding is allowed. --- middleware.ts | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/middleware.ts b/middleware.ts index e1c053371..8c21cc6a0 100644 --- a/middleware.ts +++ b/middleware.ts @@ -3,10 +3,16 @@ import { projectRegex } from './src/shared/projectRegex'; const FETCH_TIMEOUT_MS = 2000; +const STATIC_EXT = + /\.(?:js|css|map|ico|svg|png|jpg|jpeg|gif|webp|woff2?|ttf|eot)$/; + +const NO_CACHE_HEADERS: Record = { + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'CDN-Cache-Control': 'no-store', + 'Vercel-CDN-Cache-Control': 'no-store' +}; + const getConfigBaseUrl = (url: URL): string => { - // When accessing the Vercel deployment directly (e.g. for testing), - // the .well-known endpoint doesn't exist on the Vercel origin. - // Fall back to the production API for the configuration check. if (url.hostname.endsWith('.preview.descope.org')) { return 'https://api.descope.org'; } @@ -16,12 +22,14 @@ const getConfigBaseUrl = (url: URL): string => { const middleware = async (request: Request) => { const url = new URL(request.url); - // Extract the project ID from the URL path (last segment) + if (STATIC_EXT.test(url.pathname)) { + return next({ headers: NO_CACHE_HEADERS }); + } + const pathSegments = url.pathname.split('/').filter(Boolean); const lastSegment = pathSegments[pathSegments.length - 1] || ''; const projectId = projectRegex.exec(lastSegment)?.[0]; - // If we have a project ID, check if iframe embedding is allowed if (projectId) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); @@ -35,8 +43,7 @@ const middleware = async (request: Request) => { if (response.ok) { const projectConfig = await response.json(); if (projectConfig.allowAuthHostingIframeEmbedding === true) { - // Project explicitly allows iframe embedding — omit X-Frame-Options - return next(); + return next({ headers: NO_CACHE_HEADERS }); } } } catch { @@ -46,20 +53,12 @@ const middleware = async (request: Request) => { } } - // Default: add X-Frame-Options to prevent clickjacking return next({ headers: { + ...NO_CACHE_HEADERS, 'X-Frame-Options': 'SAMEORIGIN' } }); }; export default middleware; - -// Vercel reads this config to decide which routes invoke the middleware. -// Skip static assets so we only run (and fetch project config) on document routes. -export const config = { - matcher: [ - '/((?!.*\\.(?:js|css|map|ico|svg|png|jpg|jpeg|gif|webp|woff2?|ttf|eot)$).*)' - ] -}; From f0696a18e78aefd471536f0a6eb82e4b3b32277b Mon Sep 17 00:00:00 2001 From: Tom Kaminski Date: Tue, 24 Feb 2026 15:08:44 +0200 Subject: [PATCH 3/6] test(middleware): enhance tests for cache control and static asset handling - Updated tests to ensure no-cache headers are applied correctly for static assets. - Corrected API base URL in tests to reflect the change from 'api.descope.com' to 'api.descope.org'. - Improved test coverage for middleware behavior regarding iframe embedding and cache settings. --- src/middleware.test.ts | 62 ++++++++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/src/middleware.test.ts b/src/middleware.test.ts index 2866ed301..8eb6f5d77 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -1,5 +1,5 @@ import { next } from '@vercel/functions'; -import middleware, { config } from '../middleware'; +import middleware from '../middleware'; jest.mock('@vercel/functions', () => ({ next: jest.fn() @@ -19,16 +19,28 @@ afterAll(() => { const fakeRequest = (url: string): Request => ({ url }) as unknown as Request; +const NO_CACHE_HEADERS: Record = { + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'CDN-Cache-Control': 'no-store', + 'Vercel-CDN-Cache-Control': 'no-store' +}; + +const expectNoCacheOnly = () => { + expect(mockedNext).toHaveBeenCalledWith({ + headers: NO_CACHE_HEADERS + }); +}; + const expectXFrameOptions = () => { expect(mockedNext).toHaveBeenCalledWith({ - headers: { 'X-Frame-Options': 'SAMEORIGIN' } + headers: { ...NO_CACHE_HEADERS, 'X-Frame-Options': 'SAMEORIGIN' } }); }; const expectFetchCalledWith = (configUrl: string) => { expect(mockFetch).toHaveBeenCalledWith( configUrl, - expect.objectContaining({ cache: 'force-cache' }) + expect.objectContaining({ cache: 'no-store' }) ); }; @@ -82,7 +94,7 @@ describe('middleware', () => { expectFetchCalledWith( `https://example.com/.well-known/project-configuration/${projectId28}` ); - expect(mockedNext).toHaveBeenCalledWith(); + expectNoCacheOnly(); }); it('omits X-Frame-Options when embedding is allowed (32-char ID)', async () => { @@ -94,7 +106,7 @@ describe('middleware', () => { expectFetchCalledWith( `https://example.com/.well-known/project-configuration/${projectId32}` ); - expect(mockedNext).toHaveBeenCalledWith(); + expectNoCacheOnly(); }); it('adds X-Frame-Options when allowAuthHostingIframeEmbedding is false', async () => { @@ -149,7 +161,7 @@ describe('middleware', () => { describe('config base URL resolution', () => { const projectId = `P${'a'.repeat(27)}`; - it('uses api.descope.com for .preview.descope.org hostnames', async () => { + it('uses api.descope.org for .preview.descope.org hostnames', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ allowAuthHostingIframeEmbedding: true }) @@ -158,11 +170,11 @@ describe('middleware', () => { fakeRequest(`https://123456789.preview.descope.org/login/${projectId}`) ); expectFetchCalledWith( - `https://api.descope.com/.well-known/project-configuration/${projectId}` + `https://api.descope.org/.well-known/project-configuration/${projectId}` ); }); - it('uses api.descope.com for nested .preview.descope.org subdomains', async () => { + it('uses api.descope.org for nested .preview.descope.org subdomains', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ allowAuthHostingIframeEmbedding: true }) @@ -173,7 +185,7 @@ describe('middleware', () => { ) ); expectFetchCalledWith( - `https://api.descope.com/.well-known/project-configuration/${projectId}` + `https://api.descope.org/.well-known/project-configuration/${projectId}` ); }); @@ -191,24 +203,20 @@ describe('middleware', () => { }); }); - describe('matcher config', () => { - it('exports a matcher that excludes static file extensions', () => { - expect(config.matcher).toBeDefined(); - expect(config.matcher.length).toBeGreaterThan(0); - - // eslint-disable-next-line security/detect-non-literal-regexp - const pattern = new RegExp(config.matcher[0]); - // Should match document routes - const projectId = `P${'a'.repeat(27)}`; - expect(pattern.test(`/login/${projectId}`)).toBe(true); - expect(pattern.test('/')).toBe(true); - - // Should not match static assets - expect(pattern.test('/static/main.js')).toBe(false); - expect(pattern.test('/static/style.css')).toBe(false); - expect(pattern.test('/favicon.ico')).toBe(false); - expect(pattern.test('/logo.svg')).toBe(false); - expect(pattern.test('/image.png')).toBe(false); + describe('static assets', () => { + it.each([ + '/static/main.js', + '/static/style.css', + '/favicon.ico', + '/logo.svg', + '/image.png', + '/font.woff2', + '/source.map', + '/photo.jpeg' + ])('returns no-cache headers without fetch for %s', async (path) => { + await middleware(fakeRequest(`https://example.com${path}`)); + expect(mockFetch).not.toHaveBeenCalled(); + expectNoCacheOnly(); }); }); }); From 20a0e7512100808a8deda6c2a4f6c787930e44e9 Mon Sep 17 00:00:00 2001 From: Tom Kaminski Date: Tue, 24 Feb 2026 20:05:43 +0200 Subject: [PATCH 4/6] refactor(middleware): update cache handling and project configuration logic - Removed static asset cache control headers and implemented a matcher to exclude static file requests from middleware processing. - Updated API base URL for project configuration from 'https://api.descope.org' to 'https://api.descope.com'. - Changed fetch cache option to 'force-cache' for project configuration requests. - Enhanced middleware to conditionally omit X-Frame-Options header based on project configuration settings. --- middleware.ts | 35 ++++++++++++------------ src/middleware.test.ts | 62 ++++++++++++++++++------------------------ vercel.json | 19 ------------- 3 files changed, 45 insertions(+), 71 deletions(-) diff --git a/middleware.ts b/middleware.ts index 8c21cc6a0..3f2e75692 100644 --- a/middleware.ts +++ b/middleware.ts @@ -3,18 +3,12 @@ import { projectRegex } from './src/shared/projectRegex'; const FETCH_TIMEOUT_MS = 2000; -const STATIC_EXT = - /\.(?:js|css|map|ico|svg|png|jpg|jpeg|gif|webp|woff2?|ttf|eot)$/; - -const NO_CACHE_HEADERS: Record = { - 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', - 'CDN-Cache-Control': 'no-store', - 'Vercel-CDN-Cache-Control': 'no-store' -}; - const getConfigBaseUrl = (url: URL): string => { + // When accessing the Vercel deployment directly (e.g. for testing), + // the .well-known endpoint doesn't exist on the Vercel origin. + // Fall back to the production API for the configuration check. if (url.hostname.endsWith('.preview.descope.org')) { - return 'https://api.descope.org'; + return 'https://api.descope.com'; } return url.origin; }; @@ -22,14 +16,12 @@ const getConfigBaseUrl = (url: URL): string => { const middleware = async (request: Request) => { const url = new URL(request.url); - if (STATIC_EXT.test(url.pathname)) { - return next({ headers: NO_CACHE_HEADERS }); - } - + // Extract the project ID from the URL path (last segment) const pathSegments = url.pathname.split('/').filter(Boolean); const lastSegment = pathSegments[pathSegments.length - 1] || ''; const projectId = projectRegex.exec(lastSegment)?.[0]; + // If we have a project ID, check if iframe embedding is allowed if (projectId) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); @@ -38,12 +30,13 @@ const middleware = async (request: Request) => { const configUrl = `${baseUrl}/.well-known/project-configuration/${projectId}`; const response = await fetch(configUrl, { signal: controller.signal, - cache: 'no-store' + cache: 'force-cache' }); if (response.ok) { const projectConfig = await response.json(); if (projectConfig.allowAuthHostingIframeEmbedding === true) { - return next({ headers: NO_CACHE_HEADERS }); + // Project explicitly allows iframe embedding — omit X-Frame-Options + return next(); } } } catch { @@ -53,12 +46,20 @@ const middleware = async (request: Request) => { } } + // Default: add X-Frame-Options to prevent clickjacking return next({ headers: { - ...NO_CACHE_HEADERS, 'X-Frame-Options': 'SAMEORIGIN' } }); }; export default middleware; + +// Vercel reads this config to decide which routes invoke the middleware. +// Skip static assets so we only run (and fetch project config) on document routes. +export const config = { + matcher: [ + '/((?!.*\\.(?:js|css|map|ico|svg|png|jpg|jpeg|gif|webp|woff2?|ttf|eot)$).*)' + ] +}; diff --git a/src/middleware.test.ts b/src/middleware.test.ts index 8eb6f5d77..2866ed301 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -1,5 +1,5 @@ import { next } from '@vercel/functions'; -import middleware from '../middleware'; +import middleware, { config } from '../middleware'; jest.mock('@vercel/functions', () => ({ next: jest.fn() @@ -19,28 +19,16 @@ afterAll(() => { const fakeRequest = (url: string): Request => ({ url }) as unknown as Request; -const NO_CACHE_HEADERS: Record = { - 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', - 'CDN-Cache-Control': 'no-store', - 'Vercel-CDN-Cache-Control': 'no-store' -}; - -const expectNoCacheOnly = () => { - expect(mockedNext).toHaveBeenCalledWith({ - headers: NO_CACHE_HEADERS - }); -}; - const expectXFrameOptions = () => { expect(mockedNext).toHaveBeenCalledWith({ - headers: { ...NO_CACHE_HEADERS, 'X-Frame-Options': 'SAMEORIGIN' } + headers: { 'X-Frame-Options': 'SAMEORIGIN' } }); }; const expectFetchCalledWith = (configUrl: string) => { expect(mockFetch).toHaveBeenCalledWith( configUrl, - expect.objectContaining({ cache: 'no-store' }) + expect.objectContaining({ cache: 'force-cache' }) ); }; @@ -94,7 +82,7 @@ describe('middleware', () => { expectFetchCalledWith( `https://example.com/.well-known/project-configuration/${projectId28}` ); - expectNoCacheOnly(); + expect(mockedNext).toHaveBeenCalledWith(); }); it('omits X-Frame-Options when embedding is allowed (32-char ID)', async () => { @@ -106,7 +94,7 @@ describe('middleware', () => { expectFetchCalledWith( `https://example.com/.well-known/project-configuration/${projectId32}` ); - expectNoCacheOnly(); + expect(mockedNext).toHaveBeenCalledWith(); }); it('adds X-Frame-Options when allowAuthHostingIframeEmbedding is false', async () => { @@ -161,7 +149,7 @@ describe('middleware', () => { describe('config base URL resolution', () => { const projectId = `P${'a'.repeat(27)}`; - it('uses api.descope.org for .preview.descope.org hostnames', async () => { + it('uses api.descope.com for .preview.descope.org hostnames', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ allowAuthHostingIframeEmbedding: true }) @@ -170,11 +158,11 @@ describe('middleware', () => { fakeRequest(`https://123456789.preview.descope.org/login/${projectId}`) ); expectFetchCalledWith( - `https://api.descope.org/.well-known/project-configuration/${projectId}` + `https://api.descope.com/.well-known/project-configuration/${projectId}` ); }); - it('uses api.descope.org for nested .preview.descope.org subdomains', async () => { + it('uses api.descope.com for nested .preview.descope.org subdomains', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ allowAuthHostingIframeEmbedding: true }) @@ -185,7 +173,7 @@ describe('middleware', () => { ) ); expectFetchCalledWith( - `https://api.descope.org/.well-known/project-configuration/${projectId}` + `https://api.descope.com/.well-known/project-configuration/${projectId}` ); }); @@ -203,20 +191,24 @@ describe('middleware', () => { }); }); - describe('static assets', () => { - it.each([ - '/static/main.js', - '/static/style.css', - '/favicon.ico', - '/logo.svg', - '/image.png', - '/font.woff2', - '/source.map', - '/photo.jpeg' - ])('returns no-cache headers without fetch for %s', async (path) => { - await middleware(fakeRequest(`https://example.com${path}`)); - expect(mockFetch).not.toHaveBeenCalled(); - expectNoCacheOnly(); + describe('matcher config', () => { + it('exports a matcher that excludes static file extensions', () => { + expect(config.matcher).toBeDefined(); + expect(config.matcher.length).toBeGreaterThan(0); + + // eslint-disable-next-line security/detect-non-literal-regexp + const pattern = new RegExp(config.matcher[0]); + // Should match document routes + const projectId = `P${'a'.repeat(27)}`; + expect(pattern.test(`/login/${projectId}`)).toBe(true); + expect(pattern.test('/')).toBe(true); + + // Should not match static assets + expect(pattern.test('/static/main.js')).toBe(false); + expect(pattern.test('/static/style.css')).toBe(false); + expect(pattern.test('/favicon.ico')).toBe(false); + expect(pattern.test('/logo.svg')).toBe(false); + expect(pattern.test('/image.png')).toBe(false); }); }); }); diff --git a/vercel.json b/vercel.json index 773763192..38cff75bd 100644 --- a/vercel.json +++ b/vercel.json @@ -1,23 +1,4 @@ { - "headers": [ - { - "source": "/(.*)", - "headers": [ - { - "key": "Cache-Control", - "value": "no-store, no-cache, must-revalidate, max-age=0" - }, - { - "key": "CDN-Cache-Control", - "value": "no-store" - }, - { - "key": "Vercel-CDN-Cache-Control", - "value": "no-store" - } - ] - } - ], "rewrites": [ { "source": "/login/:path*", From fcea5913c3ced1a2b59a1a7881f32c2d809a8d5f Mon Sep 17 00:00:00 2001 From: Tom Kaminski Date: Tue, 24 Feb 2026 20:09:06 +0200 Subject: [PATCH 5/6] fix(middleware): correct API base URL and update response headers - Updated API base URL for preview environments from 'https://api.descope.com' to 'https://api.descope.org'. - Modified middleware to include a custom header 'moshe' based on project configuration for iframe embedding. --- middleware.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/middleware.ts b/middleware.ts index 3f2e75692..e1c4072a5 100644 --- a/middleware.ts +++ b/middleware.ts @@ -8,7 +8,7 @@ const getConfigBaseUrl = (url: URL): string => { // the .well-known endpoint doesn't exist on the Vercel origin. // Fall back to the production API for the configuration check. if (url.hostname.endsWith('.preview.descope.org')) { - return 'https://api.descope.com'; + return 'https://api.descope.org'; } return url.origin; }; @@ -36,7 +36,7 @@ const middleware = async (request: Request) => { const projectConfig = await response.json(); if (projectConfig.allowAuthHostingIframeEmbedding === true) { // Project explicitly allows iframe embedding — omit X-Frame-Options - return next(); + return next({ headers: { moshe: 'true' } }); } } } catch { @@ -49,6 +49,7 @@ const middleware = async (request: Request) => { // Default: add X-Frame-Options to prevent clickjacking return next({ headers: { + moshe: 'false', 'X-Frame-Options': 'SAMEORIGIN' } }); From c34ae937c6df797e196a6f945d9211398c13086b Mon Sep 17 00:00:00 2001 From: Tom Kaminski Date: Tue, 24 Feb 2026 20:21:56 +0200 Subject: [PATCH 6/6] fix(middleware): include project configuration in response headers - Updated middleware to include project configuration in the response headers when iframe embedding is allowed, enhancing the context provided to the next middleware function. --- middleware.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/middleware.ts b/middleware.ts index e1c4072a5..11f448e0f 100644 --- a/middleware.ts +++ b/middleware.ts @@ -2,6 +2,7 @@ import { next } from '@vercel/functions'; import { projectRegex } from './src/shared/projectRegex'; const FETCH_TIMEOUT_MS = 2000; +const DESCOPE_MIDDLEWARE_HEADER = 'x-descope-middleware'; const getConfigBaseUrl = (url: URL): string => { // When accessing the Vercel deployment directly (e.g. for testing), @@ -36,7 +37,7 @@ const middleware = async (request: Request) => { const projectConfig = await response.json(); if (projectConfig.allowAuthHostingIframeEmbedding === true) { // Project explicitly allows iframe embedding — omit X-Frame-Options - return next({ headers: { moshe: 'true' } }); + return next({ headers: { [DESCOPE_MIDDLEWARE_HEADER]: 'true' } }); } } } catch { @@ -49,7 +50,7 @@ const middleware = async (request: Request) => { // Default: add X-Frame-Options to prevent clickjacking return next({ headers: { - moshe: 'false', + [DESCOPE_MIDDLEWARE_HEADER]: 'false', 'X-Frame-Options': 'SAMEORIGIN' } });