Introduction

Managing Linux endpoints with Microsoft Intune has come a long way, but if you have ever rolled out the Company Portal (the intune-portal app) on Ubuntu, you have probably run into one annoying thing: the device does not check in on its own. Users have to open the app and sign in before a check-in happens, and if they do not, compliance state quietly goes stale.

In this blogpost, I want to walk through how I automated the full lifecycle of the Intune app on Ubuntu - launching it at login, getting rid of the manual sign-in click, dealing with deployments that fail on some devices, and keeping the app up to date - all pushed through Intune itself. But to be completely honest up front: the Linux Intune agent is not nearly as mature as its Windows and macOS counterparts, and a couple of the things below are unsupported shims that you take ownership of. I will flag those clearly as we go.

Note

Everything here was tested on Ubuntu Desktop, which is the supported target for the Intune app (currently 24.04 and 26.04 LTS). Also, the scripts mentioned in the blogpost are created with AI Coding agents. While I have tested them, make sure to review them yourself as well.

How check-ins actually work on Linux

Before automating anything, it helps to understand why the check-in behaves the way it does. On Linux, the Intune app is not a single monolithic agent. It is actually three moving parts working together:

  • intune-daemon - a background component that does the actual check-in work, listening on a socket at /run/intune/daemon.socket.
  • microsoft-identity-broker - the piece that holds your tokens. It stores the refresh token and device certificate in the user’s keyring (the freedesktop Secret Service).
  • intune-portal - the GUI you actually see. It is a thin client that talks to the daemon and the broker.

The important consequence of this design is that a check-in needs a signed-in user session with an unlocked keyring. The daemon can do its job, but only when the broker can hand it a valid token, and the broker can only do that inside a user session where the keyring is available. That is the whole reason the app does not check in “in the background” the way you would expect on Windows.

There are really two separate problems hiding in the classic “users have to open the app and log in” complaint:

  • The app is not running, so nothing checks in.
  • The app is running but demands a full sign-in (email, password, MFA) instead of silently refreshing.

Problem two is almost always a keyring issue - if the login keyring is not unlocked automatically at session start, the broker cannot refresh silently and falls back to a full interactive sign-in. That is worth fixing separately (make sure the login keyring password matches the account password so PAM unlocks it), but in this blogpost I want to focus on problem one and on removing the manual steps.

Launching the company portal at login

The first step is making the Company Portal start automatically, so periodic check-ins can happen without the user remembering to open it. On Ubuntu we can do this with a .desktop autostart entry:

1[Desktop Entry]
2Type=Application
3Exec=intune-portal
4Hidden=false
5X-GNOME-Autostart-enabled=true
6Name=Intune Portal
7Comment=Launch the Microsoft Intune Portal at login so device check-ins occur

Pushing this in root context

Your first instinct is to drop this file in the home directory of the logged-in user via ~/.config/autostart/. That works if you do it by hand, but it does not work when you push it through Intune, because Intune’s Linux configuration scripts run as root, non-interactively, with no logged-in user. There is no $HOME to resolve in that case.

The fix is to write to /etc/xdg/autostart/ instead, which applies system-wide to every user’s graphical session. That is actually a better fit for a managed fleet anyway. Here is the deployment script I push via Intune:

 1#!/bin/bash
 2# Deploy a system-wide autostart entry so the Microsoft Intune Portal launches
 3# at graphical session start for every user on this device.
 4#
 5# Designed for Microsoft Intune Linux platform scripts, which run as root in a
 6# non-interactive system context (no logged-in user guaranteed). For that reason
 7# we write to /etc/xdg/autostart (applies to all users) rather than a per-user
 8# ~/.config/autostart directory, which cannot be resolved here.
 9#
10# Idempotent: the file is only created if it does not already exist, so the
11# periodic re-runs Intune performs become a no-op after first deployment.
12
13set -euo pipefail
14
15AUTOSTART_DIR="/etc/xdg/autostart"
16AUTOSTART_FILE="${AUTOSTART_DIR}/intune-portal.desktop"
17
18# --- Logging + clean agent streams ------------------------------------------
19# Intune marks a Linux platform script as "Error" whenever it writes ANYTHING
20# to stderr, even on exit 0 (that is the "Error / error code 0" symptom). This
21# script has no stderr on its happy path, but mkdir/chown/chmod could emit one
22# on some systems. We funnel ALL output (stdout + stderr) into a log and leave
23# the agent's streams empty, so the status is driven by the honest exit code
24# alone. A genuine failure still exits non-zero (via set -e) and is still
25# reported as Error - we suppress noise, not real failures.
26LOG_DIR="/var/log/intune-autostart"
27mkdir -p "${LOG_DIR}" 2>/dev/null || LOG_DIR="/tmp"
28LOG_FILE="${LOG_DIR}/intune-autostart-$(date +%Y%m%d-%H%M%S).log"
29exec >>"${LOG_FILE}" 2>&1
30
31log() { echo "[$(date '+%F %T')] $*"; }
32
33log "start: user=$(id -un 2>/dev/null) uid=$(id -u) target=${AUTOSTART_FILE}"
34
35# --- Optional hardening (uncomment to enable) -------------------------------
36# Skip deployment on machines where the Intune Portal binary isn't present,
37# to avoid leaving a dangling autostart entry.
38# if ! command -v intune-portal >/dev/null 2>&1; then
39#     log "intune-portal not found on PATH; skipping autostart deployment."
40#     exit 0
41# fi
42# ---------------------------------------------------------------------------
43
44# Only deploy if the file does not already exist.
45if [[ -f "${AUTOSTART_FILE}" ]]; then
46    log "Autostart entry already present at ${AUTOSTART_FILE}; nothing to do."
47    exit 0
48fi
49
50# Ensure the target directory exists (it normally does on desktop installs).
51mkdir -p "${AUTOSTART_DIR}"
52
53# Write the desktop entry.
54cat > "${AUTOSTART_FILE}" <<'EOF'
55[Desktop Entry]
56Type=Application
57Exec=intune-portal
58Hidden=false
59X-GNOME-Autostart-enabled=true
60Name=Intune Portal
61Comment=Launch the Microsoft Intune Portal at login so device check-ins occur
62EOF
63
64# Normalise ownership and permissions.
65chown root:root "${AUTOSTART_FILE}"
66chmod 0644 "${AUTOSTART_FILE}"
67
68log "Deployed autostart entry to ${AUTOSTART_FILE}."
69exit 0

You push this as a custom configuration script under Devices > Linux > Scripts. Make sure to drop it as root.

After the next login, the Company Portal launches on its own.

Automating the manual sign-in click

At this point the app opens by itself, but there is still one manual step: the user has to click Sign in. Interestingly, once you click it, the app signs in silently when the microsoft-identity-broker still has valid refresh tokens. This tells us the broker and keyring are working fine. So why is the click needed at all? Remember the three-part architecture from earlier. The daemon performs the check-in, but the token handshake that lets it do so is kicked off by the portal when you sign in. There is no documented command-line flag to auto-initiate that.

Clicking the button for the user

In order to make sure users do not click away the Intune Portal app before signing in, I (read Claude Code) created a script that automates the click. But since there is no flag that can be used using CLI, I had to automate the click using the UI inside the user’s graphical session.

Warning

This is an effective but fragile way of automating, since it is based on the UI we cannot control. If Microsoft reworks the portal UI or ships a language you did not account for, the auto sign in will stop working.

The script emulates the click in the UI through the AT-SPI accessibility tree. This is a more robust way compared to clicking a screen based on coordinates. A small Python script can walk the tree, find the “Sign in” push button by name, and invoke its accessibility action. If your users are using other languages, you can add them to the “BUTTON_NAMES” variable. The core of it looks like this:

 1import pyatspi, time
 2
 3APP_NAMES = ("intune-portal", "intune portal", "microsoft intune")
 4BUTTON_NAMES = ("sign in", "aanmelden")  # add your session language
 5
 6def find_button(node, depth=0):
 7    if node is None or depth > 40:
 8        return None
 9    if node.getRole() == pyatspi.ROLE_PUSH_BUTTON:
10        name = (node.name or "").lower()
11        if any(b in name for b in BUTTON_NAMES):
12            return node
13    for i in range(node.childCount):
14        found = find_button(node.getChildAtIndex(i), depth + 1)
15        if found:
16            return found
17    return None
18
19# ... poll the desktop for the portal app, then:
20button.queryAction().doAction(0)
Note

By installing accerciser on your system, you can inspect the real accessibility tree on a device with the portal open. It shows you the exact name and role of the sign-in button, which is precisely what you will need to update when Microsoft changes it.

This python script can again be dropped on the systems using a wrapper via Intune, and placed under /etc/xdg/autostart. Below is the full wrapper script to be dropped via Intune:

  1#!/bin/bash
  2# Deploy an auto-sign-in shim for the Microsoft Intune Portal on Linux.
  3#
  4# WHAT THIS DOES
  5#   1. Installs the AT-SPI Python bindings (best effort).
  6#   2. Drops a helper script that, inside each user's graphical session,
  7#      finds the Intune Portal "Sign in" button via the accessibility tree
  8#      and invokes it -- removing the one manual click per login.
  9#   3. Drops a system-wide /etc/xdg/autostart entry that launches the helper.
 10#
 11# CONTEXT
 12#   Designed for Microsoft Intune Linux custom configuration scripts, which run
 13#   as root, non-interactively. The helper it installs, however, runs in the
 14#   USER graphical session (via /etc/xdg/autostart) -- GUI automation cannot run
 15#   from the root/no-session context this script executes in.
 16#
 17# DEBUG LOG
 18#   When the helper runs but does NOT end in a successful click, it writes a
 19#   per-user debug log to /tmp/intune-portal-autosignin-<uid>.log (mode 0600,
 20#   opened O_NOFOLLOW for safety in world-writable /tmp). A successful click
 21#   writes nothing and clears any stale log. On the most likely failure -- the
 22#   button label drifting -- the log dumps the buttons actually present so you
 23#   can see the real name and update BUTTON_NAMES.
 24#
 25# READ THIS FIRST
 26#   * This is an UNSUPPORTED, FRAGILE shim. It drives a GUI you don't control
 27#     and will break when Microsoft reworks the portal UI or ships it in a
 28#     language not listed in the helper's BUTTON_NAMES. You own the maintenance.
 29#   * Run the intune-daemon check first (systemctl / journalctl for the daemon
 30#     on /run/intune/daemon.socket). If the daemon keeps checking in through a
 31#     session on its own, you may not need this at all.
 32
 33set -euo pipefail
 34
 35HELPER="/usr/local/bin/intune-portal-autosignin.py"
 36AUTOSTART="/etc/xdg/autostart/intune-portal-autosignin.desktop"
 37
 38# --- 1. Dependencies (idempotent) ------------------------------------------
 39# python3-pyatspi provides the accessibility bindings; at-spi2-core provides
 40# the a11y bus. Guarded so the apt cache isn't refreshed on every re-run.
 41if ! dpkg -s python3-pyatspi >/dev/null 2>&1; then
 42    export DEBIAN_FRONTEND=noninteractive
 43    apt-get update -qq || true
 44    # Best effort: if this fails (no repo/network), the helper simply writes a
 45    # debug log and exits at runtime because pyatspi won't import -- nothing
 46    # breaks in the user session.
 47    apt-get install -y python3-pyatspi at-spi2-core || \
 48        echo "WARN: could not install python3-pyatspi; helper will no-op until it is present."
 49fi
 50
 51# --- 2. Helper script ------------------------------------------------------
 52# NOTE: unlike the autostart entry below, the helper is REWRITTEN on every run
 53# so that an updated version pushed via Intune actually propagates to devices.
 54# If you'd rather treat it as write-once, wrap this block in [[ ! -f "$HELPER" ]].
 55cat > "${HELPER}" <<'PYEOF'
 56#!/usr/bin/env python3
 57"""
 58Auto-invoke the 'Sign in' button in the Microsoft Intune Portal (Linux).
 59
 60FRAGILE SHIM -- read before relying on this:
 61  * This drives the Intune Portal GUI through the AT-SPI accessibility tree.
 62    It WILL break if Microsoft renames/reworks the button, changes the window
 63    structure, or if the UI is shown in a language not listed in BUTTON_NAMES.
 64  * Requires the AT-SPI accessibility bus (at-spi2-core) and the GTK
 65    atk-bridge, which the Intune Portal (a GTK3 app) exposes by default on a
 66    normal GNOME session. This runs in the USER graphical session ONLY.
 67  * There is no supported Microsoft method to skip the sign-in click. Prefer
 68    ripping this shim out if/when Microsoft ships real background check-in.
 69  * The script only ever *invokes* a button it can positively identify by
 70    name. If it can't find one, it exits quietly and changes nothing.
 71
 72DEBUG LOG
 73  On any run that does NOT end in a successful click, a debug log is written to
 74    /tmp/intune-portal-autosignin-<uid>.log
 75  A successful click writes no log and removes any stale one. The log is
 76  per-user (uid in the name) so multiple users on one host never collide, and
 77  it is opened with O_NOFOLLOW + mode 0600 to stay safe in world-writable /tmp.
 78  The most useful line on failure is the dump of the buttons actually present
 79  in the window -- that tells you the real label if Microsoft changed it.
 80"""
 81
 82import os
 83import sys
 84import time
 85import traceback
 86from datetime import datetime
 87
 88# --- Tunables ---------------------------------------------------------------
 89# Application name(s) as they appear in the AT-SPI tree (case-insensitive).
 90APP_NAMES = ("intune-portal", "intune portal", "microsoft intune")
 91# Candidate button labels, matched case-insensitively as substrings.
 92# Normalised to lowercase at load time so a stray capital in a future edit
 93# still matches. Add your session language here -- Dutch is "aanmelden".
 94BUTTON_NAMES = tuple(b.lower() for b in
 95                     ("sign in", "signin", "aanmelden", "log in", "login"))
 96# Wait this long (s) for the portal window to appear after the session starts.
 97APP_TIMEOUT = 120
 98# Once the app is up, wait this long (s) for the sign-in button before giving
 99# up (already signed in, or the label drifted).
100BUTTON_TIMEOUT = 25
101POLL = 2
102# Write a debug log when the app is up but no matching button is found. This is
103# the "already signed in OR label changed" case; logging it lets you tell which
104# from the button dump. Set False to keep quiet on that (expected) path.
105LOG_ON_NO_MATCH = True
106# ---------------------------------------------------------------------------
107
108LOG_PATH = "/tmp/intune-portal-autosignin-%d.log" % os.getuid()
109_BUFFER = []
110
111
112def log(msg):
113    line = "%s  %s" % (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), msg)
114    _BUFFER.append(line)
115    try:  # mirror to stderr; harmless if nothing captures it
116        sys.stderr.write(line + "\n")
117    except Exception:
118        pass
119
120
121def _open_log_fd():
122    # Defensive open in world-writable /tmp: never follow a symlink someone
123    # else may have planted at our path.
124    flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW
125    try:
126        return os.open(LOG_PATH, flags, 0o600)
127    except OSError:
128        try:  # clear a pre-existing symlink/file we can remove, then retry once
129            os.unlink(LOG_PATH)
130            return os.open(LOG_PATH, flags, 0o600)
131        except OSError:
132            return None
133
134
135def flush_failure(reason):
136    """Persist the buffered debug log to /tmp because the run failed."""
137    log("OUTCOME: FAILURE (%s)" % reason)
138    fd = _open_log_fd()
139    if fd is None:
140        sys.stderr.write("Could not open debug log at %s\n" % LOG_PATH)
141        return
142    try:
143        with os.fdopen(fd, "w") as handle:
144            handle.write("\n".join(_BUFFER) + "\n")
145        sys.stderr.write("Debug log written to %s\n" % LOG_PATH)
146    except Exception:
147        pass
148
149
150def cleanup_success():
151    """Remove any stale debug log left by a previous failed run."""
152    try:
153        if os.path.lexists(LOG_PATH):
154            os.unlink(LOG_PATH)
155    except Exception:
156        pass
157
158
159try:
160    import pyatspi
161except ImportError:
162    log("python3-pyatspi not installed; cannot run auto sign-in.")
163    flush_failure("pyatspi import failed")
164    sys.exit(0)  # never block the session
165
166
167def _lower(value):
168    return (value or "").strip().lower()
169
170
171def list_top_level(desktop):
172    """Names of all top-level accessible applications (for failure dumps)."""
173    names = []
174    try:
175        count = desktop.childCount
176    except Exception as exc:
177        return ["<error reading desktop: %s>" % exc]
178    for i in range(count):
179        try:
180            app = desktop.getChildAtIndex(i)
181            names.append(repr(getattr(app, "name", None)))
182        except Exception:
183            names.append("<error reading app %d>" % i)
184    return names
185
186
187def list_buttons(node, acc, depth=0, max_depth=40):
188    """Collect names of every push button under node (for failure dumps)."""
189    if node is None or depth > max_depth:
190        return
191    try:
192        if node.getRole() == pyatspi.ROLE_PUSH_BUTTON:
193            acc.append(repr(getattr(node, "name", None)))
194    except Exception:
195        pass
196    try:
197        count = node.childCount
198    except Exception:
199        return
200    for i in range(count):
201        try:
202            list_buttons(node.getChildAtIndex(i), acc, depth + 1, max_depth)
203        except Exception:
204            pass
205
206
207def find_app(desktop):
208    try:
209        count = desktop.childCount
210    except Exception:
211        return None
212    for i in range(count):
213        try:
214            app = desktop.getChildAtIndex(i)
215        except Exception:
216            continue
217        name = _lower(getattr(app, "name", ""))
218        if name and any(a in name or name in a for a in APP_NAMES):
219            return app
220    return None
221
222
223def find_sign_in_button(node, depth=0, max_depth=40):
224    """Depth-first search for a visible, sensitive push button by name."""
225    if node is None or depth > max_depth:
226        return None
227    try:
228        role = node.getRole()
229    except Exception:
230        return None
231
232    if role == pyatspi.ROLE_PUSH_BUTTON:
233        name = _lower(getattr(node, "name", ""))
234        if name and any(b in name for b in BUTTON_NAMES):
235            try:
236                state = node.getState()
237                if state.contains(pyatspi.STATE_SHOWING) and \
238                   state.contains(pyatspi.STATE_SENSITIVE):
239                    return node
240            except Exception:
241                return node  # states unavailable; take the name match
242
243    try:
244        count = node.childCount
245    except Exception:
246        count = 0
247    for i in range(count):
248        try:
249            child = node.getChildAtIndex(i)
250        except Exception:
251            continue
252        found = find_sign_in_button(child, depth + 1, max_depth)
253        if found is not None:
254            return found
255    return None
256
257
258def invoke(button):
259    """Invoke the button's default action via the AT-SPI Action interface."""
260    try:
261        action = button.queryAction()
262    except Exception:
263        return False
264    preferred = ("click", "press", "activate", "invoke")
265    idx = 0
266    try:
267        for i in range(action.nActions):
268            if _lower(action.getName(i)) in preferred:
269                idx = i
270                break
271    except Exception:
272        idx = 0
273    try:
274        action.doAction(idx)
275        return True
276    except Exception:
277        return False
278
279
280def main():
281    log("Starting (uid=%d, pid=%d)." % (os.getuid(), os.getpid()))
282    log("Session: XDG_SESSION_TYPE=%s DISPLAY=%s WAYLAND_DISPLAY=%s" % (
283        os.environ.get("XDG_SESSION_TYPE"),
284        os.environ.get("DISPLAY"),
285        os.environ.get("WAYLAND_DISPLAY")))
286
287    try:
288        desktop = pyatspi.Registry.getDesktop(0)
289    except Exception as exc:
290        log("AT-SPI desktop unavailable: %s" % exc)
291        flush_failure("AT-SPI unavailable")
292        sys.exit(0)
293
294    # Phase 1: wait for the portal window to appear.
295    app = None
296    deadline = time.time() + APP_TIMEOUT
297    while time.time() < deadline:
298        app = find_app(desktop)
299        if app is not None:
300            break
301        time.sleep(POLL)
302    if app is None:
303        log("Intune Portal window did not appear within %ds." % APP_TIMEOUT)
304        log("Top-level applications seen: %s" % ", ".join(list_top_level(desktop)))
305        flush_failure("portal window never appeared")
306        sys.exit(0)
307
308    log("Found portal application: %r" % getattr(app, "name", None))
309
310    # Phase 2: wait for the sign-in button, then invoke it once.
311    deadline = time.time() + BUTTON_TIMEOUT
312    while time.time() < deadline:
313        button = find_sign_in_button(app)
314        if button is not None:
315            log("Matched sign-in button: %r" % getattr(button, "name", None))
316            if invoke(button):
317                log("Sign-in button invoked successfully.")
318                cleanup_success()
319                sys.exit(0)
320            log("Found sign-in button but AT-SPI action failed.")
321            flush_failure("button found but invoke failed")
322            sys.exit(0)
323        time.sleep(POLL)
324
325    # App up, no matching button: already signed in, or the label changed.
326    buttons = []
327    list_buttons(app, buttons)
328    if buttons:
329        log("No sign-in button matched. Push buttons present: %s"
330            % ", ".join(buttons))
331    else:
332        log("No push buttons present in the portal window.")
333    log("Expected if already signed in; otherwise the real label is above.")
334    if LOG_ON_NO_MATCH:
335        flush_failure("no matching sign-in button")
336    else:
337        cleanup_success()
338    sys.exit(0)
339
340
341if __name__ == "__main__":
342    try:
343        main()
344    except SystemExit:
345        raise
346    except Exception:
347        log("Unhandled exception:\n%s" % traceback.format_exc())
348        flush_failure("unhandled exception")
349        sys.exit(0)
350PYEOF
351chown root:root "${HELPER}"
352chmod 0755 "${HELPER}"
353
354# --- 3. Autostart entry (only if absent) -----------------------------------
355# 'sh -c' first enables the GTK toolkit accessibility bridge for the session
356# (harmless if already on), then launches the helper. Runs after the portal's
357# own autostart; the helper polls for the window so ordering doesn't matter.
358if [[ ! -f "${AUTOSTART}" ]]; then
359    cat > "${AUTOSTART}" <<'DESKTOPEOF'
360[Desktop Entry]
361Type=Application
362Name=Intune Portal Auto Sign-in
363Comment=Invokes the Intune Portal sign-in button so device check-ins occur
364Exec=sh -c "gsettings set org.gnome.desktop.interface toolkit-accessibility true 2>/dev/null; exec /usr/local/bin/intune-portal-autosignin.py"
365Hidden=false
366X-GNOME-Autostart-enabled=true
367DESKTOPEOF
368    chown root:root "${AUTOSTART}"
369    chmod 0644 "${AUTOSTART}"
370    echo "Deployed autostart entry to ${AUTOSTART}."
371else
372    echo "Autostart entry already present at ${AUTOSTART}; left untouched."
373fi
374
375echo "Auto-sign-in shim deployment complete."
376exit 0

By now, you should have two auto start files under /etc/xdg/autostart:

After signing into the system, the Intune Portal will now start automatically and click the sign-in button without any user interaction:

Keeping the Intune app up-to-date

Last piece: keeping intune-portal current. The app is not in Ubuntu’s repositories - it comes from packages.microsoft.com and updates via apt. Microsoft’s guidance points at the Software Updater, but that is interactive, and unattended-upgrades by default only touches Ubuntu’s own security packages, so the app never updates on its own unless you tell it to.

You can wire the Microsoft repo into unattended-upgrades, but honestly, for this I went with the simpler, more transparent option: a cron job. The one nuance worth calling out is that these are laptops, and a fixed-time crontab entry (say 3:30 in the morning) simply never fires on a machine that is asleep or off at that moment. So instead of /etc/cron.d with a hardcoded time, I drop the updater into /etc/cron.daily/, which runs through anacron and catches up shortly after the next boot if a run was missed.

My deployment script drops that updater, makes it executable, and runs it once immediately so you do not have to wait a day for the first cycle. After that, cron carries it.

  1#!/bin/bash
  2# Keep the Microsoft Intune app (intune-portal) auto-updated on Linux -- the
  3# simple cron way. Installs an updater at /etc/cron.daily/intune-portal-update
  4# and runs it once immediately.
  5#
  6# For Microsoft Intune Linux platform scripts (run as root, non-interactively).
  7#
  8# WHY cron.daily (and not /etc/cron.d with a fixed time):
  9#   These are desktops/laptops. A fixed-time crontab line never fires on a
 10#   machine that's off or asleep at that moment. /etc/cron.daily runs via
 11#   anacron, which CATCHES UP shortly after the next boot if a run was missed
 12#   -- so the update actually happens on laptops. (If your fleet is always-on
 13#   servers and you want a specific time instead, see the /etc/cron.d note at
 14#   the bottom of this file.)
 15#
 16# FILENAME GOTCHA: run-parts (which executes /etc/cron.daily) ignores files
 17#   whose names contain a dot. The updater is therefore named
 18#   "intune-portal-update" with NO .sh extension, and must be executable.
 19#
 20# DIAGNOSTIC LOG -> /var/log/intune-portal-autoupdate-deploy.log (or /tmp).
 21#   The updater itself logs its activity separately to
 22#   /var/log/intune-portal-update.log.
 23#
 24# INTUNE STDERR NOTE: Intune marks a Linux platform script as "Error" if it
 25#   writes ANYTHING to stderr, even on exit 0. After the log path is chosen we
 26#   redirect the whole process (stdout + stderr) into the log, so the agent
 27#   sees clean streams and the status is driven by the honest exit code alone.
 28#   A genuine failure still exits non-zero and is still (correctly) reported as
 29#   Error -- we suppress noise, not real failures.
 30
 31DAILY_PATH="/etc/cron.daily/intune-portal-update"
 32SOURCES_DIR="/etc/apt/sources.list.d"
 33
 34# --- Choose a writable log path (before strict mode) ------------------------
 35LOG_FILE="/var/log/intune-portal-autoupdate-deploy.log"
 36if ! ( : >> "${LOG_FILE}" ) 2>/dev/null; then
 37    LOG_FILE="/tmp/intune-portal-autoupdate-deploy.log"
 38fi
 39: > "${LOG_FILE}" 2>/dev/null || true
 40
 41# --- Clean agent streams: everything from here goes to the log --------------
 42# Must come after LOG_FILE is finalised and before any output is produced.
 43exec >>"${LOG_FILE}" 2>&1
 44
 45log() {
 46    printf '%s  %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "${LOG_FILE}" 2>/dev/null || true
 47}
 48
 49# --- Environment snapshot (before set -e / trap) ----------------------------
 50log "=== cron auto-update deployment started ==="
 51log "Host: $(hostname 2>/dev/null || echo '?')  User: $(id -un 2>/dev/null || echo '?') (uid $(id -u 2>/dev/null || echo '?'))"
 52if [ -r /etc/os-release ]; then
 53    # shellcheck disable=SC1091
 54    . /etc/os-release 2>/dev/null || true
 55    log "Distro: ${PRETTY_NAME:-unknown}"
 56fi
 57if grep -rqsI packages.microsoft.com "${SOURCES_DIR}" /etc/apt/sources.list 2>/dev/null; then
 58    log "Microsoft apt repo: present."
 59else
 60    log "Microsoft apt repo: NOT FOUND -- intune-portal cannot update until the"
 61    log "  packages.microsoft.com repo exists (it normally does on enrolled devices)."
 62fi
 63# cron/anacron presence: without cron, the daily job never runs.
 64if command -v cron >/dev/null 2>&1 || command -v crond >/dev/null 2>&1 || dpkg -s cron >/dev/null 2>&1; then
 65    log "cron: present."
 66else
 67    log "cron: NOT FOUND -- the daily job will not run until cron is installed."
 68fi
 69if command -v anacron >/dev/null 2>&1 || dpkg -s anacron >/dev/null 2>&1; then
 70    log "anacron: present (missed runs will catch up after boot)."
 71else
 72    log "anacron: not found -- cron.daily still runs, but WITHOUT catch-up, so a"
 73    log "  machine off at the scheduled time skips that day. Consider installing anacron."
 74fi
 75log "intune-portal installed version: $(dpkg-query -W -f='${Version}' intune-portal 2>/dev/null || echo 'not installed')"
 76log "Log file: ${LOG_FILE}"
 77echo "intune-portal cron auto-update: logging to ${LOG_FILE}"
 78
 79# --- Strict mode + failure trap for the durable work ------------------------
 80# Note: the trap logs and exits non-zero. It intentionally does NOT write to
 81# stderr -- the non-zero exit is what tells Intune this run failed.
 82on_err() {
 83    local rc=$?
 84    local line="$1"
 85    log "ERROR rc=${rc} at line ${line}: ${BASH_COMMAND}"
 86    log "OUTCOME: FAILED"
 87    exit "${rc}"
 88}
 89trap 'on_err "${LINENO}"' ERR
 90set -euo pipefail
 91
 92# --- Write the daily updater (MUST succeed; overwritten each run) ------------
 93log "Writing ${DAILY_PATH}."
 94mkdir -p "$(dirname "${DAILY_PATH}")"
 95cat > "${DAILY_PATH}" <<'DAILYEOF'
 96#!/bin/bash
 97# Update the Microsoft Intune app (intune-portal) from packages.microsoft.com.
 98# Installed by Intune. Run daily by cron/anacron (with boot catch-up).
 99# Pass --now to skip the random jitter (used for the immediate post-deploy run).
100
101set -uo pipefail
102
103LOG_FILE="/var/log/intune-portal-update.log"
104MAX_LOG_BYTES=1048576     # rotate at ~1 MB, keeping one previous file
105JITTER_MAX=1800           # spread fleet load: sleep 0..JITTER_MAX s (0 disables)
106
107log() { printf '%s  %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "${LOG_FILE}" 2>/dev/null || true; }
108
109# Bounded log: rotate once when it gets large.
110if [ -f "${LOG_FILE}" ] && [ "$(stat -c%s "${LOG_FILE}" 2>/dev/null || echo 0)" -gt "${MAX_LOG_BYTES}" ]; then
111    mv -f "${LOG_FILE}" "${LOG_FILE}.1" 2>/dev/null || true
112fi
113
114# Random jitter so a fleet doesn't hit the repo simultaneously (skip with --now).
115if [ "${1:-}" != "--now" ] && [ "${JITTER_MAX}" -gt 0 ]; then
116    sleep $(( RANDOM % JITTER_MAX ))
117fi
118
119export DEBIAN_FRONTEND=noninteractive
120before="$(dpkg-query -W -f='${Version}' intune-portal 2>/dev/null || echo 'not installed')"
121log "=== update run (current: ${before}) ==="
122
123# apt-get writes progress/warnings to stderr even on success; keep all of it in
124# this log so nothing leaks to the caller (the immediate run is invoked by the
125# Intune deploy script, and stray stderr there would flag the policy as Error).
126if ! /usr/bin/apt-get update -qq >>"${LOG_FILE}" 2>&1; then
127    log "WARN: apt-get update failed; will retry next run."
128    exit 0
129fi
130
131if /usr/bin/apt-get install --only-upgrade -y intune-portal >>"${LOG_FILE}" 2>&1; then
132    after="$(dpkg-query -W -f='${Version}' intune-portal 2>/dev/null || echo '?')"
133    if [ "${before}" = "${after}" ]; then
134        log "Already current (${after})."
135    else
136        log "Upgraded ${before} -> ${after}."
137    fi
138else
139    log "WARN: upgrade failed; will retry next run."
140fi
141exit 0
142DAILYEOF
143chmod 0755 "${DAILY_PATH}"
144chown root:root "${DAILY_PATH}"
145
146# Sanity-check the updater parses before we rely on it.
147if bash -n "${DAILY_PATH}" 2>>"${LOG_FILE}"; then
148    log "Updater syntax OK."
149else
150    log "ERROR: updater failed syntax check; removing it."
151    rm -f "${DAILY_PATH}"
152    false   # trip the trap -> report failure instead of shipping a broken job
153fi
154
155# --- Run it once now (best effort, never fatal) -----------------------------
156log "Running the updater once (--now)."
157if "${DAILY_PATH}" --now; then
158    log "Immediate run completed. intune-portal now: $(dpkg-query -W -f='${Version}' intune-portal 2>/dev/null || echo '?')"
159else
160    log "WARN: immediate run returned non-zero (non-fatal); the daily job will retry."
161fi
162
163log "OUTCOME: SUCCESS"
164echo "intune-portal cron auto-update installed at ${DAILY_PATH}."
165exit 0
166
167# ---------------------------------------------------------------------------
168# ALTERNATIVE for always-on servers wanting a fixed time instead of cron.daily:
169# drop /etc/cron.d/intune-portal-autoupdate (filename must have NO dot) with a
170# trailing newline, containing:
171#
172#   SHELL=/bin/bash
173#   PATH=/usr/sbin:/usr/bin:/sbin:/bin
174#   MAILTO=""
175#   30 3 * * * root /usr/local/bin/intune-portal-update.sh
176#
177# (move the updater to /usr/local/bin/intune-portal-update.sh in that case).
178# cron picks up /etc/cron.d changes automatically; no reload needed. Downside:
179# no catch-up, so a machine off at 03:30 skips that day.
180# ---------------------------------------------------------------------------

When the deployment succeeds, you should find it as below:

Conclusion

With the pieces above, you can take Intune on Linux from “the user has to remember to open the app and sign in” to a device that launches the Company Portal at login, signs in on its own, and keeps itself updated - all deployed and managed from Intune.

That said, I want to end on the same honest note I started with. A couple of these steps, especially the auto sign-in, are unsupported shims against an app that ships monthly, so treat them as something you maintain rather than set-and-forget. Microsoft is actively improving the Linux agent, and I would happily throw all of this away the day a proper background check-in lands. Until then, this is what keeps my Linux fleet reporting reliably.