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

Feature request: passwordless authentication with email magic links #119

Open
kivS opened this issue Jul 14, 2022 · 26 comments
Open

Feature request: passwordless authentication with email magic links #119

kivS opened this issue Jul 14, 2022 · 26 comments

Comments

@kivS
Copy link

kivS commented Jul 14, 2022

Hi, would it be possible to have an email passwordless auth flow?

Cheers

@ganigeorgiev
Copy link
Member

This is planned, but for now it is a low priority (I will post soon the roadmap for v1.0.0).

@usmandilmeer
Copy link

Please add the Magic link sign-in method. it will be awoseome.

@Olyno
Copy link

Olyno commented Sep 4, 2022

Please do not add magic link in a first place, add an OTP code system instead. Magic link authenticates you on the current device you're using, so it's not really nice when you're checking the email on your phone but want to authenticate on Desktop.

@fdidron
Copy link

fdidron commented Sep 15, 2022

@ganigeorgiev I'd like to take a stab at a PR in the coming weeks.

A few questions:

I would call the feature MagicLink, so forms, tokens would be named something like UserMagicLinkLogin, UserMagicLinkToken, .. does it make sense ?

The flow would be:

  1. A post endpoint located at: /auth-via-magiclink expects a valid email in the payload.
  2. When hitting that endpoint, the user is found or created by email. A MagicLink Token is generated for that user with a valid period of 15 minutes
  3. A magic link email template is sent with the token to the user.
  4. A post endpoint located at: /confirm-magiclink expects a valid magic link token in the payload.
  5. When hitting that endpoint, the token is validated and if valid authenticates the user.

Then later it's a matter of updating the sdks, there's two approaches I can think of:

  1. Reuse the authViaEmail method so that password becomes optional. If no password is provided initiate a magic link flow. If a password is provided initiate a email/password flow.
  2. Create a dedicated authViaMagicLink method.

@ganigeorgiev
Copy link
Member

@fdidron "Step 5" is not enough, because we'll need to send back the authenticated user data to the application (web or mobile), which currently is not possible, so in other words - the "confirm" endpoint must be a user defined one, so that they can handle the magic-link-token->auth-token exchange.

In any case, let's leave this feature aside for now. We can pick it again after the users refactoring in #376.

@fdidron
Copy link

fdidron commented Sep 15, 2022

@ganigeorgiev I hadn't seen that refactoring proposal, indeed it's better to wait for it before working on magic links. Will keep an eye on the release and will resume the conversation then.

As an aside, I love the idea of having one user entity and leave the profile implementation details to developers.

@A2-NieR
Copy link

A2-NieR commented Sep 19, 2022

Please do not add magic link in a first place, add an OTP code system instead. Magic link authenticates you on the current device you're using, so it's not really nice when you're checking the email on your phone but want to authenticate on Desktop.

Funny, I had such a system on my own backend I built from scratch when I was learning Go, but since I had a months long break I would first need to understand my own code again and then @ganigeorgiev s as well to submit a PR for this 😅
Also I have to rewrite it since mine was using MongoDB, but if there is still interest I may look into it.

@bard
Copy link

bard commented Dec 31, 2022

Having wrestled with this in the past, I want to point out a couple of things that might save people time and frustration.

You can either make the login link stand-alone and valid on any client, or limit its validity to the client that requested it, typically by setting a cookie when the link is requested and verifying it when the link is submitted.

This link+cookie strategy can break in several ways:

  • as pointed out earlier in this discussion, the user might request the link from the phone, and click it on the laptop;
  • less obviously, the user might request the link from an embedded web view (e.g. he followed a link to your app from a social networking app) but open it from the default browser, which doesn't see the cookie set by the web view;
  • or might have installed the web app as a PWA, request the link from there, and again open it from the default browser, which doesn't see the cookie set by the PWA (at least when I last checked).

The stand-alone link isn't without problems:

  • it is vulnerable to interception as well as user error (e.g. forwarding the email);
  • mail scanners and previewers (not at all uncommon in enterprise) will accidentally consume the link.

The above is why I nowadays prefer magic codes, which can provide the security of the link+cookie strategy without the brittleness, and with a reasonable cognitive cost from the user.

@nickisnoble
Copy link

Really hoping for OTP soon!

@xttlegendapi
Copy link

A 6 digit code that expires in 15 minutes should be great

@miguelgargallo

This comment was marked as spam.

@khromov
Copy link

khromov commented Oct 10, 2023

Is there some "user-space" workarounds we can use for now to implement this on the application side? Or some way to extend the existing authentication methods?

@ghostdevv
Copy link
Contributor

@khromov I think it's possible to do this in userland but I haven't tried it, you could also try writing some go/js and extend pocketbase directly

@kalafut
Copy link

kalafut commented Jan 8, 2024

I was just introduced to PocketBase (thanks, HN) and am very interested in seeing about moving the backend for a Flutter app to it. My app currently uses email + OTP (not clickable magic links), so the very first thing I wanted to do was see if an OTP auth for PocketBase could be created.

The POC seemed to work, and I've shared the Go code and a few Dart excerpts in a gist. It was pretty straightforward, but with my ~3 hours of experience with PocketBase I don't know if I'm doing something bad, especially with the manual auth elements. I'd be keen to hear any comments from @ganigeorgiev on the correctness of the approach.

cc @ghostdevv @khromov re: a user-space example.

@ganigeorgiev
Copy link
Member

ganigeorgiev commented Jan 8, 2024

@kalafut From a brief look it looks fine to me. Some nitpics:

  • this is very unlikely to happen but to prevent timing attacks you could use security.Equal(a, b) for the otp string comparison
  • you can replace record.Get("expiration").(types.DateTime) with its return typed equivalent record.GetDateTime("expiration")
  • depending whether retentation is important or not, at some point you may want to setup a cron task to cleanup forgotten otp generations (actually just noted the you have a comment for this)
  • you can also send requests to your custom routes with the SDKs using the pb.send method (Dart doc).

@kalafut
Copy link

kalafut commented Jan 8, 2024

@ganigeorgiev Thanks for the quick review and pointers to some other PocketBase methods. Agree with all the feedback. I do have a todo around a cron task to tidy up the otp table, but didn't get to writing that bit yet (seemed pretty straightforward...)

Thanks again for this project! I'm surprised I'd not bumped into it earlier. The Go + Dart/Flutter + SQLite stack is essentially what I'm running today, so it is a great fit.

@matevarga
Copy link

@ganigeorgiev Thanks for the quick review and pointers to some other PocketBase methods. Agree with all the feedback. I do have a todo around a cron task to tidy up the otp table, but didn't get to writing that bit yet (seemed pretty straightforward...)

Thanks again for this project! I'm surprised I'd not bumped into it earlier. The Go + Dart/Flutter + SQLite stack is essentially what I'm running today, so it is a great fit.

Thanks for that -- https://gist.github.com/matevarga/daf2273ddea0644ba6a6e91ae22442e0 slight extension that enables user creation.

@samducker
Copy link

Hey @matevarga and @ganigeorgiev would you consider this extension in the gist usable as it is now or do you have some plans to merge soon?

I am wondering how best to call this on my JS frontend, or am I better to wrap in a server side endpoint.

@matevarga
Copy link

matevarga commented Mar 17, 2024

Hey @matevarga and @ganigeorgiev would you consider this extension in the gist usable as it is now or do you have some plans to merge soon?

I am wondering how best to call this on my JS frontend, or am I better to wrap in a server side endpoint.

I didn't plan to raise a PR (maybe I should?). The gist kinda works, there's one thing that was definitely missing -- after line 20, there should be
newUser.SetPassword(security.RandomStringWithAlphabet(12, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"))
-- because there's a unique key constraint on token key, and token key is only set when the password is set (I don't see why tokenkey isn't populated even if the password is not set).

Which is, in some way, possibly a bug? (@ganigeorgiev let me know if you think it's a bug, I'll raise a PR for the fix).

@samducker
Copy link

Well I'm working on "the worlds fastest research survey builder" (maybe not with me as the lead engineer lmao, but thats the marketing hook :) )

And anyways when the user pays during the stripe step, I want to just create them an account.

And then if they want to revisit there survey in future they can just login, but I dont want to ask them a password, as it just feels like friction and adding an email during payment just seems normal (you want a receipt)

Anyways my workaround ideas to achieve this were as follows

  • Use magic link/passwordless if available in pocketbase

If not available / usable in pocketbase:

  • Either create a email/password with just a random password and then the first time they login (I could track this in a boolean e.g. has logged in ever etc), maybe I use an admin API if there is one to reset there password, without them having to go to forgot password flow.

--

To make the former passwordless approach work

  • I need to be able to either create a user account either with a passwordless/magic link method or with just a random password.
  • Then I need the whole email a code thing, and verify code

In the latter alternative approach

  • I need a way to be able to change a users password as an admin method. So they don't have to do a forgot password on the first time they login.

I didn't plan to raise a PR (maybe I should?).
This would be awesome ! Let me know if you think it would help with my use case?

@ganigeorgiev
Copy link
Member

@matevarga Just for info - the "passwordHash-tokenKey" thing is expected behavior. With the planned refactoring there will be a dedicated password collection field that will normalize the getter/setters.

@matevarga
Copy link

@matevarga Just for info - the "passwordHash-tokenKey" thing is expected behavior. With the planned refactoring there will be a dedicated password collection field that will normalize the getter/setters.

Great, thanks !

@matevarga
Copy link

Well I'm working on "the worlds fastest research survey builder" (maybe not with me as the lead engineer lmao, but thats the marketing hook :) )

And anyways when the user pays during the stripe step, I want to just create them an account.

And then if they want to revisit there survey in future they can just login, but I dont want to ask them a password, as it just feels like friction and adding an email during payment just seems normal (you want a receipt)

Anyways my workaround ideas to achieve this were as follows

  • Use magic link/passwordless if available in pocketbase

If not available / usable in pocketbase:

  • Either create a email/password with just a random password and then the first time they login (I could track this in a boolean e.g. has logged in ever etc), maybe I use an admin API if there is one to reset there password, without them having to go to forgot password flow.

--

To make the former passwordless approach work

  • I need to be able to either create a user account either with a passwordless/magic link method or with just a random password.
  • Then I need the whole email a code thing, and verify code

In the latter alternative approach

  • I need a way to be able to change a users password as an admin method. So they don't have to do a forgot password on the first time they login.

I didn't plan to raise a PR (maybe I should?).
This would be awesome ! Let me know if you think it would help with my use case?

I will raise a PR after the planned refactoring, although you can already use the code as it is. It works well.

@jorahty
Copy link

jorahty commented Mar 30, 2024

I love PocketBase. My only complaint is lack of support for passwordless authentication, in particular 6-digit OTP via email.

This feature would be greatly appreciated! ❤️🙏

@mosleim
Copy link

mosleim commented Jul 14, 2024

Make a hook to send the otp/magiclink. Because, i need to send the otp/link to whatsapp or telegram.

@benallfree
Copy link

JS hook implementation. This one creates a user record if it doesn't exist, and assigns a random password.

/// <reference path="../types.d.ts" />

routerAdd('POST', '/api/otp/auth', (c) => {
  const dao = $app.dao()
  const parsed = (() => {
    const rawBody = readerToString(c.request().body)
    try {
      const parsed = JSON.parse(rawBody)
      return parsed
    } catch (e) {
      throw new BadRequestError(
        `Error parsing payload. You call this JSON? ${rawBody}`,
        e,
      )
    }
  })()
  const email = parsed.email?.trim()

  console.log(`email: ${email}  `)

  const code = $security.randomStringWithAlphabet(6, `0123456789`)
  console.log(`otp: ${code}`)

  // Delete if exists
  try {
    const record = dao.findFirstRecordByData('otp', `email`, email)
    dao.deleteRecord(record)
  } catch (e) {
    console.error(`Error deleting record: ${e}`)
  }

  // Save the code

  try {
    const collection = dao.findCollectionByNameOrId('otp')
    const record = new Record(collection, {
      email,
      code,
    })
    dao.saveRecord(record)
    console.log(`*** otp record saved: ${record.id}`)
  } catch (e) {
    console.error(`Error saving otp: ${e}`)
    throw new BadRequestError(`Error saving otp: ${e}`)
  }

  const message = new MailerMessage({
    from: {
      address: $app.settings().meta.senderAddress,
      name: $app.settings().meta.senderName,
    },
    to: [{ address: email }],
    subject: `Your 6-digit login code`,
    text: `Your 6-digit login code is: ${code}\n\nPlease enter this code in the app to continue.\n\nIf you didn't request this code, please ignore this email.`,
  })

  $app.newMailClient().send(message)

  return c.json(200, {
    message: `Please check your email for your 6-digit code.`,
  })
})

routerAdd('POST', '/api/otp/verify', (c) => {
  const dao = $app.dao()
  const parsed = (() => {
    const rawBody = readerToString(c.request().body)
    try {
      const parsed = JSON.parse(rawBody)
      return parsed
    } catch (e) {
      throw new BadRequestError(
        `Error parsing payload. You call this JSON? ${rawBody}`,
        e,
      )
    }
  })()
  console.log(`***${JSON.stringify(parsed)}`)
  const email = parsed.email.trim()
  const code = parsed.code

  console.log(`***email: ${email} , code: ${code}`)

  try {
    const record = dao.findFirstRecordByData('otp', 'email', email)
    const storedCode = record.getInt(`code`)
    if (storedCode !== code) {
      throw new BadRequestError(`Invalid code`)
    }
    const created = record.created.time().unixMilli()
    const now = Date.now()
    console.log(`***now:${now}  created:${created}`)
    if (now - created > 60000) {
      //   throw new BadRequestError(`Code expired`)
    }
  } catch (e) {
    console.error(`Error confirming otp: ${e}`)
    throw e
  }

  const userRecord = (() => {
    try {
      return dao.findFirstRecordByData('users', 'email', email)
    } catch (e) {
      console.error(`Error finding user: ${e}`)
      const usersCollection = dao.findCollectionByNameOrId('users')
      const user = new Record(usersCollection)
      try {
        const username = $app
          .dao()
          .suggestUniqueAuthRecordUsername(
            'users',
            'user' + $security.randomStringWithAlphabet(5, '123456789'),
          )
        user.set('username', username)
        user.set('email', email)
        user.set('subscription', 'free')
        user.setPassword($security.randomString(20)) // Fake password (not used)
        dao.saveRecord(user)
      } catch (e) {
        throw BadRequestError(`Could not create user: ${e}`)
      }
    }
  })()

  return $apis.recordAuthResponse($app, c, userRecord)
})
[
    {
        "id": "s3ezh62e61vy0ks",
        "name": "otp",
        "type": "base",
        "system": false,
        "schema": [
            {
                "system": false,
                "id": "y5vzwab0",
                "name": "email",
                "type": "email",
                "required": false,
                "presentable": false,
                "unique": false,
                "options": {
                    "exceptDomains": null,
                    "onlyDomains": null
                }
            },
            {
                "system": false,
                "id": "kbryaqyk",
                "name": "code",
                "type": "number",
                "required": false,
                "presentable": false,
                "unique": false,
                "options": {
                    "min": null,
                    "max": null,
                    "noDecimal": false
                }
            }
        ],
        "indexes": [
            "CREATE INDEX `idx_xUoTrf5` ON `otp` (\n  `email`,\n  `code`\n)",
            "CREATE INDEX `idx_guKleJD` ON `otp` (`email`)",
            "CREATE INDEX `idx_fUk039Z` ON `otp` (`code`)"
        ],
        "listRule": null,
        "viewRule": null,
        "createRule": null,
        "updateRule": null,
        "deleteRule": null,
        "options": {}
    }
]

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

No branches or pull requests