Skip to content

Commit 201aa8f

Browse files
committed
Reorganized and added auth command.
Moved auth code to separate file and removed some cruft. Also added a dedicated auth command to refresh Oauth tokens without required reconfiguration.
1 parent 6ff023c commit 201aa8f

5 files changed

Lines changed: 322 additions & 353 deletions

File tree

README.md

Lines changed: 2 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ Instead of using `datacustomcode configure`, you can also set credentials via en
214214
|----------|-------------|
215215
| `SFDC_CLIENT_SECRET` | External Client App Client Secret |
216216
| `SFDC_REFRESH_TOKEN` | OAuth refresh token |
217-
| `SFDC_CORE_TOKEN` | (Optional) OAuth core/access token |
217+
| `SFDC_ACCESS_TOKEN` | (Optional) OAuth core/access token |
218218

219219
Example usage:
220220
```bash
@@ -367,68 +367,7 @@ You now have all fields necessary for the `datacustomcode configure` command.
367367

368368
### Obtaining Refresh Token and Core Token
369369

370-
If you're using OAuth Tokens authentication (instead of Username/Password), follow these steps to obtain your refresh token and core token (access token).
371-
372-
#### Step 1: Note External Client App Details
373-
374-
From your External Client App, note down the following:
375-
- **Client ID**
376-
- **Client Secret**
377-
- **Callback URL** (e.g., `http://localhost:55555/callback`)
378-
379-
#### Step 2: Obtain Authorization Code
380-
381-
1. Open a browser and navigate to the following URL (replace placeholders with your values):
382-
383-
```
384-
<LOGIN_URL>/services/oauth2/authorize?response_type=code&client_id=<CLIENT_ID>&redirect_uri=<CALLBACK_URL>
385-
```
386-
387-
2. After authenticating, you'll be redirected to your callback URL. The redirected URL will be in the form:
388-
```
389-
<CALLBACK_URL>?code=<CODE>
390-
```
391-
392-
3. Extract the `<CODE>` from the address bar. If the address bar doesn't show it, check the **Network tab** in your browser's developer tools.
393-
394-
#### Step 3: Exchange Code for Tokens
395-
396-
Make a POST request to exchange the authorization code for tokens. You can use `curl` or Postman:
397-
398-
```bash
399-
curl --location --request POST '<LOGIN_URL>/services/oauth2/token' \
400-
--header 'Content-Type: application/x-www-form-urlencoded' \
401-
--data-urlencode 'grant_type=authorization_code' \
402-
--data-urlencode 'code=<CODE>' \
403-
--data-urlencode 'client_id=<CLIENT_ID>' \
404-
--data-urlencode 'client_secret=<CLIENT_SECRET>' \
405-
--data-urlencode 'redirect_uri=<CALLBACK_URL>'
406-
```
407-
408-
The response will be a JSON object containing:
409-
410-
```json
411-
{
412-
"access_token": "<access_token>",
413-
"refresh_token": "<refresh_token>",
414-
"signature": "<signature>",
415-
"scope": "refresh_token cdp_query_api api cdp_profile_api cdp_api full",
416-
"id_token": "<id_token>",
417-
"instance_url": "https://your-instance.my.salesforce.com",
418-
"id": "https://login.salesforce.com/id/00DSB.../005SB...",
419-
"token_type": "Bearer",
420-
"issued_at": "1767743916187"
421-
}
422-
```
423-
424-
The key fields you need are:
425-
| Field | Description |
426-
|-------|-------------|
427-
| `access_token` | The **core token** (also called access token) |
428-
| `refresh_token` | The **refresh token** for obtaining new access tokens |
429-
| `instance_url` | Your Salesforce instance URL |
430-
431-
Use the `refresh_token` value when running `datacustomcode configure` with OAuth Tokens authentication.
370+
If you're using OAuth Tokens authentication, the initial configure will retrieve and store tokens. Run `datacustomcode auth` to refresh these when they expire.
432371

433372
## Other docs
434373

src/datacustomcode/auth.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
# Copyright (c) 2025, Salesforce, Inc.
2+
# SPDX-License-Identifier: Apache-2
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
import http.server
16+
import queue
17+
import socketserver
18+
import threading
19+
import time
20+
from typing import Any
21+
from urllib.parse import parse_qs, urlparse
22+
import webbrowser
23+
24+
import click
25+
import requests
26+
27+
28+
class OAuthCallbackHandler(http.server.SimpleHTTPRequestHandler):
29+
"""HTTP request handler to capture OAuth callback."""
30+
31+
def __init__(self, *args, auth_code_queue=None, **kwargs):
32+
self.auth_code_queue = auth_code_queue
33+
super().__init__(*args, **kwargs)
34+
35+
def do_GET(self):
36+
"""Handle GET request from OAuth callback."""
37+
parsed_path = urlparse(self.path)
38+
query_params = parse_qs(parsed_path.query)
39+
40+
if "code" in query_params:
41+
auth_code = query_params["code"][0]
42+
self.auth_code_queue.put(auth_code)
43+
self.send_response(200)
44+
self.send_header("Content-type", "text/html")
45+
self.end_headers()
46+
self.wfile.write(
47+
b"<html><body><h1>Authentication successful!</h1>"
48+
b"<p>You can close this window and return to the terminal.</p>"
49+
b"</body></html>"
50+
)
51+
elif "error" in query_params:
52+
error = query_params["error"][0]
53+
error_description = query_params.get("error_description", [""])[0]
54+
self.auth_code_queue.put(f"ERROR:{error}:{error_description}")
55+
self.send_response(400)
56+
self.send_header("Content-type", "text/html")
57+
self.end_headers()
58+
self.wfile.write(
59+
f"<html><body><h1>Authentication failed</h1>"
60+
f"<p>Error: {error}</p>"
61+
f"<p>{error_description}</p></body></html>".encode()
62+
)
63+
else:
64+
self.send_response(400)
65+
self.send_header("Content-type", "text/html")
66+
self.end_headers()
67+
self.wfile.write(b"<html><body><h1>Invalid callback</h1></body></html>")
68+
69+
def log_message(self, format, *args):
70+
"""Suppress default logging."""
71+
72+
73+
def _run_oauth_callback_server(
74+
redirect_uri: str, auth_code_queue: "queue.Queue[str]"
75+
) -> tuple[socketserver.TCPServer, int]:
76+
"""Start a local HTTP server to catch OAuth callback.
77+
78+
Args:
79+
redirect_uri: The redirect URI configured in the OAuth app
80+
auth_code_queue: Queue to put the authorization code in
81+
82+
Returns:
83+
Tuple of (server instance, actual port number)
84+
"""
85+
parsed_uri = urlparse(redirect_uri)
86+
host = parsed_uri.hostname or "localhost"
87+
port = parsed_uri.port or 5555
88+
89+
# Create a custom handler factory
90+
def handler_factory(*args, **kwargs):
91+
return OAuthCallbackHandler(*args, auth_code_queue=auth_code_queue, **kwargs)
92+
93+
server = socketserver.TCPServer((host, port), handler_factory)
94+
server.allow_reuse_address = True
95+
96+
def serve():
97+
server.serve_forever()
98+
99+
server_thread = threading.Thread(target=serve, daemon=True)
100+
server_thread.start()
101+
102+
# Wait a moment for server to start
103+
time.sleep(0.5)
104+
105+
return server, port
106+
107+
108+
def _exchange_code_for_tokens(
109+
login_url: str,
110+
client_id: str,
111+
client_secret: str,
112+
redirect_uri: str,
113+
auth_code: str,
114+
) -> Any:
115+
"""Exchange authorization code for access and refresh tokens.
116+
117+
Args:
118+
login_url: Salesforce login URL
119+
client_id: OAuth client ID
120+
client_secret: OAuth client secret
121+
redirect_uri: Redirect URI used in authorization
122+
auth_code: Authorization code from callback
123+
124+
Returns:
125+
Dictionary containing access_token and refresh_token
126+
127+
Raises:
128+
click.ClickException: If token exchange fails
129+
"""
130+
token_url = f"{login_url.rstrip('/')}/services/oauth2/token"
131+
data = {
132+
"grant_type": "authorization_code",
133+
"code": auth_code,
134+
"client_id": client_id,
135+
"client_secret": client_secret,
136+
"redirect_uri": redirect_uri,
137+
}
138+
139+
try:
140+
response = requests.post(token_url, data=data, timeout=30)
141+
response.raise_for_status()
142+
return response.json()
143+
except requests.exceptions.RequestException as e:
144+
raise click.ClickException(
145+
f"Failed to exchange authorization code for tokens: {e}"
146+
) from e
147+
148+
149+
def perform_oauth_browser_flow(
150+
login_url: str, client_id: str, client_secret: str, redirect_uri: str
151+
) -> tuple[str, str]:
152+
"""Perform OAuth browser flow to obtain tokens.
153+
154+
Args:
155+
login_url: Salesforce login URL
156+
client_id: OAuth client ID
157+
client_secret: OAuth client secret
158+
redirect_uri: Redirect URI configured in OAuth app
159+
160+
Returns:
161+
Tuple of (refresh_token, access_token)
162+
163+
Raises:
164+
click.ClickException: If OAuth flow fails
165+
"""
166+
# Parse redirect_uri and ensure it has a port
167+
parsed_redirect = urlparse(redirect_uri)
168+
if not parsed_redirect.port:
169+
# If no port specified, default to 5555 and update redirect_uri
170+
default_port = 5555
171+
redirect_uri = f"{parsed_redirect.scheme}://{parsed_redirect.hostname}:{default_port}{parsed_redirect.path}"
172+
173+
# Create queue for communication between server and main thread
174+
auth_code_queue: queue.Queue[str] = queue.Queue()
175+
176+
# Start callback server
177+
click.echo(f"\nStarting local callback server on {redirect_uri}...")
178+
server, actual_port = _run_oauth_callback_server(redirect_uri, auth_code_queue)
179+
180+
# Build authorization URL with final redirect_uri
181+
auth_url = (
182+
f"{login_url.rstrip('/')}/services/oauth2/authorize"
183+
f"?response_type=code"
184+
f"&client_id={client_id}"
185+
f"&redirect_uri={redirect_uri}"
186+
)
187+
188+
# Open browser
189+
click.echo("Opening browser for authentication...")
190+
click.echo(f"If the browser doesn't open automatically, visit:\n{auth_url}\n")
191+
webbrowser.open(auth_url)
192+
193+
# Wait for callback (with timeout)
194+
click.echo("Waiting for authentication...")
195+
try:
196+
result = auth_code_queue.get(timeout=60) # 1 minute timeout
197+
except queue.Empty:
198+
server.shutdown()
199+
raise click.ClickException(
200+
"Authentication timeout. Please try again."
201+
) from None
202+
203+
# Shutdown server
204+
server.shutdown()
205+
206+
# Check for errors
207+
if result.startswith("ERROR:"):
208+
_, error, error_description = result.split(":", 2)
209+
raise click.ClickException(f"OAuth error: {error}. {error_description}")
210+
211+
auth_code = result
212+
213+
# Exchange code for tokens
214+
click.echo("Exchanging authorization code for tokens...")
215+
token_response = _exchange_code_for_tokens(
216+
login_url, client_id, client_secret, redirect_uri, auth_code
217+
)
218+
219+
refresh_token = token_response.get("refresh_token")
220+
access_token = token_response.get("access_token")
221+
222+
if not refresh_token:
223+
raise click.ClickException(
224+
"No refresh_token in response. Please check your OAuth app configuration."
225+
)
226+
227+
return refresh_token, access_token
228+
229+
230+
def configure_oauth_tokens(
231+
login_url: str,
232+
client_id: str,
233+
client_secret: str,
234+
redirect_uri: str,
235+
profile: str,
236+
) -> None:
237+
"""Configure credentials for OAuth Tokens authentication."""
238+
from datacustomcode.credentials import AuthType, Credentials
239+
240+
# Perform OAuth browser flow
241+
try:
242+
refresh_token, access_token = perform_oauth_browser_flow(
243+
login_url, client_id, client_secret, redirect_uri
244+
)
245+
except click.ClickException as e:
246+
click.secho(f"Error: {e}", fg="red")
247+
raise click.Abort() from None
248+
249+
credentials = Credentials(
250+
login_url=login_url,
251+
client_id=client_id,
252+
auth_type=AuthType.OAUTH_TOKENS,
253+
client_secret=client_secret,
254+
refresh_token=refresh_token,
255+
access_token=access_token,
256+
redirect_uri=redirect_uri,
257+
)
258+
credentials.update_ini(profile=profile)
259+
click.secho(
260+
f"OAuth Tokens credentials saved to profile '{profile}' successfully",
261+
fg="green",
262+
)

0 commit comments

Comments
 (0)