Heroic Labs Documentation

Custom Authentication with Third-Party Services #

When authenticating users, sometimes their credentials and metadata may be stored in a third-party service. In these situations you can hook in to an existing third-party API to both validate the user and retrieve their metadata. This metadata can then be used to create an associated user in Nakama, effectively linking their external User ID / Username to a Nakama user.

This guide will demonstrate two scenarios in which Nakama’s Custom Authentication feature is used to enable the authentication of users stored in a third-party service.

Bespoke token exchange authentication #

In this example our user’s details are stored in a third-party service that exposes a RESTful API to which we can pass an ID and retrieve user metadata in response. The Nakama server will receive an ID as part of the Custom Authentication flow, pass this along to the third-party API and then extract the user’s ID and username from the response, creating and/or authenticating the Nakama user with this information.

We will achieve this by defining a beforeAuthenticateCustom hook. The function will use configured runtime environment variables to retrieve the hostname of the third-party API and post the incoming in.Account.Id to the API for verification. Once done, the new user ID and username will be used to associate the Nakama user with their third-party user account details.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
var (
	errMarshal = runtime.NewError("cannot marshal type", 13)
	errUnmarshal = runtime.NewError("cannot unmarshal type", 13)
	errApiPost = runtime.NewError("invalid API response", 13)
	errInternal = runtime.NewError("internal server error", 13)
)

func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
	// Register before hook on custom authentication
	err := initializer.RegisterBeforeAuthenticateCustom(beforeAuthenticateCustom)
	if err != nil {
		return fmt.Errorf("error registering before authentication hook: %s", err.Error())
	}

	return nil
}

func beforeAuthenticateCustom(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, in *api.AuthenticateCustomRequest) (*api.AuthenticateCustomRequest, error) {
	// Get third-party API URL from the runtime context
	env := ctx.Value(runtime.RUNTIME_CTX_ENV).(map[string]string)
	apiUrl := env["AUTHENTICATION_API_URL"]

	// Construct a payload to send to the third-party API
	payload, err := json.Marshal(map[string]string {
		"id": in.Account.Id,
	})

	if err != nil {
		logger.Error("could not marshal api payload: %s", err.Error())
		return nil, errMarshal
	}

	// Send the HTTP Post request to the API
	/*
	Expected API Response
	HTTP 200
	{
		"user_id": "<UserId>",
		"username": "<Username>"
	}
	*/
	response, err := http.Post(apiUrl, "application/json", bytes.NewBuffer(payload))

	if err != nil {
		logger.Error("invalid API response: %s", err.Error())
		return nil, errApiPost
	}

	// Close the response when we're done
	defer response.Body.Close()

	// Read the response from the API and parse it as a map[string]string containing user info
	bytes, err := ioutil.ReadAll(response.Body)
	
	if err != nil {
		logger.Error("error reading API response body: %s", err.Error())
		return nil, errInternal
	}
	
	userInfo := make(map[string]string)
	err = json.Unmarshal(bytes, &userInfo)
	
	if err != nil {
		logger.Error("error unmarshaling API response: %s", err.Error())
		return nil, errUnmarshal
	}

	// Update the incoming authenticate request with the new user ID and username
	in.Account.Id = userInfo["user_id"]
	in.Username = userInfo["username"]

	return in, nil
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
let InitModule: nkruntime.InitModule = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer) {
  initializer.registerBeforeAuthenticateCustom(BeforeAuthenticateCustom);
};

const BeforeAuthenticateCustom: nkruntime.BeforeHookFunction<nkruntime.AuthenticateCustomRequest> = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, data: nkruntime.AuthenticateCustomRequest): nkruntime.AuthenticateCustomRequest | void {
  const apiUrl = ctx.env['AUTHENTICATION_API_URL'];
  if (!apiUrl) {
    throw new Error('missing authentication api configuration');
  }

  // Construct a payload to send to the third-party API
  const payload = JSON.stringify({
    id: data.account.id
  });

  // Send the HTTP Post request to the API
  /*
  Expected API Response
  HTTP 200
  {
    "userId": "<UserId>",
    "username": "<Username>"  
  }
  */
  const response = nk.httpRequest(apiUrl, 'post', { 'content-type': 'application/json' }, JSON.stringify(payload));
  if (response.code > 299) {
    logger.error(`API error: ${response.body}`);
    return null
  }
  
  const userInfo = JSON.parse(response.body);
  if (!userInfo.userId || !userInfo.username) {
    logger.error(`invalid API response: ${response.body}`)
    return null;
  }
  
  // Update the incoming authenticate request with the new user ID and username
  data.account.id = userInfo.userId;
  data.username = userInfo.username;
  
  return data;
};
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

The above code (when run against an appropriate third-party API) will create/authenticate a Nakama user given their third-party ID. This ID will be used to retrieve their User ID and Username from the third-party service which will be saved to their Nakama user account. Their third-party User ID will be linked to their Nakama Custom Authentication ID property.

JWT authentication #

JSON Web Tokens (JWTs) are a common way of transmitting user metadata and associated user claims/permissions. These JWTs can be verified by any receiver who knows the secret key used to sign them. This can be extremely useful as it means any service can verify the identity and permissions of a particular user, providing they trust the third-party service that created the JWT.

In this example our beforeAuthenticateCustom function will received and verify the JWT from the client using a secret key specified in a runtime environment variable. Then extract the claims from the JWT and use it to authenticate the user in Nakama, associating the Nakama user with their third-party user ID and username as before. You can use your favorite JWT library to verify the signature of the JWT as well as extract the data from it’s body. For example, the jwt-go library.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
var (
	errJwtVerification = runtime.NewError("jwt verification failed", 3)
)

type Claims struct {
	Id       string `json:"id"`
	Username string `json:"username"`
}

err := initializer.RegisterBeforeAuthenticateCustom(beforeAuthenticateCustom)
if err != nil {
  return fmt.Errorf("error registering before authentication hook: %s", err.Error())
}

func beforeAuthenticateCustom(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, in *api.AuthenticateCustomRequest) (*api.AuthenticateCustomRequest, error) {
	// Get the JWT secret key from the runtime context
	env := ctx.Value(runtime.RUNTIME_CTX_ENV).(map[string]string)
	secretKey := env["JWT_SECRET_KEY"]

	var claims Claims
	err := verifyAndParseJwt(secretKey, in.Account.Id, &claims)

	if err != nil {
		logger.Error("error verifying and parsing jwt: %s", err.Error())
		return nil, errJwtVerification
	}

	// Update the incoming authenticate request with the user ID and username from the JWT claims
	in.Account.Id = claims.Id
	in.Username = claims.Username

	return in, nil
}

func verifyAndParseJwt(secretKey string, jwt string, claims *Claims) error {
	// Use your favourite JWT library to verify the signature and decode the JWT contents (e.g. https://github.com/golang-jwt/jwt)
	// Once verified and decoded, populate the contents of the Claims object accordingly
	return nil
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
interface Claims {
  id: string,
  username: string
}

const BeforeAuthenticateCustom: nkruntime.BeforeHookFunction<nkruntime.AuthenticateCustomRequest> = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, data: nkruntime.AuthenticateCustomRequest): nkruntime.AuthenticateCustomRequest | void {
  const secretKey = ctx.env["JWT_SECRET_KEY"];
  const claims = verifyAndParseJwt(secretKey, data.account.id);

  if (!claims) {
    logger.error(`error verifying and parsing jwt`);
    return null;
  }

  // Update the incoming authenticate request with the user ID and username
  data.account.id = claims.id;
  data.username = claims.username;

  return data;
};

const verifyAndParseJwt = function (secretKey: string, jwt: string): Claims {
  // Use your favourite JWT library to verify the signature and decode the JWT contents
  // Once verified and decoded, return a Claims object accordingly
  return null;
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

As you can see from the two examples above, integrating third-party authentication services with Nakama is a straightforward and highly flexible process that will allow you to successfully authenticate your users in Nakama no matter where their information is stored.