Guide

Non-Browser Auth Guide

How to integrate rokojori-auth from any non-browser client — Godot, CLI tools, native apps, scripts. No cookies. No redirects. Pure HTTP. Read the Implementation Guide first for the mental model and token lifecycle.

0 — How non-browser clients differ

No cookies

The server sets HttpOnly cookies for browser clients automatically. A non-browser HTTP client ignores Set-Cookie response headers unless explicitly programmed to store and re-send them. Do not use cookies. Use the response body instead — login and refresh both return the tokens as JSON.

No redirects

The server never redirects a well-formed non-browser client. Your client should treat any 3xx response as a bug in its request, not something to follow. Token refresh is a direct POST that returns new tokens immediately.

Manual token management

Your client is responsible for three things the browser does automatically:

  1. Storing both tokens after login (and after every refresh).
  2. Attaching the access token to every authenticated request as Authorization: Bearer <accessToken>.
  3. Detecting a 401 response, refreshing transparently, and retrying the original request once with the new token.

1 — Endpoint reference

Base URL

https://account.rokojori.com

All requests use Content-Type: application/json and expect a JSON response body.

POST /api/auth/login

Exchange credentials for a token pair. This is the entry point for all sessions.

// Request
POST https://account.rokojori.com/api/auth/login
Content-Type: application/json

{
  "email":    "user@example.com",
  "password": "hunter2"
}

// Success  HTTP 200
{
  "accessToken":  "eyJhbGciOiJIUzI1NiJ9...",   // signed JWT, expires in 1 hour
  "refreshToken": "a3f8c2d1-..."                // opaque UUID, valid for 30 days
}

// Failure  HTTP 401
{ "error": "Invalid credentials" }

// Rate limited  HTTP 429
{ "error": "Too many attempts. Try again later." }

POST /api/auth/refresh

Exchange an old refreshToken for a new token pair. The old refreshToken is invalidated immediately — save the new tokens before making any other requests.

// Request
POST https://account.rokojori.com/api/auth/refresh
Content-Type: application/json

{
  "refreshToken": "a3f8c2d1-..."    // from your local storage
}

// Success  HTTP 200
{
  "accessToken":  "eyJhbGciOiJIUzI1NiJ9...",   // new JWT
  "refreshToken": "b7e1a4f2-..."                // new UUID — save this, old one is gone
}

// Failure  HTTP 401
{ "error": "Invalid or expired refresh token" }
// → session is over, user must log in again

POST /api/auth/logout

Revokes the refresh token on the server and ends the session. Always call this on explicit user logout so the refresh token cannot be reused.

// Request
POST https://account.rokojori.com/api/auth/logout
Content-Type: application/json

{
  "refreshToken": "a3f8c2d1-..."
}

// Response  HTTP 200
{ "ok": true }

// Also succeeds if the token is already gone — always returns 200

GET /api/auth/me

Read the current user's profile. Requires a valid access token.

// Request
GET https://account.rokojori.com/api/auth/me
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

// Success  HTTP 200
{
  "id":       "uuid",
  "email":    "user@example.com",
  "roles":    ["user"],
  "products": [{ "id": "roject-pro", "acquiredAt": "2026-07-13", "source": "polar" }],
  "settings": { "theme": "dark" }
}

// Expired or missing token  HTTP 401
{ "error": "Not authenticated" }

In practice you rarely need to call /me — the JWT access token already contains userId, roles, and products in its payload. Decode it locally to read them without a network round-trip.

Calling any protected service endpoint

Every request to a protected endpoint on any rokojori service (Roject, tunnel, etc.) must include the access token as a Bearer header:

// General pattern
GET https://roject.rokojori.com/api/projects
Authorization: Bearer <accessToken>
Content-Type: application/json

// If the token is valid:    HTTP 200  { ... }
// If the token is expired:  HTTP 401  { "error": "Not authenticated" }
//   → refresh and retry (see section 3)

2 — Token storage

What to store

Store both tokens together after login and after every successful refresh:

{
  "accessToken":  "eyJhbGciOiJIUzI1NiJ9...",
  "refreshToken": "a3f8c2d1-4b2e-..."
}

Persist to a local file so the session survives restarts. The access token expires in 1 hour; the refresh token lasts 30 days. On startup, load the saved tokens and use them directly without verifying the access token locally — if it is expired, the first API call will return 401, and the refresh flow will handle it.

Godot — store in user:// directory

## auth_store.gd

const TOKEN_PATH = "user://tokens.json"

func save_tokens(access_token: String, refresh_token: String) -> void:
    var data = { "accessToken": access_token, "refreshToken": refresh_token }
    var file = FileAccess.open(TOKEN_PATH, FileAccess.WRITE)
    file.store_string(JSON.stringify(data))
    file.close()

func load_tokens() -> Dictionary:
    if not FileAccess.file_exists(TOKEN_PATH):
        return {}
    var file = FileAccess.open(TOKEN_PATH, FileAccess.READ)
    var text = file.get_as_text()
    file.close()
    var result = JSON.parse_string(text)
    if result is Dictionary:
        return result
    return {}

func clear_tokens() -> void:
    if FileAccess.file_exists(TOKEN_PATH):
        DirAccess.remove_absolute(ProjectSettings.globalize_path(TOKEN_PATH))

user:// resolves to a per-application, per-user directory that persists between runs. On Windows this is typically %APPDATA%/Godot/app_userdata/<project-name>/.

Node.js / Electron — store in userData

// tokens.ts
import fs   from 'fs';
import path from 'path';
import { app } from 'electron'; // or any fixed path for CLI tools

const TOKEN_PATH = path.join( app.getPath( 'userData' ), 'tokens.json' );

interface Tokens { accessToken: string; refreshToken: string; }

export function saveTokens( t: Tokens ): void
{
  fs.writeFileSync( TOKEN_PATH, JSON.stringify( t ), 'utf-8' );
}

export function loadTokens(): Tokens | null
{
  try   { return JSON.parse( fs.readFileSync( TOKEN_PATH, 'utf-8' ) ) as Tokens; }
  catch { return null; }
}

export function clearTokens(): void
{
  try { fs.unlinkSync( TOKEN_PATH ); } catch { /* already gone */ }
}

3 — The auth flow

Decision tree on startup

load tokens from storage

if no tokens saved:
    → show login screen
    → POST /api/auth/login
    → save tokens
    → proceed

if tokens are saved:
    → proceed immediately
    (do NOT verify the access token locally at startup —
     it is likely expired; the first API call handles it)

Wrapping every API call — the retry pattern

Wrap all API calls in a function that catches 401, refreshes once, and retries. If refresh fails, force the user back to the login screen. Never retry more than once — a second 401 after refresh means the session is gone.

## auth_http.gd  (Godot pseudocode — adapt to your HTTP library)

var _tokens := {}   # { accessToken, refreshToken } — loaded at startup

func api_request(method: String, url: String, body: Variant = null, is_retry := false) -> Dictionary:
    var headers = [
        "Content-Type: application/json",
        "Authorization: Bearer " + _tokens.get("accessToken", ""),
    ]
    var response = await _http_post_or_get(method, url, headers, body)

    if response.status == 401 and not is_retry:
        # Access token expired — try to refresh
        var refreshed = await _try_refresh()
        if refreshed:
            return await api_request(method, url, body, true)  # retry once
        else:
            _handle_logout()   # refresh also failed — back to login
            return { "error": "Session expired" }

    return response

func _try_refresh() -> bool:
    if not _tokens.has("refreshToken"):
        return false

    var response = await _http_post(
        "https://account.rokojori.com/api/auth/refresh",
        ["Content-Type: application/json"],
        { "refreshToken": _tokens["refreshToken"] }
    )

    if response.status == 200 and response.body.has("accessToken"):
        _tokens = {
            "accessToken":  response.body["accessToken"],
            "refreshToken": response.body["refreshToken"],
        }
        save_tokens(_tokens["accessToken"], _tokens["refreshToken"])
        return true

    return false   # refresh token is expired or revoked

func _handle_logout() -> void:
    clear_tokens()
    _tokens = {}
    # emit a signal or call a function to show the login screen
    emit_signal("session_ended")

Node.js version of the same pattern

// authFetch.ts

const AUTH_HOST  = 'https://account.rokojori.com';
const SERVICE_URL = 'https://roject.rokojori.com';  // or whichever service

let currentTokens: { accessToken: string; refreshToken: string } | null = loadTokens();

async function tryRefresh(): Promise<boolean>
{
  if ( !currentTokens?.refreshToken ) return false;
  try
  {
    const r = await fetch( `${ AUTH_HOST }/api/auth/refresh`,
    {
      method:  'POST',
      headers: { 'Content-Type': 'application/json' },
      body:    JSON.stringify( { refreshToken: currentTokens.refreshToken } ),
    } );
    if ( !r.ok ) return false;
    const data = await r.json() as { accessToken: string; refreshToken: string };
    currentTokens = data;
    saveTokens( data );
    return true;
  }
  catch { return false; }
}

async function apiFetch( path: string, options: RequestInit = {}, isRetry = false ): Promise<Response>
{
  const res = await fetch( `${ SERVICE_URL }${ path }`,
  {
    ...options,
    headers:
    {
      'Content-Type':  'application/json',
      'Authorization': `Bearer ${ currentTokens?.accessToken ?? '' }`,
      ...( options.headers ?? {} ),
    },
  } );

  if ( res.status === 401 && !isRetry )
  {
    const ok = await tryRefresh();
    if ( ok ) return apiFetch( path, options, true );
    handleLogout();
  }

  return res;
}

function handleLogout(): void
{
  clearTokens();
  currentTokens = null;
  // show login screen, emit event, etc.
}

Logout flow

// Always revoke the refresh token on the server, then clear local storage.
// This prevents the old token from being used if the storage file is later read.

async function logout(): Promise<void>
{
  if ( currentTokens?.refreshToken )
  {
    try
    {
      await fetch( `${ AUTH_HOST }/api/auth/logout`,
      {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify( { refreshToken: currentTokens.refreshToken } ),
      } );
    }
    catch { /* network error — still clear local tokens */ }
  }

  clearTokens();
  currentTokens = null;
}

4 — Reading the access token payload

What is in the access token

The access token is a standard JWT (JSON Web Token). Its payload carries everything your client needs to know about the user — no round-trip to /api/auth/me required during normal operation.

// Decoded JWT payload
{
  "userId":   "550e8400-e29b-41d4-a716-446655440000",
  "email":    "user@example.com",
  "roles":    ["user"],
  "products": ["roject-pro"],
  "settings": { "theme": "dark", "language": "en" },
  "iat": 1720000000,    // issued at (Unix timestamp)
  "exp": 1720003600     // expires at (iat + 3600 seconds = 1 hour)
}

JWT structure

A JWT is three base64url-encoded segments separated by dots: header.payload.signature. The payload can be decoded without the secret — only verification requires the secret.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9    ← header
.eyJ1c2VySWQiOiI1NTBlODQwMC4uLiJ9        ← payload  (base64url → JSON)
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c   ← signature

Godot — decode the payload

## jwt_decode.gd

static func decode_payload(token: String) -> Dictionary:
    var parts = token.split(".")
    if parts.size() != 3:
        return {}

    # Base64url → base64 → bytes → UTF-8 string → JSON
    var b64 = parts[1].replace("-", "+").replace("_", "/")
    # Pad to a multiple of 4
    while b64.length() % 4 != 0:
        b64 += "="

    var bytes = Marshalls.base64_to_raw(b64)
    var text  = bytes.get_string_from_utf8()
    var result = JSON.parse_string(text)

    if result is Dictionary:
        return result
    return {}

## Usage:
# var payload = JwtDecode.decode_payload(access_token)
# var user_id  = payload.get("userId",   "")
# var roles    = payload.get("roles",    [])
# var products = payload.get("products", [])

Check expiry without verifying

You can check whether the access token has already expired locally before making a request. This avoids an unnecessary round-trip when you know the token is stale. However, do not treat a non-expired token as proof of validity — the server is the authority.

## Godot
func is_access_token_expired(token: String) -> bool:
    var payload = JwtDecode.decode_payload(token)
    if not payload.has("exp"):
        return true
    return Time.get_unix_time_from_system() >= float(payload["exp"])

## Node.js
function isExpired( token: string ): boolean
{
  try
  {
    const payload = JSON.parse( Buffer.from( token.split( '.' )[1], 'base64url' ).toString( 'utf-8' ) );
    return Date.now() / 1000 >= payload.exp;
  }
  catch { return true; }
}

Optional optimisation: if you detect the token is expired before a call, run tryRefresh() proactively so the first request goes out with a fresh token rather than taking the 401 round-trip.

5 — Full Godot example

AuthManager autoload

Put this in a singleton autoload (Project → Project Settings → Autoload) named AuthManager. All other scripts call it instead of making HTTP requests directly.

## AuthManager.gd
extends Node

signal session_ended
signal login_succeeded

const AUTH_HOST   = "https://account.rokojori.com"
const TOKEN_PATH  = "user://tokens.json"

var _access_token  := ""
var _refresh_token := ""

# ── Startup ────────────────────────────────────────────────────────────

func _ready() -> void:
    _load_tokens()
    # Do NOT verify the token here. Let the first real request handle a 401.

func is_logged_in() -> bool:
    return _refresh_token != ""

# ── Login / Logout ─────────────────────────────────────────────────────

func login(email: String, password: String) -> Dictionary:
    var response = await _post(AUTH_HOST + "/api/auth/login",
        { "email": email, "password": password })

    if response.status == 200:
        _save_new_tokens(response.body)
        emit_signal("login_succeeded")
        return { "ok": true }

    return { "ok": false, "error": response.body.get("error", "Login failed") }

func logout() -> void:
    if _refresh_token != "":
        await _post(AUTH_HOST + "/api/auth/logout", { "refreshToken": _refresh_token })
    _clear_tokens()
    emit_signal("session_ended")

# ── Authenticated request (use this for all API calls) ─────────────────

func request(method: String, url: String, body: Variant = null, is_retry := false) -> Dictionary:
    var response = await _http(method, url, body, _access_token)

    if response.status == 401 and not is_retry:
        var refreshed = await _try_refresh()
        if refreshed:
            return await request(method, url, body, true)
        _clear_tokens()
        emit_signal("session_ended")
        return { "status": 401, "body": { "error": "Session expired" } }

    return response

# ── Refresh ────────────────────────────────────────────────────────────

func _try_refresh() -> bool:
    if _refresh_token == "":
        return false

    var response = await _post(AUTH_HOST + "/api/auth/refresh",
        { "refreshToken": _refresh_token })

    if response.status == 200 and response.body.has("accessToken"):
        _save_new_tokens(response.body)
        return true

    return false

# ── Token storage ──────────────────────────────────────────────────────

func _save_new_tokens(body: Dictionary) -> void:
    _access_token  = body.get("accessToken",  "")
    _refresh_token = body.get("refreshToken", "")
    var file = FileAccess.open(TOKEN_PATH, FileAccess.WRITE)
    file.store_string(JSON.stringify({
        "accessToken":  _access_token,
        "refreshToken": _refresh_token,
    }))
    file.close()

func _load_tokens() -> void:
    if not FileAccess.file_exists(TOKEN_PATH):
        return
    var file = FileAccess.open(TOKEN_PATH, FileAccess.READ)
    var data = JSON.parse_string(file.get_as_text())
    file.close()
    if data is Dictionary:
        _access_token  = data.get("accessToken",  "")
        _refresh_token = data.get("refreshToken", "")

func _clear_tokens() -> void:
    _access_token  = ""
    _refresh_token = ""
    if FileAccess.file_exists(TOKEN_PATH):
        DirAccess.remove_absolute(
            ProjectSettings.globalize_path(TOKEN_PATH))

# ── Low-level HTTP ─────────────────────────────────────────────────────

func _post(url: String, body: Dictionary) -> Dictionary:
    return await _http("POST", url, body, "")

func _http(method: String, url: String, body: Variant, bearer: String) -> Dictionary:
    var http = HTTPRequest.new()
    add_child(http)

    var headers := ["Content-Type: application/json"]
    if bearer != "":
        headers.append("Authorization: Bearer " + bearer)

    var method_id := HTTPClient.METHOD_GET
    if method == "POST":   method_id = HTTPClient.METHOD_POST
    elif method == "PATCH":  method_id = HTTPClient.METHOD_PATCH
    elif method == "DELETE": method_id = HTTPClient.METHOD_DELETE
    elif method == "PUT":    method_id = HTTPClient.METHOD_PUT

    var body_str := ""
    if body != null:
        body_str = JSON.stringify(body)

    http.request(url, headers, method_id, body_str)
    var result = await http.request_completed
    http.queue_free()

    # result = [result_code, response_code, headers, body_bytes]
    var status      : int        = result[1]
    var body_bytes  : PackedByteArray = result[3]
    var body_parsed : Variant    = JSON.parse_string(body_bytes.get_string_from_utf8())

    return {
        "status": status,
        "body":   body_parsed if body_parsed is Dictionary else {},
    }

Usage from any other script

## login_screen.gd

func _on_login_pressed() -> void:
    var result = await AuthManager.login(email_field.text, password_field.text)
    if result["ok"]:
        get_tree().change_scene_to_file("res://scenes/main.tscn")
    else:
        error_label.text = result["error"]

## anywhere in the game

func load_player_projects() -> void:
    var response = await AuthManager.request("GET", "https://roject.rokojori.com/api/projects")
    if response["status"] == 200:
        var projects = response["body"]
        # use projects...
    else:
        print("Failed to load projects: ", response["body"].get("error", "unknown"))

6 — Debugging

Log every request and response

While developing, print the HTTP status and URL of every call. This makes the retry pattern visible and confirms refresh is working.

## In _http() — add before the return statement:
print("[auth] %s %s → %d" % [method, url, status])

## Expected output during a transparent refresh:
# [auth] GET https://roject.rokojori.com/api/projects → 401
# [auth] POST https://account.rokojori.com/api/auth/refresh → 200
# [auth] GET https://roject.rokojori.com/api/projects → 200  ← retry succeeded

Inspect token expiry

## Godot — print decoded payload and expiry time
func debug_token(token: String) -> void:
    var payload = JwtDecode.decode_payload(token)
    var exp = payload.get("exp", 0)
    var expires_at = Time.get_datetime_string_from_unix_time(int(exp))
    var now = Time.get_unix_time_from_system()
    print("[auth] userId:    ", payload.get("userId", "?"))
    print("[auth] roles:     ", payload.get("roles",  []))
    print("[auth] expires:   ", expires_at)
    print("[auth] expired:   ", now >= exp)

Test the refresh endpoint manually

# From a terminal — paste in the refreshToken from your storage file
curl -s -X POST https://account.rokojori.com/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"your-uuid-here"}' | jq

# Expected on success:
# { "accessToken": "eyJ...", "refreshToken": "new-uuid" }

# Expected on failure (token expired or already used):
# { "error": "Invalid or expired refresh token" }

Common failure modes

  • 401 on every request, refresh also 401 — the refreshToken is expired (30 days) or was already used and discarded. Clear stored tokens and prompt for login.
  • 401 immediately after login — the saved accessToken from a previous session is being sent instead of the fresh one. Always overwrite stored tokens immediately after login.
  • Every request returns 401 then succeeds on retry, foreverACCESS_TOKEN_TTL on the server is set to '10s' (development artifact). The server admin must change it to '1h'. See the Implementation Guide, section 1.
  • Refresh returns 200 but next request still 401 — you saved the new tokens but are still sending the old accessToken. Make sure _access_token is updated from _save_new_tokens() before the retry call reads it.
  • No response / timeout — network issue or the service is down. Handle as a connection error separately from auth errors. Do not call _clear_tokens() on a timeout.

Quick reference

Endpoint summary

POST /api/auth/login
  body:     { email, password }
  response: { accessToken, refreshToken }
  errors:   401 invalid credentials, 429 rate limited

POST /api/auth/refresh
  body:     { refreshToken }
  response: { accessToken, refreshToken }   ← old refreshToken is now invalid
  errors:   401 token expired or not found

POST /api/auth/logout
  body:     { refreshToken }
  response: { ok: true }
  errors:   always 200

GET /api/auth/me
  header:   Authorization: Bearer <accessToken>
  response: { id, email, roles, products, settings }
  errors:   401 not authenticated

Request pattern for every protected call

1. Attach  Authorization: Bearer <accessToken>  to request
2. Send request
3. If response is 401 and not already a retry:
     a. POST /api/auth/refresh  with current refreshToken
     b. If 200: save new tokens, retry original request once
     c. If not 200: clear tokens, show login screen
4. If response is 401 on retry: clear tokens, show login screen

Token lifetimes

accessToken:  1 hour   — short-lived JWT; verify with jwt.decode() locally
refreshToken: 30 days  — opaque UUID; single-use, rotated on every refresh

Related documents

Implementation Guide — server-side Express middleware, Electron integration, and the canonical auth.ts file. Read this first for the mental model and for understanding what the server does with your token on the receiving end.