Skip to content

Commit 30e16d9

Browse files
authored
Merge pull request #44 from automagik-dev/fix/m365-oauth-login
fix: add Microsoft 365 OAuth login
2 parents caedf05 + 668e6ac commit 30e16d9

14 files changed

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

0 commit comments

Comments
 (0)