Dramatically improve server auth stuff (#826)

This commit is contained in:
Marshall Polaris 2022-08-31 22:13:26 -07:00 committed by GitHub
parent 42548cea2a
commit 0568322c82
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 84 additions and 227 deletions

View File

@ -34,6 +34,11 @@ export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId
export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE' export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE'
export const AUTH_COOKIE_NAME = `FBUSER_${PROJECT_ID.toUpperCase().replace(
/-/g,
'_'
)}`
// Manifold's domain or any subdomains thereof // Manifold's domain or any subdomains thereof
export const CORS_ORIGIN_MANIFOLD = new RegExp( export const CORS_ORIGIN_MANIFOLD = new RegExp(
'^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$' '^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$'

View File

@ -8,17 +8,20 @@ import {
getUserAndPrivateUser, getUserAndPrivateUser,
setCachedReferralInfoForUser, setCachedReferralInfoForUser,
} from 'web/lib/firebase/users' } from 'web/lib/firebase/users'
import { deleteTokenCookies, setTokenCookies } from 'web/lib/firebase/auth'
import { createUser } from 'web/lib/firebase/api' import { createUser } from 'web/lib/firebase/api'
import { randomString } from 'common/util/random' import { randomString } from 'common/util/random'
import { identifyUser, setUserProperty } from 'web/lib/service/analytics' import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
import { useStateCheckEquality } from 'web/hooks/use-state-check-equality' import { useStateCheckEquality } from 'web/hooks/use-state-check-equality'
import { AUTH_COOKIE_NAME } from 'common/envs/constants'
import { setCookie } from 'web/lib/util/cookie'
// Either we haven't looked up the logged in user yet (undefined), or we know // Either we haven't looked up the logged in user yet (undefined), or we know
// the user is not logged in (null), or we know the user is logged in. // the user is not logged in (null), or we know the user is logged in.
type AuthUser = undefined | null | UserAndPrivateUser type AuthUser = undefined | null | UserAndPrivateUser
const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2' const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
// Proxy localStorage in case it's not available (eg in incognito iframe) // Proxy localStorage in case it's not available (eg in incognito iframe)
const localStorage = const localStorage =
typeof window !== 'undefined' typeof window !== 'undefined'
@ -38,6 +41,16 @@ const ensureDeviceToken = () => {
return deviceToken return deviceToken
} }
export const setUserCookie = (cookie: string | undefined) => {
const data = setCookie(AUTH_COOKIE_NAME, cookie ?? '', [
['path', '/'],
['max-age', (cookie === undefined ? 0 : TEN_YEARS_SECS).toString()],
['samesite', 'lax'],
['secure'],
])
document.cookie = data
}
export const AuthContext = createContext<AuthUser>(undefined) export const AuthContext = createContext<AuthUser>(undefined)
export function AuthProvider(props: { export function AuthProvider(props: {
@ -59,10 +72,7 @@ export function AuthProvider(props: {
auth, auth,
async (fbUser) => { async (fbUser) => {
if (fbUser) { if (fbUser) {
setTokenCookies({ setUserCookie(JSON.stringify(fbUser.toJSON()))
id: await fbUser.getIdToken(),
refresh: fbUser.refreshToken,
})
let current = await getUserAndPrivateUser(fbUser.uid) let current = await getUserAndPrivateUser(fbUser.uid)
if (!current.user || !current.privateUser) { if (!current.user || !current.privateUser) {
const deviceToken = ensureDeviceToken() const deviceToken = ensureDeviceToken()
@ -75,7 +85,7 @@ export function AuthProvider(props: {
setCachedReferralInfoForUser(current.user) setCachedReferralInfoForUser(current.user)
} else { } else {
// User logged out; reset to null // User logged out; reset to null
deleteTokenCookies() setUserCookie(undefined)
setAuthUser(null) setAuthUser(null)
localStorage.removeItem(CACHED_USER_KEY) localStorage.removeItem(CACHED_USER_KEY)
} }

View File

@ -1,74 +0,0 @@
import { PROJECT_ID } from 'common/envs/constants'
import { setCookie, getCookies } from '../util/cookie'
import { IncomingMessage, ServerResponse } from 'http'
const ONE_HOUR_SECS = 60 * 60
const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
const TOKEN_KINDS = ['refresh', 'id', 'custom'] as const
const TOKEN_AGES = {
id: ONE_HOUR_SECS,
refresh: TEN_YEARS_SECS,
custom: ONE_HOUR_SECS,
} as const
export type TokenKind = typeof TOKEN_KINDS[number]
const getAuthCookieName = (kind: TokenKind) => {
const suffix = `${PROJECT_ID}_${kind}`.toUpperCase().replace(/-/g, '_')
return `FIREBASE_TOKEN_${suffix}`
}
const COOKIE_NAMES = Object.fromEntries(
TOKEN_KINDS.map((k) => [k, getAuthCookieName(k)])
) as Record<TokenKind, string>
const getCookieDataIsomorphic = (req?: IncomingMessage) => {
if (req != null) {
return req.headers.cookie ?? ''
} else if (document != null) {
return document.cookie
} else {
throw new Error(
'Neither request nor document is available; no way to get cookies.'
)
}
}
const setCookieDataIsomorphic = (cookies: string[], res?: ServerResponse) => {
if (res != null) {
res.setHeader('Set-Cookie', cookies)
} else if (document != null) {
for (const ck of cookies) {
document.cookie = ck
}
} else {
throw new Error(
'Neither response nor document is available; no way to set cookies.'
)
}
}
export const getTokensFromCookies = (req?: IncomingMessage) => {
const cookies = getCookies(getCookieDataIsomorphic(req))
return Object.fromEntries(
TOKEN_KINDS.map((k) => [k, cookies[COOKIE_NAMES[k]]])
) as Partial<Record<TokenKind, string>>
}
export const setTokenCookies = (
cookies: Partial<Record<TokenKind, string | undefined>>,
res?: ServerResponse
) => {
const data = TOKEN_KINDS.filter((k) => k in cookies).map((k) => {
const maxAge = cookies[k] ? TOKEN_AGES[k as TokenKind] : 0
return setCookie(COOKIE_NAMES[k], cookies[k] ?? '', [
['path', '/'],
['max-age', maxAge.toString()],
['samesite', 'lax'],
['secure'],
])
})
setCookieDataIsomorphic(data, res)
}
export const deleteTokenCookies = (res?: ServerResponse) =>
setTokenCookies({ id: undefined, refresh: undefined, custom: undefined }, res)

View File

@ -1,165 +1,81 @@
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 { Auth as FirebaseAuth, User as FirebaseUser } from 'firebase/auth'
import { getFunctionUrl } from 'common/api' import { AUTH_COOKIE_NAME } from 'common/envs/constants'
import { UserCredential } from 'firebase/auth' import { getCookies } from 'web/lib/util/cookie'
import {
getTokensFromCookies,
setTokenCookies,
deleteTokenCookies,
} from './auth'
import { import {
GetServerSideProps, GetServerSideProps,
GetServerSidePropsContext, GetServerSidePropsContext,
GetServerSidePropsResult, GetServerSidePropsResult,
} from 'next' } from 'next'
// server firebase SDK
import * as admin from 'firebase-admin'
// client firebase SDK // client firebase SDK
import { app as clientApp } from './init' import { app as clientApp } from './init'
import { getAuth, signInWithCustomToken } from 'firebase/auth' import { getAuth, updateCurrentUser } from 'firebase/auth'
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 { 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 = {
req: IncomingMessage req: IncomingMessage
res: ServerResponse res: ServerResponse
} }
const authAndRefreshTokens = async (ctx: RequestContext) => { // The Firebase SDK doesn't really support persisting the logged-in state between
const adminAuth = (await ensureApp()).auth() // devices, or anything like that. To get it from the client to the server:
const clientAuth = getAuth(clientApp) //
console.debug('Initialized Firebase auth libraries.') // 1. We pack up the user by calling (the undocumented) User.toJSON(). This is the
// same way the Firebase SDK saves it to disk, so it's gonna have the right stuff.
//
// 2. We put it into a cookie and read the cookie out here.
//
// 3. We use the Firebase "persistence manager" to write the cookie value into the persistent
// store on the server (an in-memory store), just as if the SDK had saved the user itself.
//
// 4. We ask the persistence manager for the current user, which reads what we just wrote,
// and creates a real puffed-up internal user object from the serialized user.
//
// 5. We set that user to be the current Firebase user in the SDK.
//
// 6. We ask for the ID token, which will refresh it if necessary (i.e. if this cookie
// is from an old browser session), so that we know the SDK is prepared to do real
// Firebase queries.
//
// This strategy should be robust, since it's repurposing Firebase's internal persistence
// machinery, but the details may eventually need updating for new versions of the SDK.
//
// References:
// Persistence manager: https://github.com/firebase/firebase-js-sdk/blob/39f4635ebc07316661324145f1b8c27f9bd7aedb/packages/auth/src/core/persistence/persistence_user_manager.ts#L64
// Token manager: https://github.com/firebase/firebase-js-sdk/blob/39f4635ebc07316661324145f1b8c27f9bd7aedb/packages/auth/src/core/user/token_manager.ts#L76
let { id, refresh, custom } = getTokensFromCookies(ctx.req) interface FirebaseAuthInternal extends FirebaseAuth {
persistenceManager: {
// step 0: if you have no refresh token you are logged out fullUserKey: string
if (refresh == null) { getCurrentUser: () => Promise<FirebaseUser | null>
console.debug('User is unauthenticated.') persistence: {
return null _set: (k: string, obj: Record<string, unknown>) => Promise<void>
}
console.debug('User may be authenticated; checking cookies.')
// step 1: given a valid refresh token, ensure a valid ID token
if (id != null) {
// if they have an ID token, throw it out if it's invalid/expired
try {
await adminAuth.verifyIdToken(id)
console.debug('Verified ID token.')
} catch {
id = undefined
console.debug('Invalid existing ID token.')
} }
} }
if (id == null) {
// ask for a new one from google using the refresh token
try {
const resp = await requestFirebaseIdToken(refresh)
console.debug('Obtained fresh ID token from Firebase.')
id = resp.id_token
refresh = resp.refresh_token
} catch (e) {
// big unexpected problem -- functionally, they are not logged in
console.error(e)
return null
}
}
// step 2: given a valid ID token, ensure a valid custom token, and sign in
// to the client SDK with the custom token
if (custom != null) {
// sign in with this token, or throw it out if it's invalid/expired
try {
const creds = await signInWithCustomToken(clientAuth, custom)
console.debug('Signed in with custom token.')
return { creds, id, refresh, custom }
} catch {
custom = undefined
console.debug('Invalid existing custom token.')
}
}
if (custom == null) {
// ask for a new one from our cloud functions using the ID token, then sign in
try {
const resp = await requestManifoldCustomToken(id)
console.debug('Obtained fresh custom token from backend.')
custom = resp.token
const creds = await signInWithCustomToken(clientAuth, custom)
console.debug('Signed in with custom token.')
return { creds, id, refresh, custom }
} catch (e) {
// big unexpected problem -- functionally, they are not logged in
console.error(e)
return null
}
}
return null
} }
export const authenticateOnServer = async (ctx: RequestContext) => { export const authenticateOnServer = async (ctx: RequestContext) => {
console.debug('Server authentication sequence starting.') const user = getCookies(ctx.req.headers.cookie ?? '')[AUTH_COOKIE_NAME]
const tokens = await authAndRefreshTokens(ctx) if (user == null) {
console.debug('Finished checking and refreshing tokens.') console.debug('User is unauthenticated.')
const creds = tokens?.creds return null
try { }
if (tokens == null) { try {
deleteTokenCookies(ctx.res) const deserializedUser = JSON.parse(user)
console.debug('Not logged in; cleared token cookies.') const clientAuth = getAuth(clientApp) as FirebaseAuthInternal
} else { const persistenceManager = clientAuth.persistenceManager
setTokenCookies(tokens, ctx.res) const persistence = persistenceManager.persistence
console.debug('Logged in; set current token cookies.') await persistence._set(persistenceManager.fullUserKey, deserializedUser)
} // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
} catch (e) { const fbUser = (await persistenceManager.getCurrentUser())!
// definitely not supposed to happen, but let's be maximally robust await fbUser.getIdToken() // forces a refresh if necessary
console.error(e) await updateCurrentUser(clientAuth, fbUser)
console.debug('Signed in with user from cookie.')
return fbUser
} catch (e) {
console.error(e)
return null
} }
return creds ?? null
} }
// note that we might want to define these types more generically if we want better // note that we might want to define these types more generically if we want better
@ -167,7 +83,7 @@ export const authenticateOnServer = async (ctx: RequestContext) => {
type GetServerSidePropsAuthed<P> = ( type GetServerSidePropsAuthed<P> = (
context: GetServerSidePropsContext, context: GetServerSidePropsContext,
creds: UserCredential creds: FirebaseUser
) => Promise<GetServerSidePropsResult<P>> ) => Promise<GetServerSidePropsResult<P>>
export const redirectIfLoggedIn = <P extends { [k: string]: any }>( export const redirectIfLoggedIn = <P extends { [k: string]: any }>(

View File

@ -17,7 +17,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
const creds = await authenticateOnServer(ctx) const creds = await authenticateOnServer(ctx)
const username = ctx.params!.username as string // eslint-disable-line @typescript-eslint/no-non-null-assertion const username = ctx.params!.username as string // eslint-disable-line @typescript-eslint/no-non-null-assertion
const [auth, user] = (await Promise.all([ const [auth, user] = (await Promise.all([
creds != null ? getUserAndPrivateUser(creds.user.uid) : null, creds != null ? getUserAndPrivateUser(creds.uid) : null,
getUserByUsername(username), getUserByUsername(username),
])) as [UserAndPrivateUser | null, User | null] ])) as [UserAndPrivateUser | null, User | null]
return { props: { auth, user } } return { props: { auth, user } }

View File

@ -36,7 +36,7 @@ import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-an
import { MINUTE_MS } from 'common/util/time' import { MINUTE_MS } from 'common/util/time'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } } return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
}) })
type NewQuestionParams = { type NewQuestionParams = {

View File

@ -28,7 +28,7 @@ import { Row } from 'web/components/layout/row'
export const getServerSideProps: GetServerSideProps = async (ctx) => { export const getServerSideProps: GetServerSideProps = async (ctx) => {
const creds = await authenticateOnServer(ctx) const creds = await authenticateOnServer(ctx)
const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null const auth = creds ? await getUserAndPrivateUser(creds.uid) : null
return { props: { auth } } return { props: { auth } }
} }

View File

@ -15,7 +15,7 @@ import { usePrefetch } from 'web/hooks/use-prefetch'
export const getServerSideProps: GetServerSideProps = async (ctx) => { export const getServerSideProps: GetServerSideProps = async (ctx) => {
const creds = await authenticateOnServer(ctx) const creds = await authenticateOnServer(ctx)
const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null const auth = creds ? await getUserAndPrivateUser(creds.uid) : null
return { props: { auth } } return { props: { auth } }
} }

View File

@ -31,7 +31,7 @@ import { UserLink } from 'web/components/user-link'
const LINKS_PER_PAGE = 24 const LINKS_PER_PAGE = 24
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } } return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
}) })
export function getManalinkUrl(slug: string) { export function getManalinkUrl(slug: string) {

View File

@ -23,7 +23,7 @@ import Textarea from 'react-expanding-textarea'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } } return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
}) })
function EditUserField(props: { function EditUserField(props: {