Skip to content

Commit e83486d

Browse files
fix: add Microsoft 365 OAuth login
1 parent caedf05 commit e83486d

13 files changed

Lines changed: 1155 additions & 5 deletions

Makefile

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ LINT_NEW_FROM ?= origin/main
3434
WK_CLIENT_ID ?= $(GOG_CLIENT_ID)
3535
WK_CLIENT_SECRET ?= $(GOG_CLIENT_SECRET)
3636
WK_CALLBACK_SERVER ?= $(GOG_CALLBACK_SERVER)
37+
WK_M365_CLIENT_ID ?= $(GOG_M365_CLIENT_ID)
38+
WK_M365_TENANT_ID ?= $(GOG_M365_TENANT_ID)
3739

3840
# Allow passing CLI args as extra "targets":
3941
# make workit -- --help
@@ -60,7 +62,9 @@ build-internal:
6062
@go build -ldflags "$(LDFLAGS) \
6163
-X 'github.com/automagik-dev/workit/internal/config.DefaultClientID=$(WK_CLIENT_ID)' \
6264
-X 'github.com/automagik-dev/workit/internal/config.DefaultClientSecret=$(WK_CLIENT_SECRET)' \
63-
-X 'github.com/automagik-dev/workit/internal/config.DefaultCallbackServer=$(WK_CALLBACK_SERVER)'" \
65+
-X 'github.com/automagik-dev/workit/internal/config.DefaultCallbackServer=$(WK_CALLBACK_SERVER)' \
66+
-X 'github.com/automagik-dev/workit/internal/config.DefaultM365ClientID=$(WK_M365_CLIENT_ID)' \
67+
-X 'github.com/automagik-dev/workit/internal/config.DefaultM365TenantID=$(WK_M365_TENANT_ID)'" \
6468
-o $(BIN) $(CMD)
6569

6670
# Build with credentials from ~/.config/workit/credentials.env (WK_* primary contract).
@@ -73,20 +77,28 @@ build-automagik:
7377
wk_client_id="$${WK_CLIENT_ID:-$${GOG_CLIENT_ID}}" && \
7478
wk_client_secret="$${WK_CLIENT_SECRET:-$${GOG_CLIENT_SECRET}}" && \
7579
wk_callback_server="$${WK_CALLBACK_SERVER:-$${GOG_CALLBACK_SERVER}}" && \
80+
wk_m365_client_id="$${WK_M365_CLIENT_ID:-$${GOG_M365_CLIENT_ID}}" && \
81+
wk_m365_tenant_id="$${WK_M365_TENANT_ID:-$${GOG_M365_TENANT_ID}}" && \
7682
go build -ldflags "$(LDFLAGS) \
7783
-X 'github.com/automagik-dev/workit/internal/config.DefaultClientID=$$wk_client_id' \
7884
-X 'github.com/automagik-dev/workit/internal/config.DefaultClientSecret=$$wk_client_secret' \
79-
-X 'github.com/automagik-dev/workit/internal/config.DefaultCallbackServer=$$wk_callback_server'" \
85+
-X 'github.com/automagik-dev/workit/internal/config.DefaultCallbackServer=$$wk_callback_server' \
86+
-X 'github.com/automagik-dev/workit/internal/config.DefaultM365ClientID=$$wk_m365_client_id' \
87+
-X 'github.com/automagik-dev/workit/internal/config.DefaultM365TenantID=$$wk_m365_tenant_id'" \
8088
-o $(BIN) $(CMD); \
8189
elif [ -f "$(HOME)/.config/gog/credentials.env" ]; then \
8290
. $(HOME)/.config/gog/credentials.env && \
8391
wk_client_id="$${WK_CLIENT_ID:-$${GOG_CLIENT_ID}}" && \
8492
wk_client_secret="$${WK_CLIENT_SECRET:-$${GOG_CLIENT_SECRET}}" && \
8593
wk_callback_server="$${WK_CALLBACK_SERVER:-$${GOG_CALLBACK_SERVER}}" && \
94+
wk_m365_client_id="$${WK_M365_CLIENT_ID:-$${GOG_M365_CLIENT_ID}}" && \
95+
wk_m365_tenant_id="$${WK_M365_TENANT_ID:-$${GOG_M365_TENANT_ID}}" && \
8696
go build -ldflags "$(LDFLAGS) \
8797
-X 'github.com/automagik-dev/workit/internal/config.DefaultClientID=$$wk_client_id' \
8898
-X 'github.com/automagik-dev/workit/internal/config.DefaultClientSecret=$$wk_client_secret' \
89-
-X 'github.com/automagik-dev/workit/internal/config.DefaultCallbackServer=$$wk_callback_server'" \
99+
-X 'github.com/automagik-dev/workit/internal/config.DefaultCallbackServer=$$wk_callback_server' \
100+
-X 'github.com/automagik-dev/workit/internal/config.DefaultM365ClientID=$$wk_m365_client_id' \
101+
-X 'github.com/automagik-dev/workit/internal/config.DefaultM365TenantID=$$wk_m365_tenant_id'" \
90102
-o $(BIN) $(CMD); \
91103
else \
92104
echo "Missing credentials file: $(HOME)/.config/workit/credentials.env"; \

internal/cmd/auth.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ var (
2828
checkRefreshToken = googleauth.CheckRefreshToken
2929
ensureKeychainAccess = secrets.EnsureKeychainAccess
3030
fetchAuthorizedEmail = googleauth.EmailForRefreshToken
31+
authorizeM365 = msauth.Authorize
32+
m365ManualAuthURL = msauth.ManualAuthURL
3133
headlessAuthorize = googleauth.HeadlessAuthorize
3234
pollForToken = googleauth.PollForToken
3335
callbackServerURLFn = googleauth.CallbackServerURL
@@ -505,6 +507,10 @@ type AuthAddCmd struct {
505507
func (c *AuthAddCmd) Run(ctx context.Context, flags *RootFlags) error {
506508
u := ui.FromContext(ctx)
507509

510+
if isM365ServicesCSV(c.ServicesCSV) {
511+
return c.runM365(ctx, flags, u)
512+
}
513+
508514
override := authclient.ClientOverrideFromContext(ctx)
509515
client, err := authclient.ResolveClientWithOverride(c.Email, override)
510516
if err != nil {
@@ -1439,6 +1445,10 @@ type AuthManageCmd struct {
14391445
}
14401446

14411447
func (c *AuthManageCmd) Run(ctx context.Context, _ *RootFlags) error {
1448+
if isM365ServicesCSV(c.ServicesCSV) {
1449+
return c.runM365(ctx)
1450+
}
1451+
14421452
services, err := parseAuthServices(c.ServicesCSV)
14431453
if err != nil {
14441454
return err

internal/cmd/auth_m365.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"sort"
8+
"strings"
9+
10+
"github.com/automagik-dev/workit/internal/msauth"
11+
"github.com/automagik-dev/workit/internal/outfmt"
12+
"github.com/automagik-dev/workit/internal/secrets"
13+
"github.com/automagik-dev/workit/internal/ui"
14+
)
15+
16+
func isM365ServicesCSV(value string) bool {
17+
parts := strings.Split(value, ",")
18+
if len(parts) == 0 {
19+
return false
20+
}
21+
for _, part := range parts {
22+
if strings.ToLower(strings.TrimSpace(part)) != "m365" {
23+
return false
24+
}
25+
}
26+
return true
27+
}
28+
29+
func (c *AuthAddCmd) runM365(ctx context.Context, flags *RootFlags, u *ui.UI) error {
30+
if !c.Readonly {
31+
return usage("m365 auth requires explicit --readonly")
32+
}
33+
if c.Headless || c.NoPoll || c.CallbackServer != "" {
34+
return usage("m365 auth uses browser OAuth; headless callback-server mode is not supported yet")
35+
}
36+
if c.AuthCode != "" {
37+
return usage("m365 auth does not accept raw --auth-code; use browser OAuth")
38+
}
39+
if c.Step != 0 && c.Step != 1 && c.Step != 2 {
40+
return usage("step must be 1 or 2")
41+
}
42+
if c.Step != 0 && !c.Remote {
43+
return usage("--step requires --remote")
44+
}
45+
if c.Remote || c.Step == 1 {
46+
if c.Step == 2 || c.AuthURL != "" {
47+
return usage("m365 remote step 2 is not supported yet; use browser OAuth on this machine")
48+
}
49+
result, err := m365ManualAuthURL(ctx, msauth.ManualAuthURLOptions{Readonly: c.Readonly, ForceConsent: c.ForceConsent})
50+
if err != nil {
51+
return err
52+
}
53+
if outfmt.IsJSON(ctx) {
54+
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
55+
"provider": "microsoft_graph",
56+
"auth_url": result.URL,
57+
"state": result.State,
58+
"expires_in": result.ExpiresIn,
59+
})
60+
}
61+
u.Out().Printf("provider\tmicrosoft_graph")
62+
u.Out().Printf("auth_url\t%s", result.URL)
63+
u.Out().Printf("state\t%s", result.State)
64+
return nil
65+
}
66+
if dryRunErr := dryRunExit(ctx, flags, "auth.add.m365", map[string]any{
67+
"email": strings.TrimSpace(c.Email),
68+
"provider": "microsoft_graph",
69+
"services": []string{"m365"},
70+
"scopes": msauth.PilotAllowedScopes(),
71+
"readonly": c.Readonly,
72+
}); dryRunErr != nil {
73+
return dryRunErr
74+
}
75+
if keychainErr := ensureKeychainAccessIfNeeded(); keychainErr != nil {
76+
return fmt.Errorf("keychain access: %w", keychainErr)
77+
}
78+
result, err := authorizeM365(ctx, msauth.AuthorizeOptions{
79+
ExpectedEmail: strings.TrimSpace(c.Email),
80+
Readonly: c.Readonly,
81+
ForceConsent: c.ForceConsent,
82+
Timeout: c.Timeout,
83+
})
84+
if err != nil {
85+
return err
86+
}
87+
if normalizeEmail(result.Email) != normalizeEmail(c.Email) {
88+
return fmt.Errorf("authorized as %s, expected %s", result.Email, c.Email)
89+
}
90+
return storeM365Token(ctx, u, result.Email, result.RefreshToken)
91+
}
92+
93+
func (c *AuthManageCmd) runM365(ctx context.Context) error {
94+
result, err := m365ManualAuthURL(ctx, msauth.ManualAuthURLOptions{Readonly: true, ForceConsent: c.ForceConsent})
95+
if err != nil {
96+
return err
97+
}
98+
if outfmt.IsJSON(ctx) || c.PrintURL {
99+
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
100+
"provider": "microsoft_graph",
101+
"auth_url": result.URL,
102+
"state": result.State,
103+
"expires_in": result.ExpiresIn,
104+
})
105+
}
106+
u := ui.FromContext(ctx)
107+
u.Err().Println("Opening Microsoft 365 authorization in your browser…")
108+
u.Err().Println(result.URL)
109+
return nil
110+
}
111+
112+
func storeM365Token(ctx context.Context, u *ui.UI, email string, refreshToken string) error {
113+
store, err := openSecretsStore()
114+
if err != nil {
115+
return err
116+
}
117+
serviceNames := []string{"m365"}
118+
scopes := msauth.PilotAllowedScopes()
119+
sort.Strings(scopes)
120+
if err := store.MergeToken(msauth.ClientName, email, secrets.Token{
121+
Client: msauth.ClientName,
122+
Email: email,
123+
Services: serviceNames,
124+
Scopes: scopes,
125+
RefreshToken: refreshToken,
126+
}); err != nil {
127+
return err
128+
}
129+
return writeResult(ctx, u,
130+
kv("stored", true),
131+
kv("provider", "microsoft_graph"),
132+
kv("email", email),
133+
kv("services", serviceNames),
134+
kv("client", msauth.ClientName),
135+
)
136+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package cmd
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestAuthAddM365DryRunReportsPilotScopes(t *testing.T) {
9+
out := captureStdout(t, func() {
10+
_ = captureStderr(t, func() {
11+
if err := Execute([]string{"--json", "--dry-run", "auth", "add", "bernardo@hapvida.com.br", "--services", "m365", "--readonly"}); err != nil {
12+
t.Fatalf("dry-run m365 auth: %v", err)
13+
}
14+
})
15+
})
16+
17+
for _, want := range []string{"auth.add.m365", "microsoft_graph", "User.Read", "Mail.Read", "Calendars.Read"} {
18+
if !strings.Contains(out, want) {
19+
t.Fatalf("dry-run output missing %s: %s", want, out)
20+
}
21+
}
22+
}
23+
24+
func TestAuthAddM365RealFlowFailsClosedWithoutClientID(t *testing.T) {
25+
_ = captureStderr(t, func() {
26+
err := Execute([]string{"--json", "auth", "add", "bernardo@hapvida.com.br", "--services", "m365", "--readonly"})
27+
if err == nil {
28+
t.Fatal("expected missing m365 client id")
29+
}
30+
if !strings.Contains(err.Error(), "client id") {
31+
t.Fatalf("unexpected error: %v", err)
32+
}
33+
})
34+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package cmd
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestAuthAddM365RejectsUnsupportedModes(t *testing.T) {
9+
tests := [][]string{
10+
{"--json", "auth", "add", "bernardo@hapvida.com.br", "--services", "m365", "--readonly", "--headless"},
11+
{"--json", "auth", "add", "bernardo@hapvida.com.br", "--services", "m365", "--readonly", "--auth-code", "raw"},
12+
{"--json", "auth", "add", "bernardo@hapvida.com.br", "--services", "m365", "--readonly", "--remote", "--step", "2"},
13+
}
14+
15+
for _, args := range tests {
16+
_ = captureStderr(t, func() {
17+
err := Execute(args)
18+
if err == nil {
19+
t.Fatalf("expected error for %#v", args)
20+
}
21+
})
22+
}
23+
}
24+
25+
func TestAuthAddMixedM365AndGoogleFailsClosed(t *testing.T) {
26+
_ = captureStderr(t, func() {
27+
err := Execute([]string{"--json", "auth", "add", "bernardo@hapvida.com.br", "--services", "m365,gmail", "--readonly"})
28+
if err == nil {
29+
t.Fatal("expected mixed m365/google services to fail closed")
30+
}
31+
if !strings.Contains(err.Error(), "unknown service") {
32+
t.Fatalf("unexpected error: %v", err)
33+
}
34+
})
35+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/automagik-dev/workit/internal/msauth"
8+
)
9+
10+
func TestAuthManageM365PrintURLPropagatesForceConsent(t *testing.T) {
11+
origURL := m365ManualAuthURL
12+
t.Cleanup(func() { m365ManualAuthURL = origURL })
13+
14+
var got msauth.ManualAuthURLOptions
15+
m365ManualAuthURL = func(_ context.Context, opts msauth.ManualAuthURLOptions) (msauth.ManualAuthURLResult, error) {
16+
got = opts
17+
return msauth.ManualAuthURLResult{URL: "https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize", State: "state"}, nil
18+
}
19+
20+
_ = captureStdout(t, func() {
21+
_ = captureStderr(t, func() {
22+
if err := Execute([]string{"--json", "auth", "manage", "--services", "m365", "--force-consent", "--print-url"}); err != nil {
23+
t.Fatalf("auth manage m365: %v", err)
24+
}
25+
})
26+
})
27+
28+
if !got.Readonly || !got.ForceConsent {
29+
t.Fatalf("options = %#v", got)
30+
}
31+
}

0 commit comments

Comments
 (0)