Firestore rules not picking up custom claims - node.js

I can't get custom claims to work in the firestore rules.
I'm using nodeJS (local) to set the custom claims and initialize with the service-account from firebase. The user token is automatically added to the request headers and validates fine on node.
// Initialize
admin.initializeApp({
credential: admin.credential.cert(serviceAccount as admin.ServiceAccount), // Typing is wrong google!
databaseURL: `https://${serviceAccount.project_id}.firebaseio.com`
});
// Add custom claims for additional privileges.
const payload = await admin.auth().setCustomUserClaims(decodedToken.sub, {
customClaims })
.then(() => ({ ...decodedToken, customClaims }))
.catch(() => void 0);
if (!payload) { res.status(401).json({ error: 'Error setting custom claims on token' }); return; }
Custom claims object:
// Define custom claims
const customClaims: CustomClaims = {
serverAuth: true,
domain: domainOfUser,
developer: isDeveloper,
admin: isAdmin,
};
Angular Fire 2: User logs in with google redirect then refresh the token:
if (!this.firebaseAuth.auth.currentUser) { return Promise.reject('User object not found in fireAuth service'); }
return this.firebaseAuth.auth.currentUser.getIdToken(true);
When that's al done I do: (the fireAuthService is a custom service that handles some auth stuff)
// On user change
this.fireAuthService.user$.pipe(
map(userAuth => { if (!userAuth) { this.userSource.next(null); } return userAuth; }),
filter(notNullOrUndefined),
switchMap(async userAuth => {
const userDoc = this.userCollection.doc<UserDb>(userAuth.uid);
const exists = await userDoc.get().toPromise().then(user => user.exists)
.catch(() => this.fireAuthService.signOut());
if (!exists) {
const res = await this.serverService.createNewUser(userAuth).catch(() => void 0);
if (!res) { this.fireAuthService.signOut(); }
}
return userAuth;
}),
switchMap(userAuth => this.userCollection.doc<UserDb>(userAuth.uid).valueChanges())
).subscribe(async userDb => {
await this.fireAuthService.getAuthToken();
const isAdmin = await this.fireAuthService
.getTokenPayload()
.then(payload => (payload.claims.customClaims as CustomClaims).admin);
this.userSource.next(new CurrentUser(userDb, this.serverService, isAdmin));
runAngularFire();
});
On the payload are all my custom claims at this point. The firestore calls on the user doc firestore calls are secured by only checking the uid in the firestore rules and this works.
At this point I set up my listeners. They fail with the error:
Missing or insufficient permissions.
The firestore rules are setup as followed:
service cloud.firestore {
match /databases/{database}/documents {
// Allow users to read documents in the user's collection
match /users/{userId} {
allow read: if request.auth.token.sub == userId;
}
// Allow only reads to the db
match /{document=**} {
allow read: if request.auth.token.serverAuth == true;
}
}
I've tried just about anything and I'm at a loss. Any suggestion?
Many thanks in advance!
Edit: I also checked the token send out on channel?database=... This token has the custom claims...

After a night of sleep I noticed my error:
const payload = await admin.auth().setCustomUserClaims(decodedToken.sub, { customClaims });
To:
const payload = await admin.auth().setCustomUserClaims(decodedToken.sub, customClaims);
Also I did test the rules on a object. Objects probably don't work in rules.

Related

Correctly fetch authentication tokens server-side in a node.js React app hosted on Cloud Run

While not a front-end developer, I'm trying to set up a web app to show up a demo for a product. That app is based on the Sigma.js demo app demo repository.
You'll notice that this app relies on a graph which is hosted locally, which is loaded as:
/src/views/Root.tsx :
useEffect(() => {
fetch(`${process.env.PUBLIC_URL}/dataset.json`)
.then((res) => res.json())
.then((dataset: Dataset) => {...
// do things ....
and I wish to replace this by a call to another service which I also host on Cloud Run.
My first guess was to use the gcloud-auth-library, but I could not make it work - especially since it does not seem to support Webpack > 5 (I might be wrong here), the point here this lib introduces many problems in the app, and I thought I'd be better off trying the other way GCP suggests to handle auth tokens: by calling the Metadata server.
So I replaced the code above with:
Root.tsx :
import { getData } from "../getGraphData";
useEffect(() => {
getData()
.then((res) => res.json())
.then((dataset: Dataset) => {
// do even more things!
getGraphData.js :
import { getToken } from "./tokens";
const graphProviderUrl = '<my graph provider service URL>';
export const getData = async () => {
try {
const token = await getToken();
console.log(
"getGraphData.js :: getData : received token",
token
);
const request = await fetch(
`${graphProviderUrl}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
const data = await request.json();
console.log("getGraphData.js :: getData : received graph", data);
return data;
} catch (error) {
console.log("getGraphData.js :: getData : error getting graph data", error);
return error.message;
}
};
tokens.js :
const targetAudience = '<my graph provider service base URL>'; // base URL as audience
const metadataServerAddress = "169.254.169.254"; // use this to shortcut DNS call to metadata.google.internal
export const getToken = async () => {
if (tokenExpired()) {
const token = await getValidTokenFromServer();
sessionStorage.setItem("accessToken", token.accessToken);
sessionStorage.setItem("expirationDate", newExpirationDate());
return token.accessToken;
} else {
console.log("tokens.js 11 | token not expired");
return sessionStorage.getItem("accessToken");
}
};
const newExpirationDate = () => {
var expiration = new Date();
expiration.setHours(expiration.getHours() + 1);
return expiration;
};
const tokenExpired = () => {
const now = Date.now();
const expirationDate = sessionStorage.getItem("expirationDate");
const expDate = new Date(expirationDate);
if (now > expDate.getTime()) {
return true; // token expired
}
return false; // valid token
};
const getValidTokenFromServer = async () => {
// get new token from server
try {
const request = await fetch(`http://${metadataServerAddress}/computeMetadata/v1/instance/service-accounts/default/token?audience=${targetAudience}`, {
headers: {
'Metadata-Flavor': 'Google'
}
});
const token = await request.json();
return token;
} catch (error) {
throw new Error("Issue getting new token", error.message);
}
};
I know that this kind of call will need to be done server-side. What I don't know is how to have it happen on a React + Node app. I've tried my best to integrate good practices but most questions related to this topic (request credentials through a HTTP (not HTTPS!) API call) end with answers that just say "you need to do this server-side", without providing more insight into the implementation.
There is a question with similar formulation and setting here but the single answer, no upvote and comments is a bit underwhelming. If the actual answer to the question is "you cannot ever call the metadata server from a react app and need to set up a third-party service to do so (e.g. firebase)", I'd be keen on having it said explicitly!
Please assume I have only a very superficial understanding of node.js and React!

Cloud functions for Firebase FCM notifications to multiple users

I am using nodeJS with firebase for my flutter/firebase mobile app
I would like to send notifications to all users that have a certain query met. ie all users who have radiology as their specialty. So that they will be notified when a new article is added to the database
However I am unsure why my code (below) doesn't work to get notification tokens for all users with this query.
My database structure is users/notificationTokens/Ids of all tokens for that user stored in field 'token'
exports.sendToDevice5 = functions.firestore
.document('Articles/{paper}')
.onCreate(async (snapshot, context) => {
const paper = context.params.paper;
const item = snapshot.data();
if (item.subspecialty == "RadMSK" || item.subspecialty == "RadMS") {
const tokens = await admin.firestore().collection('users').where("specialty", "==", "RADIOLOGY").get().then(
snapshot.forEach((doc) => {
const docs = admin.firestore().collection('users').doc(doc.id).collection('notificationTokens').get();
return docs.data().token;
}));
const payload = {
notification: {
title: `${item.title}!`,
body: `New Journal`,
sound: "default",
},
data: {click_action: 'FLUTTER_NOTIFICATION_CLICK'},
};
return admin.messaging().sendToDevice(tokens, payload);
}
});

check MFA is enabled for a user using rest api

How do I check MFA is enabled for AD users using rest API loginWithServicePrincipalSecret
is there anyone who can help me out to do this....I want to do this using node sdk like this
require("isomorphic-fetch");
const { UserAgentApplication } = require("msal");
const { ImplicitMSALAuthenticationProvider } = require("#microsoft/microsoft-graph-client/lib/src/ImplicitMSALAuthenticationProvider");
const { MSALAuthenticationProviderOptions } = require("#microsoft/microsoft-graph-client/lib/src/MSALAuthenticationProviderOptions");
const msalConfig = {
auth: {
clientId: "bec52b71-dc94-4577-9f8d-b8536ed0e73d", // Client Id of the registered application
},
};
const graphScopes = ["user.read", "mail.send"]; // An array of graph scopes
const msalApplication = new UserAgentApplication(msalConfig);
const Options = new MSALAuthenticationProviderOptions(graphScopes);
const authProvider = new ImplicitMSALAuthenticationProvider(
msalApplication,
Options
);
const options = {
authProvider,
};
const Client = require("#microsoft/microsoft-graph-client");
const client = Client.init(options);
async function test() {
try {
let res = await client
.api("/reports/credentialUserRegistrationDetails")
.version("beta")
.get();
console.log("res: ", res);
} catch (error) {
throw error;
}
}
test();
This is possible with MS Graph API,
To Get information of users registered with MFA and hasn't, we can use isMfaRegistered property in credentialUserRegistrationDetails .
credentialUserRegistrationDetails help us to get the details of the
usage of self-service password reset and multi-factor authentication
(MFA) for all registered users. Details include user information,
status of registration, and the authentication method used.
This is possible programmatically with MS Graph where you will get a JSON reports an can be plugged into other reports or can be represented programmatically itself
Example:
GET https://graph.microsoft.com/beta/reports/credentialUserRegistrationDetails
sample output:
{
"id": "****************************",
"userPrincipalName": "NKS#nishantsingh.live",
"userDisplayName": "Nishant Singh",
"isRegistered": false,
"isEnabled": true,
"isCapable": false,
"isMfaRegistered": true,
"authMethods": [
"mobilePhone"
]
}
Sample code for your Node JS,
const options = {
authProvider,
};
const client = Client.init(options);
let res = await client.api('/reports/credentialUserRegistrationDetails')
.version('beta')
.get();
To implement your NodeJS code please go through step-by-step guide in MS Documentation

Verify if a phone number exist in firebase app using firebase cloud function

I am new to the firebase (and all its features) space. I have read the documentation, and I have been able to use the web sdk properly. I have created a file where all my current firebase code is written as seen in firebaseApi.js below. Also, below is an example of how I have used the functions under registration.js (Kindly correct if I am doing it wrong), the sample works. I was trying to implement
admin.auth().getUserByPhoneNumber(phoneNumber),
which I want to use to check if a currently inputted phone number already exists in the App. But I have read the Admin SDKs cannot be used in client-side environments and should only be used in privileged server environments owned or managed by the developers of a Firebase app. I am kinda lost on how to go around this.
is it possible to connect firebase cloud functions to the client-side like
I am doing with the firebaseApi?
I have cleaned up the code and kept only the relevant parts
firebaseApi.js
import firebase from 'firebase/app';
import 'firebase/firestore';
import 'firebase/auth';
import 'firebase/database';
import 'firebase/storage';
const config = {config};
firebase.initializeApp(config);
class Firebase {
register = ({ fullname, email, phone }) => {
const user = Firebase.auth.currentUser.uid;
const firestoreRef = Firebase.firestore.collection('Users').doc(user);
const settings = {
fullname,
email,
phone,
};
firestoreRef
.set(settings);
};
static init() {
Firebase.auth = firebase.auth();
Firebase.firestore = firebase.firestore();
Firebase.database = firebase.database();
Firebase.storage = firebase.storage();
Firebase.email = firebase.auth.EmailAuthProvider;
Firebase.google = firebase.auth.GoogleAuthProvider;
Firebase.phoneVerify = new firebase.auth.PhoneAuthProvider();
Firebase.phone = firebase.auth.PhoneAuthProvider;
}
}
Firebase.shared = new Firebase();
export default Firebase;
registration.js
import Firebase from './firebaseApi';
onCompleteReg() {
const { fullname, email, email } = this.state;
const settings = {
fullname,
email,
email
};
Firebase.shared
.registerSettings(settings)
.then(() => {
console.log('Successful');
}).catch((e) => {
console.log(e);
})
}
As a matter of privacy and best practices, unless the current user is an administrator, I would not be exposing the ability to check if any given phone number is used by any individual and/or is tied to your application.
Wrapped in Cloud Function
As the Admin SDK is to be used only from secure environments, you can only expose it's functionality by way of some API. It is beneficial in this case to handle user authentication and CORS automatically, so I'll use a Callable Function. Based on the sensitive nature of such an API, it would also be advised to rate-limit access to it which can be easily achieved using the firebase-functions-rate-limiter package. In the below code, we limit the API calls to 2 uses per user and 10 uses across all users, per 15 second period to prevent abuse.
import * as admin from 'firebase-admin';
import * as functions from 'firebase-functions';
import { FirebaseFunctionsRateLimiter } from 'firebase-functions-rate-limiter';
admin.initializeApp();
const realtimeDb = admin.database();
const perUserLimiter = FirebaseFunctionsRateLimiter.withRealtimeDbBackend(
{
name: 'rate-limit-phone-check',
maxCalls: 2,
periodSeconds: 15,
},
realtimeDb
);
const globalLimiter = FirebaseFunctionsRateLimiter.withRealtimeDbBackend(
{
name: 'rate-limit-phone-check',
maxCalls: 10,
periodSeconds: 15,
},
realtimeDb
);
exports.phoneNumber = functions.https.onCall(async (data, context) => {
// assert required params
if (!data.phoneNumber) {
throw new functions.https.HttpsError(
'invalid-argument',
'Value for "phoneNumber" is required.'
);
} else if (!context.auth || !context.auth.uid) {
throw new functions.https.HttpsError(
'failed-precondition',
'The function must be called while authenticated.'
);
}
// rate limiter
const [userLimitExceeded, globalLimitExceeded] = await Promise.all(
perUserLimiter.isQuotaExceededOrRecordUsage('u_' + context.auth.uid),
globalLimiter.isQuotaExceededOrRecordUsage('global'));
if (userLimitExceeded || globalLimitExceeded) {
throw new functions.https.HttpsError(
'resource-exhausted',
'Call quota exceeded. Try again later',
);
}
let userRecord = await admin.auth.getUserByPhoneNumber(phoneNumber);
return userRecord.uid;
}
To call the check, you would use the following code on the client:
let checkPhoneNumber = firebase.functions().httpsCallable('phoneNumber');
checkPhoneNumber({phoneNumber: "61123456789"})
.then(function (result) {
let userId = result.data;
// do something with userId
})
.catch(function (error) {
console.error('Failed to check phone number: ', error)
});
Attempt by Login
Rather than allow users to find out if a phone number exists or specifically exists on your service, it is best to follow the Phone Number authentication flow and allow them to prove that they own a given phone number. As the user can't verify more than one number en-masse, this is the safest approach.
From the Firebase Phone Auth Reference, the following code is used to verify a phone number:
// 'recaptcha-container' is the ID of an element in the DOM.
var applicationVerifier = new firebase.auth.RecaptchaVerifier(
'recaptcha-container');
var provider = new firebase.auth.PhoneAuthProvider();
provider.verifyPhoneNumber('+16505550101', applicationVerifier)
.then(function(verificationId) {
var verificationCode = window.prompt('Please enter the verification ' +
'code that was sent to your mobile device.');
return firebase.auth.PhoneAuthProvider.credential(verificationId,
verificationCode);
})
.then(function(phoneCredential) {
return firebase.auth().signInWithCredential(phoneCredential);
});
Privileged Phone Search
If you want an appropriately privileged user (whether they have an administrator or management role) to be able to query users by a phone number, you can use the following scaffolding. In these code samples, I limit access to those who have the isAdmin claim on their authentication token.
Database structure: (see this answer for more info)
"phoneNumbers": {
"c011234567890": { // with CC for US
"userId1": true
},
"c611234567890": { // with CC for AU
"userId3": true
},
...
}
Database rules:
{
"rules": {
...,
"phoneNumbers": {
"$phoneNumber": {
"$userId": {
".write": "auth.uid === $userId && (!newData.exists() || root.child('users').child(auth.uid).child('phoneNumber').val() == ($phoneNumber).replace('c', ''))" // only this user can edit their own record and only if it is their phone number or they are deleting this record
}
},
".read": "auth != null && auth.token.isAdmin == true", // admins may read/write everything under /phoneNumbers
".write": "auth != null && auth.token.isAdmin == true"
}
}
}
Helper functions:
function doesPhoneNumberExist(phoneNumber) {
return firebase.database.ref("phoneNumbers").child("c" + phoneNumber).once('value')
.then((snapshot) => snapshot.exists());
}
// usage: let exists = await doesPhoneNumberExist("611234567890")
function getUsersByPhoneNumber(phoneNumber) {
return firebase.database.ref("phoneNumbers").child("c" + phoneNumber).once('value')
.then((snapshot) => snapshot.exists() ? Object.keys(snapshot.val()) : []);
}
// usage: let usersArray = await getUsersByPhoneNumber("611234567890") - normally only one user
function searchPhoneNumbersThatStartWith(str) {
if (!str || str.length < 5) return Promise.reject(new Error('Search string is too short'));
return firebase.database.ref("phoneNumbers").startAt("c" + str).endAt("c" + str + "\uf8ff").once('value')
.then((snapshot) => {
let phoneNumbers = [];
snapshot.forEach((phoneEntrySnapshot) => phoneNumbers.push(phoneEntrySnapshot.key));
return phoneNumbers;
});
}
// usage: let matches = await searchPhoneNumbersThatStartWith("61455")
// best handled by Cloud Function not client
function linkPhoneNumberWithUser(phoneNumber, userId) {
return firebase.database.ref("phoneNumbers").child("c" + phoneNumber).child(userId).set(true);
}
// usage: linkPhoneNumberWithUser("611234567890", firebase.auth().currentUser.uid)
// best handled by Cloud Function not client
function unlinkPhoneNumberWithUser(phoneNumber, userId) {
return firebase.database.ref("phoneNumbers").child("c" + phoneNumber).child(userId).remove();
}
// usage: unlinkPhoneNumberWithUser("611234567890", firebase.auth().currentUser.uid)

How can I restrict the access to a static file to a specific logged in user?

Is there a way to programmatically grant a specific user access to a specific static file?
Example: A user uploads /wwwroot/files/users/2137/document.pdf. Now this file should not be accessible by browsing to www.domain.com/files/users/2137/document.pdf. Only this user, or other users which are granted access, could access it via the backend system.
You could use a Middleware + Authorization Policy to achieve that goal:
Define a policy where the user can only access his own resources.
Invoke the IAuthorizationService within a middleware that checks the policy before it goes into the StaticFiles middleware.
For example, here's a AuthorizationHandler that handles this requirement:
public class RestrictStaticFilesRequirement: AuthorizationHandler<RestrictStaticFilesRequirement>,IAuthorizationRequirement
{
public const string DefaultPolicyName = "Access-His-Own-Static-Files";
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RestrictStaticFilesRequirement requirement)
{
var user = context.User; // current User Principal
var userName = context.Resource as string; // current userName
// custom this requirement as you like
if(user != null && !string.IsNullOrEmpty(userName) && user.HasClaim(ClaimTypes.NameIdentifier, userName)) {
context.Succeed(requirement);
} else {
context.Fail();
}
return Task.CompletedTask;
}
}
Register this requirement as a Policy:
services.AddAuthorization(opts =>{
opts.AddPolicy(RestrictStaticFilesRequirement.DefaultPolicyName,pb => pb.AddRequirements(new RestrictStaticFilesRequirement()) );
});
Finally, check the policy using the IAuthorizationService and determine whether current request is allowed:
app.UseAuthentication();
app.UseWhen( ctx => ctx.Request.Path.StartsWithSegments("/files/users"), appBuilder =>{
appBuilder.Use(async (context, next) => {
// get the userId in current Path : "/files/users/{userId}/...."
var userId = context.Request.Path.Value.Substring("/files/users/".Length)
.Split('/')
.FirstOrDefault();
if(string.IsNullOrEmpty(userId)){
await next(); // current URL is not for static files
return;
}
var auth= context.RequestServices.GetRequiredService<IAuthorizationService>();
var result = await auth.AuthorizeAsync( context.User, userId ,RestrictStaticFilesRequirement.DefaultPolicyName);
if(!result.Succeeded){
context.Response.StatusCode= 403;
return;
}
await next();
});
});
app.UseStaticFiles();
// ... other middlewares

Resources