From 7d41c1ca15ba3b9e21bd5a1a9daf32d1985fe816 Mon Sep 17 00:00:00 2001 From: ang3l12 Date: Wed, 8 Jul 2026 15:13:48 -0600 Subject: [PATCH] Warn before the client secret expires (SP_SECRET_EXPIRES) Azure client secrets lapse silently into opaque 401s (AADSTS7000222). Add a self-reported expiry date (SP_SECRET_EXPIRES=YYYY-MM-DD) and a shared secretExpiryStatus() helper in graph.mjs; surface warnings <30 days out via the CLI (stderr on every command + test output), MCP server startup log, /health, and the sharepoint_test tool. Documented in both .env.examples, setup.md, and READMEs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/sharepoint-lists/.env.example | 5 +++ .../sharepoint-lists/references/setup.md | 3 ++ .../sharepoint-lists/scripts/lib/graph.mjs | 43 +++++++++++++++++++ .../skills/sharepoint-lists/scripts/sp.mjs | 8 +++- README.md | 1 + mcp-server/.env.example | 5 +++ mcp-server/README.md | 1 + mcp-server/index.mjs | 28 ++++++++++-- 8 files changed, 89 insertions(+), 5 deletions(-) diff --git a/.claude/skills/sharepoint-lists/.env.example b/.claude/skills/sharepoint-lists/.env.example index dd8beec..af52316 100644 --- a/.claude/skills/sharepoint-lists/.env.example +++ b/.claude/skills/sharepoint-lists/.env.example @@ -7,5 +7,10 @@ SP_TENANT_ID=00000000-0000-0000-0000-000000000000 SP_CLIENT_ID=00000000-0000-0000-0000-000000000000 SP_CLIENT_SECRET=your-client-secret-value-here +# When the client secret expires (shown in Entra > Certificates & secrets). +# Optional but recommended: the CLI warns on stderr when <30 days remain, +# instead of a surprise 401 later. +# SP_SECRET_EXPIRES=2027-01-01 + # Optional: default site so you can omit --site on every command. # SP_SITE_URL=https://contoso.sharepoint.com/sites/Marketing diff --git a/.claude/skills/sharepoint-lists/references/setup.md b/.claude/skills/sharepoint-lists/references/setup.md index 3334625..593307f 100644 --- a/.claude/skills/sharepoint-lists/references/setup.md +++ b/.claude/skills/sharepoint-lists/references/setup.md @@ -26,6 +26,9 @@ On the app's **Overview** page, copy: 1. App → **Certificates & secrets** → **Client secrets** → **New client secret**. 2. Set an expiry (e.g. 6–12 months — note when it expires; you'll have to rotate). 3. Copy the secret **Value** immediately (it's only shown once) → `SP_CLIENT_SECRET`. +4. Record the expiry date in `.env` as `SP_SECRET_EXPIRES=YYYY-MM-DD` — the CLI + and MCP server use it to warn you 30 days ahead instead of failing cold with + a 401 (AADSTS7000222) when the secret lapses. > Certificates are more secure than secrets for production. This skill uses a > secret for simplicity; swapping to a certificate is a future enhancement. diff --git a/.claude/skills/sharepoint-lists/scripts/lib/graph.mjs b/.claude/skills/sharepoint-lists/scripts/lib/graph.mjs index 2aeec22..8985797 100644 --- a/.claude/skills/sharepoint-lists/scripts/lib/graph.mjs +++ b/.claude/skills/sharepoint-lists/scripts/lib/graph.mjs @@ -327,6 +327,49 @@ export class SharePointListsClient { } } +/** + * Report how close the app's client secret is to expiring, so consumers can + * warn BEFORE auth starts failing with an opaque 401 (AADSTS7000222). + * + * Azure can't be queried for its own secret's expiry with app-only creds, so + * the date is self-reported: set SP_SECRET_EXPIRES=YYYY-MM-DD in .env when you + * create/rotate the secret (the Entra portal shows it in Certificates & secrets). + * + * @param {string} [expiresStr] The configured date; falsy → null (feature unused). + * @returns {null | { expiresAt: string, daysLeft: number, level: "ok"|"warning"|"expired", message: string|null }} + */ +export function secretExpiryStatus(expiresStr, { warnDays = 30 } = {}) { + if (!expiresStr) return null; + const exp = new Date(expiresStr); + if (Number.isNaN(exp.getTime())) { + return { + expiresAt: expiresStr, + daysLeft: NaN, + level: "warning", + message: `SP_SECRET_EXPIRES is set but unparseable ("${expiresStr}") — use YYYY-MM-DD.`, + }; + } + const daysLeft = Math.floor((exp.getTime() - Date.now()) / 86_400_000); + const expiresAt = exp.toISOString().slice(0, 10); + if (daysLeft < 0) { + return { + expiresAt, + daysLeft, + level: "expired", + message: `Client secret EXPIRED ${-daysLeft} day(s) ago (${expiresAt}). Auth will fail with 401 — create a new secret in Entra (Certificates & secrets), update SP_CLIENT_SECRET and SP_SECRET_EXPIRES.`, + }; + } + if (daysLeft <= warnDays) { + return { + expiresAt, + daysLeft, + level: "warning", + message: `Client secret expires in ${daysLeft} day(s) (${expiresAt}) — rotate it soon: new secret in Entra, then update SP_CLIENT_SECRET and SP_SECRET_EXPIRES.`, + }; + } + return { expiresAt, daysLeft, level: "ok", message: null }; +} + /** Flatten the Graph item shape into { id, fields, webUrl, ...meta }. */ function simplifyItem(item) { if (!item) return item; diff --git a/.claude/skills/sharepoint-lists/scripts/sp.mjs b/.claude/skills/sharepoint-lists/scripts/sp.mjs index 26d0e43..9e724ef 100644 --- a/.claude/skills/sharepoint-lists/scripts/sp.mjs +++ b/.claude/skills/sharepoint-lists/scripts/sp.mjs @@ -25,7 +25,7 @@ import { readFileSync, existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { SharePointListsClient, GraphError } from "./lib/graph.mjs"; +import { SharePointListsClient, GraphError, secretExpiryStatus } from "./lib/graph.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -57,6 +57,11 @@ async function main() { clientSecret: env("SP_CLIENT_SECRET", "AZURE_CLIENT_SECRET"), }); + // Nag (on stderr, so stdout stays clean JSON) when the client secret is + // close to its self-reported expiry — see secretExpiryStatus in graph.mjs. + const expiry = secretExpiryStatus(process.env.SP_SECRET_EXPIRES); + if (expiry?.message) process.stderr.write(`⚠ ${expiry.message}\n`); + const site = flags.site || process.env.SP_SITE_URL; const needSite = () => { if (!site) throw new Error("Missing --site URL (or set SP_SITE_URL)."); @@ -75,6 +80,7 @@ async function main() { switch (command) { case "test": result = await client.test(site); + if (expiry) result.secretExpiry = expiry; break; case "lists": result = await client.listLists(await needSite()); diff --git a/README.md b/README.md index 3c04f63..661c19a 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ a containerized/remote deployment **must** use the HTTP transport. |---|---|---| | `SP_TENANT_ID` / `SP_CLIENT_ID` / `SP_CLIENT_SECRET` | yes | from the app registration | | `SP_SITE_URL` | no | default site so tools can omit the `site` argument | +| `SP_SECRET_EXPIRES` | no | client secret expiry (`YYYY-MM-DD`); warns via CLI/`/health`/`sharepoint_test` when <30 days remain | | `SP_READONLY` | no | `true` → read-only tool set | | `MCP_AUTH_TOKEN` | HTTP only | shared bearer token required on `/mcp`; empty = unauthenticated | | `PORT` | HTTP only | listen port (default 3838) | diff --git a/mcp-server/.env.example b/mcp-server/.env.example index 80b174e..b68c1e3 100644 --- a/mcp-server/.env.example +++ b/mcp-server/.env.example @@ -3,6 +3,11 @@ SP_TENANT_ID=00000000-0000-0000-0000-000000000000 SP_CLIENT_ID=00000000-0000-0000-0000-000000000000 SP_CLIENT_SECRET=your-client-secret-value-here +# When the client secret expires (shown in Entra > Certificates & secrets). +# Optional but recommended: the server warns at startup, in /health, and in the +# sharepoint_test tool when <30 days remain — instead of a surprise 401 later. +# SP_SECRET_EXPIRES=2027-01-01 + # Optional: default site so tools can omit the `site` argument. # SP_SITE_URL=https://contoso.sharepoint.com/sites/Marketing diff --git a/mcp-server/README.md b/mcp-server/README.md index 0365c2b..735965c 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -41,6 +41,7 @@ Credentials come from the environment or `mcp-server/.env` (git-ignored). See |---|---|---| | `SP_TENANT_ID` / `SP_CLIENT_ID` / `SP_CLIENT_SECRET` | yes | app registration | | `SP_SITE_URL` | no | default site so tools can omit `site` | +| `SP_SECRET_EXPIRES` | no | client secret's expiry date (`YYYY-MM-DD`); warns at startup, in `/health`, and in `sharepoint_test` when <30 days remain | | `SP_READONLY` | no | `true` → read-only tool set | | `MCP_AUTH_TOKEN` | HTTP only | shared bearer token required on `/mcp`; empty = unauthenticated | | `PORT` | HTTP only | listen port (default 3838) | diff --git a/mcp-server/index.mjs b/mcp-server/index.mjs index 6f8b0d4..f243a96 100644 --- a/mcp-server/index.mjs +++ b/mcp-server/index.mjs @@ -29,7 +29,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; // Single source of truth — the same client the skill's CLI uses. -import { SharePointListsClient, GraphError } from "../.claude/skills/sharepoint-lists/scripts/lib/graph.mjs"; +import { SharePointListsClient, GraphError, secretExpiryStatus } from "../.claude/skills/sharepoint-lists/scripts/lib/graph.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); loadDotEnv(); @@ -42,6 +42,15 @@ const client = new SharePointListsClient({ clientSecret: process.env.SP_CLIENT_SECRET, }); +// Self-reported client-secret expiry (SP_SECRET_EXPIRES=YYYY-MM-DD) — warn +// loudly before auth starts failing with an opaque 401. Computed fresh on each +// use so a long-running container keeps counting down. +const secretExpiry = () => secretExpiryStatus(process.env.SP_SECRET_EXPIRES); +{ + const s = secretExpiry(); + if (s?.message) console.error(`[sharepoint-lists] ⚠ ${s.message}`); +} + // ---- helpers -------------------------------------------------------------- const ok = (data) => ({ @@ -97,7 +106,10 @@ function buildServer() { }, async ({ site }) => { try { - return ok(await client.test(site || process.env.SP_SITE_URL)); + const result = await client.test(site || process.env.SP_SITE_URL); + const s = secretExpiry(); + if (s) result.secretExpiry = s; + return ok(result); } catch (e) { return fail(e); } @@ -277,8 +289,16 @@ async function runHttp() { res.status(401).json({ error: "Unauthorized" }); }; - // Liveness probe (no secrets) — intentionally unauthenticated for healthchecks. - app.get("/health", (_req, res) => res.json({ ok: true, readonly: READONLY })); + // Liveness probe (no secrets — expiry days-left only) — intentionally + // unauthenticated so healthchecks/monitors can watch it. + app.get("/health", (_req, res) => { + const s = secretExpiry(); + res.json({ + ok: true, + readonly: READONLY, + ...(s ? { secretExpiry: { level: s.level, daysLeft: s.daysLeft, expiresAt: s.expiresAt } } : {}), + }); + }); // Stateless: a fresh server + transport per request. app.post("/mcp", requireAuth, async (req, res) => {