Skip to main content

Auth0 Actions for the AlphaSwarm multi-tenant rollout

The Phase 4 enforcement sweep relies on Auth0 to inject AlphaSwarm-namespaced custom claims (https://alphaswarm/org_id, https://alphaswarm/team_id, https://alphaswarm/workspace_id, https://alphaswarm/roles) into every access token. The Action snippet below ships those claims by calling the M2M-secured /_internal/auth0/sync endpoint during the post-login hook.

Why an Action?

Auth0 ships organisation / role data via the standard org_id / https://<tenant>/roles claims, but the AlphaSwarm scope chain (which workspace is the user's default, which team they're in, which roles map onto the four-tier lattice) lives in Postgres. The Action is the bridge: it asks the AlphaSwarm backend on every login

  • injects the result into the access token so the frontend + backend see a consistent set of custom claims from request 0.

Setup

  1. Create an Auth0 API for the AlphaSwarm backend (separate from the SPA Application). Set the audience to whatever you set ALPHASWARM_AUTH_OIDC_AUDIENCE to — e.g. https://api.alphaswarm.local.
  2. Create a Machine-to-Machine Application authorised against the AlphaSwarm API. Set its allowed grant types to client_credentials only. Copy the client_id + secret into the Action's secrets:
    • ALPHASWARM_M2M_CLIENT_ID
    • ALPHASWARM_M2M_CLIENT_SECRET
    • ALPHASWARM_API_AUDIENCE (the same audience as #1)
    • ALPHASWARM_BACKEND_URL (e.g. https://api.alphaswarm.local)
  3. Configure the AlphaSwarm backend:
    ALPHASWARM_AUTH_PROVIDER=auth0
    ALPHASWARM_AUTH_OIDC_ISSUER=https://your-tenant.auth0.com
    ALPHASWARM_AUTH_OIDC_AUDIENCE=https://api.alphaswarm.local
    ALPHASWARM_AUTH_M2M_ENABLED=true
    ALPHASWARM_AUTH_M2M_AUDIENCE=https://api.alphaswarm.local
    ALPHASWARM_AUTH_CLAIMS_NAMESPACE=https://alphaswarm/
    ALPHASWARM_AUTH_ENFORCE=permissive # flip to ``strict`` after the rollout dashboard is clean

The Action

Create a new Action under Library > Custom > Build new and attach it to the Login trigger.

/**
* AlphaSwarm post-login Action: lazy-provisions the internal user + injects
* AlphaSwarm-namespaced custom claims into the access token.
*
* Triggers on every login; the backend is idempotent.
*/
exports.onExecutePostLogin = async (event, api) => {
const namespace = "https://alphaswarm/";

// 1. Mint an M2M token for the AlphaSwarm backend.
const tokenResp = await fetch(`https://${event.tenant.id}.auth0.com/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: event.secrets.ALPHASWARM_M2M_CLIENT_ID,
client_secret: event.secrets.ALPHASWARM_M2M_CLIENT_SECRET,
audience: event.secrets.ALPHASWARM_API_AUDIENCE,
}),
});
if (!tokenResp.ok) {
api.access.deny("AlphaSwarm backend token mint failed");
return;
}
const { access_token } = await tokenResp.json();

// 2. Ask the AlphaSwarm backend to lazy-provision the user + return claims.
const syncResp = await fetch(`${event.secrets.ALPHASWARM_BACKEND_URL}/_internal/auth0/sync`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${access_token}`,
},
body: JSON.stringify({
user_id: event.user.user_id,
email: event.user.email,
organization_id: event.organization?.id,
organization_name: event.organization?.name,
}),
});
if (!syncResp.ok) {
// Soft failure: let the user in but log the issue. The
// backend's lazy provisioner will run on the first API call
// instead.
console.log("AlphaSwarm backend sync failed:", await syncResp.text());
return;
}
const claims = await syncResp.json();

// 3. Inject the claims into the access token.
if (claims.org_id) api.accessToken.setCustomClaim(`${namespace}org_id`, claims.org_id);
if (claims.team_id) api.accessToken.setCustomClaim(`${namespace}team_id`, claims.team_id);
if (claims.workspace_id) {
api.accessToken.setCustomClaim(`${namespace}workspace_id`, claims.workspace_id);
}
if (claims.roles && claims.roles.length) {
api.accessToken.setCustomClaim(`${namespace}roles`, claims.roles);
}
if (claims.internal_user_id) {
api.accessToken.setCustomClaim(`${namespace}user_id`, claims.internal_user_id);
}
};

Verification

  1. Log in via the SPA. The browser receives an access token.
  2. Decode it (e.g. jwt.io) and verify the https://alphaswarm/org_id / https://alphaswarm/roles claims are present.
  3. Hit GET /auth/whoami on the AlphaSwarm backend. The response should reflect the org / workspace from the Action — not the deterministic local-default seed.
  4. The Phase 6 frontend ContextBar should auto-populate the org / workspace on first render.

Failure modes

SymptomLikely cause
Token has no custom claimsAction attached to the wrong trigger or failed silently. Check the Action logs.
Backend 401 on /_internal/auth0/syncM2M token audience mismatch — Action audience must equal ALPHASWARM_AUTH_OIDC_AUDIENCE.
data.ownership.list_resources returns the local-default userprovision_user_from_claims is not running. Confirm the SPA is sending the Bearer header and ALPHASWARM_AUTH_PROVIDER != local.
Phase 4 enforcement mode showing too many 403sSome Postgres memberships rows are missing — run the lazy-provisioning sync once per user, or backfill manually.

See also

Phase 7 post-login Action (Auth0 + Microsoft federation)

This Action calls /_internal/auth0/sync, then injects returned custom claims into both the access token and ID token. The connection name mapping (requested_claims.connection) is forwarded so AlphaSwarm can record which IdP drove each login.

/**
* AlphaSwarm post-login Action.
* Calls /_internal/auth0/sync on the AlphaSwarm API and injects the
* returned custom claims into the access token. Also carries the
* Auth0 connection name (e.g. "azure-ad-myorg") so the AlphaSwarm audit
* log records WHICH IdP drove this login.
*
* Secrets used:
* ALPHASWARM_API_URL e.g. https://api.alphaswarm.example
* ALPHASWARM_M2M_CLIENT_ID Auth0 Management API M2M client id (reused)
* ALPHASWARM_M2M_CLIENT_SECRET Auth0 Management API M2M client secret
* ALPHASWARM_M2M_AUDIENCE Same as AlphaSwarm API resource identifier
*
* Set them at: Actions > Library > Custom > <your action> > Add Secret
*/
const NS = "https://alphaswarm/";

async function mintM2MToken(secrets) {
const url = `https://${event.tenant.id}.auth0.com/oauth/token`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: secrets.ALPHASWARM_M2M_CLIENT_ID,
client_secret: secrets.ALPHASWARM_M2M_CLIENT_SECRET,
audience: secrets.ALPHASWARM_M2M_AUDIENCE,
}),
});
if (!res.ok) return null;
const body = await res.json();
return body.access_token || null;
}

exports.onExecutePostLogin = async (event, api) => {
const aqpApi = event.secrets.ALPHASWARM_API_URL;
if (!aqpApi) return; // Action mis-configured; fail open
let token = await api.cache.get("alphaswarm_m2m_token");
if (!token || !token.value) {
const fresh = await mintM2MToken(event.secrets);
if (!fresh) return;
api.cache.set("alphaswarm_m2m_token", fresh, { ttl: 50 * 60 * 1000 });
token = { value: fresh };
}
const payload = {
user_id: event.user.user_id,
email: event.user.email,
organization_id: event.organization?.id,
organization_name: event.organization?.name,
requested_claims: {
connection: event.connection?.name,
strategy: event.connection?.strategy,
},
};
try {
const res = await fetch(`${aqpApi}/_internal/auth0/sync`, {
method: "POST",
headers: {
Authorization: `Bearer ${token.value}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!res.ok) return;
const claims = await res.json();
for (const [k, v] of Object.entries(claims)) {
if (v === null || v === undefined) continue;
api.accessToken.setCustomClaim(`${NS}${k}`, v);
api.idToken.setCustomClaim(`${NS}${k}`, v);
}
} catch (err) {
// Fail open — never block the user's login if AlphaSwarm API is down.
console.log("alphaswarm_sync_failed", err.message);
}
};

Custom claims it sets

ClaimMeaning
https://alphaswarm/org_idActive organization context resolved by AlphaSwarm.
https://alphaswarm/team_idTeam context resolved by AlphaSwarm.
https://alphaswarm/workspace_idActive workspace context.
https://alphaswarm/project_idActive project context.
https://alphaswarm/lab_idActive lab context.
https://alphaswarm/rolesRole list used by scope/membership checks.
https://alphaswarm/connectionAuth0 connection name, mapped from requested_claims.connection (for example azure-ad-myorg).
https://alphaswarm/internal_user_idAlphaSwarm internal user row identifier.

Why it fails open

The post-login Action should never block authentication because of a transient outage in AlphaSwarm. Missing one claim-sync cycle is recoverable on the next login, while hard-failing login creates a broader availability incident for all users.

Phase 8 — Step-up MFA addendum (AGENTS hard rule 52)

Step-up MFA on destructive routes (the kill switch, every /halt endpoint, BYOK / OAuth credential deletes, Terraform apply / destroy, organization invite issuance, broker-credential mutations, and the admin tenancy-strategy migration) is enforced server-side by alphaswarm.api.security_stepup.require_step_up. The FastAPI dep returns RFC 9470-compliant 401 responses with WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="...", max_age="..." when the access token fails the freshness or MFA-method check.

The frontend (alphaswarm_client/src/lib/auth/useStepUp.ts + apiFetch retry middleware) drives the SPA-side flow: a destructive button calls requestStepUp() to pre-flight an Auth0 popup with acr_values=http://schemas.openid.net/pape/policies/2007/06/multi-factor and max_age=0, then runs the original operation with the freshly minted token. For this round-trip to succeed, the post-login Action above MUST honour the acr_values parameter and force an MFA challenge when the caller requested it.

Add the snippet below to the Phase 7 post-login Action (don't duplicate — extend the existing exports.onExecutePostLogin):

exports.onExecutePostLogin = async (event, api) => {
// ... (the Phase 7 JIT-sync body stays as-is) ...

// ---- Phase 8: Adaptive MFA + step-up enforcement ------------------

// The SPA / CLI / agent caller can explicitly request fresh MFA by
// passing acr_values=http://schemas.openid.net/pape/policies/2007/06/multi-factor
// on /authorize. The Action MUST trigger the MFA challenge when
// either (a) the caller requested it OR (b) Auth0's Adaptive MFA
// assessment flagged the login as high-risk.
const ACR_MFA = "http://schemas.openid.net/pape/policies/2007/06/multi-factor";
const acrRequested = Array.isArray(event.transaction?.acr_values)
? event.transaction.acr_values
: [];
const explicitlyAskedForMfa = acrRequested.includes(ACR_MFA);

const methods = Array.isArray(event.authentication?.methods)
? event.authentication.methods
: [];
const mfaAlreadyCompleted = methods.some(
(m) => m?.name === "mfa" || m?.name === "otp",
);

// Auth0's Adaptive MFA risk assessment — `low` / `medium` / `high`.
// Honour `high` automatically; `medium` is left to the caller's
// explicit request so the dashboard stays usable on shared offices.
const riskConfidence = event.authentication?.riskAssessment?.confidence;

const shouldTriggerMfa =
(explicitlyAskedForMfa && !mfaAlreadyCompleted) ||
riskConfidence === "high";

if (shouldTriggerMfa) {
// ``allowRememberBrowser: false`` because step-up is sized for
// destructive ops — we never want the browser to remember the
// "MFA satisfied" flag past the 180s freshness window.
api.multifactor.enable("any", { allowRememberBrowser: false });
}

// Surface a JIT-friendly hint to the SPA so the topbar can render
// "MFA required" pre-flight UI. Not security-sensitive — purely a
// UX accelerator. The backend NEVER trusts this claim; it always
// re-checks amr + auth_time on the access token.
api.idToken.setCustomClaim(`${NS}mfa_available`, true);
};

The multifactor.enable("any", ...) call triggers Auth0's enrolment or challenge surface, depending on whether the user has already registered a factor. Operators must enable at least one factor type in Security > Multi-factor Auth for the call to succeed.

Tested factor types

FactorAuth0 enrolmentNotes
OTP (TOTP)Authenticator appAlways recommended as the primary factor
WebAuthnRoaming or PlatformStrongest; emits amr: ["mfa", "swk"] or ["mfa", "hwk"]
PushAuth0 Guardian appSmooth UX for personal accounts
SMSPhone-basedDiscouraged for B2B per AGENTS rule 52
Email OTPMagic linkAcceptable; emits amr: ["mfa"]
Recovery codeBackupAlways provisioned alongside another factor

Step-up failure recovery

If the popup fails (browser blocked, user dismissed, network drop):

  • useStepUp.requestStepUp() returns null and surfaces a "MFA required" toast.
  • apiFetch's automatic 401 retry path also surrenders after one attempt; the route handler propagates the original 401 to the caller.
  • Operators with admin:tenant can fall back to the BFF /auth/login?acr_values=... redirect flow which uses a full-page redirect instead of a popup. The redirect callback returns to the original route and the user re-clicks the destructive button.

Phase 8 — Custom Token Exchange Profile (AGENTS hard rule 54)

The Phase 8 refactor introduces RFC 8693 delegated agent tokens — when AgentRuntime makes an HTTP MCP call on behalf of a user, it exchanges the user's access token for a narrower, agent-scoped token via Auth0 Custom Token Exchange. The minted token carries an act claim identifying the agent, while the top-level sub stays the human user — so RLS, memberships, and the audit ledger all see the full delegation chain.

Required Auth0 setup

  1. Create an M2M Application named alphaswarm-agent-broker.

    • Authorise it against the AlphaSwarm API record.
    • Allowed grant types: client_credentials (required) AND urn:ietf:params:oauth:grant-type:token-exchange (required).
    • Note the client_id + client_secret.
  2. Configure backend env vars:

    ALPHASWARM_AUTH_AGENT_TOKEN_EXCHANGE_ENABLED=true
    ALPHASWARM_AUTH_AGENT_BROKER_CLIENT_ID=<alphaswarm-agent-broker client_id>
    # client_secret resolves via CredentialResolver in prod
    # (Vault / cloud KMS) — env is the local-dev shortcut:
    ALPHASWARM_AUTH_AGENT_BROKER_CLIENT_SECRET=<alphaswarm-agent-broker client_secret>
    ALPHASWARM_AUTH_AGENT_DELEGATION_TTL_SECONDS=300
  3. Create a Custom Token Exchange Profile named alphaswarm-agent-delegation.

    • In the Auth0 Dashboard, navigate to Actions > Flows > Custom Token Exchange and click "Create Profile".
    • Profile name: exactly alphaswarm-agent-delegation (matches the subject_token_profile parameter the broker sends).
    • Target API: the AlphaSwarm API record (audience https://api.alpha-swarm.ai/ or your env equivalent).
    • Subject token types accepted: urn:ietf:params:oauth:token-type:access_token.
    • Allow Skipping User Consent: enabled (required for non-interactive flows per the Custom Token Exchange docs).
    • Allowed scopes: read:mcp:data, write:mcp:data, read:mcp:codebase, write:mcp:codebase. The Profile must reject any scope NOT on this list.

The Action body

Paste this into the Profile's Action body. The Action runs INSIDE the /oauth/token exchange request — it never returns prose to the caller, only the access token Auth0 mints.

/**
* alphaswarm-agent-delegation — Custom Token Exchange Profile Action.
*
* Sources:
* event.transaction.subject_token_payload — the human's verified
* access token claims (sub, org_id, permissions, ...).
* event.transaction.actor_token_payload — the agent broker M2M
* token claims (sub = "agent|<spec_name>").
*
* The Profile MUST be paired with the alphaswarm-agent-broker M2M client
* and the broker MUST NOT be allowed to call /oauth/token with any
* other Profile. Misusing this Profile mis-attributes audit rows.
*/
exports.onExecuteCustomTokenExchange = async (event, api) => {
const subject = event.transaction?.subject_token_payload;
const actor = event.transaction?.actor_token_payload;

if (!subject || typeof subject !== "object") {
api.access.rejectInvalidSubjectToken("subject token missing");
return;
}
if (!actor || typeof actor !== "object") {
api.access.rejectInvalidSubjectToken("actor assertion missing");
return;
}

const humanSub = subject.sub;
const agentSub = actor.sub;
if (!humanSub || !agentSub) {
api.access.rejectInvalidSubjectToken("missing sub claims");
return;
}
if (!String(agentSub).startsWith("agent|")) {
api.access.rejectInvalidSubjectToken(
"actor must identify an agent (sub must start with 'agent|')",
);
return;
}

// Bind the minted access token to the human user — RLS + members
// are evaluated against this sub by the AlphaSwarm backend.
api.authentication.setUserById(humanSub);

// Narrow audience + scopes regardless of what the subject token had.
api.accessToken.setAudience(event.secrets.ALPHASWARM_API_AUDIENCE);

// Whitelist of scopes the agent is allowed to inherit. New MCP
// surfaces must be added to BOTH this list AND the Profile's
// configured allowed scopes.
const ALLOWED_AGENT_SCOPES = [
"read:mcp:data",
"write:mcp:data",
"read:mcp:codebase",
"write:mcp:codebase",
];
const requested = (event.transaction?.requested_scopes || []).filter(
(s) => ALLOWED_AGENT_SCOPES.includes(s),
);
for (const s of requested) {
api.accessToken.addScope(s);
}

// The `act` claim is the standard RFC 8693 marker. AlphaSwarm's
// get_current_user dep reads it to flip Principal.actor_type to
// "agent" and stamp on_behalf_of_sub onto every audit row.
api.accessToken.setCustomClaim("act", {
sub: agentSub,
iss: `https://${event.secrets.AUTH0_DOMAIN}/`,
});

// AlphaSwarm-specific marker so the frontend / SIEM dashboards can filter.
api.accessToken.setCustomClaim("alphaswarm_delegated", true);

// Carry the human's org_id through so RLS sees the right tenant
// even when the agent is running in a Celery worker without
// X-AlphaSwarm-Org headers.
if (subject.org_id) {
api.accessToken.setCustomClaim("org_id", subject.org_id);
}
};

Secrets

  • ALPHASWARM_API_AUDIENCE — same value the operator sets in ALPHASWARM_AUTH_OIDC_AUDIENCE on the backend.
  • AUTH0_DOMAIN — the tenant domain (alphaswarm-prod.us.auth0.com or the custom domain).

Verification

  1. Stand the backend up with ALPHASWARM_AUTH_AGENT_TOKEN_EXCHANGE_ENABLED=true and the broker credentials populated.

  2. Run an end-to-end test where an agent calls a data MCP tool:

    from alphaswarm_agents.runtime import AgentRuntime
    from alphaswarm_agents.spec import AgentSpec

    spec = AgentSpec.from_yaml_path("configs/agents/research_lead.yaml")
    runtime = AgentRuntime(
    spec,
    context=test_ctx,
    user_access_token=human_token,
    )
    delegated = runtime.delegated_token_for_mcp()
    assert delegated is not None
    # Decode the token at jwt.io — should carry act.sub="agent|research_lead"
    # while sub stays the human's auth0|... identity.
  3. Hit /mcp/data/tools/data.catalog.lineage/invoke with the delegated token in the Authorization header. The response body should include the actor object with both the agent sub and the on-behalf-of sub.

  4. Query the audit ledger:

    SELECT created_at, user_id, actor_user_id, event_type,
    details->'delegation' AS delegation
    FROM security_audit_events
    WHERE event_type LIKE 'mcp%'
    ORDER BY created_at DESC LIMIT 5;

    The delegation JSON block should carry {"agent_subject": "agent|research_lead", "on_behalf_of_user_id": "auth0|...", "profile": "alphaswarm-agent-delegation"}.

Failure modes

SymptomLikely causeFix
400 invalid_request profile not foundProfile name typo or not yet created in DashboardMatch the name exactly: alphaswarm-agent-delegation
400 unauthorized_clientalphaswarm-agent-broker app missing token-exchange grant typeEnable on the M2M app
400 invalid_target scope rejectedProfile didn't include the scope in its allowed listAdd the scope to BOTH the Profile config and the Action's ALLOWED_AGENT_SCOPES list
MCP route returns 403 missing read:mcp:dataPermissions array on AlphaSwarm API record missing the scope, or RBAC option "Add permissions in access token" is offRe-enable both in API record settings
Audit row missing delegation blockCaller didn't pass agent_subject to emit_audit_eventThe MCP server route + bridge already pass it; legacy callers need to be updated

Phase 6 — IdP group sync Action (alphaswarm-idp-group-sync)

Generalises the existing post-login flow so each org can attach non-Entra IdPs (Google Workspace, AWS IAM Identity Center, Okta, OneLogin, JumpCloud, generic SAML/OIDC) and have their external group claims automatically promote to AlphaSwarm roles. Pairs with the IdpGroupMappingEditor admin UI and the /tenancy/orgs/{org_id}/idp-group-mappings routes.

How it fits the post-login pipeline

The existing alphaswarm-post-login Action handles the JIT user upsert and the AlphaSwarm-namespaced custom claims (Phase 4 + 7). This NEW Action runs AFTER alphaswarm-post-login in the same Login trigger and specifically handles the IdP-group → AlphaSwarm-role translation. They share the M2M token cache to avoid double-minting.

Required Auth0 setup

  1. Order the Actions. In Library > Custom > Triggers > Login, drag alphaswarm-post-login to position 1, then alphaswarm-idp-group-sync to position 2. The sync action depends on event.user.user_id being a valid AlphaSwarm user, which is guaranteed by the time the post-login JIT sync completes.

  2. No new secrets — re-uses the same ALPHASWARM_API_URL / ALPHASWARM_M2M_* secrets the post-login Action already needs.

The Action body

/**
* alphaswarm-idp-group-sync — post-login Action.
*
* Reads the user's external IdP group claims and posts them to
* /_internal/idp/sync-groups so the AlphaSwarm backend can upsert
* matching Membership rows per the per-org IdpGroupMapping table.
*/
const NS = "https://alphaswarm.internal/";

function _collectExternalGroups(event) {
// Different IdPs surface group memberships under different claim
// names. We collect every well-known shape and merge into one
// de-duplicated list.
const candidates = [
event.user?.groups, // Auth0 standard
event.user?.app_metadata?.groups,
event.user?.user_metadata?.groups,
event.user?.["http://schemas.microsoft.com/ws/2008/06/identity/claims/role"],
event.user?.identities?.[0]?.profileData?.groups,
];
const merged = new Set();
for (const c of candidates) {
if (!c) continue;
if (Array.isArray(c)) {
for (const g of c) {
if (typeof g === "string" && g.trim()) merged.add(g.trim());
}
} else if (typeof c === "string" && c.trim()) {
merged.add(c.trim());
}
}
return Array.from(merged);
}

function _connectionKind(event) {
// Map Auth0 connection strategy -> AlphaSwarm IdpConnectionRecord.connection_kind.
const strategy = (event.connection?.strategy || "").toLowerCase();
const name = (event.connection?.name || "").toLowerCase();
if (strategy === "waad" || name.includes("azure")) return "entra";
if (strategy === "google-workspace" || name.includes("google-workspace")) {
return "google_workspace";
}
if (name.includes("iam-identity-center") || name.includes("aws-sso")) {
return "aws_iam_identity_center";
}
if (strategy === "okta" || name.includes("okta")) return "okta";
if (strategy === "onelogin" || name.includes("onelogin")) return "onelogin";
if (strategy === "jumpcloud" || name.includes("jumpcloud")) return "jumpcloud";
if (strategy === "samlp") return "generic_saml";
if (strategy === "oidc") return "generic_oidc";
return null;
}

exports.onExecutePostLogin = async (event, api) => {
const groups = _collectExternalGroups(event);
if (groups.length === 0) return;
const kind = _connectionKind(event);
if (!kind) return;

// Re-use the M2M token cached by alphaswarm-post-login (same Action
// namespace) so we don't double-mint.
const token = (await api.cache.get("alphaswarm_m2m_token"))?.value;
if (!token) return;

const aqpApi = event.secrets.ALPHASWARM_API_URL;
if (!aqpApi) return;

try {
await fetch(`${aqpApi}/_internal/idp/sync-groups`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
user_id: event.user.user_id,
auth0_organization_id: event.organization?.id || null,
connection_kind: kind,
external_groups: groups,
}),
});
} catch (err) {
// Fail open — never block authentication because of a transient
// backend hiccup. The next login retries.
console.log("alphaswarm_idp_group_sync_failed:", err.message);
}
};

Wire-format the backend expects

The route /_internal/idp/sync-groups validates the M2M token via the same chain as /_internal/auth0/sync, then for every active IdpConnectionRecord of the matching connection_kind it looks up matching :class:IdpGroupMapping rows and upserts the corresponding :class:Membership rows.

Verification

  1. Stand the backend up with at least one active IdpConnectionRecord for the user's org + at least one IdpGroupMapping referencing one of the user's external groups.

  2. Sign in via the matching IdP.

  3. Hit /whoami and verify the memberships array contains the expected scope_kind / scope_id / role.

  4. Query security_audit_events:

    SELECT created_at, event_type, details
    FROM security_audit_events
    WHERE event_type = 'idp_group_mapping_created'
    OR event_type = 'auth0_log_stream:s';

Don't

  • Don't bake group → role mappings into the Action body itself. The whole point of IdpGroupMapping is operator-driven mapping changes via the UI without redeploying Actions.
  • Don't surface group lists in any visible UI or error message — some enterprise IdPs treat them as PII-adjacent.
  • Don't enable this Action without first creating at least one matching IdpConnectionRecord in status=active; the route is a no-op without an active connection, but the Action wastes API call budget if it's misconfigured at scale.