@symerian/symi 2.1.4 → 2.1.5
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/{audio-preflight-5NsCxagO.js → audio-preflight-BZIRAXlv.js} +4 -4
- package/dist/build-info.json +3 -3
- package/dist/bundled/boot-md/handler.js +6 -6
- package/dist/bundled/session-memory/handler.js +6 -6
- package/dist/canvas-host/a2ui/.bundle.hash +1 -1
- package/dist/{chrome-DM65TRMk.js → chrome-791kNCCB.js} +7 -7
- package/dist/{deliver-Cgg1qwKP.js → deliver-CrlXl9KD.js} +1 -1
- package/dist/{image-xvVpGzK5.js → image-ycxQmhff.js} +1 -1
- package/dist/llm-slug-generator.js +6 -6
- package/dist/{pi-embedded-DuWfOlkO.js → pi-embedded-AKhKHxb6.js} +16 -16
- package/dist/{pi-embedded-helpers-Cd124krQ.js → pi-embedded-helpers-DoTustDz.js} +4 -4
- package/dist/{pw-ai-CL_YRdbc.js → pw-ai-DIVguI36.js} +1 -1
- package/dist/{runner-CCo2QuJE.js → runner-Cw5KZrjx.js} +1 -1
- package/dist/{web-BFa88W3e.js → web-Kz-ZuUjq.js} +6 -6
- package/extensions/outlook/index.ts +199 -0
- package/extensions/outlook/package.json +15 -0
- package/extensions/outlook/src/auth.ts +362 -0
- package/extensions/outlook/src/graph-mail.ts +150 -0
- package/extensions/outlook/src/store.ts +72 -0
- package/extensions/outlook/src/tools.ts +256 -0
- package/extensions/outlook/symi.plugin.json +11 -0
- package/package.json +1 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { buildOauthProviderAuthResult, emptyPluginConfigSchema } from "symi/plugin-sdk";
|
|
2
|
+
import type { AnyAgentTool, SymiPluginApi, ProviderAuthContext } from "../../src/plugins/types.js";
|
|
3
|
+
import { loginOutlook } from "./src/auth.js";
|
|
4
|
+
import { saveCredentials, loadCredentials, deleteCredentials } from "./src/store.js";
|
|
5
|
+
import {
|
|
6
|
+
createOutlookListTool,
|
|
7
|
+
createOutlookReadTool,
|
|
8
|
+
createOutlookSendTool,
|
|
9
|
+
createOutlookReplyTool,
|
|
10
|
+
createOutlookSearchTool,
|
|
11
|
+
createOutlookFoldersTool,
|
|
12
|
+
createOutlookMoveTool,
|
|
13
|
+
} from "./src/tools.js";
|
|
14
|
+
|
|
15
|
+
const outlookPlugin = {
|
|
16
|
+
id: "outlook",
|
|
17
|
+
name: "Outlook 365",
|
|
18
|
+
description: "Outlook 365 email integration via Microsoft Graph API with OAuth2 PKCE",
|
|
19
|
+
configSchema: emptyPluginConfigSchema(),
|
|
20
|
+
|
|
21
|
+
register(api: SymiPluginApi) {
|
|
22
|
+
// -------------------------------------------------------------------------
|
|
23
|
+
// Agent tools — available when Outlook is connected
|
|
24
|
+
// -------------------------------------------------------------------------
|
|
25
|
+
const tools = [
|
|
26
|
+
createOutlookListTool,
|
|
27
|
+
createOutlookReadTool,
|
|
28
|
+
createOutlookSendTool,
|
|
29
|
+
createOutlookReplyTool,
|
|
30
|
+
createOutlookSearchTool,
|
|
31
|
+
createOutlookFoldersTool,
|
|
32
|
+
createOutlookMoveTool,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
for (const createTool of tools) {
|
|
36
|
+
api.registerTool(createTool() as unknown as AnyAgentTool, { optional: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// -------------------------------------------------------------------------
|
|
40
|
+
// Provider — enables `symi models add --provider outlook --auth oauth`
|
|
41
|
+
// and stores credentials in the standard auth profile store.
|
|
42
|
+
// -------------------------------------------------------------------------
|
|
43
|
+
api.registerProvider({
|
|
44
|
+
id: "outlook-mail",
|
|
45
|
+
label: "Outlook 365 Mail",
|
|
46
|
+
docsPath: "/integrations/outlook",
|
|
47
|
+
aliases: ["outlook"],
|
|
48
|
+
auth: [
|
|
49
|
+
{
|
|
50
|
+
id: "oauth",
|
|
51
|
+
label: "Microsoft OAuth (sign in with your account)",
|
|
52
|
+
hint: "PKCE + localhost callback — uses your normal Microsoft credentials",
|
|
53
|
+
kind: "oauth",
|
|
54
|
+
run: async (ctx: ProviderAuthContext) => {
|
|
55
|
+
const spin = ctx.prompter.progress("Starting Outlook OAuth...");
|
|
56
|
+
try {
|
|
57
|
+
const result = await loginOutlook({
|
|
58
|
+
isRemote: ctx.isRemote,
|
|
59
|
+
openUrl: ctx.openUrl,
|
|
60
|
+
prompt: async (message) => String(await ctx.prompter.text({ message })),
|
|
61
|
+
note: ctx.prompter.note,
|
|
62
|
+
log: (message) => ctx.runtime.log(message),
|
|
63
|
+
progress: spin,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Persist to local credential store for the tools
|
|
67
|
+
saveCredentials({
|
|
68
|
+
access: result.access,
|
|
69
|
+
refresh: result.refresh,
|
|
70
|
+
expires: result.expires,
|
|
71
|
+
email: result.email,
|
|
72
|
+
displayName: result.displayName,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return buildOauthProviderAuthResult({
|
|
76
|
+
providerId: "outlook-mail",
|
|
77
|
+
defaultModel: "", // not an LLM provider
|
|
78
|
+
access: result.access,
|
|
79
|
+
refresh: result.refresh,
|
|
80
|
+
expires: result.expires,
|
|
81
|
+
email: result.email,
|
|
82
|
+
notes: [
|
|
83
|
+
result.email
|
|
84
|
+
? `Connected as ${result.displayName ?? result.email}`
|
|
85
|
+
: "Outlook connected.",
|
|
86
|
+
"Email tools (outlook_list, outlook_send, outlook_search, etc.) are now available.",
|
|
87
|
+
"Credentials stored in ~/.symi/credentials/outlook.json",
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
} catch (err) {
|
|
91
|
+
spin.stop("Outlook OAuth failed");
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// -------------------------------------------------------------------------
|
|
100
|
+
// CLI — `symi outlook login`, `symi outlook logout`, `symi outlook status`
|
|
101
|
+
// -------------------------------------------------------------------------
|
|
102
|
+
api.registerCli(
|
|
103
|
+
({ program }) => {
|
|
104
|
+
const outlook = program.command("outlook").description("Outlook 365 email integration");
|
|
105
|
+
|
|
106
|
+
outlook
|
|
107
|
+
.command("login")
|
|
108
|
+
.description("Sign in with your Microsoft account")
|
|
109
|
+
.action(async () => {
|
|
110
|
+
const { loginOutlook: login } = await import("./src/auth.js");
|
|
111
|
+
const open = (await import("node:child_process")).execSync;
|
|
112
|
+
|
|
113
|
+
const result = await login({
|
|
114
|
+
isRemote: false,
|
|
115
|
+
openUrl: async (url) => {
|
|
116
|
+
const cmd =
|
|
117
|
+
process.platform === "darwin"
|
|
118
|
+
? `open "${url}"`
|
|
119
|
+
: process.platform === "win32"
|
|
120
|
+
? `start "" "${url}"`
|
|
121
|
+
: `xdg-open "${url}" 2>/dev/null || echo "Open this URL: ${url}"`;
|
|
122
|
+
try {
|
|
123
|
+
open(cmd, { stdio: "ignore" });
|
|
124
|
+
} catch {
|
|
125
|
+
console.log(`Open this URL in your browser:\n${url}`);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
prompt: async (message) => {
|
|
129
|
+
const readline = await import("node:readline");
|
|
130
|
+
const rl = readline.createInterface({
|
|
131
|
+
input: process.stdin,
|
|
132
|
+
output: process.stdout,
|
|
133
|
+
});
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
rl.question(message, (answer) => {
|
|
136
|
+
rl.close();
|
|
137
|
+
resolve(answer);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
note: async (message) => {
|
|
142
|
+
console.log(message);
|
|
143
|
+
},
|
|
144
|
+
log: (message) => console.log(message),
|
|
145
|
+
progress: {
|
|
146
|
+
update: (msg) => process.stderr.write(`\r${msg}`),
|
|
147
|
+
stop: (msg) => {
|
|
148
|
+
if (msg) process.stderr.write(`\r${msg}\n`);
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
saveCredentials({
|
|
154
|
+
access: result.access,
|
|
155
|
+
refresh: result.refresh,
|
|
156
|
+
expires: result.expires,
|
|
157
|
+
email: result.email,
|
|
158
|
+
displayName: result.displayName,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
console.log("");
|
|
162
|
+
console.log(
|
|
163
|
+
result.email
|
|
164
|
+
? `Connected as ${result.displayName ?? result.email}`
|
|
165
|
+
: "Outlook connected.",
|
|
166
|
+
);
|
|
167
|
+
console.log("Email tools are now available to your agent.");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
outlook
|
|
171
|
+
.command("logout")
|
|
172
|
+
.description("Disconnect Outlook and remove stored credentials")
|
|
173
|
+
.action(() => {
|
|
174
|
+
const removed = deleteCredentials();
|
|
175
|
+
console.log(removed ? "Outlook credentials removed." : "No credentials found.");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
outlook
|
|
179
|
+
.command("status")
|
|
180
|
+
.description("Show Outlook connection status")
|
|
181
|
+
.action(() => {
|
|
182
|
+
const creds = loadCredentials();
|
|
183
|
+
if (!creds) {
|
|
184
|
+
console.log("Not connected. Run `symi outlook login` to sign in.");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const expired = Date.now() >= creds.expires;
|
|
188
|
+
console.log(`Account: ${creds.email ?? "unknown"}`);
|
|
189
|
+
console.log(`Name: ${creds.displayName ?? "unknown"}`);
|
|
190
|
+
console.log(`Token: ${expired ? "expired (will auto-refresh)" : "valid"}`);
|
|
191
|
+
console.log(`Updated: ${creds.updatedAt ?? "unknown"}`);
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
{ commands: ["outlook"] },
|
|
195
|
+
);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export default outlookPlugin;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@symi/outlook",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Symi Outlook 365 email integration via Microsoft Graph API",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"@symerian/symi": "workspace:*"
|
|
9
|
+
},
|
|
10
|
+
"symi": {
|
|
11
|
+
"extensions": [
|
|
12
|
+
"./index.ts"
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { isWSL2Sync } from "symi/plugin-sdk";
|
|
4
|
+
|
|
5
|
+
// Microsoft OAuth 2.0 endpoints (multi-tenant: any Microsoft account)
|
|
6
|
+
const AUTH_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
|
|
7
|
+
const TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
|
|
8
|
+
const REDIRECT_URI = "http://localhost:51122/oauth-callback";
|
|
9
|
+
|
|
10
|
+
// Scopes for Outlook mail access + offline refresh
|
|
11
|
+
const SCOPES = [
|
|
12
|
+
"https://graph.microsoft.com/Mail.Read",
|
|
13
|
+
"https://graph.microsoft.com/Mail.Send",
|
|
14
|
+
"https://graph.microsoft.com/Mail.ReadWrite",
|
|
15
|
+
"https://graph.microsoft.com/User.Read",
|
|
16
|
+
"offline_access",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const RESPONSE_PAGE = `<!DOCTYPE html>
|
|
20
|
+
<html lang="en">
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="utf-8" />
|
|
23
|
+
<title>Symi Outlook OAuth</title>
|
|
24
|
+
<style>
|
|
25
|
+
body { font-family: system-ui, sans-serif; display: flex; justify-content: center;
|
|
26
|
+
align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }
|
|
27
|
+
main { text-align: center; padding: 2rem; background: white;
|
|
28
|
+
border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
|
29
|
+
h1 { color: #0078d4; }
|
|
30
|
+
</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<main>
|
|
34
|
+
<h1>Outlook connected</h1>
|
|
35
|
+
<p>You can return to the terminal.</p>
|
|
36
|
+
</main>
|
|
37
|
+
</body>
|
|
38
|
+
</html>`;
|
|
39
|
+
|
|
40
|
+
// Symi's registered multi-tenant Azure AD app (public client, no secret needed).
|
|
41
|
+
// Users can override with SYMI_OUTLOOK_CLIENT_ID if they have their own app registration.
|
|
42
|
+
const DEFAULT_CLIENT_ID = "e24f680b-3fdd-4bce-a201-6bf547fe4735";
|
|
43
|
+
|
|
44
|
+
function resolveClientId(): string {
|
|
45
|
+
return process.env.SYMI_OUTLOOK_CLIENT_ID?.trim() || DEFAULT_CLIENT_ID;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function generatePkce(): { verifier: string; challenge: string } {
|
|
49
|
+
const verifier = randomBytes(32).toString("hex");
|
|
50
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
51
|
+
return { verifier, challenge };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
|
|
55
|
+
return isRemote || isWSL2Sync();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildAuthUrl(params: { challenge: string; state: string }): string {
|
|
59
|
+
const clientId = resolveClientId();
|
|
60
|
+
const url = new URL(AUTH_URL);
|
|
61
|
+
url.searchParams.set("client_id", clientId);
|
|
62
|
+
url.searchParams.set("response_type", "code");
|
|
63
|
+
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
|
64
|
+
url.searchParams.set("scope", SCOPES.join(" "));
|
|
65
|
+
url.searchParams.set("code_challenge", params.challenge);
|
|
66
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
67
|
+
url.searchParams.set("state", params.state);
|
|
68
|
+
url.searchParams.set("prompt", "consent");
|
|
69
|
+
return url.toString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseCallbackInput(
|
|
73
|
+
input: string,
|
|
74
|
+
): { code: string; state: string } | { error: string } {
|
|
75
|
+
const trimmed = input.trim();
|
|
76
|
+
if (!trimmed) {
|
|
77
|
+
return { error: "No input provided" };
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(trimmed);
|
|
81
|
+
const code = url.searchParams.get("code");
|
|
82
|
+
const state = url.searchParams.get("state");
|
|
83
|
+
if (!code) {
|
|
84
|
+
return { error: "Missing 'code' parameter in URL" };
|
|
85
|
+
}
|
|
86
|
+
if (!state) {
|
|
87
|
+
return { error: "Missing 'state' parameter in URL" };
|
|
88
|
+
}
|
|
89
|
+
return { code, state };
|
|
90
|
+
} catch {
|
|
91
|
+
return { error: "Paste the full redirect URL (not just the code)." };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function startCallbackServer(params: { timeoutMs: number }) {
|
|
96
|
+
const redirect = new URL(REDIRECT_URI);
|
|
97
|
+
const port = redirect.port ? Number(redirect.port) : 51122;
|
|
98
|
+
|
|
99
|
+
let settled = false;
|
|
100
|
+
let resolveCallback: (url: URL) => void;
|
|
101
|
+
let rejectCallback: (err: Error) => void;
|
|
102
|
+
|
|
103
|
+
const callbackPromise = new Promise<URL>((resolve, reject) => {
|
|
104
|
+
resolveCallback = (url) => {
|
|
105
|
+
if (settled) return;
|
|
106
|
+
settled = true;
|
|
107
|
+
resolve(url);
|
|
108
|
+
};
|
|
109
|
+
rejectCallback = (err) => {
|
|
110
|
+
if (settled) return;
|
|
111
|
+
settled = true;
|
|
112
|
+
reject(err);
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const timeout = setTimeout(() => {
|
|
117
|
+
rejectCallback(new Error("Timed out waiting for OAuth callback"));
|
|
118
|
+
}, params.timeoutMs);
|
|
119
|
+
timeout.unref?.();
|
|
120
|
+
|
|
121
|
+
const server = createServer((request, response) => {
|
|
122
|
+
if (!request.url) {
|
|
123
|
+
response.writeHead(400, { "Content-Type": "text/plain" });
|
|
124
|
+
response.end("Missing URL");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const url = new URL(request.url, `${redirect.protocol}//${redirect.host}`);
|
|
129
|
+
if (url.pathname !== redirect.pathname) {
|
|
130
|
+
response.writeHead(404, { "Content-Type": "text/plain" });
|
|
131
|
+
response.end("Not found");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
136
|
+
response.end(RESPONSE_PAGE);
|
|
137
|
+
resolveCallback(url);
|
|
138
|
+
|
|
139
|
+
setImmediate(() => {
|
|
140
|
+
server.close();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await new Promise<void>((resolve, reject) => {
|
|
145
|
+
const onError = (err: Error) => {
|
|
146
|
+
server.off("error", onError);
|
|
147
|
+
reject(err);
|
|
148
|
+
};
|
|
149
|
+
server.once("error", onError);
|
|
150
|
+
server.listen(port, "127.0.0.1", () => {
|
|
151
|
+
server.off("error", onError);
|
|
152
|
+
resolve();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
waitForCallback: () => callbackPromise,
|
|
158
|
+
close: () =>
|
|
159
|
+
new Promise<void>((resolve) => {
|
|
160
|
+
server.close(() => resolve());
|
|
161
|
+
}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function exchangeCode(params: {
|
|
166
|
+
code: string;
|
|
167
|
+
verifier: string;
|
|
168
|
+
}): Promise<{ access: string; refresh: string; expires: number }> {
|
|
169
|
+
const clientId = resolveClientId();
|
|
170
|
+
const response = await fetch(TOKEN_URL, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
173
|
+
body: new URLSearchParams({
|
|
174
|
+
client_id: clientId,
|
|
175
|
+
code: params.code,
|
|
176
|
+
grant_type: "authorization_code",
|
|
177
|
+
redirect_uri: REDIRECT_URI,
|
|
178
|
+
code_verifier: params.verifier,
|
|
179
|
+
}),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (!response.ok) {
|
|
183
|
+
const text = await response.text();
|
|
184
|
+
throw new Error(`Token exchange failed: ${text}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const data = (await response.json()) as {
|
|
188
|
+
access_token?: string;
|
|
189
|
+
refresh_token?: string;
|
|
190
|
+
expires_in?: number;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const access = data.access_token?.trim();
|
|
194
|
+
const refresh = data.refresh_token?.trim();
|
|
195
|
+
const expiresIn = data.expires_in ?? 0;
|
|
196
|
+
|
|
197
|
+
if (!access) {
|
|
198
|
+
throw new Error("Token exchange returned no access_token");
|
|
199
|
+
}
|
|
200
|
+
if (!refresh) {
|
|
201
|
+
throw new Error("Token exchange returned no refresh_token (ensure offline_access scope)");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 5-minute buffer before actual expiry (same as google-antigravity)
|
|
205
|
+
const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000;
|
|
206
|
+
return { access, refresh, expires };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function refreshAccessToken(refreshToken: string): Promise<{
|
|
210
|
+
access: string;
|
|
211
|
+
refresh: string;
|
|
212
|
+
expires: number;
|
|
213
|
+
}> {
|
|
214
|
+
const clientId = resolveClientId();
|
|
215
|
+
const response = await fetch(TOKEN_URL, {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
218
|
+
body: new URLSearchParams({
|
|
219
|
+
client_id: clientId,
|
|
220
|
+
grant_type: "refresh_token",
|
|
221
|
+
refresh_token: refreshToken,
|
|
222
|
+
scope: SCOPES.join(" "),
|
|
223
|
+
}),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
if (!response.ok) {
|
|
227
|
+
const text = await response.text();
|
|
228
|
+
throw new Error(`Token refresh failed: ${text}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const data = (await response.json()) as {
|
|
232
|
+
access_token?: string;
|
|
233
|
+
refresh_token?: string;
|
|
234
|
+
expires_in?: number;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const access = data.access_token?.trim();
|
|
238
|
+
// Microsoft may or may not rotate the refresh token
|
|
239
|
+
const refresh = data.refresh_token?.trim() ?? refreshToken;
|
|
240
|
+
const expiresIn = data.expires_in ?? 0;
|
|
241
|
+
|
|
242
|
+
if (!access) {
|
|
243
|
+
throw new Error("Token refresh returned no access_token");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000;
|
|
247
|
+
return { access, refresh, expires };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function fetchUserProfile(accessToken: string): Promise<{
|
|
251
|
+
email?: string;
|
|
252
|
+
displayName?: string;
|
|
253
|
+
}> {
|
|
254
|
+
try {
|
|
255
|
+
const response = await fetch("https://graph.microsoft.com/v1.0/me", {
|
|
256
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
257
|
+
});
|
|
258
|
+
if (!response.ok) {
|
|
259
|
+
return {};
|
|
260
|
+
}
|
|
261
|
+
const data = (await response.json()) as {
|
|
262
|
+
mail?: string;
|
|
263
|
+
userPrincipalName?: string;
|
|
264
|
+
displayName?: string;
|
|
265
|
+
};
|
|
266
|
+
return {
|
|
267
|
+
email: data.mail || data.userPrincipalName,
|
|
268
|
+
displayName: data.displayName,
|
|
269
|
+
};
|
|
270
|
+
} catch {
|
|
271
|
+
return {};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function loginOutlook(params: {
|
|
276
|
+
isRemote: boolean;
|
|
277
|
+
openUrl: (url: string) => Promise<void>;
|
|
278
|
+
prompt: (message: string) => Promise<string>;
|
|
279
|
+
note: (message: string, title?: string) => Promise<void>;
|
|
280
|
+
log: (message: string) => void;
|
|
281
|
+
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
|
|
282
|
+
}): Promise<{
|
|
283
|
+
access: string;
|
|
284
|
+
refresh: string;
|
|
285
|
+
expires: number;
|
|
286
|
+
email?: string;
|
|
287
|
+
displayName?: string;
|
|
288
|
+
}> {
|
|
289
|
+
const { verifier, challenge } = generatePkce();
|
|
290
|
+
const state = randomBytes(16).toString("hex");
|
|
291
|
+
const authUrl = buildAuthUrl({ challenge, state });
|
|
292
|
+
|
|
293
|
+
let callbackServer: Awaited<ReturnType<typeof startCallbackServer>> | null = null;
|
|
294
|
+
const needsManual = shouldUseManualOAuthFlow(params.isRemote);
|
|
295
|
+
if (!needsManual) {
|
|
296
|
+
try {
|
|
297
|
+
callbackServer = await startCallbackServer({ timeoutMs: 5 * 60 * 1000 });
|
|
298
|
+
} catch {
|
|
299
|
+
callbackServer = null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!callbackServer) {
|
|
304
|
+
await params.note(
|
|
305
|
+
[
|
|
306
|
+
"Open the URL in your local browser.",
|
|
307
|
+
"After signing in, copy the full redirect URL and paste it back here.",
|
|
308
|
+
"",
|
|
309
|
+
`Auth URL: ${authUrl}`,
|
|
310
|
+
`Redirect URI: ${REDIRECT_URI}`,
|
|
311
|
+
].join("\n"),
|
|
312
|
+
"Outlook OAuth",
|
|
313
|
+
);
|
|
314
|
+
params.log("");
|
|
315
|
+
params.log("Copy this URL:");
|
|
316
|
+
params.log(authUrl);
|
|
317
|
+
params.log("");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!needsManual) {
|
|
321
|
+
params.progress.update("Opening Microsoft sign-in...");
|
|
322
|
+
try {
|
|
323
|
+
await params.openUrl(authUrl);
|
|
324
|
+
} catch {
|
|
325
|
+
// ignore
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
let code = "";
|
|
330
|
+
let returnedState = "";
|
|
331
|
+
|
|
332
|
+
if (callbackServer) {
|
|
333
|
+
params.progress.update("Waiting for OAuth callback...");
|
|
334
|
+
const callback = await callbackServer.waitForCallback();
|
|
335
|
+
code = callback.searchParams.get("code") ?? "";
|
|
336
|
+
returnedState = callback.searchParams.get("state") ?? "";
|
|
337
|
+
await callbackServer.close();
|
|
338
|
+
} else {
|
|
339
|
+
params.progress.update("Waiting for redirect URL...");
|
|
340
|
+
const input = await params.prompt("Paste the redirect URL: ");
|
|
341
|
+
const parsed = parseCallbackInput(input);
|
|
342
|
+
if ("error" in parsed) {
|
|
343
|
+
throw new Error(parsed.error);
|
|
344
|
+
}
|
|
345
|
+
code = parsed.code;
|
|
346
|
+
returnedState = parsed.state;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!code) {
|
|
350
|
+
throw new Error("Missing OAuth code");
|
|
351
|
+
}
|
|
352
|
+
if (returnedState !== state) {
|
|
353
|
+
throw new Error("OAuth state mismatch. Please try again.");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
params.progress.update("Exchanging code for tokens...");
|
|
357
|
+
const tokens = await exchangeCode({ code, verifier });
|
|
358
|
+
const profile = await fetchUserProfile(tokens.access);
|
|
359
|
+
|
|
360
|
+
params.progress.stop("Outlook OAuth complete");
|
|
361
|
+
return { ...tokens, ...profile };
|
|
362
|
+
}
|