manifold/web/lib/firebase/server-auth.ts
Marshall Polaris d43b9e1836
Vercel auth phase 2 (#714)
* Add cloud function to get custom token from API auth

* Use custom token to authenticate Firebase SDK on server

* Make sure getcustomtoken cloud function is fast

* Make server auth code maximally robust

* Refactor cookie code, make set and delete more flexible
2022-08-05 20:49:29 -07:00

191 lines
5.4 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)
let { id, refresh, custom } = getTokensFromCookies(ctx.req)
// step 0: if you have no refresh token you are logged out
if (refresh == null) {
return undefined
}
// 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)
} catch {
id = undefined
}
}
if (id == null) {
// ask for a new one from google using the refresh token
try {
const resp = await requestFirebaseIdToken(refresh)
id = resp.id_token
refresh = resp.refresh_token
} catch (e) {
// big unexpected problem -- functionally, they are not logged in
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 (custom != null) {
// sign in with this token, or throw it out if it's invalid/expired
try {
return {
creds: await signInWithCustomToken(clientAuth, custom),
id,
refresh,
custom,
}
} catch {
custom = undefined
}
}
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)
custom = resp.token
return {
creds: await signInWithCustomToken(clientAuth, custom),
id,
refresh,
custom,
}
} catch (e) {
// big unexpected problem -- functionally, they are not logged in
console.error(e)
return undefined
}
}
}
export const authenticateOnServer = async (ctx: RequestContext) => {
const tokens = await authAndRefreshTokens(ctx)
const creds = tokens?.creds
try {
if (tokens == null) {
deleteTokenCookies(ctx.res)
} else {
setTokenCookies(tokens, ctx.res)
}
} catch (e) {
// definitely not supposed to happen, but let's be maximally robust
console.error(e)
}
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) => {
const creds = await authenticateOnServer(ctx)
if (creds == null) {
return fn != null ? await fn(ctx) : { props: {} }
} else {
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) {
return { redirect: { destination: dest, permanent: false } }
} else {
return fn != null ? await fn(ctx, creds) : { props: {} }
}
}
}