From acfb3cd75df1525ff47907fffe66a9e535769dbd Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:16:55 +0800 Subject: [PATCH] ops: manual LongPort token refresh job (dry-run default) Add workflow_dispatch refresh for paper/sg/hk with dry_run=true by default. Live mode sends expired_at, fail-closes on API errors, and writes only a new token SM version (names/days in logs). Prefer paper then sg; warn on live hk. No schedule. --- .github/workflows/refresh-longport-token.yml | 92 +++++++ docs/longport_token_refresh_runbook.md | 3 +- scripts/refresh_longport_access_token.py | 271 +++++++++++++++++++ tests/test_refresh_longport_access_token.py | 136 ++++++++++ 4 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/refresh-longport-token.yml create mode 100755 scripts/refresh_longport_access_token.py create mode 100644 tests/test_refresh_longport_access_token.py diff --git a/.github/workflows/refresh-longport-token.yml b/.github/workflows/refresh-longport-token.yml new file mode 100644 index 0000000..22d45b5 --- /dev/null +++ b/.github/workflows/refresh-longport-token.yml @@ -0,0 +1,92 @@ +name: Refresh LongPort Access Token + +# Pre-expiry Legacy token refresh into Secret Manager. +# Default dry_run=true (plan only). Live mode: paper → sg → hk last. +# Never prints secret values. No schedule (manual only). +on: + workflow_dispatch: + inputs: + target: + description: "Account target (prefer paper, then sg; hk last)." + required: true + type: choice + options: + - paper + - sg + - hk + dry_run: + description: "When true (default), only plan; do not call LongPort or write SM." + required: true + type: boolean + default: true + force: + description: "Refresh even when days remaining >= threshold (still blocked if expired)." + required: false + type: boolean + default: false + refresh_threshold_days: + description: "Refresh when days remaining is below this value." + required: false + type: string + default: "30" + +permissions: + contents: read + +concurrency: + group: refresh-longport-token-${{ inputs.target }} + cancel-in-progress: false + +jobs: + refresh: + if: github.repository == 'QuantStrategyLab/LongBridgePlatform' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + env: + GCP_PROJECT_ID: longbridgequant + GCP_WORKLOAD_IDENTITY_PROVIDER: projects/252919773759/locations/global/workloadIdentityPools/github-actions/providers/github-main + GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: longbridge-platform-deploy@longbridgequant.iam.gserviceaccount.com + TARGET: ${{ inputs.target }} + DRY_RUN: ${{ inputs.dry_run }} + FORCE: ${{ inputs.force }} + REFRESH_THRESHOLD_DAYS: ${{ inputs.refresh_threshold_days }} + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Authenticate to Google Cloud (deploy SA) + uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install dependencies + run: python -m pip install --quiet 'google-cloud-secret-manager>=2.20' 'requests>=2.31' + + - name: Refresh or dry-run plan (names + days only) + env: + PYTHONPATH: ${{ github.workspace }} + run: | + set -euo pipefail + args=( + --project "$GCP_PROJECT_ID" + --target "$TARGET" + --refresh-threshold-days "$REFRESH_THRESHOLD_DAYS" + ) + if [[ "${DRY_RUN}" == "true" ]]; then + args+=(--dry-run) + else + args+=(--no-dry-run) + fi + if [[ "${FORCE}" == "true" ]]; then + args+=(--force) + fi + python scripts/refresh_longport_access_token.py "${args[@]}" | tee "$GITHUB_STEP_SUMMARY" diff --git a/docs/longport_token_refresh_runbook.md b/docs/longport_token_refresh_runbook.md index e11bd48..ca65eda 100644 --- a/docs/longport_token_refresh_runbook.md +++ b/docs/longport_token_refresh_runbook.md @@ -68,7 +68,7 @@ Treat OAuth as a **follow-on migration** after path A is green, not the emergenc | --- | --- | --- | --- | | A0 (this PR) | Runbook + read-only JWT expiry inspector (no refresh, no SM write) | paper / hk / sg | Docs + optional manual inspect only | | A1 | QPK: send `expired_at`; fail closed on pre-expiry refresh failure; keep SM write via store_rw | kit first | Shared kit; pin bump in platform after | -| A2 | Dedicated refresh job (Actions `workflow_dispatch` → then schedule) calling refresh **before** expiry, independent of `RUNTIME_TARGET_ENABLED` | **paper first**, then **sg**, **hk last** | Mutates token SM version; invalidates previous Access Token on success | +| A2 | Dedicated refresh job ([`refresh-longport-token.yml`](../.github/workflows/refresh-longport-token.yml), default `dry_run=true`) calling refresh **before** expiry, independent of `RUNTIME_TARGET_ENABLED`; schedule only after paper/sg live succeed | **paper first**, then **sg**, **hk last** | Mutates token SM version when `dry_run=false`; invalidates previous Access Token on success | | A3 | Alert when days-to-exp < threshold (reuse inspector) | all | Notify only | | B (later) | OAuth spike on paper only, SDK 4.x, SM-backed refresh token | paper → sg → hk | Large: auth model + SDK + deploy | @@ -104,3 +104,4 @@ Constraints this plan respects: no `independent_get` / ingress changes, no produ - Probe history branch (no refresh): `main.py` `run_probe` - Rotate workflow: `.github/workflows/rotate-longport-secrets.yml` - Read-only expiry inspect: `.github/workflows/inspect-longport-token-expiry.yml` + `scripts/inspect_longport_token_expiry.py` +- Pre-expiry refresh job (manual, dry-run default): `.github/workflows/refresh-longport-token.yml` + `scripts/refresh_longport_access_token.py` diff --git a/scripts/refresh_longport_access_token.py b/scripts/refresh_longport_access_token.py new file mode 100755 index 0000000..6129fb2 --- /dev/null +++ b/scripts/refresh_longport_access_token.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""Pre-expiry LongPort Legacy Access Token refresh (Actions / ops). + +Default is dry-run: report days remaining and whether a refresh would run. +Live mode calls GET /v1/token/refresh with required ``expired_at``, then adds a +Secret Manager version. Never prints secret values. + +Rollout order: paper → sg → hk last. Prefer dry-run until paper/sg succeed. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import hmac +import json +import sys +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any +from urllib.parse import urlencode + +_ROOT = Path(__file__).resolve().parents[1] +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + +from scripts.inspect_longport_token_expiry import ( + TARGET_SECRETS, + days_until_expiry, + decode_token_expiry_unix, +) + +DEFAULT_REFRESH_THRESHOLD_DAYS = 30 +DEFAULT_NEW_TOKEN_LIFETIME_DAYS = 90 +APP_KEY_SECRETS = { + "paper": "longport-app-key-paper", + "hk": "longport-app-key-hk", + "sg": "longport-app-key-sg", +} +APP_SECRET_SECRETS = { + "paper": "longport-app-secret-paper", + "hk": "longport-app-secret-hk", + "sg": "longport-app-secret-sg", +} + + +def _access_secret_latest(project_id: str, secret_name: str) -> str: + try: + import google.cloud.secretmanager_v1 as secret_manager + except ImportError: # pragma: no cover + from google.cloud import secret_manager + + client = secret_manager.SecretManagerServiceClient() + name = f"projects/{project_id}/secrets/{secret_name}/versions/latest" + response = client.access_secret_version(request={"name": name}) + return response.payload.data.decode("UTF-8").strip() + + +def _add_secret_version(project_id: str, secret_name: str, payload: str) -> str: + try: + import google.cloud.secretmanager_v1 as secret_manager + except ImportError: # pragma: no cover + from google.cloud import secret_manager + + client = secret_manager.SecretManagerServiceClient() + parent = f"projects/{project_id}/secrets/{secret_name}" + version = client.add_secret_version( + request={"parent": parent, "payload": {"data": payload.encode("UTF-8")}} + ) + return str(version.name) + + +def _format_refresh_expired_at(*, lifetime_days: int = DEFAULT_NEW_TOKEN_LIFETIME_DAYS) -> str: + expiry = datetime.now(timezone.utc) + timedelta(days=int(lifetime_days)) + return expiry.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" + + +def _longport_sign(method: str, uri: str, headers: dict[str, str], params: str, body: str, secret: str) -> str: + canonical_request = ( + f"{method.upper()}|{uri}|{params}|" + f"authorization:{headers['Authorization']}\n" + f"x-api-key:{headers['X-Api-Key']}\n" + f"x-timestamp:{headers['X-Timestamp']}\n|authorization;x-api-key;x-timestamp|" + ) + if body: + canonical_request += hashlib.sha1(body.encode("utf-8")).hexdigest() + sign_str = "HMAC-SHA256|" + hashlib.sha1(canonical_request.encode("utf-8")).hexdigest() + signature = hmac.new(secret.encode("utf-8"), sign_str.encode("utf-8"), hashlib.sha256).hexdigest() + return f"HMAC-SHA256 SignedHeaders=authorization;x-api-key;x-timestamp, Signature={signature}" + + +def call_longport_refresh( + *, + access_token: str, + app_key: str, + app_secret: str, + lifetime_days: int = DEFAULT_NEW_TOKEN_LIFETIME_DAYS, + requests_module: Any | None = None, +) -> str: + if requests_module is None: + import requests as requests_module + + expired_at = _format_refresh_expired_at(lifetime_days=lifetime_days) + params = urlencode({"expired_at": expired_at}) + headers = { + "X-Api-Key": app_key, + "Authorization": access_token, + "X-Timestamp": str(int(time.time())), + "Content-Type": "application/json; charset=utf-8", + } + headers["X-Api-Signature"] = _longport_sign("GET", "/v1/token/refresh", headers, params, "", app_secret) + response = requests_module.get( + f"https://openapi.longportapp.com/v1/token/refresh?{params}", + headers=headers, + timeout=15, + ).json() + if response.get("code") != 0: + code = response.get("code") + message = response.get("message") or "unknown error" + raise RuntimeError(f"LongPort refresh failed with code {code}: {message}") + data = response.get("data") or {} + new_token = data.get("token") + if not isinstance(new_token, str) or not new_token.strip(): + raise RuntimeError("LongPort refresh returned an empty token payload") + return new_token.strip() + + +def plan_refresh( + *, + days_remaining: float | None, + refresh_threshold_days: float, + force: bool, +) -> tuple[bool, str]: + if force: + return True, "forced" + if days_remaining is None: + return True, "jwt_exp_undecodable" + if days_remaining <= 0: + return False, "already_expired_needs_portal_reset" + if days_remaining < float(refresh_threshold_days): + return True, "within_threshold" + return False, "outside_threshold" + + +def run_refresh( + *, + project_id: str, + target: str, + dry_run: bool, + refresh_threshold_days: float = DEFAULT_REFRESH_THRESHOLD_DAYS, + force: bool = False, + lifetime_days: int = DEFAULT_NEW_TOKEN_LIFETIME_DAYS, + secret_reader: Any | None = None, + secret_writer: Any | None = None, + refresh_caller: Any | None = None, + now: float | None = None, +) -> dict[str, Any]: + if target not in TARGET_SECRETS: + raise ValueError(f"unsupported target={target}") + token_secret = TARGET_SECRETS[target] + app_key_secret = APP_KEY_SECRETS[target] + app_secret_secret = APP_SECRET_SECRETS[target] + reader = secret_reader or _access_secret_latest + writer = secret_writer or _add_secret_version + refresher = refresh_caller or call_longport_refresh + + current = now if now is not None else time.time() + token = reader(project_id, token_secret) + expiry = decode_token_expiry_unix(token) + days = None if expiry is None else days_until_expiry(expiry, now=current) + should, reason = plan_refresh( + days_remaining=days, + refresh_threshold_days=refresh_threshold_days, + force=force, + ) + result: dict[str, Any] = { + "target": target, + "token_secret": token_secret, + "dry_run": bool(dry_run), + "force": bool(force), + "refresh_threshold_days": float(refresh_threshold_days), + "jwt_exp_present": expiry is not None, + "days_until_expiry": None if days is None else round(days, 3), + "would_refresh": bool(should), + "reason": reason, + "status": "planned", + "ok": True, + } + if not should: + if reason == "already_expired_needs_portal_reset": + result["status"] = "blocked_expired" + result["ok"] = False + else: + result["status"] = "skipped" + return result + + if dry_run: + result["status"] = "dry_run_would_refresh" + return result + + app_key = reader(project_id, app_key_secret) + app_secret = reader(project_id, app_secret_secret) + if not app_key or not app_secret: + result["status"] = "missing_app_credentials" + result["ok"] = False + return result + + new_token = refresher( + access_token=token, + app_key=app_key, + app_secret=app_secret, + lifetime_days=lifetime_days, + ) + version_name = writer(project_id, token_secret, new_token) + result["status"] = "refreshed" + result["new_version"] = version_name + return result + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--project", required=True) + parser.add_argument("--target", required=True, choices=sorted(TARGET_SECRETS)) + parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Default true: plan only; never call LongPort refresh or write SM", + ) + parser.add_argument( + "--force", + action="store_true", + help="Refresh even when days remaining >= threshold (still blocked if already expired)", + ) + parser.add_argument( + "--refresh-threshold-days", + type=float, + default=DEFAULT_REFRESH_THRESHOLD_DAYS, + ) + parser.add_argument( + "--new-token-lifetime-days", + type=int, + default=DEFAULT_NEW_TOKEN_LIFETIME_DAYS, + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if args.target == "hk" and not args.dry_run: + print( + "warning: live refresh on target=hk invalidates the current Access Token; " + "prefer paper then sg first", + file=sys.stderr, + ) + result = run_refresh( + project_id=args.project, + target=args.target, + dry_run=args.dry_run, + refresh_threshold_days=args.refresh_threshold_days, + force=args.force, + lifetime_days=args.new_token_lifetime_days, + ) + print(json.dumps(result, sort_keys=True)) + return 0 if result.get("ok") else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_refresh_longport_access_token.py b/tests/test_refresh_longport_access_token.py new file mode 100644 index 0000000..d5ee3fb --- /dev/null +++ b/tests/test_refresh_longport_access_token.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import base64 +import json +import sys +import time +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.refresh_longport_access_token import ( # noqa: E402 + call_longport_refresh, + plan_refresh, + run_refresh, +) + + +def _jwt(exp: int) -> str: + payload = base64.urlsafe_b64encode(json.dumps({"exp": exp}).encode()).decode().rstrip("=") + return f"h.{payload}.s" + + +def test_plan_refresh_threshold_and_expired(): + assert plan_refresh(days_remaining=45, refresh_threshold_days=30, force=False) == ( + False, + "outside_threshold", + ) + assert plan_refresh(days_remaining=10, refresh_threshold_days=30, force=False)[0] is True + assert plan_refresh(days_remaining=-1, refresh_threshold_days=30, force=False) == ( + False, + "already_expired_needs_portal_reset", + ) + assert plan_refresh(days_remaining=45, refresh_threshold_days=30, force=True)[0] is True + + +def test_dry_run_would_refresh_without_network(): + now = time.time() + observed = {"reads": 0, "writes": 0, "refresh": 0} + + def reader(_project, _secret): + observed["reads"] += 1 + return _jwt(int(now + 10 * 86400)) + + def writer(*_a, **_k): + observed["writes"] += 1 + raise AssertionError("dry-run must not write") + + def refresher(**_k): + observed["refresh"] += 1 + raise AssertionError("dry-run must not call LongPort") + + result = run_refresh( + project_id="demo", + target="paper", + dry_run=True, + refresh_threshold_days=30, + secret_reader=reader, + secret_writer=writer, + refresh_caller=refresher, + now=now, + ) + assert result["status"] == "dry_run_would_refresh" + assert result["ok"] is True + assert observed == {"reads": 1, "writes": 0, "refresh": 0} + jwt_value = _jwt(int(now + 10 * 86400)) + assert jwt_value not in json.dumps(result) + + +def test_live_refresh_writes_version_name_only(): + now = time.time() + secrets = { + "longport_token_paper": _jwt(int(now + 5 * 86400)), + "longport-app-key-paper": "app-key", + "longport-app-secret-paper": "app-secret", + } + written = {} + + def reader(_project, name): + return secrets[name] + + def writer(_project, name, payload): + written["name"] = name + written["payload"] = payload + return "projects/demo/secrets/longport_token_paper/versions/9" + + def refresher(**kwargs): + assert kwargs["app_key"] == "app-key" + assert kwargs["access_token"] == secrets["longport_token_paper"] + return "brand-new-token" + + result = run_refresh( + project_id="demo", + target="paper", + dry_run=False, + refresh_threshold_days=30, + secret_reader=reader, + secret_writer=writer, + refresh_caller=refresher, + now=now, + ) + assert result["status"] == "refreshed" + assert result["new_version"].endswith("/versions/9") + assert written["payload"] == "brand-new-token" + dumped = json.dumps(result) + assert "brand-new-token" not in dumped + assert "app-secret" not in dumped + + +def test_call_longport_refresh_sends_expired_at(): + class Req: + last_url = None + + @classmethod + def get(cls, url, headers, timeout): + cls.last_url = url + + class Response: + @staticmethod + def json(): + return {"code": 0, "data": {"token": "rotated"}} + + return Response() + + token = call_longport_refresh( + access_token="tok", + app_key="k", + app_secret="s", + lifetime_days=90, + requests_module=Req, + ) + assert token == "rotated" + query = parse_qs(urlparse(Req.last_url).query) + assert "expired_at" in query