From fc0a08825d410073f441c408f5421019fc619576 Mon Sep 17 00:00:00 2001 From: seanbetts Date: Mon, 11 May 2026 11:44:57 +0100 Subject: [PATCH] Persist SteamKit auth sessions --- README.md | 18 ++- SKILL.md | 11 +- scripts/check_steamkit_pics.sh | 10 +- scripts/steamkit_auth.sh | 38 +++++ tests/test_check_steamkit_pics.py | 151 +++++++++++++++++ tools/steamkit-pics/Program.cs | 259 ++++++++++++++++++++++++------ 6 files changed, 437 insertions(+), 50 deletions(-) create mode 100755 scripts/steamkit_auth.sh diff --git a/README.md b/README.md index cfaf818..ba3bb13 100644 --- a/README.md +++ b/README.md @@ -144,13 +144,23 @@ mkdir -p .local cat > .local/steamkit-env.sh <<'EOF' STEAMKIT_USERNAME='your-watch-account' STEAMKIT_PASSWORD='your-watch-account-password' -# Use one of these only when Steam Guard asks for it: +# Use one of these only for the one-time session bootstrap when Steam Guard asks for it: # STEAMKIT_AUTH_CODE='email-code' # STEAMKIT_TWO_FACTOR_CODE='authenticator-code' +# Or approve the mobile prompt manually, then set: +# STEAMKIT_ACCEPT_MOBILE_CONFIRMATION='1' EOF chmod 600 .local/steamkit-env.sh ``` +Bootstrap a persistent local session: + +```sh +scripts/steamkit_auth.sh +``` + +This writes `.local/steamkit-session.json` with a Steam refresh token and guard data. The file is gitignored, should stay local, and is sensitive. After it is created, remove any one-time `STEAMKIT_AUTH_CODE`, `STEAMKIT_TWO_FACTOR_CODE`, or `STEAMKIT_ACCEPT_MOBILE_CONFIRMATION` line from `.local/steamkit-env.sh`. + Then run either the source directly: ```sh @@ -163,6 +173,8 @@ or the normal watcher: scripts/run_watch.sh 2026-05-11 ``` +The normal watcher automatically uses `.local/steamkit-session.json`, so routine runs should not ask for a fresh Steam Guard code. If Steam invalidates the refresh token, rerun `scripts/steamkit_auth.sh` with a fresh guard approval to create a new session file. + Outputs: - `RUN_DIR/api/steamkit/pics-product-info.json` @@ -171,7 +183,7 @@ Outputs: - `RUN_DIR/reports/steamkit-pics-detail.md` - `RUN_DIR/reports/steamkit-pics-errors.txt` -If the env file is missing, the script writes a non-fatal `missing_credentials` report so the normal watcher still completes. Do not use your main Steam account, do not poll aggressively, and do not extend this helper to protected depot downloads. +If the env file and session file are missing, the script writes a non-fatal `missing_credentials` report so the normal watcher still completes. Do not use your main Steam account, do not poll aggressively, and do not extend this helper to protected depot downloads. ## Output @@ -429,7 +441,7 @@ SteamVR content is distributed through SteamPipe depots rather than the SteamOS ## Current Known Limits - `Komodo` can block both `curl` and fresh browser automation. -- `SteamKit/PICS` requires a separate Steam account and may need a fresh Steam Guard code on first login. +- `SteamKit/PICS` requires a separate Steam account and a one-time Steam Guard bootstrap to create `.local/steamkit-session.json`. - `SteamDB` can return a Cloudflare browser challenge to curl and fresh automated browser contexts. - `SteamVR` depot contents require a Steam install, Steam console, SteamCMD, or another depot downloader; the helper does not download large depots by default. - The SteamOS mirror is public, but package names are not proof of product launch state without corroborating evidence. diff --git a/SKILL.md b/SKILL.md index a317922..a11abbe 100644 --- a/SKILL.md +++ b/SKILL.md @@ -136,13 +136,22 @@ Treat SteamKit/PICS as the primary direct Steam metadata source for apps, packag Use a narrow SteamKit2 helper when available. It should: - use a separate low-risk Steam account, not the user's main account -- load credentials from `.local/steamkit-env.sh` or equivalent environment variables +- bootstrap a persistent session with `scripts/steamkit_auth.sh` before normal runs +- prefer `.local/steamkit-session.json` for routine checks so Steam Guard is not required every run +- load bootstrap credentials from `.local/steamkit-env.sh` or equivalent environment variables only when a new session is needed - poll only watched Valve hardware app and package IDs - save raw app/package product-info snapshots and changenumbers - write `steamkit-pics-packages.tsv`, `steamkit-pics-key-lines.txt`, and `steamkit-pics-detail.md` - compare snapshots against the previous run - avoid protected depot downloads and aggressive polling +SteamKit auth handling: + +- keep `.local/steamkit-env.sh` and `.local/steamkit-session.json` local and uncommitted +- use `STEAMKIT_AUTH_CODE`, `STEAMKIT_TWO_FACTOR_CODE`, or `STEAMKIT_ACCEPT_MOBILE_CONFIRMATION=1` only for the bootstrap command +- remove one-time guard values from `.local/steamkit-env.sh` after `scripts/steamkit_auth.sh` succeeds +- if routine checks fail because the refresh token expired or was revoked, ask the user to re-run `scripts/steamkit_auth.sh` with a fresh Steam Guard approval + Primary watched IDs: - apps: Controller `4165870`, Frame `4165890`, Machine `4165910` diff --git a/scripts/check_steamkit_pics.sh b/scripts/check_steamkit_pics.sh index 4abcbd7..5ad5545 100755 --- a/scripts/check_steamkit_pics.sh +++ b/scripts/check_steamkit_pics.sh @@ -12,6 +12,7 @@ REPORT_DIR="$RUN_DIR/reports" SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" REPO_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)" PROJECT_DIR="$REPO_DIR/tools/steamkit-pics" +SESSION_FILE="${STEAMKIT_SESSION_FILE:-$REPO_DIR/.local/steamkit-session.json}" mkdir -p "$OUT_DIR" "$REPORT_DIR" ERROR_FILE="$REPORT_DIR/steamkit-pics-errors.txt" @@ -84,7 +85,12 @@ write_unavailable_report() { load_local_env -if [ -z "${STEAMKIT_USERNAME:-}" ] || { [ -z "${STEAMKIT_PASSWORD:-}" ] && [ -z "${STEAMKIT_ACCESS_TOKEN:-}" ]; }; then +HAS_SESSION=0 +if [ -s "$SESSION_FILE" ]; then + HAS_SESSION=1 +fi + +if [ "$HAS_SESSION" = "0" ] && { [ -z "${STEAMKIT_USERNAME:-}" ] || { [ -z "${STEAMKIT_PASSWORD:-}" ] && [ -z "${STEAMKIT_ACCESS_TOKEN:-}" ]; }; }; then detail="STEAMKIT_USERNAME/STEAMKIT_PASSWORD not set" if [ -n "${STEAMKIT_USERNAME:-}" ] && [ -z "${STEAMKIT_PASSWORD:-}" ]; then detail="STEAMKIT_PASSWORD or STEAMKIT_ACCESS_TOKEN not set" @@ -135,6 +141,7 @@ if [ -n "$PREVIOUS_REPORT" ]; then --report "$REPORT_FILE" \ --key-lines "$KEY_LINES_FILE" \ --markdown-report "$DETAIL_REPORT_FILE" \ + --session-file "$SESSION_FILE" \ --previous-report "$PREVIOUS_REPORT" \ >> "$REPORT_DIR/steamkit-pics-dotnet.log" 2>> "$ERROR_FILE"; then detail="SteamKit/PICS helper failed; see steamkit-pics-errors.txt and steamkit-pics-dotnet.log" @@ -149,6 +156,7 @@ else --report "$REPORT_FILE" \ --key-lines "$KEY_LINES_FILE" \ --markdown-report "$DETAIL_REPORT_FILE" \ + --session-file "$SESSION_FILE" \ >> "$REPORT_DIR/steamkit-pics-dotnet.log" 2>> "$ERROR_FILE"; then detail="SteamKit/PICS helper failed; see steamkit-pics-errors.txt and steamkit-pics-dotnet.log" if [ ! -s "$REPORT_FILE" ]; then diff --git a/scripts/steamkit_auth.sh b/scripts/steamkit_auth.sh new file mode 100755 index 0000000..5d4a810 --- /dev/null +++ b/scripts/steamkit_auth.sh @@ -0,0 +1,38 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" +REPO_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)" +PROJECT_DIR="$REPO_DIR/tools/steamkit-pics" +ENV_FILE="${STEAMKIT_ENV_FILE:-$REPO_DIR/.local/steamkit-env.sh}" +SESSION_FILE="${STEAMKIT_SESSION_FILE:-$REPO_DIR/.local/steamkit-session.json}" + +if [ -f "$ENV_FILE" ]; then + # shellcheck disable=SC1090 + . "$ENV_FILE" +fi + +if [ -z "${STEAMKIT_USERNAME:-}" ] || [ -z "${STEAMKIT_PASSWORD:-}" ]; then + echo "STEAMKIT_USERNAME and STEAMKIT_PASSWORD are required in $ENV_FILE or the environment" >&2 + exit 1 +fi + +export STEAMKIT_USERNAME STEAMKIT_PASSWORD +if [ -n "${STEAMKIT_AUTH_CODE:-}" ]; then + export STEAMKIT_AUTH_CODE +fi +if [ -n "${STEAMKIT_TWO_FACTOR_CODE:-}" ]; then + export STEAMKIT_TWO_FACTOR_CODE +fi +if [ -n "${STEAMKIT_ACCEPT_MOBILE_CONFIRMATION:-}" ]; then + export STEAMKIT_ACCEPT_MOBILE_CONFIRMATION +fi +if [ -n "${STEAMKIT_TIMEOUT_SECONDS:-}" ]; then + export STEAMKIT_TIMEOUT_SECONDS +fi + +mkdir -p "$(dirname "$SESSION_FILE")" + +dotnet run --project "$PROJECT_DIR" -- --auth-session-out "$SESSION_FILE" --session-file "$SESSION_FILE" +chmod 600 "$SESSION_FILE" +printf '%s\n' "Saved SteamKit session to $SESSION_FILE" diff --git a/tests/test_check_steamkit_pics.py b/tests/test_check_steamkit_pics.py index c3e222a..b7ebfb3 100644 --- a/tests/test_check_steamkit_pics.py +++ b/tests/test_check_steamkit_pics.py @@ -16,6 +16,93 @@ def write_executable(path, content): class CheckSteamKitPicsTests(unittest.TestCase): + def test_invokes_dotnet_helper_with_persistent_session_without_password(self): + tmp = TemporaryDirectory() + self.addCleanup(tmp.cleanup) + tmp_path = Path(tmp.name) + run_dir = tmp_path / "run" + session_file = tmp_path / "steamkit-session.json" + session_file.write_text( + '{"username":"watcher","refresh_token":"refresh-token","created_at_utc":"2026-05-11T00:00:00Z"}\n', + encoding="utf-8", + ) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + log_path = tmp_path / "dotnet-args.log" + + write_executable( + fake_bin / "dotnet", + """ + #!/bin/sh + printf '%s\\n' "$*" > "$FAKE_DOTNET_LOG" + report="" + key_lines="" + markdown="" + out_dir="" + session="" + while [ "$#" -gt 0 ]; do + case "$1" in + --session-file) + shift + session="$1" + ;; + --out-dir) + shift + out_dir="$1" + ;; + --report) + shift + report="$1" + ;; + --key-lines) + shift + key_lines="$1" + ;; + --markdown-report) + shift + markdown="$1" + ;; + esac + shift || true + done + test -s "$session" || exit 12 + mkdir -p "$out_dir" "$(dirname "$report")" "$(dirname "$key_lines")" "$(dirname "$markdown")" + printf '%s\\n' '{}' > "$out_dir/pics-product-info.json" + printf '%s\\n' 'type product id status changenumber previous_changenumber changed_since_previous sha_hash only_public name related_ids details' > "$report" + printf '%s\\n' 'SteamKit/PICS package snapshot:' > "$key_lines" + printf '%s\\n' '# SteamKit / PICS Detail' > "$markdown" + """, + ) + + env = os.environ.copy() + env.update( + { + "PATH": f"{fake_bin}:{env['PATH']}", + "STEAMKIT_ENV_FILE": str(tmp_path / "missing-env.sh"), + "STEAMKIT_SESSION_FILE": str(session_file), + "FAKE_DOTNET_LOG": str(log_path), + } + ) + env.pop("STEAMKIT_USERNAME", None) + env.pop("STEAMKIT_PASSWORD", None) + + result = subprocess.run( + [str(ROOT / "scripts" / "check_steamkit_pics.sh"), str(run_dir)], + cwd=ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual("", result.stderr) + self.assertEqual(0, result.returncode) + dotnet_args = log_path.read_text(encoding="utf-8") + self.assertIn("--session-file", dotnet_args) + self.assertIn(str(session_file), dotnet_args) + self.assertNotIn("missing_credentials", (run_dir / "reports" / "steamkit-pics-packages.tsv").read_text(encoding="utf-8")) + def test_missing_credentials_writes_nonfatal_unavailable_report(self): tmp = TemporaryDirectory() self.addCleanup(tmp.cleanup) @@ -341,6 +428,70 @@ class CheckSteamKitPicsTests(unittest.TestCase): self.assertEqual("", errors) +class SteamKitAuthScriptTests(unittest.TestCase): + def test_auth_script_writes_session_file_using_dotnet_helper(self): + tmp = TemporaryDirectory() + self.addCleanup(tmp.cleanup) + tmp_path = Path(tmp.name) + env_file = tmp_path / "steamkit-env.sh" + session_file = tmp_path / "steamkit-session.json" + env_file.write_text( + "STEAMKIT_USERNAME='watcher'\n" + "STEAMKIT_PASSWORD='secret'\n" + "STEAMKIT_AUTH_CODE='ABCDE'\n", + encoding="utf-8", + ) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + log_path = tmp_path / "dotnet-args.log" + + write_executable( + fake_bin / "dotnet", + """ + #!/bin/sh + printf '%s\\n' "$*" > "$FAKE_DOTNET_LOG" + out="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--auth-session-out" ]; then + shift + out="$1" + fi + shift || true + done + mkdir -p "$(dirname "$out")" + printf '%s\\n' '{"username":"watcher","refresh_token":"refresh","created_at_utc":"2026-05-11T00:00:00Z"}' > "$out" + """, + ) + + env = os.environ.copy() + env.update( + { + "PATH": f"{fake_bin}:{env['PATH']}", + "STEAMKIT_ENV_FILE": str(env_file), + "STEAMKIT_SESSION_FILE": str(session_file), + "FAKE_DOTNET_LOG": str(log_path), + } + ) + + result = subprocess.run( + [str(ROOT / "scripts" / "steamkit_auth.sh")], + cwd=ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual("", result.stderr) + self.assertEqual(0, result.returncode) + self.assertTrue(session_file.exists()) + dotnet_args = log_path.read_text(encoding="utf-8") + self.assertIn("--auth-session-out", dotnet_args) + self.assertIn(str(session_file), dotnet_args) + self.assertIn("--session-file", dotnet_args) + + class SteamKitSummaryTests(unittest.TestCase): def test_status_draft_and_run_summary_include_steamkit_outputs(self): tmp = TemporaryDirectory() diff --git a/tools/steamkit-pics/Program.cs b/tools/steamkit-pics/Program.cs index 9ef333e..aa9da79 100644 --- a/tools/steamkit-pics/Program.cs +++ b/tools/steamkit-pics/Program.cs @@ -1,38 +1,74 @@ using System.Text.Json; using System.Text.Json.Serialization; using SteamKit2; +using SteamKit2.Authentication; +using SteamKit2.Internal; var options = Args.Parse(args); -Directory.CreateDirectory(options.OutDir); -Directory.CreateDirectory(Path.GetDirectoryName(options.ReportPath) ?? "."); -Directory.CreateDirectory(Path.GetDirectoryName(options.KeyLinesPath) ?? "."); -Directory.CreateDirectory(Path.GetDirectoryName(options.MarkdownReportPath) ?? "."); - var username = Env("STEAMKIT_USERNAME"); var password = Env("STEAMKIT_PASSWORD"); var accessToken = Env("STEAMKIT_ACCESS_TOKEN"); var authCode = Env("STEAMKIT_AUTH_CODE"); var twoFactorCode = Env("STEAMKIT_TWO_FACTOR_CODE"); +var acceptMobileConfirmation = Env("STEAMKIT_ACCEPT_MOBILE_CONFIRMATION") == "1"; var timeoutSeconds = int.TryParse(Env("STEAMKIT_TIMEOUT_SECONDS"), out var parsedTimeout) ? parsedTimeout : 90; -if (string.IsNullOrWhiteSpace(username) || (string.IsNullOrWhiteSpace(password) && string.IsNullOrWhiteSpace(accessToken))) -{ - throw new InvalidOperationException("STEAMKIT_USERNAME and STEAMKIT_PASSWORD or STEAMKIT_ACCESS_TOKEN are required."); -} - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); -var snapshot = await PicsClient.FetchAsync(username, password, accessToken, authCode, twoFactorCode, cts.Token); - var jsonOptions = new JsonSerializerOptions { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; +if (!string.IsNullOrWhiteSpace(options.AuthSessionOutPath)) +{ + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + throw new InvalidOperationException("STEAMKIT_USERNAME and STEAMKIT_PASSWORD are required for auth bootstrap."); + } + + var existingSession = SteamKitSession.Load(options.SessionFilePath); + var session = await AuthClient.CreateSessionAsync( + username, + password, + authCode, + twoFactorCode, + acceptMobileConfirmation, + existingSession?.GuardData, + cts.Token + ); + Directory.CreateDirectory(Path.GetDirectoryName(options.AuthSessionOutPath) ?? "."); + await File.WriteAllTextAsync(options.AuthSessionOutPath, JsonSerializer.Serialize(session, jsonOptions) + Environment.NewLine, cts.Token); + return; +} + +options.RequireWatchMode(); +Directory.CreateDirectory(options.OutDir!); +Directory.CreateDirectory(Path.GetDirectoryName(options.ReportPath!) ?? "."); +Directory.CreateDirectory(Path.GetDirectoryName(options.KeyLinesPath!) ?? "."); +Directory.CreateDirectory(Path.GetDirectoryName(options.MarkdownReportPath!) ?? "."); + +var savedSession = SteamKitSession.Load(options.SessionFilePath); +if (savedSession is not null) +{ + username = savedSession.Username; + password = null; + accessToken = savedSession.RefreshToken; + authCode = null; + twoFactorCode = null; +} + +if (string.IsNullOrWhiteSpace(username) || (string.IsNullOrWhiteSpace(password) && string.IsNullOrWhiteSpace(accessToken))) +{ + throw new InvalidOperationException("STEAMKIT_USERNAME and STEAMKIT_PASSWORD, STEAMKIT_ACCESS_TOKEN, or a valid --session-file are required."); +} + +var snapshot = await PicsClient.FetchAsync(username, password, accessToken, authCode, twoFactorCode, cts.Token); + await File.WriteAllTextAsync( - Path.Combine(options.OutDir, "pics-product-info.json"), + Path.Combine(options.OutDir!, "pics-product-info.json"), JsonSerializer.Serialize(snapshot, jsonOptions) + Environment.NewLine, cts.Token ); @@ -40,7 +76,7 @@ await File.WriteAllTextAsync( var previous = PreviousRows.Load(options.PreviousReportPath); var rows = ReportRows.Build(snapshot, previous); await File.WriteAllLinesAsync( - options.ReportPath, + options.ReportPath!, new[] { ReportRow.HeaderLine() }.Concat(rows.Select(row => row.ToTsv())), cts.Token ); @@ -48,13 +84,20 @@ await File.WriteAllLinesAsync( var keyLines = new List { "SteamKit/PICS package snapshot:" }; keyLines.Add(ReportRow.HeaderLine()); keyLines.AddRange(rows.Take(40).Select(row => row.ToTsv())); -await File.WriteAllLinesAsync(options.KeyLinesPath, keyLines, cts.Token); +await File.WriteAllLinesAsync(options.KeyLinesPath!, keyLines, cts.Token); -await File.WriteAllTextAsync(options.MarkdownReportPath, MarkdownReport.Build(snapshot, rows, options.PreviousReportPath), cts.Token); +await File.WriteAllTextAsync(options.MarkdownReportPath!, MarkdownReport.Build(snapshot, rows, options.PreviousReportPath), cts.Token); static string? Env(string name) => Environment.GetEnvironmentVariable(name); -sealed record Args(string OutDir, string ReportPath, string KeyLinesPath, string MarkdownReportPath, string? PreviousReportPath) +sealed record Args( + string? OutDir, + string? ReportPath, + string? KeyLinesPath, + string? MarkdownReportPath, + string? PreviousReportPath, + string? SessionFilePath, + string? AuthSessionOutPath) { public static Args Parse(string[] args) { @@ -75,20 +118,33 @@ sealed record Args(string OutDir, string ReportPath, string KeyLinesPath, string } return new Args( - Require(values, "--out-dir"), - Require(values, "--report"), - Require(values, "--key-lines"), - Require(values, "--markdown-report"), + Optional(values, "--out-dir"), + Optional(values, "--report"), + Optional(values, "--key-lines"), + Optional(values, "--markdown-report"), values.TryGetValue("--previous-report", out var previousReport) && !string.IsNullOrWhiteSpace(previousReport) ? previousReport - : null + : null, + Optional(values, "--session-file"), + Optional(values, "--auth-session-out") ); } - static string Require(Dictionary values, string key) + public void RequireWatchMode() + { + _ = Require(OutDir, "--out-dir"); + _ = Require(ReportPath, "--report"); + _ = Require(KeyLinesPath, "--key-lines"); + _ = Require(MarkdownReportPath, "--markdown-report"); + } + + static string? Optional(Dictionary values, string key) => values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value - : throw new ArgumentException($"{key} is required."); + : null; + + static string Require(string? value, string key) + => !string.IsNullOrWhiteSpace(value) ? value : throw new ArgumentException($"{key} is required."); } static class Targets @@ -112,6 +168,137 @@ static class Targets }; } +sealed record SteamKitSession( + [property: JsonPropertyName("username")] string Username, + [property: JsonPropertyName("steam_id")] string? SteamId, + [property: JsonPropertyName("refresh_token")] string RefreshToken, + [property: JsonPropertyName("access_token")] string? AccessToken, + [property: JsonPropertyName("guard_data")] string? GuardData, + [property: JsonPropertyName("created_at_utc")] DateTimeOffset CreatedAtUtc) +{ + public static SteamKitSession? Load(string? path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + return null; + } + + var session = JsonSerializer.Deserialize(File.ReadAllText(path)); + return string.IsNullOrWhiteSpace(session?.RefreshToken) ? null : session; + } +} + +static class AuthClient +{ + public static async Task CreateSessionAsync( + string username, + string password, + string? emailCode, + string? deviceCode, + bool acceptMobileConfirmation, + string? guardData, + CancellationToken cancellationToken) + { + var steamClient = new SteamClient(); + var manager = new CallbackManager(steamClient); + var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + manager.Subscribe(_ => connected.TrySetResult()); + manager.Subscribe(callback => + { + if (!callback.UserInitiated) + { + disconnected.TrySetResult("Steam disconnected before auth completed."); + } + }); + + steamClient.Connect(); + await CallbackPump.UntilAsync(manager, connected.Task, disconnected.Task, cancellationToken); + + var authTask = steamClient.Authentication.BeginAuthSessionViaCredentialsAsync(new AuthSessionDetails + { + Username = username, + Password = password, + DeviceFriendlyName = "steam-hardware-watch", + PlatformType = EAuthTokenPlatformType.k_EAuthTokenPlatformType_SteamClient, + WebsiteID = "Client", + IsPersistentSession = true, + GuardData = guardData, + Authenticator = new EnvAuthenticator(emailCode, deviceCode, acceptMobileConfirmation), + }); + + await CallbackPump.UntilAsync(manager, authTask, disconnected.Task, cancellationToken); + var authSession = await authTask; + var pollTask = authSession.PollingWaitForResultAsync(cancellationToken); + await CallbackPump.UntilAsync(manager, pollTask, disconnected.Task, cancellationToken); + var result = await pollTask; + + steamClient.Disconnect(); + + if (string.IsNullOrWhiteSpace(result.RefreshToken)) + { + throw new InvalidOperationException("Steam auth completed without a refresh token."); + } + + return new SteamKitSession( + result.AccountName, + authSession is CredentialsAuthSession credentials ? credentials.SteamID.ToString() : null, + result.RefreshToken, + result.AccessToken, + result.NewGuardData, + DateTimeOffset.UtcNow + ); + } +} + +sealed class EnvAuthenticator(string? emailCode, string? deviceCode, bool acceptMobileConfirmation) : IAuthenticator +{ + public Task GetDeviceCodeAsync(bool previousCodeWasIncorrect) + { + if (!string.IsNullOrWhiteSpace(deviceCode) && !previousCodeWasIncorrect) + { + return Task.FromResult(deviceCode); + } + + throw new InvalidOperationException("Steam mobile authenticator code required. Set STEAMKIT_TWO_FACTOR_CODE and rerun scripts/steamkit_auth.sh."); + } + + public Task GetEmailCodeAsync(string email, bool previousCodeWasIncorrect) + { + if (!string.IsNullOrWhiteSpace(emailCode) && !previousCodeWasIncorrect) + { + return Task.FromResult(emailCode); + } + + throw new InvalidOperationException($"Steam Guard email code required for {email}. Set STEAMKIT_AUTH_CODE and rerun scripts/steamkit_auth.sh."); + } + + public Task AcceptDeviceConfirmationAsync() => Task.FromResult(acceptMobileConfirmation); +} + +static class CallbackPump +{ + public static async Task UntilAsync( + CallbackManager manager, + Task primary, + Task disconnected, + CancellationToken cancellationToken) + { + while (!primary.IsCompleted) + { + if (disconnected.IsCompleted) + { + throw new InvalidOperationException(await disconnected); + } + + await manager.RunWaitCallbackAsync(cancellationToken); + } + + await primary; + } +} + static class PicsClient { public static async Task FetchAsync( @@ -138,7 +325,7 @@ static class PicsClient AccessToken = accessToken, AuthCode = authCode, TwoFactorCode = twoFactorCode, - ShouldRememberPassword = false, + ShouldRememberPassword = !string.IsNullOrWhiteSpace(accessToken), }); }); @@ -167,7 +354,7 @@ static class PicsClient }); steamClient.Connect(); - await PumpUntilAsync(manager, login.Task, disconnected.Task, cancellationToken); + await CallbackPump.UntilAsync(manager, login.Task, disconnected.Task, cancellationToken); var aggregate = new ProductAggregate(); var productInfo = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -184,7 +371,7 @@ static class PicsClient } }); - await PumpUntilAsync(manager, productInfo.Task, disconnected.Task, cancellationToken); + await CallbackPump.UntilAsync(manager, productInfo.Task, disconnected.Task, cancellationToken); steamUser.LogOff(); steamClient.Disconnect(); @@ -192,24 +379,6 @@ static class PicsClient return aggregate.ToSnapshot(); } - static async Task PumpUntilAsync( - CallbackManager manager, - Task primary, - Task disconnected, - CancellationToken cancellationToken) - { - while (!primary.IsCompleted) - { - if (disconnected.IsCompleted) - { - throw new InvalidOperationException(await disconnected); - } - - await manager.RunWaitCallbackAsync(cancellationToken); - } - - await primary; - } } sealed class ProductAggregate