diff --git a/.deadcode-baseline.txt b/.deadcode-baseline.txt index 3d9bbeb..f8817f1 100644 --- a/.deadcode-baseline.txt +++ b/.deadcode-baseline.txt @@ -39,6 +39,10 @@ internal/googleauth/oauth_flow_manual_redirect.go:54:6: unreachable func: extrac internal/googleauth/scopes.go:34:6: unreachable func: ScopesForCommands internal/googleauth/scopes.go:72:6: unreachable func: AllScopes internal/googleauth/scopes.go:96:6: unreachable func: knownCommandNames +internal/msauth/broker_store.go:28:6: unreachable func: NewMemoryBrokerStore +internal/msauth/broker_store.go:32:29: unreachable func: MemoryBrokerStore.Save +internal/msauth/broker_store.go:50:29: unreachable func: MemoryBrokerStore.Consume +internal/msauth/broker_store.go:76:6: unreachable func: ValidateBrokerAuthorizedEmail internal/msauth/scopes.go:17:6: unreachable func: canonicalPilotScope internal/msauth/scopes.go:40:6: unreachable func: GuardPilotScopes internal/officetext/extract.go:46:6: unreachable func: ExtractTextByMIME diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 789cd83..aa9a3b7 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -69,6 +69,7 @@ type AuthCmd struct { Remove AuthRemoveCmd `cmd:"" name:"remove" help:"Remove a stored refresh token"` Tokens AuthTokensCmd `cmd:"" name:"tokens" help:"Manage stored refresh tokens"` Manage AuthManageCmd `cmd:"" name:"manage" help:"Open accounts manager in browser" aliases:"login"` + M365 AuthM365Cmd `cmd:"" name:"m365" help:"Microsoft 365 authentication helpers"` ServiceAcct AuthServiceAccountCmd `cmd:"" name:"service-account" help:"Configure service account (Workspace only; domain-wide delegation)"` Keep AuthKeepCmd `cmd:"" name:"keep" help:"Configure service account for Google Keep (Workspace only)"` } diff --git a/internal/cmd/auth_m365_link.go b/internal/cmd/auth_m365_link.go new file mode 100644 index 0000000..0ac7f47 --- /dev/null +++ b/internal/cmd/auth_m365_link.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "context" + "os" + "strings" + "time" + + "github.com/automagik-dev/workit/internal/msauth" + "github.com/automagik-dev/workit/internal/outfmt" + "github.com/automagik-dev/workit/internal/ui" +) + +var createM365BrokerSession = msauth.CreateBrokerSession + +type AuthM365Cmd struct { + LoginLink AuthM365LoginLinkCmd `cmd:"" name:"login-link" help:"Create a one-click Microsoft 365 read-only login link"` +} + +type AuthM365LoginLinkCmd struct { + Email string `arg:"" name:"email" help:"Expected Microsoft 365 account email"` + BaseURL string `name:"base-url" help:"Public HTTPS broker base URL, e.g. https://login.workit.ai"` + CallbackURL string `name:"callback-url" help:"Public HTTPS Microsoft OAuth callback URL"` + TTL time.Duration `name:"ttl" help:"Login link validity duration" default:"10m"` + ForceConsent bool `name:"force-consent" help:"Force Microsoft consent screen"` +} + +func (c *AuthM365LoginLinkCmd) Run(ctx context.Context, _ *RootFlags) error { + email := strings.TrimSpace(c.Email) + if email == "" { + return usage("empty email") + } + if strings.TrimSpace(c.BaseURL) == "" { + return usage("m365 login-link requires --base-url") + } + if strings.TrimSpace(c.CallbackURL) == "" { + return usage("m365 login-link requires --callback-url") + } + + session, err := createM365BrokerSession(ctx, msauth.BrokerSessionOptions{ + ExpectedEmail: email, + BaseURL: c.BaseURL, + CallbackURL: c.CallbackURL, + Readonly: true, + ForceConsent: c.ForceConsent, + TTL: c.TTL, + }) + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, session) + } + + u := ui.FromContext(ctx) + u.Out().Printf("login_url\t%s", session.LoginURL) + u.Out().Printf("expected_email\t%s", session.ExpectedEmail) + u.Out().Printf("expires_at\t%s", session.ExpiresAt.Format(time.RFC3339)) + return nil +} diff --git a/internal/cmd/m365_login_link_test.go b/internal/cmd/m365_login_link_test.go new file mode 100644 index 0000000..556ee81 --- /dev/null +++ b/internal/cmd/m365_login_link_test.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/automagik-dev/workit/internal/msauth" +) + +func TestAuthM365LoginLinkPrintsOneClickURL(t *testing.T) { + origCreate := createM365BrokerSession + t.Cleanup(func() { createM365BrokerSession = origCreate }) + + var got msauth.BrokerSessionOptions + createM365BrokerSession = func(_ context.Context, opts msauth.BrokerSessionOptions) (msauth.BrokerSession, error) { + got = opts + return msauth.BrokerSession{ + State: "state", + ExpectedEmail: "bernardo@hapvida.com.br", + LoginURL: "https://login.workit.ai/m365/start/state", + ExpiresAt: time.Unix(1893456000, 0).UTC(), + }, nil + } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "auth", "m365", "login-link", "bernardo@hapvida.com.br", "--base-url", "https://login.workit.ai", "--callback-url", "https://login.workit.ai/m365/callback"}); err != nil { + t.Fatalf("login-link: %v", err) + } + }) + }) + + if got.ExpectedEmail != "bernardo@hapvida.com.br" || !got.Readonly { + t.Fatalf("options = %#v", got) + } + if got.BaseURL != "https://login.workit.ai" || got.CallbackURL != "https://login.workit.ai/m365/callback" { + t.Fatalf("urls = %#v", got) + } + if !strings.Contains(out, "https://login.workit.ai/m365/start/state") { + t.Fatalf("missing login link: %s", out) + } +} + +func TestAuthM365LoginLinkRequiresExplicitBrokerURLs(t *testing.T) { + _ = captureStderr(t, func() { + err := Execute([]string{"auth", "m365", "login-link", "bernardo@hapvida.com.br"}) + if err == nil { + t.Fatal("expected missing broker URL failure") + } + if !strings.Contains(err.Error(), "base-url") { + t.Fatalf("unexpected error: %v", err) + } + }) +} diff --git a/internal/msauth/broker.go b/internal/msauth/broker.go new file mode 100644 index 0000000..e557727 --- /dev/null +++ b/internal/msauth/broker.go @@ -0,0 +1,107 @@ +package msauth + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "time" +) + +var ( + ErrBrokerHTTPSRequired = errors.New("m365 broker URL must use https") + ErrBrokerMissingEmail = errors.New("m365 broker expected email missing") + ErrBrokerURLRequired = errors.New("m365 broker URL required") +) + +type BrokerSessionOptions struct { + ExpectedEmail string + BaseURL string + CallbackURL string + Readonly bool + ForceConsent bool + TTL time.Duration +} + +type BrokerSession struct { + State string `json:"state"` + ExpectedEmail string `json:"expected_email"` + LoginURL string `json:"login_url"` + AuthURL string `json:"auth_url,omitempty"` + CodeVerifier string `json:"-"` + CodeChallenge string `json:"-"` + ExpiresAt time.Time `json:"expires_at"` +} + +func CreateBrokerSession(_ context.Context, opts BrokerSessionOptions) (BrokerSession, error) { + expectedEmail := strings.ToLower(strings.TrimSpace(opts.ExpectedEmail)) + if expectedEmail == "" { + return BrokerSession{}, ErrBrokerMissingEmail + } + + if !opts.Readonly { + return BrokerSession{}, fmt.Errorf("%w: one-click broker is read-only only", ErrPilotScopeNotAllowed) + } + + baseURL, err := parseHTTPSURL(opts.BaseURL, "base-url") + if err != nil { + return BrokerSession{}, err + } + + callbackURL, err := parseHTTPSURL(opts.CallbackURL, "callback-url") + if err != nil { + return BrokerSession{}, err + } + + settings, err := resolveOAuthSettings() + if err != nil { + return BrokerSession{}, err + } + + scopes, err := OAuthScopes(opts.Readonly) + if err != nil { + return BrokerSession{}, err + } + + state, verifier, challenge, err := newOAuthStateAndPKCE() + if err != nil { + return BrokerSession{}, err + } + + if opts.TTL <= 0 { + opts.TTL = 10 * time.Minute + } + + loginURL := baseURL.JoinPath("m365", "start", state).String() + cfg := oauthConfigFn(settings, callbackURL.String(), scopes) + authURL := cfg.AuthCodeURL(state, authParams(opts.ForceConsent, challenge)...) + + return BrokerSession{ + State: state, + ExpectedEmail: expectedEmail, + LoginURL: loginURL, + AuthURL: authURL, + CodeVerifier: verifier, + CodeChallenge: challenge, + ExpiresAt: time.Now().UTC().Add(opts.TTL), + }, nil +} + +func parseHTTPSURL(raw string, field string) (*url.URL, error) { + value := strings.TrimSpace(raw) + if value == "" { + return nil, fmt.Errorf("%w: %s", ErrBrokerURLRequired, field) + } + + parsed, err := url.Parse(value) + if err != nil { + return nil, fmt.Errorf("parse m365 broker %s: %w", field, err) + } + + if parsed.Scheme != "https" || parsed.Host == "" { + return nil, fmt.Errorf("%w: %s", ErrBrokerHTTPSRequired, field) + } + + return parsed, nil +} diff --git a/internal/msauth/broker_store.go b/internal/msauth/broker_store.go new file mode 100644 index 0000000..311f432 --- /dev/null +++ b/internal/msauth/broker_store.go @@ -0,0 +1,85 @@ +package msauth + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" +) + +var ( + ErrBrokerStateNotFound = errors.New("m365 broker state not found") + ErrBrokerStateExpired = errors.New("m365 broker state expired") + ErrBrokerEmailMismatch = errors.New("m365 broker authorized email mismatch") +) + +type BrokerStore interface { + Save(context.Context, BrokerSession) error + Consume(context.Context, string) (BrokerSession, error) +} + +type MemoryBrokerStore struct { + mu sync.Mutex + sessions map[string]BrokerSession +} + +func NewMemoryBrokerStore() *MemoryBrokerStore { + return &MemoryBrokerStore{sessions: make(map[string]BrokerSession)} +} + +func (s *MemoryBrokerStore) Save(_ context.Context, session BrokerSession) error { + state := strings.TrimSpace(session.State) + if state == "" { + return ErrBrokerStateNotFound + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.sessions == nil { + s.sessions = make(map[string]BrokerSession) + } + + s.sessions[state] = session + + return nil +} + +func (s *MemoryBrokerStore) Consume(_ context.Context, state string) (BrokerSession, error) { + key := strings.TrimSpace(state) + if key == "" { + return BrokerSession{}, ErrBrokerStateNotFound + } + + s.mu.Lock() + + session, ok := s.sessions[key] + if ok { + delete(s.sessions, key) + } + + s.mu.Unlock() + + if !ok { + return BrokerSession{}, ErrBrokerStateNotFound + } + + if !session.ExpiresAt.IsZero() && time.Now().After(session.ExpiresAt) { + return BrokerSession{}, ErrBrokerStateExpired + } + + return session, nil +} + +func ValidateBrokerAuthorizedEmail(session BrokerSession, authorizedEmail string) error { + expected := strings.ToLower(strings.TrimSpace(session.ExpectedEmail)) + actual := strings.ToLower(strings.TrimSpace(authorizedEmail)) + + if expected == "" || actual == "" || expected != actual { + return fmt.Errorf("%w: expected %s got %s", ErrBrokerEmailMismatch, expected, actual) + } + + return nil +} diff --git a/internal/msauth/broker_store_test.go b/internal/msauth/broker_store_test.go new file mode 100644 index 0000000..4d456b2 --- /dev/null +++ b/internal/msauth/broker_store_test.go @@ -0,0 +1,74 @@ +package msauth + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestMemoryBrokerStoreConsumesSessionOnce(t *testing.T) { + store := NewMemoryBrokerStore() + session := BrokerSession{State: "state", ExpectedEmail: "pilot@example.com", CodeVerifier: "verifier", ExpiresAt: time.Now().Add(time.Minute)} + + if err := store.Save(context.Background(), session); err != nil { + t.Fatalf("save: %v", err) + } + + got, err := store.Consume(context.Background(), "state") + if err != nil { + t.Fatalf("consume: %v", err) + } + + if got.ExpectedEmail != "pilot@example.com" || got.CodeVerifier != "verifier" { + t.Fatalf("session = %#v", got) + } + + _, err = store.Consume(context.Background(), "state") + if !errors.Is(err, ErrBrokerStateNotFound) { + t.Fatalf("expected one-time state miss, got %v", err) + } +} + +func TestMemoryBrokerStoreZeroValueSaveInitializesSessionMap(t *testing.T) { + var store MemoryBrokerStore + session := BrokerSession{State: "state", ExpectedEmail: "pilot@example.com", ExpiresAt: time.Now().Add(time.Minute)} + + if err := store.Save(context.Background(), session); err != nil { + t.Fatalf("save zero-value store: %v", err) + } + + got, err := store.Consume(context.Background(), "state") + if err != nil { + t.Fatalf("consume zero-value store: %v", err) + } + + if got.ExpectedEmail != "pilot@example.com" { + t.Fatalf("session = %#v", got) + } +} + +func TestMemoryBrokerStoreRejectsExpiredSession(t *testing.T) { + store := NewMemoryBrokerStore() + session := BrokerSession{State: "expired", ExpectedEmail: "pilot@example.com", ExpiresAt: time.Now().Add(-time.Minute)} + + if err := store.Save(context.Background(), session); err != nil { + t.Fatalf("save: %v", err) + } + + _, err := store.Consume(context.Background(), "expired") + if !errors.Is(err, ErrBrokerStateExpired) { + t.Fatalf("expected expired state, got %v", err) + } +} + +func TestValidateBrokerAuthorizedEmailFailsClosedOnMismatch(t *testing.T) { + err := ValidateBrokerAuthorizedEmail(BrokerSession{ExpectedEmail: "bernardo@hapvida.com.br"}, "other@hapvida.com.br") + if !errors.Is(err, ErrBrokerEmailMismatch) { + t.Fatalf("expected email mismatch, got %v", err) + } + + if err := ValidateBrokerAuthorizedEmail(BrokerSession{ExpectedEmail: "Bernardo@Hapvida.com.br"}, "bernardo@hapvida.com.br"); err != nil { + t.Fatalf("expected normalized email match, got %v", err) + } +} diff --git a/internal/msauth/broker_test.go b/internal/msauth/broker_test.go new file mode 100644 index 0000000..313fe1d --- /dev/null +++ b/internal/msauth/broker_test.go @@ -0,0 +1,98 @@ +package msauth + +import ( + "context" + "net/url" + "strings" + "testing" + "time" + + "github.com/automagik-dev/workit/internal/config" +) + +func TestCreateBrokerSessionBuildsOneClickLoginLinkWithoutWriteScopes(t *testing.T) { + origClientID := config.DefaultM365ClientID + origTenantID := config.DefaultM365TenantID + origRandom := randomStateFn + + t.Cleanup(func() { + config.DefaultM365ClientID = origClientID + config.DefaultM365TenantID = origTenantID + randomStateFn = origRandom + }) + + config.DefaultM365ClientID = "client-id" + config.DefaultM365TenantID = "organizations" + randomStateFn = func() (string, error) { return "opaque-state", nil } + + session, err := CreateBrokerSession(context.Background(), BrokerSessionOptions{ + ExpectedEmail: "Bernardo@Hapvida.com.br", + BaseURL: "https://login.workit.ai", + CallbackURL: "https://login.workit.ai/m365/callback", + Readonly: true, + TTL: 10 * time.Minute, + }) + if err != nil { + t.Fatalf("CreateBrokerSession: %v", err) + } + + if session.LoginURL != "https://login.workit.ai/m365/start/opaque-state" { + t.Fatalf("login url = %q", session.LoginURL) + } + + if session.ExpectedEmail != "bernardo@hapvida.com.br" { + t.Fatalf("expected email = %q", session.ExpectedEmail) + } + + if session.CodeVerifier == "" || session.CodeChallenge == "" { + t.Fatalf("expected PKCE material: %#v", session) + } + + authURL, err := url.Parse(session.AuthURL) + if err != nil { + t.Fatalf("parse auth url: %v", err) + } + + q := authURL.Query() + if got := q.Get("redirect_uri"); got != "https://login.workit.ai/m365/callback" { + t.Fatalf("redirect_uri = %q", got) + } + + if got := q.Get("code_challenge_method"); got != "S256" { + t.Fatalf("code_challenge_method = %q", got) + } + + scope := q.Get("scope") + for _, want := range []string{"offline_access", "User.Read", "Mail.Read", "Calendars.Read"} { + if !strings.Contains(scope, want) { + t.Fatalf("scope missing %s: %s", want, scope) + } + } + + for _, forbidden := range []string{"Mail.Send", "Calendars.ReadWrite"} { + if strings.Contains(scope, forbidden) { + t.Fatalf("scope contains forbidden %s: %s", forbidden, scope) + } + } +} + +func TestCreateBrokerSessionFailsClosedForUnsafeInputs(t *testing.T) { + _, err := CreateBrokerSession(context.Background(), BrokerSessionOptions{ + ExpectedEmail: "pilot@example.com", + BaseURL: "http://login.workit.ai", + CallbackURL: "https://login.workit.ai/m365/callback", + Readonly: true, + }) + if err == nil || !strings.Contains(err.Error(), "https") { + t.Fatalf("expected https base URL failure, got %v", err) + } + + _, err = CreateBrokerSession(context.Background(), BrokerSessionOptions{ + ExpectedEmail: "pilot@example.com", + BaseURL: "https://login.workit.ai", + CallbackURL: "https://login.workit.ai/m365/callback", + }) + if err == nil || !strings.Contains(err.Error(), "read-only") { + t.Fatalf("expected read-only failure, got %v", err) + } +}