Skip to content

PolicyLogic Examples

All examples should be used as references. Depending on the NAS, you may need to add or remove AVPs in your implementation. 

Disconnect-Message OR Change-Of-Authorization

Disconnect a user's active session, or change it (for example its VLAN or bandwidth) without making them reconnect. The script finds the session in a sessions collection by Calling-Station-Id, then sends a Disconnect-Message (DM) or Change-of-Authorization (CoA) request to the NAS that's serving it. This assumes your accounting script already stores sessions in that collection, and that the NAS accepts DM/CoA on port 3799.

function getExistingSession() {
  // Look up the active session (assumes your accounting script stores sessions in the "sessions" collection)
  var session = Collection.get("sessions", "Calling-Station-Id", radius.request['Calling-Station-Id']);

  if (!session.ok) {
    fail("No active sessions found");
    return false;
  }

  // The NAS that is serving this session receives the DM or CoA request
  var nas_ip = session.data['NAS-IP-Address'];
  if (nas_ip) {
    sendDM(nas_ip);
    // Or change the session instead of disconnecting it, e.g. move it to VLAN 20:
    // sendCoA(nas_ip, { "Tunnel-Type": "13", "Tunnel-Medium-Type": "6", "Tunnel-Private-Group-Id": "20" });
  }
  return true;
}

function sendDM(nas_ip) {
  // Save the original request, then build a new one for the DM
  radius.old_request = radius.request;
  radius.request = {};
  radius.request['Calling-Station-Id'] = radius.old_request['Calling-Station-Id'];
  // Send Disconnect-Request
  return RadiusRequest("DM-REQUEST", nas_ip, 3799, "3000");
}

function sendCoA(nas_ip, avps) {
  // Save the original request, then build a new one for the CoA
  radius.old_request = radius.request;
  radius.request = {};
  radius.request['Calling-Station-Id'] = radius.old_request['Calling-Station-Id'];
  // Add the attributes to change on the session
  for (var name in avps) {
    radius.request[name] = avps[name];
  }
  // Send CoA-Request
  return RadiusRequest("COA-REQUEST", nas_ip, 3799, "3000");
}

Send PPSK (DPSK) response with dynamic vlan.

With Private PSK (also called DPSK), each device or user gets its own Wi-Fi password and VLAN on the same SSID. When a request's User-Name and User-Password match (the usual pattern for PSK requests from the NAS), the script looks up every entry in the ppsk collection for that SSID's Called-Station-Id, and returns their passphrases and VLANs as tagged tunnel attributes. It also saves the request to the sessions collection for an hour.

// Check for PPSK Auth
function checkPPSK() {
  if (radius.request['User-Name'] != radius.request['User-Password']) {
    return false;
  }

  var result = Collection.search("ppsk", "Called-Station-Id", radius.request['Called-Station-Id']);
  if (!result.ok || result.meta.count === 0) {
    return false;
  }

  var rows = result.data;
  for (var i = 0; i < rows.length; i++) {
    radius.reply['tag'][i] = {};
    radius.reply['tag'][i]['Tunnel-Password'] = rows[i]['Tunnel-Password'];
    radius.reply['tag'][i]['Tunnel-Private-Group-Id'] = rows[i]['Tunnel-Private-Group-Id'];
    radius.reply['tag'][i]['Tunnel-Type'] = rows[i]['Tunnel-Type'];
    radius.reply['tag'][i]['Tunnel-Medium-Type'] = rows[i]['Tunnel-Medium-Type'];
  }

  radius.reply['Reply-Message'] = "Success Auth";

  // Store the request in the "sessions" collection for 1 hour
  // Collection.upsert(collectionName, object, TTL, indexName, indexValue)
  Collection.upsert("sessions", radius.request, 3600, "Calling-Station-Id", radius.request['Calling-Station-Id']);

  success();
  return true;
}

Send Radius request to different AAA (proxy the request)

Forward a request to a partner's AAA server instead of authenticating it locally, based on the user's realm. Partner endpoints come from the config collection, so you can add or change partners without editing the script. The commented lines show how to set the destination, timeout and load-balancing options in the script instead.

//
// Determine whether the request has to be proxied to a partner network.
// getProxySettings(realm_or_identity) is internal function.
//
function proxyCheck() {
  const proxySettings = getProxySettings(radius.user['Realm']);
    if (proxySettings) {
        // Parameters for this realm is exists on the "config" collections. 
        const proxyInstance = new RadiusProxy();

        // Or set parameters manually.
        // proxyInstance.code = radius.code;
        // proxyInstance.dst = "1.2.3.4"; //radius.proxy['Proxy-Address'] = "1.2.3.4";
        // proxyInstance.dst_port = "1812"; // radius.proxy['Proxy-Address-Port'] = "1812";
        // proxyInstance.timeout = "2000";
        // proxyInstance.name = "MY_PROXY_PROVIDER";
        // proxyInstance.timeout_unreachable = "10000";
        // proxyInstance.balance_mode = "round-robin";
        // proxyInstance.dpd = "false";

        var  proxyResult = proxyInstance.send();
        if (proxyResult)
          return proxyResult;
    }
    return false;
}

Limit authentication by device MAC address or username

Two ways to control which devices a username can use: pin it to a known list of MAC addresses, or let it register a limited number of devices on its own.

Bind a username to specific MAC addresses

Restrict a username to a known set of devices. Each allowed pair is stored in a allowed_devices collection as { "User-Name": ..., "Calling-Station-Id": ... }; a request is accepted only if the incoming Calling-Station-Id matches one of the entries stored for that User-Name.

function checkAllowedDevice() {
  var username = radius.request['User-Name'];
  var mac = radius.request['Calling-Station-Id'];

  var result = Collection.search("allowed_devices", "User-Name", username);
  if (!result.ok || result.meta.count === 0) {
    fail("Username is not authorized for any device");
    return false;
  }

  var allowed = result.data.some(function (entry) {
    return entry['Calling-Station-Id'] === mac;
  });

  if (!allowed) {
    fail("This device is not authorized for this username");
    return false;
  }

  success();
  return true;
}

Populate allowed_devices in advance (via the API or the Collections page), or let PolicyLogic add a device automatically the first time a username authenticates, effectively pinning it to that device from then on:

function bindFirstDevice() {
  var username = radius.request['User-Name'];
  var mac = radius.request['Calling-Station-Id'];

  var result = Collection.search("allowed_devices", "User-Name", username);

  if (result.ok && result.meta.count > 0) {
    var allowed = result.data.some(function (entry) {
      return entry['Calling-Station-Id'] === mac;
    });
    if (!allowed) {
      fail("This username is already bound to a different device");
      return false;
    }
  } else {
    // First time we see this username: bind it to the current device.
    Collection.add("allowed_devices", { "User-Name": username, "Calling-Station-Id": mac }, 0);
  }

  success();
  return true;
}

Cap the number of distinct devices per username

Instead of a fixed allow-list, let each username register up to a fixed number of devices and reject any device beyond that limit. Known devices are stored in a user_devices collection with a TTL, so a device that stops being used eventually drops out of the count.

var MAX_DEVICES_PER_USER = 3;
var DEVICE_TTL = 2592000; // 30 days

function checkDeviceLimit() {
  var username = radius.request['User-Name'];
  var mac = radius.request['Calling-Station-Id'];

  var result = Collection.search("user_devices", "User-Name", username);
  var knownDevices = result.ok ? result.data : [];

  var alreadyKnown = knownDevices.some(function (entry) {
    return entry['Calling-Station-Id'] === mac;
  });

  if (!alreadyKnown && knownDevices.length >= MAX_DEVICES_PER_USER) {
    fail("Maximum number of devices reached for this username");
    return false;
  }

  if (!alreadyKnown) {
    Collection.add("user_devices", { "User-Name": username, "Calling-Station-Id": mac }, DEVICE_TTL);
  }

  success();
  return true;
}

MAC Authentication Bypass (MAB)

For devices that can't run 802.1X (printers, cameras, some IoT devices), authenticate by Calling-Station-Id alone against a pre-registered device list, and assign a VLAN from that record.

function checkMAB() {
  var mac = radius.request['Calling-Station-Id'];

  // "mab_devices" holds one entry per known device, e.g.
  // { "Calling-Station-Id": "aa:bb:cc:dd:ee:ff", "Vlan": "20" }
  var result = Collection.search("mab_devices", "Calling-Station-Id", mac);

  if (!result.ok || result.meta.count === 0) {
    fail("Unknown device, MAC Authentication Bypass denied");
    return false;
  }

  var device = result.data[0];
  radius.reply['Tunnel-Type'] = "13";         // RFC 2868: 13 = VLAN
  radius.reply['Tunnel-Medium-Type'] = "6";   // RFC 2868: 6 = IEEE-802
  radius.reply['Tunnel-Private-Group-Id'] = device['Vlan'];

  success();
  return true;
}

Lock out an account after repeated failed logins

Count failed attempts per username in a TTL'd collection, and reject further attempts once a threshold is reached within the window, even if the credentials supplied are correct. The counter resets on a successful login.

var MAX_FAILED_ATTEMPTS = 5;
var LOCKOUT_WINDOW = 900; // 15 minutes

function checkCredentialsWithLockout() {
  var username = radius.request['User-Name'];

  var lockout = Collection.search("failed_logins", "User-Name", username);
  if (lockout.ok && lockout.meta.count > 0 && lockout.data[0]['Count'] >= MAX_FAILED_ATTEMPTS) {
    fail("Account temporarily locked due to repeated failed logins");
    return false;
  }

  // Replace with your actual credential check.
  var validCredentials = radius.request['User-Password'] === "expected-value";

  if (!validCredentials) {
    recordFailedAttempt(username);
    fail("Invalid username or password");
    return false;
  }

  Collection.remove("failed_logins", "User-Name", username);
  success();
  return true;
}

function recordFailedAttempt(username) {
  var existing = Collection.search("failed_logins", "User-Name", username);
  var count = (existing.ok && existing.meta.count > 0) ? existing.data[0]['Count'] + 1 : 1;
  Collection.upsert("failed_logins", { "User-Name": username, "Count": count }, LOCKOUT_WINDOW, "User-Name", username);
}

Limit concurrent sessions per user

Track active sessions from accounting records, and either reject a new login or disconnect the oldest session once a user exceeds a fixed number of concurrent devices. Acct-Status-Type is a standard RADIUS accounting attribute (RFC 2866); it passes through into radius.request the same way as any other AVP.

var MAX_CONCURRENT_SESSIONS = 2;
var SESSION_TTL = 86400; // safety net in case a Stop record is ever lost

// Accounting entry point: keep the "active_sessions" collection in sync.
function trackSession() {
  var status = radius.request['Acct-Status-Type'];
  var sessionId = radius.request['Acct-Session-Id'];

  if (status === "Start" || status === "Interim-Update") {
    Collection.upsert("active_sessions", {
      "Acct-Session-Id": sessionId,
      "User-Name": radius.request['User-Name'],
      "Calling-Station-Id": radius.request['Calling-Station-Id'],
      "NAS-IP-Address": radius.request['NAS-IP-Address'],
      "Started": Date.now()
    }, SESSION_TTL, "Acct-Session-Id", sessionId);
  } else if (status === "Stop") {
    Collection.remove("active_sessions", "Acct-Session-Id", sessionId);
  }

  success();
}

// Auth entry point: enforce the limit before accepting a new session.
function checkSessionLimit() {
  var username = radius.request['User-Name'];
  var mac = radius.request['Calling-Station-Id'];

  var result = Collection.search("active_sessions", "User-Name", username);
  var sessions = result.ok ? result.data : [];

  // Reconnecting on the same device doesn't count as a new session.
  var sameDevice = sessions.some(function (s) { return s['Calling-Station-Id'] === mac; });

  if (!sameDevice && sessions.length >= MAX_CONCURRENT_SESSIONS) {
    // Pick one strategy: disconnect the oldest session to make room,
    // or reject the new request outright.
    var oldest = sessions.reduce(function (a, b) {
      return a['Started'] < b['Started'] ? a : b;
    });
    kickSession(oldest['NAS-IP-Address'], oldest['Calling-Station-Id']);
    // fail("Maximum concurrent sessions reached");
    // return false;
  }

  success();
  return true;
}

function kickSession(nas_ip, mac) {
  radius.old_request = radius.request;
  radius.request = {};
  radius.request['Calling-Station-Id'] = mac;
  return RadiusRequest("DM-REQUEST", nas_ip, 3799, "3000");
}

Proxy to a partner realm with failover

Send the request to a primary AAA server, and fall back to a secondary one if the primary doesn't respond. Builds on the realm-based proxy example above.

function proxyWithFailover() {
  var primary = new RadiusProxy();
  primary.dst = "10.0.0.1";
  primary.dst_port = "1812";
  primary.timeout = "2000";
  primary.name = "PRIMARY_AAA";
  primary.secret = Vault.read("primary_secret");

  var result = primary.send();

  if (!result) {
    log("Primary AAA server did not respond, failing over to secondary");
    var secondary = new RadiusProxy();
    secondary.dst = "10.0.0.2";
    secondary.dst_port = "1812";
    secondary.timeout = "2000";
    secondary.name = "SECONDARY_AAA";
    secondary.secret = Vault.read("secondary_secret");
    result = secondary.send();
  }

  return result !== undefined && result !== null ? result : false;
}

Assign a VLAN by device vendor

Look up the connecting device's NIC vendor from its MAC OUI, and route unrecognized vendors to a quarantine VLAN instead of the main network.

var CORPORATE_VENDORS = ["Dell Inc.", "Apple, Inc.", "Lenovo"];
var CORPORATE_VLAN = "10";
var QUARANTINE_VLAN = "99";

function assignVlanByVendor() {
  var vendor = getVendor(radius.request['Normalized-Mac']);
  var vlan = CORPORATE_VENDORS.indexOf(vendor) !== -1 ? CORPORATE_VLAN : QUARANTINE_VLAN;

  radius.reply['Tunnel-Type'] = "13";        // RFC 2868: 13 = VLAN
  radius.reply['Tunnel-Medium-Type'] = "6";  // RFC 2868: 6 = IEEE-802
  radius.reply['Tunnel-Private-Group-Id'] = vlan;

  success();
  return true;
}

Restrict access to a time window

Allow an account to authenticate only during a fixed window, for example a contractor or guest account limited to business hours. The server evaluates Date() in its own local timezone, so confirm what that is before relying on this in production.

var ALLOWED_START_HOUR = 8;  // 08:00
var ALLOWED_END_HOUR = 18;   // 18:00

function checkBusinessHours() {
  var now = new Date();
  var day = now.getDay();   // 0 = Sunday ... 6 = Saturday
  var hour = now.getHours();

  var isWeekday = day >= 1 && day <= 5;
  var isWithinHours = hour >= ALLOWED_START_HOUR && hour < ALLOWED_END_HOUR;

  if (!isWeekday || !isWithinHours) {
    fail("Access is only allowed on weekdays between 08:00 and 18:00");
    return false;
  }

  success();
  return true;
}

Validate certificate fields against an expected binding

Beyond checking that a certificate is valid, confirm one of its fields (here, the OU) matches what's expected for that account before granting access. Combines certificate revocation status with parseDN.

function verifyCertificateBinding() {
  if (radius.request['Inner-Auth-Method'] !== "X509") {
    fail("Certificate not found");
    return false;
  }

  // Confirm the certificate isn't revoked: use OCSP if it's enabled for this
  // certificate, otherwise fall back to SpherAAA's internal PKI status.
  var certStatus = radius.user.X509.OCSP_Response;
  if (certStatus) {
    if (certStatus !== "good") {
      fail("Certificate rejected, OCSP status: " + certStatus);
      return false;
    }
  } else if (!checkTLSCert()) {
    fail("Certificate is revoked or unknown");
    return false;
  }

  // Confirm the certificate's OU matches the department on file for this account.
  var subject = parseDN(radius.user.X509.SubjectDN);
  var expected = Collection.search("department_certs", "User-Name", radius.user['Identity']);

  if (!expected.ok || expected.meta.count === 0 || expected.data[0]['OU'] !== subject.OU) {
    fail("Certificate OU does not match the expected department");
    return false;
  }

  success();
  return true;
}

RFC5580

RFC 5580 defines RADIUS attributes for a user's location, such as a civic address and when it was recorded. This example adds location attributes to the request, for example before proxying it to a partner AAA server that expects them. Replace the address values with your own, and adjust Time-To-Live (12 hours here) to how long the location stays valid.

    radius.request['Location-Capable'] = "NAS-Location";
    radius.request['Location-Data'] = {
      'Index': '1',
      'Location': {
        'city': 'Chicago',
        'state': 'IL',
        'zip': '60603',
        'nam': 'NAM_Value',
        'plc': 'PLC_Value',
        'loc': 'LOC_Value',
        'lmk': 'LMK_Value'
      },
      'Country': 'US'
    };

    radius.request['Location-Information'] = {
      'Index': '1',
      'Code': '0',
      'Entity': '1',
      'Sighting-Time': convertToNTP64(Date.now()),
      'Time-To-Live': convertToNTP64(Date.now() + (43200*1000)),
      'Method': 'Manual'
    }

Call function in the new thread or parallel

Send the response first, and do slower work afterwards. Here the accounting script replies to the NAS straight away, then updates the sessions collection in the background half a second later with runParallel. This keeps accounting responses fast even when the follow-up work takes time.

function acct_handler() {

    //
    // To avoid delaying the Accounting-Response, execute the Session update function in parallel.
    // So first return Acct-Response
    success();
    //
    // and then insert / update "sessions" collection
    //
    runParallel('acct()', 500);
  }

  //
  // Update sessions collection.
  //
  function acct() {
    // function: Collection.upsert(collectionName, object, TTL, indexName, indexValue);
    Collection.upsert("sessions", radius.request, 3600, "Calling-Station-Id", radius.request['Calling-Station-Id']);
  }

Issue EAP-TLS certificate by calling SpherAAA API

Issue an EAP-TLS client certificate from a script by calling the SpherAAA PKI API, for example as part of a self-service onboarding flow. Replace the CA ID, the bearer token and the certificate details with your own. Keep the token in the Vault rather than in the script itself.

function issue_cert() {
  var ca_id = "6461706ef4befabdc81e6d19";
  var cert_type = "pem";
  var url = "https://aaa.spheralogic.com/api/pki/gen/?ca_id="+ca_id+"&cert_type="+cert_type

  var headers = {}
  headers["accept"] = "application/json"
  headers["Content-Type"] = "application/json"
  headers["Authorization"] = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...."

  var httpclient = new HTTPClient();
  httpclient.url = url;
  httpclient.headers = headers;
  httpclient.method = "POST";

  var data = {
    "ct": "US",
    "st": "NY",
    "city": "NewYork",
    "o": "Company",
    "ou": "USER_NAME/ID/Department_Name",
    "cn": "anonymous@company.com",
    "days": "365",
    "passphrase": "nopass",
    "comment": "EAP-TLS Client Cert",
    "email_send_as_file": false,
    "email_send_as_url": false,
    "ssid": "nossid"
  };
  httpclient.data = JSON.stringify(data);

  try {
    var response = httpclient.send();
    log("httpclient result: " + JSON.stringify(response));
  } catch (e) {
    log("Recived error: " + e.toString());
  }
}

Insert data to the Elasticsearch or OpenSearch (HTTP Basic Auth)

Send a copy of each request to Elasticsearch or OpenSearch for your own reporting and dashboards. The script posts the full radius object to an index over HTTPS with Basic authentication. Use it in the AUTH script, or in the ACCT script with the commented acct line. Replace the URL and credentials, and keep the credentials in the Vault rather than in the script. To avoid slowing down replies, you can run it with runParallel.

function ops_insert() {
  var url = "https://opensearch.domain.com:9200/my_index/_doc"
  var headers = {}
  var auth = Base64.encode("user:password")
  headers["Content-Type"] = "application/json"
  headers["Authorization"] = "Basic "+ auth
  var httpclient = new HTTPClient();
  httpclient.url = url;
  httpclient.headers = headers;
  httpclient.method = "POST";
  httpclient.disableSslVerification = false; 
  var data = {"sourcetype":"auth", "data":radius}
  //var data = {"sourcetype":"acct", "data":radius}
  httpclient.data = JSON.stringify(data);

  try {
    var response = httpclient.send();
    log("httpclient result: " + JSON.stringify(response));
  } catch (e) {
    log("Recived error: " + e.toString());
  }
}

EAP-TLS/TTLS/PEAP Authentication with Microsoft EntraID

Overview

This section shows a concise, practical approach to validate EAP‑TLS client certificates against Microsoft Entra ID (Azure AD) during a RADIUS EAP‑TLS exchange. It is based on SpherAAA extracting the client certificate field into an object and calling the EntraIDAuth.checkUser() method against EntraID.

Before using certificate-based EAP‑TLS for ongoing authentications, users must first be onboarded. Onboarding is an initial authentication that requires the user's real password (for example: EAP‑TTLS with PAP or GTC, or EAP‑PEAP with GTC). During onboarding SpherAAA authenticates the supplied credentials against EntraID (e.g., EntraIDAuth.checkCredentials(username, password)). On successful verification SpherAAA will:

  • Generate an EAP‑TLS client certificate or configuration package (PEM, PKCS#12, or Apple mobileconfig).
  • Embed the chosen identifier/OID (used later as the certificate OU or extension).
  • Deliver the certificate/configuration to the user via email (or another agreed delivery mechanism).

After onboarding completes and the client installs the certificate/configuration, subsequent authentications use EAP‑TLS for configured SSID. During those EAP‑TLS exchanges SpherAAA extracts the certificate identifier (OU/OID) from the presented client certificate and validates the account state by calling EntraIDAuth.checkUser(oid). If EntraID reports the user as active and the certificate mapping is valid (optinally), access is allowed; otherwise the request is rejected.

Implementers should ensure secure handling of secrets and certificates, record the mapping used during onboarding for later validation, and test the onboarding and EAP‑TLS flows in a staging tenant before production rollout.

Prerequisites

  • An registered application in Entra ID with Graph API permissions to read users (client credentials flow).
  • CA certificate that was imported or generated in SpherAAA PKI (the CA only needs to be trusted by SpherAAA PKI for interoperability; it does not need to be trusted by EntraID).

High-level flow

  1. Client performs EAP-TTLS with GTC or PAP (or EAP-PEAP with GTC)
  2. PolicyLogic verifies that authentication was done using credentials (username and password exists) and using EntraIDAuth.checkCredentials(username,password) method, sends request to EntraID.
  3. Upon success result from EntraIDAuth.checkCredentials, SpherAAA responds with Access-Accept to Access-Point.
  4. At the same time, SpherAAA uses the OID from EntraID response and calls pkiCertGen(oid), which creates an EAP-TLS certificate, uses the OID value as the OU field on the certificate, and sends that certificate configuration or payload to the client's email. The email is already known from the username or EntraID response.
  5. Configuration could be: Full chain PEM, PKCS#12 (for Android, Linux, etc.), or MobileConfig file for Apple products.
  6. After installing the configuration, Client performs EAP‑TLS and presents the client certificate to the RADIUS server.
  7. RADIUS script extracts the certificate OU field from the subject and PolicyLogic queries Microsoft Graph using EntraIDAuth.checkUser(oid) function to verify that this user is still active.
  8. If the response is success, SpherAAA allows access and, based on the response field, SpherAAA could set different VLANs or PolicyGroups.
  9. Otherwise, reject the access request.

Entra ID registration

  1. Sign in to the Azure portal (https://portal.azure.com) with an account that has at least Application Administrator + consent rights. Open Microsoft Entra ID.
  2. Go to: App registrations → New registration. Enter:

  3. Name: spheraaa
    Supported account types: Accounts in this organizational directory only (Single tenant)
    Redirect URI: leave empty
    Click Register

  4. On the Overview page copy and securely store:
    Application (client) ID
    Directory (tenant) ID

  5. Create a client secret: Certificates & secrets → Client secrets → New client secret.
    Description: spheraaa-client
    Expiration: choose shortest that fits rotation policy
    After creation copy the Secret Value immediately (cannot be retrieved later)

  6. Add Microsoft Graph application permission:
    API permissions → Add a permission → Microsoft Graph → Application permissions
    Select: Directory.Read.All
    Add permissions

  7. Grant admin consent:
    API permissions → Grant admin consent for → Confirm
    Verify Status = Granted for Directory.Read.All

Security notes:
* Store client_id, tenant_id, and client_secret only in Vault entries (never in source).
* Enforce secret rotation before expiry (calendar reminder + automation if possible).
* Keep permissions minimal (only Directory.Read.All).
* Review Entra audit logs after granting consent.
* Remove unused app registrations periodically.

SpherAAA Vault

After registering the app, add the credentials to the SpherAAA Vault so PolicyLogic can call Microsoft Graph.

  1. In SpherAAA UI: Collections → Vault → Create Entry.
  2. Create these vault entries (keys are the default names used by PolicyLogic examples):

  3. Key name: client_id
    Key value: <your-client-id>
    Expires: optional
    Note: Azure Application (client) ID

  4. Key name: tenant_id
    Key value: <your-tenant-id>
    Expires: optional
    Note: Azure Directory (tenant) ID

  5. Key name: client_secret
    Key value: <your-client-secret>
    Expires: recommended (set an expiration)
    Note: Application secret, keep this encrypted and rotate regularly

  6. Save each entry and verify PolicyLogic can read them in a test run.

Example script for PolicyLogic.

// Override this function on your main file. 
function verifyEapTls() {
  if (radius.request['Inner-Auth-Method'] === "X509") {
    // Check whether to perform OID-based authentication using EntraID.
    // Verify that the EAP identity is "anonymous@company.com",
    // which indicates the EAP-TLS certificate was issued during onboarding
    // (for example, via EAP-TTLS).
    if (radius.request['User-Name'] === "anonymous@company.com") {
      const subjectObjects = parseDN(radius.user.X509.SubjectDN);
      const entraResult = verifyEntraId(subjectObjects.OU);
      if (entraResult) {
        return true;
      }
    } else if (checkTLSCert()) {
      return true;
    } else {
      radius.reply['Reply-Message'] = "Certificate not found";
      return false;
    }
  } else {
    return false;
  }
}

// Verify credentials against EntraID
// more info https://spheralogic.com/wiki/policy/#entraidauth
function verifyEntraId(username, password) {

  // Read credentials from Vault
  const clientId = Vault.read("entraid-clientid");
  const tenantId = Vault.read("entraid-tenantid");
  const clientSecret = Vault.read("entraid-key");

  // Flag for cert generation
  var genCert = false;

  // Validate required parameters
  if (!clientId || !tenantId || !clientSecret) {
    throw new Error("Missing EntraID credentials in Vault");
  }

  // Initialize EntraIDAuth object
  const authClient = new EntraIDAuth();

  // Set parameters for the auth client
  authClient.client_id = clientId;
  authClient.tenant_id = tenantId;
  authClient.client_secret = clientSecret;

  // Attempt authentication
  var response;
  if (isEmail(username) && password !== undefined) {
    response = authClient.checkCredentials(username, password);
    genCert = true;
  } else
    response = authClient.checkUser(username);

  // Log for debug
  //log(response);

  const result = !!(response && response.exists);
  if (result) {
    if (genCert) {
      radius.reply['Reply-Message'] = ['UP Auth OK'];
      generateCert(response);
    } else
      radius.reply['Reply-Message'] = ['OID Auth OK'];
  }

  // Return true if authentication succeeded (user exists and access_token decoded)
  return result;
}

function generateCert(result) {
  log(result)
  var cert_request = {
    "ct": "US",
    "st": "NY",
    "city": "NewYork",
    "o": "Company",
    "ou": result.oid,
    "cn": "anonymous@company.com",
    "days": "365",
    "passphrase": "nopass",
    "comment": "EAP-TLS Client Cert",
    "email_send_as_file": result.upn,
    "email_send_as_url": false,
    "ssid": "MySecureWiFi", // Replace with your 802.1x (WPA-Ent) SSID.
    "ca_id": "687aa36926d95d8920665d72", // CA certificate ID.
    "cert_type": "pem" //cert_type: Supported formats: "pem","p12", "apple_mobileconfig".
  };

  // Log for debug
  // log(cert_request) 

  pkiGenCert(cert_request);
}

Route EAP-AKA subscribers to the right HSS, with proxy fallback for unknown carriers

An MVNO/aggregator problem: SpherAAA has a direct Diameter/SWx relationship with some carriers' HSS, but not all of them - subscribers from a carrier without a direct peer still need to authenticate, by proxying to their home 3GPP-AAA instead. Route on the subscriber's home realm (radius.user.Realm, e.g. wlan.mnc260.mcc310.3gppnetwork.org), falling back to the IMSI when the device sends no realm, and branch between EapAka (direct HSS auth) and EapAkaProxy (proxy) based on what's found:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// Route EAP-AKA subscribers to the right HSS, with proxy fallback for unknown carriers

// 1. Create collection "hss_routes", and add one entry per carrier with a direct HSS relationship:
// 
// { "realm": "wlan.mnc001.mcc001.3gppnetwork.org", "peer": "hss-carrier-a.example.com" }
// { "realm": "wlan.mnc260.mcc310.3gppnetwork.org", "peer": "HSS_GROUP_B" }  // a Diameter peer group name
// 
// 2. Include startEapAka() function inside of your start() function.
//
// function start() {
//  if (startEapAka()){
//     return;
//  }
//  if (shouldDoProxy()) {
//    return;
//  }
// ..}

function startEapAka() {
  var eapStatus = EapAkaIdentity();
  if (!eapStatus) {
    return false; // Not an EAP-AKA request at all
  }

  var identity = radius.user['3GPP-Identity'] && radius.user['3GPP-Identity']['Permanent-Identity'];
  if (!identity) {
    return true; // Still mid AKA-Identity negotiation - EapAkaIdentity() already replied
  }

  var realm = radius.user.Realm;
  var route = realm && Collection.get("hss_routes", "realm", realm);

  if (!realm) {
    // No realm sent - build it from the IMSI, trying a 3-digit then a 2-digit MNC
    var imsi = identity.substring(1).split('@')[0];
    var mcc = imsi.substring(0, 3);
    realm = "wlan.mnc" + imsi.substring(3, 6) + ".mcc" + mcc + ".3gppnetwork.org";
    route = Collection.get("hss_routes", "realm", realm);
    if (!route) {
      realm = "wlan.mnc0" + imsi.substring(3, 5) + ".mcc" + mcc + ".3gppnetwork.org";
      route = Collection.get("hss_routes", "realm", realm);
    }
  }

  if (route) {
    var eapaka = new EapAka();
    eapaka.peer_host = route['peer']; // a specific peer, or a peer group name
    eapaka.send();
  } else {
    log("No direct HSS route for " + realm + " - proxying to home 3GPP-AAA");
    var result = EapAkaProxy("3gpp-aaa.example.com", 1812, 5000);
    if (!result) {
      radius.reply['Reply-Message'] = "No route available for this carrier";
      fail();
    }
  }

  return true;
}

radius.diameter.request/radius.diameter.reply reflect the last SWx exchange either way, so a routing mistake (wrong peer, unreachable HSS) shows up directly in the request logs without extra log() calls.

Back to top