Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Microsoft 365 in your terminal.
- Stable scripting output modes: `--json` and `--plain`.
- Interactive delegated wizard (`mog auth`), advanced app-only wizard (`mog auth app`), settings editor (`mog auth update`), and non-interactive login (`mog auth login`).
- Per-command scope requests in delegated mode (progressive consent).
- `--dry-run` previews for write operations in Mail, Calendar, and OneDrive.

### Workload support matrix

Expand Down Expand Up @@ -174,6 +175,7 @@ Mail:
mog mail list --max 50 --query "from:alerts@example.com"
mog mail get <message-id>
mog mail send --to dev@contoso.com --subject "Deploy complete" --body "Finished."
mog mail send --to dev@contoso.com --subject "Deploy complete" --body "Finished." --dry-run
```

Calendar:
Expand All @@ -185,6 +187,7 @@ mog calendar create \
--start "2026-02-13T16:00:00-08:00" \
--end "2026-02-13T16:30:00-08:00" \
--body "Weekly sync"
mog calendar delete <event-id> --dry-run
```

Contacts:
Expand Down Expand Up @@ -218,8 +221,11 @@ mog onedrive put ./report.pdf --path /Reports/report.pdf
mog onedrive get /Reports/report.pdf --out ./report.pdf
mog onedrive mkdir --path /Reports/Archive
mog onedrive rm --path /Reports/old-report.pdf
mog onedrive rm --path /Reports/old-report.pdf --dry-run
```

If `mog onedrive get` is run without `--out`, files are saved under the local `onedrive-downloads` directory in your mogcli config path.

App-only target user override (mail/contacts/onedrive):

```bash
Expand Down
2 changes: 2 additions & 0 deletions docs/microsoft-port-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Implemented in codebase:
8. App-only endpoint routing for mail/contacts/onedrive using `/users/{id}` with `--user` override and profile fallback.
9. Explicit fail-fast app-only rejection for calendar/tasks with deterministic user-facing guidance.
10. Task mutation mutability error normalization in `internal/services/tasks/service.go` for built-in/well-known Microsoft To Do list constraints.
11. `--dry-run` previews for write operations in mail, calendar, and onedrive command surfaces.
12. OneDrive local path hardening (`SafeExpandPath`), metadata-rich delete confirmation, and progress output for local file transfers.

## 0. Critique of the prior plan

Expand Down
82 changes: 70 additions & 12 deletions internal/auth/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"time"

"github.com/jaredpalmer/mogcli/internal/errfmt"
"github.com/jaredpalmer/mogcli/internal/profile"
"github.com/jaredpalmer/mogcli/internal/secrets"
)

Expand All @@ -24,11 +25,13 @@ var (
ErrMissingSecret = errors.New("missing client secret")
)

// Manager coordinates login, token acquisition, and secure token persistence.
type Manager struct {
HTTPClient *http.Client
now func() time.Time
}

// NewManager returns an auth manager with default HTTP/time providers.
func NewManager() *Manager {
return &Manager{
HTTPClient: http.DefaultClient,
Expand All @@ -52,12 +55,20 @@ func (m *Manager) nowUTC() time.Time {
return time.Now().UTC()
}

func tokenCacheKey(profileName string) string {
return "mog:cache:" + strings.TrimSpace(profileName)
func tokenCacheKey(profileName string) (string, error) {
normalized, err := profile.NormalizeName(profileName)
if err != nil {
return "", fmt.Errorf("invalid profile name: %w", err)
}
return "mog:cache:" + normalized, nil
}

func appSecretKey(profileName string) string {
return "mog:appsecret:" + strings.TrimSpace(profileName)
func appSecretKey(profileName string) (string, error) {
normalized, err := profile.NormalizeName(profileName)
if err != nil {
return "", fmt.Errorf("invalid profile name: %w", err)
}
return "mog:appsecret:" + normalized, nil
}

func graphDefaultScope() string {
Expand Down Expand Up @@ -126,6 +137,7 @@ func (m *Manager) LoginDelegated(ctx context.Context, input DelegatedLoginInput,
if err != nil {
return AccountInfo{}, err
}
defer secureZero(body)

var dcr deviceCodeResponse
if err := json.Unmarshal(body, &dcr); err != nil {
Expand Down Expand Up @@ -230,11 +242,18 @@ func (m *Manager) LoginAppOnly(ctx context.Context, input AppOnlyLoginInput) err
return ErrMissingSecret
}

if err := secrets.SetSecret(appSecretKey(input.ProfileName), []byte(input.Secret)); err != nil {
secretKey, err := appSecretKey(input.ProfileName)
if err != nil {
return err
}

secretBytes := []byte(input.Secret)
defer secureZero(secretBytes)
if err := secrets.SetSecret(secretKey, secretBytes); err != nil {
return fmt.Errorf("store client secret: %w", err)
}

_, err := m.AcquireAppOnlyToken(ctx, input.ProfileName, input.ClientID, input.Authority)
_, err = m.AcquireAppOnlyToken(ctx, input.ProfileName, input.ClientID, input.Authority)
return err
}

Expand Down Expand Up @@ -281,6 +300,7 @@ func (m *Manager) AcquireDelegatedToken(
if err != nil {
return "", err
}
defer secureZero(body)
if status != http.StatusOK {
var oe oauthErrorResponse
_ = json.Unmarshal(body, &oe)
Expand Down Expand Up @@ -321,10 +341,16 @@ func (m *Manager) AcquireDelegatedToken(
}

func (m *Manager) AcquireAppOnlyToken(ctx context.Context, profileName string, clientID string, authority string) (string, error) {
secret, err := secrets.GetSecret(appSecretKey(profileName))
secretKey, err := appSecretKey(profileName)
if err != nil {
return "", err
}

secret, err := secrets.GetSecret(secretKey)
if err != nil {
return "", fmt.Errorf("read client secret: %w", err)
}
defer secureZero(secret)
if len(secret) == 0 {
return "", ErrMissingSecret
}
Expand All @@ -339,6 +365,7 @@ func (m *Manager) AcquireAppOnlyToken(ctx context.Context, profileName string, c
if err != nil {
return "", err
}
defer secureZero(body)

if status != http.StatusOK {
var oe oauthErrorResponse
Expand Down Expand Up @@ -369,10 +396,19 @@ func (m *Manager) ReadAccount(profileName string) (AccountInfo, error) {
}

func (m *Manager) Logout(profileName string) error {
if err := secrets.DeleteSecret(tokenCacheKey(profileName)); err != nil {
cacheKey, err := tokenCacheKey(profileName)
if err != nil {
return err
}
if err := secrets.DeleteSecret(cacheKey); err != nil {
return err
}

secretKey, err := appSecretKey(profileName)
if err != nil {
return err
}
if err := secrets.DeleteSecret(appSecretKey(profileName)); err != nil {
if err := secrets.DeleteSecret(secretKey); err != nil {
return err
}

Expand Down Expand Up @@ -506,7 +542,11 @@ func looksLikeTenantDomain(value string) bool {
}

func (m *Manager) purgeDelegatedTokenCache(profileName string) error {
if err := secrets.DeleteSecret(tokenCacheKey(profileName)); err != nil {
cacheKey, err := tokenCacheKey(profileName)
if err != nil {
return err
}
if err := secrets.DeleteSecret(cacheKey); err != nil {
return fmt.Errorf("purge delegated token cache: %w", err)
}
return nil
Expand All @@ -517,19 +557,31 @@ func (m *Manager) saveToken(profileName string, cache TokenCache) error {
if err != nil {
return fmt.Errorf("encode token cache: %w", err)
}
defer secureZero(payload)

cacheKey, err := tokenCacheKey(profileName)
if err != nil {
return err
}

if err := secrets.SetSecret(tokenCacheKey(profileName), payload); err != nil {
if err := secrets.SetSecret(cacheKey, payload); err != nil {
return fmt.Errorf("store token cache: %w", err)
}

return nil
}

func (m *Manager) loadToken(profileName string) (TokenCache, error) {
payload, err := secrets.GetSecret(tokenCacheKey(profileName))
cacheKey, err := tokenCacheKey(profileName)
if err != nil {
return TokenCache{}, err
}

payload, err := secrets.GetSecret(cacheKey)
if err != nil {
return TokenCache{}, fmt.Errorf("read token cache: %w", err)
}
defer secureZero(payload)

var cache TokenCache
if err := json.Unmarshal(payload, &cache); err != nil {
Expand Down Expand Up @@ -625,3 +677,9 @@ func sleepContext(ctx context.Context, delay time.Duration) error {
return nil
}
}

func secureZero(value []byte) {
for i := range value {
value[i] = 0
}
}
17 changes: 17 additions & 0 deletions internal/auth/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,3 +280,20 @@ func idTokenFor(t *testing.T, accountID string, tenantID string) string {
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
return header + "." + payload + "."
}

func TestTokenCacheKeyRejectsInvalidProfileName(t *testing.T) {
if _, err := tokenCacheKey("../bad-profile"); err == nil {
t.Fatal("expected invalid profile name error")
}
}

func TestSecureZero(t *testing.T) {
value := []byte("super-secret")
secureZero(value)

for i, b := range value {
if b != 0 {
t.Fatalf("expected index %d to be zeroed, got %d", i, b)
}
}
}
48 changes: 43 additions & 5 deletions internal/cmd/calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ type CalendarCreateCmd struct {
Body string `name:"body" help:"Optional body text"`
Attendees []string `name:"attendee" help:"Attendee email (repeat for multiple)"`
Teams bool `name:"teams" help:"Add Teams meeting link"`
DryRun bool `name:"dry-run" help:"Preview create without creating the event"`
}

func (c *CalendarCreateCmd) Run(ctx context.Context) error {
Expand Down Expand Up @@ -121,6 +122,17 @@ func (c *CalendarCreateCmd) Run(ctx context.Context) error {
payload["isOnlineMeeting"] = true
payload["onlineMeetingProvider"] = "teamsForBusiness"
}
if c.DryRun {
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"dry_run": true,
"action": "calendar.create",
"event": payload,
})
}
fmt.Fprintf(os.Stdout, "Dry run: would create event %q from %s to %s\n", c.Subject, c.Start, c.End)
return nil
}

svc := calendar.New(rt.Graph)
item, err := svc.Create(ctx, payload)
Expand All @@ -143,6 +155,7 @@ type CalendarUpdateCmd struct {
Body string `name:"body" help:"Body text"`
Attendees []string `name:"attendee" help:"Attendee email (repeat for multiple)"`
Teams bool `name:"teams" help:"Add Teams meeting link"`
DryRun bool `name:"dry-run" help:"Preview update without modifying the event"`
}

func (c *CalendarUpdateCmd) Run(ctx context.Context) error {
Expand Down Expand Up @@ -191,6 +204,18 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context) error {
if err != nil {
return err
}
if c.DryRun {
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"dry_run": true,
"action": "calendar.update",
"id": c.ID,
"changes": payload,
})
}
fmt.Fprintf(os.Stdout, "Dry run: would update event %s\n", c.ID)
return nil
}

svc := calendar.New(rt.Graph)
if err := svc.Update(ctx, c.ID, payload); err != nil {
Expand All @@ -205,10 +230,27 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context) error {
}

type CalendarDeleteCmd struct {
ID string `arg:"" required:"" help:"Event ID"`
ID string `arg:"" required:"" help:"Event ID"`
DryRun bool `name:"dry-run" help:"Preview delete without deleting the event"`
}

func (c *CalendarDeleteCmd) Run(ctx context.Context) error {
rt, err := resolveRuntime(ctx, capCalendarDelete)
if err != nil {
return err
}
if c.DryRun {
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"dry_run": true,
"action": "calendar.delete",
"id": c.ID,
})
}
fmt.Fprintf(os.Stdout, "Dry run: would delete event %s\n", c.ID)
return nil
}

flags := rootFlagsFromContext(ctx)
if flags == nil {
flags = &RootFlags{}
Expand All @@ -217,10 +259,6 @@ func (c *CalendarDeleteCmd) Run(ctx context.Context) error {
return err
}

rt, err := resolveRuntime(ctx, capCalendarDelete)
if err != nil {
return err
}
if err := calendar.New(rt.Graph).Delete(ctx, c.ID); err != nil {
return err
}
Expand Down
Loading