Use custom token to authenticate Firebase SDK on server

This commit is contained in:
Marshall Polaris 2022-08-03 14:32:29 -07:00
parent 84a3a26c01
commit 1f06f24d7a
3 changed files with 135 additions and 42 deletions

View File

@ -2,7 +2,7 @@ import { PROJECT_ID } from 'common/envs/constants'
import { setCookie, getCookies } from '../util/cookie' import { setCookie, getCookies } from '../util/cookie'
import { IncomingMessage, ServerResponse } from 'http' import { IncomingMessage, ServerResponse } from 'http'
const TOKEN_KINDS = ['refresh', 'id'] as const const TOKEN_KINDS = ['refresh', 'id', 'custom'] as const
type TokenKind = typeof TOKEN_KINDS[number] type TokenKind = typeof TOKEN_KINDS[number]
const getAuthCookieName = (kind: TokenKind) => { const getAuthCookieName = (kind: TokenKind) => {
@ -12,6 +12,9 @@ const getAuthCookieName = (kind: TokenKind) => {
const ID_COOKIE_NAME = getAuthCookieName('id') const ID_COOKIE_NAME = getAuthCookieName('id')
const REFRESH_COOKIE_NAME = getAuthCookieName('refresh') const REFRESH_COOKIE_NAME = getAuthCookieName('refresh')
const CUSTOM_COOKIE_NAME = getAuthCookieName('custom')
const ONE_HOUR_SECS = 60 * 60
const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
export const getAuthCookies = (request?: IncomingMessage) => { export const getAuthCookies = (request?: IncomingMessage) => {
const data = request != null ? request.headers.cookie ?? '' : document.cookie const data = request != null ? request.headers.cookie ?? '' : document.cookie
@ -19,24 +22,31 @@ export const getAuthCookies = (request?: IncomingMessage) => {
return { return {
idToken: cookies[ID_COOKIE_NAME] as string | undefined, idToken: cookies[ID_COOKIE_NAME] as string | undefined,
refreshToken: cookies[REFRESH_COOKIE_NAME] as string | undefined, refreshToken: cookies[REFRESH_COOKIE_NAME] as string | undefined,
customToken: cookies[CUSTOM_COOKIE_NAME] as string | undefined,
} }
} }
export const setAuthCookies = ( export const setAuthCookies = (
idToken?: string, idToken?: string,
refreshToken?: string, refreshToken?: string,
customToken?: string,
response?: ServerResponse response?: ServerResponse
) => { ) => {
// these tokens last an hour const idMaxAge = idToken != null ? ONE_HOUR_SECS : 0
const idMaxAge = idToken != null ? 60 * 60 : 0
const idCookie = setCookie(ID_COOKIE_NAME, idToken ?? '', [ const idCookie = setCookie(ID_COOKIE_NAME, idToken ?? '', [
['path', '/'], ['path', '/'],
['max-age', idMaxAge.toString()], ['max-age', idMaxAge.toString()],
['samesite', 'lax'], ['samesite', 'lax'],
['secure'], ['secure'],
]) ])
// these tokens don't expire const customMaxAge = customToken != null ? ONE_HOUR_SECS : 0
const refreshMaxAge = refreshToken != null ? 60 * 60 * 24 * 365 * 10 : 0 const customCookie = setCookie(CUSTOM_COOKIE_NAME, customToken ?? '', [
['path', '/'],
['max-age', customMaxAge.toString()],
['samesite', 'lax'],
['secure'],
])
const refreshMaxAge = refreshToken != null ? TEN_YEARS_SECS : 0
const refreshCookie = setCookie(REFRESH_COOKIE_NAME, refreshToken ?? '', [ const refreshCookie = setCookie(REFRESH_COOKIE_NAME, refreshToken ?? '', [
['path', '/'], ['path', '/'],
['max-age', refreshMaxAge.toString()], ['max-age', refreshMaxAge.toString()],
@ -44,10 +54,11 @@ export const setAuthCookies = (
['secure'], ['secure'],
]) ])
if (response != null) { if (response != null) {
response.setHeader('Set-Cookie', [idCookie, refreshCookie]) response.setHeader('Set-Cookie', [idCookie, refreshCookie, customCookie])
} else { } else {
document.cookie = idCookie document.cookie = idCookie
document.cookie = refreshCookie document.cookie = refreshCookie
document.cookie = customCookie
} }
} }

View File

@ -1,9 +1,21 @@
import * as admin from 'firebase-admin'
import fetch from 'node-fetch' import fetch from 'node-fetch'
import { IncomingMessage, ServerResponse } from 'http' import { IncomingMessage, ServerResponse } from 'http'
import { FIREBASE_CONFIG, PROJECT_ID } from 'common/envs/constants' import { FIREBASE_CONFIG, PROJECT_ID } from 'common/envs/constants'
import { getAuthCookies, setAuthCookies } from './auth' import { getFunctionUrl } from 'common/api'
import { GetServerSideProps, GetServerSidePropsContext } from 'next' import { UserCredential } from 'firebase/auth'
import { getAuthCookies, setAuthCookies, deleteAuthCookies } from './auth'
import {
GetServerSideProps,
GetServerSidePropsContext,
GetServerSidePropsResult,
} from 'next'
// server firebase SDK
import * as admin from 'firebase-admin'
// client firebase SDK
import { app as clientApp } from './init'
import { getAuth, signInWithCustomToken } from 'firebase/auth'
const ensureApp = async () => { const ensureApp = async () => {
// Note: firebase-admin can only be imported from a server context, // Note: firebase-admin can only be imported from a server context,
@ -33,7 +45,21 @@ const requestFirebaseIdToken = async (refreshToken: string) => {
if (!result.ok) { if (!result.ok) {
throw new Error(`Could not refresh ID token: ${await result.text()}`) throw new Error(`Could not refresh ID token: ${await result.text()}`)
} }
return (await result.json()) as any return (await result.json()) as { id_token: string; refresh_token: string }
}
const requestManifoldCustomToken = async (idToken: string) => {
const functionUrl = getFunctionUrl('getcustomtoken')
const result = await fetch(functionUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${idToken}`,
},
})
if (!result.ok) {
throw new Error(`Could not get custom token: ${await result.text()}`)
}
return (await result.json()) as { token: string }
} }
type RequestContext = { type RequestContext = {
@ -41,39 +67,99 @@ type RequestContext = {
res: ServerResponse res: ServerResponse
} }
export const getServerAuthenticatedUid = async (ctx: RequestContext) => { const authAndRefreshTokens = async (ctx: RequestContext) => {
const app = await ensureApp() const adminAuth = (await ensureApp()).auth()
const auth = app.auth() const clientAuth = getAuth(clientApp)
const { idToken, refreshToken } = getAuthCookies(ctx.req) let { idToken, refreshToken, customToken } = getAuthCookies(ctx.req)
// If we have a valid ID token, verify the user immediately with no network trips. // step 0: if you have no refresh token you are logged out
// If the ID token doesn't verify, we'll have to refresh it to see who they are. if (refreshToken == null) {
// If they don't have any tokens, then we have no idea who they are. return undefined
}
// step 1: given a valid refresh token, ensure a valid ID token
if (idToken != null) { if (idToken != null) {
// if they have an ID token, throw it out if it's invalid/expired
try { try {
return (await auth.verifyIdToken(idToken))?.uid await adminAuth.verifyIdToken(idToken)
} catch { } catch {
// plausibly expired; try the refresh token, if it's present idToken = undefined
} }
} }
if (refreshToken != null) { if (idToken == null) {
// ask for a new one from google using the refresh token
try { try {
const resp = await requestFirebaseIdToken(refreshToken) const resp = await requestFirebaseIdToken(refreshToken)
setAuthCookies(resp.id_token, resp.refresh_token, ctx.res) idToken = resp.id_token
return (await auth.verifyIdToken(resp.id_token))?.uid refreshToken = resp.refresh_token
} catch (e) { } catch (e) {
// this is a big unexpected problem -- either their cookies are corrupt // big unexpected problem -- functionally, they are not logged in
// or the refresh token API is down. functionally, they are not logged in
console.error(e) console.error(e)
return undefined
}
}
// step 2: given a valid ID token, ensure a valid custom token, and sign in
// to the client SDK with the custom token
if (customToken != null) {
// sign in with this token, or throw it out if it's invalid/expired
try {
return {
creds: await signInWithCustomToken(clientAuth, customToken),
idToken,
refreshToken,
customToken,
}
} catch {
customToken = undefined
}
}
if (customToken == null) {
// ask for a new one from our cloud functions using the ID token, then sign in
try {
const resp = await requestManifoldCustomToken(idToken)
customToken = resp.token
return {
creds: await signInWithCustomToken(clientAuth, customToken),
idToken,
refreshToken,
customToken,
}
} catch (e) {
// big unexpected problem -- functionally, they are not logged in
console.error(e)
return undefined
} }
} }
return undefined
} }
export const redirectIfLoggedIn = (dest: string, fn?: GetServerSideProps) => { export const authenticateOnServer = async (ctx: RequestContext) => {
const tokens = await authAndRefreshTokens(ctx)
if (tokens == null) {
deleteAuthCookies()
return undefined
} else {
const { creds, idToken, refreshToken, customToken } = tokens
setAuthCookies(idToken, refreshToken, customToken, ctx.res)
return creds
}
}
// note that we might want to define these types more generically if we want better
// type safety on next.js stuff... see the definition of GetServerSideProps
type GetServerSidePropsAuthed<P> = (
context: GetServerSidePropsContext,
creds: UserCredential
) => Promise<GetServerSidePropsResult<P>>
export const redirectIfLoggedIn = <P>(
dest: string,
fn?: GetServerSideProps<P>
) => {
return async (ctx: GetServerSidePropsContext) => { return async (ctx: GetServerSidePropsContext) => {
const uid = await getServerAuthenticatedUid(ctx) const creds = await authenticateOnServer(ctx)
if (uid == null) { if (creds == null) {
return fn != null ? await fn(ctx) : { props: {} } return fn != null ? await fn(ctx) : { props: {} }
} else { } else {
return { redirect: { destination: dest, permanent: false } } return { redirect: { destination: dest, permanent: false } }
@ -81,13 +167,16 @@ export const redirectIfLoggedIn = (dest: string, fn?: GetServerSideProps) => {
} }
} }
export const redirectIfLoggedOut = (dest: string, fn?: GetServerSideProps) => { export const redirectIfLoggedOut = <P>(
dest: string,
fn?: GetServerSidePropsAuthed<P>
) => {
return async (ctx: GetServerSidePropsContext) => { return async (ctx: GetServerSidePropsContext) => {
const uid = await getServerAuthenticatedUid(ctx) const creds = await authenticateOnServer(ctx)
if (uid == null) { if (creds == null) {
return { redirect: { destination: dest, permanent: false } } return { redirect: { destination: dest, permanent: false } }
} else { } else {
return fn != null ? await fn(ctx) : { props: {} } return fn != null ? await fn(ctx, creds) : { props: {} }
} }
} }
} }

View File

@ -40,10 +40,7 @@ import { track } from '@amplitude/analytics-browser'
import { Pagination } from 'web/components/pagination' import { Pagination } from 'web/components/pagination'
import { useWindowSize } from 'web/hooks/use-window-size' import { useWindowSize } from 'web/hooks/use-window-size'
import { safeLocalStorage } from 'web/lib/util/local' import { safeLocalStorage } from 'web/lib/util/local'
import { import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
getServerAuthenticatedUid,
redirectIfLoggedOut,
} from 'web/lib/firebase/server-auth'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { NotificationSettings } from 'web/components/NotificationSettings' import { NotificationSettings } from 'web/components/NotificationSettings'
@ -51,12 +48,8 @@ export const NOTIFICATIONS_PER_PAGE = 30
const MULTIPLE_USERS_KEY = 'multipleUsers' const MULTIPLE_USERS_KEY = 'multipleUsers'
const HIGHLIGHT_CLASS = 'bg-indigo-50' const HIGHLIGHT_CLASS = 'bg-indigo-50'
export const getServerSideProps = redirectIfLoggedOut('/', async (ctx) => { export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
const uid = await getServerAuthenticatedUid(ctx) const user = await getUser(creds.user.uid)
if (!uid) {
return { props: { user: null } }
}
const user = await getUser(uid)
return { props: { user } } return { props: { user } }
}) })