First Microsoft logins failed with 400 "email: Cannot be blank": Graph's
/oidc/userinfo endpoint omits the email claim for accounts without a
mail attribute, and team_members requires an e-mail (it also drives the
ENTRA_ADMIN_EMAILS role mapping). Reproduced against a mock Entra with
PocketBase v0.30.4; the user-supplied response body matched the
missing-email fingerprint exactly.
- provider config (reconciler + migration fast-path): userInfoURL is now
empty, so PocketBase reads the claims from the Entra id_token, which
always carries preferred_username (UPN) and email when available. The
#18 reconciler flips the already-deployed Labs provider automatically
on the next boot — no migration needed.
- pb_hooks/entra_oidc.pb.js: onRecordAuthWithOAuth2Request hook falls
back to the lowercased UPN when the email claim is absent. Guest UPNs
(ext_user#EXT#@tenant...) are excluded explicitly — "#" is RFC-valid
in an e-mail local part, so both a naive regex AND PocketBase's own
validation would accept them (caught by test V5). The hook also logs
every failed OAuth2 attempt with the underlying error to the container
log, which PocketBase otherwise only writes to its internal logs db.
Verified with a switchable mock-Entra matrix (8/8): no-email→UPN e-mail
+ allow-list role, email claim wins when present, no duplicate on
re-login, guest UPN yields a clean validation error, anonymous REST
create stays rejected, both log lines present. Regression: issue-22
matrix 9/9 (baseline pinned to 1a1351d now that #23 is merged), DoD
harness 15/15, npm test 112/112.
Closes #24
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
110 lines
4.4 KiB
JavaScript
110 lines
4.4 KiB
JavaScript
/// <reference path="../pb_data/types.d.ts" />
|
|
//
|
|
// 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",
|
|
// Deliberately empty: claims are read from the Entra id_token instead of
|
|
// Graph's /oidc/userinfo. The userinfo endpoint omits `email` for
|
|
// accounts without a mail attribute, while the id_token always carries
|
|
// `preferred_username` (UPN) which entra_oidc.pb.js uses as an e-mail
|
|
// fallback (issue #24).
|
|
userInfoURL: "",
|
|
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";
|
|
},
|
|
};
|