Implementing LNUrl Auth in PHP
May 8, 2022 11:00 AM (2 months ago)

# Implementing LNUrl Auth in PHP

I have been recently working on a Bitcoin Lightning app (that I will launch soon) and I wanted to implement a password-less sign-in and sign-up, leveraging the Public/Private Keys of Bitcoin wallets.

After a bit of research I found out the LNUrl (opens new window) specification, which is a RFC on how to go about implementing experience enhancing functionalities on your Lightning Apps. More specifically, the LNUrl Auth (opens new window) is what we are looking for.

The aim is to make it possible to sign in and sign up without having to store your private keys, email or password on the server. You would just need to scan a QR code with your Lightning wallet and you would be signed in.

Online Users

# Implementation 🔥

Reading through the specs (LNUrl Auth (opens new window)) we learn that we need to do the following:

  • Create a constant $k1 made of 32 bytes of randomly generated data.
  • Create a callback URL that will be called by the Lightning wallet, append the necessary parameters (in our case ?tag=login&k1=$k1&action=login) and encode it according to the LNUrl specs.
  • Share the encoded URL with the user, and possibly render it as a QR code so he can scan it with his Lightning wallet. You can also render it as a link by appending lightning: to the encoded URL <a href="lightning:${lnurl}" /> so that you can interact with the QR code from a browser extension wallet.
  • When the user clicks on the link or scans the QR code, the callback URL will be called by the wallet with the following parameters: $k1, $signature, $wallet_public_key.
  • The called endpoint will then verify the signature and the wallet public key and if everything is correct, it will then login or create the user and potentially store the $k1 in a separate table.

We could implement this in several ways but I will show a simple way to do it. We would need three methods/endpoints:

  • /login Generate a random $k1 at every page refresh + encoded URL and display the Login view
  • /auth Verify the signature and wallet public key and if everything is correct, login or create the user and store the $k1 in a separate table.
  • /check On the login page, check if the $k1 is existing in the database and if it is, redirect to the logged dashboard.

# Code 💻

All good so far, but how exactly do we go about encoding the URL and verifying the signature? Luckily for you I created a package to do just that https://github.com/eza/lnurl-php (opens new window) ⚡️. I will use it in the examples below

I will illustrate the concept by using some of Laravel's helpers, but the main concept is the same.

# First method /login

 <?php
  use eza\lnurl;
  use Illuminate\Support\Str;
  
  public function create()
  {
    $k1 = bin2hex(Str::random(32));
    $lnurl = lnurl\encodeUrl('https://example.com/auth?tag=login&k1='.$k1.'&action=login');
    
    return Inertia::render('Auth/Login', [
      'qrCode' => (new QRCode())->render($lnurl),
      'lnurl' => $lnurl,
      'k1' => $k1,
    ]);
  }

In the example above I am using InertiaJs (opens new window) to render the view and pass the necessary variables to it, and I am wrapping the $lnurl in a QR code render method, so that I can display it in the view like so: <img :src="qrCode" alt="Login QR Code" />.

# Second method /auth

This is the endpoint that the Lightning wallets calls back when you scan the QR code or click on the link. Those 3 parameters, $k1, $signature, $wallet_public_key, are what you will receive from every Lightning wallet that supports LNUrl Auth.

 <?php
  use eza\lnurl;
  
  public function auth(Request $request)
  {
    if (lnurl\auth($request->k1, $request->signature, $request->wallet_public_key)) {
      // find User by $wallet_public_key
      $user = User::where('public_key', $request->key)->first();
      if (!$user) {
          // create User
          $user = User::create([
              'public_key' => $request->wallet_public_key,
          ]);
      }
      // check if $k1 is in the database, if not, add it
      $loginKey = LoginKey::where('k1', $request->k1)->first();
      if (!$loginKey) {
        LoginKey::create([
          'k1' => $request->k1,
          'user_id' => $user->id,
        ]);
      }
      
      return response()->json(['status' => 'OK']);
    }
    
    return response()->json(['status' => 'ERROR', 'reason' => 'Signature was NOT VERIFIED']);
  }

# Third method /check

 <?php
  use eza\lnurl;
  use Illuminate\Support\Str;
  
  public function check(Request $request)
  {
    $loginKey = LoginKey::where(['k1', $request->k1])->first(); 
    // you should also restrict this 👆🏻 by time, and find only the $k1 that were created in the last 5 minutes

    if ($loginKey) {
      $user = User::find($loginKey->user_id);
      Auth::login($user);
      return redirect()->route('dashboard');
    }

    return response()->json([
      'status' => 'success',
    ]);
  }

The endpoint above will check if the $k1 is existing in the database and if it is, log you in and redirect you to the logged dashboard. This could be done by using Web Sockets or by simply having a method mounted on the /login page that polls the above endpoint every few seconds.

setInterval( function() {
  axios.get(route('check', { k1 }))
}, 3000)

This is it. If you have any question just let me know in the comments, and don't forget to leave a star ⭐️ on my package https://github.com/eza/lnurl-php (opens new window). 💪🏻