--- a/html/pages/logon.inc.php
+++ b/html/pages/logon.inc.php
@@ -81,6 +81,29 @@
                             print_form($form);
                             unset($form);
 
+                            if (!empty($config['auth_entra_enabled'])) {
+                                echo '<div class="form-horizontal"><div class="controls login-divider">
+                                        <div class="login-divider-line text-muted"><span>or</span></div>
+                                        <a href="entra-login.php" class="btn btn-default btn-large btn-ms-signin">
+                                          <svg class="ms-logo" width="18" height="18" viewBox="0 0 21 21" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
+                                            <rect x="1" y="1" width="9" height="9" fill="#f25022"/>
+                                            <rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
+                                            <rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
+                                            <rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
+                                          </svg>
+                                          <span>Sign in with Microsoft</span>
+                                        </a>
+                                      </div></div>
+                                      <style>
+                                        .login-divider { margin-top: 20px; }
+                                        .login-divider-line { display: flex; align-items: center; max-width: 270px; margin-bottom: 16px; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
+                                        .login-divider-line::before, .login-divider-line::after { content: ""; flex: 1 1 auto; height: 1px; background: currentColor; opacity: .25; }
+                                        .login-divider-line span { padding: 0 12px; }
+                                        .btn-ms-signin { display: inline-flex; align-items: center; gap: 10px; }
+                                        .btn-ms-signin .ms-logo { flex: none; }
+                                      </style>';
+                            }
+
                             // Logout message, see reauth_with_message(). Kept until the next successful
                             // login, so every open tab landing here shows the same reason.
                             if (isset($_SESSION['auth_message'])) {
--- a/includes/defaults.inc.php
+++ b/includes/defaults.inc.php
@@ -1092,6 +1092,30 @@
 #$config['auth_ad_groups']['support']['level'] = 1; // Only login access, for access to devices/entities require bind entity permissions
 #$config['auth_ad_groups']['disabled']['level'] = 0; // Just disabled login, default if no assigned to other groups
 
+// Entra ID (Azure AD) SSO
+// This is NOT an auth_mechanism value - it's an extra "Sign in with Microsoft" button
+// alongside the normal mysql login, implemented in html/entra-login.php,
+// html/entra-callback.php and html/includes/authentication/entra-oidc.inc.php.
+// Set tenant/client id/secret in config.php (never commit real secrets here).
+
+$config['auth_entra_enabled']        = FALSE; // master switch; also controls the login button
+$config['auth_entra_tenant_id']      = '';    // Entra tenant ID (GUID) or verified domain name
+$config['auth_entra_client_id']      = '';    // App registration (client) ID
+$config['auth_entra_client_secret']  = '';    // App registration client secret value
+#$config['auth_entra_redirect_uri']  = '';    // Optional override; default is base_url . '/entra-callback.php'
+$config['auth_entra_scope']          = 'openid profile email';
+
+$config['auth_entra_auto_provision'] = TRUE;  // Create a local user row on first SSO login
+$config['auth_entra_sync_profile']   = TRUE;  // Refresh level/email/realname from Entra on every login
+$config['auth_entra_default_level']  = 0;     // Level for users with no mapped app role (0 = disabled, must be assigned a role in Entra)
+
+// Map Entra app role names (from the app registration's "App roles", assigned to
+// users/groups in the Enterprise Application) to Observium user levels.
+#$config['auth_entra_role_map']['Observium.Admin']       = 10; // Full administrative access
+#$config['auth_entra_role_map']['Observium.SecureWrite'] = 9;  // Global Secure Read/Write
+#$config['auth_entra_role_map']['Observium.SecureRead']  = 7;  // Global read access with secured info (ie rancid configs)
+#$config['auth_entra_role_map']['Observium.Read']        = 5;  // Global read access
+
 // RADIUS Authentication
 
 $config['auth_radius_server']  = ['127.0.0.1']; // RADIUS server list
--- a/html/includes/authenticate-functions.inc.php
+++ b/html/includes/authenticate-functions.inc.php
@@ -95,6 +95,19 @@
 }
 
 /**
+ * Account source for display. SSO-provisioned accounts keep type 'mysql' so every
+ * type-filtered auth lookup still finds them; their real source is in user_options.
+ */
+function auth_user_source($user) {
+    $options = json_decode((string)($user['user_options'] ?? ''), TRUE);
+    if (is_array($options) && ($options['auth_source'] ?? NULL) === 'entra') {
+        return 'EntraID';
+    }
+
+    return $user['type'];
+}
+
+/**
  * Refresh authorization from current local account state while the session is open.
  * External directory state is synchronized by the backend's authentication paths.
  * The optional failure reason is a fixed audit-log label, never credential data.
--- a/html/pages/user_edit.inc.php
+++ b/html/pages/user_edit.inc.php
@@ -265,7 +265,7 @@
                                     </tr>
                                     <tr>
                                         <th>User Source</th>
-                                        <td><?php echo(get_type_class_label($user_data['type'], 'user_type')); ?></td>
+                                        <td><?php echo(get_type_class_label(auth_user_source($user_data), 'user_type')); ?></td>
                                     </tr>
                                 </table>
 
@@ -627,7 +627,7 @@
         <td>' . escape_html($user['realname']) . '</td>
         <td>' . escape_html($user['email']) . '</td>
         <td>' . $login . '</td>
-        <td>' . get_type_class_label($user['type'], 'user_type') . '</td>
+        <td>' . get_type_class_label(auth_user_source($user), 'user_type') . '</td>
       </tr>';
         }
 
--- /dev/null
+++ b/html/includes/authentication/entra-oidc.inc.php
@@ -0,0 +1,484 @@
+<?php
+/**
+ * Observium
+ *
+ *   This file is part of Observium.
+ *
+ * @package        observium
+ * @subpackage     authentication
+ *
+ */
+
+/**
+ * Microsoft Entra ID (Azure AD) SSO support.
+ *
+ * This is NOT wired up as a `auth_mechanism` value. It is an additional login
+ * path on top of the normal mysql auth: users land on the usual logon form and
+ * can either log in with a local password, or click "Sign in with Microsoft"
+ * to go through this OIDC Authorization Code + PKCE flow. On success, the
+ * Entra-authenticated identity is mapped onto a normal local `users` row
+ * (type='mysql') and the Observium session is finalized exactly as a password
+ * login would be. This keeps roles, permissions, API tokens and user
+ * management (all keyed on $config['auth_mechanism'] === 'mysql') working
+ * unchanged for both local and SSO accounts.
+ *
+ * New configuration settings (set in config.php):
+ *
+ * auth_entra_enabled         bool   master switch, also controls the login button
+ * auth_entra_tenant_id       string Entra tenant ID (GUID) or verified domain name
+ * auth_entra_client_id       string App registration (client) ID
+ * auth_entra_client_secret   string App registration client secret value
+ * auth_entra_redirect_uri    string Optional override; default base_url . '/entra-callback.php'
+ * auth_entra_scope           string Optional override; default 'openid profile email'
+ * auth_entra_role_map        array  Entra app role name => Observium user level
+ * auth_entra_default_level   int    Level for users with no mapped app role (default 0 = disabled)
+ * auth_entra_auto_provision  bool   Create a local user row on first SSO login (default TRUE)
+ * auth_entra_sync_profile    bool   Refresh level/email/realname from Entra on every login (default TRUE)
+ */
+
+// --- PKCE / misc helpers --------------------------------------------------
+
+function entra_base64url_encode($data) {
+    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
+}
+
+function entra_base64url_decode($data) {
+    $remainder = strlen($data) % 4;
+    if ($remainder) {
+        $data .= str_repeat('=', 4 - $remainder);
+    }
+    return base64_decode(strtr($data, '-_', '+/'));
+}
+
+function entra_pkce_generate() {
+    $verifier  = entra_base64url_encode(random_bytes(32));
+    $challenge = entra_base64url_encode(hash('sha256', $verifier, TRUE));
+    return [ $verifier, $challenge ];
+}
+
+function entra_redirect_uri() {
+    global $config;
+
+    if (!empty($config['auth_entra_redirect_uri'])) {
+        return $config['auth_entra_redirect_uri'];
+    }
+
+    return rtrim($config['base_url'], '/') . '/entra-callback.php';
+}
+
+// --- OIDC discovery / JWKS, file-cached to avoid a round trip per login --
+
+function entra_oidc_discovery() {
+    global $config;
+
+    $tenant = $config['auth_entra_tenant_id'] ?? '';
+    if (safe_empty($tenant)) {
+        print_debug('EntraID: auth_entra_tenant_id is not set');
+        return FALSE;
+    }
+
+    $cache_file = rtrim($config['temp_dir'], '/') . '/entra_oidc_discovery_' . preg_replace('/[^a-zA-Z0-9_.-]/', '_', $tenant) . '.json';
+    if (is_file($cache_file) && (time() - filemtime($cache_file)) < 21600) {
+        $cached = json_decode((string)file_get_contents($cache_file), TRUE);
+        if (is_array($cached) && !empty($cached['token_endpoint'])) {
+            return $cached;
+        }
+    }
+
+    $url      = 'https://login.microsoftonline.com/' . rawurlencode($tenant) . '/v2.0/.well-known/openid-configuration';
+    $response = get_http_request($url, [ 'timeout' => 10 ]);
+
+    if ($response === FALSE || empty($GLOBALS['request_status'])) {
+        print_debug('EntraID: OIDC discovery request failed');
+        return FALSE;
+    }
+
+    $data = json_decode($response, TRUE);
+    if (!is_array($data) || empty($data['token_endpoint']) || empty($data['authorization_endpoint']) || empty($data['jwks_uri'])) {
+        print_debug('EntraID: OIDC discovery response invalid');
+        return FALSE;
+    }
+
+    @file_put_contents($cache_file, json_encode($data));
+
+    return $data;
+}
+
+function entra_oidc_jwks($jwks_uri) {
+    global $config;
+
+    $cache_file = rtrim($config['temp_dir'], '/') . '/entra_oidc_jwks_' . hash('sha256', $jwks_uri) . '.json';
+    if (is_file($cache_file) && (time() - filemtime($cache_file)) < 21600) {
+        $cached = json_decode((string)file_get_contents($cache_file), TRUE);
+        if (is_array($cached) && !empty($cached['keys'])) {
+            return $cached['keys'];
+        }
+    }
+
+    $response = get_http_request($jwks_uri, [ 'timeout' => 10 ]);
+    if ($response === FALSE || empty($GLOBALS['request_status'])) {
+        print_debug('EntraID: JWKS request failed');
+        return FALSE;
+    }
+
+    $data = json_decode($response, TRUE);
+    if (!is_array($data) || empty($data['keys'])) {
+        print_debug('EntraID: JWKS response invalid');
+        return FALSE;
+    }
+
+    @file_put_contents($cache_file, json_encode($data));
+
+    return $data['keys'];
+}
+
+// --- Authorize URL / token exchange ---------------------------------------
+
+function entra_build_authorize_url($state, $nonce, $code_challenge) {
+    global $config;
+
+    if (empty($config['auth_entra_enabled']) || safe_empty($config['auth_entra_tenant_id']) || safe_empty($config['auth_entra_client_id'])) {
+        return FALSE;
+    }
+
+    $discovery = entra_oidc_discovery();
+    if (!$discovery) {
+        return FALSE;
+    }
+
+    $params = [
+        'client_id'             => $config['auth_entra_client_id'],
+        'response_type'         => 'code',
+        'redirect_uri'          => entra_redirect_uri(),
+        'response_mode'         => 'query',
+        'scope'                 => $config['auth_entra_scope'] ?? 'openid profile email',
+        'state'                 => $state,
+        'nonce'                 => $nonce,
+        'code_challenge'        => $code_challenge,
+        'code_challenge_method' => 'S256',
+    ];
+
+    return $discovery['authorization_endpoint'] . '?' . http_build_query($params);
+}
+
+function entra_exchange_code($code, $code_verifier) {
+    global $config;
+
+    $discovery = entra_oidc_discovery();
+    if (!$discovery) {
+        return FALSE;
+    }
+
+    $body = http_build_query([
+        'client_id'     => $config['auth_entra_client_id'],
+        'client_secret' => $config['auth_entra_client_secret'],
+        'grant_type'    => 'authorization_code',
+        'code'          => $code,
+        'redirect_uri'  => entra_redirect_uri(),
+        'code_verifier' => $code_verifier,
+    ]);
+
+    $response = get_http_request($discovery['token_endpoint'], [
+        'method'  => 'POST',
+        'content' => $body,
+        'header'  => "Content-Type: application/x-www-form-urlencoded\r\n",
+        'timeout' => 15,
+    ]);
+
+    if ($response === FALSE) {
+        print_debug('EntraID: token exchange request failed');
+        return FALSE;
+    }
+
+    $data = json_decode($response, TRUE);
+
+    if (empty($GLOBALS['request_status'])) {
+        print_debug('EntraID: token exchange rejected: ' . ($data['error'] ?? 'unknown') . ' - ' . ($data['error_description'] ?? ''));
+        return FALSE;
+    }
+
+    if (!is_array($data) || empty($data['id_token'])) {
+        print_debug('EntraID: token exchange response has no id_token');
+        return FALSE;
+    }
+
+    return $data;
+}
+
+// --- RSA JWK -> PEM (no external libs; small ASN.1 DER builder) ----------
+
+function entra_der_length($len) {
+    if ($len < 0x80) {
+        return chr($len);
+    }
+    $bytes = '';
+    while ($len > 0) {
+        $bytes = chr($len & 0xFF) . $bytes;
+        $len >>= 8;
+    }
+    return chr(0x80 | strlen($bytes)) . $bytes;
+}
+
+function entra_der_integer($bytes) {
+    if (strlen($bytes) === 0) {
+        $bytes = "\x00";
+    }
+    if ((ord($bytes[0]) & 0x80) !== 0) {
+        // Keep the integer positive: DER integers are signed two's complement
+        $bytes = "\x00" . $bytes;
+    }
+    return "\x02" . entra_der_length(strlen($bytes)) . $bytes;
+}
+
+function entra_der_sequence($bytes) {
+    return "\x30" . entra_der_length(strlen($bytes)) . $bytes;
+}
+
+function entra_der_bitstring($bytes) {
+    $bytes = "\x00" . $bytes; // 0 unused bits
+    return "\x03" . entra_der_length(strlen($bytes)) . $bytes;
+}
+
+function entra_jwk_to_pem($jwk) {
+    if (($jwk['kty'] ?? '') !== 'RSA' || empty($jwk['n']) || empty($jwk['e'])) {
+        return FALSE;
+    }
+
+    $modulus  = entra_base64url_decode($jwk['n']);
+    $exponent = entra_base64url_decode($jwk['e']);
+    if ($modulus === FALSE || $exponent === FALSE || $modulus === '' || $exponent === '') {
+        return FALSE;
+    }
+
+    $rsa_oid        = "\x30\x0D\x06\x09\x2A\x86\x48\x86\xF7\x0D\x01\x01\x01\x05\x00"; // SEQUENCE(OID rsaEncryption, NULL)
+    $rsa_pubkey_seq = entra_der_sequence(entra_der_integer($modulus) . entra_der_integer($exponent));
+    $spki           = entra_der_sequence($rsa_oid . entra_der_bitstring($rsa_pubkey_seq));
+
+    return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), 64, "\n") . "-----END PUBLIC KEY-----\n";
+}
+
+// --- ID token verification --------------------------------------------------
+
+/**
+ * Verify an Entra ID id_token: RS256 signature against Entra's published JWKS,
+ * then issuer, audience, expiry/not-before and nonce.
+ *
+ * @return array|FALSE Decoded token claims on success, FALSE on any failure
+ */
+function entra_verify_id_token($id_token, $expected_nonce) {
+    global $config;
+
+    $parts = explode('.', $id_token);
+    if (count($parts) !== 3) {
+        print_debug('EntraID: malformed id_token');
+        return FALSE;
+    }
+    [ $header_b64, $payload_b64, $sig_b64 ] = $parts;
+
+    $header  = json_decode(entra_base64url_decode($header_b64), TRUE);
+    $payload = json_decode(entra_base64url_decode($payload_b64), TRUE);
+    $sig     = entra_base64url_decode($sig_b64);
+
+    if (!is_array($header) || !is_array($payload) || $sig === FALSE || $sig === '') {
+        print_debug('EntraID: could not decode id_token');
+        return FALSE;
+    }
+
+    if (($header['alg'] ?? '') !== 'RS256') {
+        print_debug('EntraID: unexpected id_token alg: ' . ($header['alg'] ?? ''));
+        return FALSE;
+    }
+
+    $discovery = entra_oidc_discovery();
+    if (!$discovery) {
+        return FALSE;
+    }
+
+    $keys = entra_oidc_jwks($discovery['jwks_uri']);
+    if (!$keys) {
+        return FALSE;
+    }
+
+    $kid = $header['kid'] ?? NULL;
+    $pem = FALSE;
+    foreach ($keys as $key) {
+        if ($kid === NULL || ($key['kid'] ?? NULL) === $kid) {
+            $pem = entra_jwk_to_pem($key);
+            if ($pem !== FALSE) {
+                break;
+            }
+        }
+    }
+
+    if ($pem === FALSE) {
+        print_debug('EntraID: no matching JWKS key for kid=' . $kid);
+        return FALSE;
+    }
+
+    $pubkey = openssl_pkey_get_public($pem);
+    if ($pubkey === FALSE) {
+        print_debug('EntraID: could not load RSA public key from JWKS');
+        return FALSE;
+    }
+
+    $valid = openssl_verify($header_b64 . '.' . $payload_b64, $sig, $pubkey, OPENSSL_ALGO_SHA256);
+    if ($valid !== 1) {
+        print_debug('EntraID: id_token signature verification failed');
+        return FALSE;
+    }
+
+    $expected_issuers = [ 'https://login.microsoftonline.com/' . $config['auth_entra_tenant_id'] . '/v2.0' ];
+    if (!empty($discovery['issuer'])) {
+        // Entra discovery returns the issuer with the tenant's GUID resolved, even when
+        // auth_entra_tenant_id is a domain name - accept either form.
+        $expected_issuers[] = $discovery['issuer'];
+    }
+    if (!in_array($payload['iss'] ?? NULL, $expected_issuers, TRUE)) {
+        print_debug('EntraID: unexpected issuer: ' . ($payload['iss'] ?? ''));
+        return FALSE;
+    }
+
+    if (($payload['aud'] ?? NULL) !== $config['auth_entra_client_id']) {
+        print_debug('EntraID: unexpected audience: ' . ($payload['aud'] ?? ''));
+        return FALSE;
+    }
+
+    $now  = time();
+    $skew = 120;
+    if (!isset($payload['exp']) || $now > ((int)$payload['exp'] + $skew)) {
+        print_debug('EntraID: id_token expired');
+        return FALSE;
+    }
+    if (isset($payload['nbf']) && $now < ((int)$payload['nbf'] - $skew)) {
+        print_debug('EntraID: id_token not yet valid');
+        return FALSE;
+    }
+
+    if (!isset($payload['nonce']) || !hash_equals((string)$expected_nonce, (string)$payload['nonce'])) {
+        print_debug('EntraID: nonce mismatch');
+        return FALSE;
+    }
+
+    return $payload;
+}
+
+// --- App role -> Observium level, and local user provisioning ------------
+
+function entra_map_level($roles) {
+    global $config;
+
+    if (!is_array($roles)) {
+        $roles = [];
+    }
+
+    $map   = $config['auth_entra_role_map'] ?? [];
+    $level = NULL;
+    foreach ($roles as $role) {
+        if (isset($map[$role]) && ($level === NULL || $map[$role] > $level)) {
+            $level = $map[$role];
+        }
+    }
+
+    if ($level === NULL) {
+        $level = $config['auth_entra_default_level'] ?? 0;
+    }
+
+    return (int)$level;
+}
+
+/**
+ * Find-or-create the local (type='mysql') user backing an Entra login.
+ * An existing account's password is never touched here. A brand new account
+ * gets a random, unusable local password - sign-in is via Entra only, unless
+ * an admin later sets a real password from Admin > Users.
+ */
+function entra_provision_user($username, $email, $realname, $level) {
+    global $config;
+
+    if (mysql_auth_user_exists($username)) {
+        if (!empty($config['auth_entra_sync_profile'])) {
+            $update = [ 'level' => $level ];
+            if (!safe_empty($email)) {
+                $update['email'] = $email;
+            }
+            if (!safe_empty($realname)) {
+                $update['realname'] = $realname;
+            }
+            dbUpdate($update, 'users', '`username` = ? AND `type` = ?', [ $username, 'mysql' ]);
+        }
+
+        return TRUE;
+    }
+
+    if (empty($config['auth_entra_auto_provision'])) {
+        print_debug('EntraID: user does not exist locally and auto-provisioning is disabled: ' . $username);
+        return FALSE;
+    }
+
+    $random_password = bin2hex(random_bytes(32));
+
+    if (!mysql_adduser($username, $random_password, $level, (string)$email, (string)$realname, 0,
+                       'Provisioned automatically via Entra ID SSO')) {
+        return FALSE;
+    }
+
+    // Shown as the account's source by auth_user_source(); type stays 'mysql' on purpose.
+    dbUpdate([ 'user_options' => json_encode([ 'auth_source' => 'entra' ]) ], 'users',
+             '`username` = ? AND `type` = ?', [ $username, 'mysql' ]);
+
+    return TRUE;
+}
+
+/**
+ * Finalize an Entra-authenticated login exactly as authenticate.inc.php does
+ * for a successful password login: regenerate the session id, mark it
+ * authenticated, then call auth_session_refresh() - the same call a password
+ * login makes - to resolve userlevel and, critically, set
+ * $_SESSION['auth_password_fingerprint']. Skipping that call logs the session
+ * straight back out on the very next request ("session password fingerprint
+ * missing"), since nothing else ever sets that fingerprint for an SSO login.
+ *
+ * @return bool TRUE on success, FALSE if auth_session_refresh() rejects the account
+ */
+function entra_finalize_login($username) {
+    global $config;
+
+    session_set_var('username', $username);
+    session_regenerate_login_id();
+    session_set_var('auth_mechanism', $config['auth_mechanism']);
+    session_set_var('authenticated', TRUE);
+    session_set_var('entra_sso', TRUE);
+
+    // Each session_set_var() call above opens, writes and closes the session on its
+    // own. Reopen once here so the raw $_SESSION writes below (user_id, and
+    // userlevel/fingerprint inside auth_session_refresh()) land in the same, still-open
+    // session - otherwise the next session_set_var() call below would reopen the
+    // session by reloading the OLDER, already-closed copy from storage and silently
+    // wipe them before they are ever saved.
+    @session_start();
+    $_SESSION['user_id'] = auth_user_id($username);
+
+    $refresh_failure = NULL;
+    if (!auth_session_refresh(TRUE, $refresh_failure)) {
+        print_debug('EntraID: auth_session_refresh rejected the login: ' . $refresh_failure);
+        session_write_close();
+        session_logout(FALSE, 'EntraID: ' . $refresh_failure);
+        return FALSE;
+    }
+
+    // Persist user_id/userlevel/fingerprint now, before the requesttoken check below
+    // potentially reopens (and would otherwise reload the stale pre-refresh state).
+    session_write_close();
+
+    authlog('EntraID', 'login');
+
+    if (empty($_SESSION['requesttoken'])) {
+        session_set_var('requesttoken', random_hex(64));
+    }
+
+    permissions_cache($_SESSION['user_id']);
+
+    return TRUE;
+}
+
+// EOF
--- /dev/null
+++ b/html/entra-login.php
@@ -0,0 +1,45 @@
+<?php
+/**
+ * Observium
+ *
+ *   This file is part of Observium.
+ *
+ * @package    observium
+ * @subpackage web
+ *
+ * Entra ID SSO: start the OIDC Authorization Code + PKCE flow.
+ * Reached by clicking "Sign in with Microsoft" on the logon page.
+ */
+
+include_once("../includes/observium.inc.php");
+include($config['html_dir'] . "/includes/authenticate.inc.php");
+include($config['html_dir'] . "/includes/authentication/entra-oidc.inc.php");
+
+if (!empty($_SESSION['authenticated'])) {
+    redirect_to_url($config['base_url']);
+    exit();
+}
+
+if (empty($config['auth_entra_enabled'])) {
+    display_error_http(404);
+    exit();
+}
+
+[ $verifier, $challenge ] = entra_pkce_generate();
+$state = random_hex(32);
+$nonce = random_hex(32);
+
+session_set_var('entra_oauth_state', $state);
+session_set_var('entra_oauth_nonce', $nonce);
+session_set_var('entra_oauth_verifier', $verifier);
+
+$authorize_url = entra_build_authorize_url($state, $nonce, $challenge);
+if ($authorize_url === FALSE) {
+    display_error_http(503, 'Entra ID SSO is not configured correctly');
+    exit();
+}
+
+redirect_to_url($authorize_url);
+exit();
+
+// EOF
--- /dev/null
+++ b/html/entra-callback.php
@@ -0,0 +1,100 @@
+<?php
+/**
+ * Observium
+ *
+ *   This file is part of Observium.
+ *
+ * @package    observium
+ * @subpackage web
+ *
+ * Entra ID SSO: OIDC redirect callback. Exchanges the authorization code for
+ * tokens, verifies the id_token, maps the Entra app role to an Observium
+ * level, provisions/updates the local user and finalizes the session.
+ */
+
+include_once("../includes/observium.inc.php");
+include($config['html_dir'] . "/includes/authenticate.inc.php");
+include($config['html_dir'] . "/includes/authentication/entra-oidc.inc.php");
+
+if (empty($config['auth_entra_enabled'])) {
+    display_error_http(404);
+    exit();
+}
+
+if (!empty($_SESSION['authenticated'])) {
+    redirect_to_url($config['base_url']);
+    exit();
+}
+
+$expected_state = $_SESSION['entra_oauth_state'] ?? NULL;
+$verifier        = $_SESSION['entra_oauth_verifier'] ?? NULL;
+$nonce           = $_SESSION['entra_oauth_nonce'] ?? NULL;
+
+session_unset_var('entra_oauth_state');
+session_unset_var('entra_oauth_nonce');
+session_unset_var('entra_oauth_verifier');
+
+$state_ok = $expected_state !== NULL && isset($_GET['state']) && hash_equals($expected_state, (string)$_GET['state']);
+
+if (!$state_ok || !$verifier || !$nonce) {
+    session_logout(FALSE, 'EntraID: invalid or expired login state');
+    reauth_with_message('Microsoft sign-in failed (expired login attempt), please try again.');
+    exit();
+}
+
+if (isset($_GET['error'])) {
+    session_logout(FALSE, 'EntraID: ' . $_GET['error'] . ' - ' . ($_GET['error_description'] ?? ''));
+    reauth_with_message('Microsoft sign-in was cancelled or denied.');
+    exit();
+}
+
+if (!isset($_GET['code'])) {
+    session_logout(FALSE, 'EntraID: callback missing authorization code');
+    reauth_with_message('Microsoft sign-in failed, please try again.');
+    exit();
+}
+
+$tokens = entra_exchange_code($_GET['code'], $verifier);
+if (!$tokens || empty($tokens['id_token'])) {
+    session_logout(FALSE, 'EntraID: token exchange failed');
+    reauth_with_message('Microsoft sign-in failed, please try again.');
+    exit();
+}
+
+$claims = entra_verify_id_token($tokens['id_token'], $nonce);
+if (!$claims) {
+    session_logout(FALSE, 'EntraID: id_token verification failed');
+    reauth_with_message('Microsoft sign-in failed, please try again.');
+    exit();
+}
+
+$username = $claims['preferred_username'] ?? $claims['email'] ?? $claims['upn'] ?? NULL;
+if (!is_valid_param($username, 'username')) {
+    session_logout(FALSE, 'EntraID: no usable username claim');
+    reauth_with_message('Microsoft sign-in failed: no usable account name was returned.');
+    exit();
+}
+
+$level = entra_map_level($claims['roles'] ?? []);
+
+if (!entra_provision_user($username, $claims['email'] ?? $username, $claims['name'] ?? '', $level)) {
+    session_logout(FALSE, 'EntraID: user provisioning failed (' . $username . ')');
+    reauth_with_message('Microsoft sign-in failed: could not set up the local account.');
+    exit();
+}
+
+if (auth_user_level($username) < 1) {
+    session_logout(FALSE, 'EntraID: user disabled or not assigned an app role (' . $username . ')');
+    reauth_with_message('Your Microsoft account is not authorized for Observium. Contact an administrator.');
+    exit();
+}
+
+if (!entra_finalize_login($username)) {
+    reauth_with_message('Account changed, please log in again!');
+    exit();
+}
+
+redirect_to_url($config['base_url']);
+exit();
+
+// EOF
