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.
Files changed (78) hide show
  1. bc_cli-0.1.0.dist-info/METADATA +224 -0
  2. bc_cli-0.1.0.dist-info/RECORD +78 -0
  3. bc_cli-0.1.0.dist-info/WHEEL +4 -0
  4. bc_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. bc_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  6. bc_cli-0.1.0.dist-info/licenses/NOTICE +10 -0
  7. bcli/__init__.py +44 -0
  8. bcli/_url.py +133 -0
  9. bcli/_version.py +1 -0
  10. bcli/auth/__init__.py +8 -0
  11. bcli/auth/_base.py +17 -0
  12. bcli/auth/_browser.py +234 -0
  13. bcli/auth/_credentials.py +169 -0
  14. bcli/auth/_device_code.py +90 -0
  15. bcli/auth/_secure_io.py +155 -0
  16. bcli/auth/_token_cache.py +93 -0
  17. bcli/auth/_workos.py +279 -0
  18. bcli/client/__init__.py +6 -0
  19. bcli/client/_async.py +566 -0
  20. bcli/client/_safety.py +179 -0
  21. bcli/client/_sync.py +171 -0
  22. bcli/client/_transport.py +303 -0
  23. bcli/config/__init__.py +6 -0
  24. bcli/config/_defaults.py +47 -0
  25. bcli/config/_loader.py +190 -0
  26. bcli/config/_model.py +206 -0
  27. bcli/errors.py +69 -0
  28. bcli/etl/__init__.py +63 -0
  29. bcli/etl/_auth.py +75 -0
  30. bcli/etl/_bridge.py +122 -0
  31. bcli/etl/_client.py +177 -0
  32. bcli/etl/_generic.py +220 -0
  33. bcli/etl/_polaris.py +170 -0
  34. bcli/etl/_stampers.py +74 -0
  35. bcli/odata/__init__.py +8 -0
  36. bcli/odata/_escape.py +32 -0
  37. bcli/odata/_filter_fields.py +137 -0
  38. bcli/odata/_pagination.py +42 -0
  39. bcli/odata/_query.py +117 -0
  40. bcli/odata/_response.py +46 -0
  41. bcli/py.typed +0 -0
  42. bcli/registry/__init__.py +12 -0
  43. bcli/registry/_importers.py +335 -0
  44. bcli/registry/_registry.py +148 -0
  45. bcli/registry/_schema.py +49 -0
  46. bcli/registry/standard_v2.json +85 -0
  47. bcli/telemetry/__init__.py +37 -0
  48. bcli/telemetry/_azure_monitor.py +103 -0
  49. bcli/telemetry/_factory.py +112 -0
  50. bcli/telemetry/_protocol.py +83 -0
  51. bcli/telemetry/events.py +271 -0
  52. bcli/workflow/__init__.py +19 -0
  53. bcli/workflow/_models.py +68 -0
  54. bcli/workflow/_resolver.py +131 -0
  55. bcli_cli/__init__.py +1 -0
  56. bcli_cli/_safety.py +73 -0
  57. bcli_cli/_state.py +113 -0
  58. bcli_cli/app.py +193 -0
  59. bcli_cli/commands/__init__.py +1 -0
  60. bcli_cli/commands/auth_cmd.py +182 -0
  61. bcli_cli/commands/batch_cmd.py +386 -0
  62. bcli_cli/commands/beautech_cmd.py +214 -0
  63. bcli_cli/commands/company_cmd.py +229 -0
  64. bcli_cli/commands/config_cmd.py +380 -0
  65. bcli_cli/commands/context_cmd.py +99 -0
  66. bcli_cli/commands/delete_cmd.py +52 -0
  67. bcli_cli/commands/endpoint_cmd.py +197 -0
  68. bcli_cli/commands/env_cmd.py +86 -0
  69. bcli_cli/commands/etl_cmd.py +218 -0
  70. bcli_cli/commands/get_cmd.py +266 -0
  71. bcli_cli/commands/patch_cmd.py +68 -0
  72. bcli_cli/commands/post_cmd.py +67 -0
  73. bcli_cli/commands/query_cmd.py +474 -0
  74. bcli_cli/commands/registry_cmd.py +135 -0
  75. bcli_cli/commands/test_cmd.py +95 -0
  76. bcli_cli/output/__init__.py +6 -0
  77. bcli_cli/output/_display.py +28 -0
  78. 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)