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

feat: Hanko passkeys support #14741

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open

feat: Hanko passkeys support #14741

wants to merge 2 commits into from

Conversation

merlindru
Copy link

Note: This previously was #13127 but has since been reworked and simplified

What does this PR do?

Fixes #13342
Fixes CAL-2984

This PR adds passkey support for logging in through Hanko Passkeys.

Type of change

  • New feature (non-breaking change which adds functionality)
  • This change requires a documentation update (probably)

How should this be tested?

  • Go to cloud.hanko.io and create a "Passkey API" project
    • Then create a new API key
      set it to HANKO_PASSKEYS_API_KEY="<api key>"
      (quotes important)
    • Copy the tenant ID to NEXT_PUBLIC_HANKO_PASSKEYS_TENANT_ID=<tenant id>
  • Log in as normal (with password/google/...)
  • Go to settings > passkeys > create a new passkey
  • Log out
  • Go to login > sign in with a passkey

Mandatory Tasks

  • Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.

next-auth-options.ts

This file now includes a PasskeyProvider.

For its authorize function, I've collected all of the important checks I could find in the email+password provider (a.k.a. CredentialsProvider), such as rate-limiting, user.locked, belongsToActiveTeams, ...) and included them in the PasskeyProvider.

For some of these, the fields/relations returned by UserRepository.findById wasn't enough. Unfortunately .findTeamsByUserId doesn't return enough info either

I didn't want to touch any of the existing queries, so I've added UserRepository.findByIdAndIncludeProfilesAndPassword

...which is the exact same as the email version of this function (findByEmailAndInclude...). It just looks the user up by their ID instead.

Checklist

  • I haven't checked if my PR needs changes to the documentation (unlikely though)
  • I haven't added tests that prove my fix is effective or that my feature works (this is possible with Playwright)

UI

Note about images

These are the same images from the old PR. There's one minor change since then: since icon components aren't supported for Button, EmptyPage, ... anymore, I used Lucide's key-round icon

This is what key-round looks like:

Versus the passkey icon:


Adds passkey button to login page:

CleanShot 2024-01-09 at 17 36 45@2x

Adds "Passkeys" settings section to sidebar:

CleanShot 2024-01-09 at 17 44 38@2x

Adds passkeys settings page

Empty:
CleanShot 2024-01-09 at 17 45 45@2x


Clicking on the "add" button and "sign in with a passkey" buttons opens a browser dialog. This looks different for every browser, phone, etc.

For example:
CleanShot 2024-01-09 at 17 47 48@2x

With passkeys added:
CleanShot 2024-01-09 at 17 49 38@2x

  • The name is set by the Hanko Passkeys API (our backend). We'll improve the name soon so it's not just a semi-random string and more user friendly
  • If the passkey was used, the description says e.g. "Created 3 days ago • Last used 5 minutes ago"

Dropdown stolen straight from API settings page:
CleanShot 2024-01-09 at 17 53 13@2x

Copy link

vercel bot commented Apr 25, 2024

@merlindru is attempting to deploy a commit to the cal Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions bot added app-store area: app store, apps, calendar integrations, google calendar, outlook, lark, apple calendar app-store-template Created by Linear-GitHub Sync community Created by Linear-GitHub Sync foundation Low priority Created by Linear-GitHub Sync labels Apr 25, 2024
Copy link
Contributor

github-actions bot commented Apr 25, 2024

Thank you for following the naming conventions! 🙏 Feel free to join our discord and post your PR link.

@github-actions github-actions bot added the ❗️ .env changes contains changes to env variables label Apr 25, 2024
Copy link

New and removed dependencies detected. Learn more about Socket for GitHub ↗︎

Package New capabilities Transitives Size Publisher
npm/js-tokens@4.0.0 None 0 15.1 kB lydell
npm/object-assign@4.1.1 None 0 5.49 kB sindresorhus
npm/regenerator-runtime@0.14.1 None 0 27.9 kB benjamn
npm/typescript@5.4.5 None 0 32.4 MB typescript-bot

🚮 Removed packages: npm/superjson@1.9.1

View full report↗︎

@PeerRich PeerRich changed the title Hanko passkeys support feat: Hanko passkeys support Apr 25, 2024
PeerRich
PeerRich previously approved these changes Apr 25, 2024
.env.example Outdated
@@ -116,15 +116,31 @@ NEXT_PUBLIC_FRESHCHAT_HOST=
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
GOOGLE_LOGIN_ENABLED=false

# - GOOGLE CALENDAR/MEET/LOGIN
# Hanko Passkeys Config
Copy link
Member

Choose a reason for hiding this comment

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

lets mention this lower in the file

Copy link
Author

Choose a reason for hiding this comment

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

yeah was unsure about that. wanted to place it below google auth stuff since i did that everywhere else, but agreed, for the env file its a little too high up

@merlindru
Copy link
Author

We'd really like to use the passkey icon (not part of lucide, it's an SVG)

however the components that made use of it (Button, EmptyPage, ...) have all been changed to only accept a lucide icon name (string)

Is there any way we still could use a custom component as an icon for these?

@merlindru merlindru marked this pull request as ready for review April 25, 2024 08:06
@graphite-app graphite-app bot requested review from a team April 25, 2024 08:06
@dosubot dosubot bot added authentication area: authentication, auth, google sign in, password, SAML, password reset, can't log in ✨ feature New feature or request labels Apr 25, 2024
Copy link

graphite-app bot commented Apr 25, 2024

Graphite Automations

"Add foundation team as reviewer" took an action on this PR • (04/25/24)

1 reviewer was added to this PR based on Keith Williams's automation.

"Add consumer team as reviewer" took an action on this PR • (04/25/24)

1 reviewer was added to this PR based on Keith Williams's automation.

@merlindru
Copy link
Author

While PASSKEY_LOGIN_ENABLED hides all passkey related UI on the login page, the settings page as well as the API routes to register and remove passkeys stay enabled

These aren't strictly related to logging in with Passkeys; do you feel like the env var should disable everything related to passkeys or just the ability to login?

@@ -62,6 +63,7 @@
"@stripe/react-stripe-js": "^1.10.0",
"@stripe/stripe-js": "^1.35.0",
"@tanstack/react-query": "^5.17.15",
"@teamhanko/passkeys-next-auth-provider": "^0.2.7",
Copy link
Contributor

Choose a reason for hiding this comment

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

I wish Node had a better way to make optional dependencies 🤷‍♂️

Copy link
Author

@merlindru merlindru Apr 25, 2024

Choose a reason for hiding this comment

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

I'm not sure since when npm has this, but there is optionalDependencies

https://docs.npmjs.com/cli/v10/configuring-npm/package-json#optionaldependencies

Was supported in yarn v1 so I assume v2+ has it too

Copy link
Author

Choose a reason for hiding this comment

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

Opted not to do this for now because optionalDependencies isn't in use for any other packages atm. Also I'm unsure if a build without installing optional deps would be successful, especially because of import behavior, haven't tested it

Do you want me to test this? What other not-strictly-needed dependencies are there that could be moved to optionalDependencies (at least in theory)?


export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!tenantId || !apiKey) {
return res.status(503).json({ message: "Passkey API not configured" });
Copy link
Contributor

Choose a reason for hiding this comment

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

Is 503 the best status code to use here?

Copy link
Author

@merlindru merlindru Apr 25, 2024

Choose a reason for hiding this comment

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

Other routes use 403: https://github.com/calcom/cal.com/blob/main/apps/web/pages/api/auth/signup.ts#L23

However, MDN says

[...] 503 Service Unavailable server error response code indicates that the server is not ready to handle the request.

Common causes are a server that is down for maintenance or that is overloaded. This response should be used for temporary conditions [...]

While in my mind 403 has always been more for insufficient permissions etc

What do you think? Maybe 403 for consistency's sake?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'd prefer 403 Forbidden, 503 implies a server issue but most of the time this'll be a client issue (don't access the route)

Copy link
Contributor

Choose a reason for hiding this comment

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

Don't think a 403 is correct here though because that leads the client to believe they are lacking permissions when in reality the functionality isn't supported at all due to a lack of server configuration. Would consider 501 Not Implemented or 405 Method Not Allowed instead.

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Author

Choose a reason for hiding this comment

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

If everyone is fine with it, will go with 501 - seems like the most appropriate IMO

onClick={async (e) => {
e.preventDefault();
await signInWithPasskey({
tenantId: process.env.NEXT_PUBLIC_HANKO_PASSKEYS_TENANT_ID!,
Copy link
Contributor

Choose a reason for hiding this comment

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

Forbidden non-null assertion needs an eslint comment to exclude or check to prevent

Copy link
Author

Choose a reason for hiding this comment

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

I'll check for it explicitly

lastUsedAt?: string;
}

const PasskeyListItem = (props: Passkey & { onRemoved?: () => any }) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Try to avoid using any

Copy link
Author

Choose a reason for hiding this comment

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

Crap, I resolved this ☝️ but missed an onError: (e: any) => {} in another place. Sorry - I'll wait for more feedback before fixing this (so I don't make too many commits)

Copy link
Contributor

@emrysal emrysal left a comment

Choose a reason for hiding this comment

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

While PASSKEY_LOGIN_ENABLED hides all passkey related UI on the login page, the settings page as well as the API routes to register and remove passkeys stay enabled

These aren't strictly related to logging in with Passkeys; do you feel like the env var should disable everything related to passkeys or just the ability to login?

In line with current practises it's not needed to fully "unregister" routes

We'd really like to use the passkey icon (not part of lucide, it's an SVG)

Feel free to modify the button component to allow StartIcon's that aren't strings, ideally in a way it doesn't accept lucide icons (strings should be used instead).

Comment on lines +49 to +54
const IS_PASSKEY_LOGIN_ENABLED = !!(
process.env.PASSKEY_LOGIN_ENABLED === "true" &&
process.env.NEXT_PUBLIC_HANKO_PASSKEYS_TENANT_ID &&
process.env.HANKO_PASSKEYS_API_KEY
);

Copy link
Member

Choose a reason for hiding this comment

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

I think we already have this in constants - lets put it from there

Copy link
Author

Choose a reason for hiding this comment

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

We do, but the GOOGLE variables are also duplicated in next-auth-options, so I did the same to stay consistent. Do you know if there's any reason for this?

Otherwise we could import both from constants.ts.
(I don't think the google constants differ from the ones in next-aut-options.ts in any way)

@keithwillcode keithwillcode added this to the v4.1 milestone May 8, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
app-store area: app store, apps, calendar integrations, google calendar, outlook, lark, apple calendar app-store-template Created by Linear-GitHub Sync authentication area: authentication, auth, google sign in, password, SAML, password reset, can't log in community Created by Linear-GitHub Sync ❗️ .env changes contains changes to env variables ✨ feature New feature or request foundation Low priority Created by Linear-GitHub Sync
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[CAL-2984] Add Passkey support
5 participants