dfe246b7ca
- outlook-auth.js: one-time device-code flow against Microsoft login; stores the refresh token in the outlook-tokens compose volume - update-outlook.js: per-minute cron fetching the unread count and the latest unread messages via Microsoft Graph; caches access tokens and persists rotated refresh tokens - Widget is hidden until the mailbox is actually authorized, so it stays out of sight while (admin) consent is pending - New external card for Outlook (Office 365) with the official icon - compose: pass OUTLOOK_CLIENT_ID/OUTLOOK_TENANT/GITEA_TOKEN from .env - README: Entra ID app registration steps and the tenant-ID hint for directories not resolvable via the organizations endpoint
191 lines
6.3 KiB
JavaScript
191 lines
6.3 KiB
JavaScript
// ---------------------------------------------------------------------------
|
|
// 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);
|
|
}); |