PassKey in Go
Tags: go, security, tutorial, javascript
Published: 2024-02-25 11:56:41

## 😅 What?

In this article, we are implementing full-working auth flow by [WebAuthn / PassKey](https://teampassword.com/blog/passkey-vs-webauthn) 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](https://www.corbado.com/blog/passkey-tutorial-how-to-implement-passkeys) 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:
- [github.com/go-webauthn/webauthn](github.com/go-webauthn/webauthn) – default and looks like the only lib for `webauthn`on go
- [github.com/google/uuid](github.com/google/uuid) – UUID provider, we will need it to generate session keys. Totally optional

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:

- https://simplewebauthn.dev/docs/packages/browser – JS lib to use WebAuthn browser API
- https://getbootstrap.com/docs/5.0 – just to save time

### 🏛️ Architecture

#### Registration

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

![PassKey auth flow schema](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/a0bri1dgs8nsm2uqpxy4.png)

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. 
```go
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)  
}
```
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`
```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  
}
```
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`
```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  
}
```

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`
```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>
```
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](https://simplewebauthn.dev/docs/packages/browser) 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`
```javascript
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);  
    }  
}
```

### 🤔 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
- https://github.com/egregors/go-passkey?tab=readme-ov-file
* [https://github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn)
* [https://simplewebauthn.dev/docs/packages/browser](https://simplewebauthn.dev/docs/packages/browser)
* [https://www.corbado.com/blog/passkey-tutorial-how-to-implement-passkeys](https://www.corbado.com/blog/passkey-tutorial-how-to-implement-passkeys)
* https://teampassword.com/blog/passkey-vs-webauthn
* https://github.com/herrjemand/awesome-webauthn?tab=readme-ov-file