Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to get users from Firebase auth based on custom claims?

I'm beginning to use custom claims in my Firebase project to implement a role-based authorization system to my app.

I'll have a firebase-admin script which is going to set {admin: true} for a specific user's uid. This will help me write better and clearer Firestore security rules.

admin.auth().setCustomUserClaims(uid, {admin: true})

So far, so good. My problem is that I'll also need a dashboard page to let me know which users are currently admins inside my app.

Basically I'll need a way to query/list users based on custom claims. Is there a way to do this?

From this answer, I can see that it's not possible to do this.

But maybe, Is there at least a way to inspect (using Firebase Console) the customUserClaims that were set to a specific user?

My current solution would be to store that information (the admins uid's) inside an admin-users collection in my Firestore and keep that information up-to-date with the any admin customClaims that I set or revoke. Can you think of a better solution?

like image 986
cbdeveloper Avatar asked Feb 27 '19 18:02

cbdeveloper


People also ask

What is Firebase custom claims?

Defining roles via Firebase Functions on user creation. In this example, custom claims are set on a user on creation using Cloud Functions. Custom claims can be added using Cloud Functions, and propagated immediately with Realtime Database. The function is called only on signup using an onCreate trigger.

What does Firebase auth () CurrentUser return?

If a user isn't signed in, CurrentUser returns null. Note: CurrentUser might also return null because the auth object has not finished initializing.


1 Answers

I solved this use case recently, by duplicating the custom claims as "roles" array field into the according firestore 'users/{uid}/private-user/{data}' documents. In my scenario I had to distinguish between two roles ("admin" and "superadmin"). The documents of the firestore 'users/' collection are public, and the documents of the 'users/{uid}/private-user/' collection are only accessible from the client side by the owning user and "superadmin" users, or via the firestore Admin SDK (server side) also only as "superadmin" user.

Additionally, I only wanted to allow "superadmin" users to add or remove "superadmin" or "admin" roles/claims; or to get a list of "superadmin" or "admin" users.

Data duplication is quite common in the NoSQL world, and is NOT considered as a bad practice.

Here is my code (Node.js/TypeScript)

First, the firebase cloud function implementation (requires Admin SDK) to add a custom user claim/role.

Note, that the "superadmin" validation line

await validateUserClaim(functionName, context, "superadmin")

must be removed until at least one "superadmin" has been created that can be used later on to add or remove additional roles/claims to users!

const functionName = "add-admin-user"

export default async (
  payload: string,
  context: CallableContext,
): Promise<void> => {
  try {
    validateAuthentication(functionName, context)
    validateEmailVerified(functionName, context)
    await validateUserClaim(functionName, context, "superadmin")
    const request = parseRequestPayload<AddAdminUserRoleRequest>(
      functionName,
      payload,
    )
    // Note, to remove a custom claim just use "{ [request.roleName]: null }"
    // as second input parameter.
    await admin
      .auth()
      .setCustomUserClaims(request.uid, { [request.roleName]: true })
    const userDoc = await db
      .collection(`users/${request.uid}/private-user`)
      .doc("data")
      .get()
    const roles = userDoc.data()?.roles ?? []
    if (roles.indexOf(request.roleName) === -1) {
      roles.push(request.roleName)
      db.collection(`users/${request.uid}/private-user`)
        .doc("data")
        .set({ roles }, { merge: true })
    }
  } catch (e) {
    throw logAndReturnHttpsError(
      "internal",
      `Firestore ${functionName} not executed. Failed to add 'admin' or ` +
      `'superadmin' claim to user. (${(<Error>e)?.message})`,
        `${functionName}/internal`,
      e,
     )
  }
}

Second, the firebase cloud function implementation (requires Admin SDK) that returns a list of "superadmin" or "admin" users.

const functionName = "get-admin-users"

export default async (
  payload: string,
  context: CallableContext,
): Promise<GetAdminUsersResponse> => {
  try {
    validateAuthentication(functionName, context)
    validateEmailVerified(functionName, context)
    await validateUserClaim(functionName, context, "superadmin")
    const request = parseRequestPayload<GetAdminUsersRequest>(
      functionName,
      payload,
    )
    const adminUserDocs = await db
      .collectionGroup("private-user")
      .where("roles", "array-contains", request.roleName)
      .get()

    const admins = adminUserDocs.docs.map((doc) => {
      return {
        uid: doc.data().uid,
        username: doc.data().username,
        email: doc.data().email,
        roleName: request.roleName,
      }
    })
    return { admins }
  } catch (e) {
    throw logAndReturnHttpsError(
      "internal",
      `Firestore ${functionName} not executed. Failed to query admin users. (${
        (<Error>e)?.message
      })`,
      `${functionName}/internal`,
      e,
    )
  }
}

And third, the validation helper functions (require the Admin SDK).

export type AdminRoles = "admin" | "superadmin"

export const validateAuthentication = (
  functionName: string,
  context: CallableContext,
): void => {
  if (!context.auth || !context.auth?.uid) {
    throw logAndReturnHttpsError(
      "unauthenticated",
      `Firestore ${functionName} not executed. User not authenticated.`,
      `${functionName}/unauthenticated`,
    )
  }
}

export const validateUserClaim = async (
  functionName: string,
  context: CallableContext,
  roleName: AdminRoles,
): Promise<void> => {
  if (context.auth?.uid) {
    const hasRole = await admin
      .auth()
      .getUser(context.auth?.uid)
      .then((userRecord) => {
        return !!userRecord.customClaims?.[roleName]
      })
    if (hasRole) {
      return
    }
  }
  throw logAndReturnHttpsError(
    "unauthenticated",
    `Firestore ${functionName} not executed. User not authenticated as ` +
      `'${roleName}'. `,
    `${functionName}/unauthenticated`,
  )
}

export const validateEmailVerified = async (
  functionName: string,
  context: CallableContext,
): Promise<void> => {
  if (context.auth?.uid) {
    const userRecord = await auth.getUser(context.auth?.uid)
    if (!userRecord.emailVerified) {
      throw logAndReturnHttpsError(
        "unauthenticated",
        `Firestore ${functionName} not executed. Email is not verified.`,
        `${functionName}/email-not-verified`,
      )
    }
  }
}

Finally, custom claims can be added or removed only on the server side as the according "setCustomUserClaims" function belong to the firebase Admin SDK, whereas the "get-admin-users" function could be implemented also on the client side. Here and here you will find more information about custom claims, including firestore rules for client side queries protected by a custom user claim/role.

like image 83
Nils Heumer Avatar answered Oct 06 '22 04:10

Nils Heumer