@sassoftware/sas-score-mcp-serverjs 0.4.1-5 → 0.4.1-7
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.
- package/cli.js +43 -1
- package/package.json +1 -1
- package/skills/mcp-tool-description-optimizer/SKILL.md +129 -0
- package/skills/mcp-tool-description-optimizer/references/examples.md +123 -0
- package/skills/sas-read-and-score/SKILL.md +91 -0
- package/skills/sas-read-strategy/SKILL.md +143 -0
- package/skills/sas-score-workflow/SKILL.md +300 -0
- package/src/authpkce.js +220 -0
- package/src/expressMcpServer.js +484 -336
- package/src/toolSet/.claude/settings.local.json +12 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sas-score-workflow
|
|
3
|
+
description: >
|
|
4
|
+
Guide the full model scoring workflow: validate model familiarity, route to appropriate scoring tool
|
|
5
|
+
based on model type, invoke scoring with scenario data, and present merged results. Use this skill
|
|
6
|
+
when the user wants to run predictions on data (already fetched or user-supplied). Supports generic
|
|
7
|
+
syntax: "score with model <name>.<type> scenario =<params>" where type is job|jobdef|mas|scr|sas.
|
|
8
|
+
Trigger phrases: "score these records", "predict using model", "run model on", "score with model X.mas".
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# SAS Score Workflow
|
|
12
|
+
|
|
13
|
+
Orchestrates model validation, type-based routing, scoring invocation, and result presentation.
|
|
14
|
+
Handles both MAS models and alternative scoring engines (jobs, jobdefs, SCR, SAS programs).
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Generic Scoring Syntax
|
|
19
|
+
|
|
20
|
+
Users can invoke scoring with a unified syntax that automatically routes to the correct tool:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
score with model <name>.<type> [scenario =<key=value pairs>]
|
|
24
|
+
score <name>.<type> [scenario =<key=value pairs>]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Type determines the routing:**
|
|
28
|
+
- `.job` → route to `run-job` with scoring parameters
|
|
29
|
+
- `.jobdef` → route to `run-jobdef` with scoring parameters
|
|
30
|
+
- `.mas` → route to `model-score` (Model Analytical Service — default)
|
|
31
|
+
- `.scr` → route to `scr-score` (SAS Container Runtime)
|
|
32
|
+
- `.sas` → route to `run-sas-program` to run sas program in folder
|
|
33
|
+
|
|
34
|
+
If no type is specified (bare model name), assume `.mas` (MAS model).
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Type-Based Routing
|
|
39
|
+
|
|
40
|
+
### Parse and Strip Model Type
|
|
41
|
+
|
|
42
|
+
When a user provides a model name with a type suffix (e.g., `simplejon.job`, `churn.mas`):
|
|
43
|
+
|
|
44
|
+
1. **Extract the type:** Split on the last dot to identify the type suffix
|
|
45
|
+
- `simplejon.job` → type = `job`, base name = `simplejon`
|
|
46
|
+
- `churn.mas` → type = `mas`, base name = `churn`
|
|
47
|
+
- `fraud_detector.jobdef` → type = `jobdef`, base name = `fraud_detector`
|
|
48
|
+
|
|
49
|
+
2. **Validate the type:** Confirm it matches one of the supported types: `job`, `jobdef`, `mas`, `scr`, `sas`
|
|
50
|
+
- If type is unrecognized, assume `.mas` (default MAS model) and treat the entire input as the model name
|
|
51
|
+
|
|
52
|
+
3. **Strip the type suffix:** Remove the `.type` from the model name before passing to the routing tool
|
|
53
|
+
- **Critical:** Always pass the base name (without the dot and type) to the invoked tool
|
|
54
|
+
- `simplejon.job` → pass `simplejon` to `run-job`
|
|
55
|
+
- `churn.mas` → pass `churn` to `model-score`
|
|
56
|
+
- `fraud_detector.jobdef` → pass `fraud_detector` to `run-jobdef`
|
|
57
|
+
|
|
58
|
+
### Type: `.mas` (Model Aggregation Service)
|
|
59
|
+
- **Tool**: `model-score`
|
|
60
|
+
- **Use for**: Standard MAS-deployed predictive models
|
|
61
|
+
- **Example**: `score with model churn.mas scenario =age=45,income=60000`
|
|
62
|
+
- **Invocation**: `model-score({ model: "churn", scenario: {...} })`
|
|
63
|
+
|
|
64
|
+
### Type: `.job` (SAS Viya Job)
|
|
65
|
+
- **Tool**: `run-job`
|
|
66
|
+
- **Use for**: Pre-built scoring jobs with parameters
|
|
67
|
+
- **Example**: `score with model monthly_scorer.job scenario =month=10,year=2025`
|
|
68
|
+
- **Invocation**: `run-job({ name: "monthly_scorer", scenario: {...} })`
|
|
69
|
+
|
|
70
|
+
### Type: `.jobdef` (SAS Viya Job Definition)
|
|
71
|
+
- **Tool**: `run-jobdef`
|
|
72
|
+
- **Use for**: Job definitions that perform scoring logic
|
|
73
|
+
- **Example**: `score with model fraud_detector.jobdef using amount=500,merchant=online`
|
|
74
|
+
- **Invocation**: `run-jobdef({ name: "fraud_detector", scenario: {...} })`
|
|
75
|
+
|
|
76
|
+
### Type: `.scr` (Score Code Runtime)
|
|
77
|
+
- **Tool**: `scr-score`
|
|
78
|
+
- **Use for**: Models deployed in SCR containers (REST endpoints)
|
|
79
|
+
- **Example**: `score https://scr-host/models/loan.scr using age=45,credit=700`
|
|
80
|
+
- **Invocation**: `scr-score({ url: "https://scr-host/models/loan", scenario: {...} })`
|
|
81
|
+
|
|
82
|
+
### Type: `.sas` (SAS Program / SQL)
|
|
83
|
+
- **Tool**: `run-sas-program`
|
|
84
|
+
- **Use for**: Custom SAS or SQL scoring code
|
|
85
|
+
- **Example**: `score my_scoring_code.sas using x=1,y=2`
|
|
86
|
+
- **Invocation**: `run-sas-program({ folder: "my_scoring_code", scenario: {...} })`
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Scenario Parsing
|
|
93
|
+
|
|
94
|
+
The scenario parameter (comma-separated key=value pairs) is parsed into an object:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
scenario =age=45,income=60000,region=South
|
|
98
|
+
↓ parsed as:
|
|
99
|
+
{ age: "45", income: "60000", region: "South" }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Accepted formats:
|
|
103
|
+
- **String**: `age=45,income=60000`
|
|
104
|
+
- **Object**: `{ age: 45, income: 60000 }`
|
|
105
|
+
- **Array** (batch): `[ {age:45, income:60000}, {age:50, income:75000} ]`
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Step 1 — Check model familiarity before scoring
|
|
110
|
+
|
|
111
|
+
Score immediately if:
|
|
112
|
+
- The user names a specific model they've used before in this session, OR
|
|
113
|
+
- The model name matches a previously confirmed model in the conversation
|
|
114
|
+
|
|
115
|
+
Pause and suggest investigation if:
|
|
116
|
+
- The model name is new, vague, or misspelled-looking (e.g. "the churn one", "that cancer model")
|
|
117
|
+
- The user seems unsure of the required input variable names
|
|
118
|
+
|
|
119
|
+
**Suggested message:**
|
|
120
|
+
> "I don't recognize that model — want me to run `find-model` to confirm it exists,
|
|
121
|
+
> or `model-info` to check its required inputs first?"
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Step 2 — Prepare the scenario data
|
|
126
|
+
|
|
127
|
+
**For a single record** (one object):
|
|
128
|
+
```javascript
|
|
129
|
+
scenario = { field1: value1, field2: value2, ... }
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**For batch scoring** (multiple records — the typical case):
|
|
133
|
+
```javascript
|
|
134
|
+
scenario = [
|
|
135
|
+
{ field1: val1, field2: val2, ... },
|
|
136
|
+
{ field1: val3, field2: val4, ... },
|
|
137
|
+
...
|
|
138
|
+
]
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Critical rules:**
|
|
142
|
+
- Loop or call model-score **once per row**.
|
|
143
|
+
- Field names in the scenario must match the model's expected input variable names **exactly**.
|
|
144
|
+
- If table column names differ from model input names, **flag this to the user** and ask for confirmation before scoring.
|
|
145
|
+
- Example: Table has `age_years`, but model expects `age` → ask user which column maps to which input.
|
|
146
|
+
- Do not add units, labels, or extra metadata — raw field values only.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Step 3 — Invoke the appropriate scoring tool
|
|
151
|
+
|
|
152
|
+
Based on the type extracted from the model name, invoke the corresponding tool:
|
|
153
|
+
|
|
154
|
+
**For `.mas` (default):**
|
|
155
|
+
```javascript
|
|
156
|
+
model-score({
|
|
157
|
+
model: "<modelname>",
|
|
158
|
+
scenario: scenario, // object or array
|
|
159
|
+
uflag: false // set true if you need field names prefixed with _
|
|
160
|
+
})
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**For `.job`:**
|
|
164
|
+
```javascript
|
|
165
|
+
run-job({
|
|
166
|
+
name: "<jobname>",
|
|
167
|
+
scenario: scenario
|
|
168
|
+
})
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**For `.jobdef`:**
|
|
172
|
+
```javascript
|
|
173
|
+
run-jobdef({
|
|
174
|
+
name: "<jobdefname>",
|
|
175
|
+
scenario: scenario
|
|
176
|
+
})
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**For `.scr`:**
|
|
180
|
+
```javascript
|
|
181
|
+
scr-score({
|
|
182
|
+
url: "<scr_endpoint_url>",
|
|
183
|
+
scenario: scenario
|
|
184
|
+
})
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
**For `.sas`:**
|
|
188
|
+
```javascript
|
|
189
|
+
run-sas-program({
|
|
190
|
+
src: "<sas_or_sql_code>",
|
|
191
|
+
scenario: scenario
|
|
192
|
+
})
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**Rules:**
|
|
196
|
+
- Pass the full batch in one call; do not loop over rows
|
|
197
|
+
- If scoring fails, return the structured error and suggest troubleshooting
|
|
198
|
+
- For MAS models, include uflag parameter if underscore-prefixed output is needed
|
|
199
|
+
- For jobs/jobdefs, scenario becomes parameter arguments
|
|
200
|
+
- For SCR, include full URL endpoint
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Step 4 — Present the results
|
|
205
|
+
|
|
206
|
+
Merge the scoring output back with the input records and present as a table where possible.
|
|
207
|
+
|
|
208
|
+
**Always surface:**
|
|
209
|
+
- The key prediction/score field(s) (e.g. `P_churn`, `score`, `prediction`, `P_risk`)
|
|
210
|
+
- Any probability/confidence fields for classification models (e.g. `P_class0`, `P_class1`)
|
|
211
|
+
- Selected input fields that drove the prediction, so the user can see context
|
|
212
|
+
|
|
213
|
+
**Formatting:**
|
|
214
|
+
- Present results in a table for clarity
|
|
215
|
+
- If results exceed 10 rows, show the first 10 and ask: *"Want to see more results or export the full set?"*
|
|
216
|
+
- Round numeric predictions to 2–4 decimal places for readability
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Common flows
|
|
221
|
+
|
|
222
|
+
**Flow A — Score rows with MAS model**
|
|
223
|
+
> "Score the first 10 customers in Public.customers with the churn model"
|
|
224
|
+
|
|
225
|
+
1. `read-table` → { table: "Public.customers", limit: 10 }
|
|
226
|
+
2. `model-score` → { model: "churn", scenario: [ ...10 row objects ] }
|
|
227
|
+
3. Present merged results with prediction + key inputs
|
|
228
|
+
|
|
229
|
+
**Flow B — Score with a scoring job**
|
|
230
|
+
> "Score December sales with the monthly_scorer job using month=12,year=2025"
|
|
231
|
+
|
|
232
|
+
1. `run-job` → { name: "monthly_scorer", scenario: { month: "12", year: "2025" } }
|
|
233
|
+
2. Capture job output and tables
|
|
234
|
+
3. Present results
|
|
235
|
+
|
|
236
|
+
**Flow C — Score with a job definition**
|
|
237
|
+
> "Run fraud detection jobdef on transaction amount=500, merchant=online"
|
|
238
|
+
|
|
239
|
+
1. `run-jobdef` → { name: "fraud_detection", scenario: { amount: "500", merchant: "online" } }
|
|
240
|
+
2. Capture log, listings, and tables
|
|
241
|
+
3. Present results
|
|
242
|
+
|
|
243
|
+
**Flow D — Score with SCR endpoint**
|
|
244
|
+
> "Score with the loan model at https://scr-host/models/loan using age=45, credit_score=700"
|
|
245
|
+
|
|
246
|
+
1. `scr-score` → { url: "https://scr-host/models/loan", scenario: { age: "45", credit_score: "700" } }
|
|
247
|
+
2. Capture prediction response
|
|
248
|
+
3. Present result
|
|
249
|
+
|
|
250
|
+
**Flow E — Score results of an analytical query with MAS**
|
|
251
|
+
> "Score high-value customers (spend > 5000) in mylib.sales with the fraud model"
|
|
252
|
+
|
|
253
|
+
1. `sas-query` → { table: "mylib.sales", sql: "SELECT * FROM mylib.sales WHERE spend > 5000" }
|
|
254
|
+
2. `model-score` → { model: "fraud", scenario: [ ...result rows ] }
|
|
255
|
+
3. Present merged results
|
|
256
|
+
|
|
257
|
+
**Flow F — User supplies scenario data directly**
|
|
258
|
+
> "Score age=45, income=60000, region=South with the churn model"
|
|
259
|
+
|
|
260
|
+
1. Skip read step
|
|
261
|
+
2. `model-score` → { model: "churn", scenario: { age: "45", income: "60000", region: "South" } }
|
|
262
|
+
3. Present result
|
|
263
|
+
|
|
264
|
+
**Flow G — Model unfamiliar, need to confirm**
|
|
265
|
+
> "Score Public.applicants with the creditRisk2 model"
|
|
266
|
+
|
|
267
|
+
1. Pause — "creditRisk2" is new
|
|
268
|
+
2. Suggest: `find-model` to confirm it exists, `model-info` to get input variables
|
|
269
|
+
3. Once confirmed → `read-table` + `model-score`
|
|
270
|
+
|
|
271
|
+
**Flow H — Generic score syntax with type routing**
|
|
272
|
+
> "score with model churn.mas scenario =age=45,income=60000"
|
|
273
|
+
> "score fraud_detector.jobdef where scenario =amount=500"
|
|
274
|
+
> "score monthly_report.job using month=10,year=2025"
|
|
275
|
+
|
|
276
|
+
1. Parse model name to extract type (.mas, .job, .jobdef, .scr, .sas)
|
|
277
|
+
2. Route to appropriate tool based on type
|
|
278
|
+
3. Parse scenario and invoke tool with parameters
|
|
279
|
+
4. Present results from routed tool
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Error handling
|
|
284
|
+
|
|
285
|
+
| Problem | Action |
|
|
286
|
+
|---|---|
|
|
287
|
+
| Model not found | Suggest `find-model` to verify the model is deployed |
|
|
288
|
+
| Input field name mismatch | Show the mismatch (table has X, model expects Y), ask user to confirm mapping |
|
|
289
|
+
| Scoring error / invalid inputs | Return structured error, suggest `model-info` to check required inputs and data types |
|
|
290
|
+
| Empty read result | Tell user, ask if they want to adjust the query/filter before scoring |
|
|
291
|
+
| Missing input fields | Ask which table columns map to the required model inputs |
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Tips
|
|
296
|
+
|
|
297
|
+
- **Batch is better:** Always pass the full set of records in one `model-score` call. Do not loop.
|
|
298
|
+
- **Confirm mappings:** If column names don't match model inputs, ask before scoring.
|
|
299
|
+
- **Show context:** Include key input columns in the result output so predictions make sense.
|
|
300
|
+
- **Limit output:** For large result sets (>10 rows), ask before showing all.
|
package/src/authpkce.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SAS Viya PKCE Authorization Flow
|
|
3
|
+
*
|
|
4
|
+
* Uses the Authorization Code flow with PKCE (RFC 7636).
|
|
5
|
+
* Starts a local HTTP server to capture the redirect callback.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* node sas-viya-pkce-auth.js
|
|
9
|
+
*
|
|
10
|
+
* Prerequisites:
|
|
11
|
+
* - A SAS Viya client registered with:
|
|
12
|
+
* grant_types: authorization_code
|
|
13
|
+
* redirect_uri: http://localhost:3000/callback
|
|
14
|
+
* PKCE enabled (no client_secret required)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import http from "http";
|
|
18
|
+
import https from "https";
|
|
19
|
+
import crypto from "crypto";
|
|
20
|
+
import url from "url";
|
|
21
|
+
|
|
22
|
+
function authpkce(_appContext) {
|
|
23
|
+
let {VIYA_SERVER,CLIENTID, PORT} = _appContext;
|
|
24
|
+
// ── Configuration ────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
const CONFIG = {
|
|
27
|
+
authBaseUrl: `${VIYA_SERVER}/oauth/SASLogon`,
|
|
28
|
+
clientId: CLIENTID, // <-- replace with your client ID
|
|
29
|
+
redirectUri: `http://localhost:${PORT}/callback`,
|
|
30
|
+
scopes: "openid profile",
|
|
31
|
+
localPort: PORT,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
main().catch((err) => {
|
|
35
|
+
console.error("Unexpected error:", err);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
// Generate a cryptographically random code verifier (43–128 chars, base64url)
|
|
42
|
+
function generateCodeVerifier() {
|
|
43
|
+
return crypto.randomBytes(64).toString("base64url");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Derive the code challenge: BASE64URL(SHA-256(verifier))
|
|
47
|
+
function generateCodeChallenge(verifier) {
|
|
48
|
+
return crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Build the SASLogon authorization URL
|
|
52
|
+
function buildAuthUrl(codeChallenge, state) {
|
|
53
|
+
const params = new URLSearchParams({
|
|
54
|
+
response_type: "code",
|
|
55
|
+
client_id: CONFIG.clientId,
|
|
56
|
+
redirect_uri: CONFIG.redirectUri,
|
|
57
|
+
scope: CONFIG.scopes,
|
|
58
|
+
state,
|
|
59
|
+
code_challenge: codeChallenge,
|
|
60
|
+
code_challenge_method: "S256",
|
|
61
|
+
});
|
|
62
|
+
return `${CONFIG.authBaseUrl}/authorize?${params}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Exchange the authorization code for tokens
|
|
66
|
+
function exchangeCodeForTokens(code, codeVerifier) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const body = new URLSearchParams({
|
|
69
|
+
grant_type: "authorization_code",
|
|
70
|
+
client_id: CONFIG.clientId,
|
|
71
|
+
redirect_uri: CONFIG.redirectUri,
|
|
72
|
+
code,
|
|
73
|
+
code_verifier: codeVerifier,
|
|
74
|
+
}).toString();
|
|
75
|
+
|
|
76
|
+
const tokenUrl = new URL(`${CONFIG.authBaseUrl}/token`);
|
|
77
|
+
|
|
78
|
+
const options = {
|
|
79
|
+
hostname: tokenUrl.hostname,
|
|
80
|
+
port: tokenUrl.port || 443,
|
|
81
|
+
path: tokenUrl.pathname,
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: {
|
|
84
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
85
|
+
"Content-Length": Buffer.byteLength(body),
|
|
86
|
+
},
|
|
87
|
+
// Remove the line below if your Viya instance has a valid TLS cert
|
|
88
|
+
rejectUnauthorized: false,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const req = https.request(options, (res) => {
|
|
92
|
+
let data = "";
|
|
93
|
+
res.on("data", (chunk) => (data += chunk));
|
|
94
|
+
res.on("end", () => {
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(data);
|
|
97
|
+
if (res.statusCode >= 400) {
|
|
98
|
+
reject(new Error(`Token error (${res.statusCode}): ${data}`));
|
|
99
|
+
} else {
|
|
100
|
+
resolve(parsed);
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
reject(new Error(`Failed to parse token response: ${data}`));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
req.on("error", reject);
|
|
109
|
+
req.write(body);
|
|
110
|
+
req.end();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Wait for the browser redirect and extract code + state
|
|
115
|
+
function waitForCallback(expectedState) {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
const server = http.createServer((req, res) => {
|
|
118
|
+
const parsed = url.parse(req.url, true);
|
|
119
|
+
|
|
120
|
+
if (parsed.pathname !== "/callback") {
|
|
121
|
+
res.writeHead(404);
|
|
122
|
+
res.end("Not found");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const { code, state, error, error_description } = parsed.query;
|
|
127
|
+
|
|
128
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
129
|
+
res.end(`
|
|
130
|
+
<html><body>
|
|
131
|
+
<h2>${error ? "Authorization failed" : "Authorization successful!"}</h2>
|
|
132
|
+
<p>You may close this tab.</p>
|
|
133
|
+
</body></html>
|
|
134
|
+
`);
|
|
135
|
+
|
|
136
|
+
server.close();
|
|
137
|
+
|
|
138
|
+
if (error) {
|
|
139
|
+
reject(new Error(`OAuth error: ${error} — ${error_description}`));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (state !== expectedState) {
|
|
143
|
+
reject(new Error("State mismatch — possible CSRF attack"));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
resolve(code);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
server.listen(CONFIG.localPort, () => {
|
|
151
|
+
console.error(`Listening on http://localhost:${CONFIG.localPort}/callback`);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
server.on("error", reject);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Main flow
|
|
159
|
+
async function main() {
|
|
160
|
+
const codeVerifier = generateCodeVerifier();
|
|
161
|
+
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
162
|
+
const state = crypto.randomBytes(16).toString("hex");
|
|
163
|
+
|
|
164
|
+
const authUrl = buildAuthUrl(codeChallenge, state);
|
|
165
|
+
|
|
166
|
+
console.error("\nOpen this URL in your browser to log in:\n");
|
|
167
|
+
console.error(authUrl);
|
|
168
|
+
console.error();
|
|
169
|
+
|
|
170
|
+
// Try to auto-open in the default browser (best-effort)
|
|
171
|
+
try {
|
|
172
|
+
const { exec } = require("child_process");
|
|
173
|
+
const cmd =
|
|
174
|
+
process.platform === "win32"
|
|
175
|
+
? `start "" "${authUrl}"`
|
|
176
|
+
: process.platform === "darwin"
|
|
177
|
+
? `open "${authUrl}"`
|
|
178
|
+
: `xdg-open "${authUrl}"`;
|
|
179
|
+
exec(cmd);
|
|
180
|
+
} catch {
|
|
181
|
+
// ignore — user can open manually
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let code;
|
|
185
|
+
try {
|
|
186
|
+
code = await waitForCallback(state);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.error("Callback error:", err.message);
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
console.error("Authorization code received. Exchanging for tokens...");
|
|
193
|
+
|
|
194
|
+
let tokens;
|
|
195
|
+
try {
|
|
196
|
+
tokens = await exchangeCodeForTokens(code, codeVerifier);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
console.error("Token exchange error:", err.message);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
console.error("\nTokens received:");
|
|
203
|
+
console.error(" access_token :", tokens.access_token?.slice(0, 40) + "...");
|
|
204
|
+
console.error(" token_type :", tokens.token_type);
|
|
205
|
+
console.error(" expires_in :", tokens.expires_in, "seconds");
|
|
206
|
+
if (tokens.refresh_token) {
|
|
207
|
+
console.error(" refresh_token:", tokens.refresh_token.slice(0, 20) + "...");
|
|
208
|
+
}
|
|
209
|
+
if (tokens.id_token) {
|
|
210
|
+
console.error(" id_token :", tokens.id_token.slice(0, 40) + "...");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// tokens.access_token is ready to use as a Bearer token for Viya REST APIs
|
|
214
|
+
return tokens.access_token
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
}
|