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

Login with magic code #1818

Merged
merged 29 commits into from
Feb 4, 2025
Merged

Login with magic code #1818

merged 29 commits into from
Feb 4, 2025

Conversation

Soxasora
Copy link
Member

@Soxasora Soxasora commented Jan 14, 2025

Description

Partially fixes #727
Introduces a 6-character bech32 magic code (token) that expires in 5 minutes and can be used to login with email on PWA, especially iOS since atm there's no way to open a PWA from a link.

On signIn, the email and the callbackUrl will be temporarily saved on sessionStorage

Upon submitting the magic code, it will build and redirect to a magic link (as we are used to) with the user inserted token and email, callbackUrl from sessionStorage

After 3 failed attempts, a custom useVerificationToken will delete email and token from the database, requiring the user to get a new one.

Introduces MultiInput, a customizable component that allows for n inputs and supports keyboard traveling, can prepend a sequence (1-input, 2-input, etc.)

Screenshots

code input page:
image

invalid magic code:
image

if no callback url or email
image

email template:
image

error page:
image

Additional Context

afaik in this version of NextAuth we can't send parameters from signIn to /verify-request to /email without risking to be disruptive, thus I had to resort to sessionStorage

Also we can't send the default token AND the new custom token

Changed from 'login as [email protected]' to 'login with [email protected]' to make it independent from context (if login or setting an email); removed any reference to links

InputInner component has been modified to check for hideError, if true error messages will be showed by MultiInput instead of InputInner

Checklist

Are your changes backwards compatible? Please answer below:
Changes token generation

On a scale of 1-10 how well and how have you QA'd this change and any features it might affect? Please answer below:
7: login, signup, link email, emails
7: token invalidation occurs on success and on 4th failed attempt

For frontend changes: Tested on mobile, light and dark mode? Please answer below:
Yes

Did you introduce any new environment variables? If so, call them out explicitly here:
No

Progress

Removed checkboxes, was lagging too much

  • fix flex rules
  • disable magic code input if callback is not available
  • magic code input via MultiInput
    • change layout method, inputs are too big on desktop
    • fix mobile layout
    • cleanup
    • comments
    • single error message instead of per input
    • autofocus
  • magic code for non-PWA users
  • check if we can avoid sessionStorage
  • better token invalidation tests
  • avoid isPWA duplicate (pull-to-refresh.js; email.js)
  • expire token/session after 3 failed tries
  • use bech32 as generator
  • Auth, revise token generation
  • UX, better email
  • UX, adjust remaining email templates
  • cleanup

@Soxasora Soxasora force-pushed the magic_login branch 2 times, most recently from 62e4f47 to 6954342 Compare January 14, 2025 14:14
@Soxasora Soxasora marked this pull request as ready for review January 14, 2025 19:27
@Soxasora
Copy link
Member Author

Soxasora commented Jan 14, 2025

We could also enable magic code for non-PWA users, imho it would be handy, token is 6-digits anyway and you can still click the link

@Soxasora Soxasora marked this pull request as draft January 14, 2025 22:46
@Soxasora Soxasora force-pushed the magic_login branch 2 times, most recently from 3930597 to 19296ab Compare January 16, 2025 12:56
@Soxasora Soxasora changed the title fix: cannot login with email on PWA Login with magic code Jan 16, 2025
@Soxasora Soxasora marked this pull request as ready for review January 16, 2025 13:32
@ekzyis ekzyis self-requested a review January 18, 2025 15:46
Copy link
Member

@ekzyis ekzyis left a comment

Choose a reason for hiding this comment

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

Turns out I didn't submit this review two days ago 👀

I didn't test yet, only looked at the code. Left some questions.

We could also enable magic code for non-PWA users, imho it would be handy, token is 6-digits anyway and you can still click the link

I think this is the way to go. Less complexity in our code, less confusing for users and would allow to use emails on mobile to login on desktop and vice versa.


useEffect(checkPWA, [])
useEffect(() => {
typeof window !== 'undefined' && setIsPWA(checkPWA(window))
Copy link
Member

Choose a reason for hiding this comment

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

Do you need to check typeof window !== undefined? Do effects not always run on the client?

Copy link
Member Author

@Soxasora Soxasora Jan 20, 2025

Choose a reason for hiding this comment

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

That's what I thought and still believe, I had to include typeof window !== undefined because it seems that the code is being ran before client exposes window or on server while compiling. I might be saying some big bullshit here but that was the error I was getting.

pages/api/auth/[...nextauth].js Outdated Show resolved Hide resolved
Comment on lines 406 to 409
function randomizeToken () {
const words = bech32.toWords(Buffer.from(randomBytes(3)))
return bech32.encode('token', words).slice(6, 12)
}
Copy link
Member

Choose a reason for hiding this comment

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

I need to think more if 24 bits is strong enough even if the link is only valid for 5 mins. Need to do some research how other services deal with the low entropy 🤔

Using bech32 encoding to go from random bytes to guaranteed alphanumeric characters is also an interesting choice. I think I know why you decided to use bech32 but can you elaborate?

Copy link
Member Author

@Soxasora Soxasora Jan 20, 2025

Choose a reason for hiding this comment

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

This is more a product of better safe than sorry. Discussing with @huumn we agreed that bech32 encoding would produce a safer token than a standard 6-digit or even an 8-digit one.

The real reason is the resulting entropy, there are 32 possible characters per every 'digit', 32^6 = 1.073.741.824 (auto calc came in clutch lol).
24-bit or 32-bit entropy will result anyway in 32^6 after slicing!1

Edit:

go from random bytes to guaranteed alphanumeric characters

I realized I didn't address this, it's just one way to randomize the starting seed, we could choose whatever in place of that

Footnotes

  1. if I'm not mistaken

@Soxasora Soxasora marked this pull request as draft January 20, 2025 20:28
@Soxasora Soxasora marked this pull request as ready for review January 22, 2025 14:58
@Soxasora Soxasora marked this pull request as draft January 22, 2025 15:03
@Soxasora
Copy link
Member Author

explore better magic code input

Redrafting to give this full priority to 'complete' the PR as everything else is ready for review

@Soxasora Soxasora marked this pull request as ready for review January 28, 2025 12:54
@Soxasora Soxasora marked this pull request as draft January 28, 2025 22:10
@Soxasora Soxasora marked this pull request as ready for review January 29, 2025 10:06
@@ -27,8 +27,8 @@ export default function AuthError ({ error }) {
return (
<StaticLayout>
<Image className='rounded-1 shadow-sm' width='500' height='375' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/double.gif`} fluid />
<h2 className='pt-4'>This magic link has expired.</h2>
<h4 className='text-muted pt-2'>Get another by logging in.</h4>
<h2 className='pt-4'>Where did the magic go?</h2>
Copy link
Member Author

Choose a reason for hiding this comment

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

this is a placeholder for eventual stackernews-themed sentences 👀

Copy link
Member

@huumn huumn left a comment

Choose a reason for hiding this comment

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

I'll QA and stuff first thing tomorrow, but we'll want to change the usage of the bech32 lib.

@@ -366,9 +403,15 @@ export default async (req, res) => {
await NextAuth(req, res, getAuthOptions(req, res))
}

function randomizeToken () {
const words = bech32.toWords(Buffer.from(randomBytes(3)))
return bech32.encode('token', words).slice(6, 12)
Copy link
Member

@huumn huumn Jan 30, 2025

Choose a reason for hiding this comment

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

I don't think we want to use the bech32 encoding lib:

  1. I don't understand it
  2. it has features that we don't need like prefix and checksum

I suggested bech32 for the alphanumeric character set because in lower case, none of the characters can be confused for other numbers/letters. For upper case, there might be a better character set, but basically we want something like the following to generate the string (we have no need for a checksum):

function generateRandomString(length = 6, charset) {
  // Default charset if none provided
  charset = charset || 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
  
  const bytes = crypto.randomBytes(length);
  let result = '';
  
  // Map each byte to a character in the charset
  for (let i = 0; i < length; i++) {
    result += charset[bytes[i] % charset.length];
  }
  
  return result;
}

Copy link
Member Author

@Soxasora Soxasora Jan 30, 2025

Choose a reason for hiding this comment

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

Okay! I juggled around the bech32 library because we were already using it (appropriately) for other stuff. I'll gladly switch to manual generation and avoid shenanigans.
Didn't know about avoiding confusion with uppercase characters but I see it now, switching asap!

Copy link
Member

Choose a reason for hiding this comment

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

FYI Probably not going to get to QA until later today. Troubleshooting weird lightning stuff.

Copy link
Member

Choose a reason for hiding this comment

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

I didn't get to it at all today - add to #1853 that the lnd cert expired, paginated comments deployment caused a meltdown and had weird ux issues.

The fixes you made look good though. I'll QA first thing tomorrow hopefully.

Copy link
Member Author

@Soxasora Soxasora Jan 31, 2025

Choose a reason for hiding this comment

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

A signal from the universe that paginated comments are 🔥🔥🔥, jokes aside don't worry, this PR ain't going somewhere else while we sleep ^^

Comment on lines 406 to 416
function generateRandomString (length = 6, charset = BECH32_CHARSET) {
const bytes = randomBytes(length)
const result = new Array(length)

// Map each byte to a character in the charset
for (let i = 0; i < length; i++) {
result[i] = charset[bytes[i] % charset.length]
}

return result.join('')
}
Copy link
Member Author

@Soxasora Soxasora Jan 30, 2025

Choose a reason for hiding this comment

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

Just added the bech32 charset to constants, made it default and switched to array building instead of string concatenation (it would allocate a string per addition, actually not a problem with 6 characters but it's better to be safe than sorry)

The result is this:
image

edit: my opinion on array join vs string concatenation is something that I learnt but I might be wrong, the performance change is impossible to perceive anyway!
I'll test performance asap

Okay I was wrong, string concatenation in this use case is 0.17% faster, this edit is basically useless... reverting

Copy link
Member

@huumn huumn left a comment

Choose a reason for hiding this comment

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

I haven't done a line by line review yet but this works well in my QA so far.

Is it intentional that the code must be typed manually? The email says "copy it" but each input only takes one character so I can't copy then paste. I have to manually input each character.

Also it might be nice if the input autofocused to assist copy and pasting.

I'll continue QAing and let you know if anything else comes up

@Soxasora
Copy link
Member Author

Soxasora commented Jan 31, 2025

Is it intentional that the code must be typed manually?

Oh, didn't think about it! I'm going to push it right now.

Also it might be nice if the input autofocused to assist copy and pasting.

That's a bug, autofocus works on desktop hmm... on it, thanks k00b!
edit: autofocus might not be permitted on iOS, weird since a native app can with webview

@huumn
Copy link
Member

huumn commented Feb 1, 2025

Copy/paste works now and it QAs great.

I'll do a line-by-line and finer UI/UX crit tomorrow, but unless there's something too big for me to change easily, you can probably consider this done.

Nice work! 🎆

},
orderBy: {
createdAt: 'desc'
}
})

if (!verificationRequest) return null
Copy link
Member

@huumn huumn Feb 4, 2025

Choose a reason for hiding this comment

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

This forces a redirect to the login page (my change that is) if all 3 attempts have been tried which lets us avoid a the "go back or login again" on the error page. Instead, the button on the error page just goes back in the browser history.

Copy link
Member Author

Choose a reason for hiding this comment

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

This is a way better UX, thanks!

await prisma.verificationToken.update({
where: { id: verificationRequest.id },
data: { attempts: { increment: 1 } }
})
Copy link
Member

Choose a reason for hiding this comment

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

Your changes were perfectly fine, but the increment wasn't an atomic read-write; it read from the db in one tx, then updated it in another. While it's still possible with my fixes for someone to get more than 3 attempts in quick succession, this makes it a little less likely.

Ideally, this code would not have any races at all, but it's a bit tricky because identifier isn't unique. We could wrap it all in a transaction and do an optimistic lock, or make it seriallizable, but our attempt limit is so low they won't get many more attempts anyway.

@huumn
Copy link
Member

huumn commented Feb 4, 2025

Great work! Couldn't fault except for relative nitpicks.

@huumn huumn merged commit be7c702 into stackernews:master Feb 4, 2025
6 checks passed
@Sebastix
Copy link

Sebastix commented Feb 4, 2025

Woa, this is very neat!! Nice solution for fixing the loggin in with the PWA 👍🏻

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

Successfully merging this pull request may close these issues.

Cannot login PWA (iOS) with e-mail or Nostr
4 participants