Authentication Protocol

Version: 1.0.0
Category: Authentication
Status: Stable


Overview

The Authentication Protocol defines how AI agents should authenticate and authorize when interacting with Structs APIs. This includes session or bearer authentication for the web application, transaction signing for the consensus network, and NATS authentication for streaming.

Key Principles:

  1. Use appropriate authentication for each API type
  2. Maintain session state for webapp API
  3. Sign transactions securely for consensus API
  4. Handle authentication errors gracefully
  5. Re-authenticate when sessions expire

Authentication Types

1. Web Application Authentication (Session or Bearer)

Use Case: Authenticating with structs-webapp API

Implementation: PHP Symfony application (structs-webapp)

Method: Wallet-signature login producing a session cookie or an opaque bearer token

Base URL: http://localhost:8080
Base Path: /api/auth

Note: The API built into structs-webapp is the main user-facing API. Authentication is implemented in the PHP Symfony application.


2. Consensus Network Authentication (Transaction Signing)

Use Case: Submitting transactions to structsd

Method: Transaction signing using private keys

Base URL: http://localhost:1317
Transaction Endpoint: /cosmos/tx/v1beta1/txs


3. Streaming Authentication (NATS)

Use Case: Connecting to GRASS streaming service

Method: Optional NATS authentication

NATS Protocol: nats://localhost:4222
NATS WebSocket: ws://localhost:1443


Web Application Authentication

The webapp validates a signed login message from an approved address in the requested guild. POST /api/auth/login creates a session; POST /api/auth/token accepts the same proof and returns an opaque bearer token with expires_in: 86400. The token is not a JWT. Send it with Authorization: Bearer <token>; an invalid Bearer credential does not fall back to a session cookie.

The authentication reference lists the exact anonymous guild profile/directory/roster/stat routes. Signup, login, native registration proof/status, timestamp, settings and onboarding events are public. Other entity/catalog reads and private device data require authentication. Public guild GET/HEAD supports anonymous cross-origin reads; private bearer access still requires an appropriate deployment CORS policy in a browser.

Wallet proofs are hex-encoded compressed secp256k1 public keys (66 characters) and raw R followed by S signatures (128 characters), signing SHA-256 of the UTF-8 message. This encoding is separate from the base64 protobuf fields used in native transactions.

Webapp authentication and device scope reviewed against integration 5991ca95. Other sections retain their independent source baselines; the chain version alone does not certify a guild API deployment.

Login Flow

Step 0: Build and sign the message (SignatureValidationManager::buildLoginMessage):

LOGIN_GUILD{guildId}ADDRESS{address}DATETIME{unix_timestamp}

unix_timestamp (unix seconds) must be a canonical decimal string less than 600 seconds from server time in either direction.

Step 1: Login Request

{
  "request": {
    "method": "POST",
    "url": "http://localhost:8080/api/auth/login",
    "headers": {
      "Content-Type": "application/json"
    },
    "body": {
      "address": "structs1...",
      "signature": "hex-raw-signature",
      "pubkey": "hex-compressed-pubkey",
      "guild_id": "0-1",
      "unix_timestamp": "1715000000"
    }
  }
}

Step 2: Login Response

{
  "response": {
    "status": 200,
    "headers": {
      "Set-Cookie": "PHPSESSID=abc123def456; Path=/; HttpOnly"
    },
    "body": {
      "success": true,
      "errors": [],
      "data": null
    }
  }
}

On failure the server returns 401 with keyed errors (signature_validation_failed, player_address_does_not_exists, player_does_not_exists).

Step 3: Using Session Cookie (browser clients use credentials: include)

{
  "request": {
    "method": "GET",
    "url": "http://localhost:8080/api/reactor/all/page/1",
    "headers": {
      "Cookie": "PHPSESSID=abc123def456",
      "Accept": "application/json"
    }
  }
}

Complete Login Example

{
  "flow": "webapp-login",
  "steps": [
    {
      "step": 1,
      "action": "login",
      "request": {
        "method": "POST",
        "url": "/api/auth/login",
        "body": {
          "address": "structs1...",
          "signature": "hex-raw-signature",
          "pubkey": "hex-compressed-pubkey",
          "guild_id": "0-1",
          "unix_timestamp": "1715000000"
        }
      },
      "response": {
        "status": 200,
        "cookie": "PHPSESSID=abc123def456"
      },
      "store": "session.cookie"
    },
    {
      "step": 2,
      "action": "authenticated-request",
      "request": {
        "method": "GET",
        "url": "/api/reactor/all/page/1",
        "headers": {
          "Cookie": ""
        }
      }
    }
  ]
}

Signup Flow

Registration is also signature-based and asynchronous: persisting the pending player triggers a GuildMembershipJoinProxy chain message, and the player ID is assigned later. Login must be called separately once the ID exists. Signed message (buildGuildMembershipJoinProxyMessage): GUILD{guildId}ADDRESS{address}NONCE{nonce}.

Request:

{
  "request": {
    "method": "POST",
    "url": "http://localhost:8080/api/auth/signup",
    "headers": {
      "Content-Type": "application/json"
    },
    "body": {
      "primary_address": "structs1...",
      "signature": "hex-raw-signature",
      "pubkey": "hex-compressed-pubkey",
      "guild_id": "0-1",
      "username": "new_player",
      "pfp": "optional-pfp"
    }
  }
}

Response:

{
  "response": {
    "status": 202,
    "body": {
      "success": true,
      "errors": [],
      "data": null
    }
  }
}

Logout Flow

Request:

{
  "request": {
    "method": "POST",
    "url": "http://localhost:8080/api/auth/logout",
    "headers": {
      "Cookie": "PHPSESSID=abc123def456"
    }
  }
}

Response:

{
  "response": {
    "status": 200,
    "body": {
      "success": true,
      "errors": [],
      "data": null
    }
  }
}

Logout invalidates the presented server session and revokes the presented bearer until its expiry; other tokens are unaffected. Clear local credentials even when HTTP logout is unavailable. Retain the public journal for any uncertain native transaction. Repeating logout is safe. An API logout alone does not revoke a key on chain.

Session Management

Session Storage:

{
  "session": {
    "cookie": "PHPSESSID=abc123def456",
    "expires": "2025-12-07 12:00:00",
    "lastUsed": "2025-12-07 11:00:00"
  }
}

Session Validation:


Consensus Network Authentication

Transaction Signing

Overview: Transactions must be signed with a private key before submission.

Process:

  1. Create transaction message
  2. Sign transaction with private key
  3. Submit signed transaction

Transaction Message Format

{
  "body": {
    "messages": [
      {
        "@type": "/structs.structs.MsgStructBuildInitiate",
        "creator": "structs1abc...",
        "playerId": "1-11",
        "structTypeId": "1",
        "operatingAmbit": "land",
        "slot": "0"
      }
    ],
    "memo": "",
    "timeout_height": "0",
    "extension_options": [],
    "non_critical_extension_options": []
  },
  "auth_info": {
    "signer_infos": [
      {
        "public_key": {
          "@type": "/cosmos.crypto.secp256k1.PubKey",
          "key": "base64_public_key"
        },
        "mode_info": {
          "single": {
            "mode": "SIGN_MODE_DIRECT"
          }
        },
        "sequence": "0"
      }
    ],
    "fee": {
      "amount": [],
      "gas_limit": "200000",
      "payer": "",
      "granter": ""
    }
  },
  "signatures": [
    "base64_signature"
  ]
}

Signing Process

Step 1: Get Account Information

{
  "request": {
    "method": "GET",
    "url": "http://localhost:1317/cosmos/auth/v1beta1/accounts/structs1abc..."
  },
  "response": {
    "account": {
      "address": "structs1abc...",
      "pub_key": {...},
      "account_number": "0",
      "sequence": "0"
    }
  }
}

Step 2: Create Transaction

{
  "transaction": {
    "body": {
      "messages": [...],
      "memo": ""
    },
    "auth_info": {
      "signer_infos": [...],
      "fee": {
        "amount": [],
        "gas_limit": "200000"
      }
    }
  }
}

Step 3: Sign Transaction

{
  "signing": {
    "method": "secp256k1",
    "privateKey": "0x...",
    "message": "transaction_bytes",
    "result": "base64_signature"
  }
}

Step 4: Submit Transaction

{
  "request": {
    "method": "POST",
    "url": "http://localhost:1317/cosmos/tx/v1beta1/txs",
    "headers": {
      "Content-Type": "application/json"
    },
    "body": {
      "tx_bytes": "base64_encoded_signed_transaction",
      "mode": "BROADCAST_MODE_SYNC"
    }
  }
}

Using Client Libraries

TypeScript (generated bindings + CosmJS):

Generate the message types from the structsd repo with make proto-gen-ts, register them with CosmJS, and sign with a SigningStargateClient:

import { OfflineSigner, Registry } from '@cosmjs/proto-signing';
import { SigningStargateClient } from '@cosmjs/stargate';
// Generated by `make proto-gen-ts` in the structsd repo:
import { MsgStructBuildInitiate } from './generated/structs/structs/tx';

const signer: OfflineSigner = /* get signer from Keplr or other wallet */;

const registry = new Registry();
registry.register('/structs.structs.MsgStructBuildInitiate', MsgStructBuildInitiate);

const client = await SigningStargateClient.connectWithSigner(
  'http://localhost:26657',
  signer,
  { registry }
);

const [account] = await signer.getAccounts();

const msg = {
  typeUrl: '/structs.structs.MsgStructBuildInitiate',
  value: {
    creator: account.address,
    playerId: '1-11',
    structTypeId: 1,
    operatingAmbit: 2, // ambit enum: water 1, land 2, air 3, space 4
    slot: 0
  }
};

const result = await client.signAndBroadcast(account.address, [msg], {
  amount: [],
  gas: '200000'
});

Python:

from cosmpy.aerial.client import LedgerClient
from cosmpy.aerial.wallet import LocalWallet
from cosmpy.crypto.keypairs import PrivateKey

# Initialize wallet
private_key = PrivateKey.from_hex("...")
wallet = LocalWallet(private_key)

# Initialize client
client = LedgerClient("http://localhost:1317")

# Create and sign transaction
tx = client.execute_tx(
    msgs=[msg],
    sender=wallet,
    gas_limit=200000
)

Streaming Authentication (NATS)

NATS Connection

NATS Protocol:

{
  "connection": {
    "url": "nats://localhost:4222",
    "protocol": "NATS",
    "authentication": "optional"
  }
}

NATS WebSocket:

{
  "connection": {
    "url": "ws://localhost:1443",
    "protocol": "NATS",
    "transport": "WebSocket",
    "authentication": "optional"
  }
}

Authentication (If Required)

Token Authentication:

{
  "connection": {
    "url": "nats://localhost:4222",
    "authentication": {
      "type": "token",
      "token": "nats_token_here"
    }
  }
}

Username/Password Authentication:

{
  "connection": {
    "url": "nats://localhost:4222",
    "authentication": {
      "type": "username_password",
      "username": "nats_user",
      "password": "nats_password"
    }
  }
}

Connection Example

JavaScript/TypeScript:

import { connect } from 'nats';

// Connect to NATS
const nc = await connect({
  servers: ['nats://localhost:4222'],
  // Optional authentication
  // token: 'nats_token',
  // user: 'nats_user',
  // pass: 'nats_password'
});

// Subscribe to subjects
const sub = nc.subscribe('structs.player.1', (msg) => {
  console.log('Received:', msg.data);
});

// Keep connection alive
await nc.closed();

Python:

import asyncio
from nats.aio.client import Client as NATS

async def main():
    nc = NATS()
    await nc.connect('nats://localhost:4222')
    
    # Optional authentication
    # await nc.connect('nats://localhost:4222', token='nats_token')
    
    async def message_handler(msg):
        print(f'Subject: {msg.subject}')
        print(f'Data: {msg.data.decode()}')
    
    await nc.subscribe('structs.player.1', cb=message_handler)
    
    # Keep connection alive
    await asyncio.sleep(3600)
    await nc.close()

asyncio.run(main())

Error Handling

Authentication Errors

401 Unauthorized (Webapp):

{
  "response": {
    "status": 401,
    "body": {
      "success": false,
      "errors": {"authentication_error": "Login required"},
      "data": null
    }
  },
  "action": "re-authenticate",
  "retry": true
}

401 Unauthorized (Consensus):

{
  "response": {
    "status": 401,
    "body": {
      "code": 4,
      "message": "unauthorized"
    }
  },
  "action": "check-signer",
  "retry": false
}

403 Forbidden:

{
  "response": {
    "status": 403,
    "body": {
      "error": "Forbidden",
      "message": "Insufficient permissions"
    }
  },
  "action": "check-permissions",
  "retry": false
}

Error Recovery

Session Expired:

{
  "error": "session-expired",
  "recovery": {
    "step1": "detect-401-error",
    "step2": "clear-session",
    "step3": "re-authenticate",
    "step4": "retry-original-request"
  }
}

Signature Validation Failed:

{
  "error": "signature_validation_failed",
  "recovery": {
    "step1": "log-error",
    "step2": "rebuild-login-message-with-fresh-unix-timestamp",
    "step3": "re-sign-and-retry",
    "note": "signatures expire after 600s; confirm the address is an approved guild member"
  }
}

Transaction Signing Failed:

{
  "error": "signing-failed",
  "recovery": {
    "step1": "check-private-key",
    "step2": "verify-account-sequence",
    "step3": "if-send-was-possible-reconcile-the-original-hash-before-any-new-signature"
  }
}

Best Practices

Security

  1. Never store credentials in code - Use environment variables or secure storage
  2. Use HTTPS/WSS in production - Never use HTTP/WS for authentication
  3. Rotate sessions regularly - Re-authenticate periodically
  4. Validate certificates - Verify SSL/TLS certificates
  5. Use secure key storage - Store private keys securely

Session Management

  1. Store session cookies securely - Use secure storage
  2. Check session expiration - Validate before requests
  3. Handle session expiration - Re-authenticate automatically
  4. Logout on completion - Clear sessions when done

Transaction Signing

  1. Verify account sequence - Check sequence before signing
  2. Preserve uncertain submissions - Reconcile the original signed identity; an account-sequence change is not proof of failure
  3. Validate transaction format - Verify message structure
  4. Check gas limits - Ensure sufficient gas

NATS Connection

  1. Handle connection errors - Implement reconnection logic
  2. Validate subjects - Verify subject patterns
  3. Handle message errors - Process errors gracefully
  4. Monitor connection - Track connection health

Configuration

Environment Variables

{
  "webapp": {
    "baseUrl": "http://localhost:8080",
    "address": "${WEBAPP_ADDRESS}",
    "pubkey": "${WEBAPP_PUBKEY}",
    "privateKey": "${WEBAPP_PRIVATE_KEY}",
    "guildId": "${WEBAPP_GUILD_ID}"
  },
  "consensus": {
    "rpcURL": "http://localhost:26657",
    "apiURL": "http://localhost:1317",
    "privateKey": "${PRIVATE_KEY}",
    "address": "${ADDRESS}"
  },
  "streaming": {
    "natsURL": "nats://localhost:4222",
    "natsWebSocketURL": "ws://localhost:1443",
    "token": "${NATS_TOKEN}"
  }
}

Network Configurations

Testnet:

{
  "webapp": {
    "baseUrl": "https://testnet.structs.network"
  },
  "consensus": {
    "rpcURL": "https://public.testnet.structs.network:26657",
    "apiURL": "https://public.testnet.structs.network"
  },
  "streaming": {
    "natsURL": "nats://testnet.structs.network:4222",
    "natsWebSocketURL": "wss://testnet.structs.network:1443"
  }
}

Mainnet:

{
  "webapp": {
    "baseUrl": "https://structs.network"
  },
  "consensus": {
    "rpcURL": "https://rpc.structs.network:26657",
    "apiURL": "https://api.structs.network:1317"
  },
  "streaming": {
    "natsURL": "nats://structs.network:4222",
    "natsWebSocketURL": "wss://structs.network:1443"
  }
}

Examples

Complete Authentication Flow

{
  "example": "complete-authentication-flow",
  "steps": [
    {
      "step": 1,
      "service": "webapp",
      "action": "login",
      "request": "POST /api/auth/login",
      "store": "session.cookie"
    },
    {
      "step": 2,
      "service": "webapp",
      "action": "authenticated-request",
      "request": "GET /api/guild/this",
      "headers": {
        "Cookie": ""
      }
    },
    {
      "step": 3,
      "service": "consensus",
      "action": "sign-transaction",
      "message": "MsgStructBuildInitiate",
      "signer": "private_key"
    },
    {
      "step": 4,
      "service": "consensus",
      "action": "submit-transaction",
      "request": "POST /cosmos/tx/v1beta1/txs"
    },
    {
      "step": 5,
      "service": "streaming",
      "action": "connect-nats",
      "url": "nats://localhost:4222"
    },
    {
      "step": 6,
      "service": "streaming",
      "action": "subscribe",
      "subject": "structs.player.1"
    }
  ]
}

Last Updated: December 7, 2025

Logout method rechecked against webapp 461bba5a (AuthController and GuildAPI): POST only. Other sections retain their existing verification scope.