ArtisanPackUI\MicrosoftOAuth\Tokens\TokenManager is the piece service packages interact with when making Microsoft API calls. It returns a valid access token, refreshing transparently when the current one is close to expiring, and marks the connection disconnected on a terminal refresh error.
Getting a valid access token
Most callers use one of the higher-level entry points rather than the token manager directly:
MicrosoftOAuth::request( $userId )— returns a bearer-configuredPendingRequest(facade).app( TokenProvider::class )->accessTokenFor( $userId )— returns just the token string (contract).
Both eventually call TokenManager::getValidAccessToken( $connection ). Use it directly only when you already have the MicrosoftConnection on hand:
use ArtisanPackUI\MicrosoftOAuth\Tokens\TokenManager;
use ArtisanPackUI\MicrosoftOAuth\Models\MicrosoftConnection;
use Illuminate\Support\Facades\Http;
$connection = MicrosoftConnection::firstWhere( 'user_id', $user->id );
$token = app( TokenManager::class )->getValidAccessToken( $connection );
$response = Http::withToken( $token )
->acceptJson()
->get( 'https://graph.microsoft.com/v1.0/me' );
getValidAccessToken() decides what to return:
- If the connection isn't connected → throws
TokenRefreshException("Microsoft connection is disconnected."). - If the current access token is present and not close to expiring → returns it as-is.
- Otherwise → calls
refresh()and returns the fresh token.
Direct vs. broker refreshes
In the default direct mode, refreshes go straight to Microsoft through the stateless MicrosoftClient. In broker mode they go to the broker's /refresh endpoint through BrokerClient. Everything on this page applies to both, except where noted.
Refresh window
MicrosoftConnection::isExpired() treats the token as expired 60 seconds before expires_at:
public function isExpired(): bool
{
if ( null === $this->expires_at ) {
return true;
}
return $this->expires_at->copy()->subSeconds( 60 )->isPast();
}
So the manager refreshes proactively — no API call ever ships with a token about to die mid-request.
Missing expires_at (which shouldn't happen but Microsoft's response is technically allowed to omit expires_in) is treated as expired: the next call triggers a refresh.
Forced refresh
If you know for some reason the stored token is bad, force a refresh:
app( TokenManager::class )->refresh( $connection );
This bypasses the expiry check and always hits the refresh endpoint. Useful in tests or when reacting to a 401 Unauthorized from Microsoft that the expiry check didn't predict.
What happens during refresh
TokenManager::refresh():
- If no refresh token is on file, calls
$connection->markDisconnected('Missing refresh token.')and throwsTokenRefreshException("No refresh token stored for this connection."). - If
client_idis missing or the configured tenant isn't a valid authority (misconfiguration), throwsTokenRefreshException(getError()=invalid_client/invalid_tenant) without marking the connection disconnected — the fix is in configuration, not the connection. In broker mode, incomplete broker credentials do the same withbroker_not_configured. - POSTs to
https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token(broker mode: the broker's/api/v1/oauth/refreshwithrefresh_tokenandprovider=microsoft):Theclient_id = <from config driver> refresh_token = <from connection> grant_type = refresh_token scope = <space-separated grantedScopes()> client_secret = <from config driver, if present>scopeparameter is required on Microsoft's v2.0 refresh — sending the connection's current scope set keeps the grant intact rather than accidentally downgrading. - On non-success, extracts
errorfrom the response body. If it's one of the terminal errors (see below), marks the connection disconnected. Either way, throwsTokenRefreshException("Microsoft token refresh failed: {error}"), with the code available fromgetError(). - On success, updates the connection:
access_token,token_type— always.expires_at— computed fromexpires_in, when the response includes it.refresh_token— only if the response included one. Microsoft rotates refresh tokens on every refresh, so this path is usually taken; the guard prevents wiping the existing one if a response is malformed.scopes— if the response includes them.
Every path either returns a fresh access token or throws — there's no partial-success state.
Terminal refresh errors
Four Microsoft error codes are treated as terminal — the refresh token is dead and re-consent is required:
invalid_grant— token was revoked or expired.interaction_required— the user needs to complete an interactive step Microsoft can't do silently.consent_required— a scope requires consent that hasn't been granted.login_required— the user's Microsoft session expired or was invalidated.
On any of these, the connection is marked disconnected with reason "Refresh token revoked or expired (:error).". All other errors (invalid_client, invalid_tenant, unauthorized_client, license_expired, network failures) throw TokenRefreshException but leave the connection connected — the caller can retry after fixing the underlying issue.
Failure modes
Microsoft connection is disconnected.
The connection's status is 'disconnected' — someone (a prior terminal refresh failure, a user disconnect, an admin) marked it dead. Service packages should either surface a "Reconnect Microsoft" prompt or defer until the user reconnects. Don't retry — a disconnected connection stays disconnected until the user re-runs /connect.
No refresh token stored for this connection.
Rare, but possible if:
- The connection was created without
offline_accessin the granted scopes — Microsoft only returns a refresh token whenoffline_accessis granted. The baseline includes it, so this only happens if you overrode the registry to remove it. - The
refresh_tokencolumn was somehow wiped (manual DB edit, encryption failure).
The manager marks the connection disconnected so subsequent calls fail loudly.
Microsoft OAuth is not configured: client_id is missing.
The credential driver returns no client_id. The connection is not marked disconnected — this is a config problem, not a per-user problem. Fix your driver setup and retry.
Invalid Microsoft OAuth tenant "…"
Since 1.1.0. The configured tenant isn't common, organizations, consumers, a GUID, or a verified domain. getError() is invalid_tenant, and the connection is not marked disconnected. Fix MICROSOFT_OAUTH_TENANT (or the driver's stored tenant) and retry.
The Microsoft connection cannot be refreshed because the site license has expired.
Broker mode only. The broker refused the refresh with 402 / license_expired, and the token manager threw LicenseExpiredException. The connection is not marked disconnected, because the Microsoft grant is still valid. Send the user (or the site admin) to $e->getRenewUrl(). Once the license is renewed, refreshes resume without reconnecting. See Broker Mode → License expiry.
Microsoft token refresh failed: invalid_grant
The refresh token is dead. Common causes:
- User revoked access at my Microsoft account permissions (personal) or via their tenant admin (work / school).
- Password change / MFA change that invalidates the underlying refresh token.
- Tenant admin revoked the app's consent.
- Extremely long inactivity — refresh tokens for Entra apps can eventually expire (typically 90+ days of inactivity, though the exact behavior varies by tenant policy).
The manager marks the connection disconnected with reason "Refresh token revoked or expired (invalid_grant).".
Microsoft token refresh failed: interaction_required / consent_required / login_required
Terminal errors — treated the same as invalid_grant. Marked disconnected, user must reconnect.
Handling exceptions in service packages
use ArtisanPackUI\MicrosoftOAuth\Exceptions\LicenseExpiredException;
use ArtisanPackUI\MicrosoftOAuth\Exceptions\TokenRefreshException;
use ArtisanPackUI\MicrosoftOAuth\Exceptions\MissingConnectionException;
use ArtisanPackUI\MicrosoftOAuth\Facades\MicrosoftOAuth;
try {
$response = MicrosoftOAuth::request( $user->id )
->acceptJson()
->get( 'https://graph.microsoft.com/v1.0/me' );
} catch ( MissingConnectionException $e ) {
// User has never connected — send them through /connect.
return redirect()->route( 'microsoft.auth.connect' );
} catch ( LicenseExpiredException $e ) {
// Broker mode only — the connection is fine, the license isn't.
return back()->with( 'renew_url', $e->getRenewUrl() );
} catch ( TokenRefreshException $e ) {
// Re-fetch the connection; the manager may have flipped its status.
$connection = \ArtisanPackUI\MicrosoftOAuth\Models\MicrosoftConnection::firstWhere( 'user_id', $user->id );
if ( ! $connection?->isConnected() ) {
return redirect()->route( 'settings.integrations' )
->with( 'error', __( 'Please reconnect Microsoft.' ) );
}
// Otherwise it's likely a transient error — retry once or bubble up.
throw $e;
}
MissingConnectionException extends OAuthException, and TokenRefreshException is separate — catch them independently so you can render the right prompt. LicenseExpiredException extends TokenRefreshException, so catch it first.
Concurrency
TokenManager doesn't hold locks. If two workers hit getValidAccessToken() for the same connection at the exact same time, both will refresh — one will win the DB write and the other will overwrite it with what it received. In practice this is mostly harmless — both access tokens are valid. But because Microsoft rotates refresh tokens on every refresh, whichever refresh token got stored last is the only one that will work for the next refresh; the other one is now invalid on Microsoft's side. The next refresh cycle either uses the surviving token (fine) or hits the now-invalidated one (throws invalid_grant and disconnects).
If your workload is genuinely concurrent enough for this to bite, wrap the call in Cache::lock("microsoft-refresh:{$connection->id}") or serialize refreshes through a queue.
Testing
Fake the HTTP calls with Laravel's Http::fake():
use ArtisanPackUI\MicrosoftOAuth\Tokens\TokenManager;
use ArtisanPackUI\MicrosoftOAuth\Models\MicrosoftConnection;
use Illuminate\Support\Facades\Http;
Http::fake( [
'https://login.microsoftonline.com/*' => Http::response( [
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'expires_in' => 3600,
'token_type' => 'Bearer',
] ),
] );
$connection = MicrosoftConnection::create( [
'user_id' => 1,
'access_token' => 'expired',
'refresh_token' => 'old-refresh-token',
'expires_at' => now()->subMinute(),
'status' => 'connected',
] );
expect( app( TokenManager::class )->getValidAccessToken( $connection ) )->toBe( 'new-access-token' );
expect( $connection->fresh()->refresh_token )->toBe( 'new-refresh-token' );
The manager resolves its HTTP client from Illuminate\Http\Client\Factory, which Http::fake() swaps in transparently.

