Skip to content

Authentication

Authentication in Starfish is optional. A server may require clients to present credentials during the handshake; when it does not, clients connect anonymously and the flow is identical to an unauthenticated deployment.

The protocol defines only an opaque, extensible credential envelope and the handshake flow — it does not prescribe credential schemes or validation logic. The server owns all validation; clients simply carry a credential to it. This keeps Starfish agnostic to how you actually authenticate: a shared secret, an opaque bearer token, a JWT, or a lookup against your own user database.

See protocol spec §3.4 for the normative definition.

The handshake flow

Authentication happens during the connection handshake, before the server assigns a client ID. There are no extra round-trips — the credential rides along in the client.hello and the server accepts or rejects in its server.welcome.

Client                                  Server
  │                                        │
  │  client.hello                          │
  │  { auth: { type: "token",              │
  │            token: "…" }, project }     │
  │ ──────────────────────────────────────▶│
  │                                        │  validate auth envelope
  │                                        │
  │            server.welcome (success)    │
  │  { clientId, auth: { required: true }, │
  │    resumeToken, … }                    │
  │ ◀──────────────────────────────────────│
  │                                        │

If validation fails, the server replies with a welcome-error frame instead of a success welcome, and the client is never registered (it holds no server state).

  │            welcome error               │
  │  { status: "error",                    │
  │    error: { code: "auth.failed",       │
  │             retry: false } }           │
  │ ◀──────────────────────────────────────│

Server advertisement

Every server.welcome payload carries an additive auth object advertising whether the server requires credentials:

json
"auth": { "required": true }

A missing auth object in the welcome means { "required": false }. Clients can read this to decide whether to prompt for credentials before retrying.

The auth envelope

The client carries an auth object in its client.hello, keyed by type:

json
"auth": {
  "type": "token",
  "token": "eyJhbGciOi..."
}
  • type (string, required) identifies the credential scheme. The reserved value "none" — or an omitted auth object — means the client is presenting no credentials.
  • Additional fields carry the credential for that scheme.

The protocol does not prescribe schemes beyond none. Two common conventions are documented so servers and clients agree on field names:

typeFieldsNotes
noneAnonymous. Default when auth is omitted.
tokentoken (string)Opaque bearer token or JWT.
shared-secretsecret (string)Pre-shared secret common to all clients.

Servers may define additional custom type values. Both sides ignore auth fields they do not understand rather than failing, so schemes can evolve without breaking older peers.

Rejections

When a server requires authentication and validation does not pass, it rejects the fresh handshake with one of two error codes. Both are welcome-response error frames (resource: "client", method: "welcome", kind: "response", payload.status: "error", retry: false):

CodeMeaning
auth.requiredThe client presented no credentials (auth omitted or none).
auth.failedThe client presented credentials that failed validation.
json
{
  "header": {
    "v": 1,
    "id": "err_002",
    "resource": "client",
    "method": "welcome",
    "kind": "response",
    "replyTo": "msg_001"
  },
  "payload": {
    "status": "error",
    "error": {
      "code": "auth.required",
      "resource": "client",
      "message": "Authentication required.",
      "retry": false
    }
  }
}

A third code, project.forbidden, is returned when a credential is valid but maps to a different Project than the one the client declared — see Project resolution.

Reconnection

Resumed connections are not re-challenged. The resumeToken issued in a prior welcome is itself a bearer credential scoped to the resume window, so a hello carrying a valid resumeToken bypasses auth validation. Once the resume window expires, the client performs a fresh handshake and re-authenticates. SDK reconnection (see Configuration → reconnect) handles this transparently as long as it reconnects within the window.

Transport security

wss:// / TLS required

Credentials travel in the client.hello payload as plaintext within the WebSocket frame. Whenever authentication is in use, clients and servers must use a secure transport (wss:// / TLS). Sending a token over ws:// exposes it to anyone on the network path.

Two additional server obligations:

  • Compare secrets and tokens using a constant-time comparison to avoid timing side channels. The built-in token validators below already do this.
  • Never log credential values.

Configuring a server validator

Each server exposes an Authenticator seam on its config. The default is an allow-all authenticator (no credentials required, declared project honored), so authentication is off until you supply one.

TypeScriptGoPython
Config fieldStarfishConfig.authenticatorConfig.AuthenticatorStarfishConfig.authenticator
Defaultnew AllowAllAuthenticator()AllowAllAuthenticator{}AllowAllAuthenticator()
Validate methodauthenticate(req)AuthResult | PromiseAuthenticate(req)AuthResult (sync)authenticate(req)AuthResult | Awaitable
"required" advertised viarequired: boolean fieldoptional AuthAdvertiser.RequiresAuth()required: bool attribute

The validator receives the entire auth envelope (not just a token) plus the client's declared project, and returns a result carrying either the effective project (on success) or a rejection code.

Built-in constant-time token validator

Each server ships a TokenAuthenticator that checks the conventional token scheme against a single expected token using a constant-time comparison. This is the quickest way to lock down a deployment with one shared bearer token.

ts
import { StarfishServer, defaultConfig, TokenAuthenticator } from "@starfish/server";

const config = defaultConfig();
config.authenticator = new TokenAuthenticator("s3cret");

const server = new StarfishServer(config);
await server.start();
go
config := starfish.DefaultConfig()
config.Authenticator = starfish.NewTokenAuthenticator("s3cret")

server := starfish.NewServer(config)
http.Handle("/", server)
http.ListenAndServe(config.Addr, nil)
python
from starfish_server import StarfishServer, default_config
from starfish_server.auth import TokenAuthenticator

config = default_config()
config.authenticator = TokenAuthenticator("s3cret")

server = StarfishServer(config)
await server.serve_forever()

One token, not a set

The built-in TokenAuthenticator validates against a single expected token. To accept multiple tokens, per-user tokens, JWTs, or a database lookup, write a custom authenticator (below).

Writing a custom validator

Implement the Authenticator seam to plug in any scheme — verify a JWT signature, check an HMAC, or look the token up in your database. TypeScript and Python may return a promise/awaitable, so async I/O (a DB or network call) is fine; Go's Authenticate is synchronous.

ts
import type { Authenticator, AuthRequest, AuthResult } from "@starfish/server";

class JwtAuthenticator implements Authenticator {
  readonly required = true;

  async authenticate(req: AuthRequest): Promise<AuthResult> {
    const token = req.auth?.token as string | undefined;
    if (!token || req.auth?.type !== "token") {
      return { ok: false, code: "auth.required" };
    }
    try {
      const claims = await verifyJwt(token); // your verification
      return { ok: true, project: claims.project ?? req.project };
    } catch {
      return { ok: false, code: "auth.failed" };
    }
  }
}

config.authenticator = new JwtAuthenticator();
go
type JWTAuthenticator struct{}

// Advertise that credentials are required (drives welcome auth.required).
func (JWTAuthenticator) RequiresAuth() bool { return true }

func (JWTAuthenticator) Authenticate(req starfish.AuthRequest) starfish.AuthResult {
    token, _ := req.Auth["token"].(string)
    if token == "" {
        return starfish.AuthResult{OK: false, Code: starfish.ErrAuthRequired}
    }
    claims, err := verifyJWT(token) // your verification
    if err != nil {
        return starfish.AuthResult{OK: false, Code: starfish.ErrAuthFailed}
    }
    return starfish.AuthResult{OK: true, Project: claims.Project}
}

config.Authenticator = JWTAuthenticator{}
python
from starfish_server.auth import AuthRequest, AuthResult
from starfish_server.errors import ERR_AUTH_REQUIRED, ERR_AUTH_FAILED

class JwtAuthenticator:
    required = True

    async def authenticate(self, request: AuthRequest) -> AuthResult:
        auth = request.auth or {}
        token = auth.get("token")
        if not token or auth.get("type") != "token":
            return AuthResult(ok=False, code=ERR_AUTH_REQUIRED)
        try:
            claims = await verify_jwt(token)  # your verification
        except Exception:
            return AuthResult(ok=False, code=ERR_AUTH_FAILED)
        return AuthResult(ok=True, project=claims.get("project", request.project))

config.authenticator = JwtAuthenticator()

Return an AuthResult with:

  • ok: true and the effective project (echo req.project if you don't derive one from the credential), or
  • ok: false and a code of auth.required (no credential) or auth.failed (bad credential).

In Go, also implement the optional AuthAdvertiser interface (RequiresAuth() bool) so the welcome advertises auth.required: true; without it the server reports auth as not required. In TypeScript and Python, set the required field/attribute.

Projects and authentication

Authentication and Project isolation are separate concerns but compose: your authenticator may derive or constrain the effective Project from the credential by returning a different project in the AuthResult. A credential that resolves to one Project but declares a different one is rejected with project.forbidden. This is how a deployment mints one Project per API key.

Passing a token from a client SDK

Set the auth option when constructing the client. If validation fails, connect() raises an error whose code is auth.required or auth.failed.

ts
import { StarfishClient } from "@starfish/client";

const client = new StarfishClient({
  server: "wss://example.com",
  webSocketFactory: (url) => new WebSocket(url),
  auth: { type: "token", token: "s3cret" },
});

try {
  await client.connect();
} catch (err) {
  if (err.code === "auth.required" || err.code === "auth.failed") {
    // prompt for credentials and retry
  }
}
go
client := starfish.NewClient(starfish.ClientOptions{
    Server: "wss://example.com",
    Auth:   &starfish.AuthOptions{Type: "token", Token: "s3cret"},
})

if err := client.Connect(context.Background()); err != nil {
    var sfErr *starfish.Error
    if errors.As(err, &sfErr) && (sfErr.Code == "auth.required" || sfErr.Code == "auth.failed") {
        // prompt for credentials and retry
    }
}
python
from starfish import StarfishClient, StarfishClientOptions, AuthOptions
from starfish.pending import StarfishRequestError

client = StarfishClient(StarfishClientOptions(
    server="wss://example.com",
    auth=AuthOptions(type="token", token="s3cret"),
))

try:
    await client.connect()
except StarfishRequestError as err:
    if err.code in ("auth.required", "auth.failed"):
        ...  # prompt for credentials and retry
swift
import StarfishClient

let client = StarfishClient(options: StarfishClientOptions(
    server: URL(string: "wss://example.com")!,
    auth: AuthOptions(type: "token", token: "s3cret")
))

do {
    _ = try await client.connect()
} catch let error as StarfishError {
    if error.serverErrorCode == .authRequired || error.serverErrorCode == .authFailed {
        // prompt for credentials and retry
    }
}

Custom schemes

The TypeScript and Go clients carry arbitrary scheme fields alongside type, so you can send a shared-secret (or any custom scheme) as long as the server understands it:

ts
auth: { type: "shared-secret", secret: "hunter2" }
go
Auth: &starfish.AuthOptions{
    Type:   "shared-secret",
    Fields: map[string]any{"secret": "hunter2"},
}

The Python and Swift clients currently transmit only the type and token fields, so they support the token scheme (with any type string) but cannot carry extra scheme-specific fields like secret.

JVM / Kotlin

The JVM/Kotlin SDK exposes an AuthConfig option, but its handshake does not yet transmit the token — authentication is not functional there. Track this gap before relying on auth from a JVM client.

See also