Accessing Epic Online Services Game Services with the Connect Interface

Rajen Kishna, Technical Account Manager, Epic Games |
November 16, 2021
If you missed the previous articles in this Epic Online Services Getting Started series, we wrapped up using Epic Account Services last week with the Friends Interface APIs. We’re now ready to dive into Game Services, so we first have to explore and implement the Connect Interface.

This interface is used to—as the name implies—connect any supported authenticated identity with an EOS Product User ID (PUID), which in turn is used as the product-specific player identifier for all Game Services. Important to note here is that Game Services and PUIDs do not depend on Epic Account Services and Epic Accounts.

If you’re following along, make sure you use Epic Online Services 1.14.1 SDK, as it contains some changes in how the Connect flow works in combination with Epic Account Services. In this article, we’ll cover:
 

A brief summary of Auth vs. Connect Interfaces

We covered the differences between these interfaces in more detail in the Authenticating with Epic Account Services (EAS) article, but to recap: the Auth Interface is used to authenticate Epic Accounts to use Epic Account Services for presence and friend information, as well as purchasing on the Epic Games Store

The Connect Interface is what we’ll use going forward in this series to generate a unique Product User ID (PUID) for each player of our product, to use the Game Services covering multiplayer, progression, and operations. These can be used independently of each other, and the Connect Interface can be used with any supported identity provider and does not require using Epic Accounts.

The Connect authentication flow

In our sample app, we will implement the Connect API integration using Epic Accounts, as we’ve already implemented that code. However, before we proceed, let’s walk through the flow of how this is implemented using any supported identity provider:
 
  1. The game prompts the player to authenticate using an identity provider (A) through Connect.Login().
  2. If a Product User ID (PUID) is found for this player, using the specified identity provider, the ResultCode of the callback will be Result.Success and the callback will return the PUID in the LocalUserId member.
  3. If a PUID is not found, the ResultCode will be Result.InvalidUser and the callback will return a ContinuanceToken. At this point, you can ask the player if they want to authenticate using a different identity provider you support, if you support multiple. This is done to prevent creating a second PUID that will have separate progress data attached to it and avoiding future account merges. Alternatively, offer to continue using this identity (after which we’ll create a new PUID for them).
  4. If the user wants to continue using this identity, we use Connect.CreateUser(), passing in the ContinuanceToken via CreateUserOptions. Once this returns with a ResultCode of Result.Success, the PUID can be found in the LocalUserId member.
  5. If the user wants to authenticate using a different identity provider (B), we store the ContinuanceToken, call Connect.Login() again using identity provider B. After a successful login, we can prompt the user if they want to link both identities, and call Connect.LinkAccount() to ensure both accounts are associated with the same PUID.
As briefly mentioned in the EAS article, there is one additional option we can offer to further streamline the authentication process for our users: using Device ID. If we want to offer players an option of playing the game without having to log in to any identity provider, we can create a pseudo-account on their behalf.

Important to note here is that the Device ID is not connected to an identity, so it’s recommended to prompt the user to log in (and link) using a supported identity provider when some progress is made to ensure their account is not lost. On PC this is connected to the user logged into Windows, but on iOS and Android the Device ID is deleted alongside app uninstall. The Device ID feature is not supported on console platforms, as they will always have a local authenticated user readily available.

You can find more information on the Device ID flow in the documentation, but we’ll keep it out of scope for this article.

Implementing Connect login

Now that we understand the flow and the different ways we can integrate it, let’s take a look at the basic flow of using an authenticated local Epic Account user to call the Login method of the Connect Interface:
 
  1. Open MainWindow.xaml and replace the top Grid’s content (above the TabControl) with the following to show the PUID and add a Connect login button:

<StackPanel Grid.Column="1">
    <StackPanel Orientation="Horizontal">
        <Button Width="100" Height="23" Margin="2" Command="{Binding AuthLogin}" Content="Auth Login" />
        <Button x:Name="AuthLogoutButton" Width="100" Height="23" Margin="2" Command="{Binding AuthLogout}" Content="Auth Logout" />
    </StackPanel>
    <Button x:Name="ConnectLoginButton" HorizontalAlignment="Right" Width="100" Height="23" Margin="2" Command="{Binding ConnectLogin}" Content="Connect Login" />
</StackPanel>

<StackPanel Grid.Column="0">
    <StackPanel Orientation="Horizontal">
        <TextBlock Text="AccountId:" Margin="2" />
        <TextBox Text="{Binding AccountId}" IsReadOnly="True" Margin="2" />
    </StackPanel>
    <StackPanel Orientation="Horizontal">
        <TextBlock Text="DisplayName:" Margin="2" />
        <TextBox Text="{Binding DisplayName}" IsReadOnly="True" Margin="2" />
    </StackPanel>
    <StackPanel Orientation="Horizontal">
        <TextBlock Text="ProductUserId:" Margin="2" />
        <TextBox Text="{Binding ProductUserId}" IsReadOnly="True" Margin="2" />
    </StackPanel>
</StackPanel>

 
  1. Add the PUID member to MainViewModel.cs, alongside the members to hold IDs for expiration and status change notifications:

private string _productUserId;
public string ProductUserId
{
    get { return _productUserId; }
    set { SetProperty(ref _productUserId, value); }
}

private ulong _connectAuthExpirationNotificationId;
public ulong ConnectAuthExpirationNotificationId
{
    get { return _connectAuthExpirationNotificationId; }
    set { SetProperty(ref _connectAuthExpirationNotificationId, value); }
}

private ulong _connectLoginStatusChangedNotificationId;
public ulong ConnectLoginStatusChangedNotificationId
{
    get { return _connectLoginStatusChangedNotificationId; }
    set { SetProperty(ref _connectLoginStatusChangedNotificationId, value); }

}
 
  1. Add a ConnectLoginCommand.cs class to the Commands folder:

public class ConnectLoginCommand : CommandBase
{
    public override bool CanExecute(object parameter)
    {
        return !string.IsNullOrWhiteSpace(ViewModelLocator.Main.AccountId) && string
.IsNullOrWhiteSpace(ViewModelLocator.Main.ProductUserId);
    }

    public override void Execute(object parameter)
    {
        ConnectService.ConnectLogin();
    }
}

 
  1. Add a ConnectService.cs class to the Services folder for our login logic:

public static class ConnectService
{
    public static void ConnectLogin()
    {
        ViewModelLocator.Main.StatusBarText = "Getting auth interface...";

        var authInterface = App.Settings.PlatformInterface.GetAuthInterface();
        if (authInterface == null)
        {
            Debug.WriteLine("Failed to get auth interface");
            ViewModelLocator.Main.StatusBarText = string.Empty;
            return;
        }
        var copyIdTokenOptions = new Epic.OnlineServices.Auth.CopyIdTokenOptions()
        {
            AccountId = EpicAccountId.FromString(ViewModelLocator.Main.AccountId)
        };

        var result = authInterface.CopyIdToken(copyIdTokenOptions, out var userAuthToken);

        if (result == Result.Success)
        {
            var connectInterface = App.Settings.PlatformInterface.GetConnectInterface();
            if (connectInterface == null)
            {
                Debug.WriteLine("Failed to get connect interface");
                return;
            }

            var loginOptions = new LoginOptions()
            {
                Credentials = new Credentials()
                {
                    Type = ExternalCredentialType.EpicIdToken,
                    Token = userAuthToken.JsonWebToken
                }
            };

            ViewModelLocator.Main.StatusBarText = "Requesting user login...";

            // Ensure platform tick is called on an interval, or the following call will never callback.
            connectInterface.Login(loginOptions, null, (LoginCallbackInfo loginCallbackInfo)=>
            {
                Debug.WriteLine($"Connect login {loginCallbackInfo.ResultCode}");

                if (loginCallbackInfo.ResultCode == Result.Success)
                {
                    ViewModelLocator.Main.StatusBarText = "Connect login successful.";

                    ViewModelLocator.Main.ConnectAuthExpirationNotificationId = connectInterface.AddNotifyAuthExpiration(new AddNotifyAuthExpirationOptions(), null, AuthExpirationCallback);
                    ViewModelLocator.Main.ConnectLoginStatusChangedNotificationId = connectInterface.AddNotifyLoginStatusChanged(new AddNotifyLoginStatusChangedOptions(), null, LoginStatusChangedCallback);

                    ViewModelLocator.Main.ProductUserId = loginCallbackInfo.LocalUserId.ToString();
                }
                else if (loginCallbackInfo.ResultCode == Result.InvalidUser)
                {
                    ViewModelLocator.Main.StatusBarText = "Connect login failed: " + loginCallbackInfo.ResultCode;

                    var loginWithDifferentCredentials = MessageBox.Show("User not found. Log in with different credentials?", "Invalid User", MessageBoxButton.YesNo, MessageBoxImage.Question);
                    if (loginWithDifferentCredentials == MessageBoxResult.No)
                    {
                        var createUserOptions = new CreateUserOptions()
                        {
                            ContinuanceToken = loginCallbackInfo.ContinuanceToken
                        };

                        connectInterface.CreateUser(createUserOptions, null, (CreateUserCallbackInfo createUserCallbackInfo) =>
                        {
                            if (createUserCallbackInfo.ResultCode == Result.Success)
                            {
                                ViewModelLocator.Main.StatusBarText = "User successfully created.";

                                ViewModelLocator.Main.ConnectAuthExpirationNotificationId = connectInterface
.AddNotifyAuthExpiration(new AddNotifyAuthExpirationOptions(), null, AuthExpirationCallback);
                                ViewModelLocator.Main.ConnectLoginStatusChangedNotificationId = connectInterface
.AddNotifyLoginStatusChanged(new AddNotifyLoginStatusChangedOptions(), null, LoginStatusChangedCallback);

                                ViewModelLocator.Main.ProductUserId = createUserCallbackInfo.LocalUserId.ToString();
                            }
                            else if (Common.IsOperationComplete(loginCallbackInfo.ResultCode))
                            {
                                Debug.WriteLine("User creation failed: " + createUserCallbackInfo.ResultCode);
                            }

                            ViewModelLocator.Main.StatusBarText = string.Empty;
                            ViewModelLocator.RaiseConnectCanExecuteChanged();
                        });
                    }
                    else
                    {
                        // Prompt for login with different credentials
                    }
                }
                else if (Common.IsOperationComplete(loginCallbackInfo.ResultCode))
                {
                    Debug.WriteLine("Connect login failed: " + loginCallbackInfo.ResultCode);
                }

                ViewModelLocator.Main.StatusBarText = string.Empty;
                ViewModelLocator.RaiseConnectCanExecuteChanged();
            });
        }
        else if (Common.IsOperationComplete(result))
        {
            Debug.WriteLine("CopyIdToken failed: " + result);
            ViewModelLocator.Main.StatusBarText = string.Empty;
        }
    }

    private static void AuthExpirationCallback(AuthExpirationCallbackInfo data)
    {
        // Handle 10-minute warning prior to token expiration by calling Connect.Login()
    }
    private static void LoginStatusChangedCallback(LoginStatusChangedCallbackInfo data)
    {
        switch (data.CurrentStatus)
        {
            case LoginStatus.NotLoggedIn:
                if (data.PreviousStatus == LoginStatus.LoggedIn)
                {
                    // Handle token expiration
                }
                break;
            case LoginStatus.UsingLocalProfile:
                break;
            case LoginStatus.LoggedIn:
                break;
        }
    }

    public static void RemoveNotifications()
    {
        App.Settings.PlatformInterface.GetConnectInterface()
.RemoveNotifyAuthExpiration(ViewModelLocator.Main
.ConnectAuthExpirationNotificationId);
        App.Settings.PlatformInterface.GetConnectInterface()
.RemoveNotifyLoginStatusChanged(ViewModelLocator.Main
.ConnectLoginStatusChangedNotificationId);
    }
}


This is quite a bit of code, so let’s break it down:
 
  • First, we get a reference to the Auth Interface and call Auth.CopyIdToken to retrieve the ID token for the logged-in Epic Account. This will vary depending on which identity provider you implement, but since we’re already using Epic Account Services, it’s easy to get the token through the Auth Interface.
    • Note that you don’t have to use the Auth Interface at all to use EOS Game Services, as you can use any supported identity provider to authenticate via the Connect Interface.
  • Then we call Connect.Login with the ExternalCredentialType EpicIdToken, passing in the ID token we just retrieved. This is new in SDK 1.14.1.
  • In the callback, we check if the ResultCode equals Result.Success, in which case we can simply take the PUID from the LocalUserId member and copy it to our ViewModel.
  • If the ResultCode is Result.InvalidUser instead, we prompt the user if they want to log in with different credentials. If not, we proceed with calling Connect.CreateUser to create a new PUID, passing in the ContinuanceToken we got in our initial Login callback.
    • Note that we don’t do anything here if the user wants to log in with a different identity provider, but you’d implement the behavior described above here.
  • When CreateUser returns successfully, we simply take the LocalUserId from the callback data and copy it to our ViewModel.
  • In both cases (valid initial login or successful CreateUser), we use Connect.AddNotifyAuthExpiration and Connect.AddNotifyLoginStatusChanged to get notified when the authentication is about to expire (remember that they authenticate with an identity provider, so this will get informed of expiration approximately 10 minutes in advance) or login status changes otherwise.
    • Note that I don’t have the code implemented here, but the two methods are where you can handle those cases accordingly.
    • It is important that for each Connect authentication refresh your code will also generate a new fresh authentication token from the identity provider to use with the Connect.Login API. Otherwise, the Connect.Login call will fail due to an expired platform authentication token.
  • Lastly, we call a new RaiseConnectCanExecuteChanged method on our ViewModel, which we’ll use in future articles to ensure we can only call Game Services functionality after a user has successfully authenticated through the Connect Interface.
 
  1. Open AuthLogoutCommand.cs and add the following to the Execute() method to ensure we stop listening to the notifications we set up on logout:

ConnectService.RemoveNotifications();
 
  1. Open MainViewModel.cs again to declare and instantiate ConnectLoginCommand:

public ConnectLoginCommand ConnectLogin { get; set; }
public MainViewModel()
{
    AuthLogin = new AuthLoginCommand();
    AuthLogout = new AuthLogoutCommand();
    ConnectLogin = new ConnectLoginCommand();
}

 
  1. Still in MainViewModel.cs, also add the members to hold our notification IDs:

private ulong _connectAuthExpirationNotificationId;
public ulong ConnectAuthExpirationNotificationId
{
    get { return _connectAuthExpirationNotificationId; }
    set { SetProperty(ref _connectAuthExpirationNotificationId, value); }
}

private ulong _connectLoginStatusChangedNotificationId;
public ulong ConnectLoginStatusChangedNotificationId
{
    get { return _connectLoginStatusChangedNotificationId; }
    set { SetProperty(ref _connectLoginStatusChangedNotificationId, value); }
}

 
  1. Open ViewModelLocator.cs and add the RaiseConnectCanExecuteChanged method:

public static void RaiseConnectCanExecuteChanged()
{
    Main.ConnectLogin.RaiseCanExecuteChanged();
}

 
  1. Open AuthService.cs to clear the ProductUserId when logging out. Add the following line in the AuthLogout() method, inside the if (logoutCallbackInfo.ResultCode == Result.Success) block:

ViewModelLocator.Main.ProductUserId = string.Empty;
 
  1. Lastly, in ViewModelLocator.cs, add the following line to RaiseAuthCanExecuteChanged to enable the Connect login button only after a successful Auth login. Again, this is specific to our sample flow, but in your game you can use the Connect Interface and EOS Game Services without using Epic Account Services and the Auth Interface.

Main.ConnectLogin.RaiseCanExecuteChanged();

Now when we run our sample and click on the Connect login after successfully completing Auth login, we see our prompt, as we get an InvalidUser response:
 
App Connect Login Failed InvalidUser
Connect Login failed with result InvalidUser

After clicking “No”, the code proceeds to call CreateUser and we’ll end up with a valid PUID being shown in the UI:
 
App Connect Login Success
Connect Login succeeded

Get the code

Get the code for this article below (follow steps five and ten of this article to add the SDK to the solution, and add your SDK credentials to ApplicationSettings.cs).
Now that we’ve successfully authenticated, we’ll continue the next post by implementing the Title Storage Interface, which lets us retrieve game-specific data from the cloud.

The full list of articles in this series can be found in the series reference. If you have feedback or questions, head over to the Community forum and let us know.

    We succeed when you succeed

    Epic believes in an open, integrated games community. By offering our online services to everyone for free, we aim to empower more developers to serve their own player communities.