Steam Cloud Pipeline Integration Guide

Overview

This guide explains how to integrate Steam authentication with Loreguard Cloud to enable player-authenticated NPC chat. Instead of embedding API tokens in your game client (which would be insecure), you exchange Steam session tickets for short-lived Player JWTs.


Architecture

┌──────────────────────────────────────────────────────────────────────┐
│                           YOUR GAME CLIENT                                │
│  ┌─────────────────┐                                                     │
│  │  Steam SDK      │──── GetAuthSessionTicket() ─────┐                   │
│  └─────────────────┘                                  │                   │
│                                                       ▼                   │
│  ┌─────────────────┐    POST /api/player/steam    ┌─────────────────┐   │
│  │  HTTP Client    │─────────────────────────────▶│  Loreguard API  │   │
│  └─────────────────┘    {app_id, ticket}          └────────┬────────┘   │
│           │                                                 │            │
│           │◀──────────── Player JWT ────────────────────────┘            │
│           │                                                              │
│           ▼                                                              │
│  ┌─────────────────┐    POST /api/chat            ┌─────────────────┐   │
│  │  NPC Chat       │─────────────────────────────▶│  Loreguard API  │   │
│  │  Authorization: Bearer <JWT>                   └─────────────────┘   │
│  └─────────────────┘                                                     │
└──────────────────────────────────────────────────────────────────────┘

Flow:

  1. Game client obtains Steam session ticket from Steamworks SDK
  2. Client exchanges ticket for Player JWT via /api/player/steam
  3. Loreguard validates ticket with Steam Web API
  4. Client uses JWT in Authorization: Bearer header for NPC chat

Setup Steps

1. Register Your Steam App

Before your game can authenticate players, you need to register your Steam AppID:

  1. Go to Loreguard Dashboard > Developers > Steam Apps
  2. Click "Add Steam App"
  3. Enter your Steam AppID (e.g., 480 for Spacewar test app)
  4. Map it to a Title ID (used for analytics and rate limiting)
  5. Enable the app for production

2. Exchange Steam Ticket for Player JWT

Unity C# Example

using Steamworks;
using UnityEngine;
using UnityEngine.Networking;
using System;
using System.Collections;
using System.Text;

public class LoreguardSteamAuth : MonoBehaviour
{
    private const string LOREGUARD_API = "https://api.loreguard.com";

    private string _playerJwt;
    private DateTime _jwtExpiresAt;

    [Serializable]
    private class SteamExchangeRequest
    {
        public string app_id;
        public string ticket;
    }

    [Serializable]
    private class SteamExchangeResponse
    {
        public string token;
        public string expires_at;
        public string player_id;
        public string studio_id;
        public string title_id;
    }

    public IEnumerator AuthenticateWithSteam()
    {
        // Get Steam auth ticket
        byte[] ticketData = new byte[1024];
        uint ticketSize;
        HAuthTicket authTicket = SteamUser.GetAuthSessionTicket(
            ticketData,
            ticketData.Length,
            out ticketSize
        );

        if (authTicket == HAuthTicket.Invalid)
        {
            Debug.LogError("Failed to get Steam auth ticket");
            yield break;
        }

        // Convert to base64
        string ticketBase64 = Convert.ToBase64String(ticketData, 0, (int)ticketSize);

        // Exchange for Loreguard JWT
        var request = new SteamExchangeRequest
        {
            app_id = SteamUtils.GetAppID().ToString(),
            ticket = ticketBase64
        };

        string json = JsonUtility.ToJson(request);
        byte[] bodyRaw = Encoding.UTF8.GetBytes(json);

        using (var www = new UnityWebRequest($"{LOREGUARD_API}/api/player/steam", "POST"))
        {
            www.uploadHandler = new UploadHandlerRaw(bodyRaw);
            www.downloadHandler = new DownloadHandlerBuffer();
            www.SetRequestHeader("Content-Type", "application/json");

            yield return www.SendWebRequest();

            if (www.result != UnityWebRequest.Result.Success)
            {
                Debug.LogError($"Steam auth failed: {www.error}");
                yield break;
            }

            var response = JsonUtility.FromJson<SteamExchangeResponse>(
                www.downloadHandler.text
            );

            _playerJwt = response.token;
            _jwtExpiresAt = DateTime.Parse(response.expires_at);

            Debug.Log($"Authenticated as Steam player: {response.player_id}");
        }

        // Cancel the Steam ticket (we have our JWT now)
        SteamUser.CancelAuthTicket(authTicket);
    }

    public bool IsAuthenticated =>
        !string.IsNullOrEmpty(_playerJwt) && DateTime.UtcNow < _jwtExpiresAt;

    public string GetAuthHeader() => $"Bearer {_playerJwt}";
}

Godot GDScript Example

extends Node

const LOREGUARD_API = "https://api.loreguard.com"

var player_jwt: String = ""
var jwt_expires_at: int = 0

signal auth_completed(success: bool)
signal auth_error(message: String)

func authenticate_with_steam() -> void:
    # Get Steam auth ticket
    var ticket_dict = Steam.getAuthSessionTicket()
    if ticket_dict.is_empty():
        emit_signal("auth_error", "Failed to get Steam auth ticket")
        return

    var ticket_id = ticket_dict["id"]
    var ticket_buffer = ticket_dict["buffer"]

    # Convert to base64
    var ticket_base64 = Marshalls.raw_to_base64(ticket_buffer)

    # Exchange for Loreguard JWT
    var http = HTTPRequest.new()
    add_child(http)
    http.request_completed.connect(_on_steam_exchange_completed.bind(http, ticket_id))

    var body = JSON.stringify({
        "app_id": str(Steam.getAppID()),
        "ticket": ticket_base64
    })

    var headers = ["Content-Type: application/json"]
    var error = http.request(
        LOREGUARD_API + "/api/player/steam",
        headers,
        HTTPClient.METHOD_POST,
        body
    )

    if error != OK:
        emit_signal("auth_error", "HTTP request failed: " + str(error))
        http.queue_free()

func _on_steam_exchange_completed(
    result: int,
    response_code: int,
    headers: PackedStringArray,
    body: PackedByteArray,
    http: HTTPRequest,
    ticket_id: int
) -> void:
    http.queue_free()

    # Cancel Steam ticket (we have our JWT now)
    Steam.cancelAuthTicket(ticket_id)

    if response_code != 200:
        emit_signal("auth_error", "Steam exchange failed: " + str(response_code))
        return

    var json = JSON.parse_string(body.get_string_from_utf8())
    if json == null:
        emit_signal("auth_error", "Invalid JSON response")
        return

    player_jwt = json.get("token", "")
    var expires_at_str = json.get("expires_at", "")

    # Parse ISO8601 expiry
    jwt_expires_at = Time.get_unix_time_from_datetime_string(expires_at_str)

    print("Authenticated as Steam player: ", json.get("player_id"))
    emit_signal("auth_completed", true)

func is_authenticated() -> bool:
    return player_jwt != "" and Time.get_unix_time_from_system() < jwt_expires_at

func get_auth_header() -> String:
    return "Bearer " + player_jwt

3. Use Player JWT for NPC Chat

Unity C# Example

public IEnumerator ChatWithNPC(string characterId, string message)
{
    if (!IsAuthenticated)
    {
        yield return AuthenticateWithSteam();
    }

    var request = new
    {
        character_id = characterId,
        message = message
    };

    string json = JsonUtility.ToJson(request);
    byte[] bodyRaw = Encoding.UTF8.GetBytes(json);

    using (var www = new UnityWebRequest($"{LOREGUARD_API}/api/chat", "POST"))
    {
        www.uploadHandler = new UploadHandlerRaw(bodyRaw);
        www.downloadHandler = new DownloadHandlerBuffer();
        www.SetRequestHeader("Content-Type", "application/json");
        www.SetRequestHeader("Authorization", GetAuthHeader());

        yield return www.SendWebRequest();

        if (www.result != UnityWebRequest.Result.Success)
        {
            Debug.LogError($"Chat failed: {www.error}");
            yield break;
        }

        var response = JsonUtility.FromJson<ChatResponse>(www.downloadHandler.text);
        Debug.Log($"NPC says: {response.speech}");
    }
}

Godot GDScript Example

func chat_with_npc(character_id: String, message: String) -> void:
    if not is_authenticated():
        await authenticate_with_steam()
        await auth_completed

    var http = HTTPRequest.new()
    add_child(http)
    http.request_completed.connect(_on_chat_completed.bind(http))

    var body = JSON.stringify({
        "character_id": character_id,
        "message": message
    })

    var headers = [
        "Content-Type: application/json",
        "Authorization: " + get_auth_header()
    ]

    http.request(LOREGUARD_API + "/api/chat", headers, HTTPClient.METHOD_POST, body)

func _on_chat_completed(
    result: int,
    response_code: int,
    headers: PackedStringArray,
    body: PackedByteArray,
    http: HTTPRequest
) -> void:
    http.queue_free()

    if response_code == 429:
        # Rate limited
        var json = JSON.parse_string(body.get_string_from_utf8())
        print("Rate limited: ", json.get("error"))
        return

    if response_code != 200:
        print("Chat failed: ", response_code)
        return

    var json = JSON.parse_string(body.get_string_from_utf8())
    print("NPC says: ", json.get("speech"))

Rate Limits

Player JWTs are subject to rate limiting to ensure fair usage:

Limit Type Value Scope
Per Player 60 requests/minute Per studio:player pair
Per Studio Configurable All players combined
Steam Exchange 30 requests/minute Per IP address
Steam Exchange 30 requests/minute Per AppID

When rate limited, you'll receive a 429 Too Many Requests response:

{
  "error": "rate_limited",
  "limit_type": "player",
  "requests_used": 60,
  "requests_limit": 60,
  "reset_at": "2025-01-01T12:01:00Z"
}

Security Notes

Player JWT Properties

Best Practices

  1. Never embed API tokens in game clients - Use Steam exchange instead
  2. Refresh JWTs proactively - Re-authenticate before expiry
  3. Handle 401 gracefully - Re-authenticate on token rejection
  4. Don't store raw Steam tickets - Exchange immediately, then discard

Family Sharing Support

Loreguard supports Steam Family Sharing when enabled by the studio:

Setting Behavior
ALLOW_STEAM_FAMILY_SHARING=false Only game owners can authenticate (default)
ALLOW_STEAM_FAMILY_SHARING=true Family sharing users can authenticate

When family sharing is enabled:

To enable family sharing, contact support or configure it in your Steam App settings in the dashboard.


Troubleshooting

"Steam ticket validation failed"

"App not found or not enabled"

"Invalid player token"

"Rate limit exceeded"


API Reference

POST /api/player/steam

Exchange Steam session ticket for Player JWT.

Request:

{
  "app_id": "480",
  "ticket": "base64-encoded-steam-ticket"
}

Response (200 OK):

{
  "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...",
  "expires_at": "2025-01-01T12:30:00Z",
  "player_id": "76561198012345678",
  "studio_id": "studio_abc123",
  "title_id": "my-game"
}

Error Responses:

POST /api/chat

Chat with an NPC using Player JWT authentication.

Headers:

Authorization: Bearer <player_jwt>
Content-Type: application/json

Request:

{
  "character_id": "merchant-npc",
  "message": "Hello, what do you sell?",
  "player_id": "optional-override",
  "current_context": "The player is in the market district."
}

Response (200 OK):

{
  "speech": "Ah, a customer! I have potions, scrolls, and rare artifacts.",
  "verified": true,
  "citations": [
    {"claim": "potions, scrolls, and rare artifacts", "evidence_id": "E1", "verified": true}
  ],
  "latency_ms": 245
}