bc-cli 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.
- bc_cli-0.1.0.dist-info/METADATA +224 -0
- bc_cli-0.1.0.dist-info/RECORD +78 -0
- bc_cli-0.1.0.dist-info/WHEEL +4 -0
- bc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- bc_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- bc_cli-0.1.0.dist-info/licenses/NOTICE +10 -0
- bcli/__init__.py +44 -0
- bcli/_url.py +133 -0
- bcli/_version.py +1 -0
- bcli/auth/__init__.py +8 -0
- bcli/auth/_base.py +17 -0
- bcli/auth/_browser.py +234 -0
- bcli/auth/_credentials.py +169 -0
- bcli/auth/_device_code.py +90 -0
- bcli/auth/_secure_io.py +155 -0
- bcli/auth/_token_cache.py +93 -0
- bcli/auth/_workos.py +279 -0
- bcli/client/__init__.py +6 -0
- bcli/client/_async.py +566 -0
- bcli/client/_safety.py +179 -0
- bcli/client/_sync.py +171 -0
- bcli/client/_transport.py +303 -0
- bcli/config/__init__.py +6 -0
- bcli/config/_defaults.py +47 -0
- bcli/config/_loader.py +190 -0
- bcli/config/_model.py +206 -0
- bcli/errors.py +69 -0
- bcli/etl/__init__.py +63 -0
- bcli/etl/_auth.py +75 -0
- bcli/etl/_bridge.py +122 -0
- bcli/etl/_client.py +177 -0
- bcli/etl/_generic.py +220 -0
- bcli/etl/_polaris.py +170 -0
- bcli/etl/_stampers.py +74 -0
- bcli/odata/__init__.py +8 -0
- bcli/odata/_escape.py +32 -0
- bcli/odata/_filter_fields.py +137 -0
- bcli/odata/_pagination.py +42 -0
- bcli/odata/_query.py +117 -0
- bcli/odata/_response.py +46 -0
- bcli/py.typed +0 -0
- bcli/registry/__init__.py +12 -0
- bcli/registry/_importers.py +335 -0
- bcli/registry/_registry.py +148 -0
- bcli/registry/_schema.py +49 -0
- bcli/registry/standard_v2.json +85 -0
- bcli/telemetry/__init__.py +37 -0
- bcli/telemetry/_azure_monitor.py +103 -0
- bcli/telemetry/_factory.py +112 -0
- bcli/telemetry/_protocol.py +83 -0
- bcli/telemetry/events.py +271 -0
- bcli/workflow/__init__.py +19 -0
- bcli/workflow/_models.py +68 -0
- bcli/workflow/_resolver.py +131 -0
- bcli_cli/__init__.py +1 -0
- bcli_cli/_safety.py +73 -0
- bcli_cli/_state.py +113 -0
- bcli_cli/app.py +193 -0
- bcli_cli/commands/__init__.py +1 -0
- bcli_cli/commands/auth_cmd.py +182 -0
- bcli_cli/commands/batch_cmd.py +386 -0
- bcli_cli/commands/beautech_cmd.py +214 -0
- bcli_cli/commands/company_cmd.py +229 -0
- bcli_cli/commands/config_cmd.py +380 -0
- bcli_cli/commands/context_cmd.py +99 -0
- bcli_cli/commands/delete_cmd.py +52 -0
- bcli_cli/commands/endpoint_cmd.py +197 -0
- bcli_cli/commands/env_cmd.py +86 -0
- bcli_cli/commands/etl_cmd.py +218 -0
- bcli_cli/commands/get_cmd.py +266 -0
- bcli_cli/commands/patch_cmd.py +68 -0
- bcli_cli/commands/post_cmd.py +67 -0
- bcli_cli/commands/query_cmd.py +474 -0
- bcli_cli/commands/registry_cmd.py +135 -0
- bcli_cli/commands/test_cmd.py +95 -0
- bcli_cli/output/__init__.py +6 -0
- bcli_cli/output/_display.py +28 -0
- bcli_cli/output/_formatters.py +168 -0
bcli/_url.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""URL builder for Business Central API endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from bcli.config._defaults import BC_BASE_URL, BC_STANDARD_API_PATH
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_url(
|
|
9
|
+
*,
|
|
10
|
+
environment: str,
|
|
11
|
+
company_id: str,
|
|
12
|
+
entity_set_name: str,
|
|
13
|
+
record_id: str | None = None,
|
|
14
|
+
# For custom APIs — None means standard v2.0
|
|
15
|
+
publisher: str | None = None,
|
|
16
|
+
group: str | None = None,
|
|
17
|
+
version: str | None = None,
|
|
18
|
+
) -> str:
|
|
19
|
+
"""Build a full BC API URL.
|
|
20
|
+
|
|
21
|
+
Standard v2.0:
|
|
22
|
+
https://api.businesscentral.dynamics.com/v2.0/{env}/api/v2.0/companies({id})/{entity}
|
|
23
|
+
|
|
24
|
+
Custom API:
|
|
25
|
+
https://api.businesscentral.dynamics.com/v2.0/{env}/api/{pub}/{grp}/{ver}/companies({id})/{entity}
|
|
26
|
+
"""
|
|
27
|
+
if publisher and group and version:
|
|
28
|
+
api_path = f"api/{publisher}/{group}/{version}"
|
|
29
|
+
else:
|
|
30
|
+
api_path = BC_STANDARD_API_PATH
|
|
31
|
+
|
|
32
|
+
url = f"{BC_BASE_URL}/{environment}/{api_path}/companies({company_id})/{entity_set_name}"
|
|
33
|
+
|
|
34
|
+
if record_id:
|
|
35
|
+
url = f"{url}({record_id})"
|
|
36
|
+
|
|
37
|
+
return url
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_companies_url(*, environment: str) -> str:
|
|
41
|
+
"""Build URL to list companies (no company context needed)."""
|
|
42
|
+
return f"{BC_BASE_URL}/{environment}/{BC_STANDARD_API_PATH}/companies"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_environments_url(*, tenant_id: str) -> str:
|
|
46
|
+
"""Build URL for BC Admin Center environments API."""
|
|
47
|
+
return (
|
|
48
|
+
"https://api.businesscentral.dynamics.com"
|
|
49
|
+
"/admin/v2.1/applications/businesscentral/environments"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_metadata_url(
|
|
54
|
+
*,
|
|
55
|
+
environment: str,
|
|
56
|
+
publisher: str | None = None,
|
|
57
|
+
group: str | None = None,
|
|
58
|
+
version: str | None = None,
|
|
59
|
+
) -> str:
|
|
60
|
+
"""Build URL for OData $metadata endpoint."""
|
|
61
|
+
if publisher and group and version:
|
|
62
|
+
api_path = f"api/{publisher}/{group}/{version}"
|
|
63
|
+
else:
|
|
64
|
+
api_path = BC_STANDARD_API_PATH
|
|
65
|
+
return f"{BC_BASE_URL}/{environment}/{api_path}/$metadata"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ─── Host allowlist for absolute URLs ─────────────────────────────────────
|
|
69
|
+
#
|
|
70
|
+
# bcli attaches a BC bearer token to every outgoing request. If a BC
|
|
71
|
+
# response ever returns an off-origin ``@odata.nextLink`` (e.g. a
|
|
72
|
+
# compromised custom-API page that returns ``@odata.nextLink:
|
|
73
|
+
# https://attacker.example/leak``), the paginator would happily follow it
|
|
74
|
+
# with the token attached, leaking the credential to the attacker. Guard
|
|
75
|
+
# the paginator (and any other absolute-URL follower) by running absolute
|
|
76
|
+
# URLs through ``assert_bc_origin`` before they reach the bearer-injecting
|
|
77
|
+
# transport.
|
|
78
|
+
#
|
|
79
|
+
# We allow the entire ``.businesscentral.dynamics.com`` suffix because BC
|
|
80
|
+
# is regional — ``api.businesscentral.dynamics.com`` for the API,
|
|
81
|
+
# ``api.bc.dynamics.com`` for the legacy alias, plus customer-specific
|
|
82
|
+
# regional CNAMEs that all live under the same parent suffix. The leading
|
|
83
|
+
# dot prevents the classic ``evilbusinesscentral.dynamics.com.attacker``
|
|
84
|
+
# trick.
|
|
85
|
+
|
|
86
|
+
# Suffix list is ordered most-specific-first; a hostname matches if it
|
|
87
|
+
# equals the suffix or ends with ``"." + suffix``.
|
|
88
|
+
_ALLOWED_HOST_SUFFIXES: tuple[str, ...] = (
|
|
89
|
+
"businesscentral.dynamics.com",
|
|
90
|
+
"bc.dynamics.com",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def is_bc_origin(url: str) -> bool:
|
|
95
|
+
"""Return ``True`` if ``url`` is absolute and points at a BC host.
|
|
96
|
+
|
|
97
|
+
Relative URLs (no scheme) are considered safe — they get joined to the
|
|
98
|
+
SDK's BC base URL by httpx, so they can't leak auth elsewhere.
|
|
99
|
+
"""
|
|
100
|
+
from urllib.parse import urlparse
|
|
101
|
+
|
|
102
|
+
parsed = urlparse(url)
|
|
103
|
+
if not parsed.scheme:
|
|
104
|
+
# Relative URL — caller will resolve it against the BC base URL.
|
|
105
|
+
return True
|
|
106
|
+
if parsed.scheme not in ("http", "https"):
|
|
107
|
+
return False
|
|
108
|
+
host = (parsed.hostname or "").lower()
|
|
109
|
+
if not host:
|
|
110
|
+
return False
|
|
111
|
+
for suffix in _ALLOWED_HOST_SUFFIXES:
|
|
112
|
+
if host == suffix or host.endswith("." + suffix):
|
|
113
|
+
return True
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def assert_bc_origin(url: str) -> None:
|
|
118
|
+
"""Raise ``ValueError`` if ``url`` isn't a relative URL or BC host.
|
|
119
|
+
|
|
120
|
+
Called by the transport layer before an absolute URL reaches the
|
|
121
|
+
bearer-injecting request path. The error message intentionally
|
|
122
|
+
surfaces the rejected URL so the operator can audit a misbehaving
|
|
123
|
+
endpoint or registry entry.
|
|
124
|
+
"""
|
|
125
|
+
if not is_bc_origin(url):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"Refusing to attach BC credentials to off-origin URL: {url!r}. "
|
|
128
|
+
f"Allowed host suffixes: {list(_ALLOWED_HOST_SUFFIXES)}. "
|
|
129
|
+
f"This URL came from an @odata.nextLink or similar follow-up "
|
|
130
|
+
f"reference; if the BC tenant is genuinely returning it, the "
|
|
131
|
+
f"allowlist needs to be expanded — but check first that the "
|
|
132
|
+
f"response wasn't tampered with."
|
|
133
|
+
)
|
bcli/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
bcli/auth/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Authentication providers for Business Central."""
|
|
2
|
+
|
|
3
|
+
from bcli.auth._base import AuthProvider
|
|
4
|
+
from bcli.auth._browser import BrowserAuth
|
|
5
|
+
from bcli.auth._credentials import ClientCredentialsAuth
|
|
6
|
+
from bcli.auth._token_cache import TokenCache
|
|
7
|
+
|
|
8
|
+
__all__ = ["AuthProvider", "BrowserAuth", "ClientCredentialsAuth", "TokenCache"]
|
bcli/auth/_base.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Auth provider protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthProvider(Protocol):
|
|
9
|
+
"""Protocol for authentication providers."""
|
|
10
|
+
|
|
11
|
+
async def get_access_token(self) -> str:
|
|
12
|
+
"""Return a valid access token, refreshing if needed."""
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
def clear_cache(self) -> None:
|
|
16
|
+
"""Clear any cached tokens."""
|
|
17
|
+
...
|
bcli/auth/_browser.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Authorization code flow with PKCE for interactive browser-based auth.
|
|
2
|
+
|
|
3
|
+
Opens the user's default browser to authenticate. A temporary localhost server
|
|
4
|
+
catches the redirect callback. The token carries the user's identity, so
|
|
5
|
+
Business Central enforces their permission sets on every API call.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import platform
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
import webbrowser
|
|
16
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
17
|
+
from typing import Any
|
|
18
|
+
from urllib.parse import parse_qs, urlparse
|
|
19
|
+
|
|
20
|
+
import msal
|
|
21
|
+
|
|
22
|
+
from bcli.auth._token_cache import TokenCache
|
|
23
|
+
from bcli.config._defaults import BC_SCOPE, ENTRA_AUTHORITY_BASE
|
|
24
|
+
from bcli.errors import AuthError
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
_AUTH_TIMEOUT = 120 # seconds to wait for browser callback
|
|
29
|
+
_DEFAULT_PORT = 8400 # fixed port — register http://localhost:8400 in Entra ID
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _open_browser(url: str, *, incognito: bool = False) -> None:
|
|
33
|
+
"""Open a URL in the browser, optionally in incognito/private mode."""
|
|
34
|
+
if not incognito:
|
|
35
|
+
webbrowser.open(url)
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
system = platform.system()
|
|
39
|
+
try:
|
|
40
|
+
if system == "Darwin":
|
|
41
|
+
# Try Chrome first, fall back to Safari private
|
|
42
|
+
for app, flag in [
|
|
43
|
+
("/Applications/Google Chrome.app", "--incognito"),
|
|
44
|
+
("/Applications/Microsoft Edge.app", "--inprivate"),
|
|
45
|
+
("/Applications/Brave Browser.app", "--incognito"),
|
|
46
|
+
]:
|
|
47
|
+
try:
|
|
48
|
+
subprocess.Popen([
|
|
49
|
+
"open", "-na", app, "--args", flag, url,
|
|
50
|
+
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
51
|
+
return
|
|
52
|
+
except (FileNotFoundError, OSError):
|
|
53
|
+
continue
|
|
54
|
+
# Safari doesn't support incognito via CLI, fall back to default
|
|
55
|
+
webbrowser.open(url)
|
|
56
|
+
elif system == "Windows":
|
|
57
|
+
for exe, flag in [
|
|
58
|
+
("chrome", "--incognito"),
|
|
59
|
+
("msedge", "--inprivate"),
|
|
60
|
+
]:
|
|
61
|
+
try:
|
|
62
|
+
subprocess.Popen([exe, flag, url])
|
|
63
|
+
return
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
continue
|
|
66
|
+
webbrowser.open(url)
|
|
67
|
+
else:
|
|
68
|
+
# Linux
|
|
69
|
+
for exe, flag in [
|
|
70
|
+
("google-chrome", "--incognito"),
|
|
71
|
+
("chromium-browser", "--incognito"),
|
|
72
|
+
("firefox", "--private-window"),
|
|
73
|
+
]:
|
|
74
|
+
try:
|
|
75
|
+
subprocess.Popen([exe, flag, url])
|
|
76
|
+
return
|
|
77
|
+
except FileNotFoundError:
|
|
78
|
+
continue
|
|
79
|
+
webbrowser.open(url)
|
|
80
|
+
except Exception:
|
|
81
|
+
webbrowser.open(url)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class BrowserAuth:
|
|
85
|
+
"""Interactive browser-based auth via authorization code flow with PKCE.
|
|
86
|
+
|
|
87
|
+
User authenticates as themselves — BC enforces their permission sets.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
*,
|
|
93
|
+
tenant_id: str,
|
|
94
|
+
client_id: str,
|
|
95
|
+
token_cache: TokenCache | None = None,
|
|
96
|
+
login_hint: str | None = None,
|
|
97
|
+
incognito: bool = False,
|
|
98
|
+
) -> None:
|
|
99
|
+
self._tenant_id = tenant_id
|
|
100
|
+
self._client_id = client_id
|
|
101
|
+
self._token_cache = token_cache or TokenCache()
|
|
102
|
+
self._authority = f"{ENTRA_AUTHORITY_BASE}/{tenant_id}"
|
|
103
|
+
self._login_hint = login_hint
|
|
104
|
+
self._incognito = incognito
|
|
105
|
+
|
|
106
|
+
async def get_access_token(self) -> str:
|
|
107
|
+
"""Get a valid access token, using cache or browser flow."""
|
|
108
|
+
# Check disk cache first
|
|
109
|
+
cached = self._token_cache.get(self._tenant_id, self._client_id)
|
|
110
|
+
if cached:
|
|
111
|
+
return cached
|
|
112
|
+
|
|
113
|
+
# Build MSAL public client
|
|
114
|
+
app = msal.PublicClientApplication(
|
|
115
|
+
client_id=self._client_id,
|
|
116
|
+
authority=self._authority,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Try silent acquisition from MSAL in-memory cache
|
|
120
|
+
accounts = app.get_accounts()
|
|
121
|
+
if accounts:
|
|
122
|
+
result = app.acquire_token_silent(
|
|
123
|
+
scopes=[BC_SCOPE],
|
|
124
|
+
account=accounts[0],
|
|
125
|
+
)
|
|
126
|
+
if result and "access_token" in result:
|
|
127
|
+
self._cache_token(result)
|
|
128
|
+
return result["access_token"]
|
|
129
|
+
|
|
130
|
+
# Start browser auth flow
|
|
131
|
+
port = _DEFAULT_PORT
|
|
132
|
+
redirect_uri = f"http://localhost:{port}"
|
|
133
|
+
|
|
134
|
+
# MSAL handles PKCE automatically via initiate_auth_code_flow
|
|
135
|
+
flow_kwargs: dict[str, str] = {}
|
|
136
|
+
if self._login_hint:
|
|
137
|
+
# Pre-fill the email and skip account picker (coming from WorkOS)
|
|
138
|
+
flow_kwargs["login_hint"] = self._login_hint
|
|
139
|
+
else:
|
|
140
|
+
# Standalone browser auth — show account picker
|
|
141
|
+
flow_kwargs["prompt"] = "select_account"
|
|
142
|
+
|
|
143
|
+
flow = app.initiate_auth_code_flow(
|
|
144
|
+
scopes=[BC_SCOPE],
|
|
145
|
+
redirect_uri=redirect_uri,
|
|
146
|
+
**flow_kwargs,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if "auth_uri" not in flow:
|
|
150
|
+
raise AuthError(
|
|
151
|
+
f"Failed to initiate browser auth: {flow.get('error_description', 'Unknown error')}",
|
|
152
|
+
status_code=401,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Start localhost server to catch the callback
|
|
156
|
+
auth_response: dict[str, Any] = {}
|
|
157
|
+
server_error: list[str] = []
|
|
158
|
+
|
|
159
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
160
|
+
def do_GET(self) -> None:
|
|
161
|
+
parsed = urlparse(self.path)
|
|
162
|
+
params = parse_qs(parsed.query)
|
|
163
|
+
# Flatten single-value params
|
|
164
|
+
auth_response.update({k: v[0] if len(v) == 1 else v for k, v in params.items()})
|
|
165
|
+
|
|
166
|
+
self.send_response(200)
|
|
167
|
+
self.send_header("Content-Type", "text/html")
|
|
168
|
+
self.end_headers()
|
|
169
|
+
|
|
170
|
+
if "error" in params:
|
|
171
|
+
error_msg = params.get("error_description", params.get("error", ["Unknown"]))[0]
|
|
172
|
+
server_error.append(error_msg)
|
|
173
|
+
self.wfile.write(
|
|
174
|
+
b"<html><body><h2>Authentication failed</h2>"
|
|
175
|
+
b"<p>You can close this tab.</p></body></html>"
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
self.wfile.write(
|
|
179
|
+
b"<html><body><h2>Authenticated successfully</h2>"
|
|
180
|
+
b"<p>You can close this tab and return to the terminal.</p></body></html>"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
184
|
+
pass # Suppress HTTP server logs
|
|
185
|
+
|
|
186
|
+
server = HTTPServer(("127.0.0.1", port), CallbackHandler)
|
|
187
|
+
server.timeout = _AUTH_TIMEOUT
|
|
188
|
+
|
|
189
|
+
# Open browser
|
|
190
|
+
auth_url = flow["auth_uri"]
|
|
191
|
+
mode = " (incognito)" if self._incognito else ""
|
|
192
|
+
print(f"\nOpening browser for authentication{mode}...", file=sys.stderr)
|
|
193
|
+
print(f"If the browser doesn't open, visit:\n {auth_url}\n", file=sys.stderr)
|
|
194
|
+
_open_browser(auth_url, incognito=self._incognito)
|
|
195
|
+
|
|
196
|
+
# Wait for single callback request
|
|
197
|
+
server_thread = threading.Thread(target=server.handle_request, daemon=True)
|
|
198
|
+
server_thread.start()
|
|
199
|
+
server_thread.join(timeout=_AUTH_TIMEOUT)
|
|
200
|
+
server.server_close()
|
|
201
|
+
|
|
202
|
+
if not auth_response:
|
|
203
|
+
raise AuthError(
|
|
204
|
+
f"Browser authentication timed out after {_AUTH_TIMEOUT} seconds. "
|
|
205
|
+
"Try 'bcli auth login --method device' as a fallback.",
|
|
206
|
+
status_code=401,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if server_error:
|
|
210
|
+
raise AuthError(
|
|
211
|
+
f"Browser authentication failed: {server_error[0]}",
|
|
212
|
+
status_code=401,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Exchange auth code for token
|
|
216
|
+
result = app.acquire_token_by_auth_code_flow(flow, auth_response)
|
|
217
|
+
|
|
218
|
+
if "access_token" not in result:
|
|
219
|
+
error_desc = result.get("error_description", result.get("error", "Unknown error"))
|
|
220
|
+
raise AuthError(f"Token acquisition failed: {error_desc}", status_code=401)
|
|
221
|
+
|
|
222
|
+
self._cache_token(result)
|
|
223
|
+
logger.info("Acquired BC API token via browser auth flow")
|
|
224
|
+
return result["access_token"]
|
|
225
|
+
|
|
226
|
+
def _cache_token(self, result: dict) -> None:
|
|
227
|
+
"""Cache the token to disk."""
|
|
228
|
+
access_token = result["access_token"]
|
|
229
|
+
expires_in = result.get("expires_in", 3600)
|
|
230
|
+
self._token_cache.put(self._tenant_id, self._client_id, access_token, expires_in)
|
|
231
|
+
|
|
232
|
+
def clear_cache(self) -> None:
|
|
233
|
+
"""Clear cached tokens for this tenant/client."""
|
|
234
|
+
self._token_cache.clear(self._tenant_id, self._client_id)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Client credentials auth flow using MSAL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import msal
|
|
9
|
+
|
|
10
|
+
from bcli.auth._token_cache import TokenCache
|
|
11
|
+
from bcli.config._defaults import BC_SCOPE, ENTRA_AUTHORITY_BASE
|
|
12
|
+
from bcli.errors import AuthError, ConfigError
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _try_keyring_get(service: str, username: str) -> str | None:
|
|
18
|
+
"""Try to get a secret from the OS keychain. Returns None if keyring unavailable."""
|
|
19
|
+
try:
|
|
20
|
+
import keyring
|
|
21
|
+
|
|
22
|
+
return keyring.get_password(service, username)
|
|
23
|
+
except Exception:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _try_keyring_set(service: str, username: str, password: str) -> bool:
|
|
28
|
+
"""Try to store a secret in the OS keychain. Returns True on success."""
|
|
29
|
+
try:
|
|
30
|
+
import keyring
|
|
31
|
+
|
|
32
|
+
keyring.set_password(service, username, password)
|
|
33
|
+
return True
|
|
34
|
+
except Exception:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _try_keyring_delete(service: str, username: str) -> bool:
|
|
39
|
+
"""Try to delete a secret from the OS keychain."""
|
|
40
|
+
try:
|
|
41
|
+
import keyring
|
|
42
|
+
|
|
43
|
+
keyring.delete_password(service, username)
|
|
44
|
+
return True
|
|
45
|
+
except Exception:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
KEYRING_SERVICE = "bcli"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ClientCredentialsAuth:
|
|
53
|
+
"""Service-to-service auth via OAuth2 client credentials flow.
|
|
54
|
+
|
|
55
|
+
Secret resolution is lazy — only resolved when a new token is needed.
|
|
56
|
+
Resolution order:
|
|
57
|
+
1. Direct client_secret parameter
|
|
58
|
+
2. OS keychain (via keyring library)
|
|
59
|
+
3. Environment variable (client_secret_env)
|
|
60
|
+
4. Error
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
tenant_id: str,
|
|
67
|
+
client_id: str,
|
|
68
|
+
client_secret: str | None = None,
|
|
69
|
+
client_secret_env: str | None = None,
|
|
70
|
+
token_cache: TokenCache | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
self._tenant_id = tenant_id
|
|
73
|
+
self._client_id = client_id
|
|
74
|
+
self._token_cache = token_cache or TokenCache()
|
|
75
|
+
self._client_secret = client_secret # May be None — resolved lazily
|
|
76
|
+
self._client_secret_env = client_secret_env
|
|
77
|
+
self._authority = f"{ENTRA_AUTHORITY_BASE}/{tenant_id}"
|
|
78
|
+
|
|
79
|
+
def _resolve_secret(self) -> str:
|
|
80
|
+
"""Resolve the client secret. Only called when a new token is needed."""
|
|
81
|
+
# 1. Already provided directly
|
|
82
|
+
if self._client_secret:
|
|
83
|
+
return self._client_secret
|
|
84
|
+
|
|
85
|
+
# 2. Try OS keychain
|
|
86
|
+
keyring_key = f"{self._tenant_id}:{self._client_id}"
|
|
87
|
+
secret = _try_keyring_get(KEYRING_SERVICE, keyring_key)
|
|
88
|
+
if secret:
|
|
89
|
+
logger.debug("Resolved client secret from OS keychain")
|
|
90
|
+
return secret
|
|
91
|
+
|
|
92
|
+
# 3. Try environment variable
|
|
93
|
+
if self._client_secret_env:
|
|
94
|
+
secret = os.environ.get(self._client_secret_env)
|
|
95
|
+
if secret:
|
|
96
|
+
return secret
|
|
97
|
+
|
|
98
|
+
# 4. Try generic fallback env var
|
|
99
|
+
secret = os.environ.get("BCLI_CLIENT_SECRET") or os.environ.get("BCLI_SECRET")
|
|
100
|
+
if secret:
|
|
101
|
+
return secret
|
|
102
|
+
|
|
103
|
+
# Nothing found
|
|
104
|
+
hints = []
|
|
105
|
+
hints.append("bcli auth store-secret (saves to OS keychain)")
|
|
106
|
+
if self._client_secret_env:
|
|
107
|
+
hints.append(f"export {self._client_secret_env}=<secret>")
|
|
108
|
+
raise ConfigError(
|
|
109
|
+
"No client secret found. Options:\n " + "\n ".join(hints)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
async def get_access_token(self) -> str:
|
|
113
|
+
"""Get a valid access token, using cache if available."""
|
|
114
|
+
# Check disk cache first — no secret needed
|
|
115
|
+
cached = self._token_cache.get(self._tenant_id, self._client_id)
|
|
116
|
+
if cached:
|
|
117
|
+
return cached
|
|
118
|
+
|
|
119
|
+
# Need a new token — resolve secret now
|
|
120
|
+
secret = self._resolve_secret()
|
|
121
|
+
|
|
122
|
+
app = msal.ConfidentialClientApplication(
|
|
123
|
+
client_id=self._client_id,
|
|
124
|
+
authority=self._authority,
|
|
125
|
+
client_credential=secret,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
result = app.acquire_token_for_client(scopes=[BC_SCOPE])
|
|
129
|
+
|
|
130
|
+
if "access_token" not in result:
|
|
131
|
+
error_desc = result.get("error_description", result.get("error", "Unknown error"))
|
|
132
|
+
raise AuthError(
|
|
133
|
+
f"Failed to acquire token: {error_desc}",
|
|
134
|
+
status_code=401,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
access_token = result["access_token"]
|
|
138
|
+
expires_in = result.get("expires_in", 3600)
|
|
139
|
+
|
|
140
|
+
self._token_cache.put(self._tenant_id, self._client_id, access_token, expires_in)
|
|
141
|
+
|
|
142
|
+
logger.info("Acquired new BC API access token")
|
|
143
|
+
return access_token
|
|
144
|
+
|
|
145
|
+
def clear_cache(self) -> None:
|
|
146
|
+
"""Clear cached tokens for this tenant/client."""
|
|
147
|
+
self._token_cache.clear(self._tenant_id, self._client_id)
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def store_secret(tenant_id: str, client_id: str, secret: str) -> bool:
|
|
151
|
+
"""Store a client secret in the OS keychain."""
|
|
152
|
+
keyring_key = f"{tenant_id}:{client_id}"
|
|
153
|
+
return _try_keyring_set(KEYRING_SERVICE, keyring_key, secret)
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def delete_secret(tenant_id: str, client_id: str) -> bool:
|
|
157
|
+
"""Delete a client secret from the OS keychain."""
|
|
158
|
+
keyring_key = f"{tenant_id}:{client_id}"
|
|
159
|
+
return _try_keyring_delete(KEYRING_SERVICE, keyring_key)
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def has_keyring() -> bool:
|
|
163
|
+
"""Check if the keyring library is available."""
|
|
164
|
+
try:
|
|
165
|
+
import keyring # noqa: F401
|
|
166
|
+
|
|
167
|
+
return True
|
|
168
|
+
except ImportError:
|
|
169
|
+
return False
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Device code auth flow for interactive CLI use."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import msal
|
|
9
|
+
|
|
10
|
+
from bcli.auth._token_cache import TokenCache
|
|
11
|
+
from bcli.config._defaults import BC_SCOPE, ENTRA_AUTHORITY_BASE
|
|
12
|
+
from bcli.errors import AuthError
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DeviceCodeAuth:
|
|
18
|
+
"""Interactive device code flow — user authenticates via browser.
|
|
19
|
+
|
|
20
|
+
Used for CLI interactive sessions where the user is present.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
tenant_id: str,
|
|
27
|
+
client_id: str,
|
|
28
|
+
token_cache: TokenCache | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._tenant_id = tenant_id
|
|
31
|
+
self._client_id = client_id
|
|
32
|
+
self._token_cache = token_cache or TokenCache()
|
|
33
|
+
self._authority = f"{ENTRA_AUTHORITY_BASE}/{tenant_id}"
|
|
34
|
+
|
|
35
|
+
async def get_access_token(self) -> str:
|
|
36
|
+
"""Get a valid access token, using cache or device code flow."""
|
|
37
|
+
# Check disk cache first
|
|
38
|
+
cached = self._token_cache.get(self._tenant_id, self._client_id)
|
|
39
|
+
if cached:
|
|
40
|
+
return cached
|
|
41
|
+
|
|
42
|
+
# Build MSAL public client (no client_secret needed)
|
|
43
|
+
app = msal.PublicClientApplication(
|
|
44
|
+
client_id=self._client_id,
|
|
45
|
+
authority=self._authority,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Try silent acquisition first (MSAL in-memory cache from prior flows)
|
|
49
|
+
accounts = app.get_accounts()
|
|
50
|
+
if accounts:
|
|
51
|
+
result = app.acquire_token_silent(
|
|
52
|
+
scopes=[BC_SCOPE],
|
|
53
|
+
account=accounts[0],
|
|
54
|
+
)
|
|
55
|
+
if result and "access_token" in result:
|
|
56
|
+
self._cache_token(result)
|
|
57
|
+
return result["access_token"]
|
|
58
|
+
|
|
59
|
+
# Initiate device code flow
|
|
60
|
+
flow = app.initiate_device_flow(scopes=[BC_SCOPE])
|
|
61
|
+
|
|
62
|
+
if "user_code" not in flow:
|
|
63
|
+
raise AuthError(
|
|
64
|
+
f"Device code flow failed: {flow.get('error_description', 'Unknown error')}",
|
|
65
|
+
status_code=401,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Print the device code message for the user
|
|
69
|
+
print(f"\n{flow['message']}\n", file=sys.stderr)
|
|
70
|
+
|
|
71
|
+
# Block until user completes browser auth
|
|
72
|
+
result = app.acquire_token_by_device_flow(flow)
|
|
73
|
+
|
|
74
|
+
if "access_token" not in result:
|
|
75
|
+
error_desc = result.get("error_description", result.get("error", "Unknown error"))
|
|
76
|
+
raise AuthError(f"Device code auth failed: {error_desc}", status_code=401)
|
|
77
|
+
|
|
78
|
+
self._cache_token(result)
|
|
79
|
+
logger.info("Acquired BC API token via device code flow")
|
|
80
|
+
return result["access_token"]
|
|
81
|
+
|
|
82
|
+
def _cache_token(self, result: dict) -> None:
|
|
83
|
+
"""Cache the token to disk."""
|
|
84
|
+
access_token = result["access_token"]
|
|
85
|
+
expires_in = result.get("expires_in", 3600)
|
|
86
|
+
self._token_cache.put(self._tenant_id, self._client_id, access_token, expires_in)
|
|
87
|
+
|
|
88
|
+
def clear_cache(self) -> None:
|
|
89
|
+
"""Clear cached tokens for this tenant/client."""
|
|
90
|
+
self._token_cache.clear(self._tenant_id, self._client_id)
|