Resolving JSON Parsing Errors in Nakama Custom Authentication Introduction

The Problem

  • The Nakama client sends a request to the server to authenticate a user.
  • The server responds with a JWT token, but the Nakama client throws a parsing error.
  • The error indicates that the response from the server is not valid JSON.

While integrating Nakama’s custom authentication feature with a React Vite frontend and a GoLang backend, I encountered a persistent parsing error:

SyntaxError: Unexpected token 'e', "eyJhbGciOi"... is not valid JSON

Interestingly, the network response shows a 200 OK status, but the console throws an error during JSON parsing, and I cannot log the server response.

This is just a dummy code to make sure that I am able to communicate between my client and server.

Tech Stack

  1. Client-Side: React Vite with nakama-js client library.
  2. Server-Side: Nakama Server with GoLang for custom RPC functions.

Here’s my App.jsx for the client app

import React, { useEffect } from "react";
import NakamaClient from './utility/nakamaClient'

const App = () => {
  const nakama = new NakamaClient();

  useEffect(() => {
    const authenticateUser = async () => {
      try {

        await nakama.authenticate();
        console.log("User authenticated!");

        await nakama.createSocketConnection();
        console.log("WebSocket connection established.");
      } catch (error) {
        console.error("Authentication failed:", error);
      }
    };

    authenticateUser();
    return () => {
      nakama.closeSocketConnection();
    };
  }, [nakama]); 

  return (
    <div>
      Testing
    </div>
  );
};

export default App;

Here’s my NakamaClient.js code

import { Client } from "@heroiclabs/nakama-js";
import { v4 as uuidv4 } from "uuid";

class NakamaClient {
  constructor() {
    this.client = null;
    this.session = null;
    this.socket = null;
    this.matchID = null;
  }

  initClient() {
    this.client = new Client("defaultkey", "localhost", "7350");
    this.client.ssl = false;
  }

  async inbuilt_authenticate() {
    this.initClient();

    let deviceId = localStorage.getItem("deviceID");
    if (!deviceId) {
      deviceId = uuidv4();
      localStorage.setItem("deviceID", deviceId);
    }

    try {
      this.session = await this.client.authenticateDevice(deviceId, true);
      localStorage.setItem("userID", this.session.user_id);

    } catch (error) {
      console.error("Authentication failed:", error);
      throw error; 
    }
  }

  async authenticate() {
    await this.inbuilt_authenticate(); 
    const rpcid = "authenticate";

    let deviceId = localStorage.getItem("deviceID");
    let UserID = localStorage.getItem("userID");

    try {
      const response = await this.client.rpc(this.session, rpcid, {device_id: deviceId, user_id: UserID});
      console.log("WTF", response.payload); 

    } catch (error) {
      console.error("Custom Authentication failed:", error);
      throw error; 
    }
  }
  
  

  async createSocketConnection() {

    const trace = false;
    this.socket = this.client.createSocket(this.client.ssl, trace);
    try {
      await this.socket.connect(this.session);
      console.log("WebSocket connection established.");
    } catch (error) {
      console.error("Failed to connect WebSocket:", error);
      throw error; 
    }
  }

  closeSocketConnection() {
    if (this.socket) {
      this.socket.disconnect();
      console.log("WebSocket connection closed.");
    }
  }
}

export default NakamaClient;

Now here’s my main.go

package main

import (
	"context"
	"database/sql"
	"time"

	"server/modules"

	"github.com/heroiclabs/nakama-common/runtime"
)

const (
	rpcAuthenticate = "authenticate"
	rpcHealthCheck = "healthcheck"
)

func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
	initStart := time.Now()
        // test to see if server is running
	if err := initializer.RegisterRpc(rpcHealthCheck, modules.RpcEverybodyWithMe); err != nil {
		return err
	}

	// Register the RPC function for authentication
	if err := initializer.RegisterRpc(rpcAuthenticate, modules.AuthenticateDevice()); err != nil {
		return err
	}

	logger.Info("Module loaded in %dms", time.Since(initStart).Milliseconds())
	return nil
}

And here’s my modules.AuthenticateDevice() Function implementation

package modules

import (
	"context"
	"database/sql"
	"encoding/json"
	// "os"
	"time"

	"github.com/dgrijalva/jwt-go"
	"github.com/heroiclabs/nakama-common/runtime"
)

var (
	errInvalidPayloadFormat = runtime.NewError("Invalid payload format", 3)
	errNoPayLoad            = runtime.NewError("no payload provided", 3)
	errDeviceAlreadyExists  = runtime.NewError("device already registered", 3)
	errFailedToRegisterDevice = runtime.NewError("failed to register device", 13)
	errMarshal        = runtime.NewError("cannot marshal type", 13)  
	// errUnmarshal      = runtime.NewError("cannot unmarshal type", 13)
)

type AuthRequest struct {
	DeviceID string `json:"device_id"`
	UserID   string `json:"user_id"`
}

// const (
// 	jwtSecretEnv = "JWT_SECRET"
// )

func getJWTSecret() string {
	// secret := os.Getenv(jwtSecretEnv)
	secret := "makima"
	if secret == "" {
		panic("JWT_SECRET environment variable not set")
	}
	return secret
}

func AuthenticateDevice() func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	return func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
		// Check if payload is empty
		if payload == "" {
			return "", errNoPayLoad
		}

		// Parse the payload into AuthRequest
		var authRequest AuthRequest
		if err := json.Unmarshal([]byte(payload), &authRequest); err != nil {
			logger.Error("Failed to parse payload: %v", err)
			return "", errInvalidPayloadFormat
		}

		// Validate that DeviceID is provided
		if authRequest.DeviceID == "" {
			return "", runtime.NewError("device_id is required", 3)
		}

		// Check if the device is already registered in the database
		userID, err := checkDeviceExists(db, authRequest.DeviceID)
		if err == nil {
			// If device exists, generate and return JWT token for the existing user
			token, err := generateJWT(userID)
			if err != nil {
				logger.Error("Failed to generate JWT for user %s: %v", userID, err)
				return "", err
			}
			return token, nil
		}

		// If device is not found, authenticate the device or create a new user
		userID, username, created, err := nk.AuthenticateDevice(ctx, authRequest.DeviceID, "", true)
		if err != nil {
			logger.Error("Failed to authenticate device: %v", err)
			return "", runtime.NewError("failed to authenticate device", 13)
		}

		// Log successful authentication
		logger.Info("Authenticated device %s for user %s (username: %s, created: %v)", authRequest.DeviceID, userID, username, created)

		// Store the device in the database
		if err := storeDeviceInDatabase(ctx, db, userID, authRequest.DeviceID); err != nil {
			logger.Error("Failed to store device in database: %v", err)
			return "", errFailedToRegisterDevice
		}

		// Generate and return JWT token for the newly authenticated user
		token, err := generateJWT(userID)
		if err != nil {
			logger.Error("Failed to generate JWT for user %s: %v", userID, err)
			return "", runtime.NewError("failed to generate token", 13)
		}

		response := map[string]string{
			"token": token,
		}
		responseJSON, err := json.Marshal(response)
		if err != nil {
			logger.Error("Error marshaling response: %v", err)
			return "", errMarshal
		}
		logger.Info("WTF", responseJSON)
		
		return string(responseJSON), nil		
		
		// return token, nil
	}
}


// GenerateJWT creates a JWT token for the authenticated user.
func generateJWT(userID string) (string, error) {
	secret := getJWTSecret()
	claims := jwt.MapClaims{
		"sub": userID, // Subject: User ID
		"exp": time.Now().Add(time.Hour * 24).Unix(), // Expiry: 24 hours
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString([]byte(secret))
}

func checkDeviceExists(db *sql.DB, deviceID string) (string, error) {
	var userID string
	query := `SELECT user_id FROM device_user WHERE device_id = $1`
	err := db.QueryRow(query, deviceID).Scan(&userID)
	if err != nil {
		if err == sql.ErrNoRows {
			return "", err
		}
		return "", err 
	}
	return userID, nil
}

// StoreDeviceInDatabase stores the device and user association in the database.
func storeDeviceInDatabase(ctx context.Context, db *sql.DB, userID string, deviceID string) error {
	_, err := checkDeviceExists(db, deviceID)
	if err == nil {
		return errDeviceAlreadyExists
	} else if err != sql.ErrNoRows {
		return err
	}

	// Ensure device_id is passed correctly as a string
	query := `INSERT INTO device_user (user_id, device_id) VALUES ($1, $2)`
	_, err = db.ExecContext(ctx, query, userID, deviceID)
	if err != nil {
		return err
	}

	return nil
}

Why Use Built-in Authentication First?

As a beginner, I opted to use Nakama’s built-in device authentication first to obtain a valid session. This is because interacting with the Nakama server (including RPC calls) requires a session token. Once I establish a session, I use this session to implement my custom JWT-based authentication for added control and security.

If there’s a more efficient way to handle this, I’d appreciate any guidance on best practices.

Hello @mahanshAdtiya,

I use this session to implement my custom JWT-based authentication for added control and security.

Can you elaborate on what need you’re trying to meet that is not covered by the provided auth methods?

If you’re using Nakama’s SDKs and the built-in AuthenticateDevice APIs, then you shouldn’t need to implement it yourself - by calling this.client.authenticateDevice(deviceId, true), user creation will be handled automatically for you, without the need of custom Go code, nor to unpack the JWT yourself.

Please clarify and we’ll try to guide you on the best way to achieve what you’re looking for.

Best.