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
18 changes: 15 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ LINT_NEW_FROM ?= origin/main
WK_CLIENT_ID ?= $(GOG_CLIENT_ID)
WK_CLIENT_SECRET ?= $(GOG_CLIENT_SECRET)
WK_CALLBACK_SERVER ?= $(GOG_CALLBACK_SERVER)
WK_M365_CLIENT_ID ?= $(GOG_M365_CLIENT_ID)
WK_M365_TENANT_ID ?= $(GOG_M365_TENANT_ID)

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

# Build with credentials from ~/.config/workit/credentials.env (WK_* primary contract).
Expand All @@ -73,20 +77,28 @@ build-automagik:
wk_client_id="$${WK_CLIENT_ID:-$${GOG_CLIENT_ID}}" && \
wk_client_secret="$${WK_CLIENT_SECRET:-$${GOG_CLIENT_SECRET}}" && \
wk_callback_server="$${WK_CALLBACK_SERVER:-$${GOG_CALLBACK_SERVER}}" && \
wk_m365_client_id="$${WK_M365_CLIENT_ID:-$${GOG_M365_CLIENT_ID}}" && \
wk_m365_tenant_id="$${WK_M365_TENANT_ID:-$${GOG_M365_TENANT_ID}}" && \
go build -ldflags "$(LDFLAGS) \
-X 'github.com/automagik-dev/workit/internal/config.DefaultClientID=$$wk_client_id' \
-X 'github.com/automagik-dev/workit/internal/config.DefaultClientSecret=$$wk_client_secret' \
-X 'github.com/automagik-dev/workit/internal/config.DefaultCallbackServer=$$wk_callback_server'" \
-X 'github.com/automagik-dev/workit/internal/config.DefaultCallbackServer=$$wk_callback_server' \
-X 'github.com/automagik-dev/workit/internal/config.DefaultM365ClientID=$$wk_m365_client_id' \
-X 'github.com/automagik-dev/workit/internal/config.DefaultM365TenantID=$$wk_m365_tenant_id'" \
-o $(BIN) $(CMD); \
elif [ -f "$(HOME)/.config/gog/credentials.env" ]; then \
. $(HOME)/.config/gog/credentials.env && \
wk_client_id="$${WK_CLIENT_ID:-$${GOG_CLIENT_ID}}" && \
wk_client_secret="$${WK_CLIENT_SECRET:-$${GOG_CLIENT_SECRET}}" && \
wk_callback_server="$${WK_CALLBACK_SERVER:-$${GOG_CALLBACK_SERVER}}" && \
wk_m365_client_id="$${WK_M365_CLIENT_ID:-$${GOG_M365_CLIENT_ID}}" && \
wk_m365_tenant_id="$${WK_M365_TENANT_ID:-$${GOG_M365_TENANT_ID}}" && \
go build -ldflags "$(LDFLAGS) \
-X 'github.com/automagik-dev/workit/internal/config.DefaultClientID=$$wk_client_id' \
-X 'github.com/automagik-dev/workit/internal/config.DefaultClientSecret=$$wk_client_secret' \
-X 'github.com/automagik-dev/workit/internal/config.DefaultCallbackServer=$$wk_callback_server'" \
-X 'github.com/automagik-dev/workit/internal/config.DefaultCallbackServer=$$wk_callback_server' \
-X 'github.com/automagik-dev/workit/internal/config.DefaultM365ClientID=$$wk_m365_client_id' \
-X 'github.com/automagik-dev/workit/internal/config.DefaultM365TenantID=$$wk_m365_tenant_id'" \
-o $(BIN) $(CMD); \
else \
echo "Missing credentials file: $(HOME)/.config/workit/credentials.env"; \
Expand Down
10 changes: 10 additions & 0 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ var (
checkRefreshToken = googleauth.CheckRefreshToken
ensureKeychainAccess = secrets.EnsureKeychainAccess
fetchAuthorizedEmail = googleauth.EmailForRefreshToken
authorizeM365 = msauth.Authorize
m365ManualAuthURL = msauth.ManualAuthURL
headlessAuthorize = googleauth.HeadlessAuthorize
pollForToken = googleauth.PollForToken
callbackServerURLFn = googleauth.CallbackServerURL
Expand Down Expand Up @@ -505,6 +507,10 @@ type AuthAddCmd struct {
func (c *AuthAddCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)

if isM365ServicesCSV(c.ServicesCSV) {
return c.runM365(ctx, flags, u)
}

override := authclient.ClientOverrideFromContext(ctx)
client, err := authclient.ResolveClientWithOverride(c.Email, override)
if err != nil {
Expand Down Expand Up @@ -1439,6 +1445,10 @@ type AuthManageCmd struct {
}

func (c *AuthManageCmd) Run(ctx context.Context, _ *RootFlags) error {
if isM365ServicesCSV(c.ServicesCSV) {
return c.runM365(ctx)
}

services, err := parseAuthServices(c.ServicesCSV)
if err != nil {
return err
Expand Down
119 changes: 119 additions & 0 deletions internal/cmd/auth_m365.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package cmd

import (
"context"
"fmt"
"os"
"sort"
"strings"

"github.com/automagik-dev/workit/internal/msauth"
"github.com/automagik-dev/workit/internal/outfmt"
"github.com/automagik-dev/workit/internal/secrets"
"github.com/automagik-dev/workit/internal/ui"
)

func isM365ServicesCSV(value string) bool {
parts := strings.Split(value, ",")
if len(parts) == 0 {
return false
}
for _, part := range parts {
if strings.ToLower(strings.TrimSpace(part)) != "m365" {
return false
}
}
return true
}

func (c *AuthAddCmd) runM365(ctx context.Context, flags *RootFlags, u *ui.UI) error {
if !c.Readonly {
return usage("m365 auth requires explicit --readonly")
}
if c.Headless || c.NoPoll || c.CallbackServer != "" {
return usage("m365 auth uses browser OAuth; headless callback-server mode is not supported yet")
}
if c.AuthCode != "" {
return usage("m365 auth does not accept raw --auth-code; use browser OAuth")
}
if c.Step != 0 && c.Step != 1 && c.Step != 2 {
return usage("step must be 1 or 2")
}
if c.Step != 0 && !c.Remote {
return usage("--step requires --remote")
}
if c.Remote || c.Step != 0 || c.AuthURL != "" {
return usage("m365 remote auth is not supported yet; use browser OAuth on this machine")
}
if dryRunErr := dryRunExit(ctx, flags, "auth.add.m365", map[string]any{
"email": strings.TrimSpace(c.Email),
"provider": "microsoft_graph",
"services": []string{"m365"},
"scopes": msauth.PilotAllowedScopes(),
"readonly": c.Readonly,
}); dryRunErr != nil {
return dryRunErr
}
if keychainErr := ensureKeychainAccessIfNeeded(); keychainErr != nil {
return fmt.Errorf("keychain access: %w", keychainErr)
}
result, err := authorizeM365(ctx, msauth.AuthorizeOptions{
ExpectedEmail: strings.TrimSpace(c.Email),
Readonly: c.Readonly,
ForceConsent: c.ForceConsent,
Timeout: c.Timeout,
})
if err != nil {
return err
}
if normalizeEmail(result.Email) != normalizeEmail(c.Email) {
return fmt.Errorf("authorized as %s, expected %s", result.Email, c.Email)
}
return storeM365Token(ctx, u, result.Email, result.RefreshToken)
}

func (c *AuthManageCmd) runM365(ctx context.Context) error {
if !outfmt.IsJSON(ctx) && !c.PrintURL {
return usage("m365 auth manage requires --print-url")
}

result, err := m365ManualAuthURL(ctx, msauth.ManualAuthURLOptions{Readonly: true, ForceConsent: c.ForceConsent})
if err != nil {
return err
}
if outfmt.IsJSON(ctx) || c.PrintURL {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"provider": "microsoft_graph",
"auth_url": result.URL,
"state": result.State,
"expires_in": result.ExpiresIn,
})
}
return nil
Comment thread
namastex888 marked this conversation as resolved.
}

func storeM365Token(ctx context.Context, u *ui.UI, email string, refreshToken string) error {
store, err := openSecretsStore()
if err != nil {
return err
}
serviceNames := []string{"m365"}
scopes := append([]string(nil), msauth.PilotAllowedScopes()...)
sort.Strings(scopes)
if err := store.MergeToken(msauth.ClientName, email, secrets.Token{
Client: msauth.ClientName,
Email: email,
Services: serviceNames,
Scopes: scopes,
RefreshToken: refreshToken,
}); err != nil {
return err
}
return writeResult(ctx, u,
kv("stored", true),
kv("provider", "microsoft_graph"),
kv("email", email),
kv("services", serviceNames),
kv("client", msauth.ClientName),
)
}
50 changes: 50 additions & 0 deletions internal/cmd/m365_auth_dryrun_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package cmd

import (
"os"
"strings"
"testing"

"github.com/automagik-dev/workit/internal/config"
)

func TestAuthAddM365DryRunReportsPilotScopes(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--dry-run", "auth", "add", "pilot@example.com", "--services", "m365", "--readonly"}); err != nil {
t.Fatalf("dry-run m365 auth: %v", err)
}
})
})

for _, want := range []string{"auth.add.m365", "microsoft_graph", "User.Read", "Mail.Read", "Calendars.Read"} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %s: %s", want, out)
}
}
}

func TestAuthAddM365RealFlowFailsClosedWithoutClientID(t *testing.T) {
origClientID := config.DefaultM365ClientID
origEnv, hadEnv := os.LookupEnv("WK_M365_CLIENT_ID")
t.Cleanup(func() {
config.DefaultM365ClientID = origClientID
if hadEnv {
_ = os.Setenv("WK_M365_CLIENT_ID", origEnv)
} else {
_ = os.Unsetenv("WK_M365_CLIENT_ID")
}
})
config.DefaultM365ClientID = ""
_ = os.Unsetenv("WK_M365_CLIENT_ID")

_ = captureStderr(t, func() {
err := Execute([]string{"--json", "auth", "add", "pilot@example.com", "--services", "m365", "--readonly"})
if err == nil {
t.Fatal("expected missing m365 client id")
}
if !strings.Contains(err.Error(), "client id") {
t.Fatalf("unexpected error: %v", err)
}
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
41 changes: 41 additions & 0 deletions internal/cmd/m365_auth_edges_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package cmd

import (
"strings"
"testing"
)

func TestAuthAddM365RejectsUnsupportedModes(t *testing.T) {
tests := []struct {
args []string
want string
}{
{[]string{"--json", "auth", "add", "pilot@example.com", "--services", "m365", "--readonly", "--headless"}, "headless callback-server mode is not supported yet"},
{[]string{"--json", "auth", "add", "pilot@example.com", "--services", "m365", "--readonly", "--auth-code", "raw"}, "does not accept raw --auth-code"},
{[]string{"--json", "auth", "add", "pilot@example.com", "--services", "m365", "--readonly", "--remote", "--step", "2"}, "remote auth is not supported yet"},
}

for _, tc := range tests {
_ = captureStderr(t, func() {
err := Execute(tc.args)
if err == nil {
t.Fatalf("expected error for %#v", tc.args)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected %q for %#v, got %v", tc.want, tc.args, err)
}
})
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestAuthAddMixedM365AndGoogleFailsClosed(t *testing.T) {
_ = captureStderr(t, func() {
err := Execute([]string{"--json", "auth", "add", "pilot@example.com", "--services", "m365,gmail", "--readonly"})
if err == nil {
t.Fatal("expected mixed m365/google services to fail closed")
}
if !strings.Contains(err.Error(), "unknown service") {
t.Fatalf("unexpected error: %v", err)
}
})
}
44 changes: 44 additions & 0 deletions internal/cmd/m365_auth_more_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package cmd

import (
"context"
"strings"
"testing"

"github.com/automagik-dev/workit/internal/msauth"
)

func TestAuthManageM365PrintURLPropagatesForceConsent(t *testing.T) {
origURL := m365ManualAuthURL
t.Cleanup(func() { m365ManualAuthURL = origURL })

var got msauth.ManualAuthURLOptions
m365ManualAuthURL = func(_ context.Context, opts msauth.ManualAuthURLOptions) (msauth.ManualAuthURLResult, error) {
got = opts
return msauth.ManualAuthURLResult{URL: "https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize", State: "state"}, nil
}

_ = captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "auth", "manage", "--services", "m365", "--force-consent", "--print-url"}); err != nil {
t.Fatalf("auth manage m365: %v", err)
}
})
})

if !got.Readonly || !got.ForceConsent {
t.Fatalf("options = %#v", got)
}
}

func TestAuthManageM365RequiresPrintURLForTextMode(t *testing.T) {
_ = captureStderr(t, func() {
err := Execute([]string{"auth", "manage", "--services", "m365"})
if err == nil {
t.Fatal("expected m365 manage text mode to fail closed")
}
if !strings.Contains(err.Error(), "requires --print-url") {
t.Fatalf("unexpected error: %v", err)
}
})
}
Loading
Loading