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.
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.
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.
Your client is responsible for three things the browser does automatically:
Authorization: Bearer <accessToken>.401 response, refreshing transparently, and retrying
the original request once with the new token.https://account.rokojori.com
All requests use Content-Type: application/json and expect
a JSON response body.
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." }
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
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
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.
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)
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.
## 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>/.
// 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 */ }
}
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)
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")
// 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.
}
// 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;
}
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)
}
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
## 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", [])
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.
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 {},
}
## 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"))
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
## 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)
# 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" }
ACCESS_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.
_access_token is updated from _save_new_tokens()
before the retry call reads it.
_clear_tokens() on a timeout.
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
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
accessToken: 1 hour — short-lived JWT; verify with jwt.decode() locally
refreshToken: 30 days — opaque UUID; single-use, rotated on every refresh
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.