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
4 changes: 4 additions & 0 deletions .deadcode-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)"`
}
Expand Down
61 changes: 61 additions & 0 deletions internal/cmd/auth_m365_link.go
Original file line number Diff line number Diff line change
@@ -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
}
56 changes: 56 additions & 0 deletions internal/cmd/m365_login_link_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
107 changes: 107 additions & 0 deletions internal/msauth/broker.go
Original file line number Diff line number Diff line change
@@ -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
}
85 changes: 85 additions & 0 deletions internal/msauth/broker_store.go
Original file line number Diff line number Diff line change
@@ -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
}
Comment on lines +32 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The MemoryBrokerStore struct can be initialized as a zero-value (e.g., var store MemoryBrokerStore or store := &MemoryBrokerStore{}). If this happens, s.sessions will be nil, and calling Save will result in a runtime panic when writing to the map.

To make the store resilient to zero-value initialization, we should lazily initialize the sessions map inside the Save method under the lock.

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
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2ba3d21: Save now lazily initializes the session map under the lock, and TestMemoryBrokerStoreZeroValueSaveInitializesSessionMap covers zero-value usage. Local gates and CI are green.


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
}
Loading
Loading