Skip to content

Commit da942b5

Browse files
authored
feat: add M365 one-click login link
Refs #45. Adds Workit-side one-click M365 read-only login-link/session contract; hosted broker service remains next.
1 parent 30e16d9 commit da942b5

8 files changed

Lines changed: 486 additions & 0 deletions

File tree

.deadcode-baseline.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ internal/googleauth/oauth_flow_manual_redirect.go:54:6: unreachable func: extrac
3939
internal/googleauth/scopes.go:34:6: unreachable func: ScopesForCommands
4040
internal/googleauth/scopes.go:72:6: unreachable func: AllScopes
4141
internal/googleauth/scopes.go:96:6: unreachable func: knownCommandNames
42+
internal/msauth/broker_store.go:28:6: unreachable func: NewMemoryBrokerStore
43+
internal/msauth/broker_store.go:32:29: unreachable func: MemoryBrokerStore.Save
44+
internal/msauth/broker_store.go:50:29: unreachable func: MemoryBrokerStore.Consume
45+
internal/msauth/broker_store.go:76:6: unreachable func: ValidateBrokerAuthorizedEmail
4246
internal/msauth/scopes.go:17:6: unreachable func: canonicalPilotScope
4347
internal/msauth/scopes.go:40:6: unreachable func: GuardPilotScopes
4448
internal/officetext/extract.go:46:6: unreachable func: ExtractTextByMIME

internal/cmd/auth.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ type AuthCmd struct {
6969
Remove AuthRemoveCmd `cmd:"" name:"remove" help:"Remove a stored refresh token"`
7070
Tokens AuthTokensCmd `cmd:"" name:"tokens" help:"Manage stored refresh tokens"`
7171
Manage AuthManageCmd `cmd:"" name:"manage" help:"Open accounts manager in browser" aliases:"login"`
72+
M365 AuthM365Cmd `cmd:"" name:"m365" help:"Microsoft 365 authentication helpers"`
7273
ServiceAcct AuthServiceAccountCmd `cmd:"" name:"service-account" help:"Configure service account (Workspace only; domain-wide delegation)"`
7374
Keep AuthKeepCmd `cmd:"" name:"keep" help:"Configure service account for Google Keep (Workspace only)"`
7475
}

internal/cmd/auth_m365_link.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"os"
6+
"strings"
7+
"time"
8+
9+
"github.com/automagik-dev/workit/internal/msauth"
10+
"github.com/automagik-dev/workit/internal/outfmt"
11+
"github.com/automagik-dev/workit/internal/ui"
12+
)
13+
14+
var createM365BrokerSession = msauth.CreateBrokerSession
15+
16+
type AuthM365Cmd struct {
17+
LoginLink AuthM365LoginLinkCmd `cmd:"" name:"login-link" help:"Create a one-click Microsoft 365 read-only login link"`
18+
}
19+
20+
type AuthM365LoginLinkCmd struct {
21+
Email string `arg:"" name:"email" help:"Expected Microsoft 365 account email"`
22+
BaseURL string `name:"base-url" help:"Public HTTPS broker base URL, e.g. https://login.workit.ai"`
23+
CallbackURL string `name:"callback-url" help:"Public HTTPS Microsoft OAuth callback URL"`
24+
TTL time.Duration `name:"ttl" help:"Login link validity duration" default:"10m"`
25+
ForceConsent bool `name:"force-consent" help:"Force Microsoft consent screen"`
26+
}
27+
28+
func (c *AuthM365LoginLinkCmd) Run(ctx context.Context, _ *RootFlags) error {
29+
email := strings.TrimSpace(c.Email)
30+
if email == "" {
31+
return usage("empty email")
32+
}
33+
if strings.TrimSpace(c.BaseURL) == "" {
34+
return usage("m365 login-link requires --base-url")
35+
}
36+
if strings.TrimSpace(c.CallbackURL) == "" {
37+
return usage("m365 login-link requires --callback-url")
38+
}
39+
40+
session, err := createM365BrokerSession(ctx, msauth.BrokerSessionOptions{
41+
ExpectedEmail: email,
42+
BaseURL: c.BaseURL,
43+
CallbackURL: c.CallbackURL,
44+
Readonly: true,
45+
ForceConsent: c.ForceConsent,
46+
TTL: c.TTL,
47+
})
48+
if err != nil {
49+
return err
50+
}
51+
52+
if outfmt.IsJSON(ctx) {
53+
return outfmt.WriteJSON(ctx, os.Stdout, session)
54+
}
55+
56+
u := ui.FromContext(ctx)
57+
u.Out().Printf("login_url\t%s", session.LoginURL)
58+
u.Out().Printf("expected_email\t%s", session.ExpectedEmail)
59+
u.Out().Printf("expires_at\t%s", session.ExpiresAt.Format(time.RFC3339))
60+
return nil
61+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
"time"
8+
9+
"github.com/automagik-dev/workit/internal/msauth"
10+
)
11+
12+
func TestAuthM365LoginLinkPrintsOneClickURL(t *testing.T) {
13+
origCreate := createM365BrokerSession
14+
t.Cleanup(func() { createM365BrokerSession = origCreate })
15+
16+
var got msauth.BrokerSessionOptions
17+
createM365BrokerSession = func(_ context.Context, opts msauth.BrokerSessionOptions) (msauth.BrokerSession, error) {
18+
got = opts
19+
return msauth.BrokerSession{
20+
State: "state",
21+
ExpectedEmail: "bernardo@hapvida.com.br",
22+
LoginURL: "https://login.workit.ai/m365/start/state",
23+
ExpiresAt: time.Unix(1893456000, 0).UTC(),
24+
}, nil
25+
}
26+
27+
out := captureStdout(t, func() {
28+
_ = captureStderr(t, func() {
29+
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 {
30+
t.Fatalf("login-link: %v", err)
31+
}
32+
})
33+
})
34+
35+
if got.ExpectedEmail != "bernardo@hapvida.com.br" || !got.Readonly {
36+
t.Fatalf("options = %#v", got)
37+
}
38+
if got.BaseURL != "https://login.workit.ai" || got.CallbackURL != "https://login.workit.ai/m365/callback" {
39+
t.Fatalf("urls = %#v", got)
40+
}
41+
if !strings.Contains(out, "https://login.workit.ai/m365/start/state") {
42+
t.Fatalf("missing login link: %s", out)
43+
}
44+
}
45+
46+
func TestAuthM365LoginLinkRequiresExplicitBrokerURLs(t *testing.T) {
47+
_ = captureStderr(t, func() {
48+
err := Execute([]string{"auth", "m365", "login-link", "bernardo@hapvida.com.br"})
49+
if err == nil {
50+
t.Fatal("expected missing broker URL failure")
51+
}
52+
if !strings.Contains(err.Error(), "base-url") {
53+
t.Fatalf("unexpected error: %v", err)
54+
}
55+
})
56+
}

internal/msauth/broker.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package msauth
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net/url"
8+
"strings"
9+
"time"
10+
)
11+
12+
var (
13+
ErrBrokerHTTPSRequired = errors.New("m365 broker URL must use https")
14+
ErrBrokerMissingEmail = errors.New("m365 broker expected email missing")
15+
ErrBrokerURLRequired = errors.New("m365 broker URL required")
16+
)
17+
18+
type BrokerSessionOptions struct {
19+
ExpectedEmail string
20+
BaseURL string
21+
CallbackURL string
22+
Readonly bool
23+
ForceConsent bool
24+
TTL time.Duration
25+
}
26+
27+
type BrokerSession struct {
28+
State string `json:"state"`
29+
ExpectedEmail string `json:"expected_email"`
30+
LoginURL string `json:"login_url"`
31+
AuthURL string `json:"auth_url,omitempty"`
32+
CodeVerifier string `json:"-"`
33+
CodeChallenge string `json:"-"`
34+
ExpiresAt time.Time `json:"expires_at"`
35+
}
36+
37+
func CreateBrokerSession(_ context.Context, opts BrokerSessionOptions) (BrokerSession, error) {
38+
expectedEmail := strings.ToLower(strings.TrimSpace(opts.ExpectedEmail))
39+
if expectedEmail == "" {
40+
return BrokerSession{}, ErrBrokerMissingEmail
41+
}
42+
43+
if !opts.Readonly {
44+
return BrokerSession{}, fmt.Errorf("%w: one-click broker is read-only only", ErrPilotScopeNotAllowed)
45+
}
46+
47+
baseURL, err := parseHTTPSURL(opts.BaseURL, "base-url")
48+
if err != nil {
49+
return BrokerSession{}, err
50+
}
51+
52+
callbackURL, err := parseHTTPSURL(opts.CallbackURL, "callback-url")
53+
if err != nil {
54+
return BrokerSession{}, err
55+
}
56+
57+
settings, err := resolveOAuthSettings()
58+
if err != nil {
59+
return BrokerSession{}, err
60+
}
61+
62+
scopes, err := OAuthScopes(opts.Readonly)
63+
if err != nil {
64+
return BrokerSession{}, err
65+
}
66+
67+
state, verifier, challenge, err := newOAuthStateAndPKCE()
68+
if err != nil {
69+
return BrokerSession{}, err
70+
}
71+
72+
if opts.TTL <= 0 {
73+
opts.TTL = 10 * time.Minute
74+
}
75+
76+
loginURL := baseURL.JoinPath("m365", "start", state).String()
77+
cfg := oauthConfigFn(settings, callbackURL.String(), scopes)
78+
authURL := cfg.AuthCodeURL(state, authParams(opts.ForceConsent, challenge)...)
79+
80+
return BrokerSession{
81+
State: state,
82+
ExpectedEmail: expectedEmail,
83+
LoginURL: loginURL,
84+
AuthURL: authURL,
85+
CodeVerifier: verifier,
86+
CodeChallenge: challenge,
87+
ExpiresAt: time.Now().UTC().Add(opts.TTL),
88+
}, nil
89+
}
90+
91+
func parseHTTPSURL(raw string, field string) (*url.URL, error) {
92+
value := strings.TrimSpace(raw)
93+
if value == "" {
94+
return nil, fmt.Errorf("%w: %s", ErrBrokerURLRequired, field)
95+
}
96+
97+
parsed, err := url.Parse(value)
98+
if err != nil {
99+
return nil, fmt.Errorf("parse m365 broker %s: %w", field, err)
100+
}
101+
102+
if parsed.Scheme != "https" || parsed.Host == "" {
103+
return nil, fmt.Errorf("%w: %s", ErrBrokerHTTPSRequired, field)
104+
}
105+
106+
return parsed, nil
107+
}

internal/msauth/broker_store.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package msauth
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
"sync"
9+
"time"
10+
)
11+
12+
var (
13+
ErrBrokerStateNotFound = errors.New("m365 broker state not found")
14+
ErrBrokerStateExpired = errors.New("m365 broker state expired")
15+
ErrBrokerEmailMismatch = errors.New("m365 broker authorized email mismatch")
16+
)
17+
18+
type BrokerStore interface {
19+
Save(context.Context, BrokerSession) error
20+
Consume(context.Context, string) (BrokerSession, error)
21+
}
22+
23+
type MemoryBrokerStore struct {
24+
mu sync.Mutex
25+
sessions map[string]BrokerSession
26+
}
27+
28+
func NewMemoryBrokerStore() *MemoryBrokerStore {
29+
return &MemoryBrokerStore{sessions: make(map[string]BrokerSession)}
30+
}
31+
32+
func (s *MemoryBrokerStore) Save(_ context.Context, session BrokerSession) error {
33+
state := strings.TrimSpace(session.State)
34+
if state == "" {
35+
return ErrBrokerStateNotFound
36+
}
37+
38+
s.mu.Lock()
39+
defer s.mu.Unlock()
40+
41+
if s.sessions == nil {
42+
s.sessions = make(map[string]BrokerSession)
43+
}
44+
45+
s.sessions[state] = session
46+
47+
return nil
48+
}
49+
50+
func (s *MemoryBrokerStore) Consume(_ context.Context, state string) (BrokerSession, error) {
51+
key := strings.TrimSpace(state)
52+
if key == "" {
53+
return BrokerSession{}, ErrBrokerStateNotFound
54+
}
55+
56+
s.mu.Lock()
57+
58+
session, ok := s.sessions[key]
59+
if ok {
60+
delete(s.sessions, key)
61+
}
62+
63+
s.mu.Unlock()
64+
65+
if !ok {
66+
return BrokerSession{}, ErrBrokerStateNotFound
67+
}
68+
69+
if !session.ExpiresAt.IsZero() && time.Now().After(session.ExpiresAt) {
70+
return BrokerSession{}, ErrBrokerStateExpired
71+
}
72+
73+
return session, nil
74+
}
75+
76+
func ValidateBrokerAuthorizedEmail(session BrokerSession, authorizedEmail string) error {
77+
expected := strings.ToLower(strings.TrimSpace(session.ExpectedEmail))
78+
actual := strings.ToLower(strings.TrimSpace(authorizedEmail))
79+
80+
if expected == "" || actual == "" || expected != actual {
81+
return fmt.Errorf("%w: expected %s got %s", ErrBrokerEmailMismatch, expected, actual)
82+
}
83+
84+
return nil
85+
}

0 commit comments

Comments
 (0)