Initial draft of Vercel Firebase auth (#593)

* Set a cookie with an up-to-date Firebase ID token

* Implement server-side authentication cookie reading logic

* Change index page to redirect for authed users

* No branch necessary for logged in users on index page

* Add helpers for creating server-side redirects

* Add some common sense redirects
This commit is contained in:
Marshall Polaris 2022-07-19 00:50:11 -07:00 committed by GitHub
parent d1ad0716c8
commit a103a2ee2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 202 additions and 51 deletions

54
web/lib/firebase/auth.ts Normal file
View File

@ -0,0 +1,54 @@
import { PROJECT_ID } from 'common/envs/constants'
import { setCookie, getCookies } from '../util/cookie'
import { IncomingMessage, ServerResponse } from 'http'
const TOKEN_KINDS = ['refresh', 'id'] as const
type TokenKind = typeof TOKEN_KINDS[number]
const getAuthCookieName = (kind: TokenKind) => {
const suffix = `${PROJECT_ID}_${kind}`.toUpperCase().replaceAll('-', '_')
return `FIREBASE_TOKEN_${suffix}`
}
const ID_COOKIE_NAME = getAuthCookieName('id')
const REFRESH_COOKIE_NAME = getAuthCookieName('refresh')
export const getAuthCookies = (request?: IncomingMessage) => {
const data = request != null ? request.headers.cookie ?? '' : document.cookie
const cookies = getCookies(data)
return {
idToken: cookies[ID_COOKIE_NAME] as string | undefined,
refreshToken: cookies[REFRESH_COOKIE_NAME] as string | undefined,
}
}
export const setAuthCookies = (
idToken?: string,
refreshToken?: string,
response?: ServerResponse
) => {
// these tokens last an hour
const idMaxAge = idToken != null ? 60 * 60 : 0
const idCookie = setCookie(ID_COOKIE_NAME, idToken ?? '', [
['path', '/'],
['max-age', idMaxAge.toString()],
['samesite', 'lax'],
['secure'],
])
// these tokens don't expire
const refreshMaxAge = refreshToken != null ? 60 * 60 * 24 * 365 * 10 : 0
const refreshCookie = setCookie(REFRESH_COOKIE_NAME, refreshToken ?? '', [
['path', '/'],
['max-age', refreshMaxAge.toString()],
['samesite', 'lax'],
['secure'],
])
if (response != null) {
response.setHeader('Set-Cookie', [idCookie, refreshCookie])
} else {
document.cookie = idCookie
document.cookie = refreshCookie
}
}
export const deleteAuthCookies = () => setAuthCookies()

View File

@ -0,0 +1,86 @@
import * as admin from 'firebase-admin'
import fetch from 'node-fetch'
import { IncomingMessage, ServerResponse } from 'http'
import { FIREBASE_CONFIG, PROJECT_ID } from 'common/envs/constants'
import { getAuthCookies, setAuthCookies } from './auth'
import { GetServerSideProps, GetServerSidePropsContext } from 'next'
const ensureApp = async () => {
// Note: firebase-admin can only be imported from a server context,
// because it relies on Node standard library dependencies.
if (admin.apps.length === 0) {
// never initialize twice
return admin.initializeApp({ projectId: PROJECT_ID })
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return admin.apps[0]!
}
const requestFirebaseIdToken = async (refreshToken: string) => {
// See https://firebase.google.com/docs/reference/rest/auth/#section-refresh-token
const refreshUrl = new URL('https://securetoken.googleapis.com/v1/token')
refreshUrl.searchParams.append('key', FIREBASE_CONFIG.apiKey)
const result = await fetch(refreshUrl.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
}),
})
if (!result.ok) {
throw new Error(`Could not refresh ID token: ${await result.text()}`)
}
return (await result.json()) as any
}
type RequestContext = {
req: IncomingMessage
res: ServerResponse
}
export const getServerAuthenticatedUid = async (ctx: RequestContext) => {
const app = await ensureApp()
const auth = app.auth()
const { idToken, refreshToken } = getAuthCookies(ctx.req)
// If we have a valid ID token, verify the user immediately with no network trips.
// If the ID token doesn't verify, we'll have to refresh it to see who they are.
// If they don't have any tokens, then we have no idea who they are.
if (idToken != null) {
try {
return (await auth.verifyIdToken(idToken))?.uid
} catch (e) {
if (refreshToken != null) {
const resp = await requestFirebaseIdToken(refreshToken)
setAuthCookies(resp.id_token, resp.refresh_token, ctx.res)
return (await auth.verifyIdToken(resp.id_token))?.uid
}
}
}
return undefined
}
export const redirectIfLoggedIn = (dest: string, fn?: GetServerSideProps) => {
return async (ctx: GetServerSidePropsContext) => {
const uid = await getServerAuthenticatedUid(ctx)
if (uid == null) {
return fn != null ? await fn(ctx) : { props: {} }
} else {
return { redirect: { destination: dest, permanent: false } }
}
}
}
export const redirectIfLoggedOut = (dest: string, fn?: GetServerSideProps) => {
return async (ctx: GetServerSidePropsContext) => {
const uid = await getServerAuthenticatedUid(ctx)
if (uid == null) {
return { redirect: { destination: dest, permanent: false } }
} else {
return fn != null ? await fn(ctx) : { props: {} }
}
}
}

View File

@ -16,7 +16,7 @@ import {
import { getAuth } from 'firebase/auth' import { getAuth } from 'firebase/auth'
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage' import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage'
import { import {
onAuthStateChanged, onIdTokenChanged,
GoogleAuthProvider, GoogleAuthProvider,
signInWithPopup, signInWithPopup,
} from 'firebase/auth' } from 'firebase/auth'
@ -43,6 +43,7 @@ import utc from 'dayjs/plugin/utc'
dayjs.extend(utc) dayjs.extend(utc)
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { deleteAuthCookies, setAuthCookies } from './auth'
export const users = coll<User>('users') export const users = coll<User>('users')
export const privateUsers = coll<PrivateUser>('private-users') export const privateUsers = coll<PrivateUser>('private-users')
@ -188,10 +189,9 @@ export function listenForLogin(onUser: (user: User | null) => void) {
const cachedUser = local?.getItem(CACHED_USER_KEY) const cachedUser = local?.getItem(CACHED_USER_KEY)
onUser(cachedUser && JSON.parse(cachedUser)) onUser(cachedUser && JSON.parse(cachedUser))
return onAuthStateChanged(auth, async (fbUser) => { return onIdTokenChanged(auth, async (fbUser) => {
if (fbUser) { if (fbUser) {
let user: User | null = await getUser(fbUser.uid) let user: User | null = await getUser(fbUser.uid)
if (!user) { if (!user) {
if (createUserPromise == null) { if (createUserPromise == null) {
const local = safeLocalStorage() const local = safeLocalStorage()
@ -204,17 +204,19 @@ export function listenForLogin(onUser: (user: User | null) => void) {
} }
user = await createUserPromise user = await createUserPromise
} }
onUser(user) onUser(user)
// Persist to local storage, to reduce login blink next time. // Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb // Note: Cap on localStorage size is ~5mb
local?.setItem(CACHED_USER_KEY, JSON.stringify(user)) local?.setItem(CACHED_USER_KEY, JSON.stringify(user))
setCachedReferralInfoForUser(user) setCachedReferralInfoForUser(user)
setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken)
} else { } else {
// User logged out; reset to null // User logged out; reset to null
onUser(null) onUser(null)
createUserPromise = undefined
local?.removeItem(CACHED_USER_KEY) local?.removeItem(CACHED_USER_KEY)
deleteAuthCookies()
} }
}) })
} }

26
web/lib/util/cookie.ts Normal file
View File

@ -0,0 +1,26 @@
type CookieOptions = string[][]
const encodeCookie = (name: string, val: string) => {
return `${name}=${encodeURIComponent(val)}`
}
const decodeCookie = (cookie: string) => {
const parts = cookie.trim().split('=')
if (parts.length != 2) {
throw new Error(`Invalid cookie contents: ${cookie}`)
}
return [parts[0], decodeURIComponent(parts[1])] as const
}
export const setCookie = (name: string, val: string, opts?: CookieOptions) => {
const parts = [encodeCookie(name, val)]
if (opts != null) {
parts.push(...opts.map((opt) => opt.join('=')))
}
return parts.join('; ')
}
// Note that this intentionally ignores the case where multiple cookies have
// the same name but different paths. Hopefully we never need to think about it.
export const getCookies = (cookies: string) =>
Object.fromEntries(cookies.split(';').map(decodeCookie))

View File

@ -8,6 +8,9 @@ import { checkoutURL } from 'web/lib/service/stripe'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
export const getServerSideProps = redirectIfLoggedOut('/')
export default function AddFundsPage() { export default function AddFundsPage() {
const user = useUser() const user = useUser()

View File

@ -9,6 +9,9 @@ import { useContracts } from 'web/hooks/use-contracts'
import { mapKeys } from 'lodash' import { mapKeys } from 'lodash'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { contractPath } from 'web/lib/firebase/contracts' import { contractPath } from 'web/lib/firebase/contracts'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
export const getServerSideProps = redirectIfLoggedOut('/')
function avatarHtml(avatarUrl: string) { function avatarHtml(avatarUrl: string) {
return `<img return `<img

View File

@ -28,6 +28,9 @@ import { GroupSelector } from 'web/components/groups/group-selector'
import { User } from 'common/user' import { User } from 'common/user'
import { TextEditor, useTextEditor } from 'web/components/editor' import { TextEditor, useTextEditor } from 'web/components/editor'
import { Checkbox } from 'web/components/checkbox' import { Checkbox } from 'web/components/checkbox'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
export const getServerSideProps = redirectIfLoggedOut('/')
type NewQuestionParams = { type NewQuestionParams = {
groupId?: string groupId?: string
@ -55,11 +58,7 @@ export default function Create() {
}, [params.q]) }, [params.q])
const creator = useUser() const creator = useUser()
useEffect(() => { if (!router.isReady || creator) return <div />
if (creator === null) router.push('/')
}, [creator, router])
if (!router.isReady || !creator) return <div />
return ( return (
<Page> <Page>
@ -93,7 +92,7 @@ export default function Create() {
// Allow user to create a new contract // Allow user to create a new contract
export function NewContract(props: { export function NewContract(props: {
creator: User creator?: User | null
question: string question: string
params?: NewQuestionParams params?: NewQuestionParams
}) { }) {

View File

@ -1,10 +1,9 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import Router, { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { PlusSmIcon } from '@heroicons/react/solid' import { PlusSmIcon } from '@heroicons/react/solid'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { getSavedSort } from 'web/hooks/use-sort-and-query-params' import { getSavedSort } from 'web/hooks/use-sort-and-query-params'
import { ContractSearch } from 'web/components/contract-search' import { ContractSearch } from 'web/components/contract-search'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
@ -12,19 +11,16 @@ import { ContractPageContent } from './[username]/[contractSlug]'
import { getContractFromSlug } from 'web/lib/firebase/contracts' import { getContractFromSlug } from 'web/lib/firebase/contracts'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
export const getServerSideProps = redirectIfLoggedOut('/')
const Home = () => { const Home = () => {
const user = useUser()
const [contract, setContract] = useContractPage() const [contract, setContract] = useContractPage()
const router = useRouter() const router = useRouter()
useTracking('view home') useTracking('view home')
if (user === null) {
Router.replace('/')
return <></>
}
return ( return (
<> <>
<Page suspend={!!contract}> <Page suspend={!!contract}>

View File

@ -1,14 +1,13 @@
import React from 'react' import React from 'react'
import Router from 'next/router'
import { Contract, getContractsBySlugs } from 'web/lib/firebase/contracts' import { Contract, getContractsBySlugs } from 'web/lib/firebase/contracts'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { LandingPagePanel } from 'web/components/landing-page-panel' import { LandingPagePanel } from 'web/components/landing-page-panel'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { ManifoldLogo } from 'web/components/nav/manifold-logo' import { ManifoldLogo } from 'web/components/nav/manifold-logo'
import { redirectIfLoggedIn } from 'web/lib/firebase/server-auth'
export async function getStaticProps() { export const getServerSideProps = redirectIfLoggedIn('/home', async (_) => {
// These hardcoded markets will be shown in the frontpage for signed-out users: // These hardcoded markets will be shown in the frontpage for signed-out users:
const hotContracts = await getContractsBySlugs([ const hotContracts = await getContractsBySlugs([
'will-max-go-to-prom-with-a-girl', 'will-max-go-to-prom-with-a-girl',
@ -22,23 +21,11 @@ export async function getStaticProps() {
'will-congress-hold-any-hearings-abo-e21f987033b3', 'will-congress-hold-any-hearings-abo-e21f987033b3',
'will-at-least-10-world-cities-have', 'will-at-least-10-world-cities-have',
]) ])
return { props: { hotContracts } }
})
return { export default function Home(props: { hotContracts: Contract[] }) {
props: { hotContracts },
revalidate: 60, // regenerate after a minute
}
}
const Home = (props: { hotContracts: Contract[] }) => {
const { hotContracts } = props const { hotContracts } = props
const user = useUser()
if (user) {
Router.replace('/home')
return <></>
}
return ( return (
<Page> <Page>
<div className="px-4 pt-2 md:mt-0 lg:hidden"> <div className="px-4 pt-2 md:mt-0 lg:hidden">
@ -58,5 +45,3 @@ const Home = (props: { hotContracts: Contract[] }) => {
</Page> </Page>
) )
} }
export default Home

View File

@ -18,11 +18,14 @@ import { Avatar } from 'web/components/avatar'
import { RelativeTimestamp } from 'web/components/relative-timestamp' import { RelativeTimestamp } from 'web/components/relative-timestamp'
import { UserLink } from 'web/components/user-page' import { UserLink } from 'web/components/user-page'
import { CreateLinksButton } from 'web/components/manalinks/create-links-button' import { CreateLinksButton } from 'web/components/manalinks/create-links-button'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat' import customParseFormat from 'dayjs/plugin/customParseFormat'
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
export const getServerSideProps = redirectIfLoggedOut('/')
export function getManalinkUrl(slug: string) { export function getManalinkUrl(slug: string) {
return `${location.protocol}//${location.host}/link/${slug}` return `${location.protocol}//${location.host}/link/${slug}`
} }

View File

@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { RefreshIcon } from '@heroicons/react/outline' import { RefreshIcon } from '@heroicons/react/outline'
import Router from 'next/router'
import { AddFundsButton } from 'web/components/add-funds-button' import { AddFundsButton } from 'web/components/add-funds-button'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
@ -18,6 +17,9 @@ import { updateUser, updatePrivateUser } from 'web/lib/firebase/users'
import { defaultBannerUrl } from 'web/components/user-page' import { defaultBannerUrl } from 'web/components/user-page'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import Textarea from 'react-expanding-textarea' import Textarea from 'react-expanding-textarea'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
export const getServerSideProps = redirectIfLoggedOut('/')
function EditUserField(props: { function EditUserField(props: {
user: User user: User
@ -134,8 +136,7 @@ export default function ProfilePage() {
}) })
} }
if (user === null) { if (user == null) {
Router.replace('/')
return <></> return <></>
} }

View File

@ -1,17 +1,10 @@
import Router from 'next/router' import Router from 'next/router'
import { useEffect } from 'react' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { useUser } from 'web/hooks/use-user' export const getServerSideProps = redirectIfLoggedOut('/')
// Deprecated: redirects to /portfolio. // Deprecated: redirects to /portfolio.
// Eventually, this will be removed. // Eventually, this will be removed.
export default function TradesPage() { export default function TradesPage() {
const user = useUser() Router.replace('/portfolio')
useEffect(() => {
if (user === null) Router.replace('/')
else Router.replace('/portfolio')
})
return <></>
} }