msauth

package module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 31, 2026 License: MIT Imports: 18 Imported by: 0

README

msauth-go

Small, pure-Go (cgo-free) helper for Microsoft-identity delegated auth, built on MSAL Go. It signs a user into a personal Microsoft Account (or work/school) and hands out delegated access tokens, with a persistent token cache for silent refresh — so tools don't re-implement the OAuth2 dance.

Foundation of the carteiro program (→ outlook-go, outlook-cli, carteiro). Scope-agnostic: you pass whatever delegated scopes you need (Graph, Outlook resource, …).

Why it exists / what's proven

  • Zero app registration by default. Uses the well-known first-party public client "Microsoft Graph Command Line Tools" (14d82eec-…), which accepts personal accounts. Override with your own registration via Config.ClientID.
  • Robust interactive login. Auth-Code + PKCE over our own dual-stack loopback (binds both 127.0.0.1 and ::1). This works around MSAL Go's built-in interactive server, which binds a single stack and intermittently yields "localhost refused to connect" on macOS.
  • Silent refresh across restarts. MSAL's token cache is persisted through a pluggable cache.Store (macOS Keychain or an AES-256-GCM encrypted file).

Note: device-code login is included for work/school accounts. Microsoft rejects device-code for personal accounts (AADSTS90133 under /common//consumers; the consumers tenant GUID isn't discoverable) — use the interactive flow there.

Usage

import (
    "context"
    "fmt"

    "github.com/albertocavalcante/msauth-go"
    "github.com/albertocavalcante/msauth-go/cache"
)

func main() {
    auth, err := msauth.New(msauth.Config{
        Scopes: []string{"Mail.Read"}, // defaults: Graph-CLI client, /consumers authority
    }, cache.NewKeychainStore())
    if err != nil { panic(err) }

    ctx := context.Background()

    // First run: opens the browser (one sign-in). Later runs: cache hit, no prompt.
    if _, err := auth.Identity(ctx); err != nil { // ErrLoginRequired when not cached
        id, err := auth.Login(ctx)
        if err != nil { panic(err) }
        fmt.Println("signed in as", id.Username)
    }

    token, err := auth.Token(ctx) // silently refreshed
    if err != nil { panic(err) }
    _ = token // attach as: Authorization: Bearer <token>
}
Handing tokens to a REST client

Authenticator.Token is variadic, so it does not satisfy the Token(ctx) (string, error) interface that Microsoft REST clients expect. TokenSource is the scope-bound adapter that does, without either module importing the other:

graph, err := msgraph.New(auth.TokenSource("Mail.Read"))

It holds no token state: every call goes through the cached silent-refresh path, so calling it per request is the intended usage. Use TokenInfo instead of Token when the caller needs the expiry or the scopes consent actually granted:

token, err := auth.TokenInfo(ctx)
if token.Expired(time.Now(), time.Minute) { /* ... */ }
if !token.HasScope("Mail.Send") { /* consent was declined */ }
Token store
  • cache.NewKeychainStore() — macOS Keychain / Linux Secret Service (default choice).
  • cache.NewFileStore(dir) — AES-256-GCM encrypted file (headless / no keyring). Weaker than Keychain (key sits beside data); 0700 directory and 0600 files.
Options
  • Config: ClientID, Authority (default /consumers), Scopes, Account (pick among multiple cached by username, home_account_id, or local account ID), LoginTimeout.
  • Login: WithDevice(), WithLoginScopes(...), WithPrompt(fn), WithBrowserOpener(fn).
  • Token / TokenInfo: WithScopes(...).
Exact account connections

New and Config.Account retain their existing selection behavior. Tools that manage independent connections can instead bind a client to an exact account:

candidate, err := msauth.New(msauth.Config{
    Authority: "https://login.microsoftonline.com/common",
    Scopes: []string{"User.Read", "Mail.Read"},
}, candidateStore) // a separate, private Store for this candidate
if err != nil { return err }
info, err := candidate.LoginAccount(ctx)
if err != nil { return err }
// Validate info.Ref against the expected identity when reauthenticating.
bound, err := msauth.NewForAccount(msauth.Config{
    Scopes: []string{"User.Read", "Mail.Read"},
}, candidateStore, info.Ref)
if err != nil { return err }
_ = bound // publish this candidate only after identity validation succeeds

AccountRef includes the home-account ID, authority environment, resource tenant and optional local-account ID. IDs match exactly; environment hostnames match case-insensitively. A missing or ambiguous reference fails without using another cached identity. NewForAccount pins organizational token requests to that resource tenant and uses /consumers for Microsoft's documented personal-account tenant. Conflicting authority settings or Config.Account are rejected. The store layout and legacy default authority remain unchanged.

Accounts(ctx) enumerates safe metadata without token acquisition or cache writes. Account(ctx) returns the selected metadata. AccountInfo.CacheRealm describes the cache entry's authority path: common and organizations do not prove a tenant, so enumerating them leaves Ref.TenantID empty. LoginAccount gets the resource tenant from the completed login result's ID-token claim; callers never need to parse a token or raw MSAL cache. A later exact selector can use a unique discovery entry with matching home and local IDs while explicitly requesting the verified tenant. An exact tenant entry takes precedence once it exists.

A bound client refuses Login and LoginAccount with ErrBoundLogin before opening a browser or changing the cache. Stage reauthentication in a separate store, verify LoginAccount's result and then publish it. Like legacy Login, LoginAccount persists the candidate before returning, including when identity metadata validation fails; the candidate's owner must discard it on failure.

Bound Logout always returns ErrAccountRemovalUnsafe before IO. MSAL removes all resource realms for a home identity/environment pair, and account enumeration does not reveal every token-only record. Neither a matching discovery alias nor the number of listed accounts proves exclusive ownership of cached credentials. Give independently managed connections separate named keychain services or file-store directories. The owner of such an isolated namespace may explicitly clear it through an unbound authenticator; never apply that operation to a shared cache as a substitute for tenant-scoped removal. Legacy New/Config.Account logout behavior is unchanged. This library does not implement aliases, defaults, profile lifecycle.

Cache transactions

Built-in keychain and encrypted-file stores implement cache.TransactionalStore. An authenticator holds a cancellable cross-process lock around each complete cache operation, including silent token refresh. It creates a fresh MSAL client inside the lock so deletion or replacement cannot leave stale in-memory accounts. Locks cover the entire keychain service or file-store directory: MSAL can retain multiple accounts and tenants in one cache blob. Process death releases the OS lock. Windows uses LockFileEx; Unix uses flock.

Encrypted-file storage uses Unix permission modes or protected Windows DACLs that grant only the current process user access. Existing encryption keys are protected before use, and new key/ciphertext files are protected before writing. Directory protection preserves existing children's access grants; Windows may normalize their inheritance flags. Invalid key paths are rejected before any directory permission change.

cache.Transact(ctx, store, callback) exposes a temporary Store view. Use that view only within the callback; nesting through the original store can deadlock. Transactions serialize cooperating writers but do not roll back writes. Validate before publishing a single blob when atomic publication is required. Custom stores can implement the interface; otherwise the callback runs without coordination.

Direct Store calls and older library versions do not acquire this transaction lock. Move a new application to its own namespace when older writers must remain available. Existing credentials may be a read-only migration seed; never let the old application write the new primary namespace. Interactive login also holds the lock, so applications needing concurrent silent refresh should stage login in a separate nontransactional store, verify the identity, then compare the original cache and publish the single staged blob inside a transaction. Account-bound login and logout remain rejected before any cache or lock IO.

API

New, (*Authenticator).Login / Token / TokenInfo / TokenSource / Identity / Logout. On legacy/unbound clients, Logout removes only Config.Account when set; otherwise it removes all cached accounts for the client ID. Exact-bound clients instead return ErrAccountRemovalUnsafe before IO. ErrLoginRequired is joined into errors when no usable cached account exists. ErrNoScopes guards the empty-scope footgun on both Login and Token. AccessToken carries Value, ExpiresOn, GrantedScopes, plus Expired(now, skew) and HasScope. Test doubles in msauthtest (MemStore).

Develop

just ci   # fmt-check + vet + test-race + lint

The public tools.go.mod pins only test and lint tools. Optional just mutate commands use tools/mutation/go.mod and require authorized access to the private mutation tool. Normal CI and consumer installs do not fetch that module or need cross-repository credentials.

The alternate public tools file uses the library's module path so just tidy-tools resolves this checkout's packages rather than downloading an older copy of the library. Use just tidy-mutation-tools explicitly to maintain the optional private tool module.

Deps: MSAL Go, zalando/go-keyring, pkg/browser. cgo-free. go 1.26.

Manual live smoke test:

go run ./cmd/msauth-live-smoke
go run ./cmd/msauth-live-smoke   # second run should reuse the cache silently

Documentation

Overview

Package msauth is a small, pure-Go (cgo-free) helper for Microsoft-identity delegated authentication, built on the Microsoft Authentication Library (MSAL) for Go.

It exists to make CLI and desktop tools sign a user in to a personal Microsoft Account (or a work/school account) and obtain delegated access tokens, without every tool re-implementing the OAuth2 dance. It provides:

  • interactive Authorization-Code + PKCE login over a robust, dual-stack loopback redirect (works around MSAL Go binding only one of 127.0.0.1/::1, which breaks the redirect on macOS),
  • device-code login (useful for work/school accounts; note that Microsoft rejects device-code for personal accounts),
  • silent token refresh using a persisted token cache, and
  • a pluggable cache.Store with a macOS Keychain implementation and an encrypted-file fallback.

It is scope-agnostic: callers pass whatever delegated scopes they need (Microsoft Graph scopes, Outlook resource scopes, etc.). It is the shared auth foundation for the carteiro program (outlook-go and friends).

The default client id is the well-known first-party "Microsoft Graph Command Line Tools" public client, which accepts personal Microsoft Accounts with no app registration. Override it with your own registration via Config.

Index

Constants

View Source
const (
	// DefaultAuthority targets personal Microsoft Accounts only. Use
	// "https://login.microsoftonline.com/common" to also accept work/school
	// accounts.
	DefaultAuthority = "https://login.microsoftonline.com/consumers"

	// GraphCLIClientID is Microsoft's well-known first-party public client,
	// "Microsoft Graph Command Line Tools". It has http://localhost registered
	// and grants dynamic delegated Microsoft Graph scopes. It was proven (the
	// carteiro Gate-0 probe) to accept personal Microsoft Account sign-in with
	// no app registration, which is why it is the default. Override it with your
	// own registration for anything distributed.
	GraphCLIClientID = "14d82eec-204b-4c2f-b7e8-296a70dab67e"

	// DefaultLoginTimeout bounds an interactive or device-code login.
	DefaultLoginTimeout = 3 * time.Minute
)

Variables

View Source
var ErrAccountAmbiguous = errors.New("msauth: exact account reference is ambiguous")

ErrAccountAmbiguous reports multiple cache entries matching an exact selector.

View Source
var ErrAccountMismatch = errors.New("msauth: account context mismatch")

ErrAccountMismatch reports a conflicting authority or authenticated identity.

View Source
var ErrAccountRemovalUnsafe = errors.New("msauth: exact-bound account removal is unsupported")

ErrAccountRemovalUnsafe reports that MSAL cannot guarantee tenant-scoped removal. Bound authenticators return it before IO. Only an isolated store owner may explicitly use an unbound authenticator to clear that namespace.

View Source
var ErrBoundLogin = errors.New("msauth: exact-bound account requires staged login")

ErrBoundLogin reports interactive login on an exact-bound authenticator. Stage login with New and a separate Store, validate LoginAccount's result, then publish that candidate instead of modifying the existing connection.

View Source
var ErrInvalidAccountRef = errors.New("msauth: invalid exact account reference")

ErrInvalidAccountRef reports an incomplete or conflicting exact selector.

View Source
var ErrLoginRequired = errors.New("msauth: login required")

ErrLoginRequired is returned by Authenticator.Token and Authenticator.Identity when there is no usable cached account (never logged in, cache wiped, or the refresh token has expired/been revoked). Callers should prompt the user to run login. It is joined with the underlying MSAL error when a silent refresh fails, so errors.Is(err, ErrLoginRequired) is true while the cause remains inspectable.

View Source
var ErrNoScopes = errors.New("msauth: no scopes requested (set Config.Scopes or use WithScopes/WithLoginScopes)")

ErrNoScopes is returned by Login and Token when no delegated scopes are set (neither Config.Scopes nor a WithScopes/WithLoginScopes option). MSAL rejects empty scopes with an opaque message on login and, worse, silently misses the cache on Token; this turns that into a clear, actionable error.

Functions

This section is empty.

Types

type AccessToken added in v0.2.0

type AccessToken struct {
	// Value is the bearer token to put in an Authorization header.
	Value string
	// ExpiresOn is when the token stops being accepted, in UTC.
	ExpiresOn time.Time
	// GrantedScopes are the scopes the service actually issued, which can be a
	// subset of those requested when the user declines part of the consent.
	GrantedScopes []string
}

AccessToken is a delegated access token together with the metadata a caller needs to reason about it. Authenticator.Token returns only the bearer string, which is enough to send a request but not enough to decide whether a token is about to expire or which scopes consent actually granted.

func (AccessToken) Expired added in v0.2.0

func (t AccessToken) Expired(now time.Time, skew time.Duration) bool

Expired reports whether the token is expired, treating anything within skew of expiry as already expired. A non-positive skew disables the margin.

func (AccessToken) HasScope added in v0.2.0

func (t AccessToken) HasScope(scope string) bool

HasScope reports whether scope was granted. Comparison is case-insensitive because Entra echoes scopes back with inconsistent casing.

type AccountInfo added in v0.4.0

type AccountInfo struct {
	Ref        AccountRef
	Username   string
	Name       string
	CacheRealm string
}

AccountInfo is safe account metadata, without tokens or raw MSAL values. CacheRealm records the cache's authority path, which can be a discovery alias such as common rather than the identity's resource tenant. Accounts and unbound Account leave Ref.TenantID empty for such aliases; LoginAccount fills it from the login result's ID-token tenant claim.

type AccountRef added in v0.4.0

type AccountRef struct {
	HomeAccountID  string
	Environment    string
	TenantID       string
	LocalAccountID string
}

AccountRef identifies one account in one resource tenant. Opaque IDs are compared exactly; Environment is a case-insensitive authority hostname. LocalAccountID may be empty when the cache does not provide it. TenantID is the resource tenant, never a tenant inferred from HomeAccountID.

type Authenticator

type Authenticator struct {
	// contains filtered or unexported fields
}

Authenticator signs a user in and hands out delegated access tokens, backed by a persistent token cache for silent refresh. Built-in stores coordinate complete operations across cooperating processes. Custom stores must implement cache.TransactionalStore for coordination across Authenticator instances. Older clients and direct Store writes are not coordinated; isolate their storage when they must coexist.

func New

func New(cfg Config, store cache.Store) (*Authenticator, error)

New builds an Authenticator for cfg, persisting the MSAL token cache in store. store must be non-nil (use an in-memory fake in tests, or a cache.Store impl).

func NewForAccount added in v0.4.0

func NewForAccount(cfg Config, store cache.Store, ref AccountRef) (*Authenticator, error)

NewForAccount creates an authenticator bound to an exact account reference. It preserves New's cache layout but never falls back to another account. Config.Account must be empty. A configured discovery authority is pinned to ref.TenantID; a different environment or specific tenant is rejected. Personal accounts use the consumers authority. Construction does no IO.

A bound authenticator supports silent acquisition and metadata. Login returns ErrBoundLogin and Logout returns ErrAccountRemovalUnsafe before IO. Stage reauthentication using New and a separate Store, call LoginAccount, validate its Ref, then publish the candidate store.

func (*Authenticator) Account added in v0.4.0

func (a *Authenticator) Account(ctx context.Context) (*AccountInfo, error)

Account returns the selected cached account's safe metadata without acquiring a token. On a bound instance Ref is the exact configured reference; CacheRealm still describes the underlying cache entry.

func (*Authenticator) Accounts added in v0.4.0

func (a *Authenticator) Accounts(ctx context.Context) ([]AccountInfo, error)

Accounts lists cached account metadata without acquiring tokens or changing the cache. The result is sorted by reference for deterministic discovery. It lists all accounts in this Store/client ID, including on a bound instance.

func (*Authenticator) Identity

func (a *Authenticator) Identity(ctx context.Context) (*Identity, error)

Identity returns the cached user's identity without a network call. Name is empty here (it comes from the login-time id token). Returns ErrLoginRequired when nothing is cached.

func (*Authenticator) Login

func (a *Authenticator) Login(ctx context.Context, opts ...LoginOption) (*Identity, error)

Login signs the user in interactively (or via device code) and returns their identity. The token cache is persisted as a side effect, so later Token calls can refresh silently. The call is bounded by Config.LoginTimeout.

func (*Authenticator) LoginAccount added in v0.4.0

func (a *Authenticator) LoginAccount(ctx context.Context, opts ...LoginOption) (*AccountInfo, error)

LoginAccount performs Login and returns the full account context. Like Login, it persists credentials before returning; use a separate candidate Store when adding or reauthenticating a managed account. A missing or conflicting tenant claim returns ErrAccountMismatch, leaving the candidate for its owner to discard. A bound instance returns ErrBoundLogin before any login side effect.

func (*Authenticator) Logout

func (a *Authenticator) Logout(ctx context.Context) error

Logout returns ErrAccountRemovalUnsafe before IO on an exact-bound client. For legacy New clients, Logout removes cached accounts. When Config.Account is set, only the matching username or home_account_id is removed; otherwise all accounts under this Authenticator's client id are removed.

func (*Authenticator) Token

func (a *Authenticator) Token(ctx context.Context, opts ...TokenOption) (string, error)

Token returns a valid delegated access token for the cached account, refreshing silently as needed. It returns ErrLoginRequired (joined with the underlying cause) when there is no usable cached account.

func (*Authenticator) TokenInfo added in v0.2.0

func (a *Authenticator) TokenInfo(ctx context.Context, opts ...TokenOption) (AccessToken, error)

TokenInfo is Authenticator.Token with the token's expiry and granted scopes attached. Use it when the caller has to schedule around expiry or verify that consent actually granted a scope it asked for.

func (*Authenticator) TokenSource added in v0.2.0

func (a *Authenticator) TokenSource(scopes ...string) *TokenSource

TokenSource returns a token provider bound to scopes. With no scopes it uses Config.Scopes.

type Config

type Config struct {
	// ClientID is the OAuth public client (application) id. Defaults to
	// [GraphCLIClientID].
	ClientID string

	// Authority is the OAuth2 authority URL. Defaults to [DefaultAuthority].
	Authority string

	// Scopes are the default delegated scopes requested by Login and Token when
	// no per-call scopes are given. Reserved OIDC scopes (openid, profile,
	// offline_access) are added automatically for interactive login.
	Scopes []string

	// Account is the preferred cached account (matched case-insensitively
	// against the account username) when more than one is cached. Empty means
	// "the first cached account".
	Account string

	// LoginTimeout bounds an interactive/device login. Defaults to
	// [DefaultLoginTimeout].
	LoginTimeout time.Duration
}

Config configures an Authenticator. The zero value is usable: it defaults to the well-known public client, the /consumers authority, and a 3-minute login timeout.

type Identity

type Identity struct {
	// Username is the account's preferred username (typically the email).
	Username string
	// Name is the display name from the id token. It may be empty when the
	// identity is read from the cache alone (see [Authenticator.Identity]).
	Name string
	// HomeAccountID is MSAL's stable account identifier.
	HomeAccountID string
}

Identity describes the signed-in user, derived from the cached account and, after an interactive login, the id token.

type LoginOption

type LoginOption func(*loginConfig)

LoginOption customizes a Login call.

func WithBrowserOpener

func WithBrowserOpener(fn func(url string) error) LoginOption

WithBrowserOpener overrides how the interactive flow opens the browser (defaults to pkg/browser). Useful for headless tests.

func WithDevice

func WithDevice() LoginOption

WithDevice selects the device-code flow instead of the interactive browser flow. Note: Microsoft rejects device-code for personal Microsoft Accounts; this is for work/school accounts.

func WithLoginScopes

func WithLoginScopes(scopes ...string) LoginOption

WithLoginScopes overrides the default Config.Scopes for this login.

func WithPrompt

func WithPrompt(fn func(message string)) LoginOption

WithPrompt sets a callback for device-code instructions (defaults to writing the MSAL message to stderr).

type TokenOption

type TokenOption func(*tokenConfig)

TokenOption customizes a Token call.

func WithScopes

func WithScopes(scopes ...string) TokenOption

WithScopes overrides the default Config.Scopes for this token request. MSAL caches tokens per scope set, so requesting different scopes for a resource is fine on one cached account.

type TokenSource added in v0.2.0

type TokenSource struct {
	// contains filtered or unexported fields
}

TokenSource is a scope-bound token provider. Its method set is exactly

Token(ctx context.Context) (string, error)

which is the shape Graph and other Microsoft REST clients expect, so it can be handed to them directly. Authenticator.Token cannot be used for that because its variadic options change its signature.

A TokenSource caches the token it last obtained and reuses it until it is close to expiring, so calling it per request is the intended usage.

The caching is not an optimization of something already cheap. The silent path underneath re-reads and decrypts the token cache on every call, which on a keychain-backed store costs hundreds of milliseconds of CPU; a command making twenty requests spent almost all of its time there rather than talking to the service.

A TokenSource is safe for concurrent use. A refresh is taken under the lock so that concurrent callers wait for one refresh instead of each starting their own, which is the behavior that made the cost visible in the first place.

func (*TokenSource) Invalidate added in v0.3.0

func (s *TokenSource) Invalidate()

Invalidate discards the cached token so the next call fetches a fresh one.

Expiry is handled without this. It exists for the case expiry cannot predict: a token revoked or a consent withdrawn while it is still within its stated lifetime, which the service reports as a 401 rather than in advance.

func (*TokenSource) Scopes added in v0.2.0

func (s *TokenSource) Scopes() []string

Scopes returns the scopes this source requests.

func (*TokenSource) Token added in v0.2.0

func (s *TokenSource) Token(ctx context.Context) (string, error)

Token returns a valid access token, refreshing silently as needed.

func (*TokenSource) TokenInfo added in v0.2.0

func (s *TokenSource) TokenInfo(ctx context.Context) (AccessToken, error)

TokenInfo returns the access token with its expiry and granted scopes, reusing the last one until it is close to expiring.

Directories

Path Synopsis
Package cache provides persistent storage for the MSAL token cache used by msauth.
Package cache provides persistent storage for the MSAL token cache used by msauth.
cmd
msauth-live-smoke command
Command msauth-live-smoke verifies interactive login plus silent token reuse.
Command msauth-live-smoke verifies interactive login plus silent token reuse.
Package msauthtest provides test doubles for msauth, so downstream packages can exercise auth-dependent code without real sign-in or a keyring.
Package msauthtest provides test doubles for msauth, so downstream packages can exercise auth-dependent code without real sign-in or a keyring.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL
⌂ 换网址