Skip to content

Commit 6060622

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

8 files changed

Lines changed: 778 additions & 3 deletions

File tree

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

internal/cmd/m365_auth_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"strings"
7+
"testing"
8+
"time"
9+
10+
"github.com/automagik-dev/workit/internal/msauth"
11+
"github.com/automagik-dev/workit/internal/secrets"
12+
)
13+
14+
func TestAuthAddM365UsesOAuthAndStoresToken(t *testing.T) {
15+
origOpen := openSecretsStore
16+
origAuth := authorizeM365
17+
origKeychain := ensureKeychainAccess
18+
t.Cleanup(func() {
19+
openSecretsStore = origOpen
20+
authorizeM365 = origAuth
21+
ensureKeychainAccess = origKeychain
22+
})
23+
24+
store := newMemSecretsStore()
25+
openSecretsStore = func() (secrets.Store, error) { return store, nil }
26+
ensureKeychainAccess = func() error { return nil }
27+
authorizeM365 = func(context.Context, msauth.AuthorizeOptions) (msauth.AuthorizeResult, error) {
28+
return msauth.AuthorizeResult{Email: "bernardo@hapvida.com.br", RefreshToken: "m365-refresh-token"}, nil
29+
}
30+
31+
out := captureStdout(t, func() {
32+
_ = captureStderr(t, func() {
33+
if err := Execute([]string{"--json", "auth", "add", "bernardo@hapvida.com.br", "--services", "m365", "--readonly"}); err != nil {
34+
t.Fatalf("auth add m365: %v", err)
35+
}
36+
})
37+
})
38+
39+
var payload map[string]any
40+
if err := json.Unmarshal([]byte(out), &payload); err != nil {
41+
t.Fatalf("json output: %v\n%s", err, out)
42+
}
43+
if payload["provider"] != "microsoft_graph" || payload["stored"] != true {
44+
t.Fatalf("unexpected output: %#v", payload)
45+
}
46+
tok, err := store.GetToken(msauth.ClientName, "bernardo@hapvida.com.br")
47+
if err != nil {
48+
t.Fatalf("stored m365 token: %v", err)
49+
}
50+
if tok.RefreshToken != "m365-refresh-token" {
51+
t.Fatalf("refresh token = %q", tok.RefreshToken)
52+
}
53+
if !stringSliceContainsForM365AuthTest(tok.Services, "m365") {
54+
t.Fatalf("services = %#v", tok.Services)
55+
}
56+
}
57+
58+
func TestAuthAddM365RequiresReadonly(t *testing.T) {
59+
_ = captureStderr(t, func() {
60+
err := Execute([]string{"--json", "auth", "add", "bernardo@hapvida.com.br", "--services", "m365"})
61+
if err == nil {
62+
t.Fatal("expected missing --readonly to fail closed")
63+
}
64+
if !strings.Contains(err.Error(), "--readonly") {
65+
t.Fatalf("expected --readonly error, got: %v", err)
66+
}
67+
})
68+
}
69+
70+
func TestAuthAddM365RemoteStepOnePrintsMicrosoftURL(t *testing.T) {
71+
origURL := m365ManualAuthURL
72+
t.Cleanup(func() { m365ManualAuthURL = origURL })
73+
m365ManualAuthURL = func(context.Context, msauth.ManualAuthURLOptions) (msauth.ManualAuthURLResult, error) {
74+
return msauth.ManualAuthURLResult{URL: "https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize?client_id=test", State: "state"}, nil
75+
}
76+
77+
out := captureStdout(t, func() {
78+
_ = captureStderr(t, func() {
79+
if err := Execute([]string{"--json", "auth", "add", "bernardo@hapvida.com.br", "--services", "m365", "--readonly", "--remote", "--step", "1"}); err != nil {
80+
t.Fatalf("remote step 1: %v", err)
81+
}
82+
})
83+
})
84+
if !strings.Contains(out, "login.microsoftonline.com") || !strings.Contains(out, "auth_url") {
85+
t.Fatalf("unexpected output: %s", out)
86+
}
87+
}
88+
89+
func TestAuthManageM365PrintURLIsNonTechnicalOAuthHandoff(t *testing.T) {
90+
origURL := m365ManualAuthURL
91+
t.Cleanup(func() { m365ManualAuthURL = origURL })
92+
m365ManualAuthURL = func(context.Context, msauth.ManualAuthURLOptions) (msauth.ManualAuthURLResult, error) {
93+
return msauth.ManualAuthURLResult{URL: "https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize?client_id=test", State: "state", ExpiresIn: int((5 * time.Minute).Seconds())}, nil
94+
}
95+
96+
out := captureStdout(t, func() {
97+
_ = captureStderr(t, func() {
98+
if err := Execute([]string{"--json", "auth", "manage", "--services", "m365", "--print-url"}); err != nil {
99+
t.Fatalf("auth manage m365 print-url: %v", err)
100+
}
101+
})
102+
})
103+
if !strings.Contains(out, "login.microsoftonline.com") || !strings.Contains(out, "microsoft_graph") {
104+
t.Fatalf("unexpected output: %s", out)
105+
}
106+
}
107+
108+
func stringSliceContainsForM365AuthTest(values []string, want string) bool {
109+
for _, value := range values {
110+
if value == want {
111+
return true
112+
}
113+
}
114+
return false
115+
}

internal/config/defaults.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ var (
1111
DefaultClientID string
1212
DefaultClientSecret string
1313

14+
// DefaultM365ClientID and DefaultM365TenantID are optional Microsoft 365 OAuth defaults
15+
// injected into internal builds so non-technical users can log in with a browser.
16+
DefaultM365ClientID string
17+
DefaultM365TenantID string
18+
1419
// DefaultCallbackServer is the default OAuth relay used when WK_CALLBACK_SERVER is not set.
1520
// Override at build time via: -ldflags "-X github.com/automagik-dev/workit/internal/config.DefaultCallbackServer=https://custom.example.com"
1621
DefaultCallbackServer = "https://auth.automagik.dev"

0 commit comments

Comments
 (0)