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 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
export const CORS_ORIGIN_MANIFOLD = new RegExp(
'^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$'

View File

@ -8,17 +8,20 @@ import {
getUserAndPrivateUser,
setCachedReferralInfoForUser,
} from 'web/lib/firebase/users'
import { deleteTokenCookies, setTokenCookies } from 'web/lib/firebase/auth'
import { createUser } from 'web/lib/firebase/api'
import { randomString } from 'common/util/random'
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
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
// the user is not logged in (null), or we know the user is logged in.
type AuthUser = undefined | null | UserAndPrivateUser
const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
// Proxy localStorage in case it's not available (eg in incognito iframe)
const localStorage =
typeof window !== 'undefined'
@ -38,6 +41,16 @@ const ensureDeviceToken = () => {
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 function AuthProvider(props: {
@ -59,10 +72,7 @@ export function AuthProvider(props: {
auth,
async (fbUser) => {
if (fbUser) {
setTokenCookies({
id: await fbUser.getIdToken(),
refresh: fbUser.refreshToken,
})
setUserCookie(JSON.stringify(fbUser.toJSON()))
let current = await getUserAndPrivateUser(fbUser.uid)
if (!current.user || !current.privateUser) {
const deviceToken = ensureDeviceToken()
@ -75,7 +85,7 @@ export function AuthProvider(props: {
setCachedReferralInfoForUser(current.user)
} else {
// User logged out; reset to null
deleteTokenCookies()
setUserCookie(undefined)
setAuthUser(null)
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 { FIREBASE_CONFIG, PROJECT_ID } from 'common/envs/constants'
import { getFunctionUrl } from 'common/api'
import { UserCredential } from 'firebase/auth'
import {
getTokensFromCookies,
setTokenCookies,
deleteTokenCookies,
} from './auth'
import { Auth as FirebaseAuth, User as FirebaseUser } from 'firebase/auth'
import { AUTH_COOKIE_NAME } from 'common/envs/constants'
import { getCookies } from 'web/lib/util/cookie'
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 () => {
// 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 }
}
import { getAuth, updateCurrentUser } from 'firebase/auth'
type RequestContext = {
req: IncomingMessage
res: ServerResponse
}
const authAndRefreshTokens = async (ctx: RequestContext) => {
const adminAuth = (await ensureApp()).auth()
const clientAuth = getAuth(clientApp)
console.debug('Initialized Firebase auth libraries.')
// The Firebase SDK doesn't really support persisting the logged-in state between
// devices, or anything like that. To get it from the client to the server:
//
// 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)
// step 0: if you have no refresh token you are logged out
if (refresh == null) {
console.debug('User is unauthenticated.')
return null
}
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.')
interface FirebaseAuthInternal extends FirebaseAuth {
persistenceManager: {
fullUserKey: string
getCurrentUser: () => Promise<FirebaseUser | null>
persistence: {
_set: (k: string, obj: Record<string, unknown>) => Promise<void>
}
}
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) => {
console.debug('Server authentication sequence starting.')
const tokens = await authAndRefreshTokens(ctx)
console.debug('Finished checking and refreshing tokens.')
const creds = tokens?.creds
const user = getCookies(ctx.req.headers.cookie ?? '')[AUTH_COOKIE_NAME]
if (user == null) {
console.debug('User is unauthenticated.')
return null
}
try {
if (tokens == null) {
deleteTokenCookies(ctx.res)
console.debug('Not logged in; cleared token cookies.')
} else {
setTokenCookies(tokens, ctx.res)
console.debug('Logged in; set current token cookies.')
}
const deserializedUser = JSON.parse(user)
const clientAuth = getAuth(clientApp) as FirebaseAuthInternal
const persistenceManager = clientAuth.persistenceManager
const persistence = persistenceManager.persistence
await persistence._set(persistenceManager.fullUserKey, deserializedUser)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const fbUser = (await persistenceManager.getCurrentUser())!
await fbUser.getIdToken() // forces a refresh if necessary
await updateCurrentUser(clientAuth, fbUser)
console.debug('Signed in with user from cookie.')
return fbUser
} catch (e) {
// definitely not supposed to happen, but let's be maximally robust
console.error(e)
return null
}
return creds ?? null
}
// 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> = (
context: GetServerSidePropsContext,
creds: UserCredential
creds: FirebaseUser
) => Promise<GetServerSidePropsResult<P>>
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 username = ctx.params!.username as string // eslint-disable-line @typescript-eslint/no-non-null-assertion
const [auth, user] = (await Promise.all([
creds != null ? getUserAndPrivateUser(creds.user.uid) : null,
creds != null ? getUserAndPrivateUser(creds.uid) : null,
getUserByUsername(username),
])) as [UserAndPrivateUser | null, User | null]
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'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } }
return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
})
type NewQuestionParams = {

View File

@ -28,7 +28,7 @@ import { Row } from 'web/components/layout/row'
export const getServerSideProps: GetServerSideProps = async (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 } }
}

View File

@ -15,7 +15,7 @@ import { usePrefetch } from 'web/hooks/use-prefetch'
export const getServerSideProps: GetServerSideProps = async (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 } }
}

View File

@ -31,7 +31,7 @@ import { UserLink } from 'web/components/user-link'
const LINKS_PER_PAGE = 24
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) {

View File

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