Skip to content

PoC adding passkey from dashboard #3158

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

Closed
wants to merge 22 commits into from
Closed

PoC adding passkey from dashboard #3158

wants to merge 22 commits into from

Conversation

lmuntaner
Copy link
Collaborator

@lmuntaner lmuntaner commented Jul 2, 2025

Motivation

Proof Of Concept to add a passkey from the dashboard.

UX Flow

NOTE: This is not final yet - failing to create the passkey on the new device leaves a temporary key visible on the dashboard. The new device does not automatically login after creating the passkey yet (this is a browser API limitation and needs a major workaround). Styling is preliminary.

Changes

  • Add feature flag CROSS_DEVICE_PASSKEYS
  • Add flag-gated frontend functionality to add passkey on new client device
  • Add new client device flow under /flow/
  • Add backend query method to poll for the tentative device having been verified
  • Add basic styling for new flows

Tests

  • Added new passkey using the new flow

@@ -76,7 +76,7 @@ export const HARDWARE_KEY_TEST = createFeatureFlagStore(

export const DISCOVERABLE_PASSKEY_FLOW = createFeatureFlagStore(
"DISCOVERABLE_PASSKEY_FLOW",
false,
true,
Copy link
Contributor

@sea-snake sea-snake Jul 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be changed back to false 😅

(Probably also why e2e tests are failing)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops 🙈

@LXIF LXIF marked this pull request as ready for review July 4, 2025 10:00
@LXIF LXIF requested a review from sea-snake July 4, 2025 10:00
Copy link
Collaborator Author

@lmuntaner lmuntaner left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good, thanks!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this doesn't need to be here? hehe

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No idea what this is, removing it.

@@ -511,6 +511,10 @@ type AuthnMethodConfirmationError = variant {
NoAuthnMethodToConfirm;
};

type AuthorizationError = record {
principal;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What principal does it return? Why not returning Ok: false if there is any error?

Copy link
Contributor

@LXIF LXIF Jul 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would consider Ok: false an antipattern. The whole point of Result is to use Ok for an error-free state and Err for errors. The principal returned is just the user's principal - I didn't come up with this, just using the error I get from the internal function. We can of course return something like Err: Unauthorized.

Apart from all that, Ok: false is already used for when it is not yet verified. Arguably there is still an issue there because this call fails as long as it is not yet verified.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say Err: Unauthorized makes sense as well, in another PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what I implemented now roughly:
pub enum AuthnMethodVerifiedPollError { Unauthorized, }

@@ -853,6 +857,9 @@ service : (opt InternetIdentityInit) -> {
// Requires authentication.
authn_method_confirm : (IdentityNumber, confirmation_code : text) -> (variant { Ok; Err : AuthnMethodConfirmationError });


authn_method_poll_for_verified : (IdentityNumber) -> (variant {Ok : bool; Err: AuthorizationError});
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't yuo need to add "query" at the end to make it a query?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done ✅

/// Checks whether a tentative device has been verified without mutating anything
/// This is so that on the new client we can prompt for adding the final passkey as soon as
/// on the old client we have verified the temporary key
pub fn poll_for_device_verified(identity_number: IdentityNumber) -> bool {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why call it it poll_for? This is not polling, is it?

Maybe has_tentative_device_registrations?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we are polling.

Screenshot 2025-07-04 at 13 28 17

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get the idea, but the method itself isn't actually going to poll anything once you call it, the frontend is actually doing the polling by repeatedly calling the method. This method could just as well be triggered by user interaction e.g. Refresh button instead of polling.

On another note, see the updated sequence diagram in Slack that includes this particular detail now and let me know if there's anything unclear/needs changes over there :D

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sea-snake It polls exactly once 🤷🏻‍♂️ But if it's important to both of you we can rename it of course

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done ✅

@@ -27,11 +27,11 @@
</h4>
</div>

{#if identityInfo.openIdCredentials.length === 0}
{#if identityInfo.openIdCredentials.length === 0 || (identityInfo.authnMethods.length <= 8 && $CROSS_DEVICE_PASSKEYS)}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this mean that all users won't see the "Add" button to link a Google account?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it doesn't. This means you see the "Add" button if either:

  • You have no google account linked
    OR
  • The feature flag for cross device passkeys is enabled AND you have less than 8 authentication methods. (I think this should actually be 10, updating this)

credential_id: [],
};
this.verificationCode = undefined;
this.#pollForVerified = setInterval(async () => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#pollForVerified is a number to clear the interval, should ca call it pollForVerifiedIntervalId?

And I'd rather use setTimeout to make sure we don't accumulate calls. Update calls can take a long time and might be longer than the interval.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are query calls. But yes, we can use a timeout pattern.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done ✅


const identityNumber = this.#identityNumber;

const identityInfoResponse =
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can do:

const { name } = await get(sessionStore).actor.identity_info(identityNumber).catch(throwCanisterError);

Isn't this how you intended to be used @sea-snake ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's with .then but yes that works

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be precise, it's.then(throwCanisterError) instead of .catch(throwCanisterError).

And yes, the intend was to unwrap the Rust result type and throw an error if an Err value is present.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done ✅

authn_method: {
WebAuthn: {
pubkey: new Uint8Array(passkeyIdentity.getPublicKey().toDer()),
credential_id: new Uint8Array(passkeyIdentity.getCredentialId()!),
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we do a check before instead of using ! here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's a problem but why not

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't control how it will break if it breaks. Can be done in another PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed.

},
);

void throwCanisterError(replaceResult);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto above on how to use throwCanisterError

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done ✅


void throwCanisterError(replaceResult);

const credentialId = new Uint8Array(passkeyIdentity.getCredentialId()!);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto no !

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's a problem but why not

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done ✅

Comment on lines 10 to 11
const user = page.url.searchParams.get("user");
const flow = new AddPasskeyFlow(BigInt(user!));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code assumes user is always present and valid, which could cause runtime errors if the parameter is missing or invalid. Consider adding validation before passing to BigInt():

const userParam = page.url.searchParams.get("user");
if (!userParam || isNaN(Number(userParam))) {
  // Handle error case - perhaps redirect or show error message
}
const flow = new AddPasskeyFlow(BigInt(userParam));

This would prevent uncaught exceptions when the URL is malformed or manually entered without the required parameter.

Suggested change
const user = page.url.searchParams.get("user");
const flow = new AddPasskeyFlow(BigInt(user!));
const userParam = page.url.searchParams.get("user");
if (!userParam || isNaN(Number(userParam))) {
// Handle error case - perhaps redirect or show error message
throw new Error("Invalid or missing user parameter");
}
const flow = new AddPasskeyFlow(BigInt(userParam));

Spotted by Diamond

Is this helpful? React 👍 or 👎 to let us know.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done ✅

Comment on lines 38 to 58
#pollForTentativeDevice = async (): Promise<DeviceData | undefined> => {
const actor = await get(authenticatedStore).actor;
// eslint-disable-next-line no-async-promise-executor
return new Promise<DeviceData | undefined>(async (resolve) => {
// TODO: Add countdown or timeout
let counter = 0;
const maxCount = 1000;
while (counter < maxCount) {
const anchorInfo = await actor.get_anchor_info(this.#identityNumber);
const tentativeDevice =
anchorInfo.device_registration[0]?.tentative_device[0];
if (nonNullish(tentativeDevice)) {
resolve(tentativeDevice);
return;
}
// Debounce a little; in practice won't be noticed by users but
// will avoid hot looping in case the op becomes near instantaneous.
await waitFor(100);
counter++;
}
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling method lacks proper error handling when the maximum polling attempts are reached. Currently, if counter reaches maxCount (1000), the promise neither resolves nor rejects, potentially leaving the caller hanging indefinitely. Consider adding a rejection when the maximum count is reached:

if (counter >= maxCount) {
  resolve(undefined); // or reject with an error
  return;
}

This ensures the promise always completes, allowing the caller to handle the timeout case appropriately.

Suggested change
#pollForTentativeDevice = async (): Promise<DeviceData | undefined> => {
const actor = await get(authenticatedStore).actor;
// eslint-disable-next-line no-async-promise-executor
return new Promise<DeviceData | undefined>(async (resolve) => {
// TODO: Add countdown or timeout
let counter = 0;
const maxCount = 1000;
while (counter < maxCount) {
const anchorInfo = await actor.get_anchor_info(this.#identityNumber);
const tentativeDevice =
anchorInfo.device_registration[0]?.tentative_device[0];
if (nonNullish(tentativeDevice)) {
resolve(tentativeDevice);
return;
}
// Debounce a little; in practice won't be noticed by users but
// will avoid hot looping in case the op becomes near instantaneous.
await waitFor(100);
counter++;
}
});
#pollForTentativeDevice = async (): Promise<DeviceData | undefined> => {
const actor = await get(authenticatedStore).actor;
// eslint-disable-next-line no-async-promise-executor
return new Promise<DeviceData | undefined>(async (resolve) => {
// TODO: Add countdown or timeout
let counter = 0;
const maxCount = 1000;
while (counter < maxCount) {
const anchorInfo = await actor.get_anchor_info(this.#identityNumber);
const tentativeDevice =
anchorInfo.device_registration[0]?.tentative_device[0];
if (nonNullish(tentativeDevice)) {
resolve(tentativeDevice);
return;
}
// Debounce a little; in practice won't be noticed by users but
// will avoid hot looping in case the op becomes near instantaneous.
await waitFor(100);
counter++;
}
// Resolve with undefined if max count is reached
resolve(undefined);
});

Spotted by Diamond

Is this helpful? React 👍 or 👎 to let us know.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed ✅

@LXIF LXIF marked this pull request as draft July 7, 2025 14:59
@lmuntaner
Copy link
Collaborator Author

Closing, this has been merged in different PRs

@lmuntaner lmuntaner closed this Jul 24, 2025
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

Successfully merging this pull request may close these issues.

3 participants