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
- Client-Side: React Vite with
nakama-js
client library. - 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.