diff --git a/__tests__/middleware.test.js b/__tests__/middleware.test.js index d53c18d3..68342953 100644 --- a/__tests__/middleware.test.js +++ b/__tests__/middleware.test.js @@ -1,7 +1,7 @@ import { describe, expect, it, beforeAll, afterEach } from '@jest/globals'; import { NextRequest, NextResponse } from 'next/server'; import jwt from 'jsonwebtoken'; -import { middleware } from '@/middleware.ts'; +import middleware from '@/middleware.ts'; // Need to disable eslint for this import because // you need to import the module you're going to mock with Jest // eslint-disable-next-line no-unused-vars @@ -57,6 +57,10 @@ describe('/login', () => { expect(location).toMatch('state=baz'); expect(location).toMatch('response_type=code'); }); + + it('has CSP headers present', () => { + expect(response.headers.get('content-security-policy')).not.toBeNull(); + }); }); describe('auth/login/callback', () => { @@ -290,4 +294,16 @@ describe('/orgs/* when logged in', () => { expect(response.cookies.get('lastViewedOrgId')).toBeUndefined(); }); }); + + describe('withCSP', () => { + it('should modify request headers', async () => { + // setup + const request = new NextRequest(new URL('/', process.env.ROOT_URL)); + + const response = await middleware(request); + + // Assert that the headers were added as expected + expect(response.headers.get('content-security-policy')).not.toBeNull(); + }); + }); }); diff --git a/next.config.js b/next.config.js index c203dfdf..a1b637ce 100644 --- a/next.config.js +++ b/next.config.js @@ -1,36 +1,10 @@ const path = require('path'); -const cspHeader = ` - default-src 'self'; - script-src 'self' 'unsafe-eval' 'unsafe-inline'; - style-src 'self' 'unsafe-inline'; - img-src 'self' blob: data:; - font-src 'self'; - object-src 'none'; - base-uri 'self'; - form-action 'self'; - frame-ancestors 'none'; - upgrade-insecure-requests; -`; - module.exports = { generateBuildId: async () => { // placeholder build id for development return '0.0.1'; }, - async headers() { - return [ - { - source: '/(.*)', - headers: [ - { - key: 'Content-Security-Policy', - value: cspHeader.replace(/\n/g, ''), - }, - ], - }, - ]; - }, sassOptions: { includePaths: [ path.join(__dirname, 'node_modules', '@uswds', 'uswds', 'packages'), diff --git a/src/app/orgs/page.tsx b/src/app/orgs/page.tsx index ee1167be..3227e2a8 100644 --- a/src/app/orgs/page.tsx +++ b/src/app/orgs/page.tsx @@ -1,5 +1,6 @@ 'use server'; +import { headers } from 'next/headers'; import { getOrgsPage } from '@/controllers/controllers'; import { OrganizationsList } from '@/components/OrganizationsList/OrganizationsList'; import { PageHeader } from '@/components/PageHeader'; @@ -7,6 +8,8 @@ import { LastViewedOrgLink } from '@/components/LastViewedOrgLink'; import { Timestamp } from '@/components/Timestamp'; export default async function OrgsPage() { + const headersList = await headers(); + const nonce = headersList.get('x-nonce') || undefined; const { payload } = await getOrgsPage(); return ( @@ -28,6 +31,7 @@ export default async function OrgsPage() { memoryCurrentUsage={payload.memoryCurrentUsage} spaceCounts={payload.spaceCounts} roles={payload.roles} + nonce={nonce} /> ); diff --git a/src/assets/stylesheets/styles.scss b/src/assets/stylesheets/styles.scss index 684bee00..e8ddb05d 100644 --- a/src/assets/stylesheets/styles.scss +++ b/src/assets/stylesheets/styles.scss @@ -108,14 +108,14 @@ 'palette-color-system-green-cool-vivid', 'palette-color-system-red-cool-vivid', 'palette-color-system-red-vivid' - // no trailing comma,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, + // no trailing comma,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, ), $border-color-palettes: ( 'palette-color-system-green-cool', 'palette-color-system-red-vivid', 'palette-color-system-gray-cool' - // no trailing comma,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, + // no trailing comma,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, ), $top-palettes: ( @@ -164,6 +164,9 @@ text-overflow: clip; text-overflow: ellipsis; } +.text-underline-offset { + text-underline-offset: 0.7em; +} /* Custom selector styles */ @@ -429,7 +432,11 @@ $error-color-dark: 'red-40v'; // ProgressBar .progress__bg--infinite { - background: linear-gradient(90deg, color('blue-cool-20v') 78.42%, color('blue-cool-30v') 100%); + background: linear-gradient( + 90deg, + color('blue-cool-20v') 78.42%, + color('blue-cool-30v') 100% + ); } .progress__infinity-logo { diff --git a/src/components/Image.tsx b/src/components/Image.tsx new file mode 100644 index 00000000..d97ae74c --- /dev/null +++ b/src/components/Image.tsx @@ -0,0 +1,13 @@ +import NextImage, { getImageProps } from 'next/image'; +import { ComponentProps } from 'react'; + +export default function Image(props: ComponentProps) { + const { props: nextProps } = getImageProps({ + ...props, + }); + + // eslint-disable-next-line no-unused-vars + const { style: _omit, ...delegated } = nextProps; + + return ; +} diff --git a/src/components/MemoryBar.tsx b/src/components/MemoryBar.tsx index c44605b0..09064779 100644 --- a/src/components/MemoryBar.tsx +++ b/src/components/MemoryBar.tsx @@ -4,9 +4,11 @@ import { formatMb } from '@/helpers/numbers'; export function MemoryBar({ memoryUsed, memoryAllocated, + nonce, }: { memoryUsed?: number | null | undefined; memoryAllocated?: number | null | undefined; + nonce: string | undefined; }) { const memoryUsedNum = memoryUsed || 0; const mbRemaining = (memoryAllocated || 0) - memoryUsedNum; @@ -14,7 +16,7 @@ export function MemoryBar({

Memory:

- +
diff --git a/src/components/NavGlobal/NavGlobal.tsx b/src/components/NavGlobal/NavGlobal.tsx index 130a8e8f..819d8963 100644 --- a/src/components/NavGlobal/NavGlobal.tsx +++ b/src/components/NavGlobal/NavGlobal.tsx @@ -1,4 +1,4 @@ -import Image from 'next/image'; +import Image from '@/components/Image'; import CloudGovLogo from '@/components/svgs/CloudGovLogo'; import cloudPagesIcon from '@/../public/img/logos/cloud-pages-icon.svg'; diff --git a/src/components/OrgPicker/OrgPicker.tsx b/src/components/OrgPicker/OrgPicker.tsx index 46393cc8..22765810 100644 --- a/src/components/OrgPicker/OrgPicker.tsx +++ b/src/components/OrgPicker/OrgPicker.tsx @@ -5,7 +5,7 @@ 'use client'; import React from 'react'; import { useState, useRef, useEffect } from 'react'; -import Image from 'next/image'; +import Image from '@/components/Image'; import { usePathname } from 'next/navigation'; import collapseIcon from '@/../public/img/uswds/usa-icons/expand_more.svg'; import { OrgPickerList } from './OrgPickerList'; diff --git a/src/components/OrganizationsList/OrganizationsList.tsx b/src/components/OrganizationsList/OrganizationsList.tsx index dd943972..54037a72 100644 --- a/src/components/OrganizationsList/OrganizationsList.tsx +++ b/src/components/OrganizationsList/OrganizationsList.tsx @@ -12,6 +12,7 @@ export function OrganizationsList({ memoryCurrentUsage, spaceCounts, roles, + nonce, }: { orgs: Array; userCounts: { [orgGuid: string]: number }; @@ -20,6 +21,7 @@ export function OrganizationsList({ memoryCurrentUsage: { [orgGuid: string]: number }; spaceCounts: { [orgGuid: string]: number }; roles: { [orgGuid: string]: Array }; + nonce: string | undefined; }) { if (!orgs.length) { return <>no orgs found; @@ -44,6 +46,7 @@ export function OrganizationsList({ memoryCurrentUsage={memoryCurrentUsage[org.guid]} spaceCount={spaceCounts[org.guid]} roles={roles[org.guid]} + nonce={nonce} /> ); })} diff --git a/src/components/OrganizationsList/OrganizationsListCard.tsx b/src/components/OrganizationsList/OrganizationsListCard.tsx index a3e5c69a..8fceba3e 100644 --- a/src/components/OrganizationsList/OrganizationsListCard.tsx +++ b/src/components/OrganizationsList/OrganizationsListCard.tsx @@ -7,7 +7,7 @@ import { formatInt } from '@/helpers/numbers'; import { MemoryBar } from '@/components/MemoryBar'; import { formatOrgRoleName } from '@/helpers/text'; -export function OrganizationsListCard({ +export async function OrganizationsListCard({ org, userCount, appCount, @@ -15,6 +15,7 @@ export function OrganizationsListCard({ memoryCurrentUsage, spaceCount, roles, + nonce, }: { org: OrgObj; userCount: number; @@ -23,6 +24,7 @@ export function OrganizationsListCard({ memoryCurrentUsage: number; spaceCount: number; roles: Array; + nonce: string | undefined; }) { const getOrgRolesText = (orgGuid: string): React.ReactNode => { if (!roles || !roles.length) { @@ -81,6 +83,7 @@ export function OrganizationsListCard({ ); diff --git a/src/components/OverlayDrawer.tsx b/src/components/OverlayDrawer.tsx index 35e897c2..a986224c 100644 --- a/src/components/OverlayDrawer.tsx +++ b/src/components/OverlayDrawer.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useRef, useEffect } from 'react'; -import Image from 'next/image'; +import Image from '@/components/Image'; import closeIcon from '@/../public/img/uswds/usa-icons/close.svg'; export function OverlayDrawer({ diff --git a/src/components/Overlays/OverlayHeaderUsername.tsx b/src/components/Overlays/OverlayHeaderUsername.tsx index 63080479..aeb0b99c 100644 --- a/src/components/Overlays/OverlayHeaderUsername.tsx +++ b/src/components/Overlays/OverlayHeaderUsername.tsx @@ -13,10 +13,7 @@ export function OverlayHeaderUsername({ }) { return ( <> -

+

{header}

{serviceAccount && ( diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx index 9c6f8aef..229034c5 100644 --- a/src/components/ProgressBar.tsx +++ b/src/components/ProgressBar.tsx @@ -6,12 +6,14 @@ export function ProgressBar({ threshold1 = 75, // percentage where color should change first, between 0 and 100 threshold2 = 90, // percentage where color should change next, between 0 and 100 changeColors = true, + nonce, }: { total: number | null | undefined; fill: number; threshold1?: number; threshold2?: number; changeColors?: boolean; + nonce: string | undefined; }) { const heightClass = 'height-1'; const percentage = total ? Math.floor((fill / total) * 100) : 100; @@ -33,6 +35,7 @@ export function ProgressBar({ className={`${heightClass} radius-pill ${color}`} style={{ width: `${percentage}%` }} data-testid="progress" + nonce={nonce} >
{!total && ( diff --git a/src/components/uswds/Banner.tsx b/src/components/uswds/Banner.tsx index 57542369..c5f281bb 100644 --- a/src/components/uswds/Banner.tsx +++ b/src/components/uswds/Banner.tsx @@ -1,6 +1,6 @@ 'use client'; -import Image from 'next/image'; +import Image from '@/components/Image'; import { useState } from 'react'; function BannerContent() { diff --git a/src/components/uswds/Footer.tsx b/src/components/uswds/Footer.tsx index 65ea8fe8..14579436 100644 --- a/src/components/uswds/Footer.tsx +++ b/src/components/uswds/Footer.tsx @@ -1,4 +1,4 @@ -import Image from 'next/image'; +import Image from '@/components/Image'; import cloudGovIcon from '@/../public/img/logos/cloud-gov-logo-full-grey.svg'; diff --git a/src/components/uswds/Identifier.tsx b/src/components/uswds/Identifier.tsx index 3ecdfa6d..10a86e3b 100644 --- a/src/components/uswds/Identifier.tsx +++ b/src/components/uswds/Identifier.tsx @@ -1,4 +1,4 @@ -import Image from 'next/image'; +import Image from '@/components/Image'; export function Identifier() { const links = [ diff --git a/src/components/uswds/Modal.tsx b/src/components/uswds/Modal.tsx index 9c2b1c0b..fc37f1c3 100644 --- a/src/components/uswds/Modal.tsx +++ b/src/components/uswds/Modal.tsx @@ -1,5 +1,5 @@ import React, { useRef, useEffect } from 'react'; -import Image from 'next/image'; +import Image from '@/components/Image'; import closeIcon from '@/../public/img/uswds/usa-icons/close.svg'; export const modalHeadingId = (item: { guid: string }) => diff --git a/src/middleware.ts b/src/middleware.ts index 390c9bac..7a004d7b 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,194 +1,25 @@ -// docs: https://nextjs.org/docs/app/building-your-application/routing/middleware -import type { NextRequest } from 'next/server'; -import { NextResponse } from 'next/server'; -import { decodeJwt } from 'jose'; -import { postToAuthTokenUrl, UAATokenResponseObj } from '@/api/auth'; -import { logInPath } from '@/helpers/authentication'; +import { stackMiddlewares } from './middlewares/stackMiddlewares'; +import { withAuth } from './middlewares/withAuth'; +import { withNonce } from './middlewares/withNonce'; +import { withCSP } from './middlewares/withCSP'; -export function login(request: NextRequest) { - if ( - !process.env.UAA_ROOT_URL || - !process.env.UAA_AUTH_PATH || - !process.env.OAUTH_CLIENT_ID - ) { - throw new Error('UAA environment variables are not set'); - } - const state = request.nextUrl.searchParams.get('state') || ''; - const loginUrl = new URL( - process.env.UAA_ROOT_URL + process.env.UAA_AUTH_PATH - ); - const params = new URLSearchParams(loginUrl.search); - params.set('client_id', process.env.OAUTH_CLIENT_ID); - params.set('state', state); - params.set('response_type', 'code'); - const response = NextResponse.redirect(loginUrl + '?' + params.toString()); - response.cookies.set('state', state); - return response; -} +export default stackMiddlewares([withNonce, withCSP, withAuth]); -export function logout() { - if ( - !process.env.UAA_ROOT_URL || - !process.env.UAA_LOGOUT_PATH || - !process.env.ROOT_URL || - !process.env.AUTH_CALLBACK_PATH || - !process.env.OAUTH_CLIENT_ID - ) { - throw new Error('UAA environment variables are not set'); - } - - const logoutUrl = new URL( - process.env.UAA_ROOT_URL + process.env.UAA_LOGOUT_PATH - ); - const params = new URLSearchParams(logoutUrl.search); - params.set('client_id', process.env.OAUTH_CLIENT_ID); - params.set('redirect', process.env.ROOT_URL + process.env.AUTH_CALLBACK_PATH); - const response = NextResponse.redirect(logoutUrl + '?' + params.toString()); - response.cookies.delete('authsession'); - return response; -} - -export function setAuthCookie( - data: UAATokenResponseObj, - response: NextResponse -) { - const decodedToken = decodeJwt(data.access_token); - response.cookies.set( - 'authsession', - JSON.stringify({ - accessToken: data.access_token, - user_id: decodedToken.user_id, - user_name: decodedToken.user_name, - email: decodedToken.email, - refreshToken: data.refresh_token, - expiry: Date.now() + data.expires_in * 1000, - }) - ); - return response; -} - -export async function requestAndSetAuthToken(request: NextRequest) { - if ( - !process.env.UAA_ROOT_URL || - !process.env.OAUTH_CLIENT_ID || - !process.env.OAUTH_CLIENT_SECRET - ) { - throw new Error('UAA environment variables are not set'); - } - - const stateCookie = request.cookies.get('state'); - let response; - let lastPagePath; - if ((lastPagePath = request.cookies.get('last_page')?.value)) { - response = NextResponse.redirect(new URL(lastPagePath, request.url)); - } else { - response = NextResponse.redirect(new URL('/', request.url)); - } - - if ( - !stateCookie || - request.nextUrl.searchParams.get('state') != stateCookie['value'] - ) { - return response; - } - const data = await postToAuthTokenUrl({ - code: request.nextUrl.searchParams.get('code') || '', - grant_type: 'authorization_code', - response_type: 'token', - client_id: process.env.OAUTH_CLIENT_ID, - client_secret: process.env.OAUTH_CLIENT_SECRET, - }); - response = setAuthCookie(data, response); - response.cookies.delete('state'); - response.cookies.delete('last_page'); - return response; -} - -export async function refreshAuthToken(refreshToken: string) { - if (!process.env.OAUTH_CLIENT_ID || !process.env.OAUTH_CLIENT_SECRET) { - throw new Error('OAUTH environment variables are not set'); - } - - const data = await postToAuthTokenUrl({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: process.env.OAUTH_CLIENT_ID, - client_secret: process.env.OAUTH_CLIENT_SECRET, - }); - return data; -} - -export function redirectToLogin(request: NextRequest): NextResponse { - const loginPath = logInPath(); - const response = NextResponse.redirect(new URL(loginPath, request.url)); - response.cookies.set('last_page', request.nextUrl.pathname); - return response; -} - -export async function authenticateRoute(request: NextRequest) { - let response = NextResponse.next(); - // For those working locally, just pass them through - if (process.env.NODE_ENV === 'development') { - response = setLastViewedOrg(request, response); - return response; - } - // get auth session cookie - const authCookie = request.cookies.get('authsession'); - // if no cookie, redirect to login page - if (!authCookie) return redirectToLogin(request); - - const authObj = JSON.parse(authCookie['value']); - // if no expiration at all, redirect to login page - if (!authObj.expiry) return redirectToLogin(request); - // if cookie expired, run refresh routine - if (Date.now() > authObj.expiry) { - const newAuthResponse = await refreshAuthToken(authObj.refreshToken); - response = setAuthCookie(newAuthResponse, response); - return response; - } - // they're logged in already - response = setLastViewedOrg(request, response); - // go to page - return response; -} - -export function setLastViewedOrg(request: NextRequest, response: NextResponse) { - const matches = request.nextUrl.pathname.match( - /orgs\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})/ - ); - let id; - if (matches && (id = matches[1])) { - response.cookies.set('lastViewedOrgId', id); - } - return response; -} - -export function middleware(request: NextRequest) { - const pn = request.nextUrl.pathname; - if (pn.startsWith('/test/authenticated') || pn.startsWith('/orgs')) { - return authenticateRoute(request); - } - if (pn.startsWith('/login')) { - return login(request); - } - if (pn.startsWith('/logout')) { - return logout(); - } - if (pn.startsWith('/auth/login/callback')) { - return requestAndSetAuthToken(request); - } -} - -// regex can be used here using path-to-regexp: -// https://github.com/pillarjs/path-to-regexp#path-to-regexp-1 -// TODO: not sure why I'd need both route matching and conditionals above export const config = { matcher: [ - '/auth/login/callback', - '/logout', - '/login', - '/test/authenticated/:path*', - '/orgs', - '/orgs/:path*', + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + */ + { + source: '/((?!api|_next/static|_next/image|favicon.ico).*)', + missing: [ + { type: 'header', key: 'next-router-prefetch' }, + { type: 'header', key: 'purpose', value: 'prefetch' }, + ], + }, ], }; diff --git a/src/middlewares/stackMiddlewares.ts b/src/middlewares/stackMiddlewares.ts new file mode 100644 index 00000000..8a3f2ea7 --- /dev/null +++ b/src/middlewares/stackMiddlewares.ts @@ -0,0 +1,14 @@ +import { NextMiddleware, NextResponse } from 'next/server'; +import { MiddlewareFactory } from './types'; + +export function stackMiddlewares( + functions: MiddlewareFactory[] = [], + index = 0 +): NextMiddleware { + const current = functions[index]; + if (current) { + const next = stackMiddlewares(functions, index + 1); + return current(next); + } + return () => NextResponse.next(); +} diff --git a/src/middlewares/types.ts b/src/middlewares/types.ts new file mode 100644 index 00000000..8ab2db92 --- /dev/null +++ b/src/middlewares/types.ts @@ -0,0 +1,4 @@ +import { NextMiddleware } from 'next/server'; + +// eslint-disable-next-line no-unused-vars +export type MiddlewareFactory = (middleware: NextMiddleware) => NextMiddleware; diff --git a/src/middlewares/withAuth.ts b/src/middlewares/withAuth.ts new file mode 100644 index 00000000..199d3142 --- /dev/null +++ b/src/middlewares/withAuth.ts @@ -0,0 +1,188 @@ +import { + NextFetchEvent, + NextMiddleware, + NextRequest, + NextResponse, +} from 'next/server'; +import { MiddlewareFactory } from './types'; +import { decodeJwt } from 'jose'; +import { postToAuthTokenUrl, UAATokenResponseObj } from '@/api/auth'; +import { logInPath } from '@/helpers/authentication'; + +export function login(request: NextRequest) { + if ( + !process.env.UAA_ROOT_URL || + !process.env.UAA_AUTH_PATH || + !process.env.OAUTH_CLIENT_ID + ) { + throw new Error('UAA environment variables are not set'); + } + const state = request.nextUrl.searchParams.get('state') || ''; + const loginUrl = new URL( + process.env.UAA_ROOT_URL + process.env.UAA_AUTH_PATH + ); + const params = new URLSearchParams(loginUrl.search); + params.set('client_id', process.env.OAUTH_CLIENT_ID); + params.set('state', state); + params.set('response_type', 'code'); + const response = NextResponse.redirect(loginUrl + '?' + params.toString()); + response.cookies.set('state', state); + return response; +} + +export function logout() { + if ( + !process.env.UAA_ROOT_URL || + !process.env.UAA_LOGOUT_PATH || + !process.env.ROOT_URL || + !process.env.AUTH_CALLBACK_PATH || + !process.env.OAUTH_CLIENT_ID + ) { + throw new Error('UAA environment variables are not set'); + } + + const logoutUrl = new URL( + process.env.UAA_ROOT_URL + process.env.UAA_LOGOUT_PATH + ); + const params = new URLSearchParams(logoutUrl.search); + params.set('client_id', process.env.OAUTH_CLIENT_ID); + params.set('redirect', process.env.ROOT_URL + process.env.AUTH_CALLBACK_PATH); + const response = NextResponse.redirect(logoutUrl + '?' + params.toString()); + response.cookies.delete('authsession'); + return response; +} + +export function setAuthCookie( + data: UAATokenResponseObj, + response: NextResponse +) { + const decodedToken = decodeJwt(data.access_token); + response.cookies.set( + 'authsession', + JSON.stringify({ + accessToken: data.access_token, + user_id: decodedToken.user_id, + user_name: decodedToken.user_name, + email: decodedToken.email, + refreshToken: data.refresh_token, + expiry: Date.now() + data.expires_in * 1000, + }) + ); + return response; +} + +export async function requestAndSetAuthToken(request: NextRequest) { + if ( + !process.env.UAA_ROOT_URL || + !process.env.OAUTH_CLIENT_ID || + !process.env.OAUTH_CLIENT_SECRET + ) { + throw new Error('UAA environment variables are not set'); + } + + const stateCookie = request.cookies.get('state'); + let response; + let lastPagePath; + if ((lastPagePath = request.cookies.get('last_page')?.value)) { + response = NextResponse.redirect(new URL(lastPagePath, request.url)); + } else { + response = NextResponse.redirect(new URL('/', request.url)); + } + + if ( + !stateCookie || + request.nextUrl.searchParams.get('state') != stateCookie['value'] + ) { + return response; + } + const data = await postToAuthTokenUrl({ + code: request.nextUrl.searchParams.get('code') || '', + grant_type: 'authorization_code', + response_type: 'token', + client_id: process.env.OAUTH_CLIENT_ID, + client_secret: process.env.OAUTH_CLIENT_SECRET, + }); + response = setAuthCookie(data, response); + response.cookies.delete('state'); + response.cookies.delete('last_page'); + return response; +} + +export async function refreshAuthToken(refreshToken: string) { + if (!process.env.OAUTH_CLIENT_ID || !process.env.OAUTH_CLIENT_SECRET) { + throw new Error('OAUTH environment variables are not set'); + } + + const data = await postToAuthTokenUrl({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: process.env.OAUTH_CLIENT_ID, + client_secret: process.env.OAUTH_CLIENT_SECRET, + }); + return data; +} + +export function redirectToLogin(request: NextRequest): NextResponse { + const loginPath = logInPath(); + const response = NextResponse.redirect(new URL(loginPath, request.url)); + response.cookies.set('last_page', request.nextUrl.pathname); + return response; +} + +export async function authenticateRoute(request: NextRequest) { + let response = NextResponse.next(); + // For those working locally, just pass them through + if (process.env.NODE_ENV === 'development') { + response = setLastViewedOrg(request, response); + return response; + } + // get auth session cookie + const authCookie = request.cookies.get('authsession'); + // if no cookie, redirect to login page + if (!authCookie) return redirectToLogin(request); + + const authObj = JSON.parse(authCookie['value']); + // if no expiration at all, redirect to login page + if (!authObj.expiry) return redirectToLogin(request); + // if cookie expired, run refresh routine + if (Date.now() > authObj.expiry) { + const newAuthResponse = await refreshAuthToken(authObj.refreshToken); + response = setAuthCookie(newAuthResponse, response); + return response; + } + // they're logged in already + response = setLastViewedOrg(request, response); + // go to page + return response; +} + +export function setLastViewedOrg(request: NextRequest, response: NextResponse) { + const matches = request.nextUrl.pathname.match( + /orgs\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})/ + ); + let id; + if (matches && (id = matches[1])) { + response.cookies.set('lastViewedOrgId', id); + } + return response; +} + +export const withAuth: MiddlewareFactory = (next: NextMiddleware) => { + return async (request: NextRequest, _next: NextFetchEvent) => { + const pn = request.nextUrl.pathname; + if (pn.startsWith('/test/authenticated') || pn.startsWith('/orgs')) { + return authenticateRoute(request); + } + if (pn.startsWith('/login')) { + return login(request); + } + if (pn.startsWith('/logout')) { + return logout(); + } + if (pn.startsWith('/auth/login/callback')) { + return requestAndSetAuthToken(request); + } + + return next(request, _next); + }; +}; diff --git a/src/middlewares/withCSP.ts b/src/middlewares/withCSP.ts new file mode 100644 index 00000000..98e40dba --- /dev/null +++ b/src/middlewares/withCSP.ts @@ -0,0 +1,42 @@ +import { NextFetchEvent, NextMiddleware, NextRequest } from 'next/server'; + +import { MiddlewareFactory } from './types'; + +export const withCSP: MiddlewareFactory = (next: NextMiddleware) => { + return async (request: NextRequest, _next: NextFetchEvent) => { + if (request.headers.get('x-nonce') != null) { + const nonce = request.headers.get('x-nonce'); + const cspHeader = ` + default-src 'self'; + script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${process.env.NODE_ENV === 'development' ? "'unsafe-eval'" : ''}; + style-src 'self' 'nonce-${nonce}'; + img-src 'self' blob: data:; + font-src 'self'; + object-src 'none'; + base-uri 'self'; + form-action 'self'; + frame-ancestors 'none'; + ${process.env.NODE_ENV !== 'development' ? 'upgrade-insecure-requests;' : ''}; + `; + + // Replace newline characters and spaces + const contentSecurityPolicyHeaderValue = cspHeader + .replace(/\s{2,}/g, ' ') + .trim(); + request.headers.set( + 'Content-Security-Policy', + contentSecurityPolicyHeaderValue + ); + const response = await next(request, _next); + if (response) { + response.headers.set( + 'Content-Security-Policy', + contentSecurityPolicyHeaderValue + ); + } + return response; + } else { + return next(request, _next); + } + }; +}; diff --git a/src/middlewares/withNonce.ts b/src/middlewares/withNonce.ts new file mode 100644 index 00000000..f9e0b459 --- /dev/null +++ b/src/middlewares/withNonce.ts @@ -0,0 +1,11 @@ +import { NextFetchEvent, NextMiddleware, NextRequest } from 'next/server'; + +import { MiddlewareFactory } from './types'; + +export const withNonce: MiddlewareFactory = (next: NextMiddleware) => { + return async (request: NextRequest, _next: NextFetchEvent) => { + const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); + request.headers.set('x-nonce', nonce); + return next(request, _next); + }; +};