/// // // Shared helpers for pb_hooks/*.pb.js callbacks. // // PocketBase's JSVM serializes and runs each registered hook callback as its // own isolated program, so a plain top-level function declared in a *.pb.js // file is NOT in scope inside a callback at call time. Reusable helpers must // instead live in a plain (non *.pb.js, so it isn't auto-loaded as a hook) // module and be pulled in per-callback via require(`${__hooks}/utils.js`). // See: https://github.com/pocketbase/pocketbase/discussions/3599 // // Loaded modules use a shared registry across callback invocations, so keep // this file stateless. (Deliberate exception: the warn-once latch below, which // exists precisely BECAUSE the registry is shared — it dedupes the env-missing // warning across the bootstrap hook and the per-minute cron tick.) // // Keep resolveRole in sync with src/lib/azureAuth.js's resolveRole (the // canonical, unit-tested version used by the frontend). let warnedEnvMissing = false; module.exports = { /** * Resolve a user's role from their e-mail and the ENTRA_ADMIN_EMAILS allow-list. * @param {string} email * @returns {"admin"|"user"} */ resolveRole: function (email) { const raw = ($os.getenv("ENTRA_ADMIN_EMAILS") || "").toLowerCase(); const allow = raw.split(",").map(function (s) { return s.trim(); }).filter(Boolean); return allow.indexOf(String(email || "").toLowerCase()) !== -1 ? "admin" : "user"; }, /** * Reconcile the Entra (Azure) OIDC provider on the team_members auth * collection from the ENTRA_* environment variables (issue #18). * * Idempotent: compares the desired provider config against the stored one * and only saves when something actually changed, so it is safe to call on * every bootstrap and cron tick. Keeping this OUT of the one-shot migration * means late or rotated secrets are picked up on the next start/tick without * any manual re-apply. * * @param {core.App} app * @returns {"collection-missing"|"not-auth-yet"|"env-missing"|"in-sync"|"updated"} */ reconcileEntraOidc: function (app) { let col; try { col = app.findCollectionByNameOrId("team_members"); } catch (_) { return "collection-missing"; // migration has not created it yet } if (col.type !== "auth") { return "not-auth-yet"; // pre-conversion PIN-era collection } const tenant = $os.getenv("ENTRA_TENANT_ID") || "common"; const clientId = $os.getenv("ENTRA_CLIENT_ID") || ""; const clientSecret = $os.getenv("ENTRA_CLIENT_SECRET") || ""; if (!clientId || !clientSecret) { if (!warnedEnvMissing) { warnedEnvMissing = true; console.log( "WARN entra_oidc: ENTRA_CLIENT_ID / ENTRA_CLIENT_SECRET not set — Microsoft login stays " + "disabled until the env vars are provided (they are reconciled automatically on startup)." ); } return "env-missing"; } warnedEnvMissing = false; // env restored — re-arm the warning for future rotations const desired = { name: "oidc", clientId: clientId, clientSecret: clientSecret, authURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize", tokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token", userInfoURL: "https://graph.microsoft.com/oidc/userinfo", displayName: "Microsoft", pkce: true, }; const providers = (col.oauth2 && col.oauth2.providers) || []; const current = providers.length === 1 ? providers[0] : null; const inSync = !!current && col.oauth2.enabled === true && current.name === desired.name && current.clientId === desired.clientId && current.clientSecret === desired.clientSecret && current.authURL === desired.authURL && current.tokenURL === desired.tokenURL && current.userInfoURL === desired.userInfoURL && current.displayName === desired.displayName; if (inSync) { return "in-sync"; } col.oauth2.enabled = true; col.oauth2.providers = [desired]; app.save(col); return "updated"; }, };