Skip to content

Commit

Permalink
Use middleware to add nonce and CSP headers
Browse files Browse the repository at this point in the history
  • Loading branch information
hursey013 committed Oct 10, 2024
1 parent c5bfadc commit fcc90bf
Show file tree
Hide file tree
Showing 16 changed files with 283 additions and 182 deletions.
2 changes: 1 addition & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/basic-features/typescript for more information.
13 changes: 13 additions & 0 deletions src/components/Image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import NextImage, { getImageProps } from 'next/image';
import { ComponentProps } from 'react';

export default function Image(props: ComponentProps<typeof NextImage>) {
const { props: nextProps } = getImageProps({
...props,
});

// eslint-disable-next-line no-unused-vars
const { style: _omit, ...delegated } = nextProps;

return <img {...delegated} />;
}
2 changes: 1 addition & 1 deletion src/components/NavGlobal/NavGlobal.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/components/OrgPicker/OrgPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/components/OverlayDrawer.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
5 changes: 1 addition & 4 deletions src/components/Overlays/OverlayHeaderUsername.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,7 @@ export function OverlayHeaderUsername({
}) {
return (
<>
<h2
className="margin-top-0 margin-bottom-7 text-uppercase text-light underline-base-light text-underline font-sans-xs"
style={{ textUnderlineOffset: '0.7em' }}
>
<h2 className="margin-top-0 margin-bottom-7 text-uppercase text-light underline-base-light text-underline font-sans-xs">
{header}
</h2>
{serviceAccount && (
Expand Down
2 changes: 1 addition & 1 deletion src/components/uswds/Banner.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import Image from 'next/image';
import Image from '@/components/Image';
import { useState } from 'react';

function BannerContent() {
Expand Down
2 changes: 1 addition & 1 deletion src/components/uswds/Footer.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
2 changes: 1 addition & 1 deletion src/components/uswds/Identifier.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Image from 'next/image';
import Image from '@/components/Image';

export function Identifier() {
const links = [
Expand Down
2 changes: 1 addition & 1 deletion src/components/uswds/Modal.tsx
Original file line number Diff line number Diff line change
@@ -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 }) =>
Expand Down
189 changes: 19 additions & 170 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,176 +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 { withCSP } from './middlewares/withCSP';
import { withNonce } from './middlewares/withNonce';

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,
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) {
// For those working locally, just pass them through
if (process.env.NODE_ENV === 'development') return NextResponse.next();
// 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);
let nextRes = NextResponse.next();
nextRes = setAuthCookie(newAuthResponse, nextRes);
return nextRes;
}
// cookie is not expired, go to page
return NextResponse.next();
}

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' },
],
},
],
};
14 changes: 14 additions & 0 deletions src/middlewares/stackMiddlewares.ts
Original file line number Diff line number Diff line change
@@ -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();
}
4 changes: 4 additions & 0 deletions src/middlewares/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { NextMiddleware } from 'next/server';

// eslint-disable-next-line no-unused-vars
export type MiddlewareFactory = (middleware: NextMiddleware) => NextMiddleware;
Loading

0 comments on commit fcc90bf

Please sign in to comment.