runline 0.29.0 → 0.30.0
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/dist/auth/errors.d.ts +22 -0
- package/dist/auth/errors.js +25 -0
- package/dist/auth/oauth2.d.ts +21 -0
- package/dist/auth/oauth2.js +154 -0
- package/dist/auth/token.d.ts +11 -0
- package/dist/auth/token.js +231 -0
- package/dist/auth/types.d.ts +93 -0
- package/dist/auth/types.js +1 -0
- package/dist/commands/auth.js +26 -13
- package/dist/commands/connection.js +3 -6
- package/dist/commands/exec.js +7 -2
- package/dist/commands/init.js +9 -2
- package/dist/config/loader.d.ts +7 -13
- package/dist/config/loader.js +58 -88
- package/dist/config/store.d.ts +17 -0
- package/dist/config/store.js +85 -0
- package/dist/connections/file.d.ts +13 -0
- package/dist/connections/file.js +53 -0
- package/dist/connections/memory.d.ts +15 -0
- package/dist/connections/memory.js +70 -0
- package/dist/connections/types.d.ts +47 -0
- package/dist/connections/types.js +10 -0
- package/dist/core/engine.d.ts +5 -2
- package/dist/core/engine.js +48 -55
- package/dist/core/oauth.d.ts +20 -15
- package/dist/core/oauth.js +195 -98
- package/dist/credentials/http.d.ts +19 -0
- package/dist/credentials/http.js +109 -0
- package/dist/credentials/policy.d.ts +5 -0
- package/dist/credentials/policy.js +92 -0
- package/dist/credentials/registry.d.ts +31 -0
- package/dist/credentials/registry.js +171 -0
- package/dist/credentials/transport.d.ts +38 -0
- package/dist/credentials/transport.js +283 -0
- package/dist/credentials/types.d.ts +80 -0
- package/dist/credentials/types.js +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +8 -0
- package/dist/plugin/types.d.ts +30 -17
- package/dist/plugins/_shared/credentialAdapter.js +88 -0
- package/dist/plugins/_shared/googleAuth.js +89 -103
- package/dist/plugins/_shared/googleCredentials.js +160 -0
- package/dist/plugins/_shared/microsoftAuth.js +81 -74
- package/dist/plugins/_shared/microsoftCredentials.js +90 -0
- package/dist/plugins/_shared/tokenRefresh.js +39 -0
- package/dist/plugins/bitwarden/src/index.js +13 -31
- package/dist/plugins/gmail/src/index.js +8 -44
- package/dist/plugins/googleAppsScript/src/index.js +3 -25
- package/dist/plugins/googleCalendar/src/index.js +3 -41
- package/dist/plugins/googleContacts/src/index.js +3 -36
- package/dist/plugins/googleDocs/src/index.js +5 -0
- package/dist/plugins/googleDocs/src/shared.js +2 -30
- package/dist/plugins/googleDrive/src/index.js +52 -98
- package/dist/plugins/googleSheets/src/index.js +3 -40
- package/dist/plugins/googleSlides/src/index.js +5 -39
- package/dist/plugins/googleTasks/src/index.js +3 -30
- package/dist/plugins/halopsa/src/index.js +16 -31
- package/dist/plugins/microsoftCalendar/src/index.js +2 -1
- package/dist/plugins/microsoftFiles/src/index.js +10 -24
- package/dist/plugins/microsoftMail/src/index.js +2 -1
- package/dist/plugins/paypal/src/index.js +14 -29
- package/dist/plugins/plaud/src/index.js +210 -0
- package/dist/plugins/plaud/src/shared.js +221 -0
- package/dist/plugins/salesforce/src/shared.js +17 -32
- package/dist/sdk.d.ts +13 -5
- package/dist/sdk.js +35 -29
- package/dist/utils/observer.d.ts +2 -0
- package/dist/utils/observer.js +11 -0
- package/package.json +1 -1
|
@@ -1,113 +1,99 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
async function refreshOAuthAccessToken(ctx, pluginName, cfg) {
|
|
20
|
-
const { clientId, clientSecret, refreshToken } = cfg;
|
|
21
|
-
if (!clientId || !clientSecret || !refreshToken) {
|
|
22
|
-
throw new Error(`${pluginName}: missing OAuth clientId/clientSecret/refreshToken or service account credentials. Run the OAuth helper or set serviceAccountJson.`);
|
|
23
|
-
}
|
|
24
|
-
const body = new URLSearchParams({
|
|
25
|
-
client_id: clientId,
|
|
26
|
-
client_secret: clientSecret,
|
|
27
|
-
refresh_token: refreshToken,
|
|
28
|
-
grant_type: "refresh_token",
|
|
29
|
-
});
|
|
30
|
-
const res = await fetch(TOKEN_ENDPOINT, {
|
|
31
|
-
method: "POST",
|
|
32
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
33
|
-
body: body.toString(),
|
|
1
|
+
import { AuthError, downloadResource, } from "runline";
|
|
2
|
+
import { credentialRuntime } from "./credentialAdapter.js";
|
|
3
|
+
import { googleCredentialType, googleIdentity, googleMethod, googleResources, } from "./googleCredentials.js";
|
|
4
|
+
/** Explicit storage adapter: the registry owns token protocols and renewal. */
|
|
5
|
+
export function googleRuntime(ctx, pluginName, scopes) {
|
|
6
|
+
const config = ctx.connection.config;
|
|
7
|
+
const method = googleMethod(config);
|
|
8
|
+
const runtime = credentialRuntime(ctx, googleCredentialType(scopes, pluginName), method, (current) => {
|
|
9
|
+
const cfg = current;
|
|
10
|
+
return [
|
|
11
|
+
pluginName,
|
|
12
|
+
scopes,
|
|
13
|
+
googleMethod(cfg),
|
|
14
|
+
...(method === "serviceAccount"
|
|
15
|
+
? [googleIdentity(cfg)]
|
|
16
|
+
: [cfg.clientId, cfg.clientSecret]),
|
|
17
|
+
];
|
|
34
18
|
});
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const data = (await res.json());
|
|
39
|
-
const expiresAt = Date.now() + data.expires_in * 1000;
|
|
40
|
-
await ctx.updateConnection({
|
|
41
|
-
accessToken: data.access_token,
|
|
42
|
-
accessTokenExpiresAt: expiresAt,
|
|
43
|
-
});
|
|
44
|
-
return data.access_token;
|
|
19
|
+
if (method === "serviceAccount")
|
|
20
|
+
runtime.binding.jwtIdentity = googleIdentity(config);
|
|
21
|
+
return runtime;
|
|
45
22
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
throw new Error(`${pluginName}: service account token failed (${res.status}): ${await res.text()}`);
|
|
23
|
+
/** Trusted-runtime token compatibility only; resource consumers use googleResponse. */
|
|
24
|
+
export async function googleAccessToken(ctx, pluginName, scopes) {
|
|
25
|
+
const { binding, transport } = googleRuntime(ctx, pluginName, scopes);
|
|
26
|
+
return transport.accessToken(binding);
|
|
27
|
+
}
|
|
28
|
+
/** Translate builtin absolute URLs at one boundary; validate the raw path before URL normalization. */
|
|
29
|
+
export async function googleResponse(ctx, plugin, scopes, url, init = {}, query) {
|
|
30
|
+
const resources = googleResources(plugin);
|
|
31
|
+
const selected = Object.entries(resources.targets).find(([, target]) => url.startsWith(target.baseUrl));
|
|
32
|
+
if (!selected)
|
|
33
|
+
throw new AuthError("request_not_allowed");
|
|
34
|
+
const [target, policy] = selected;
|
|
35
|
+
let path = url.slice(policy.baseUrl.length);
|
|
36
|
+
if (query) {
|
|
37
|
+
const separator = path.indexOf("?");
|
|
38
|
+
const params = new URLSearchParams(separator < 0 ? "" : path.slice(separator + 1));
|
|
39
|
+
for (const [key, value] of Object.entries(query)) {
|
|
40
|
+
if (value === undefined || value === null)
|
|
41
|
+
continue;
|
|
42
|
+
params.delete(key);
|
|
43
|
+
for (const entry of Array.isArray(value) ? value : [value])
|
|
44
|
+
params.append(key, String(entry));
|
|
45
|
+
}
|
|
46
|
+
path = `${separator < 0 ? path : path.slice(0, separator)}?${params}`;
|
|
71
47
|
}
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
48
|
+
const { binding, transport } = googleRuntime(ctx, plugin, scopes);
|
|
49
|
+
return transport.request(binding, {
|
|
50
|
+
target,
|
|
51
|
+
path,
|
|
52
|
+
method: (init.method ?? "GET"),
|
|
53
|
+
headers: init.headers,
|
|
54
|
+
body: init.body,
|
|
77
55
|
});
|
|
78
|
-
return data.access_token;
|
|
79
56
|
}
|
|
80
|
-
function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
57
|
+
export async function googleJsonRequest(ctx, plugin, scopes, method, url, body, query) {
|
|
58
|
+
const response = await googleResponse(ctx, plugin, scopes, url, {
|
|
59
|
+
method,
|
|
60
|
+
headers: {
|
|
61
|
+
Accept: "application/json",
|
|
62
|
+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
63
|
+
},
|
|
64
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
65
|
+
}, query);
|
|
66
|
+
if (!response.ok)
|
|
67
|
+
throw new Error(`${plugin}: request failed (HTTP ${response.status})`);
|
|
68
|
+
if (response.status === 204)
|
|
69
|
+
return { success: true };
|
|
70
|
+
const text = await response.text();
|
|
71
|
+
try {
|
|
72
|
+
return text ? JSON.parse(text) : { success: true };
|
|
91
73
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
client_email: cfg.serviceAccountEmail,
|
|
95
|
-
private_key: cfg.serviceAccountPrivateKey.replace(/\\n/g, "\n"),
|
|
96
|
-
};
|
|
74
|
+
catch {
|
|
75
|
+
throw new AuthError("invalid_response");
|
|
97
76
|
}
|
|
98
|
-
throw new Error(`${pluginName}: service account requires serviceAccountJson or serviceAccountEmail/serviceAccountPrivateKey`);
|
|
99
77
|
}
|
|
100
|
-
function
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
104
|
-
const signer = createSign("RSA-SHA256");
|
|
105
|
-
signer.update(signingInput);
|
|
106
|
-
signer.end();
|
|
107
|
-
const signature = signer.sign(privateKey.replace(/\\n/g, "\n"));
|
|
108
|
-
return `${signingInput}.${base64url(signature)}`;
|
|
78
|
+
export async function googleProbe(ctx, plugin, scopes) {
|
|
79
|
+
const { binding, transport } = googleRuntime(ctx, plugin, scopes);
|
|
80
|
+
return transport.probe(binding);
|
|
109
81
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
82
|
+
/** Only Google-issued thumbnail hosts; never attach credentials to signed URLs. */
|
|
83
|
+
export async function googleDownload(value) {
|
|
84
|
+
let url;
|
|
85
|
+
try {
|
|
86
|
+
url = new URL(value);
|
|
87
|
+
if (url.port ||
|
|
88
|
+
!(url.hostname === "googleusercontent.com" ||
|
|
89
|
+
url.hostname.endsWith(".googleusercontent.com")))
|
|
90
|
+
throw new Error();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new AuthError("request_not_allowed");
|
|
94
|
+
}
|
|
95
|
+
return downloadResource(value, {
|
|
96
|
+
allowedOrigins: [url.origin],
|
|
97
|
+
fetch: globalThis.fetch,
|
|
98
|
+
});
|
|
113
99
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { AuthError, OAuthGrantSchema, } from "runline";
|
|
2
|
+
import * as t from "typebox";
|
|
3
|
+
/** Only legacy storage infers a method; host bindings always select it explicitly. */
|
|
4
|
+
export function googleMethod(cfg) {
|
|
5
|
+
if (cfg.authMethod !== undefined) {
|
|
6
|
+
if (cfg.authMethod !== "delegated" && cfg.authMethod !== "serviceAccount")
|
|
7
|
+
throw new AuthError("invalid_credentials");
|
|
8
|
+
return cfg.authMethod;
|
|
9
|
+
}
|
|
10
|
+
return cfg.serviceAccountJson ||
|
|
11
|
+
cfg.serviceAccountEmail ||
|
|
12
|
+
cfg.serviceAccountPrivateKey
|
|
13
|
+
? "serviceAccount"
|
|
14
|
+
: "delegated";
|
|
15
|
+
}
|
|
16
|
+
export function googleIdentity(cfg) {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = cfg.serviceAccountJson
|
|
19
|
+
? JSON.parse(cfg.serviceAccountJson)
|
|
20
|
+
: {
|
|
21
|
+
client_email: cfg.serviceAccountEmail,
|
|
22
|
+
private_key: cfg.serviceAccountPrivateKey,
|
|
23
|
+
};
|
|
24
|
+
if (!parsed ||
|
|
25
|
+
typeof parsed.client_email !== "string" ||
|
|
26
|
+
!parsed.client_email ||
|
|
27
|
+
typeof parsed.private_key !== "string" ||
|
|
28
|
+
!parsed.private_key)
|
|
29
|
+
throw new Error();
|
|
30
|
+
return {
|
|
31
|
+
issuer: parsed.client_email,
|
|
32
|
+
privateKey: parsed.private_key.replace(/\\n/g, "\n"),
|
|
33
|
+
...(cfg.serviceAccountSubject === undefined
|
|
34
|
+
? {}
|
|
35
|
+
: { subject: cfg.serviceAccountSubject }),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new AuthError("invalid_credentials");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const API_BASES = {
|
|
43
|
+
drive: "https://www.googleapis.com/drive/v3/",
|
|
44
|
+
upload: "https://www.googleapis.com/upload/drive/v3/",
|
|
45
|
+
docs: "https://docs.googleapis.com/v1/",
|
|
46
|
+
sheets: "https://sheets.googleapis.com/v4/",
|
|
47
|
+
slides: "https://slides.googleapis.com/v1/",
|
|
48
|
+
script: "https://script.googleapis.com/v1/",
|
|
49
|
+
tasks: "https://tasks.googleapis.com/tasks/v1/",
|
|
50
|
+
people: "https://people.googleapis.com/",
|
|
51
|
+
calendar: "https://www.googleapis.com/calendar/v3/",
|
|
52
|
+
gmail: "https://gmail.googleapis.com/gmail/v1/users/me/",
|
|
53
|
+
};
|
|
54
|
+
const PLUGIN_TARGETS = {
|
|
55
|
+
googleDrive: ["drive", "upload", "docs"],
|
|
56
|
+
googleDocs: ["docs", "drive"],
|
|
57
|
+
googleSheets: ["sheets", "drive"],
|
|
58
|
+
googleSlides: ["slides"],
|
|
59
|
+
googleAppsScript: ["script", "drive"],
|
|
60
|
+
googleTasks: ["tasks"],
|
|
61
|
+
googleContacts: ["people"],
|
|
62
|
+
googleCalendar: ["calendar"],
|
|
63
|
+
gmail: ["gmail"],
|
|
64
|
+
};
|
|
65
|
+
/** Builtin routing policy, never derived from action input or a returned URL. */
|
|
66
|
+
export function googleResources(plugin) {
|
|
67
|
+
if (!Object.hasOwn(PLUGIN_TARGETS, plugin))
|
|
68
|
+
throw new AuthError("request_not_allowed");
|
|
69
|
+
const names = PLUGIN_TARGETS[plugin];
|
|
70
|
+
const targets = Object.fromEntries(names.map((name) => [
|
|
71
|
+
name,
|
|
72
|
+
{
|
|
73
|
+
baseUrl: API_BASES[name],
|
|
74
|
+
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
75
|
+
...(name === "upload"
|
|
76
|
+
? {
|
|
77
|
+
allowedHeaders: [
|
|
78
|
+
"X-Upload-Content-Type",
|
|
79
|
+
"X-Upload-Content-Length",
|
|
80
|
+
"Content-Range",
|
|
81
|
+
],
|
|
82
|
+
resumableUpload: true,
|
|
83
|
+
}
|
|
84
|
+
: {}),
|
|
85
|
+
},
|
|
86
|
+
]));
|
|
87
|
+
const probes = {
|
|
88
|
+
googleDrive: ["drive", "files?pageSize=1&fields=files(id)"],
|
|
89
|
+
googleTasks: ["tasks", "users/@me/lists?maxResults=1"],
|
|
90
|
+
googleContacts: [
|
|
91
|
+
"people",
|
|
92
|
+
"v1/people/me/connections?pageSize=1&personFields=names",
|
|
93
|
+
],
|
|
94
|
+
googleCalendar: ["calendar", "users/me/calendarList?maxResults=1"],
|
|
95
|
+
gmail: ["gmail", "profile"],
|
|
96
|
+
googleAppsScript: ["drive", "files?pageSize=1&fields=files(id)"],
|
|
97
|
+
};
|
|
98
|
+
const probe = probes[plugin];
|
|
99
|
+
// Docs/Sheets/Slides have no fixed scoped document to probe. Do not guess an ID
|
|
100
|
+
// or require broader Drive permissions merely to manufacture a successful check.
|
|
101
|
+
return {
|
|
102
|
+
targets,
|
|
103
|
+
...(probe
|
|
104
|
+
? {
|
|
105
|
+
probe: {
|
|
106
|
+
target: probe[0],
|
|
107
|
+
path: probe[1],
|
|
108
|
+
method: "GET",
|
|
109
|
+
acceptedStatuses: [200],
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
: {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Fixed vendor endpoints; a JSON credential's token_uri can never select egress. */
|
|
116
|
+
export function googleCredentialType(scopes, plugin) {
|
|
117
|
+
const url = "https://oauth2.googleapis.com/token";
|
|
118
|
+
const schema = t.Object({ grant: t.Optional(OAuthGrantSchema) }, { additionalProperties: false });
|
|
119
|
+
const { targets, probe } = googleResources(plugin);
|
|
120
|
+
return {
|
|
121
|
+
id: "google.oauth",
|
|
122
|
+
methods: {
|
|
123
|
+
delegated: {
|
|
124
|
+
schema,
|
|
125
|
+
authentication: {
|
|
126
|
+
kind: "oauth2",
|
|
127
|
+
grantField: "grant",
|
|
128
|
+
renewal: "refresh",
|
|
129
|
+
definition: {
|
|
130
|
+
id: "google.oauth",
|
|
131
|
+
provider: "google",
|
|
132
|
+
refresh: { url, clientAuthentication: "client_secret_post" },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
targets,
|
|
136
|
+
probe,
|
|
137
|
+
},
|
|
138
|
+
...(scopes.length
|
|
139
|
+
? {
|
|
140
|
+
serviceAccount: {
|
|
141
|
+
schema,
|
|
142
|
+
authentication: {
|
|
143
|
+
kind: "oauth2",
|
|
144
|
+
grantField: "grant",
|
|
145
|
+
renewal: "jwtBearer",
|
|
146
|
+
scopes,
|
|
147
|
+
definition: {
|
|
148
|
+
id: "google.serviceAccount",
|
|
149
|
+
provider: "google",
|
|
150
|
+
jwtBearer: { url, clientAuthentication: "none" },
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
targets,
|
|
154
|
+
probe,
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
: {}),
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -1,87 +1,94 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
}
|
|
1
|
+
import { AuthError, downloadResource, } from "runline";
|
|
2
|
+
import { credentialRuntime } from "./credentialAdapter.js";
|
|
3
|
+
import { microsoftCredentialType, microsoftMethod, microsoftUserBase, } from "./microsoftCredentials.js";
|
|
4
|
+
export { microsoftDriveBase } from "./microsoftCredentials.js";
|
|
5
5
|
export function isAppOnly(cfg) {
|
|
6
|
-
return
|
|
6
|
+
return microsoftMethod(cfg) === "appOnly";
|
|
7
7
|
}
|
|
8
|
-
/** Graph path prefix for the acting principal: /me (delegated) or /users/{upn} (app-only). */
|
|
9
8
|
export function userBase(ctx) {
|
|
10
|
-
|
|
11
|
-
if (cfg.refreshToken)
|
|
12
|
-
return "/me";
|
|
13
|
-
if (cfg.userUpn)
|
|
14
|
-
return `/users/${encodeURIComponent(cfg.userUpn)}`;
|
|
15
|
-
throw new Error("microsoft: app-only mode requires userUpn (target mailbox/drive). Set MS_GRAPH_USER_UPN, or connect via OAuth.");
|
|
9
|
+
return microsoftUserBase(ctx.connection.config);
|
|
16
10
|
}
|
|
17
|
-
|
|
11
|
+
function runtime(ctx, plugin, scopes) {
|
|
18
12
|
const cfg = ctx.connection.config;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
grant_type: "refresh_token",
|
|
34
|
-
scope: [...scopes, "offline_access"].join(" "),
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
else if (isAppOnly(cfg)) {
|
|
38
|
-
body = new URLSearchParams({
|
|
39
|
-
client_id: cfg.clientId,
|
|
40
|
-
client_secret: cfg.clientSecret,
|
|
41
|
-
grant_type: "client_credentials",
|
|
42
|
-
scope: "https://graph.microsoft.com/.default",
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
else {
|
|
46
|
-
throw new Error(`${pluginName}: no credentials. Connect via OAuth, or set tenantId/clientId/clientSecret (app-only).`);
|
|
47
|
-
}
|
|
48
|
-
const res = await fetch(authority(cfg), {
|
|
49
|
-
method: "POST",
|
|
50
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
51
|
-
body: body.toString(),
|
|
13
|
+
const method = microsoftMethod(cfg);
|
|
14
|
+
if (method === "appOnly" &&
|
|
15
|
+
(!cfg.tenantId ||
|
|
16
|
+
["common", "organizations", "consumers"].includes(cfg.tenantId)))
|
|
17
|
+
throw new AuthError("invalid_credentials");
|
|
18
|
+
return credentialRuntime(ctx, microsoftCredentialType(cfg, plugin, scopes), method, (current) => {
|
|
19
|
+
const c = current;
|
|
20
|
+
return [
|
|
21
|
+
microsoftMethod(c),
|
|
22
|
+
c.tenantId,
|
|
23
|
+
c.clientId,
|
|
24
|
+
c.clientSecret,
|
|
25
|
+
scopes,
|
|
26
|
+
];
|
|
52
27
|
});
|
|
53
|
-
if (!res.ok) {
|
|
54
|
-
throw new Error(`${pluginName}: token request failed (${res.status}): ${await res.text()}`);
|
|
55
|
-
}
|
|
56
|
-
const data = (await res.json());
|
|
57
|
-
const patch = {
|
|
58
|
-
accessToken: data.access_token,
|
|
59
|
-
accessTokenExpiresAt: Date.now() + data.expires_in * 1000,
|
|
60
|
-
};
|
|
61
|
-
// Microsoft rotates refresh tokens — persist the new one when present.
|
|
62
|
-
if (data.refresh_token)
|
|
63
|
-
patch.refreshToken = data.refresh_token;
|
|
64
|
-
await ctx.updateConnection(patch);
|
|
65
|
-
return data.access_token;
|
|
66
28
|
}
|
|
67
|
-
/**
|
|
68
|
-
export async function
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
29
|
+
/** Compatibility token access uses the same grant runtime as resource requests. */
|
|
30
|
+
export async function microsoftAccessToken(ctx, plugin, scopes) {
|
|
31
|
+
const { binding, transport } = runtime(ctx, plugin, scopes);
|
|
32
|
+
return transport.accessToken(binding);
|
|
33
|
+
}
|
|
34
|
+
export async function microsoftProbe(ctx, plugin, scopes) {
|
|
35
|
+
const { binding, transport } = runtime(ctx, plugin, scopes);
|
|
36
|
+
return transport.probe(binding);
|
|
37
|
+
}
|
|
38
|
+
export async function graphResponse(ctx, plugin, scopes, method, path, body, contentType) {
|
|
39
|
+
if (!path.startsWith("/") || path.startsWith("//"))
|
|
40
|
+
throw new AuthError("request_not_allowed");
|
|
41
|
+
const { binding, transport } = runtime(ctx, plugin, scopes);
|
|
42
|
+
return transport.request(binding, {
|
|
43
|
+
target: "graph",
|
|
44
|
+
path: path.slice(1),
|
|
45
|
+
method: method,
|
|
46
|
+
headers: {
|
|
47
|
+
Accept: "application/json",
|
|
48
|
+
...(contentType ? { "Content-Type": contentType } : {}),
|
|
49
|
+
},
|
|
50
|
+
body,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** JSON and binary requests share the same destination, renewal and replay policy. */
|
|
54
|
+
export async function graphRequest(ctx, plugin, scopes, method, path, body) {
|
|
55
|
+
const res = await graphResponse(ctx, plugin, scopes, method, path, body === undefined ? undefined : JSON.stringify(body), body === undefined ? undefined : "application/json");
|
|
56
|
+
if (!res.ok)
|
|
57
|
+
throw new Error(`${plugin}: Graph request failed (HTTP ${res.status})`);
|
|
79
58
|
if (res.status === 204)
|
|
80
59
|
return { success: true };
|
|
81
60
|
const text = await res.text();
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
61
|
+
try {
|
|
62
|
+
return text ? JSON.parse(text) : { success: true };
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
throw new Error(`${plugin}: invalid Graph response`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Graph-issued signed download URLs carry their own authority, never a bearer header. */
|
|
69
|
+
export async function microsoftDownload(value) {
|
|
70
|
+
if (typeof value !== "string")
|
|
71
|
+
throw new AuthError("invalid_response");
|
|
72
|
+
let url;
|
|
73
|
+
try {
|
|
74
|
+
url = new URL(value);
|
|
75
|
+
const domains = [
|
|
76
|
+
"sharepoint.com",
|
|
77
|
+
"1drv.com",
|
|
78
|
+
"storage.live.com",
|
|
79
|
+
"onedrive.com",
|
|
80
|
+
];
|
|
81
|
+
if (url.port ||
|
|
82
|
+
!domains.some((domain) => url.hostname === domain || url.hostname.endsWith(`.${domain}`)))
|
|
83
|
+
throw new Error();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new AuthError("request_not_allowed");
|
|
87
|
+
}
|
|
88
|
+
return downloadResource(value, {
|
|
89
|
+
allowedOrigins: [url.origin],
|
|
90
|
+
fetch: globalThis.fetch,
|
|
91
|
+
});
|
|
85
92
|
}
|
|
86
93
|
/** Setup help shown by the OAuth wizard for all Microsoft plugins. */
|
|
87
94
|
export function microsoftSetupHelp(apiName) {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { AuthError, OAuthGrantSchema, } from "runline";
|
|
2
|
+
import * as t from "typebox";
|
|
3
|
+
/** Compatibility inference is confined to legacy configs without an explicit method. */
|
|
4
|
+
export function microsoftMethod(cfg) {
|
|
5
|
+
if (cfg.authMethod !== undefined) {
|
|
6
|
+
if (cfg.authMethod !== "delegated" && cfg.authMethod !== "appOnly")
|
|
7
|
+
throw new AuthError("invalid_credentials");
|
|
8
|
+
return cfg.authMethod;
|
|
9
|
+
}
|
|
10
|
+
return !cfg.refreshToken && cfg.tenantId && cfg.clientId && cfg.clientSecret
|
|
11
|
+
? "appOnly"
|
|
12
|
+
: "delegated";
|
|
13
|
+
}
|
|
14
|
+
export function microsoftUserBase(cfg) {
|
|
15
|
+
if (microsoftMethod(cfg) === "delegated")
|
|
16
|
+
return "/me";
|
|
17
|
+
if (!cfg.userUpn)
|
|
18
|
+
throw new AuthError("invalid_credentials");
|
|
19
|
+
return `/users/${encodeURIComponent(cfg.userUpn)}`;
|
|
20
|
+
}
|
|
21
|
+
export function microsoftDriveBase(cfg) {
|
|
22
|
+
if (cfg.driveId)
|
|
23
|
+
return `/drives/${encodeURIComponent(cfg.driveId)}`;
|
|
24
|
+
if (cfg.siteId)
|
|
25
|
+
return `/sites/${encodeURIComponent(cfg.siteId)}/drive`;
|
|
26
|
+
return `${microsoftUserBase(cfg)}/drive`;
|
|
27
|
+
}
|
|
28
|
+
/** Scope-appropriate probes never require User.Read merely to check mail/calendar/files. */
|
|
29
|
+
export function microsoftCredentialType(cfg, plugin, scopes) {
|
|
30
|
+
const tenant = cfg.tenantId || "common";
|
|
31
|
+
if (!/^[a-zA-Z0-9.-]+$/.test(tenant))
|
|
32
|
+
throw new AuthError("invalid_credentials");
|
|
33
|
+
const endpoint = {
|
|
34
|
+
url: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
|
|
35
|
+
clientAuthentication: "client_secret_post",
|
|
36
|
+
};
|
|
37
|
+
const schema = t.Object({ grant: t.Optional(OAuthGrantSchema) }, { additionalProperties: false });
|
|
38
|
+
const methods = Object.fromEntries(["delegated", "appOnly"].map((method) => {
|
|
39
|
+
let probe;
|
|
40
|
+
const selected = { ...cfg, authMethod: method };
|
|
41
|
+
if (method === "delegated" ||
|
|
42
|
+
cfg.userUpn ||
|
|
43
|
+
(plugin === "microsoftFiles" && (cfg.driveId || cfg.siteId))) {
|
|
44
|
+
const path = plugin === "microsoftMail"
|
|
45
|
+
? `${microsoftUserBase(selected)}/messages?$top=1&$select=id`
|
|
46
|
+
: plugin === "microsoftCalendar"
|
|
47
|
+
? `${microsoftUserBase(selected)}/events?$top=1&$select=id`
|
|
48
|
+
: plugin === "microsoftFiles"
|
|
49
|
+
? `${microsoftDriveBase(selected)}/root?$select=id`
|
|
50
|
+
: undefined;
|
|
51
|
+
if (path)
|
|
52
|
+
probe = {
|
|
53
|
+
target: "graph",
|
|
54
|
+
path: path.slice(1),
|
|
55
|
+
method: "GET",
|
|
56
|
+
acceptedStatuses: [200],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return [
|
|
60
|
+
method,
|
|
61
|
+
{
|
|
62
|
+
schema,
|
|
63
|
+
authentication: {
|
|
64
|
+
kind: "oauth2",
|
|
65
|
+
grantField: "grant",
|
|
66
|
+
renewal: method === "delegated"
|
|
67
|
+
? "refresh"
|
|
68
|
+
: "clientCredentials",
|
|
69
|
+
definition: {
|
|
70
|
+
id: "microsoft.oauth",
|
|
71
|
+
provider: "microsoft",
|
|
72
|
+
refresh: endpoint,
|
|
73
|
+
clientCredentials: endpoint,
|
|
74
|
+
},
|
|
75
|
+
scopes: method === "delegated"
|
|
76
|
+
? [...scopes, "offline_access"]
|
|
77
|
+
: ["https://graph.microsoft.com/.default"],
|
|
78
|
+
},
|
|
79
|
+
targets: {
|
|
80
|
+
graph: {
|
|
81
|
+
baseUrl: "https://graph.microsoft.com/v1.0/",
|
|
82
|
+
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
...(probe ? { probe } : {}),
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
}));
|
|
89
|
+
return { id: "microsoft.graph", methods };
|
|
90
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { requestOAuth2Token, } from "runline";
|
|
2
|
+
const REFRESH_SKEW_MS = 60_000;
|
|
3
|
+
function usableToken(config) {
|
|
4
|
+
return typeof config.accessToken === "string" &&
|
|
5
|
+
config.accessToken &&
|
|
6
|
+
(config.accessTokenExpiresAt === undefined ||
|
|
7
|
+
(typeof config.accessTokenExpiresAt === "number" &&
|
|
8
|
+
Number.isFinite(config.accessTokenExpiresAt) &&
|
|
9
|
+
Date.now() < config.accessTokenExpiresAt - REFRESH_SKEW_MS))
|
|
10
|
+
? config.accessToken
|
|
11
|
+
: undefined;
|
|
12
|
+
}
|
|
13
|
+
/** Refresh ownership includes the provider request and persistence of rotated tokens. */
|
|
14
|
+
export async function coordinatedAccessToken(ctx, refresh) {
|
|
15
|
+
const cached = usableToken(ctx.connection.config);
|
|
16
|
+
if (cached)
|
|
17
|
+
return cached;
|
|
18
|
+
await ctx.updateConnection(async (current) => {
|
|
19
|
+
if (usableToken(current))
|
|
20
|
+
return;
|
|
21
|
+
return refresh(current);
|
|
22
|
+
});
|
|
23
|
+
const token = ctx.connection.config.accessToken;
|
|
24
|
+
if (typeof token !== "string" || !token)
|
|
25
|
+
throw new Error("Token refresh committed no access token");
|
|
26
|
+
return token;
|
|
27
|
+
}
|
|
28
|
+
/** Token errors exclude response bodies, parser diagnostics, and request credentials. */
|
|
29
|
+
export async function requestToken(endpoint, body, application) {
|
|
30
|
+
const tokens = await requestOAuth2Token(endpoint, body, application);
|
|
31
|
+
return {
|
|
32
|
+
accessToken: tokens.accessToken,
|
|
33
|
+
// Unknown expiry replaces an old expiry; it never inherits a stale timestamp.
|
|
34
|
+
accessTokenExpiresAt: tokens.expiresAt,
|
|
35
|
+
...(tokens.refreshToken === undefined
|
|
36
|
+
? {}
|
|
37
|
+
: { refreshToken: tokens.refreshToken }),
|
|
38
|
+
};
|
|
39
|
+
}
|