import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
const CONFIG_DIR = process.env.P3X_ONENOTE_CONFIG_DIR
|| join(homedir(), '.config', 'p3x-onenote-mcp');
const TOKEN_FILE = join(CONFIG_DIR, 'tokens.json');
const TENANT = process.env.P3X_ONENOTE_TENANT || 'common';
const SCOPES = 'Notes.ReadWrite User.Read offline_access';
const AUTH_BASE = `https://login.microsoftonline.com/${TENANT}/oauth2/v2.0`;
const clientId = () => {
const id = process.env.P3X_ONENOTE_CLIENT_ID;
if (!id) {
throw new Error(
'Missing P3X_ONENOTE_CLIENT_ID. Register a public client app in Azure Portal '
+ '(App registrations → New → "Public client/native", redirect '
+ 'https://login.microsoftonline.com/common/oauth2/nativeclient) and add delegated '
+ 'Microsoft Graph permissions: Notes.ReadWrite.All, User.Read. Then set '
+ 'P3X_ONENOTE_CLIENT_ID to the Application (client) ID.'
);
}
return id;
};
async function readTokens() {
try {
const raw = await readFile(TOKEN_FILE, 'utf8');
return JSON.parse(raw);
} catch {
return null;
}
}
async function writeTokens(tokens) {
await mkdir(dirname(TOKEN_FILE), { recursive: true, mode: 0o700 });
await writeFile(TOKEN_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
}
export async function startDeviceCode() {
const res = await fetch(`${AUTH_BASE}/devicecode`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ client_id: clientId(), scope: SCOPES }),
});
if (!res.ok) throw new Error(`devicecode: ${res.status} ${await res.text()}`);
return res.json();
}
export async function pollDeviceCode(deviceCode, intervalSeconds = 5, expiresInSeconds = 900) {
const deadline = Date.now() + expiresInSeconds * 1000;
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, intervalSeconds * 1000));
const res = await fetch(`${AUTH_BASE}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
client_id: clientId(),
device_code: deviceCode,
}),
});
const json = await res.json();
if (res.ok) {
const tokens = normalizeTokens(json);
await writeTokens(tokens);
return tokens;
}
if (json.error === 'authorization_pending') continue;
if (json.error === 'slow_down') { intervalSeconds += 5; continue; }
throw new Error(`token: ${json.error} — ${json.error_description || ''}`);
}
throw new Error('Device code expired before authorization completed');
}
async function refresh(refreshToken) {
const res = await fetch(`${AUTH_BASE}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: clientId(),
refresh_token: refreshToken,
scope: SCOPES,
}),
});
const json = await res.json();
if (!res.ok) throw new Error(`refresh: ${json.error} — ${json.error_description || ''}`);
return normalizeTokens(json);
}
function normalizeTokens(json) {
return {
accessToken: json.access_token,
refreshToken: json.refresh_token,
expiresAt: Date.now() + (json.expires_in ?? 3600) * 1000,
scope: json.scope,
};
}
export async function getAccessToken() {
const tokens = await readTokens();
if (!tokens) throw new Error('Not authenticated. Call the `authenticate` tool first.');
if (Date.now() < tokens.expiresAt - 60_000) return tokens.accessToken;
const fresh = await refresh(tokens.refreshToken);
await writeTokens(fresh);
return fresh.accessToken;
}
export async function isAuthenticated() {
const tokens = await readTokens();
return !!tokens?.refreshToken;
}
export { CONFIG_DIR, TOKEN_FILE };