850 lines
30 KiB
HTML
850 lines
30 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Non-Browser Auth Guide — rokojori</title>
|
|
<link rel="stylesheet" href="./_assets_/styles.css">
|
|
<link rel="stylesheet" href="./_assets_/nav.css">
|
|
</head>
|
|
<body>
|
|
<div class="page">
|
|
|
|
<header>
|
|
<p class="date">Guide</p>
|
|
<h1>Non-Browser Auth Guide</h1>
|
|
<p class="subtitle">
|
|
How to integrate rokojori-auth from any non-browser client — Godot, CLI tools,
|
|
native apps, scripts. No cookies. No redirects. Pure HTTP.
|
|
Read the <a href="./implementation-guide.html">Implementation Guide</a> first
|
|
for the mental model and token lifecycle.
|
|
</p>
|
|
</header>
|
|
|
|
<!-- ─── 0. Key differences from browser clients ──────────────── -->
|
|
<section>
|
|
<h2>0 — How non-browser clients differ</h2>
|
|
|
|
<div class="card">
|
|
<h3>No cookies</h3>
|
|
<p>
|
|
The server sets <code>HttpOnly</code> cookies for browser clients automatically.
|
|
A non-browser HTTP client ignores <code>Set-Cookie</code> response headers unless
|
|
explicitly programmed to store and re-send them. <strong style="color:var(--text)">Do
|
|
not use cookies.</strong> Use the response body instead — login and refresh both
|
|
return the tokens as JSON.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>No redirects</h3>
|
|
<p>
|
|
The server never redirects a well-formed non-browser client. Your client should
|
|
treat any <code>3xx</code> response as a bug in its request, not something to follow.
|
|
Token refresh is a direct <code>POST</code> that returns new tokens immediately.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Manual token management</h3>
|
|
<p>
|
|
Your client is responsible for three things the browser does automatically:
|
|
</p>
|
|
<ol style="margin-top:0.5rem;line-height:1.9;font-size:0.9rem;color:var(--muted)">
|
|
<li>Storing both tokens after login (and after every refresh).</li>
|
|
<li>Attaching the access token to every authenticated request as
|
|
<code>Authorization: Bearer <accessToken></code>.</li>
|
|
<li>Detecting a <code>401</code> response, refreshing transparently, and retrying
|
|
the original request once with the new token.</li>
|
|
</ol>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ─── 1. Endpoints ─────────────────────────────────────────── -->
|
|
<section>
|
|
<h2>1 — Endpoint reference</h2>
|
|
|
|
<div class="card">
|
|
<h3>Base URL</h3>
|
|
<pre><code>https://account.rokojori.com</code></pre>
|
|
<p style="margin-top:0.5rem">
|
|
All requests use <code>Content-Type: application/json</code> and expect
|
|
a JSON response body.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>POST /api/auth/login</h3>
|
|
<p>Exchange credentials for a token pair. This is the entry point for all sessions.</p>
|
|
<pre><code>// 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." }</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>POST /api/auth/refresh</h3>
|
|
<p>
|
|
Exchange an old refreshToken for a new token pair.
|
|
<strong style="color:var(--text)">The old refreshToken is invalidated immediately</strong>
|
|
— save the new tokens before making any other requests.
|
|
</p>
|
|
<pre><code>// 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</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>POST /api/auth/logout</h3>
|
|
<p>
|
|
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.
|
|
</p>
|
|
<pre><code>// 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</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>GET /api/auth/me</h3>
|
|
<p>Read the current user's profile. Requires a valid access token.</p>
|
|
<pre><code>// 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" }</code></pre>
|
|
<p style="margin-top:0.75rem">
|
|
In practice you rarely need to call <code>/me</code> — the JWT access token
|
|
already contains <code>userId</code>, <code>roles</code>, and <code>products</code>
|
|
in its payload. Decode it locally to read them without a network round-trip.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Calling any protected service endpoint</h3>
|
|
<p>
|
|
Every request to a protected endpoint on any rokojori service (Roject, tunnel, etc.)
|
|
must include the access token as a Bearer header:
|
|
</p>
|
|
<pre><code>// 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)</code></pre>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ─── 2. Token storage ─────────────────────────────────────── -->
|
|
<section>
|
|
<h2>2 — Token storage</h2>
|
|
|
|
<div class="card">
|
|
<h3>What to store</h3>
|
|
<p>Store both tokens together after login and after every successful refresh:</p>
|
|
<pre><code>{
|
|
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
|
|
"refreshToken": "a3f8c2d1-4b2e-..."
|
|
}</code></pre>
|
|
<p style="margin-top:0.75rem">
|
|
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.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Godot — store in user:// directory</h3>
|
|
<pre><code>## 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))</code></pre>
|
|
<p style="margin-top:0.75rem">
|
|
<code>user://</code> resolves to a per-application, per-user directory that
|
|
persists between runs. On Windows this is typically
|
|
<code>%APPDATA%/Godot/app_userdata/<project-name>/</code>.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Node.js / Electron — store in userData</h3>
|
|
<pre><code>// 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 */ }
|
|
}</code></pre>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ─── 3. The auth flow ──────────────────────────────────────── -->
|
|
<section>
|
|
<h2>3 — The auth flow</h2>
|
|
|
|
<div class="card">
|
|
<h3>Decision tree on startup</h3>
|
|
<pre><code>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)</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Wrapping every API call — the retry pattern</h3>
|
|
<p>
|
|
Wrap all API calls in a function that catches <code>401</code>, 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.
|
|
</p>
|
|
<pre><code>## 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")</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Node.js version of the same pattern</h3>
|
|
<pre><code>// 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.
|
|
}</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Logout flow</h3>
|
|
<pre><code>// 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;
|
|
}</code></pre>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ─── 4. Decoding the JWT locally ──────────────────────────── -->
|
|
<section>
|
|
<h2>4 — Reading the access token payload</h2>
|
|
|
|
<div class="card">
|
|
<h3>What is in the access token</h3>
|
|
<p>
|
|
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
|
|
<code>/api/auth/me</code> required during normal operation.
|
|
</p>
|
|
<pre><code>// 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)
|
|
}</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>JWT structure</h3>
|
|
<p>
|
|
A JWT is three base64url-encoded segments separated by dots:
|
|
<code>header.payload.signature</code>. The payload can be decoded without
|
|
the secret — only verification requires the secret.
|
|
</p>
|
|
<pre><code>eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← header
|
|
.eyJ1c2VySWQiOiI1NTBlODQwMC4uLiJ9 ← payload (base64url → JSON)
|
|
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← signature</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Godot — decode the payload</h3>
|
|
<pre><code>## 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", [])</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Check expiry without verifying</h3>
|
|
<p>
|
|
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 <strong style="color:var(--text)">not</strong> treat a non-expired
|
|
token as proof of validity — the server is the authority.
|
|
</p>
|
|
<pre><code>## 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; }
|
|
}</code></pre>
|
|
<p style="margin-top:0.75rem">
|
|
Optional optimisation: if you detect the token is expired before a call, run
|
|
<code>tryRefresh()</code> proactively so the first request goes out with a
|
|
fresh token rather than taking the 401 round-trip.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ─── 5. Full Godot example ─────────────────────────────────── -->
|
|
<section>
|
|
<h2>5 — Full Godot example</h2>
|
|
|
|
<div class="card">
|
|
<h3>AuthManager autoload</h3>
|
|
<p>
|
|
Put this in a singleton autoload (<code>Project → Project Settings → Autoload</code>)
|
|
named <code>AuthManager</code>. All other scripts call it instead of making HTTP
|
|
requests directly.
|
|
</p>
|
|
<pre><code>## 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 {},
|
|
}</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Usage from any other script</h3>
|
|
<pre><code>## 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"))</code></pre>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ─── 6. Debugging ─────────────────────────────────────────── -->
|
|
<section>
|
|
<h2>6 — Debugging</h2>
|
|
|
|
<div class="card">
|
|
<h3>Log every request and response</h3>
|
|
<p>
|
|
While developing, print the HTTP status and URL of every call.
|
|
This makes the retry pattern visible and confirms refresh is working.
|
|
</p>
|
|
<pre><code>## 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</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Inspect token expiry</h3>
|
|
<pre><code>## 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)</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Test the refresh endpoint manually</h3>
|
|
<pre><code># 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" }</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Common failure modes</h3>
|
|
<ul style="margin-top:0.5rem;line-height:2;font-size:0.9rem;color:var(--muted)">
|
|
<li>
|
|
<strong style="color:var(--text)">401 on every request, refresh also 401</strong>
|
|
— the refreshToken is expired (30 days) or was already used and discarded.
|
|
Clear stored tokens and prompt for login.
|
|
</li>
|
|
<li>
|
|
<strong style="color:var(--text)">401 immediately after login</strong>
|
|
— the saved accessToken from a previous session is being sent instead of
|
|
the fresh one. Always overwrite stored tokens immediately after login.
|
|
</li>
|
|
<li>
|
|
<strong style="color:var(--text)">Every request returns 401 then succeeds on retry,
|
|
forever</strong>
|
|
— <code>ACCESS_TOKEN_TTL</code> on the server is set to <code>'10s'</code>
|
|
(development artifact). The server admin must change it to <code>'1h'</code>.
|
|
See the <a href="./implementation-guide.html">Implementation Guide</a>, section 1.
|
|
</li>
|
|
<li>
|
|
<strong style="color:var(--text)">Refresh returns 200 but next request still 401</strong>
|
|
— you saved the new tokens but are still sending the old accessToken.
|
|
Make sure <code>_access_token</code> is updated from <code>_save_new_tokens()</code>
|
|
before the retry call reads it.
|
|
</li>
|
|
<li>
|
|
<strong style="color:var(--text)">No response / timeout</strong>
|
|
— network issue or the service is down. Handle as a connection error separately
|
|
from auth errors. Do not call <code>_clear_tokens()</code> on a timeout.
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ─── Quick reference ──────────────────────────────────────── -->
|
|
<section>
|
|
<h2>Quick reference</h2>
|
|
|
|
<div class="card">
|
|
<h3>Endpoint summary</h3>
|
|
<pre><code>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</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Request pattern for every protected call</h3>
|
|
<pre><code>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</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Token lifetimes</h3>
|
|
<pre><code>accessToken: 1 hour — short-lived JWT; verify with jwt.decode() locally
|
|
refreshToken: 30 days — opaque UUID; single-use, rotated on every refresh</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Related documents</h3>
|
|
<p>
|
|
<a href="./implementation-guide.html">Implementation Guide</a> — server-side
|
|
Express middleware, Electron integration, and the canonical
|
|
<code>auth.ts</code> file. Read this first for the mental model and for
|
|
understanding what the server does with your token on the receiving end.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<footer>
|
|
rokojori-auth — non-browser implementation guide
|
|
</footer>
|
|
|
|
</div>
|
|
<script>var NAV_ROOT = './';</script>
|
|
<script src="./_assets_/nav-data.js"></script>
|
|
<script src="./_assets_/nav.js"></script>
|
|
</body>
|
|
</html>
|