Skip to content

Instantly share code, notes, and snippets.

@bruceharrison1984
Last active December 29, 2022 21:38
Show Gist options
  • Save bruceharrison1984/5b85d50da0f4a61ca228c2705ac08717 to your computer and use it in GitHub Desktop.
Save bruceharrison1984/5b85d50da0f4a61ca228c2705ac08717 to your computer and use it in GitHub Desktop.
NextJS Cookie Auth sample
import * as jose from 'jose';
const FIREBASE_PUBLIC_KEY_URL =
'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com';
const isDev = process.env.NODE_ENV === 'development';
const firestoreBaseUrl = isDev
? 'http://localhost:8080'
: 'https://firestore.googleapis.com';
/**
* Check if the ID token is a valid, not expired JWT.
* For local development, the ID token is just unwrapped and returned and is not validated.
* When deployed, the ID token is validated, and the unwrapped body is returned.
* @param idToken Stringy JWT ID token
* @returns JWTPayload
*/
export const verifyJwt = async (idToken: string) => {
if (isDev) return jose.decodeJwt(idToken);
const { kid, alg } = jose.decodeProtectedHeader(idToken);
if (!kid || !alg) throw new Error('KID or ALG properties on JWT are invalid');
const firebasePublicKeysReq = await fetch(FIREBASE_PUBLIC_KEY_URL);
const firebasePublicKeys = await firebasePublicKeysReq.json();
const publicKey = await jose.importX509(firebasePublicKeys[kid], alg);
return (await jose.jwtVerify(idToken, publicKey)).payload;
};
/**
* Use the Firestore REST Api to retrieve the user profile and check if user has been validated.
* REST Api is used because NextJS middleware cannot support Firebase SDKs.
* The REST call is made via the user's ID token, so all security rules still apply.
* @param userId The user's unique Firebase ID
* @param jwtToken Current valid JWT for the user
* @returns True/False indicating validation status
*/
export const isUserValidated = async (userId: string, jwtToken: string) => {
const firestoreProfileUrl = [
firestoreBaseUrl,
'v1/projects/<project-id>/databases/(default)/documents/appData',
userId,
].join('/');
const req = await fetch(firestoreProfileUrl, {
headers: { Authorization: `Bearer ${jwtToken}` },
});
const userProfile = await req.json();
return userProfile.fields.isValidated.booleanValue as boolean;
};
import { ComponentWithLayout } from '@/types';
import { Divider, IconButton, LargeIcon, LoginCard } from '@/components';
import {
FacebookAuthProvider,
GoogleAuthProvider,
getAuth,
signInWithPopup,
} from 'firebase/auth';
import { faDharmachakra } from '@fortawesome/free-solid-svg-icons';
import { faFacebook, faGoogle } from '@fortawesome/free-brands-svg-icons';
import { getSplashPageLayout } from '@/layouts/SplashPageLayout';
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import Cookies from 'js-cookie';
const LoginPage: ComponentWithLayout<{}> = () => {
const router = useRouter();
const auth = getAuth();
useEffect(() => {
// profile creation happens in a Firebase trigger function
const unsub = auth.onAuthStateChanged(async (user) => {
if (user) {
const idToken = await user.getIdToken(true);
Cookies.set('plh-user', idToken, {
secure: true,
sameSite: 'strict',
});
await router.push('/signin');
}
});
return () => {
unsub();
};
}, [router, auth]);
return (
<LoginCard isVisible={true}>
<LargeIcon icon={faDharmachakra} shouldSpin={false} />
<Divider />
<div className="space-y-5">
<IconButton
buttonText="Login with Google"
buttonColorClassName="bg-red-500"
buttonHoverColorClassName="bg-red-700"
icon={faGoogle}
onClick={async () =>
await signInWithPopup(auth, new GoogleAuthProvider())
}
/>
<IconButton
buttonText="Login with Facebook"
buttonColorClassName="bg-blue-500"
buttonHoverColorClassName="bg-blue-700"
icon={faFacebook}
onClick={async () =>
await signInWithPopup(auth, new FacebookAuthProvider())
}
/>
</div>
</LoginCard>
);
};
LoginPage.getLayout = getSplashPageLayout;
LoginPage.pageTitle = 'Login';
export default LoginPage;
import { NextMiddleware, NextResponse } from 'next/server';
import { isUserValidated, verifyJwt } from './firebase/jwtVerification';
export const middleware: NextMiddleware = async (req) => {
const res = NextResponse.next();
const idToken = req.cookies.get('plh-user');
const originalPath = req.nextUrl.pathname;
const basePath = req.nextUrl.search;
/**
* Create a redirect response that attaches a cookie that contains the original destination.
* This is so the user is redirected to their target location upon login
* @returns NextResponse
*/
const redirectResponse = (redirectPath = '/') => {
const resp = NextResponse.redirect(new URL(redirectPath, req.url));
resp.cookies.set('plh-redirect', originalPath + basePath, {
sameSite: 'strict',
secure: true,
});
return resp;
};
if (!idToken || !idToken.value) {
console.error('No id token present');
return redirectResponse();
}
try {
const decodedJwt = await verifyJwt(idToken.value);
// only needed if user profile has a secondary validation field
const isValidated = await isUserValidated(decodedJwt.sub!, idToken.value);
if (!isValidated) {
console.error('User exists but has not been validated', { idToken });
return redirectResponse('/validate');
}
} catch (err) {
console.error('Middleware failed to authenticate user', { err, idToken });
return redirectResponse();
}
return res;
};
export const config = {
matcher: ['/app/:path*', '/signin'],
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment