Skip to content

Authentication design

Goals

  • Authenticate end users (never service principals) against Microsoft Entra ID.
  • Support multiple accounts in multiple tenants simultaneously.
  • Get tokens with the audience that OneLake DFS and the Fabric REST API both accept: https://storage.azure.com/.
  • Cache tokens persistently across daemon restarts using the macOS Keychain.
  • Handle token refresh transparently; surface re-auth requests minimally (per Sam's preference for non-intrusive UX).
  • Public cloud only in MVP.

Microsoft Entra App Registration

We register a multi-tenant public client application in our own tenant:

Property Value
Display name OneLake File Explorer for macOS
Supported account types Accounts in any organizational directory (multi-tenant)
Redirect URI http://localhost (Public client/native)
Allow public client flows Yes
API permissions https://storage.azure.com/user_impersonation (delegated). Optionally also Fabric Service if needed for admin-search endpoints in later phases.

The client ID lives in internal/auth/client.go as a constant — it is a public identifier, not a secret.

Why our own registration instead of reusing the Azure CLI client ID (04b07795-…)? Branded consent screen ("OneLake File Explorer for macOS" instead of "Microsoft Azure CLI"), clean telemetry attribution, our own scope control, and we don't get caught by tenant policies that specifically block the Azure CLI app.

Token acquisition flows

We support two flows, chosen automatically per environment:

  1. Interactive browser (default on macOS desktop):
  2. Opens https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/authorize?... in the system default browser.
  3. We run a tiny localhost HTTP server (random port between 49152–65535) to catch the redirect.
  4. User signs in, consents (first time), and is redirected back to http://localhost:PORT/?code=...&state=....
  5. We exchange the authorization code for an access token + refresh token.

  6. Device code (fallback for SSH sessions, headless environments, or when --device-code is passed):

  7. We call /devicecode endpoint to get user_code and device_code.
  8. We display: To sign in, visit https://microsoft.com/devicelogin and enter the code: XXXX-YYYY.
  9. We poll /token every 5 seconds until success, error, or expiry.

Both flows are implemented on top of the Go MSAL library github.com/AzureAD/microsoft-authentication-library-for-go (msal-go). MSAL Go natively supports both flows on a PublicClientApplication.

Why MSAL Go and not the Azure SDK's azidentity? azidentity's InteractiveBrowserCredential and DeviceCodeCredential are convenience wrappers around MSAL anyway, with less control over the cache backend and tenant-switching semantics. For a multi-account / multi-tenant app where we manage AuthenticationRecords explicitly, going to MSAL directly is cleaner.

Multi-tenant + multi-account model

Each account is identified by a user-chosen short alias (e.g. work, client-a). Internally each account has:

type Account struct {
    Alias        string    // user-chosen, unique
    HomeAccountID string   // MSAL's unique identifier (objectId.tenantId)
    Username     string    // UPN, for display only
    TenantID     string    // GUID
    TenantName   string    // display name, optional
    AddedAt      time.Time
}

We construct one PublicClientApplication per account-tenant (MSAL recommends a separate authority per tenant for clean cache scoping). The authority for account X is https://login.microsoftonline.com/{tenantId}, not /common or /organizations — that way refresh and silent acquisition stay scoped to the right tenant.

A shared in-process account registry keeps the list of accounts and dispatches token requests to the right MSAL client.

Token cache: macOS Keychain

MSAL Go uses an in-memory cache.ExportReplace interface by default. We implement it backed by the macOS Keychain:

  • One Keychain item per account, labeled dev.debruyn.ofem — <alias> (<UPN>).
  • Service: dev.debruyn.ofem.
  • Account name: <HomeAccountID>.
  • Data: MSAL's serialized JSON token cache for that account.

Library: github.com/keybase/go-keychain or github.com/zalando/go-keyring. Both work on macOS without any C dependency.

Keychain access is scoped per-app (via the app's code signature once we have a Developer ID). Until then (Phase 0 dev builds), access is per-user.

Token refresh and silent acquisition

On every OneLake request:

  1. Look up the cached access token for the account.
  2. If present and not within 5 minutes of expiry → use it.
  3. Otherwise call MSAL AcquireTokenSilent to refresh.
  4. If silent refresh fails with interaction_required (e.g. Conditional Access challenge, MFA expired) → mark account as needs_reauth, queue a menu-bar error indicator. No system notification.
  5. The next time the user opens the menu bar app (or runs ofem status), the indicator shows them which account needs re-auth. They re-run ofem login --account <alias> to interactively unblock.

Conditional Access / MFA challenges

Per Sam's preference: silent retry on a fixed interval (30 minutes for AADSTS50076 / AADSTS50079 / interaction_required), plus a menu bar error indicator. No macOS notification.

Rationale: data engineers are used to seeing the "click to re-auth" pattern in many tools and would rather have a quiet UI than a poking notification.

Logout / account removal

ofem account remove <alias>:

  1. Removes the Keychain item.
  2. Removes the account from the registry config.
  3. Optionally calls Microsoft's /oauth2/v2.0/logout (best effort, no error if it fails).
  4. Removes the account's folder from ~/OneLake/<alias>/ after a confirmation prompt (or --force).

Sovereign clouds

Out of scope for MVP. Architecturally we keep the authority host configurable per account (authority_host: login.microsoftonline.com default), so adding US Gov / China / Germany is a config change + endpoint mapping when someone files an issue.

Security considerations

  • No client secret: public client only, so there is no secret to leak.
  • PKCE is used for the authorization code flow (MSAL Go does this by default).
  • State parameter is generated per flow and validated.
  • Random port for localhost redirect avoids collisions and reduces attack surface vs. a fixed well-known port.
  • Refresh tokens are stored in Keychain, scoped per account; they cannot be exported via the CLI.
  • No token printing: even ofem debug commands never echo tokens.
  • The Entra App Registration is owned by Sam; users only consent to delegated permissions and can revoke at any time via https://myapplications.microsoft.com.