One other solution is to try to first do a quick microbenchmark, and then decide if the user is logging in with approximately the capabilities you've seen before. If no, besides comparing against existing saved results, generate new independent results that can be used next time for devices closer to this performance.
Basically, you can have multiple tuples of
- Argon2id parameters
- salt, and
- derived public key or whatever you're using for comparison,
for each user. Basic one-to-many relationship.
When a user starts a login (or maybe even preemptively, if you have a good heuristic for when to do it, or don't mind hitting the user with a microbenchmark even if they weren't going to log in) you can start that microbenchmark in the background.
When that microbenchmark is done, pick Argon2id parameters that are a good fit for results in that ballpark, and then:
If you already have an entry for those parameters, run Argon2id with those parameters and that salt, and compare with those keys.
If you don't already have an entry for those parameters,
- do the login with the closest parameters you do have an entry for, and then
- generate a new salt (if each parameterization of Argon2id for a user gets its own salt, then even if an attacker is able to get the existing salts, the attacker still can't benefit from pre-computing rainbow tables with weaker parameters and then causing their target user to run on a system that benchmarks to those weaker settings), generate new keys, and save that new entry for later. (This step can be done in the background, you don't have to block the UI or get in the user's way.)
Expire the entries which aren't used for a while, maybe with a bias for expiring weaker or uncommonly used entries sooner. (This way, if a user logs in once from some old or weak device and then doesn't for a while, you're not keeping their weaker less crack-resistant results around indefinitely, reducing the time window during which a database leak or similar could expose those weaker entries.)
Of course, alternatively, you could just keep only the weakest Argon2id parameters and derived public key seen so far... the one argument for multiple entries being a bad idea is that there are multiple possible results the attacker could try to brute force, and maybe one day it turns out that Argon2id is easier to crack if the salt has very specific bits or if you have multiple results with different iterarions (in which case multiple entries with individual salts means more chances to have one with such a bad salt). But I would still keep the Argon2id settings from those entries, with at least a timestamp of the most recent time they logged in from a device that would get those settings, so that you can still expire the weakest entry (and delete it on next login after upgrading to a stronger one) - otherwise your system ends up a ratchet that only ever downgrades settings, which is the exact opposite of the goal here.
With all this in place, the work factor automatically tunes itself for each user's current systems. The only weakness is that if an attacker can raise the user's CPU and memory usage, or get them to use a device with lower specs, that can become a downgrade attack instead of just making login slow. But of course you'd still impose a minimum acceptable floor on the Argon2id settings, right? The strength is that as your user upgrades their device, their work factor will automatically increase after a slight lag time.
Is this scheme actually good? Ehh, decide for yourself. Personally I like it, and I'd rather use something like that than something that got stuck on whatever work factor was appropriate for the one device I registered from, but I'd want good cryptographers and security experts to weigh in and look at it critically first.