DEV Community

Cover image for PassKey in Go
Egregors
Egregors

Posted on • Edited on

PassKey in Go

πŸ˜… What?

In this article, we are implementing full-working auth flow by WebAuthn / PassKey mechanism. We gonna use Go and vanilla JS with minimal amount of dependencies.

πŸ€¦β€β™€οΈ Why?

You can easily find a few pretty good articles about WebAuthn | PassKey. One of them has a lot of details and solution example on node.js. I recommend checking it out first.

In this post, I'd like to tell about the implementation PassKey auth on a particular stack: Go + vanilla JS. We will make a backend of top of webauthn go lib, add a simple web page for registration | login, HTTP server to serve static and provide PassKey API.

A code tells us mach more, right?

TLDR; https://github.com/egregors/go-passkey

πŸ§‘β€πŸ’» Implementation

So, in general we want to make a tiny website, which allow us to sign up new user with only username and PassKey and login with the same creds.

For backend, I gonna use go (go1.21.7) with the only two dependencies:

We're going to implement a web server to serve static (index.html, js, css) and API to provide backend part of auth process.

The frontend will be just a simple HTML form with username input and Registration and Login buttons. Client side of webauthn will represents in just a few JavaScript functions.

– What? But... but we need to use React even for absolutely not suitable problems...
– No, we don't.

It will also be a few dependencies:

πŸ›οΈ Architecture

Registration

On a high level, the whole process does not seem very complicated.

PassKey auth flow schema

First, we ask username and send it to backend. In BeginRegistration API endpoint, we are trying to get existing user or create a new one. Here we call webAuthn.BeginRegistration to get auth options and sessions data. Both contain Challenge which should be signed by authenticator. Create a new session, put it with a session data from webAuthn to the store. And send options back to client.

On a client, we got an auth options and now ready to communicate with browser WebAuthn API. Here we are using JS lib and call SimpleWebAuthnBrowser.startRegistration (inside register function). The lib asks the authenticator to create a new Passkey. Authenticator generates new key pair and provides attestationResponse. Attestation contains credential information and signed Challenge. Now we need just forward this attestationResponse to backend, by calling FinishRegistration endpoint.

On a backend side we just get session key from headers, with session information we can get User. After that we should provide user, session and attestation result for validation into webAuthn. If signature is correct and all other validation are passed, we'll get Credential (public key and some meta) of current user. Save it to the store and clear the session. Well done!

Login

It may not be obvious, but the login process looks almost the same as the registration process. The only difference here is we don't ask authenticator to create a new keys pair before signing the challenge. Let's just to implement it and you're going to see it be yourself.

πŸŽ’ Backend

Before we start making API, we need to provide required dependencies. To deal with webAuthn we need a data store and webAuthn compatible User models.

type PasskeyUser interface {  
   webauthn.User  
   AddCredential(*webauthn.Credential)  
   UpdateCredential(*webauthn.Credential)  
}  

type PasskeyStore interface {  
   GetUser(userName string) PasskeyUser  
   SaveUser(PasskeyUser)  
   GetSession(token string) webauthn.SessionData  
   SaveSession(token string, data webauthn.SessionData)  
   DeleteSession(token string)  
}
Enter fullscreen mode Exit fullscreen mode

Implementation of these stuff is not a main topic here, so let's skip it. (You always can find the whole code on GitHub: https://github.com/egregors/go-passkey?tab=readme-ov-file)

We are starting from creating webauthn backend and setting up the server:
./main.go

package main  

import (  
   "encoding/json"  
   "fmt"   
   "log"   
   "net/http"   
   "os"  

   "github.com/go-webauthn/webauthn/webauthn"   
   "github.com/google/uuid"
)  

var (  
   webAuthn *webauthn.WebAuthn  
   err      error  

   datastore PasskeyStore  
   l         Logger  
)

func main() {  
   l = log.Default()  

   proto := getEnv("PROTOCOL", "http")  
   host := getEnv("HOST", "localhost")  
   port := getEnv("PORT", ":8080")  
   origin := fmt.Sprintf("%s://%s%s", proto, host, port)  

   l.Printf("[INFO] make webauthn config")  
   wconfig := &webauthn.Config{  
      RPDisplayName: "Go Webauthn",    // Display Name for your site  
      RPID:          host,             // Generally the FQDN for your site  
      RPOrigins:     []string{origin}, // The origin URLs allowed for WebAuthn  
   }  

   l.Printf("[INFO] create webauthn")  
   if webAuthn, err = webauthn.New(wconfig); err != nil {  
      fmt.Printf("[FATA] %s", err.Error())  
      os.Exit(1)  
   }  

   l.Printf("[INFO] create datastore")  
   datastore = NewInMem(l)  

   l.Printf("[INFO] register routes")  
   // Serve the web files  
   http.Handle("/", http.FileServer(http.Dir("./web")))  

   // Add auth the routes  
   http.HandleFunc("/api/passkey/registerStart", BeginRegistration)  
   http.HandleFunc("/api/passkey/registerFinish", FinishRegistration)  
   http.HandleFunc("/api/passkey/loginStart", BeginLogin)  
   http.HandleFunc("/api/passkey/loginFinish", FinishLogin)  

   // Start the server  
   l.Printf("[INFO] start server at %s", origin)  
   if err := http.ListenAndServe(port, nil); err != nil {  
      fmt.Println(err)  
   }  
}

// getEnv is a helper function to get the environment variable
func getEnv(key, def string) string {  
   if value, exists := os.LookupEnv(key); exists {  
      return value  
   }  

   return def  
}
Enter fullscreen mode Exit fullscreen mode

As you see, here we create a webAuthn instance and add 5 http handlers:

  • / – going to serve static files from ./web: index.html, css and script.js
  • /api/passkey/registerStart – get username from client, send auth options back
  • /api/passkey/registerFinish – get new public key and attestation from client, sent validation result
  • /api/passkey/loginStart – get username, send auth options back
  • /api/passkey/loginFinish – get attestation from client, sent validation

Each of Start | Finish hanlder look simular:

./main.go

func BeginRegistration(w http.ResponseWriter, r *http.Request) {  
   l.Printf("[INFO] begin registration ----------------------\\")  

   username, err := getUsername(r)  
   if err != nil {  
      l.Printf("[ERRO] can't get user name: %s", err.Error())  
      panic(err)  
   }  

   user := datastore.GetUser(username) // Find or create the new user  

   options, session, err := webAuthn.BeginRegistration(user)  
   if err != nil {  
      msg := fmt.Sprintf("can't begin registration: %s", err.Error())  
      l.Printf("[ERRO] %s", msg)  
      JSONResponse(w, "", msg, http.StatusBadRequest)  

      return  
   }  

   // Make a session key and store the sessionData values  
   t := uuid.New().String()  
   datastore.SaveSession(t, *session)  

   JSONResponse(w, t, options, http.StatusOK) // return the options generated with the session key  
   // options.publicKey contain our registration options
}  

func FinishRegistration(w http.ResponseWriter, r *http.Request) {  
   // Get the session key from the header  
   t := r.Header.Get("Session-Key")  
   // Get the session data stored from the function above  
   session := datastore.GetSession(t) // FIXME: cover invalid session  

   // In out example username == userID, but in real world it should be different   user := datastore.GetUser(string(session.UserID)) // Get the user  

   credential, err := webAuthn.FinishRegistration(user, session, r)  
   if err != nil {  
      msg := fmt.Sprintf("can't finish registration: %s", err.Error())  
      l.Printf("[ERRO] %s", msg)  
      JSONResponse(w, "", msg, http.StatusBadRequest)  

      return  
   }  

   // If creation was successful, store the credential object  
   user.AddCredential(credential)  
   datastore.SaveUser(user)  
   // Delete the session data  
   datastore.DeleteSession(t)  

   l.Printf("[INFO] finish registration ----------------------/")  
   JSONResponse(w, "", "Registration Success", http.StatusOK) // Handle next steps  
}  

func BeginLogin(w http.ResponseWriter, r *http.Request) {  
   l.Printf("[INFO] begin login ----------------------\\")  

   username, err := getUsername(r)  
   if err != nil {  
      l.Printf("[ERRO]can't get user name: %s", err.Error())  
      panic(err)  
   }  

   user := datastore.GetUser(username) // Find the user  

   options, session, err := webAuthn.BeginLogin(user)  
   if err != nil {  
      msg := fmt.Sprintf("can't begin login: %s", err.Error())  
      l.Printf("[ERRO] %s", msg)  
      JSONResponse(w, "", msg, http.StatusBadRequest)  

      return  
   }  

   // Make a session key and store the sessionData values  
   t := uuid.New().String()  
   datastore.SaveSession(t, *session)  

   JSONResponse(w, t, options, http.StatusOK) // return the options generated with the session key  
   // options.publicKey contain our registration options
}  

func FinishLogin(w http.ResponseWriter, r *http.Request) {  
   // Get the session key from the header  
   t := r.Header.Get("Session-Key")  
   // Get the session data stored from the function above  
   session := datastore.GetSession(t) // FIXME: cover invalid session  

   // In out example username == userID, but in real world it should be different   user := datastore.GetUser(string(session.UserID)) // Get the user  

   credential, err := webAuthn.FinishLogin(user, session, r)  
   if err != nil {  
      l.Printf("[ERRO] can't finish login %s", err.Error())  
      panic(err)  
   }  

   // Handle credential.Authenticator.CloneWarning  
   if credential.Authenticator.CloneWarning {  
      l.Printf("[WARN] can't finish login: %s", "CloneWarning")  
   }  

   // If login was successful, update the credential object  
   user.UpdateCredential(credential)  
   datastore.SaveUser(user)  
   // Delete the session data  
   datastore.DeleteSession(t)  

   l.Printf("[INFO] finish login ----------------------/")  
   JSONResponse(w, "", "Login Success", http.StatusOK)  
}  

// JSONResponse is a helper function to send json responsefunc JSONResponse(w http.ResponseWriter, sessionKey string, data interface{}, status int) {  
   w.Header().Set("Content-Type", "application/json")  
   w.Header().Set("Session-Key", sessionKey)  
   w.WriteHeader(status)  
   _ = json.NewEncoder(w).Encode(data)  
}  

// getUsername is a helper function to extract the username from json requestfunc getUsername(r *http.Request) (string, error) {  
   type Username struct {  
      Username string `json:"username"`  
   }  
   var u Username  
   if err := json.NewDecoder(r.Body).Decode(&u); err != nil {  
      return "", err  
   }  

   return u.Username, nil  
}
Enter fullscreen mode Exit fullscreen mode

And... in fact, all the work on the server side that needed to be done.

πŸ‘¨β€πŸŽ¨ Frontend

For frontend part we need first of all HTML page
./web/index.html

<!DOCTYPE html>  
<html lang="en">  
<head>  
    <meta charset="UTF-8">  
    <meta name="viewport" content="width=device-width, initial-scale=1.0">  
    <title>Passkey</title>  
    <link href="bootstrap.min.css" rel="stylesheet">  
</head>  
<body>  

<div class="container d-flex justify-content-center align-items-center vh-100">  
    <div class="bg-light p-5 rounded w-50">  
        <h1 class="mb-4 text-center">πŸ”‘ Passkey</h1>  
        <div class="text-center" id="message"></div>  
        <div class="mb-3">  
            <input type="text" class="form-control" id="username" placeholder="username">  
        </div>        
        <div class="d-grid gap-2">  
            <div class="row">  
                <div class="col">  
                    <button class="btn btn-primary w-100" id="registerButton">Register</button>  
                </div>                
                <div class="col">  
                    <button class="btn btn-primary w-100" id="loginButton">Login</button>  
                </div>            
            </div>        
        </div>    
    </div>
</div>  

<script src="index.es5.umd.min.js"></script>  
<script src="script.js"></script>  
</body>  
</html>
Enter fullscreen mode Exit fullscreen mode

Here we just make a simple "form" with username input, two buttons and place to messages. I use bootstrap ccs here to make the form a bit prettier and index.es5.umd.min.js – js lib for working with browser API. We do not use here any other frameworks. Just vanilla JS and SimpleWebAuthnBrowser lib.

All the magic will be inside script.js. Just kidding, there is no magic here at all.
./web/script.js

document.getElementById('registerButton').addEventListener('click', register);  
document.getElementById('loginButton').addEventListener('click', login);  


function showMessage(message, isError = false) {  
    const messageElement = document.getElementById('message');  
    messageElement.textContent = message;  
    messageElement.style.color = isError ? 'red' : 'green';  
}  

async function register() {  
    // Retrieve the username from the input field  
    const username = document.getElementById('username').value;  

    try {  
        // Get registration options from your server. Here, we also receive the challenge.  
        const response = await fetch('/api/passkey/registerStart', {  
            method: 'POST', headers: {'Content-Type': 'application/json'},  
            body: JSON.stringify({username: username})  
        });  
        console.log(response)  

        // Check if the registration options are ok.  
        if (!response.ok) {  
            const msg = await response.json();  
            throw new Error('User already exists or failed to get registration options from server: ' + msg);  
        }  

        // Convert the registration options to JSON.  
        const options = await response.json();  
        console.log(options)  

        // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello).  
        // A new attestation is created. This also means a new public-private-key pair is created.        
        const attestationResponse = await SimpleWebAuthnBrowser.startRegistration(options.publicKey);  

        // Send attestationResponse back to server for verification and storage.  
        const verificationResponse = await fetch('/api/passkey/registerFinish', {  
            method: 'POST',  
            headers: {  
                'Content-Type': 'application/json',  
                'Session-Key': response.headers.get('Session-Key')  
            },  
            body: JSON.stringify(attestationResponse)  
        });  


        const msg = await verificationResponse.json();  
        if (verificationResponse.ok) {  
            showMessage(msg, false);  
        } else {  
            showMessage(msg, true);  
        }  
    } catch  
        (error) {  
        showMessage('Error: ' + error.message, true);  
    }  
}  

async function login() {  
    // Retrieve the username from the input field  
    const username = document.getElementById('username').value;  

    try {  
        // Get login options from your server. Here, we also receive the challenge.  
        const response = await fetch('/api/passkey/loginStart', {  
            method: 'POST', headers: {'Content-Type': 'application/json'},  
            body: JSON.stringify({username: username})  
        });  
        // Check if the login options are ok.  
        if (!response.ok) {  
            const msg = await response.json();  
            throw new Error('Failed to get login options from server: ' + msg);  
        }  
        // Convert the login options to JSON.  
        const options = await response.json();  
        console.log(options)  

        // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello).  
        // A new assertionResponse is created. This also means that the challenge has been signed.        
        const assertionResponse = await SimpleWebAuthnBrowser.startAuthentication(options.publicKey);  

        // Send assertionResponse back to server for verification.  
        const verificationResponse = await fetch('/api/passkey/loginFinish', {  
            method: 'POST',  
            headers: {  
                'Content-Type': 'application/json',  
                'Session-Key': response.headers.get('Session-Key'),  
            },  
            body: JSON.stringify(assertionResponse)  
        });  

        const msg = await verificationResponse.json();  
        if (verificationResponse.ok) {  
            showMessage(msg, false);  
        } else {  
            showMessage(msg, true);  
        }  
    } catch (error) {  
        showMessage('Error: ' + error.message, true);  
    }  
}
Enter fullscreen mode Exit fullscreen mode

πŸ€” What next?

So, now we know how to register and login users by passKey, but is it a complete solution? Of course not!

  • Session processing are simplified to maintain low complicity level (session key could be easily stolen)
  • Store is just inmem struct with a few maps (thread unsafe!)
  • We didn't provide any middleware to actually use authentication (some public and some private routes)

Besides, we left here a pair of fascinating question:

  • How to allow user to have several passkeys associated with one account?
  • What should we do if credential.Authenticator.CloneWarning is true?
  • It would be nice to have some sort of middleware to handle User status.

πŸ“š Reference

Top comments (2)

Collapse
 
vdelitz profile image
vdelitz

Nice tutorial!

Collapse
 
sabrina_duquaine_4f104694 profile image
Sabrina Duquaine

very helpful