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) <noreply@anthropic.com>
This commit is contained in:
@@ -7,5 +7,10 @@ SP_TENANT_ID=00000000-0000-0000-0000-000000000000
|
|||||||
SP_CLIENT_ID=00000000-0000-0000-0000-000000000000
|
SP_CLIENT_ID=00000000-0000-0000-0000-000000000000
|
||||||
SP_CLIENT_SECRET=your-client-secret-value-here
|
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.
|
# Optional: default site so you can omit --site on every command.
|
||||||
# SP_SITE_URL=https://contoso.sharepoint.com/sites/Marketing
|
# SP_SITE_URL=https://contoso.sharepoint.com/sites/Marketing
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ On the app's **Overview** page, copy:
|
|||||||
1. App → **Certificates & secrets** → **Client secrets** → **New client secret**.
|
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).
|
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`.
|
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
|
> Certificates are more secure than secrets for production. This skill uses a
|
||||||
> secret for simplicity; swapping to a certificate is a future enhancement.
|
> secret for simplicity; swapping to a certificate is a future enhancement.
|
||||||
|
|||||||
@@ -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 }. */
|
/** Flatten the Graph item shape into { id, fields, webUrl, ...meta }. */
|
||||||
function simplifyItem(item) {
|
function simplifyItem(item) {
|
||||||
if (!item) return item;
|
if (!item) return item;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
import { readFileSync, existsSync } from "node:fs";
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
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));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -57,6 +57,11 @@ async function main() {
|
|||||||
clientSecret: env("SP_CLIENT_SECRET", "AZURE_CLIENT_SECRET"),
|
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 site = flags.site || process.env.SP_SITE_URL;
|
||||||
const needSite = () => {
|
const needSite = () => {
|
||||||
if (!site) throw new Error("Missing --site URL (or set SP_SITE_URL).");
|
if (!site) throw new Error("Missing --site URL (or set SP_SITE_URL).");
|
||||||
@@ -75,6 +80,7 @@ async function main() {
|
|||||||
switch (command) {
|
switch (command) {
|
||||||
case "test":
|
case "test":
|
||||||
result = await client.test(site);
|
result = await client.test(site);
|
||||||
|
if (expiry) result.secretExpiry = expiry;
|
||||||
break;
|
break;
|
||||||
case "lists":
|
case "lists":
|
||||||
result = await client.listLists(await needSite());
|
result = await client.listLists(await needSite());
|
||||||
|
|||||||
@@ -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_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_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 |
|
| `SP_READONLY` | no | `true` → read-only tool set |
|
||||||
| `MCP_AUTH_TOKEN` | HTTP only | shared bearer token required on `/mcp`; empty = unauthenticated |
|
| `MCP_AUTH_TOKEN` | HTTP only | shared bearer token required on `/mcp`; empty = unauthenticated |
|
||||||
| `PORT` | HTTP only | listen port (default 3838) |
|
| `PORT` | HTTP only | listen port (default 3838) |
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ SP_TENANT_ID=00000000-0000-0000-0000-000000000000
|
|||||||
SP_CLIENT_ID=00000000-0000-0000-0000-000000000000
|
SP_CLIENT_ID=00000000-0000-0000-0000-000000000000
|
||||||
SP_CLIENT_SECRET=your-client-secret-value-here
|
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.
|
# Optional: default site so tools can omit the `site` argument.
|
||||||
# SP_SITE_URL=https://contoso.sharepoint.com/sites/Marketing
|
# SP_SITE_URL=https://contoso.sharepoint.com/sites/Marketing
|
||||||
|
|
||||||
|
|||||||
@@ -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_TENANT_ID` / `SP_CLIENT_ID` / `SP_CLIENT_SECRET` | yes | app registration |
|
||||||
| `SP_SITE_URL` | no | default site so tools can omit `site` |
|
| `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 |
|
| `SP_READONLY` | no | `true` → read-only tool set |
|
||||||
| `MCP_AUTH_TOKEN` | HTTP only | shared bearer token required on `/mcp`; empty = unauthenticated |
|
| `MCP_AUTH_TOKEN` | HTTP only | shared bearer token required on `/mcp`; empty = unauthenticated |
|
||||||
| `PORT` | HTTP only | listen port (default 3838) |
|
| `PORT` | HTTP only | listen port (default 3838) |
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
// Single source of truth — the same client the skill's CLI uses.
|
// 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));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
loadDotEnv();
|
loadDotEnv();
|
||||||
@@ -42,6 +42,15 @@ const client = new SharePointListsClient({
|
|||||||
clientSecret: process.env.SP_CLIENT_SECRET,
|
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 --------------------------------------------------------------
|
// ---- helpers --------------------------------------------------------------
|
||||||
|
|
||||||
const ok = (data) => ({
|
const ok = (data) => ({
|
||||||
@@ -97,7 +106,10 @@ function buildServer() {
|
|||||||
},
|
},
|
||||||
async ({ site }) => {
|
async ({ site }) => {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
}
|
}
|
||||||
@@ -277,8 +289,16 @@ async function runHttp() {
|
|||||||
res.status(401).json({ error: "Unauthorized" });
|
res.status(401).json({ error: "Unauthorized" });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Liveness probe (no secrets) — intentionally unauthenticated for healthchecks.
|
// Liveness probe (no secrets — expiry days-left only) — intentionally
|
||||||
app.get("/health", (_req, res) => res.json({ ok: true, readonly: READONLY }));
|
// 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.
|
// Stateless: a fresh server + transport per request.
|
||||||
app.post("/mcp", requireAuth, async (req, res) => {
|
app.post("/mcp", requireAuth, async (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user