-
Notifications
You must be signed in to change notification settings - Fork 153
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
Conversation
@@ -76,7 +76,7 @@ export const HARDWARE_KEY_TEST = createFeatureFlagStore( | |||
|
|||
export const DISCOVERABLE_PASSKEY_FLOW = createFeatureFlagStore( | |||
"DISCOVERABLE_PASSKEY_FLOW", | |||
false, | |||
true, |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops 🙈
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking good, thanks!
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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}); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 () => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 = |
There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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()!), |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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()!); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ditto no !
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done ✅
const user = page.url.searchParams.get("user"); | ||
const flow = new AddPasskeyFlow(BigInt(user!)); |
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done ✅
#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++; | ||
} | ||
}); |
There was a problem hiding this comment.
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.
#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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed ✅
Closing, this has been merged in different PRs |
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
CROSS_DEVICE_PASSKEYS
Tests