Last active
December 29, 2022 21:38
-
-
Save bruceharrison1984/5b85d50da0f4a61ca228c2705ac08717 to your computer and use it in GitHub Desktop.
NextJS Cookie Auth sample
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as jose from 'jose'; | |
const FIREBASE_PUBLIC_KEY_URL = | |
'https://www.googleapis.com/robot/v1/metadata/x509/[email protected]'; | |
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; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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