Skip to content

Commit

Permalink
feat(auth): handle ssr
Browse files Browse the repository at this point in the history
  • Loading branch information
posva committed Dec 8, 2022
1 parent df3c235 commit 567fd12
Show file tree
Hide file tree
Showing 10 changed files with 191 additions and 53 deletions.
30 changes: 20 additions & 10 deletions packages/nuxt/playground/pages/authentication.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<script lang="ts" setup>
import {
createUserWithEmailAndPassword,
EmailAuthProvider,
Expand All @@ -23,7 +22,9 @@ import {
} from 'vuefire'
import { googleAuthProvider } from '~/helpers/auth'
const auth = useFirebaseAuth()
// auth is null on the server but it's fine as long as we don't use it. So we force the type to be non-null here because
// auth is only used within methods that are only called on the client
const auth = useFirebaseAuth()!
const user = useCurrentUser()
let credential: AuthCredential | null = null
Expand Down Expand Up @@ -68,11 +69,13 @@ function signinRedirect() {
signInWithRedirect(auth, googleAuthProvider)
}
getRedirectResult(auth).then((creds) => {
console.log('got creds', creds)
if (creds) {
// credential = creds.user.
}
onMounted(() => {
getRedirectResult(auth).then((creds) => {
console.log('got creds', creds)
if (creds) {
// credential = creds.user.
}
})
})
</script>

Expand Down Expand Up @@ -125,12 +128,19 @@ getRedirectResult(auth).then((creds) => {

<p v-if="user">
Name: {{ user.displayName }} <br>
<img v-if="user.photoURL" :src="user.photoURL" referrerpolicy="no-referrer">
<img
v-if="user.photoURL"
:src="user.photoURL"
referrerpolicy="no-referrer"
>
</p>

<hr>

<p>Current User:</p>
<pre>{{ user }}</pre>
<!-- this is for debug purposes only, displaying it on the server would create a hydration mismatch -->
<ClientOnly>
<p>Current User:</p>
<pre>{{ user }}</pre>
</ClientOnly>
</main>
</template>
24 changes: 24 additions & 0 deletions packages/nuxt/playground/pages/firestore-secure-doc.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { doc, getDoc } from 'firebase/firestore'
import { useCurrentUser, useDocument, useFirestore, usePendingPromises } from 'vuefire'
import { ref } from 'vue'
const db = useFirestore()
const user = useCurrentUser()
console.log(user.value?.uid)
const secretRef = computed(() => user.value ? doc(db, 'secrets', user.value.uid) : null)
const secret = useDocument(secretRef)
</script>

<template>
<div>
<p v-if="!user">
Log in in the authentication page to test this.
</p>
<template v-else>
<p>Secret Data for user {{ user.displayName }} ({{ user.uid }})</p>
<pre>{{ secret }}</pre>
</template>
</div>
</template>
21 changes: 19 additions & 2 deletions packages/nuxt/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ import {
} from '@nuxt/kit'
import type { NuxtModule } from '@nuxt/schema'
// cannot import from firebase-admin because the build fails, maybe a nuxt bug?
import type { FirebaseOptions } from '@firebase/app-types'
import type { AppOptions, ServiceAccount } from 'firebase-admin'
import type { FirebaseApp, FirebaseOptions } from '@firebase/app-types'
import type {
AppOptions,
ServiceAccount,
App as FirebaseAdminApp,
} from 'firebase-admin/app'
import type { NuxtVueFireAppCheckOptions } from './runtime/app-check'

export interface VueFireNuxtModuleOptions {
Expand Down Expand Up @@ -172,3 +176,16 @@ declare module '@nuxt/schema' {
}
}
}

declare module '#app' {
interface NuxtApp {
$firebaseApp: FirebaseApp
$adminApp: FirebaseAdminApp
}
}
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$firebaseApp: FirebaseApp
$adminApp: FirebaseAdminApp
}
}
2 changes: 1 addition & 1 deletion packages/nuxt/src/runtime/auth/api.session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,4 @@ export default defineEventHandler(async (event) => {

// TODO: customizable defaults
export const AUTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 5 * 1_000
export const AUTH_COOKIE_NAME = '_vuefire_auth'
export const AUTH_COOKIE_NAME = 'csrfToken'
14 changes: 7 additions & 7 deletions packages/nuxt/src/runtime/auth/plugin.client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { FirebaseApp } from '@firebase/app-types'
import type { FirebaseError, FirebaseApp } from 'firebase/app'
import { getAuth, onIdTokenChanged } from 'firebase/auth'
import { VueFireAuth } from 'vuefire'
import { defineNuxtPlugin } from '#app'
import { defineNuxtPlugin, showError } from '#app'

/**
* Setups VueFireAuth and automatically mints a cookie based auth session. On the server, it reads the cookie to
Expand All @@ -10,18 +10,18 @@ import { defineNuxtPlugin } from '#app'
export default defineNuxtPlugin((nuxtApp) => {
const firebaseApp = nuxtApp.$firebaseApp as FirebaseApp

// TODO: provide the server user?
VueFireAuth()(firebaseApp, nuxtApp.vueApp)
VueFireAuth(nuxtApp.payload.vuefireUser)(firebaseApp, nuxtApp.vueApp)
const auth = getAuth(firebaseApp)
// send a post request to the server when auth state changes to mint a cookie
onIdTokenChanged(auth, async (user) => {
const jwtToken = await user?.getIdToken()
// console.log('📚 updating server cookie with', jwtToken)
// TODO: error handling: should we call showError() in dev only?
await $fetch('/api/_vuefire/auth', {
$fetch('/api/_vuefire/auth', {
method: 'POST',
// if the token is undefined, the server will delete the cookie
body: { token: jwtToken },
}).catch((reason: FirebaseError) => {
// there is no need to return a rejected error as `onIdTokenChanged` won't use it
showError(reason)
})
})
})
41 changes: 26 additions & 15 deletions packages/nuxt/src/runtime/auth/plugin.server.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import type { FirebaseApp } from '@firebase/app-types'
import type { App as AdminApp } from 'firebase-admin/app'
import { getAuth as getAdminAuth } from 'firebase-admin/auth'
import {
getAuth,
onIdTokenChanged,
signInWithCredential,
AuthCredential,
} from 'firebase/auth'
import { getCurrentUser, VueFireAuth } from 'vuefire'
import { getAuth as getAdminAuth, UserRecord } from 'firebase-admin/auth'
import { VueFireAuthServer } from 'vuefire/server'
import { getCookie } from 'h3'
// FirebaseError is an interface here but is a class in firebase/app
import type { FirebaseError } from 'firebase-admin'
import { AUTH_COOKIE_NAME } from './api.session'
import { defineNuxtPlugin, useRequestEvent } from '#app'

Expand All @@ -20,18 +16,33 @@ export default defineNuxtPlugin(async (nuxtApp) => {

const event = useRequestEvent()
const token = getCookie(event, AUTH_COOKIE_NAME)
let user: UserRecord | undefined

if (token) {
const adminApp = nuxtApp.$adminApp as AdminApp
const auth = getAdminAuth(adminApp)

const decodedToken = await auth.verifyIdToken(token)
const user = await auth.getUser(decodedToken.uid)

// signInWithCredential(getAuth(firebaseApp)))
try {
const decodedToken = await auth.verifyIdToken(token)
user = await auth.getUser(decodedToken.uid)
} catch (err) {
// TODO: some errors should probably go higher
// ignore the error and consider the user as not logged in
if (isFirebaseError(err) && err.code === 'auth/id-token-expired') {
// the error is fine, the user is not logged in
} else {
// ignore the error and consider the user as not logged in
console.error(err)
}
}
}

console.log('🔥 setting user', user)
nuxtApp.payload.vuefireUser = user?.toJSON()

// provide user
}
// provide the user data to the app during ssr
VueFireAuthServer(firebaseApp, nuxtApp.vueApp, user)
})

function isFirebaseError(err: any): err is FirebaseError {
return err != null && 'code' in err
}
20 changes: 5 additions & 15 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,33 +28,23 @@ export {
* })
* ```
*/
export function VueFireAuth(_app?: never) {
// ^
// app: never to prevent the user from just passing `VueFireAuth` without calling the function

// TODO: Hopefully we should be able to remove this with the next Vue release
if (process.env.NODE_ENV !== 'production') {
if (_app != null) {
console.warn(`Did you forget to call the VueFireAuth function? It should look like
modules: [VueFireAuth()]`)
}
}

export function VueFireAuth(initialUser?: _Nullable<User>) {
return (firebaseApp: FirebaseApp, app: App) => {
const user = getGlobalScope(firebaseApp, app).run(() =>
ref<_Nullable<User>>()
ref<_Nullable<User>>(initialUser)
)!
// this should only be on client
authUserMap.set(firebaseApp, user)
setupOnAuthStateChanged(user, firebaseApp)
}
}

/**
* Retrieves the Firebase Auth instance.
* Retrieves the Firebase Auth instance. Returns `null` on the server.
*
* @param name - name of the application
* @returns the Auth instance
*/
export function useFirebaseAuth(name?: string) {
return getAuth(useFirebaseApp(name))
return typeof window === 'undefined' ? null : getAuth(useFirebaseApp(name))
}
6 changes: 3 additions & 3 deletions src/auth/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
reauthenticateWithCredential,
AuthCredential,
} from 'firebase/auth'
import { inject, InjectionKey, Ref } from 'vue-demi'
import type { Ref } from 'vue-demi'
import { useFirebaseApp } from '../app'
import type { _MaybeRef, _Nullable } from '../shared'

Expand All @@ -19,8 +19,8 @@ import type { _MaybeRef, _Nullable } from '../shared'
export const authUserMap = new WeakMap<FirebaseApp, Ref<_Nullable<User>>>()

/**
* Returns a shallowRef of the currently authenticated user in the firebase app. The ref is null if no user is
* authenticated or when the user logs out. The ref is undefined when the user is not yet loaded.
* Returns a reactive variable of the currently authenticated user in the firebase app. The ref is null if no user is
* authenticated or when the user logs out. The ref is undefined when the user is not yet loaded. Note th
* @param name - name of the application
*/
export function useCurrentUser(name?: string) {
Expand Down
85 changes: 85 additions & 0 deletions src/server/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { FirebaseApp } from 'firebase/app'
import { User } from 'firebase/auth'
import { UserRecord } from 'firebase-admin/auth'
import { App, ref } from 'vue'
import { authUserMap } from '../auth/user'
import { getGlobalScope } from '../globals'
import { _Nullable } from '../shared'

export function VueFireAuthServer(
firebaseApp: FirebaseApp,
app: App,
userRecord: _Nullable<UserRecord>
) {
const user = getGlobalScope(firebaseApp, app).run(() =>
ref<_Nullable<User>>(createServerUser(userRecord))
)!
authUserMap.set(firebaseApp, user)
}

/**
* Creates a user object that is compatible with the client but will throw errors when its functions are used as they
* shouldn't be called within in the server.
*
* @param userRecord - user data from firebase-admin
*/
function createServerUser(userRecord: _Nullable<UserRecord>): _Nullable<User> {
if (!userRecord) return null
const user = userRecord.toJSON() as UserRecord

return {
...user,
// these seem to be type mismatches within firebase source code
tenantId: user.tenantId || null,
displayName: user.displayName || null,
photoURL: user.photoURL || null,
email: user.email || null,
phoneNumber: user.phoneNumber || null,

delete: InvalidServerFunction('delete'),
getIdToken: InvalidServerFunction('getIdToken'),
getIdTokenResult: InvalidServerFunction('getIdTokenResult'),
reload: InvalidServerFunction('reload'),
toJSON: InvalidServerFunction('toJSON'),
get isAnonymous() {
return warnInvalidServerGetter('isAnonymous', false)
},
get refreshToken() {
return warnInvalidServerGetter('refreshToken', '')
},
get providerId() {
return warnInvalidServerGetter('providerId', '')
},
}
}

// function helpers to warn on wrong usage on server

/**
* Creates a function that throws an error when called.
*
* @param name - name of the function
*/
function InvalidServerFunction(name: string) {
return () => {
throw new Error(
`The function User.${name}() is not available on the server.`
)
}
}

/**
* Creates a getter that warns when called and return a fallback value.
*
* @param name - name of the getter
* @param value - value to return
*/

function warnInvalidServerGetter<T>(name: string, value: T) {
console.warn(
`The getter User.${name} is not available on the server. It will return ${String(
value
)}.`
)
return value
}
1 change: 1 addition & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { VueFireAppCheckServer } from './app-check'
export { VueFireAuthServer } from './auth'

0 comments on commit 567fd12

Please sign in to comment.