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

1

11

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?

Antre answered 27/2, 2019 at 18:3 Comment(5)
Why don't you just create a User node in the database then write a specific property like : "IsAdmin: true" ?Kutchins
Because from what I understood that would make my Firestore security rules much more complex. See This video. I would have to read from another place (like the one you suggested) in my database before allowing a read/write operation on some protected data.Antre
@cbdev420 Actually the solution described in the SO Question/answer you mention in your Question and the proposal of Christophe does not change the way authorisation is implemented: it still uses the Custom Claims (and all their advantages) but, in parallel, the list of your users WITH theirs Claims is stored in the Firebase database.Dolhenty
youtube.com/watch?v=3hj_r_N0qMsImogeneimojean
Does this answer your question? Firebase Auth - list of users by Custom ClaimsMegrim
C
4

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.

Classic answered 24/2, 2022 at 16:54 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.