Auth Updates TTL

This commit is contained in:
Rokojori 2026-07-17 13:36:03 +02:00
parent 253ca97fe8
commit c45ff3be45
4 changed files with 1880 additions and 2 deletions

View File

@ -9,7 +9,7 @@ import { isSuperAdmin } from '../roles';
const router = Router(); const router = Router();
const ACCESS_TOKEN_TTL = '10s'; const ACCESS_TOKEN_TTL = process.env.ACCESS_TOKEN_TTL ?? '1h';
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com'; const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com';
const RESET_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com'; const RESET_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com';
const ACCOUNT_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com'; const ACCOUNT_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com';

View File

@ -1,5 +1,9 @@
var NAV_DATA = { var NAV_DATA = {
title: 'Workspace', title: 'Workspace',
path: 'index.html', path: 'index.html',
children: [] children: [
{ title: 'Implementation Guide', path: 'implementation-guide.html' },
{ title: 'Non-Browser Implementation Guide', path: 'non-browser-implementation-guide.html' },
{ title: 'Add a Subdomain', path: 'add-subdomain.html' },
]
}; };

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,849 @@
<!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 &lt;accessToken&gt;</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 &lt;accessToken&gt;
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/&lt;project-name&gt;/</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&lt;boolean&gt;
{
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&lt;Response&gt;
{
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&lt;void&gt;
{
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 &lt;accessToken&gt;
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 &lt;accessToken&gt; 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 &mdash; 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>