diff --git a/web/lib/firebase/auth.ts b/web/lib/firebase/auth.ts index b6daea6e..bb4cbcc4 100644 --- a/web/lib/firebase/auth.ts +++ b/web/lib/firebase/auth.ts @@ -2,7 +2,7 @@ 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 +const TOKEN_KINDS = ['refresh', 'id', 'custom'] as const type TokenKind = typeof TOKEN_KINDS[number] const getAuthCookieName = (kind: TokenKind) => { @@ -12,6 +12,9 @@ const getAuthCookieName = (kind: TokenKind) => { const ID_COOKIE_NAME = getAuthCookieName('id') const REFRESH_COOKIE_NAME = getAuthCookieName('refresh') +const CUSTOM_COOKIE_NAME = getAuthCookieName('custom') +const ONE_HOUR_SECS = 60 * 60 +const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10 export const getAuthCookies = (request?: IncomingMessage) => { const data = request != null ? request.headers.cookie ?? '' : document.cookie @@ -19,24 +22,31 @@ export const getAuthCookies = (request?: IncomingMessage) => { return { idToken: cookies[ID_COOKIE_NAME] as string | undefined, refreshToken: cookies[REFRESH_COOKIE_NAME] as string | undefined, + customToken: cookies[CUSTOM_COOKIE_NAME] as string | undefined, } } export const setAuthCookies = ( idToken?: string, refreshToken?: string, + customToken?: string, response?: ServerResponse ) => { - // these tokens last an hour - const idMaxAge = idToken != null ? 60 * 60 : 0 + const idMaxAge = idToken != null ? ONE_HOUR_SECS : 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 customMaxAge = customToken != null ? ONE_HOUR_SECS : 0 + const customCookie = setCookie(CUSTOM_COOKIE_NAME, customToken ?? '', [ + ['path', '/'], + ['max-age', customMaxAge.toString()], + ['samesite', 'lax'], + ['secure'], + ]) + const refreshMaxAge = refreshToken != null ? TEN_YEARS_SECS : 0 const refreshCookie = setCookie(REFRESH_COOKIE_NAME, refreshToken ?? '', [ ['path', '/'], ['max-age', refreshMaxAge.toString()], @@ -44,10 +54,11 @@ export const setAuthCookies = ( ['secure'], ]) if (response != null) { - response.setHeader('Set-Cookie', [idCookie, refreshCookie]) + response.setHeader('Set-Cookie', [idCookie, refreshCookie, customCookie]) } else { document.cookie = idCookie document.cookie = refreshCookie + document.cookie = customCookie } } diff --git a/web/lib/firebase/server-auth.ts b/web/lib/firebase/server-auth.ts index 47eadb45..1dd74c9c 100644 --- a/web/lib/firebase/server-auth.ts +++ b/web/lib/firebase/server-auth.ts @@ -1,9 +1,21 @@ -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' +import { getFunctionUrl } from 'common/api' +import { UserCredential } from 'firebase/auth' +import { getAuthCookies, setAuthCookies, deleteAuthCookies } 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, @@ -33,7 +45,21 @@ const requestFirebaseIdToken = async (refreshToken: string) => { if (!result.ok) { throw new Error(`Could not refresh ID token: ${await result.text()}`) } - return (await result.json()) as any + 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 = { @@ -41,39 +67,99 @@ type RequestContext = { res: ServerResponse } -export const getServerAuthenticatedUid = async (ctx: RequestContext) => { - const app = await ensureApp() - const auth = app.auth() - const { idToken, refreshToken } = getAuthCookies(ctx.req) +const authAndRefreshTokens = async (ctx: RequestContext) => { + const adminAuth = (await ensureApp()).auth() + const clientAuth = getAuth(clientApp) + let { idToken, refreshToken, customToken } = 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. + // step 0: if you have no refresh token you are logged out + if (refreshToken == null) { + return undefined + } + + // step 1: given a valid refresh token, ensure a valid ID token if (idToken != null) { + // if they have an ID token, throw it out if it's invalid/expired try { - return (await auth.verifyIdToken(idToken))?.uid + await adminAuth.verifyIdToken(idToken) } catch { - // plausibly expired; try the refresh token, if it's present + idToken = undefined } } - if (refreshToken != null) { + if (idToken == null) { + // ask for a new one from google using the refresh token try { const resp = await requestFirebaseIdToken(refreshToken) - setAuthCookies(resp.id_token, resp.refresh_token, ctx.res) - return (await auth.verifyIdToken(resp.id_token))?.uid + idToken = resp.id_token + refreshToken = resp.refresh_token } catch (e) { - // this is a big unexpected problem -- either their cookies are corrupt - // or the refresh token API is down. functionally, they are not logged in + // 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 (customToken != null) { + // sign in with this token, or throw it out if it's invalid/expired + try { + return { + creds: await signInWithCustomToken(clientAuth, customToken), + idToken, + refreshToken, + customToken, + } + } catch { + customToken = undefined + } + } + if (customToken == null) { + // ask for a new one from our cloud functions using the ID token, then sign in + try { + const resp = await requestManifoldCustomToken(idToken) + customToken = resp.token + return { + creds: await signInWithCustomToken(clientAuth, customToken), + idToken, + refreshToken, + customToken, + } + } catch (e) { + // big unexpected problem -- functionally, they are not logged in + console.error(e) + return undefined } } - return undefined } -export const redirectIfLoggedIn = (dest: string, fn?: GetServerSideProps) => { +export const authenticateOnServer = async (ctx: RequestContext) => { + const tokens = await authAndRefreshTokens(ctx) + if (tokens == null) { + deleteAuthCookies() + return undefined + } else { + const { creds, idToken, refreshToken, customToken } = tokens + setAuthCookies(idToken, refreshToken, customToken, ctx.res) + 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
= (
+ context: GetServerSidePropsContext,
+ creds: UserCredential
+) => Promise (
+ dest: string,
+ fn?: GetServerSideProps
+) => {
return async (ctx: GetServerSidePropsContext) => {
- const uid = await getServerAuthenticatedUid(ctx)
- if (uid == null) {
+ const creds = await authenticateOnServer(ctx)
+ if (creds == null) {
return fn != null ? await fn(ctx) : { props: {} }
} else {
return { redirect: { destination: dest, permanent: false } }
@@ -81,13 +167,16 @@ export const redirectIfLoggedIn = (dest: string, fn?: GetServerSideProps) => {
}
}
-export const redirectIfLoggedOut = (dest: string, fn?: GetServerSideProps) => {
+export const redirectIfLoggedOut = (
+ dest: string,
+ fn?: GetServerSidePropsAuthed
+) => {
return async (ctx: GetServerSidePropsContext) => {
- const uid = await getServerAuthenticatedUid(ctx)
- if (uid == null) {
+ const creds = await authenticateOnServer(ctx)
+ if (creds == null) {
return { redirect: { destination: dest, permanent: false } }
} else {
- return fn != null ? await fn(ctx) : { props: {} }
+ return fn != null ? await fn(ctx, creds) : { props: {} }
}
}
}
diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx
index 625c7c17..89ffb5d9 100644
--- a/web/pages/notifications.tsx
+++ b/web/pages/notifications.tsx
@@ -40,10 +40,7 @@ import { track } from '@amplitude/analytics-browser'
import { Pagination } from 'web/components/pagination'
import { useWindowSize } from 'web/hooks/use-window-size'
import { safeLocalStorage } from 'web/lib/util/local'
-import {
- getServerAuthenticatedUid,
- redirectIfLoggedOut,
-} from 'web/lib/firebase/server-auth'
+import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { SiteLink } from 'web/components/site-link'
import { NotificationSettings } from 'web/components/NotificationSettings'
@@ -51,12 +48,8 @@ export const NOTIFICATIONS_PER_PAGE = 30
const MULTIPLE_USERS_KEY = 'multipleUsers'
const HIGHLIGHT_CLASS = 'bg-indigo-50'
-export const getServerSideProps = redirectIfLoggedOut('/', async (ctx) => {
- const uid = await getServerAuthenticatedUid(ctx)
- if (!uid) {
- return { props: { user: null } }
- }
- const user = await getUser(uid)
+export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
+ const user = await getUser(creds.user.uid)
return { props: { user } }
})