Skip to content

Auth flow

AuthService issues two kinds of bearer session tokens, and MakerService additionally accepts a static organization API key. Pick the path that matches the caller:

PathHeaderMinted byLifetimeAccepted by
Keypair sessionauthorization: Bearer <token>Authenticate (signed nonce)6 hTxService, MakerService
Organization sessionauthorization: Bearer <token>VerifyLoginCode (passwordless email)7 dMakerService
Organization API keyx-api-key: <key>static, hashed server-siden/aMakerService only

MarketDataService, StatsService, HistoricalService, and every AuthService RPC are unauthenticated. MakerService additionally requires that the resolved maker_id be registered in the server's organizations table.

The keypair path is multi-step and signed:

  1. Challenge(pubkey) — server returns a single-use random nonce bound to your pubkey, valid for a short TTL.
  2. Sign AUTH_DOMAIN_PREFIX || nonce (the raw bytes — no extra encoding) with the keypair owning the pubkey. The constant is b"SWEETSPOT-AUTH-V1:".
  3. Authenticate(pubkey, signature) — server verifies the signature, maps the pubkey to a maker_id via its registry, and returns a session_token plus an expires_at.
  4. Use the token as authorization: Bearer <token> on subsequent RPCs.
  5. Re-auth before expires_at. The SDK helpers cache the token and refresh skew ahead of expiry automatically.

The passwordless email path is two RPCs:

  1. RequestLoginCode(email) — server emails a 6-digit code, rate-limited to one request per email per minute.
  2. VerifyLoginCode(email, code) — server returns a 7-day session_token + maker_id + expires_at.

The Rust and Python SDKs each ship an AuthFlow helper that drives the keypair handshake — the same shape in both (token / refresh / revoke, refreshing skew ahead of expiry). The TypeScript SDK ships an AuthFlow for the email path and an apiKeyInterceptor for the API key path; the keypair signed-nonce flow is not exposed in TS (use a wallet adapter directly, or the Rust/Python SDK).

Wallet signer

The signer is intentionally narrow: produce a 64-byte ed25519 signature over an arbitrary byte slice, and return the corresponding 32-byte public key. This lets you back the same AuthFlow with a keypair file, hardware wallet, remote signer, or browser wallet adapter.

rust
// The Rust SDK takes any `solana_sdk::signer::Signer` (`Keypair`,
// hardware wallet, presigner, remote signer) directly — typed as
// `Wallet = Arc<dyn Signer + Send + Sync>`.
use std::sync::Arc;
use solana_sdk::signer::keypair::{read_keypair_file, Keypair};
use sweetspot_api_client::api::auth::Wallet;

// Local keypair file.
let kp: Keypair = read_keypair_file("/path/to/id.json")
    .map_err(|e| anyhow::anyhow!("{e}"))?;
let wallet: Wallet = Arc::new(kp);

// Hardware wallets / remote signers: implement `solana_sdk::signer::Signer`
// (`pubkey()`, `try_sign_message(...)`) and wrap with `Arc::new(...)`.

Refresh strategy

spawn_refresh_loop() returns a tokio::JoinHandle and defaults to a 30 s skew before expiry. Override with with_skew.

Revoke

If you need to invalidate a token before its natural expiration (e.g. on logout), call revoke():

rust
auth.revoke().await?;

The cached session is dropped; subsequent calls re-authenticate from scratch.

Errors

AuthService.Authenticate returns UNAUTHENTICATED when:

  • The pubkey has no outstanding nonce.
  • The nonce has expired (TTL exceeded).
  • The signature is invalid for the nonce.
  • The pubkey is not registered as a quoting authority.

The SDK surfaces these as AuthError::Service, with the gRPC status preserved so callers can branch on the code.

Python: keypair signed-nonce

The Python AuthFlow drives the same handshake. Client.authenticate() runs it once; token() returns the cached session and re-authenticates skew seconds (default 30) before expires_at; revoke() invalidates the token server-side and drops the cache. Omit the keypair and auth_url when constructing a Python Client for public market-data, stats, or historical calls. Pass both for maker or tx RPCs.

python
import asyncio

from solders.keypair import Keypair
from flint import Client, Endpoints


async def main() -> None:
    client = Client(
        Endpoints(
            public_url="https://api.superis.exchange",
            auth_url="https://auth.api.superis.exchange",
        ),
        Keypair.from_json(open("/path/to/id.json").read()),
    )

    session = await client.authenticate()          # Challenge -> sign -> Authenticate
    print("authenticated as maker_id", session.maker_id)

    await client.token()                            # cached; re-auths near expiry
    await client.revoke()                           # invalidate + drop the cache
    await client.close()


asyncio.run(main())

To wire it up by hand, construct AuthFlow over an AuthServiceStub and stamp await auth.auth_metadata() ([("authorization", "Bearer <token>")]) on each authed RPC. Pass skew_seconds= to tune the refresh window.

TypeScript: passwordless email login

Browser apps authenticate against MakerService with the passwordless email flow. AuthFlow drives the two RPCs, caches the resulting session, and produces an interceptor that stamps authorization: Bearer <token> on every outbound call.

ts
import {
  AuthFlow,
  createMakerClient,
} from "@superis-labs/sweetspot-client";

const auth = new AuthFlow({
  baseUrl: "https://api.superis.exchange",
  storage: globalThis.localStorage,        // optional — survives reload
  onExpired: (session) => router.push("/login"),
});

await auth.requestLoginCode("trader@example.com");
// …prompt the user…
const session = await auth.verifyLoginCode("trader@example.com", code);
console.log("logged in as maker", session.makerId);

const maker = createMakerClient({
  baseUrl: "https://auth.api.superis.exchange",
  auth,
});
const { balances } = await maker.getBalance({ spotIds: [] });

Session persistence

AuthFlow accepts any localStorage-shaped store (getItem / setItem / removeItem). Pass globalThis.localStorage in a browser, globalThis.sessionStorage for tab-scoped persistence, or any custom adapter (IndexedDB-backed, cookie-backed, in-memory) that implements the same three methods.

ts
const auth = new AuthFlow({
  baseUrl,
  storage: globalThis.localStorage,
  storageKey: "sweetspot.session",  // default
});

On construction AuthFlow rehydrates a session from storage if one is present. On verifyLoginCode() it overwrites; on clear() and on detected expiry it removes the entry.

Expiry callback

AuthFlow does not auto-refresh — organization sessions are minted by a user-driven email flow, not a signer. Subscribe to onExpired and re-prompt:

ts
const auth = new AuthFlow({
  baseUrl,
  onExpired: () => {
    // Storage already cleared. Send the user back to the login form.
    router.push("/login");
  },
  expirySkewMs: 60_000,  // treat as expired this long before expiresAt; default 30s
});

The callback fires once per session, inside the interceptor, just before it throws GrpcError(Unauthenticated) — wrapping for await loops will unwind on the next emission.

Organization API key

For server-side Node consumers (or any context where embedding a long-lived secret in client JS is acceptable), pass an API key directly via apiKeyInterceptor:

ts
import {
  apiKeyInterceptor,
  createMakerClient,
} from "@superis-labs/sweetspot-client";

const maker = createMakerClient({
  baseUrl: "https://auth.api.superis.exchange",
  auth: apiKeyInterceptor(process.env.SUPERIS_API_KEY!),
});

API keys are hashed server-side (SHA-256) against organizations.api_key_hashes. They are accepted by MakerService, but not by TxService, and never expire.

Browser apps

Don't ship an API key in browser JavaScript. Anyone viewing the page can read it and impersonate the organization. Use AuthFlow instead; API keys belong on a trusted server.

Build on Solana