Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Meteor.user() does not work in webApp connect handlers #13051

Open
nesbtesh opened this issue Mar 4, 2024 · 16 comments
Open

Meteor.user() does not work in webApp connect handlers #13051

nesbtesh opened this issue Mar 4, 2024 · 16 comments

Comments

@nesbtesh
Copy link

nesbtesh commented Mar 4, 2024

const currentInvocation = DDP._CurrentMethodInvocation.get() || DDP._CurrentPublicationInvocation.get();

Is there a reason why Meteor.user() and Meteor.userId() doesnt work with the webApp connect handlers?

Can I create a pull request to make a change?

@StorytellerCZ
Copy link
Collaborator

Pull request to change things would only make sense if targeted against Meteor 3.

@Grubba27 Grubba27 changed the title Meteor.user() Meteor.user() does not work in webApp connect handlers Mar 5, 2024
@rj-david
Copy link
Contributor

rj-david commented Mar 6, 2024

Was there a package similar to this wherein interfacing with the rest points require some validation token to "log in" the user?

@jamauro
Copy link
Contributor

jamauro commented Mar 6, 2024

I think this would be a nice addition to core. Curious how'd you go about implementing it.

One idea would be to automatically include the loginToken inside Meteor's fetch. And then use it behind the scenes provide access to the userId and user.

I shared one way to currently do it here: https://forums.meteor.com/t/call-meteor-userid-from-connecthandler/61072/2?u=jam

Maybe there's an even better way.

@jamauro
Copy link
Contributor

jamauro commented Mar 8, 2024

I have a POC. What do y'all think of this?

// in 'meteor/fetch'
function isExternalUrl(url) {
  return url.includes('://');
}

const originalFetch = global.fetch;
global.fetch = function(url, options = {}) {
  if (!isExternalUrl(url) && Meteor.userId() && (!options.headers || !options.headers.get('authorization'))) {
    options.headers = options.headers || new global.Headers();
    options.headers.append('Authorization', `Bearer ${Accounts._storedLoginToken()}`);
  }

  return originalFetch(url, options);
};

exports.fetch = global.fetch;
exports.Headers = global.Headers;
exports.Request = global.Request;
exports.Response = global.Response;
// in 'meteor/webapp' (in which case we wouldn't import WebApp but I didn't go that far in this POC)
import { WebApp } from 'meteor/webapp';
import { Accounts } from 'meteor/accounts-base';
import { DDP } from 'meteor/ddp';

// Middleware to set up DDP context for Meteor.userId()
const ddpUserContextMiddleware = (req, res, next) => {
  const { authorization } = req.headers;
  const [ prefix, token ] = authorization?.split(' ') || [];

  const user = (prefix === 'Bearer' && token) ? Meteor.users.findOne({
    'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken(token),
  }) : undefined;

  const ddpSession = {
    userId: user ? user._id : undefined,
    connection: {},
    isSimulation: false,
  };

  DDP._CurrentInvocation.withValue(ddpSession, next);
};

// Apply middleware to all connectHandlers
WebApp.connectHandlers.use(ddpUserContextMiddleware);
// server
WebApp.connectHandlers.use('/something', (req, res, next) => {
  const userId = Meteor.userId();
  const user = Meteor.user();
  if (userId) {
    console.log('User ID:', userId, user);
    // Now you can use userId as needed
  } else {
    console.log('User ID not found');
  }
  next();
});
// client
import { fetch } from 'meteor/fetch'

fetch('/something')

@rj-david
Copy link
Contributor

rj-david commented Mar 8, 2024

@jamauro, that will be a security issue attaching the token to meteor fetch as it is used universally (not only for your app's endpoints)

@jamauro
Copy link
Contributor

jamauro commented Mar 8, 2024

@rj-david good point. I updated the snippet above to exclude external URLs to avoid that issue.

maybe there's a better solution entirely. curious to hear what that could be.

@rj-david
Copy link
Contributor

rj-david commented Mar 9, 2024

What is the normal use case of a meteor app using rest api instead of methods to access the server?

@jamauro
Copy link
Contributor

jamauro commented Mar 9, 2024

It's not a bad question. It's kind of an anti-pattern imo to reach for a WebApp endpoint when you can just use a method. Having said that:

  1. It has come up in the forums a number of times.
  2. In the past people have built packages to get the userId inside WebApp.connectHandlers – though you still couldn't simply use Meteor.userId().
  3. It does feel like an artificial limitation. It'd be nice if Meteor.userId() and Meteor.user() "just worked" inside WebApp.connectHandlers. After all, the Meteor Guide says they can be used anywhere but publish functions.

@rj-david
Copy link
Contributor

The normal use case is a 3rd party system accessing a rest endpoint of a meteor app. With this use case, the code above has shortcomings

  1. Meteor fetch will not work
  2. loginToken requires existing login session that is not expired

For the second point, this is normally the case for machine-to-machine integrations which requires long-lived access tokens like in oauth.

@rj-david
Copy link
Contributor

What is the normal use case of a meteor app using rest api instead of methods to access the server?

One thing that I can think of is accessing user-based files. The file requires authentication for access. But instead of using DDP, use https which is more efficient in this case.

(Of course, one can argue to just use S3 and use a signed url from the meteor app to access the files.)

@nesbtesh
Copy link
Author

The primary application of our system involves creating Excel spreadsheets and leveraging AWS Lambda for tasks that require significant computational resources. We utilize Meteor for data management, but often find ourselves executing tasks outside of Node.js. This approach is taken because some processes are more efficiently handled outside the Node.js environment, especially when dealing with large volumes of data or computationally intensive tasks. This strategy allows us to optimize performance and manage our workload more effectively.

import bodyParser from "body-parser";
import { Meteor } from "meteor/meteor";
import { WebApp } from "meteor/webapp";
// Assuming this is running on the server

WebApp.connectHandlers.use(bodyParser.json());

// Step 1: Initialize an object to store method names
global.MethodsList = {};

// Step 2: Create a wrapper for Meteor.methods
const originalMeteorMethods = Meteor.methods;

WebApp.connectHandlers.use("/api", async (req, res, next) => {
	// Extract the Authorization header
	const authHeader = req.headers.authorization;

	if (authHeader && authHeader.startsWith("Bearer ")) {
		const token = authHeader.slice(7); // Remove "Bearer " from the start

		const hashedToken = Accounts._hashLoginToken(token);

		// Find the user by the token
		const user = await Meteor.users.findOneAsync({
			"services.resume.loginTokens.hashedToken": hashedToken,
		});

		if (user) {
			// Attach user information to the request object
			req.user = user;
			req.userId = user._id;

			// Proceed to the next handler/middleware
			next();
		} else {
			// Authentication failed
			res.writeHead(401);
			res.writeHead(500, { "Content-Type": "application/json" });
			res.end(
				JSON.stringify({
					status: "error",
					message: "Authentication failed",
				}),
			);
		}
	} else {
		// No or improperly formatted Authorization header present
		next();
	}
});

async function responseHandler(callback, req, res) {
	try {
		const result = await callback(req, res);
		res.writeHead(200, { "Content-Type": "application/json" });
		res.end(JSON.stringify(result));
	} catch (error) {
		res.writeHead(500, { "Content-Type": "application/json" });
		if (error && error.reason) {
			res.end(
				JSON.stringify({
					status: "ko",
					reason: error.reason,
				}),
			);
		} else {
			res.end(JSON.stringify(error));
		}
	}
}

function createApiFunction({ url, func, method }) {
	console.log("createApiFunction", url, func, method);

	// Register API endpoint
	if (method === "GET") {
		WebApp.connectHandlers.use(`/api${url}`, async (req, res) => {
			responseHandler(
				async (req) => {
					const userId = await req.userId;
					return func.apply(this, [req.body, userId]);
				},
				req,
				res,
			);
		});
	} else if (method === "POST") {
		WebApp.connectHandlers.use(`/api${url}`, async (req, res) => {
			responseHandler(
				async (req) => {
					const userId = await req.userId;
					return func.apply(this, [req.body, userId]);
				},
				req,
				res,
			);
		});
	} else if (method === "PUT") {
		WebApp.connectHandlers.use(`/api${url}`, async (req, res) => {
			responseHandler(
				async (req) => {
					const userId = await req.userId;
					return func.apply(this, [req.body, userId]);
				},
				req,
				res,
			);
		});
	}
}

Meteor.methods = function (methods) {
	const methodsToRegister = {};

	Object.keys(methods).forEach((methodName) => {
		// Store method names in the global object
		MethodsList[methodName] = true;

		if (typeof methods[methodName] === "function") {
			// methodsToRegister[methodName] = methods[methodName];
			methodsToRegister[methodName] = function func(...args) {
				this.unblock();
				args[1] = Meteor.userId();
				return methods[methodName].apply(this, args);
			};
		} else {
			methodsToRegister[methodName] = function func(...args) {
				this.unblock();

				args[1] = Meteor.userId();

				return methods[methodName].function.apply(this, args);
			};

			if (methods[methodName].api) {
				createApiFunction({
					...methods[methodName].api,
					func: methods[methodName].function,
				});
			}
		}
	});

	// Call the original Meteor.methods function
	originalMeteorMethods(methodsToRegister);
};

The primary objective is to expose all of our methods to a GPT4 bot as well as standarize the way of creating API like this:

Meteor.methods({
	helloWord: {
		function: (data, userId) => {
			// Meteor.userId();

			console.log("hello user: ", userId);
			// console.log("hello user data: ", Meteor.user());
			console.log("hello", data);
			return "Hello World!";
		},
		api: {
			method: "POST",
			url: "/hello",
		},
                ai: {
                   description: "use to say hello",
                   params: {
                          name: String
                   }
                }
	},
})

@rj-david
Copy link
Contributor

@nesbtesh, how do you handle expired tokens for this case?

@nesbtesh
Copy link
Author

nesbtesh commented Mar 11, 2024

this is just a test but I will add it... @rj-david how do you suggest we handle it?

@nachocodoner
Copy link
Member

What is the normal use case of a meteor app using rest api instead of methods to access the server?

Maybe another use-case less explored where using HTTP endpoints over the DDP is when using a Server-Side Rendering (SSR) approach for your app, not really extended approach on Meteor though. But if we take ever that direction further, having Meteor context on those endpoints would be great. I like the approach from @jamauro above.

@rj-david
Copy link
Contributor

rj-david commented Mar 13, 2024

this is just a test but I will add it... @rj-david how do you suggest we handle it?

On top of my head, this should be through a mechanism of long-lived sessions like oauth if the access is coming from a 3rd party system without an actual logged-in user accessing the app.

Although when the access is made on behalf of a logged-in user, that looks like a clear use-case for this.

@rj-david
Copy link
Contributor

What is the normal use case of a meteor app using rest api instead of methods to access the server?

Maybe another use-case less explored where using HTTP endpoints over the DDP is when using a Server-Side Rendering (SSR) approach for your app, not really extended approach on Meteor though. But if we take ever that direction further, having Meteor context on those endpoints would be great. I like the approach from @jamauro above.

The challenge here is that the starting point in the client does not have that context, yet (client is not yet running). This is solved through the use of cookies by existing packages.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants