— Technical deep-dive
Microsoft 365 device-code auth for Tauri desktop apps
The OAuth flow Microsoft designed specifically for desktop apps — and the edge cases the docs leave out.
— TL;DR
- Don’t embed a webview for OAuth. Microsoft explicitly disallows it for production apps. Device-code is the supported path for desktop.
- Two HTTP endpoints, one polling loop.
POST /devicecodeto start,POST /tokenon a 5-second timer until you get back a token or the user gives up. - The interesting work is everything after. Secure token storage, refresh-token rotation, conditional-access challenges, cross-tenant rebinding, MFA re-prompts.
- Microsoft has a Rust SDK now.
microsoft-graphandazure_identityexist but are early. For most production work, raw HTTP + a thin wrapper is still cleaner than the abstraction tax.
The problem
You’re shipping a desktop app that needs to read a user’s SharePoint folder, post into a Teams channel, query Planner tasks, or write back to a Graph mailbox. Either you trust the user to paste in an API key (you don’t) or you do real OAuth (you do).
Real OAuth from a desktop app is where most attempts collapse. The naive approach embeds a webview, captures the redirect, parses the token out of the URL fragment. Microsoft has been actively phasing this pattern out since 2019 — it violates RFC 8252 and as of 2024 the embedded-webview path is either blocked or rate-limited for new tenants.
The supported alternative for desktop apps is the OAuth 2.0 Device Authorization Grant — RFC 8628, colloquially “device-code flow”. It was designed for exactly this case: a client that can’t reliably host a browser redirect endpoint.
What device-code flow actually is
The whole thing is three things in sequence:
- Your app calls
POST /devicecodeand gets back a short user code and a verification URL. - Your app shows the user the code and asks them to open the URL in a browser. They type the code, sign in normally (including MFA, conditional access, whatever their tenant requires), and consent to the scopes you asked for.
- Meanwhile your app polls
POST /tokenevery ~5 seconds. While the user is still working through the browser flow you get backauthorization_pending. When they finish, you get back the access token and refresh token. When they cancel or time out, you get an error you handle gracefully.
The user never types a password into your app. Your app never sees their credentials. The browser is doing all the auth work, including any MFA or conditional access policy their tenant enforces — your app just gets the tokens at the end if everything passed.
Step 1: Register your Azure AD app
In the Azure portal under App registrations → New registration:
- Supported account types: usually Accounts in any organizational directory and personal Microsoft accounts.
- Redirect URI: leave blank. Device-code does not use one.
- After it’s created, go to Authentication → Advanced settings → set Allow public client flows to Yes. This is the single setting that enables device-code. The default is no and the docs don’t make this obvious.
- API permissions: add whatever Graph permissions your app needs (delegated, not application). Common ones for a SharePoint sync use case:
Files.ReadWrite.All,Sites.ReadWrite.All,User.Read,offline_access(critical — this is what gives you a refresh token).
Save the Application (client) ID. You’ll bake it into your binary. The tenant value is common for multi-tenant or personal-account apps.
Step 2: Request a device code
Rust — device code requestuse reqwest::Client; use serde::Deserialize; #[derive(Deserialize)] struct DeviceCodeResponse { user_code: String, device_code: String, verification_uri: String, expires_in: u64, interval: u64, // seconds between polls message: String, // human-friendly instructions } async fn request_device_code( client: &Client, client_id: &str, scopes: &[&str], ) -> anyhow::Result<DeviceCodeResponse> { let resp = client .post("https://login.microsoftonline.com/common/oauth2/v2.0/devicecode") .form(&[ ("client_id", client_id), ("scope", &scopes.join(" ")), ]) .send() .await? .error_for_status()? .json::<DeviceCodeResponse>() .await?; Ok(resp) }
Show the user response.message — Microsoft writes it in their voice and supports localization, which beats whatever you’d write yourself. In a Tauri app, this typically goes into a modal with the user code in large monospace and a button to open the verification URL.
Step 3: Poll for the token
Rust — polling loopuse std::time::Duration; use tokio::time::sleep; #[derive(Deserialize)] struct TokenResponse { access_token: String, refresh_token: Option<String>, // only if you asked for offline_access expires_in: u64, scope: String, } #[derive(Deserialize)] struct TokenError { error: String, error_description: Option<String>, } async fn poll_for_token( client: &Client, client_id: &str, device_code: &str, interval_secs: u64, expires_at: std::time::Instant, ) -> anyhow::Result<TokenResponse> { loop { if std::time::Instant::now() >= expires_at { anyhow::bail!("Device code expired before user completed sign-in."); } let resp = client .post("https://login.microsoftonline.com/common/oauth2/v2.0/token") .form(&[ ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), ("client_id", client_id), ("device_code", device_code), ]) .send() .await?; if resp.status().is_success() { return Ok(resp.json::<TokenResponse>().await?); } let err: TokenError = resp.json().await?; match err.error.as_str() { // Expected — user hasn't finished yet. Keep polling. "authorization_pending" => sleep(Duration::from_secs(interval_secs)).await, // We're polling too fast. Microsoft tells us to slow down. "slow_down" => sleep(Duration::from_secs(interval_secs + 5)).await, // Terminal — bail out. "authorization_declined" => anyhow::bail!("User declined the sign-in request."), "expired_token" => anyhow::bail!("Device code expired."), "bad_verification_code" => anyhow::bail!("Invalid device code (programmer error)."), other => anyhow::bail!("Token endpoint returned: {}", other), } } }
Three things from production experience that the docs don’t flag clearly:
- Respect the
slow_downresponse. Microsoft will start dropping your requests if you ignore it. Bump the interval up by 5 seconds when you see it, and don’t go back down. - The polling can outlive the user’s patience. Default device-code expiry is 15 minutes. Most users don’t take that long. Give them an obvious cancel button.
- If you don’t see a refresh token, you forgot the
offline_accessscope. This is the #1 thing that bites people. Without a refresh token, the user has to do the whole device-code dance again in an hour when the access token expires.
Step 4: Secure token storage
Don’t put refresh tokens in plaintext on disk. The OS gives you a secret store for exactly this:
- Windows: Credential Manager (DPAPI under the hood)
- macOS: Keychain Services
- Linux: Secret Service via libsecret (GNOME Keyring, KWallet)
The keyring Rust crate abstracts all three with one API. Use it. Resist the temptation to roll your own encrypted SQLite blob “just to keep one dependency out” — your users’ IT team will quietly thank you for using the platform store.
Step 5: Refresh tokens correctly
The refresh-token call is almost identical to the device-code-grant call, with two changes: different grant type, no device code:
Rust — refresh tokenasync fn refresh_token( client: &Client, client_id: &str, refresh_token: &str, scopes: &[&str], ) -> anyhow::Result<TokenResponse> { let resp = client .post("https://login.microsoftonline.com/common/oauth2/v2.0/token") .form(&[ ("grant_type", "refresh_token"), ("client_id", client_id), ("refresh_token", refresh_token), ("scope", &scopes.join(" ")), ]) .send() .await? .error_for_status()? .json::<TokenResponse>() .await?; Ok(resp) }
Two important behaviors to internalize:
- Refresh tokens rotate. Every successful refresh call returns a NEW refresh token. The old one is now invalid. If you forget to replace what’s in your keyring after each refresh, your next refresh call breaks and the user is back to device-code.
- Refresh tokens expire on inactivity. The Microsoft default is 90 days. If your user opens the app once every quarter, you’ll get an
invalid_granterror and need to fall back to device-code. Don’t treat this as a bug — handle it gracefully with a clear “sign in again” modal.
Edge cases worth wiring before you ship
- Conditional access challenge mid-call. When the user’s tenant enforces conditional access policies (typical in enterprise), Graph endpoints can return a
claimschallenge in the response headers. Parse it. Send the user back through device-code with theclaimsparameter included. Don’t swallow the error. - Cross-tenant rebinding. Users who manage multiple tenants will want to sign in to a different one without uninstalling. Build a “sign out” that clears the keyring + lets them start fresh. Use the actual
tidfrom the ID token to detect tenant switches. - Token expiry mid-request. A long-running sync that started with a valid access token can fail partway through if the token expires. Wrap your Graph client to check
expires_atand proactively refresh ~5 minutes before — not reactively on 401. - Network failure during polling. Treat transient HTTP errors as retryable. A WiFi blip 90 seconds into the user’s sign-in attempt should not abort the whole flow.
- Clock skew. Token expiry is computed from system time. If your user’s laptop clock is wrong by more than a few minutes, you’ll get cryptic auth failures. Trust the
Dateheader in token responses for relative timing if you really need to be defensive.
MSAL vs raw HTTP — pick your tax
Microsoft ships an official Rust SDK now — azure_identity for auth, microsoft-graph for the API surface. As of mid-2026 both are early. They work, but the abstractions fight you when you need to do something they didn’t anticipate.
For most production work we’ve done, raw HTTP plus a small typed wrapper has been easier to maintain. ~300 lines of Rust gets you device-code, refresh, token storage, conditional-access handling, and the half-dozen Graph endpoints you actually call. The SDK pulls in ~40 transitive deps to do the same thing. For shops that already speak HTTP and OAuth, the SDK’s primary value is the strongly-typed model classes, which you can also generate from Microsoft’s OpenAPI spec if you need them.
Your call. Both work.
What we’d do differently
- Build the “sign in again” modal first. You’ll need it for the 90-day refresh-token expiry, the conditional-access challenge, the tenant switch, and the user-deleted-the-app-from-Entra-portal cases. Treating it as an afterthought means handling each of these failure paths separately.
- Log scopes per token in dev. Scope creep during development is real — you end up requesting permissions you don’t use, and the IT admin reviewing the consent screen denies you. A scope audit per token in your dev console catches it early.
- Wire the cancel button before the happy path. Users will hit cancel. They’ll close the browser tab. They’ll quit your app mid-flow. All of those need to leave the keyring and your app state clean.
The big picture
Device-code is the right tool for desktop apps that need Microsoft 365 access. The flow itself is ~150 lines of Rust. The interesting work is everything around it — secure storage, refresh, conditional access, the half-dozen failure modes. None of it is particularly hard, but it all has to be there or your users will hate the app within a week.
If you’re building one of these and stuck on any of the above, we’ve shipped this pattern in production multiple times. Happy to talk.
Building something against Microsoft 365?
We take on a small number of custom desktop + cloud engagements per year — Tauri, Microsoft Graph, Azure AD, Office add-ins, the infrastructure most studios run away from.