// --------------------------------------------------------------------------- // One-time device-code login for the Outlook unread-mail widget. // // Usage: // 1. Set OUTLOOK_CLIENT_ID (Entra ID app registration — see README) // 2. docker exec -it homelab-dashboard node /opt/dashboard/outlook-auth.js // (or locally: OUTLOOK_CLIENT_ID=... node api/outlook-auth.js) // 3. Open the printed URL, enter the code, sign in with your work account // // Stores the refresh token in a token file used by update-outlook.js. // --------------------------------------------------------------------------- const fs = require("fs"); const path = require("path"); const https = require("https"); const { URL } = require("url"); const CLIENT_ID = (process.env.OUTLOOK_CLIENT_ID || "").trim(); const TENANT = (process.env.OUTLOOK_TENANT || "organizations").trim(); const SCOPE = "https://graph.microsoft.com/Mail.Read offline_access"; // Hints for the most common Entra ID app-registration mistakes. const AUTH_HINTS = { 50059: "The client ID does not match any registered application. Copy the " + "'Application (client) ID' from App registrations > your app > Overview " + "(NOT the 'Directory (tenant) ID' or 'Object ID'), check for stray " + "whitespace/quotes, and verify the app exists in your directory. If the " + "client ID is correct, your directory may not be resolvable via the " + "generic 'organizations' endpoint — set OUTLOOK_TENANT to your " + "Directory (tenant) ID.", 700038: "The client ID is not a valid identifier — check for typos or truncation.", 700016: "Application not found in the tenant. The app registration may be in a " + "different directory, or the client ID is wrong.", 7000215: "The app is not configured as a public client. In the app registration: " + "Authentication > Allow public client flows > Yes.", }; function aadstsHint(body) { try { const codes = JSON.parse(body)?.error_codes || []; for (const c of codes) { if (AUTH_HINTS[c]) return `\nHint (AADSTS${c}): ${AUTH_HINTS[c]}`; } const m = /AADSTS(\d+)/.exec(String(body)); if (m && AUTH_HINTS[m[1]]) return `\nHint (AADSTS${m[1]}): ${AUTH_HINTS[m[1]]}`; } catch { // ignore } return ""; } function tokenFilePath() { const candidates = [ process.env.OUTLOOK_TOKEN_FILE, "/opt/dashboard/tokens/outlook.json", path.join(__dirname, "tokens", "outlook.json"), ].filter(Boolean); for (const p of candidates) { try { if (fs.existsSync(path.dirname(p))) return p; } catch { // ignore } } const fallback = path.join(__dirname, "tokens", "outlook.json"); fs.mkdirSync(path.dirname(fallback), { recursive: true }); return fallback; } function request(url, options = {}) { return new Promise((resolve, reject) => { const u = new URL(url); const req = https.request( u, { method: options.method || "GET", headers: options.headers || {}, timeout: options.timeout || 15000, }, (res) => { let body = ""; res.setEncoding("utf8"); res.on("data", (chunk) => (body += chunk)); res.on("end", () => resolve({ status: res.statusCode, body })); } ); req.on("error", reject); req.on("timeout", () => { req.destroy(); reject(new Error("Timeout")); }); if (options.body) req.write(options.body); req.end(); }); } function form(data) { return new URLSearchParams(data).toString(); } async function main() { if (!CLIENT_ID) { console.error("OUTLOOK_CLIENT_ID is not set."); console.error("Register an app in Entra ID (see README: Outlook unread mail) and put its client ID in .env."); process.exit(1); } const file = tokenFilePath(); console.log(`Token file: ${file}\n`); // 1. Request a device code const dcRes = await request( `https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/devicecode`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: form({ client_id: CLIENT_ID, scope: SCOPE }), } ); if (dcRes.status !== 200) { console.error(`Device code request failed (${dcRes.status}):`, dcRes.body); console.error(aadstsHint(dcRes.body)); process.exit(1); } const dc = JSON.parse(dcRes.body); console.log("----------------------------------------------------------------"); console.log(` 1. Open: ${dc.verification_uri}`); console.log(` 2. Code: ${dc.user_code}`); console.log(` 3. Sign in with your work account and approve the consent prompt`); console.log("----------------------------------------------------------------\n"); console.log("Waiting for login…"); // 2. Poll for the token const interval = (dc.interval || 5) * 1000; const deadline = Date.now() + (dc.expires_in || 900) * 1000; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, interval)); const res = await request( `https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: form({ client_id: CLIENT_ID, grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: dc.device_code, }), } ); const data = JSON.parse(res.body); if (res.status === 200 && data.access_token) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync( file, JSON.stringify( { refresh_token: data.refresh_token, access_token: data.access_token, expires_on: Date.now() + (data.expires_in || 3600) * 1000, }, null, 2 ) ); console.log("\nLogin successful — token stored. The unread-mail widget will pick it up within a minute."); return; } if (data.error === "authorization_pending") continue; if (data.error === "slow_down") { await new Promise((r) => setTimeout(r, 5000)); continue; } console.error(`\nLogin failed: ${data.error} — ${data.error_description || ""}`); process.exit(1); } console.error("\nDevice code expired — run the script again."); process.exit(1); } main().catch((err) => { console.error(err); process.exit(1); });