manifold/web/lib/firebase/server-auth.ts
Marshall Polaris 456d9398a1
Revamp a lot of stuff on the user page to make it usably efficient (#751)
* Load bets and comments tabs data on user page independently

* Implement basic pagination on profile comments list

* Tweak server auth to return `null` instead of `undefined`

* Switch to SSR for user page

* Fix lint

* Fix broken contract fetching in user bets list

* Tidying
2022-08-12 20:42:58 -07:00

214 lines
6.5 KiB
TypeScript

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 {
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 }
}
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.')
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.')
}
}
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
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.')
}
} catch (e) {
// definitely not supposed to happen, but let's be maximally robust
console.error(e)
}
return creds ?? null
}
// 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) => {
const creds = await authenticateOnServer(ctx)
if (creds == null) {
if (fn == null) {
return { props: {} }
} else {
const props = await fn(ctx)
console.debug('Finished getting initial props for rendering.')
return props
}
} else {
console.debug(`Redirecting to ${dest}.`)
return { redirect: { destination: dest, permanent: false } }
}
}
}
export const redirectIfLoggedOut = <P>(
dest: string,
fn?: GetServerSidePropsAuthed<P>
) => {
return async (ctx: GetServerSidePropsContext) => {
const creds = await authenticateOnServer(ctx)
if (creds == null) {
console.debug(`Redirecting to ${dest}.`)
return { redirect: { destination: dest, permanent: false } }
} else {
if (fn == null) {
return { props: {} }
} else {
const props = await fn(ctx, creds)
console.debug('Finished getting initial props for rendering.')
return props
}
}
}
}