From 5700aef8e174e909832a18f406e5a35c210c3fe9 Mon Sep 17 00:00:00 2001 From: Caner <7274374+caner-bot@users.noreply.github.com> Date: Tue, 15 Sep 2026 05:56:06 +0200 Subject: [PATCH] fix: accept a live refresh token as proof of the offline_access grant when checking Microsoft consent, because the v2.0 token response echoes only the scopes the access token is valid for and never lists offline_access, which was turning working Outlook mailboxes away at onboarding (#500) --- internal/app/email/onboarding.go | 7 +++++++ internal/app/email/onboarding_owner_test.go | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/internal/app/email/onboarding.go b/internal/app/email/onboarding.go index 0cc110bfc..ec3591c3b 100644 --- a/internal/app/email/onboarding.go +++ b/internal/app/email/onboarding.go @@ -419,6 +419,10 @@ func fetchInboxOwner(ctx context.Context, provider models.InboxProvider, accessT // Microsoft return a space-separated "scope" alongside the token; an empty or // absent one means the provider did not say, which is not the same as "nothing // was granted" and must not be read as a denial. +// +// Microsoft does not echo offline_access in the scope list (documented: it is +// not an access-token scope), even when a refresh token was issued, so a live +// refresh token confers it: without one there is nothing to refresh. func grantedScopes(tok *oauth2.Token) (map[string]bool, bool) { raw, _ := tok.Extra("scope").(string) if strings.TrimSpace(raw) == "" { @@ -428,6 +432,9 @@ func grantedScopes(tok *oauth2.Token) (map[string]bool, bool) { for _, sc := range strings.Fields(raw) { out[sc] = true } + if tok.RefreshToken != "" { + out["offline_access"] = true + } return out, true } diff --git a/internal/app/email/onboarding_owner_test.go b/internal/app/email/onboarding_owner_test.go index b7c1e4f38..1ed476d03 100644 --- a/internal/app/email/onboarding_owner_test.go +++ b/internal/app/email/onboarding_owner_test.go @@ -173,6 +173,22 @@ func TestOutlookPartialConsentIsRefused(t *testing.T) { } } +// Microsoft does not echo offline_access in the scope list for personal +// accounts even when a refresh token was issued, so the live token confers +// the grant the list omits. +func TestOutlookRefreshTokenSatisfiesOfflineAccess(t *testing.T) { + want := []string{ + "https://graph.microsoft.com/Mail.Send", + "https://graph.microsoft.com/Mail.ReadWrite", + "offline_access", + } + xerr := checkGrantedScopes(context.Background(), models.InboxProviderOutlook, want, + tokenWithScope("https://graph.microsoft.com/Mail.Send https://graph.microsoft.com/Mail.ReadWrite")) + if xerr != nil { + t.Fatalf("a live refresh token must satisfy offline_access, got %v", xerr) + } +} + // ── what may be recorded ───────────────────────────────────────────────────── // A decode failure and a missing address both carry status 200, and that body