sf-exit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sf_exit/__init__.py +3 -0
- sf_exit/__main__.py +4 -0
- sf_exit/auth.py +190 -0
- sf_exit/cli.py +498 -0
- sf_exit/client.py +446 -0
- sf_exit/discovery.py +199 -0
- sf_exit/exporter.py +631 -0
- sf_exit/loader.py +247 -0
- sf_exit/schema.py +134 -0
- sf_exit/utils.py +52 -0
- sf_exit/verify.py +122 -0
- sf_exit-0.1.0.dist-info/METADATA +327 -0
- sf_exit-0.1.0.dist-info/RECORD +16 -0
- sf_exit-0.1.0.dist-info/WHEEL +4 -0
- sf_exit-0.1.0.dist-info/entry_points.txt +2 -0
- sf_exit-0.1.0.dist-info/licenses/LICENSE +21 -0
sf_exit/__init__.py
ADDED
sf_exit/__main__.py
ADDED
sf_exit/auth.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Ways to log in to Salesforce.
|
|
2
|
+
|
|
3
|
+
Resolution order (first one that has enough information wins):
|
|
4
|
+
|
|
5
|
+
1. ``--access-token`` + ``--instance-url`` (any existing session)
|
|
6
|
+
2. ``--org ALIAS`` (reuse a Salesforce CLI login: `sf org login web`)
|
|
7
|
+
3. ``--jwt-key-file`` + ``--client-id`` + ``--username`` (JWT bearer flow, headless/CI)
|
|
8
|
+
4. ``--username`` + ``--password`` + ``--client-id`` (OAuth username-password flow)
|
|
9
|
+
5. ``--client-id`` + ``--client-secret`` + ``--instance-url`` (OAuth client-credentials flow)
|
|
10
|
+
6. nothing at all → the Salesforce CLI's *default* org, if one is set
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Callable, Optional
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
from sf_exit.client import SalesforceError
|
|
25
|
+
|
|
26
|
+
PRODUCTION_LOGIN = "https://login.salesforce.com"
|
|
27
|
+
SANDBOX_LOGIN = "https://test.salesforce.com"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AuthError(Exception):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class AuthConfig:
|
|
36
|
+
access_token: Optional[str] = None
|
|
37
|
+
instance_url: Optional[str] = None
|
|
38
|
+
org: Optional[str] = None
|
|
39
|
+
client_id: Optional[str] = None
|
|
40
|
+
client_secret: Optional[str] = None
|
|
41
|
+
username: Optional[str] = None
|
|
42
|
+
password: Optional[str] = None
|
|
43
|
+
jwt_key_file: Optional[str] = None
|
|
44
|
+
login_url: str = PRODUCTION_LOGIN
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Session:
|
|
49
|
+
instance_url: str
|
|
50
|
+
access_token: str
|
|
51
|
+
method: str
|
|
52
|
+
refresh: Optional[Callable[[], str]] = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _token_request(url: str, data: dict) -> dict:
|
|
56
|
+
resp = requests.post(url.rstrip("/") + "/services/oauth2/token", data=data, timeout=60)
|
|
57
|
+
if resp.status_code >= 400:
|
|
58
|
+
try:
|
|
59
|
+
body = resp.json()
|
|
60
|
+
msg = f"{body.get('error')}: {body.get('error_description')}"
|
|
61
|
+
except ValueError:
|
|
62
|
+
msg = resp.text[:300]
|
|
63
|
+
raise AuthError(f"Salesforce login failed ({resp.status_code}) {msg}")
|
|
64
|
+
return resp.json()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _sf_cli_binary() -> Optional[str]:
|
|
68
|
+
return shutil.which("sf") or shutil.which("sfdx")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def from_sf_cli(org: Optional[str]) -> Session:
|
|
72
|
+
binary = _sf_cli_binary()
|
|
73
|
+
if not binary:
|
|
74
|
+
raise AuthError(
|
|
75
|
+
"The Salesforce CLI ('sf') was not found on PATH. Install it from "
|
|
76
|
+
"https://developer.salesforce.com/tools/salesforcecli or pass other credentials."
|
|
77
|
+
)
|
|
78
|
+
cmd = [binary, "org", "display", "--json"]
|
|
79
|
+
if org:
|
|
80
|
+
cmd += ["--target-org", org]
|
|
81
|
+
try:
|
|
82
|
+
out = subprocess.run(cmd, capture_output=True, text=True, timeout=120, check=False)
|
|
83
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
84
|
+
raise AuthError(f"Could not run the Salesforce CLI: {exc}") from exc
|
|
85
|
+
try:
|
|
86
|
+
payload = json.loads(out.stdout or "{}")
|
|
87
|
+
except ValueError as exc:
|
|
88
|
+
raise AuthError(f"Unexpected output from '{' '.join(cmd)}': {out.stdout[:300]}") from exc
|
|
89
|
+
result = payload.get("result") or {}
|
|
90
|
+
if payload.get("status", 1) != 0 or not result.get("accessToken"):
|
|
91
|
+
message = payload.get("message") or out.stderr.strip() or "no org found"
|
|
92
|
+
raise AuthError(f"Salesforce CLI could not provide a session for {org or 'the default org'}: {message}")
|
|
93
|
+
|
|
94
|
+
def refresh() -> str:
|
|
95
|
+
return from_sf_cli(org).access_token
|
|
96
|
+
|
|
97
|
+
return Session(result["instanceUrl"], result["accessToken"], f"sf-cli:{result.get('alias') or result.get('username') or org}", refresh)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def jwt_flow(cfg: AuthConfig) -> Session:
|
|
101
|
+
try:
|
|
102
|
+
import jwt # type: ignore
|
|
103
|
+
except ImportError as exc: # pragma: no cover - depends on extras
|
|
104
|
+
raise AuthError("JWT login needs an extra: pip install 'sf-exit[jwt]'") from exc
|
|
105
|
+
with open(cfg.jwt_key_file or "", encoding="utf-8") as fh:
|
|
106
|
+
key = fh.read()
|
|
107
|
+
sandbox = "test.salesforce.com" in cfg.login_url or ".sandbox.my.salesforce.com" in cfg.login_url
|
|
108
|
+
audience = SANDBOX_LOGIN if sandbox else PRODUCTION_LOGIN
|
|
109
|
+
|
|
110
|
+
def fetch() -> dict:
|
|
111
|
+
assertion = jwt.encode(
|
|
112
|
+
{"iss": cfg.client_id, "sub": cfg.username, "aud": audience, "exp": int(time.time()) + 180},
|
|
113
|
+
key,
|
|
114
|
+
algorithm="RS256",
|
|
115
|
+
)
|
|
116
|
+
return _token_request(
|
|
117
|
+
cfg.login_url,
|
|
118
|
+
{"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": assertion},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
tok = fetch()
|
|
122
|
+
return Session(tok["instance_url"], tok["access_token"], "jwt", lambda: fetch()["access_token"])
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def password_flow(cfg: AuthConfig) -> Session:
|
|
126
|
+
def fetch() -> dict:
|
|
127
|
+
data = {
|
|
128
|
+
"grant_type": "password",
|
|
129
|
+
"client_id": cfg.client_id,
|
|
130
|
+
"username": cfg.username,
|
|
131
|
+
"password": cfg.password,
|
|
132
|
+
}
|
|
133
|
+
if cfg.client_secret:
|
|
134
|
+
data["client_secret"] = cfg.client_secret
|
|
135
|
+
return _token_request(cfg.login_url, data)
|
|
136
|
+
|
|
137
|
+
tok = fetch()
|
|
138
|
+
return Session(tok["instance_url"], tok["access_token"], "password", lambda: fetch()["access_token"])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def client_credentials_flow(cfg: AuthConfig) -> Session:
|
|
142
|
+
def fetch() -> dict:
|
|
143
|
+
return _token_request(
|
|
144
|
+
cfg.instance_url or cfg.login_url,
|
|
145
|
+
{
|
|
146
|
+
"grant_type": "client_credentials",
|
|
147
|
+
"client_id": cfg.client_id,
|
|
148
|
+
"client_secret": cfg.client_secret,
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
tok = fetch()
|
|
153
|
+
return Session(
|
|
154
|
+
tok.get("instance_url") or cfg.instance_url or "",
|
|
155
|
+
tok["access_token"],
|
|
156
|
+
"client-credentials",
|
|
157
|
+
lambda: fetch()["access_token"],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def login(cfg: AuthConfig) -> Session:
|
|
162
|
+
try:
|
|
163
|
+
if cfg.access_token:
|
|
164
|
+
if not cfg.instance_url:
|
|
165
|
+
raise AuthError("--access-token also needs --instance-url (e.g. https://acme.my.salesforce.com)")
|
|
166
|
+
return Session(cfg.instance_url, cfg.access_token, "access-token")
|
|
167
|
+
if cfg.org:
|
|
168
|
+
return from_sf_cli(cfg.org)
|
|
169
|
+
if cfg.jwt_key_file:
|
|
170
|
+
if not (cfg.client_id and cfg.username):
|
|
171
|
+
raise AuthError("JWT login needs --client-id and --username as well as --jwt-key-file")
|
|
172
|
+
return jwt_flow(cfg)
|
|
173
|
+
if cfg.username and cfg.password:
|
|
174
|
+
if not cfg.client_id:
|
|
175
|
+
raise AuthError("Username/password login needs --client-id (a connected app consumer key)")
|
|
176
|
+
return password_flow(cfg)
|
|
177
|
+
if cfg.client_id and cfg.client_secret:
|
|
178
|
+
if not cfg.instance_url:
|
|
179
|
+
raise AuthError("Client-credentials login needs --instance-url (your My Domain URL)")
|
|
180
|
+
return client_credentials_flow(cfg)
|
|
181
|
+
if _sf_cli_binary():
|
|
182
|
+
return from_sf_cli(None)
|
|
183
|
+
except SalesforceError as exc: # pragma: no cover - defensive
|
|
184
|
+
raise AuthError(str(exc)) from exc
|
|
185
|
+
raise AuthError(
|
|
186
|
+
"No Salesforce credentials given.\n"
|
|
187
|
+
" Easiest: install the Salesforce CLI, run `sf org login web --alias my-org`,\n"
|
|
188
|
+
" then `sf-exit export --org my-org`.\n"
|
|
189
|
+
" See `sf-exit export --help` for token, JWT, password and client-credentials options."
|
|
190
|
+
)
|