Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 26 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Use Supabase-friendly session helpers in Godot 4.

This addon gives you JWT decoding, local session storage, and stable client IDs. It does not force a specific auth UI or HTTP client.
This addon gives you explicitly unverified JWT metadata, adapter-based session storage, and non-authentication client IDs. It does not force a specific auth UI or HTTP client.

## Installation

Expand All @@ -21,34 +21,46 @@ const JwtModule = preload("res://addons/@aviorstudio_gd-supabase/src/jwt_module.
const SessionStoreModule = preload("res://addons/@aviorstudio_gd-supabase/src/session_store_module.gd")

var store := SessionStoreModule.new()
store.save({"access_token": token, "refresh_token": refresh_token})

var session := store.load_session()
var access_token := str(session.get("access_token", ""))

if JwtModule.is_expired(access_token):
var saved: SessionStoreModule.OperationResult = store.save({
"access_token": token,
"refresh_token": refresh_token,
})
if not saved.is_success():
push_error(saved.error)

var loaded: SessionStoreModule.LoadResult = store.load_session()
var access_token := str(loaded.data.get("access_token", ""))

var expiry: JwtModule.ExpiryResult = JwtModule.get_expiry_hint(access_token)
if expiry.status == JwtModule.ExpiryStatus.EXPIRED:
_refresh_session()
```

The default session store is memory-only and reports `NON_PERSISTENT`. For native persistence, inject a `SessionStoreModule.NativeCredentialAdapter` implemented with the target OS credential facility and select `NATIVE_CREDENTIAL`. For Web persistence, explicitly select `WEB_SESSION_STORAGE`; browser `sessionStorage` is script-accessible and is not a secure keystore.

## Client ID Example

```gdscript
const ClientIdModule = preload("res://addons/@aviorstudio_gd-supabase/src/client_id_module.gd")

var client_id := ClientIdModule.get_or_create_client_id()
var client_id := ClientIdModule.get_client_id()
```

**Correction ([fieldsofrevik#155](https://github.com/aviorstudio/fieldsofrevik/issues/155)):** the earlier example called nonexistent `get_or_create_client_id()`. The compiling API is `get_client_id()`. Native uses `OS.get_unique_id()`; Web defaults to process memory and allows explicit `sessionStorage` opt-in through `ClientIdConfig.web_storage_mode`.

## What You Get

- `JwtModule`: decode JWT payloads and check expiration timestamps.
- `SessionStoreModule`: save, load, and clear local session dictionaries.
- `ClientIdModule`: get or create a stable client ID across supported platforms.
- `JwtModule`: structurally inspect unverified JWT metadata and return typed expiry hints.
- `SessionStoreModule`: typed memory, injected native credential, and explicit Web tab storage.
- `ClientIdModule`: non-authentication client IDs with explicit Web persistence.

## Security Notes

- `JwtModule` decodes JWT payloads but does not verify signatures.
- `SessionStoreModule` stores local JSON-like session data.
- Your game owns refresh, revoke, encryption, platform credential storage, and server trust decisions.
- `JwtModule` does not verify signatures. Its claims and expiry are untrusted scheduling/display hints and must never establish identity or authorization.
- Native persistence exists only through a caller-injected OS credential adapter. There is no plaintext or bundled-key encryption fallback.
- Web defaults to memory. Explicit `sessionStorage` is accessible to page scripts and is not a secure keystore; sessions do not use `localStorage`.
- Session payloads are lossless JSON objects bounded to 1 MiB; JWT inputs are bounded to 64 KiB. The caller owns refresh, revoke, server verification, and trust decisions.
- Legacy plaintext migration is explicit, requires destination write/readback success, and never deletes the source in this release.

## Repository Layout

Expand Down
2 changes: 1 addition & 1 deletion addon/plugin.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
name="GD Supabase"
description="Game-agnostic Supabase session primitives for Godot 4 (JWT/session/client-id)."
author="Avior Studio"
version="0.0.1"
version="0.0.2"
script="plugin.gd"
70 changes: 45 additions & 25 deletions addon/src/client_id_module.gd
Original file line number Diff line number Diff line change
@@ -1,35 +1,55 @@
## Cross-platform client ID generation and persistence helper.
## Non-authentication client ID generation with explicit Web persistence.
class_name ClientIdModule
extends RefCounted

## Runtime configuration for web storage behavior.
enum WebStorageMode {
MEMORY,
SESSION_STORAGE,
}

class ClientIdConfig extends RefCounted:
## localStorage key used on web platforms.
var storage_key: String = "app_client_id"
## Prefix applied to generated web IDs.
var prefix: String = "web_"
## Memory is the safe default. sessionStorage is script-accessible opt-in.
var web_storage_mode: int = WebStorageMode.MEMORY

## Returns a stable client ID for current platform.
static func get_client_id(config: ClientIdConfig = null) -> String:
if OS.has_feature("web"):
return _get_web_client_id(_resolve_config(config))
return OS.get_unique_id()
static var _web_memory: Dictionary[String, String] = {}

static func _resolve_config(config: ClientIdConfig) -> ClientIdConfig:
if config != null:
return config
return ClientIdConfig.new()
## Returns a stable ID for the current process/tab. It is never authentication.
static func get_client_id(config: ClientIdConfig = null) -> String:
if not OS.has_feature("web"):
return OS.get_unique_id()
var resolved := config if config != null else ClientIdConfig.new()
if resolved.storage_key.is_empty():
return ""
if resolved.web_storage_mode == WebStorageMode.SESSION_STORAGE:
return _get_session_storage_id(resolved)
if resolved.web_storage_mode != WebStorageMode.MEMORY:
return ""
if _web_memory.has(resolved.storage_key):
return _web_memory[resolved.storage_key]
var generated := resolved.prefix + _random_id()
_web_memory[resolved.storage_key] = generated
return generated

static func _get_web_client_id(config: ClientIdConfig) -> String:
var existing_value: String = str(JavaScriptBridge.eval("localStorage.getItem('%s')" % config.storage_key, true))
if existing_value != "null" and not existing_value.is_empty():
return existing_value
var new_id: String = _generate_web_id(config.prefix)
JavaScriptBridge.eval("localStorage.setItem('%s', '%s')" % [config.storage_key, new_id], true)
return new_id
static func _get_session_storage_id(config: ClientIdConfig) -> String:
var storage: JavaScriptObject = JavaScriptBridge.get_interface("sessionStorage")
if storage == null:
return ""
var existing: Variant = storage.call("getItem", config.storage_key)
if existing is String and not str(existing).is_empty():
return str(existing)
var generated := config.prefix + _random_id()
storage.call("setItem", config.storage_key, generated)
var checked: Variant = storage.call("getItem", config.storage_key)
return str(checked) if checked is String and str(checked) == generated else ""

static func _generate_web_id(prefix: String) -> String:
var uuid: String = str(JavaScriptBridge.eval("(typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : ''", true))
if uuid != "null" and not uuid.is_empty():
return prefix + uuid
return prefix + str(Time.get_ticks_msec()) + "_" + str(randi()) + "_" + str(randi())
static func _random_id() -> String:
if OS.has_feature("web"):
var crypto_interface: JavaScriptObject = JavaScriptBridge.get_interface("crypto")
if crypto_interface != null:
var uuid: Variant = crypto_interface.call("randomUUID")
if uuid is String and not str(uuid).is_empty():
return str(uuid)
var random_bytes := Crypto.new().generate_random_bytes(16)
return random_bytes.hex_encode()
236 changes: 177 additions & 59 deletions addon/src/jwt_module.gd
Original file line number Diff line number Diff line change
@@ -1,65 +1,183 @@
## JWT helpers for payload decoding and expiry checks (no signature verification).
## Strict JWT inspection for explicitly unverified metadata hints.
## This module never verifies signatures and must not be used for authorization.
class_name JwtModule
extends RefCounted

## Parsed JWT payload object.
class JwtPayload extends RefCounted:
## JWT `sub` claim.
var subject: String = ""
## JWT `email` claim.
var email: String = ""
## JWT `exp` claim in unix seconds.
var expires_at: int = 0
## JWT `iat` claim in unix seconds.
var issued_at: int = 0
## Full decoded claim map.
var claims: Dictionary[String, Variant] = {}
const MAX_TOKEN_BYTES := 64 * 1024
const MAX_EXACT_JSON_INTEGER := 9007199254740991

enum InspectionStatus {
OK,
TOKEN_TOO_LARGE,
INVALID_STRUCTURE,
INVALID_BASE64URL,
INVALID_UTF8,
INVALID_JSON,
INVALID_HEADER,
INVALID_CLAIMS,
}

## Decodes and parses payload segment from a JWT string.
static func decode_payload(token: String) -> JwtPayload:
var payload: JwtPayload = JwtPayload.new()
var parts: PackedStringArray = token.split(".")
if parts.size() < 2:
return payload
var payload_segment: String = _base64url_to_base64(parts[1])
var payload_raw: PackedByteArray = Marshalls.base64_to_raw(payload_segment)
if payload_raw.is_empty():
return payload
var payload_text: String = payload_raw.get_string_from_utf8()
if payload_text.is_empty():
return payload
var parsed: Variant = JSON.parse_string(payload_text)
if not (parsed is Dictionary):
return payload
enum ExpiryStatus {
VALID,
EXPIRED,
INVALID_OR_UNKNOWN,
}

## Header and claims decoded without signature verification.
class UnverifiedMetadata extends RefCounted:
var header: Dictionary[String, Variant] = {}
var claims: Dictionary[String, Variant] = {}
claims.merge(parsed)
payload.claims = claims
payload.subject = str(claims.get("sub", ""))
payload.email = str(claims.get("email", ""))
payload.expires_at = int(claims.get("exp", 0))
payload.issued_at = int(claims.get("iat", 0))
return payload

## Returns true when the token has a valid `exp` claim in the past.
static func is_expired(token: String, now_unix: int = -1) -> bool:
var expiry_unix: int = get_expiry_unix(token)
if expiry_unix <= 0:
return false
var now_seconds: int = now_unix if now_unix >= 0 else int(Time.get_unix_time_from_system())
return now_seconds >= expiry_unix

## Returns token expiry timestamp (`exp`) in unix seconds, or 0 when unavailable.
static func get_expiry_unix(token: String) -> int:
var payload: JwtPayload = decode_payload(token)
return payload.expires_at

static func _base64url_to_base64(value: String) -> String:
var normalized: String = value.replace("-", "+").replace("_", "/")
var remainder: int = normalized.length() % 4
if remainder == 2:
normalized += "=="
elif remainder == 3:
## Always false. Kept explicit so metadata cannot be mistaken for verified identity.
var trusted: bool = false

## Typed result of structural JWT inspection.
class InspectionResult extends RefCounted:
var status: int = InspectionStatus.INVALID_STRUCTURE
var metadata: UnverifiedMetadata = UnverifiedMetadata.new()
var error: String = ""

func is_valid() -> bool:
return status == InspectionStatus.OK

## Typed unverified expiry hint.
class ExpiryResult extends RefCounted:
var status: int = ExpiryStatus.INVALID_OR_UNKNOWN
var expires_at: int = 0
var error: String = ""
## Always false: expiry parsing is not signature verification.
var trusted: bool = false

## Inspects a compact JWT. Claims are untrusted metadata only.
static func inspect_unverified(token: String) -> InspectionResult:
if token.to_utf8_buffer().size() > MAX_TOKEN_BYTES:
return _inspection_error(InspectionStatus.TOKEN_TOO_LARGE, "token exceeds 64 KiB")
var parts: PackedStringArray = token.split(".", true)
if parts.size() != 3 or parts[0].is_empty() or parts[1].is_empty() or parts[2].is_empty():
return _inspection_error(InspectionStatus.INVALID_STRUCTURE, "JWT must contain three non-empty segments")
if not _is_base64url(parts[2]) or parts[2].length() % 4 == 1:
return _inspection_error(InspectionStatus.INVALID_BASE64URL, "invalid signature base64url segment")

var header_result: Dictionary = _decode_json_object(parts[0])
if not bool(header_result.get("ok", false)):
return _inspection_error(int(header_result.status), str(header_result.error))
var payload_result: Dictionary = _decode_json_object(parts[1])
if not bool(payload_result.get("ok", false)):
return _inspection_error(int(payload_result.status), str(payload_result.error))

var header: Dictionary = header_result.value
var claims: Dictionary = payload_result.value
if not (header.get("alg") is String) or str(header.get("alg")).is_empty() or str(header.get("alg")).to_lower() == "none":
return _inspection_error(InspectionStatus.INVALID_HEADER, "JWT alg must be a non-empty, non-none string")
for claim_name: String in ["sub", "email"]:
if claims.has(claim_name) and not (claims[claim_name] is String):
return _inspection_error(InspectionStatus.INVALID_CLAIMS, "%s must be a string" % claim_name)
for claim_name: String in ["exp", "iat"]:
if claims.has(claim_name) and not _is_safe_nonnegative_integer(claims[claim_name]):
return _inspection_error(InspectionStatus.INVALID_CLAIMS, "%s must be an exact non-negative JSON integer" % claim_name)

var result := InspectionResult.new()
result.status = InspectionStatus.OK
result.metadata.header.merge(header)
result.metadata.claims.merge(claims)
return result

## Returns a typed expiry result. It is an unverified scheduling hint, not auth.
static func get_expiry_hint(token: String, now_unix: int = -1) -> ExpiryResult:
var inspected := inspect_unverified(token)
if not inspected.is_valid():
return _expiry_error(inspected.error)
if not inspected.metadata.claims.has("exp"):
return _expiry_error("exp claim is missing")
var expiry_value: Variant = inspected.metadata.claims.exp
if not _is_safe_nonnegative_integer(expiry_value) or int(expiry_value) <= 0:
return _expiry_error("exp claim is invalid")
var result := ExpiryResult.new()
result.expires_at = int(expiry_value)
var now_seconds := now_unix if now_unix >= 0 else int(Time.get_unix_time_from_system())
result.status = ExpiryStatus.EXPIRED if now_seconds >= result.expires_at else ExpiryStatus.VALID
return result

static func _decode_json_object(segment: String) -> Dictionary:
if not _is_base64url(segment) or segment.length() % 4 == 1:
return {"ok": false, "status": InspectionStatus.INVALID_BASE64URL, "error": "invalid base64url segment"}
var normalized := segment.replace("-", "+").replace("_", "/")
while normalized.length() % 4 != 0:
normalized += "="
elif remainder == 1:
normalized += "==="
return normalized
var raw := Marshalls.base64_to_raw(normalized)
if raw.is_empty():
return {"ok": false, "status": InspectionStatus.INVALID_BASE64URL, "error": "empty decoded segment"}
var canonical := Marshalls.raw_to_base64(raw).replace("+", "-").replace("/", "_").trim_suffix("=").trim_suffix("=")
if canonical != segment:
return {"ok": false, "status": InspectionStatus.INVALID_BASE64URL, "error": "non-canonical base64url segment"}
if not _is_valid_utf8(raw):
return {"ok": false, "status": InspectionStatus.INVALID_UTF8, "error": "segment is not valid UTF-8"}
var text := raw.get_string_from_utf8()
var json := JSON.new()
if json.parse(text) != OK:
return {"ok": false, "status": InspectionStatus.INVALID_JSON, "error": "segment is not valid JSON"}
if not (json.data is Dictionary):
return {"ok": false, "status": InspectionStatus.INVALID_JSON, "error": "segment JSON must be an object"}
return {"ok": true, "value": json.data}

static func _is_base64url(value: String) -> bool:
for index: int in value.length():
var code := value.unicode_at(index)
var allowed := (code >= 65 and code <= 90) or (code >= 97 and code <= 122) or (code >= 48 and code <= 57) or code == 45 or code == 95
if not allowed:
return false
return not value.is_empty()

static func _is_safe_nonnegative_integer(value: Variant) -> bool:
if not (value is int or value is float):
return false
var number := float(value)
return is_finite(number) and number >= 0.0 and number <= MAX_EXACT_JSON_INTEGER and floor(number) == number

static func _is_valid_utf8(raw: PackedByteArray) -> bool:
var index := 0
while index < raw.size():
var first := raw[index]
if first <= 0x7f:
index += 1
continue
var continuation_count := 0
var second_min := 0x80
var second_max := 0xbf
if first >= 0xc2 and first <= 0xdf:
continuation_count = 1
elif first >= 0xe0 and first <= 0xef:
continuation_count = 2
if first == 0xe0:
second_min = 0xa0
elif first == 0xed:
second_max = 0x9f
elif first >= 0xf0 and first <= 0xf4:
continuation_count = 3
if first == 0xf0:
second_min = 0x90
elif first == 0xf4:
second_max = 0x8f
else:
return false
if index + continuation_count >= raw.size():
return false
var second := raw[index + 1]
if second < second_min or second > second_max:
return false
for offset: int in range(2, continuation_count + 1):
var continuation := raw[index + offset]
if continuation < 0x80 or continuation > 0xbf:
return false
index += continuation_count + 1
return true

static func _inspection_error(status: int, error: String) -> InspectionResult:
var result := InspectionResult.new()
result.status = status
result.error = error
return result

static func _expiry_error(error: String) -> ExpiryResult:
var result := ExpiryResult.new()
result.error = error
return result
Loading
Loading