From 6875837cf4bda3b1792525f2571a298ef949168b Mon Sep 17 00:00:00 2001 From: nicodes Date: Sat, 12 Sep 2026 17:44:44 -0600 Subject: [PATCH 1/2] Record session boundary decisions --- docs/session-contract.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/session-contract.md diff --git a/docs/session-contract.md b/docs/session-contract.md new file mode 100644 index 0000000..0ce29de --- /dev/null +++ b/docs/session-contract.md @@ -0,0 +1,13 @@ +# Session boundary contract + +Decision record for [fieldsofrevik#155](https://github.com/aviorstudio/fieldsofrevik/issues/155), selected 2026-09-12. + +- Keep the Supabase and PocketBase adapters as independent implementations with matching externally tested behavior (D-06); no shared source dependency is introduced. +- Session operations return typed result classes so callers can distinguish success, non-persistent memory mode, invalid stored data, unavailable adapters, and failed migration/write/readback. +- Native persistence requires a caller-injected credential-store adapter. Without one, the safe default is memory-only and is reported as non-persistent; plaintext files are never a fallback. +- Web persistence defaults to memory. A caller may explicitly select `sessionStorage`, which is script-accessible browser storage and is not described as a secure keystore. `localStorage` is not used for sessions. +- Existing plaintext legacy files are read only for an explicit migration into an injected credential adapter. Source data is retained unless and until destination write and readback both succeed; this release does not destructively remove legacy data (D-08). +- Client IDs are not authentication. Native client IDs continue to use `OS.get_unique_id()` with documented platform/privacy limitations. Web client IDs use memory by default with explicit `sessionStorage` opt-in and safe JavaScript object calls. +- Decoded JWT claims are unverified hints only. They cannot establish identity, authorization, issuer/audience trust, or signature validity. Expiry has typed valid, expired, and invalid/unknown outcomes. + +Concrete methods and statuses must preserve these decisions and are covered by native and Web contract tests in the behavior layer. From 1ed6a5fc3f21d5e00a19f0b74e0a16ea2ee055de Mon Sep 17 00:00:00 2001 From: nicodes Date: Sat, 12 Sep 2026 18:55:31 -0600 Subject: [PATCH 2/2] Harden session boundary behavior --- README.md | 40 ++- addon/plugin.cfg | 2 +- addon/src/client_id_module.gd | 70 +++-- addon/src/jwt_module.gd | 236 +++++++++++---- addon/src/session_store_module.gd | 354 +++++++++++++++++----- docs/session-contract.md | 2 +- tests/client_id_module_test.gd | 3 +- tests/evidence/issue-155-behavior-web.png | Bin 0 -> 9608 bytes tests/jwt_module_test.gd | 87 +++--- tests/package_fixture/main.gd | 32 +- tests/session_store_module_test.gd | 176 ++++++++--- tests/web_test.mjs | 7 + 12 files changed, 745 insertions(+), 264 deletions(-) create mode 100644 tests/evidence/issue-155-behavior-web.png diff --git a/README.md b/README.md index fcdd6ea..3e3b76b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/addon/plugin.cfg b/addon/plugin.cfg index 3249692..3d8de90 100644 --- a/addon/plugin.cfg +++ b/addon/plugin.cfg @@ -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" diff --git a/addon/src/client_id_module.gd b/addon/src/client_id_module.gd index 02031d7..2434c77 100644 --- a/addon/src/client_id_module.gd +++ b/addon/src/client_id_module.gd @@ -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() diff --git a/addon/src/jwt_module.gd b/addon/src/jwt_module.gd index 0db5cf0..a4e5603 100644 --- a/addon/src/jwt_module.gd +++ b/addon/src/jwt_module.gd @@ -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 diff --git a/addon/src/session_store_module.gd b/addon/src/session_store_module.gd index fd2d2b5..dee660a 100644 --- a/addon/src/session_store_module.gd +++ b/addon/src/session_store_module.gd @@ -1,96 +1,296 @@ -## File-backed session persistence with optional legacy path migration. +## Session storage with safe memory, injected native credential, and Web tab adapters. class_name SessionStoreModule extends RefCounted -## Configuration for session storage and migration behavior. +const MAX_SESSION_BYTES := 1024 * 1024 + +enum StorageMode { + MEMORY, + NATIVE_CREDENTIAL, + WEB_SESSION_STORAGE, +} + +enum Status { + OK, + NOT_FOUND, + NON_PERSISTENT, + INVALID_DATA, + TOO_LARGE, + ADAPTER_ERROR, + WRITE_VERIFY_FAILED, + MIGRATION_FAILED, + UNSUPPORTED, +} + +enum CredentialReadStatus { + FOUND, + NOT_FOUND, + ERROR, +} + +class CredentialReadResult extends RefCounted: + var status: int = CredentialReadStatus.NOT_FOUND + var value: String = "" + var error: String = "" + +## Caller implementation backed by the target OS credential facility. +## `write_atomic` must replace the value atomically or leave the old value intact. +class NativeCredentialAdapter extends RefCounted: + func read_value(_key: String) -> CredentialReadResult: + var result := CredentialReadResult.new() + result.status = CredentialReadStatus.ERROR + result.error = "read_value is not implemented" + return result + + func write_atomic(_key: String, _value: String) -> bool: + return false + + func delete_value(_key: String) -> bool: + return false + class SessionStoreConfig extends RefCounted: - ## Format string for the target session path (`%s` receives `store_id`). - var file_path_template: String = "user://session_%s.dat" - ## Logical session store identifier. - var store_id: String = "default" - ## Legacy file paths to check and migrate from on first load. + var storage_key: String = "gd_supabase_session_default" + var mode: int = StorageMode.MEMORY + var credential_adapter: NativeCredentialAdapter = null + ## Explicit legacy plaintext paths eligible for nondestructive migration. var legacy_paths: Array[String] = [] +class OperationResult extends RefCounted: + var status: int = Status.ADAPTER_ERROR + var error: String = "" + var persistent: bool = false + + func is_success() -> bool: + return status == Status.OK or status == Status.NON_PERSISTENT + +class LoadResult extends OperationResult: + var data: Dictionary[String, Variant] = {} + var migrated_from: String = "" + var _config: SessionStoreConfig = SessionStoreConfig.new() +var _memory: Dictionary[String, Variant] = {} -## Creates a new session store instance. func _init(config: SessionStoreConfig = null) -> void: if config != null: _config = config -## Replaces runtime config values. -func configure(config: SessionStoreConfig) -> void: - if config == null: - return +func configure(config: SessionStoreConfig) -> OperationResult: + if config == null or config.storage_key.is_empty(): + return _operation(Status.INVALID_DATA, "storage config and key are required") _config = config + _memory.clear() + return _operation(Status.OK, "", _is_persistent_mode()) -## Saves session payload to primary session path. -func save(data: Dictionary[String, Variant]) -> bool: +## Saves JSON-compatible session data. Memory mode succeeds explicitly as non-persistent. +func save(data: Dictionary[String, Variant]) -> OperationResult: if data.is_empty(): - clear() - return true - var file: FileAccess = FileAccess.open(_get_primary_path(), FileAccess.WRITE) - if file == null: - return false - file.store_string(JSON.stringify(data)) - return true - -## Loads session data from primary or legacy path; migrates legacy data to primary. -func load_session() -> Dictionary[String, Variant]: - var resolved_path: String = _resolve_existing_path() - if resolved_path.is_empty(): - return {} - var file: FileAccess = FileAccess.open(resolved_path, FileAccess.READ) - if file == null: - return {} - var raw: String = file.get_as_text() - if raw.is_empty(): - return {} - var parsed: Variant = JSON.parse_string(raw) - if not (parsed is Dictionary): - clear() - return {} - var payload: Dictionary[String, Variant] = {} - payload.merge(parsed) - var primary_path: String = _get_primary_path() - if resolved_path != primary_path: - save(payload) - if FileAccess.file_exists(resolved_path): - _remove_file(resolved_path) - return payload - -## Clears persisted session files for primary and legacy paths. -func clear() -> void: - for candidate_path: String in _get_candidate_paths(): - if FileAccess.file_exists(candidate_path): - _remove_file(candidate_path) - -## Returns true when any known session file exists. -func exists() -> bool: - for candidate_path: String in _get_candidate_paths(): - if FileAccess.file_exists(candidate_path): + return clear() + var encoded := _encode_payload(data) + if not bool(encoded.ok): + return _operation(int(encoded.status), str(encoded.error), _is_persistent_mode()) + return _write_encoded(str(encoded.value)) + +func load_session() -> LoadResult: + var read := _read_encoded() + if not read.is_success() or read.status == Status.NOT_FOUND: + return read + var decoded := _decode_payload(str(read.data.get("encoded", ""))) + if not decoded.is_success(): + decoded.persistent = read.persistent + return decoded + decoded.persistent = read.persistent + if not read.persistent: + decoded.status = Status.NON_PERSISTENT + return decoded + +## Clears the active destination only. Legacy sources are never deleted. +func clear() -> OperationResult: + match _config.mode: + StorageMode.MEMORY: + _memory.clear() + return _operation(Status.NON_PERSISTENT, "", false) + StorageMode.NATIVE_CREDENTIAL: + if _config.credential_adapter == null: + return _operation(Status.ADAPTER_ERROR, "native credential adapter is required", true) + if not _config.credential_adapter.delete_value(_config.storage_key): + return _operation(Status.ADAPTER_ERROR, "credential delete failed", true) + var checked := _config.credential_adapter.read_value(_config.storage_key) + if checked.status == CredentialReadStatus.FOUND: + return _operation(Status.WRITE_VERIFY_FAILED, "credential delete readback failed", true) + if checked.status == CredentialReadStatus.ERROR: + return _operation(Status.ADAPTER_ERROR, checked.error, true) + return _operation(Status.OK, "", true) + StorageMode.WEB_SESSION_STORAGE: + var storage := _web_storage() + if storage == null: + return _operation(Status.UNSUPPORTED, "sessionStorage requires a Web export with JavaScriptBridge", true) + storage.call("removeItem", _config.storage_key) + if typeof(storage.call("getItem", _config.storage_key)) != TYPE_NIL: + return _operation(Status.WRITE_VERIFY_FAILED, "sessionStorage delete readback failed", true) + return _operation(Status.OK, "", true) + return _operation(Status.UNSUPPORTED, "unknown storage mode") + +## Explicitly migrates one valid legacy file into a persistent destination. +## The source remains intact even after successful write/readback (D-08). +func migrate_legacy() -> LoadResult: + if not _is_persistent_mode(): + return _load_error(Status.MIGRATION_FAILED, "migration requires a persistent destination", false) + var destination := load_session() + if destination.is_success() and not destination.data.is_empty(): + return destination + if destination.status != Status.NOT_FOUND: + return _load_error(Status.MIGRATION_FAILED, destination.error, true) + for path: String in _config.legacy_paths: + if path.is_empty() or not FileAccess.file_exists(path): + continue + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return _load_error(Status.MIGRATION_FAILED, "legacy source could not be opened", true) + if file.get_length() > MAX_SESSION_BYTES: + return _load_error(Status.MIGRATION_FAILED, "legacy source exceeds 1 MiB", true) + var decoded := _decode_payload(file.get_as_text()) + if not decoded.is_success(): + return _load_error(Status.MIGRATION_FAILED, decoded.error, true) + var saved := save(decoded.data) + if not saved.is_success(): + return _load_error(Status.MIGRATION_FAILED, saved.error, true) + var verified := load_session() + if not verified.is_success() or verified.data != decoded.data: + return _load_error(Status.MIGRATION_FAILED, "migration destination readback mismatch", true) + verified.migrated_from = path + return verified + return _load_error(Status.NOT_FOUND, "", true) + +func _write_encoded(encoded: String) -> OperationResult: + match _config.mode: + StorageMode.MEMORY: + var decoded := _decode_payload(encoded) + if not decoded.is_success(): + return _operation(decoded.status, decoded.error) + _memory = decoded.data.duplicate(true) + return _operation(Status.NON_PERSISTENT, "", false) + StorageMode.NATIVE_CREDENTIAL: + if _config.credential_adapter == null: + return _operation(Status.ADAPTER_ERROR, "native credential adapter is required", true) + if not _config.credential_adapter.write_atomic(_config.storage_key, encoded): + return _operation(Status.ADAPTER_ERROR, "credential atomic write failed", true) + var checked := _config.credential_adapter.read_value(_config.storage_key) + if checked.status != CredentialReadStatus.FOUND or checked.value != encoded: + return _operation(Status.WRITE_VERIFY_FAILED, "credential write readback mismatch", true) + return _operation(Status.OK, "", true) + StorageMode.WEB_SESSION_STORAGE: + var storage := _web_storage() + if storage == null: + return _operation(Status.UNSUPPORTED, "sessionStorage requires a Web export with JavaScriptBridge", true) + storage.call("setItem", _config.storage_key, encoded) + var checked: Variant = storage.call("getItem", _config.storage_key) + if not (checked is String) or str(checked) != encoded: + return _operation(Status.WRITE_VERIFY_FAILED, "sessionStorage write readback mismatch", true) + return _operation(Status.OK, "", true) + return _operation(Status.UNSUPPORTED, "unknown storage mode") + +func _read_encoded() -> LoadResult: + match _config.mode: + StorageMode.MEMORY: + if _memory.is_empty(): + return _load_error(Status.NOT_FOUND, "", false) + var encoded := _encode_payload(_memory) + if not bool(encoded.ok): + return _load_error(int(encoded.status), str(encoded.error), false) + return _encoded_load(Status.NON_PERSISTENT, str(encoded.value), false) + StorageMode.NATIVE_CREDENTIAL: + if _config.credential_adapter == null: + return _load_error(Status.ADAPTER_ERROR, "native credential adapter is required", true) + var read := _config.credential_adapter.read_value(_config.storage_key) + if read.status == CredentialReadStatus.NOT_FOUND: + return _load_error(Status.NOT_FOUND, "", true) + if read.status != CredentialReadStatus.FOUND: + return _load_error(Status.ADAPTER_ERROR, read.error, true) + return _encoded_load(Status.OK, read.value, true) + StorageMode.WEB_SESSION_STORAGE: + var storage := _web_storage() + if storage == null: + return _load_error(Status.UNSUPPORTED, "sessionStorage requires a Web export with JavaScriptBridge", true) + var value: Variant = storage.call("getItem", _config.storage_key) + if typeof(value) == TYPE_NIL: + return _load_error(Status.NOT_FOUND, "", true) + if not (value is String): + return _load_error(Status.INVALID_DATA, "sessionStorage value is not a string", true) + return _encoded_load(Status.OK, str(value), true) + return _load_error(Status.UNSUPPORTED, "unknown storage mode", false) + +func _encode_payload(data: Dictionary[String, Variant]) -> Dictionary: + if not _is_json_value(data, []): + return {"ok": false, "status": Status.INVALID_DATA, "error": "session payload must contain only acyclic JSON-compatible values"} + var encoded := JSON.stringify(data) + if encoded.is_empty() or encoded.to_utf8_buffer().size() > MAX_SESSION_BYTES: + return {"ok": false, "status": Status.TOO_LARGE, "error": "session payload exceeds 1 MiB or cannot be encoded"} + return {"ok": true, "value": encoded} + +func _is_json_value(value: Variant, containers: Array[Variant]) -> bool: + match typeof(value): + TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_STRING: + return true + TYPE_FLOAT: + return is_finite(float(value)) + TYPE_ARRAY, TYPE_DICTIONARY: + for existing: Variant in containers: + if is_same(existing, value): + return false + containers.append(value) + if value is Array: + for item: Variant in value: + if not _is_json_value(item, containers): + containers.pop_back() + return false + else: + for key: Variant in value: + if not (key is String) or not _is_json_value(value[key], containers): + containers.pop_back() + return false + containers.pop_back() return true return false -func _get_primary_path() -> String: - return _config.file_path_template % _config.store_id +func _decode_payload(encoded: String) -> LoadResult: + if encoded.to_utf8_buffer().size() > MAX_SESSION_BYTES: + return _load_error(Status.TOO_LARGE, "stored session exceeds 1 MiB", _is_persistent_mode()) + var json := JSON.new() + if json.parse(encoded) != OK or not (json.data is Dictionary): + return _load_error(Status.INVALID_DATA, "stored session is not a JSON object", _is_persistent_mode()) + var data: Dictionary[String, Variant] = {} + data.merge(json.data) + var result := LoadResult.new() + result.status = Status.OK + result.data = data + result.persistent = _is_persistent_mode() + return result -func _get_candidate_paths() -> Array[String]: - var candidates: Array[String] = [_get_primary_path()] - for legacy_path: String in _config.legacy_paths: - if legacy_path.is_empty(): - continue - if candidates.has(legacy_path): - continue - candidates.append(legacy_path) - return candidates - -func _resolve_existing_path() -> String: - for candidate_path: String in _get_candidate_paths(): - if FileAccess.file_exists(candidate_path): - return candidate_path - return "" - -func _remove_file(path: String) -> void: - var absolute_path: String = ProjectSettings.globalize_path(path) - DirAccess.remove_absolute(absolute_path) +func _web_storage() -> JavaScriptObject: + if not OS.has_feature("web"): + return null + return JavaScriptBridge.get_interface("sessionStorage") + +func _is_persistent_mode() -> bool: + return _config.mode != StorageMode.MEMORY + +func _operation(status: int, error: String = "", persistent: bool = false) -> OperationResult: + var result := OperationResult.new() + result.status = status + result.error = error + result.persistent = persistent + return result + +func _load_error(status: int, error: String, persistent: bool) -> LoadResult: + var result := LoadResult.new() + result.status = status + result.error = error + result.persistent = persistent + return result + +func _encoded_load(status: int, encoded: String, persistent: bool) -> LoadResult: + var result := LoadResult.new() + result.status = status + result.persistent = persistent + result.data = {"encoded": encoded} + return result diff --git a/docs/session-contract.md b/docs/session-contract.md index 0ce29de..9d065ee 100644 --- a/docs/session-contract.md +++ b/docs/session-contract.md @@ -6,7 +6,7 @@ Decision record for [fieldsofrevik#155](https://github.com/aviorstudio/fieldsofr - Session operations return typed result classes so callers can distinguish success, non-persistent memory mode, invalid stored data, unavailable adapters, and failed migration/write/readback. - Native persistence requires a caller-injected credential-store adapter. Without one, the safe default is memory-only and is reported as non-persistent; plaintext files are never a fallback. - Web persistence defaults to memory. A caller may explicitly select `sessionStorage`, which is script-accessible browser storage and is not described as a secure keystore. `localStorage` is not used for sessions. -- Existing plaintext legacy files are read only for an explicit migration into an injected credential adapter. Source data is retained unless and until destination write and readback both succeed; this release does not destructively remove legacy data (D-08). +- Existing plaintext legacy files are read only for an explicit migration into an injected credential adapter. Destination write and readback must both succeed, and this release retains the source even after success; it never destructively removes legacy data (D-08). - Client IDs are not authentication. Native client IDs continue to use `OS.get_unique_id()` with documented platform/privacy limitations. Web client IDs use memory by default with explicit `sessionStorage` opt-in and safe JavaScript object calls. - Decoded JWT claims are unverified hints only. They cannot establish identity, authorization, issuer/audience trust, or signature validity. Expiry has typed valid, expired, and invalid/unknown outcomes. diff --git a/tests/client_id_module_test.gd b/tests/client_id_module_test.gd index bf024b5..4967e33 100644 --- a/tests/client_id_module_test.gd +++ b/tests/client_id_module_test.gd @@ -7,7 +7,7 @@ func _init() -> void: func _run() -> void: _test_native_client_id_path() - print("TEST_REACHED:client_id_module_test:3") + print("TEST_REACHED:client_id_module_test:4") quit(1 if _failures > 0 else 0) var _failures: int = 0 @@ -20,6 +20,7 @@ func _test_native_client_id_path() -> void: _assert(not id_a.is_empty(), "native client id should not be empty") _assert(id_a == id_b, "native client id should be stable across calls") _assert(id_a == OS.get_unique_id(), "native client id should use OS unique id") + _assert(not ClientIdModule.new().has_method("authorize"), "client IDs must expose no authorization API") func _assert(condition: bool, message: String) -> void: if condition: diff --git a/tests/evidence/issue-155-behavior-web.png b/tests/evidence/issue-155-behavior-web.png new file mode 100644 index 0000000000000000000000000000000000000000..20ce22aa2ce90908c0471b2ded909d6d05ceb3e8 GIT binary patch literal 9608 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 TH_*f2H;@aqm(Et6xpwPc>3Ly_ literal 0 HcmV?d00001 diff --git a/tests/jwt_module_test.gd b/tests/jwt_module_test.gd index afe257d..b6cc113 100644 --- a/tests/jwt_module_test.gd +++ b/tests/jwt_module_test.gd @@ -2,52 +2,67 @@ extends SceneTree const JwtModule = preload("res://addon/src/jwt_module.gd") +var _failures := 0 +var _assertions := 0 + func _init() -> void: call_deferred("_run") func _run() -> void: - _test_decode_payload_extracts_claims() - _test_get_expiry_and_is_expired() - print("TEST_REACHED:jwt_module_test:7") + _test_unverified_metadata() + _test_typed_expiry() + _test_malformed_tokens() + _test_claim_types_and_bounds() + print("TEST_REACHED:jwt_module_test:%d" % _assertions) quit(1 if _failures > 0 else 0) -var _failures: int = 0 - -func _test_decode_payload_extracts_claims() -> void: - var token: String = _build_jwt({ - "sub": "user-1", - "email": "player@example.com", - "exp": int(Time.get_unix_time_from_system()) + 3600, - "iat": int(Time.get_unix_time_from_system()) - }) - var payload: JwtModule.JwtPayload = JwtModule.decode_payload(token) - _assert(payload.subject == "user-1", "subject should decode from JWT payload") - _assert(payload.email == "player@example.com", "email should decode from JWT payload") - _assert(payload.expires_at > 0, "exp should decode from JWT payload") - _assert(payload.issued_at > 0, "iat should decode from JWT payload") - -func _test_get_expiry_and_is_expired() -> void: - var future_expiry: int = int(Time.get_unix_time_from_system()) + 120 - var token: String = _build_jwt({"exp": future_expiry}) - _assert(JwtModule.get_expiry_unix(token) == future_expiry, "get_expiry_unix should return exp claim") - _assert(not JwtModule.is_expired(token, future_expiry - 1), "token should not be expired before exp") - _assert(JwtModule.is_expired(token, future_expiry), "token should be expired at exp") - -func _build_jwt(claims: Dictionary[String, Variant]) -> String: - var header_json: String = JSON.stringify({"alg": "HS256", "typ": "JWT"}) - var payload_json: String = JSON.stringify(claims) - var header_segment: String = _to_base64url(header_json.to_utf8_buffer()) - var payload_segment: String = _to_base64url(payload_json.to_utf8_buffer()) - return "%s.%s.signature" % [header_segment, payload_segment] +func _test_unverified_metadata() -> void: + var token := _build_jwt({"sub": "user-1", "email": "玩家@example.com", "exp": 2000, "iat": 1000}) + var result: JwtModule.InspectionResult = JwtModule.inspect_unverified(token) + _assert(result.is_valid(), "valid compact JWT should be structurally inspectable") + _assert(not result.metadata.trusted, "decoded metadata must always be explicitly untrusted") + _assert(result.metadata.claims.get("sub") == "user-1", "subject is available only in untrusted claims") + _assert(result.metadata.claims.get("email") == "玩家@example.com", "Unicode claims should decode losslessly") + _assert(not result.metadata.has_method("authorize"), "unverified metadata must expose no authorization API") + +func _test_typed_expiry() -> void: + var token := _build_jwt({"exp": 2000}) + var valid: JwtModule.ExpiryResult = JwtModule.get_expiry_hint(token, 1999) + var expired: JwtModule.ExpiryResult = JwtModule.get_expiry_hint(token, 2000) + var missing: JwtModule.ExpiryResult = JwtModule.get_expiry_hint(_build_jwt({}), 1000) + var invalid: JwtModule.ExpiryResult = JwtModule.get_expiry_hint(_build_jwt({"exp": "2000"}), 1000) + _assert(valid.status == JwtModule.ExpiryStatus.VALID and not valid.trusted, "future exp should be a valid untrusted hint") + _assert(expired.status == JwtModule.ExpiryStatus.EXPIRED, "exp equal to now should be expired with selected zero skew") + _assert(missing.status == JwtModule.ExpiryStatus.INVALID_OR_UNKNOWN, "missing exp must be invalid/unknown") + _assert(invalid.status == JwtModule.ExpiryStatus.INVALID_OR_UNKNOWN, "non-numeric exp must be invalid/unknown") + +func _test_malformed_tokens() -> void: + _assert(JwtModule.inspect_unverified("not-a-token").status == JwtModule.InspectionStatus.INVALID_STRUCTURE, "wrong segment count should fail") + _assert(JwtModule.inspect_unverified("a.%.c").status == JwtModule.InspectionStatus.INVALID_BASE64URL, "invalid base64url should fail") + _assert(JwtModule.inspect_unverified("%s.%s.%%" % [_json_segment({"alg": "HS256"}), _json_segment({})]).status == JwtModule.InspectionStatus.INVALID_BASE64URL, "invalid signature base64url should fail") + var invalid_utf8 := _to_base64url(PackedByteArray([0xff, 0xfe])) + _assert(JwtModule.inspect_unverified("%s.%s.c2ln" % [_json_segment({"alg": "HS256"}), invalid_utf8]).status == JwtModule.InspectionStatus.INVALID_UTF8, "invalid UTF-8 should fail") + _assert(JwtModule.inspect_unverified("%s.%s.c2ln" % [_json_segment({"alg": "HS256"}), _to_base64url("[]".to_utf8_buffer())]).status == JwtModule.InspectionStatus.INVALID_JSON, "non-object payload should fail") + _assert(JwtModule.inspect_unverified(_build_jwt({}, "none")).status == JwtModule.InspectionStatus.INVALID_HEADER, "none algorithm should fail structural policy") + _assert(JwtModule.inspect_unverified("x".repeat(JwtModule.MAX_TOKEN_BYTES + 1)).status == JwtModule.InspectionStatus.TOKEN_TOO_LARGE, "token over 64 KiB should fail before decoding") + +func _test_claim_types_and_bounds() -> void: + _assert(JwtModule.inspect_unverified(_build_jwt({"sub": 123})).status == JwtModule.InspectionStatus.INVALID_CLAIMS, "non-string subject should fail") + _assert(JwtModule.get_expiry_hint(_build_jwt({"exp": -1})).status == JwtModule.ExpiryStatus.INVALID_OR_UNKNOWN, "negative exp should fail") + _assert(JwtModule.get_expiry_hint(_build_jwt({"exp": 9007199254740992.0})).status == JwtModule.ExpiryStatus.INVALID_OR_UNKNOWN, "exp beyond exact JSON integer range should fail") + _assert(JwtModule.get_expiry_hint(_build_jwt({"exp": 1.5})).status == JwtModule.ExpiryStatus.INVALID_OR_UNKNOWN, "fractional exp should fail") + +func _build_jwt(claims: Dictionary, algorithm: String = "HS256") -> String: + return "%s.%s.%s" % [_json_segment({"alg": algorithm, "typ": "JWT"}), _json_segment(claims), _to_base64url("signature".to_utf8_buffer())] + +func _json_segment(value: Variant) -> String: + return _to_base64url(JSON.stringify(value).to_utf8_buffer()) func _to_base64url(raw: PackedByteArray) -> String: - var encoded: String = Marshalls.raw_to_base64(raw) - encoded = encoded.replace("+", "-").replace("/", "_") - while encoded.ends_with("="): - encoded = encoded.substr(0, encoded.length() - 1) - return encoded + return Marshalls.raw_to_base64(raw).replace("+", "-").replace("/", "_").trim_suffix("=").trim_suffix("=") func _assert(condition: bool, message: String) -> void: + _assertions += 1 if condition: return _failures += 1 diff --git a/tests/package_fixture/main.gd b/tests/package_fixture/main.gd index 2351535..871ad5b 100644 --- a/tests/package_fixture/main.gd +++ b/tests/package_fixture/main.gd @@ -2,17 +2,39 @@ extends Node const ClientIdModule = preload("res://addons/@aviorstudio_gd-supabase/src/client_id_module.gd") const JwtModule = preload("res://addons/@aviorstudio_gd-supabase/src/jwt_module.gd") +const SessionStoreModule = preload("res://addons/@aviorstudio_gd-supabase/src/session_store_module.gd") func _ready() -> void: print("PACKAGE_PLATFORM:%s:web=%s" % [OS.get_name(), OS.has_feature("web")]) - var token := "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjQxMDI0NDQ4MDB9.signature" - var passed := JwtModule.get_expiry_unix(token) == 4102444800 + var token := "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjQxMDI0NDQ4MDB9.c2ln" + var expiry := JwtModule.get_expiry_hint(token, 2000) + var passed := expiry.status == JwtModule.ExpiryStatus.VALID and not expiry.trusted if not OS.has_feature("web"): passed = passed and not ClientIdModule.get_client_id().is_empty() + else: + var storage: JavaScriptObject = JavaScriptBridge.get_interface("sessionStorage") + var client_key := "client'\nkey" + storage.call("removeItem", client_key) + var memory_config := ClientIdModule.ClientIdConfig.new() + memory_config.storage_key = client_key + var memory_id := ClientIdModule.get_client_id(memory_config) + passed = passed and not memory_id.is_empty() and memory_id == ClientIdModule.get_client_id(memory_config) + passed = passed and typeof(storage.call("getItem", client_key)) == TYPE_NIL + var tab_config := ClientIdModule.ClientIdConfig.new() + tab_config.storage_key = client_key + tab_config.web_storage_mode = ClientIdModule.WebStorageMode.SESSION_STORAGE + var tab_id := ClientIdModule.get_client_id(tab_config) + passed = passed and not tab_id.is_empty() and storage.call("getItem", client_key) == tab_id + + var session_config := SessionStoreModule.SessionStoreConfig.new() + session_config.mode = SessionStoreModule.StorageMode.WEB_SESSION_STORAGE + session_config.storage_key = "session'\nkey" + var session_store := SessionStoreModule.new(session_config) + var saved: SessionStoreModule.OperationResult = session_store.save({"access_token": "web-test", "refresh_token": "web-refresh"}) + var loaded: SessionStoreModule.LoadResult = session_store.load_session() + passed = passed and saved.status == SessionStoreModule.Status.OK and loaded.status == SessionStoreModule.Status.OK + passed = passed and loaded.data.get("access_token") == "web-test" $Status.text = "PACKAGED GD-SUPABASE: PASS" if passed else "PACKAGED GD-SUPABASE: FAIL" print("PACKAGE_SMOKE_REACHED:%s" % ("PASS" if passed else "FAIL")) - if OS.has_feature("web"): - var document: JavaScriptObject = JavaScriptBridge.get_interface("document") - document.set("title", "GD Supabase Web %s" % ("PASS" if passed else "FAIL")) if not passed: get_tree().quit(1) diff --git a/tests/session_store_module_test.gd b/tests/session_store_module_test.gd index 43c4896..b241558 100644 --- a/tests/session_store_module_test.gd +++ b/tests/session_store_module_test.gd @@ -2,67 +2,153 @@ extends SceneTree const SessionStoreModule = preload("res://addon/src/session_store_module.gd") +class FakeCredentialAdapter extends SessionStoreModule.NativeCredentialAdapter: + const READ_FOUND := 0 + const READ_NOT_FOUND := 1 + const READ_ERROR := 2 + var values: Dictionary[String, String] = {} + var fail_write := false + var fail_read := false + var corrupt_read := false + var fail_delete := false + + func read_value(key: String): + var result = super.read_value(key) + if fail_read: + result.status = READ_ERROR + result.error = "injected read failure" + elif not values.has(key): + result.status = READ_NOT_FOUND + else: + result.status = READ_FOUND + result.value = "{\"corrupt\":true}" if corrupt_read else values[key] + return result + + func write_atomic(key: String, value: String) -> bool: + if fail_write: + return false + values[key] = value + return true + + func delete_value(key: String) -> bool: + if fail_delete: + return false + values.erase(key) + return true + +var _failures := 0 +var _assertions := 0 + func _init() -> void: call_deferred("_run") func _run() -> void: - _test_save_and_load_roundtrip() - _test_legacy_migration() - _test_clear_removes_files() - print("TEST_REACHED:session_store_module_test:8") + _test_memory_default() + _test_credential_roundtrip_and_logout() + _test_atomic_failure_and_readback() + _test_invalid_and_bounded_payloads() + _test_nondestructive_migration() + _test_failed_migration_retains_source() + _test_platform_mode() + print("TEST_REACHED:session_store_module_test:%d" % _assertions) quit(1 if _failures > 0 else 0) -var _failures: int = 0 +func _test_memory_default() -> void: + var store := SessionStoreModule.new() + var saved := store.save({"access_token": "memory-token"}) + var loaded := store.load_session() + _assert(saved.status == SessionStoreModule.Status.NON_PERSISTENT and saved.is_success(), "default save should explicitly report memory-only success") + _assert(loaded.status == SessionStoreModule.Status.NON_PERSISTENT and loaded.data.access_token == "memory-token", "memory load should return typed non-persistent data") + _assert(SessionStoreModule.new().load_session().status == SessionStoreModule.Status.NOT_FOUND, "memory sessions must not survive a new store instance") -func _test_save_and_load_roundtrip() -> void: - var store: SessionStoreModule = _build_store("roundtrip") - store.clear() - var payload: Dictionary[String, Variant] = { - "access_token": "token-a", - "user_id": "user-1" - } - _assert(store.save(payload), "save should return true for valid payload") - var loaded: Dictionary[String, Variant] = store.load_session() - _assert(str(loaded.get("access_token", "")) == "token-a", "load should restore access_token") - _assert(str(loaded.get("user_id", "")) == "user-1", "load should restore user_id") - store.clear() +func _test_credential_roundtrip_and_logout() -> void: + var adapter := FakeCredentialAdapter.new() + var store := _credential_store(adapter, "roundtrip") + _assert(store.save({"access_token": "token-a", "refresh_token": "token-b"}).status == SessionStoreModule.Status.OK, "credential save should pass write/readback") + var loaded := store.load_session() + _assert(loaded.status == SessionStoreModule.Status.OK and loaded.persistent and loaded.data.refresh_token == "token-b", "credential load should be typed and persistent") + _assert(store.save({"writer": 1}).is_success() and store.save({"writer": 2}).is_success() and store.load_session().data.writer == 2, "sequential concurrent writers should commit the last complete value") + _assert(store.clear().status == SessionStoreModule.Status.OK, "logout clear should delete the active credential") + _assert(store.load_session().status == SessionStoreModule.Status.NOT_FOUND, "logout clear should not leave an active session") -func _test_legacy_migration() -> void: - var timestamp: int = Time.get_ticks_msec() - var legacy_path: String = "user://legacy_session_%d.dat" % timestamp - var config: SessionStoreModule.SessionStoreConfig = SessionStoreModule.SessionStoreConfig.new() - config.file_path_template = "user://session_%s.dat" - config.store_id = "legacy_%d" % timestamp - config.legacy_paths = [legacy_path] - var store: SessionStoreModule = SessionStoreModule.new(config) - store.clear() +func _test_atomic_failure_and_readback() -> void: + var adapter := FakeCredentialAdapter.new() + var store := _credential_store(adapter, "atomic") + _assert(store.save({"value": "old"}).is_success(), "control credential write should pass") + adapter.fail_write = true + _assert(store.save({"value": "new"}).status == SessionStoreModule.Status.ADAPTER_ERROR, "failed atomic write should be visible") + adapter.fail_write = false + _assert(store.load_session().data.value == "old", "failed atomic write must preserve the old value") + adapter.corrupt_read = true + _assert(store.save({"value": "checked"}).status == SessionStoreModule.Status.WRITE_VERIFY_FAILED, "partial/corrupt write readback should fail") - var legacy_file: FileAccess = FileAccess.open(legacy_path, FileAccess.WRITE) - _assert(legacy_file != null, "legacy path should open for migration test") - if legacy_file != null: - legacy_file.store_string(JSON.stringify({"access_token": "legacy-token"})) - legacy_file = null +func _test_invalid_and_bounded_payloads() -> void: + var adapter := FakeCredentialAdapter.new() + var store := _credential_store(adapter, "bounds") + var huge := "x".repeat(SessionStoreModule.MAX_SESSION_BYTES + 1) + _assert(store.save({"value": huge}).status == SessionStoreModule.Status.TOO_LARGE, "serialized sessions over 1 MiB should fail") + adapter.values["bounds"] = "not-json" + _assert(store.load_session().status == SessionStoreModule.Status.INVALID_DATA, "malformed stored JSON should be typed invalid") + var unsupported := Object.new() + _assert(store.save({"unsupported": unsupported}).status == SessionStoreModule.Status.INVALID_DATA, "lossy non-JSON values should fail") + unsupported.free() + var cycle: Array = [] + cycle.append(cycle) + _assert(store.save({"cycle": cycle}).status == SessionStoreModule.Status.INVALID_DATA, "cyclic session data should fail without recursive serialization") + cycle.clear() - var loaded: Dictionary[String, Variant] = store.load_session() - _assert(str(loaded.get("access_token", "")) == "legacy-token", "load should read legacy payload") - _assert(not FileAccess.file_exists(legacy_path), "legacy file should be deleted after migration") +func _test_nondestructive_migration() -> void: + var path := "user://gd_supabase_legacy_success_%d.json" % Time.get_ticks_msec() + _write_legacy(path, {"access_token": "legacy-token"}) + var adapter := FakeCredentialAdapter.new() + var store := _credential_store(adapter, "migration", [path]) + var migrated := store.migrate_legacy() + _assert(migrated.status == SessionStoreModule.Status.OK and migrated.migrated_from == path, "migration should require committed destination readback") + _assert(migrated.data.access_token == "legacy-token", "migration should return committed data") + _assert(FileAccess.file_exists(path), "legacy source must remain after successful migration without destructive approval") store.clear() + _assert(store.load_session().status == SessionStoreModule.Status.NOT_FOUND, "logout must not automatically resurrect retained legacy data") + _remove(path) -func _test_clear_removes_files() -> void: - var store: SessionStoreModule = _build_store("clear") - store.save({"access_token": "token-clear"}) - _assert(store.exists(), "exists should be true after save") - store.clear() - _assert(not store.exists(), "exists should be false after clear") +func _test_failed_migration_retains_source() -> void: + var path := "user://gd_supabase_legacy_failure_%d.json" % Time.get_ticks_msec() + _write_legacy(path, {"access_token": "legacy-token"}) + var adapter := FakeCredentialAdapter.new() + adapter.fail_write = true + var migrated := _credential_store(adapter, "migration-fail", [path]).migrate_legacy() + _assert(migrated.status == SessionStoreModule.Status.MIGRATION_FAILED, "destination failure should be typed migration failure") + _assert(FileAccess.file_exists(path), "failed migration must never delete its source") + _remove(path) -func _build_store(name: String) -> SessionStoreModule: - var config: SessionStoreModule.SessionStoreConfig = SessionStoreModule.SessionStoreConfig.new() - config.file_path_template = "user://session_%s.dat" - config.store_id = "%s_%d" % [name, Time.get_ticks_msec()] - config.legacy_paths = [] +func _test_platform_mode() -> void: + var native_config := SessionStoreModule.SessionStoreConfig.new() + native_config.mode = SessionStoreModule.StorageMode.NATIVE_CREDENTIAL + _assert(SessionStoreModule.new(native_config).save({"value": "native"}).status == SessionStoreModule.Status.ADAPTER_ERROR, "missing native credential adapter should be typed") + var config := SessionStoreModule.SessionStoreConfig.new() + config.mode = SessionStoreModule.StorageMode.WEB_SESSION_STORAGE + var result := SessionStoreModule.new(config).save({"value": "native"}) + _assert(result.status == SessionStoreModule.Status.UNSUPPORTED, "sessionStorage mode should be explicitly unsupported on native") + +func _credential_store(adapter: FakeCredentialAdapter, key: String, legacy: Array[String] = []) -> SessionStoreModule: + var config := SessionStoreModule.SessionStoreConfig.new() + config.mode = SessionStoreModule.StorageMode.NATIVE_CREDENTIAL + config.storage_key = key + config.credential_adapter = adapter + config.legacy_paths = legacy return SessionStoreModule.new(config) +func _write_legacy(path: String, value: Dictionary) -> void: + var file := FileAccess.open(path, FileAccess.WRITE) + _assert(file != null, "legacy fixture should open") + if file != null: + file.store_string(JSON.stringify(value)) + +func _remove(path: String) -> void: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + func _assert(condition: bool, message: String) -> void: + _assertions += 1 if condition: return _failures += 1 diff --git a/tests/web_test.mjs b/tests/web_test.mjs index e9ebccf..fba35da 100644 --- a/tests/web_test.mjs +++ b/tests/web_test.mjs @@ -21,6 +21,13 @@ try { }); await page.goto(`http://127.0.0.1:${port}/index.html`, { waitUntil: "domcontentloaded" }); await reached; + const safeKeysReached = await page.evaluate(([clientKey, sessionKey]) => ( + sessionStorage.getItem(clientKey)?.startsWith("web_") === true + && JSON.parse(sessionStorage.getItem(sessionKey) ?? "null")?.access_token === "web-test" + ), ["client'\nkey", "session'\nkey"]); + if (!safeKeysReached) { + throw new Error("safe quoted/newline sessionStorage keys were not reached"); + } await mkdir(new URL("../dist/", import.meta.url), { recursive: true }); await page.screenshot({ path: new URL("../dist/web-evidence.png", import.meta.url).pathname }); console.log("WEB_TEST_REACHED:packaged-addon-smoke");