Building a lightning auth api from scratch

A few weeks ago, while working on the new version of the makers.bolt.fun platform, we wanted to make Lightning Authentication the primary authentication method (aka LNURL-auth).

But unfortunately, since it is a relatively new protocol, I struggled to find enough resources that explain how to implement it.

So I had to spend a lot of time going through trial and error until I finally got something working.

I don’t want you to waste a lot of time on it like I did, so that’s why I’m sharing how I approached & implemented this system. Hopefully, you will set it up & run it within a day!!

First things first, what is LNURL-Auth??

LNURL-auth is an authentication protocol that authenticates a user using a digital signature only, so there is no need for any credentials (email, password), no need for a social media account, & your identity remains private on the websites you visit. All you need is a lightning wallet that supports LNURL-auth like Alby, Breez, Phoenix, … etc.

Note: This post isn’t an explanation of the protocol, it’s about implementing it.

So if you are not very familiar with LNURL-auth, then I would strongly recommend that you get acquainted with the base specs here.

If you want to learn why you should use LNURL-auth instead of a regular auth system, then this article written by PeakShift is a great start:
Passwordless Authentication Pt.1

With that out of the way, let’s get started !!!

So, how do we implement LNURL-auth??

Luckily, the wallet part is already done by the wallets themselves,

so we don’t need to worry about signing or dealing with private/public keys.

What we need to build ourselves is the api and the client app.

Before you build an LNURL-auth system from scratch, I should mention that there is already a passport.js package that implements LNURL-auth with express-sessions. The package also handles a lot of the things straight out-of-the-box for you.

If this is the case, then why re-build it ourselves??

1- Your backend api is not built with node-js / express-js

2- You are using serverless functions, and passport-lnurl-auth makes use of express-sessions, therefore it’s tricky to get express-sessions working properly in a serverless environment. (This was the case with us.)

3- You love to know how things are working under the hood, & you don’t like magic-black-boxes very much (This is me).

If these cases sound familiar, then you’ve come to the right place!!

Implementation

We can jump straight into coding, but I like to draw a flowchart or a pseudo-code for anything complex before I start coding it. This makes coding it a lot easier. So let’s do it this way here as well.

(Note: if you haven’t read the specs page linked above yet, then now is a good time to read it. When you’re done, come back here and I’ll be waiting for you ☺)

By the end we want to have a simple login page. When we navigate to this page, we will generate a lnurl-auth url, show it to the user, then the user can scan the QR with his wallet OR use his browser wallet extension. Once they have logged in, the page shows the user their basic stored info (like username, avatar, … etc)

Let’s think about each part, figure out what different api endpoints we need, and what each one does.

1- Generating the auth url.

eg. “/api/get-login-url”

We need an api that can generate a new lnurl-auth each time a request is made.

The generated url should contain a unique K1 that is not used for other urls. We can keep used k1s in a store (e.g. DB)

Also, we want to keep track of the user that requested generating this login-url. We do this so that we are aware when a user logs in successfully with the same browser, or even through an external wallet.

This works by the generating of a jwt token containing the unique k1 of the generated url. This we can send back as a signed cookie (Basically acting as a session cookie)

So, to put it in pseudo-code:

- Generate a random k1 that isn’t used before

- Keep it stored somewhere

- generate a lnurl-auth url that contains this k1

- create a jwt containing the k1 & sign it with some secret

- return the generated url, & the jwt cookie

2- The login api.

eg. “/login?tag=login&k1=..&sig=..”

This is the generated url from the previous step. It will be called from the user’s wallet and it will receive the k1, linking key, & signature.

First, it’ll have to verify the signature, which is a straightforward task, if we make use of the ‘lnurl’ npm package. However, if you don’t want to use a package for it, you can easily implement the code yourself. It’s not complicated.

Now here comes the important part (& the slightly more difficult one)

After verifying the signature, we will generate a jwt authentication token, then we will associate it with the k1 of the auth-url, & finally store this pair (k1, authToken) in some kind of store (Database, memory, …etc).

This authToken is the accessToken that will be given to the user to access protected resources.

But how will we send this auth token to the user who made the original request to the “/api/get-login-url” api???

For that we will use a final third api:

3- the is-logged-in api.

From its name, this api will check if the user making this request is logged in or not.

How do we know that??

Do you remember the session cookie that contains the k1 that we generated when the user requested the auth-url?

It’ll come in handy now.

This session cookie contains the k1 of the generated url. If the user has logged in successfully, then we must already have a pair of (k1, authToken) in our store.

If we find such a pair, then we can extract the authToken from it, set it as a cookie for the user, and remove this pair from our store.

Oookay…..

but how will the user know when to call this “/is-logged-in” api??

The easiest way would be to keep calling this api while on the login page with a few seconds interval between each call, and when it returns the auth token, we stop calling it, and we redirect to the home page.

Phewwwwwwh…

If you are still with me, then congratulations, we’ve just gone through the hardest part.

Code

What remains is to map each step to code.

So let’s do that.

I will cover the crucial steps of the process, but I’ll skip the trivial details.

I will leave the functions that use the data store for you to implement as you see fit for your project.

1- Generating the auth url

    const lnurl = require('lnurl');
    const jose = require('jose'); // a library used for dealing with JWT tokens

    async function generateAuthUrl() {
        const hostname = CONSTS.LNURL_AUTH_HOST;
        const k1 = await generateSecret(); // generate any string to be used as the k1
        const url = `${hostname}?tag=login&k1=${k1}`;
        const hash = createHash(k1);
        return {
            url,
            encoded: lnurl.encode(url).toUpperCase(),
            k1,
            hash,
        }
    }


    app.get('/api/get-login-url', (req,res)=>{
             const data = await LnurlService.generateAuthUrl();
            const maxAge = 1000 * 60 * 3; //3 mins

            await storePair(hash,null); // this is the (k1,authToken) pair we talked about previously.

            const jwt = await new jose.SignJWT({ hash: data.hashedK1  })
                .setProtectedHeader({ alg: 'HS256' })
                .setIssuedAt()
                .setExpirationTime('5min')
                .sign(Buffer.from(JWT_SECRET, 'utf-8'))

            return res
                .status(200)
                .cookie('login_session', jwt, {
                    maxAge,
                    secure: true,
                    httpOnly: true,
                    sameSite: "none",
                })
                .json(data);
    })

2- Login Api

     app.get('/api/login', (req,res)=>{
            try{
                const { tag, k1, sig, key } = req.query;
                if(!lnurl.verifyAuthorizationSignature(sig,k1,key))
                    throw new Error('invalid signature');
                if(!isValidK1(k1))
                    throw new Error("this k1 isn't generated by us");

                const user = createUser(key); // method for creating a new user if not exist before for this key
                                              // think of the key as an id.


                // generate the auth jwt token
                const hour = 3600000
                const maxAge = 30 * 24 * hour;

                const jwt = await new jose.SignJWT({ pubKey: key })
                    .setProtectedHeader({ alg: 'HS256' })
                    .setIssuedAt()
                    .setExpirationTime(maxAge)
                    //TODO: Set audience, issuer
                    .sign(Buffer.from(JWT_SECRET, 'utf-8'))

                // associate the auth token with the hash in the store
                await storePair(k1, jwt);


                return res.status(200).json({ status: "OK" })

            } catch(error){
                return res.status(400).json({ status: 'ERROR', reason: 'Something wrong happened...' })
            }
    })

3- Is logged in api

```js
 app.get('is-logged-in',(req, res) => {
    try {
        const login_session = req.cookies?.login_session;
        if (login_session) {

            const { payload } = await jose.jwtVerify(login_session, Buffer.from(JWT_SECRET), {
                algorithms: ['HS256'],
            });
            const hash = payload.hash;
            const token = await getAuthTokenByHash(hash); // lookup for a pair of (k1,token) and return the token
            if (!token)
                throw new Error("Not logged in yet")

             removeHashFromStore(hash); // we no longer need this hash

            res
                .status(200)
                .clearCookie('login_session', {
                    secure: true,
                    httpOnly: true,
                    sameSite: "none",
                })
                .cookie('Authorization', token, {
                    maxAge: 3600000 * 24 * 30,
                    secure: true,
                    httpOnly: true,
                    sameSite: "none",
                })
                .json({
                    logged_in: true
                });
        } else {
            res.json({
                logged_in: false
            });
        }
    } catch (error) {
        console.log(error);
        res.json({
            logged_in: false
        })
    }


})


And that should be all!!!!

Congratulations! You made it this far! I know the journey wasn’t an easy one, but hopefully you learned something from it.

If you want to see what the full implementation looks like, you can check the [makers.bolt.fun](https://makers.bolt.fun) [github](https://github.com/peakshift/makers.bolt.fun/tree/dev/api/functions) repo for that:

And if you have any suggestions for improvements, or if you’ve found problems, then please let me know. Cause I’m also a learner in this field, and I make a lot of mistakes.

**Thanks a lot for** reaching the end & I hope you have a nice day!!

My Photo

About Me

I'm a freelance web developer who specializes in frontend.
I have a special love for React. And my personal goal is: Building things that are Awesome! ✨
If you are someone who is building a project where high quality is a MUST, then Let's Chat! I'll be glad to help you with it.

© 2023-present Mohammed Taher Ghazal. All Rights Reserved.