From c77c9f1ea99d948ebf47586542df5baccd47b979 Mon Sep 17 00:00:00 2001 From: Genie Automagik Date: Tue, 2 Jun 2026 18:51:07 +0000 Subject: [PATCH 1/3] feat: wire m365 broker into auth server --- Makefile | 9 +- auth-server/Dockerfile | 41 +-- auth-server/README.md | 23 +- auth-server/config.go | 58 ++++ auth-server/dockerfile_test.go | 21 ++ auth-server/handlers.go | 6 + auth-server/m365.go | 396 +++++++++++++++++++++++++++ auth-server/m365_handlers_test.go | 113 ++++++++ auth-server/main.go | 59 ++-- auth-server/main_config_test.go | 31 +++ internal/cmd/auth_m365_link.go | 39 ++- internal/cmd/m365_login_link_test.go | 38 ++- 12 files changed, 765 insertions(+), 69 deletions(-) create mode 100644 auth-server/config.go create mode 100644 auth-server/dockerfile_test.go create mode 100644 auth-server/m365.go create mode 100644 auth-server/m365_handlers_test.go create mode 100644 auth-server/main_config_test.go diff --git a/Makefile b/Makefile index bd737d93..f6b6bb9d 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ SHELL := /bin/bash .DEFAULT_GOAL := build .PHONY: build build-gog wk workit gog wk-help workit-help help fmt fmt-check lint lint-full test ci tools -.PHONY: worker-ci build-internal build-automagik deadcode race coverage +.PHONY: worker-ci build-internal build-automagik build-auth-server docker-auth-server deadcode race coverage BIN_DIR := $(CURDIR)/bin BIN := $(BIN_DIR)/wk @@ -49,6 +49,13 @@ build: @mkdir -p $(BIN_DIR) @go build -ldflags "$(LDFLAGS)" -o $(BIN) $(CMD) +build-auth-server: + @mkdir -p $(BIN_DIR) + @cd auth-server && go build -o $(BIN_DIR)/workit-auth-server . + +docker-auth-server: + @docker build -t workit-auth-server:local auth-server + # Build the deprecated "gog" backward-compat alias binary. build-gog: @mkdir -p $(BIN_DIR) diff --git a/auth-server/Dockerfile b/auth-server/Dockerfile index f88f5f9d..6986a2b0 100644 --- a/auth-server/Dockerfile +++ b/auth-server/Dockerfile @@ -1,39 +1,22 @@ -# Build stage -FROM golang:1.25-alpine AS builder +# syntax=docker/dockerfile:1.7 -WORKDIR /app +FROM golang:1.25-alpine AS build +WORKDIR /src -# Copy go.mod and go.sum first for better caching COPY go.mod go.sum ./ RUN go mod download -# Copy source code -COPY *.go ./ - -# Build the binary -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o auth-server . - -# Runtime stage -FROM alpine:3.19 - -# Add ca-certificates for HTTPS calls to Google OAuth -RUN apk --no-cache add ca-certificates +COPY . ./ +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/workit-auth-server . +FROM gcr.io/distroless/static-debian12:nonroot WORKDIR /app +COPY --from=build /out/workit-auth-server /app/workit-auth-server -# Copy the binary from builder -COPY --from=builder /app/auth-server . - -# Create non-root user -RUN adduser -D -u 1000 appuser -USER appuser - -# Expose default port +ENV WK_PUBLIC_BASE_URL="" +ENV WK_M365_CLIENT_ID="" +ENV WK_M365_TENANT_ID="organizations" EXPOSE 8080 -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 - -# Run the server -ENTRYPOINT ["./auth-server"] +USER nonroot:nonroot +ENTRYPOINT ["/app/workit-auth-server"] diff --git a/auth-server/README.md b/auth-server/README.md index a3e7539a..0b91ee52 100644 --- a/auth-server/README.md +++ b/auth-server/README.md @@ -17,9 +17,12 @@ When users authenticate via the headless OAuth flow: | Endpoint | Method | Description | |----------|--------|-------------| | `/health` | GET | Health check, returns `{"status": "ok"}` | -| `/callback` | GET | OAuth callback, exchanges code for token | +| `/callback` | GET | Google OAuth callback, exchanges code for token | | `/token/{state}` | GET | Retrieve token (consumes it) | | `/status/{state}` | GET | Check token status without consuming | +| `/m365/sessions` | POST | Create a one-click Microsoft 365 read-only login session | +| `/m365/start/{state}` | GET | Redirect user to Microsoft authorize URL | +| `/m365/callback` | GET | Microsoft OAuth callback with PKCE/email validation | ### Response Codes @@ -51,7 +54,11 @@ When users authenticate via the headless OAuth flow: |----------|-------------| | `WK_CLIENT_ID` | OAuth client ID | | `WK_CLIENT_SECRET` | OAuth client secret | -| `WK_REDIRECT_URL` | OAuth redirect URL | +| `WK_REDIRECT_URL` | Google OAuth redirect URL | +| `WK_PUBLIC_BASE_URL` | Public HTTPS base URL for the deployed auth server; M365 derives `/m365/callback` from this | +| `WK_CALLBACK_SERVER` | Backward-compatible public base URL fallback | +| `WK_M365_CLIENT_ID` | Microsoft Entra application/client ID for M365 broker | +| `WK_M365_TENANT_ID` | Microsoft tenant ID; defaults to `organizations` | Command-line flags take precedence over environment variables. @@ -68,14 +75,16 @@ go build -o auth-server . ### Docker Build ```bash -docker build -t auth-server . +docker build -t workit-auth-server . docker run -p 8080:8080 \ - -e WK_CLIENT_ID="your-client-id" \ - -e WK_CLIENT_SECRET="your-client-secret" \ - -e WK_REDIRECT_URL="https://auth.example.com/callback" \ - auth-server + -e WK_PUBLIC_BASE_URL="https://auth.hv.example" \ + -e WK_M365_CLIENT_ID="" \ + -e WK_M365_TENANT_ID="" \ + workit-auth-server ``` +For Google relay compatibility, also set `WK_CLIENT_ID`, `WK_CLIENT_SECRET`, and optionally `WK_REDIRECT_URL`. + ## Deployment ### Docker Compose Example diff --git a/auth-server/config.go b/auth-server/config.go new file mode 100644 index 00000000..03c02bf7 --- /dev/null +++ b/auth-server/config.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +type serverConfigInput struct { + Port int + ClientID string + ClientSecret string + RedirectURL string + PublicBaseURL string + M365ClientID string + M365TenantID string +} + +type serverConfig struct { + clientID string + clientSecret string + redirectURL string + publicBaseURL string + m365ClientID string + m365TenantID string +} + +func resolveServerConfig(input serverConfigInput) serverConfig { + publicBaseURL := firstNonEmpty(input.PublicBaseURL, os.Getenv("WK_PUBLIC_BASE_URL"), os.Getenv("WK_CALLBACK_SERVER")) + publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/") + + redirectURL := firstNonEmpty(input.RedirectURL, os.Getenv("WK_REDIRECT_URL")) + if redirectURL == "" && publicBaseURL != "" { + redirectURL = publicBaseURL + "/callback" + } + if redirectURL == "" { + redirectURL = fmt.Sprintf("http://localhost:%d/callback", input.Port) + } + + return serverConfig{ + clientID: firstNonEmpty(input.ClientID, os.Getenv("WK_CLIENT_ID")), + clientSecret: firstNonEmpty(input.ClientSecret, os.Getenv("WK_CLIENT_SECRET")), + redirectURL: redirectURL, + publicBaseURL: publicBaseURL, + m365ClientID: firstNonEmpty(input.M365ClientID, os.Getenv("WK_M365_CLIENT_ID")), + m365TenantID: firstNonEmpty(input.M365TenantID, os.Getenv("WK_M365_TENANT_ID")), + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + + return "" +} diff --git a/auth-server/dockerfile_test.go b/auth-server/dockerfile_test.go new file mode 100644 index 00000000..8a6fa8ee --- /dev/null +++ b/auth-server/dockerfile_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +func TestDockerfileDocumentsEnterpriseM365EnvContract(t *testing.T) { + data, err := os.ReadFile("Dockerfile") + if err != nil { + t.Fatalf("read Dockerfile: %v", err) + } + + content := string(data) + for _, want := range []string{"WK_PUBLIC_BASE_URL", "WK_M365_CLIENT_ID", "WK_M365_TENANT_ID", "EXPOSE 8080", "workit-auth-server"} { + if !strings.Contains(content, want) { + t.Fatalf("Dockerfile missing %s", want) + } + } +} diff --git a/auth-server/handlers.go b/auth-server/handlers.go index ab149448..588551ec 100644 --- a/auth-server/handlers.go +++ b/auth-server/handlers.go @@ -19,6 +19,12 @@ type Server struct { oauthConfig *oauth2.Config mux *http.ServeMux exchangeFunc func(ctx context.Context, code string) (*oauth2.Token, error) + + m365Enabled bool + m365ClientID string + m365TenantID string + publicBaseURL string + m365Sessions *m365SessionStore } // NewServer creates a new Server with the given configuration. diff --git a/auth-server/m365.go b/auth-server/m365.go new file mode 100644 index 00000000..f0372f71 --- /dev/null +++ b/auth-server/m365.go @@ -0,0 +1,396 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" +) + +var ( + errM365Disabled = errors.New("m365 broker disabled") + errM365MissingClientID = errors.New("m365 client id missing") + errM365MissingBaseURL = errors.New("m365 public base url missing") + errM365MissingEmail = errors.New("m365 expected email missing") + errM365StateNotFound = errors.New("m365 broker state not found") + errM365StateExpired = errors.New("m365 broker state expired") + errM365EmailMismatch = errors.New("m365 authorized email mismatch") +) + +const defaultM365TenantID = "organizations" + +type ServerOptions struct { + Store *TokenStore + GoogleClientID string + GoogleClientSecret string + GoogleRedirectURL string + M365Enabled bool + M365ClientID string + M365TenantID string + PublicBaseURL string +} + +type m365Session struct { + State string + ExpectedEmail string + AuthURL string + CodeVerifier string + ExpiresAt time.Time +} + +type m365SessionStore struct { + mu sync.Mutex + sessions map[string]m365Session +} + +func newM365SessionStore() *m365SessionStore { + return &m365SessionStore{sessions: make(map[string]m365Session)} +} + +func (s *m365SessionStore) save(session m365Session) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.sessions == nil { + s.sessions = make(map[string]m365Session) + } + + s.sessions[session.State] = session +} + +func (s *m365SessionStore) get(state string) (m365Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + + session, ok := s.sessions[strings.TrimSpace(state)] + if !ok { + return m365Session{}, errM365StateNotFound + } + + if !session.ExpiresAt.IsZero() && time.Now().After(session.ExpiresAt) { + delete(s.sessions, session.State) + return m365Session{}, errM365StateExpired + } + + return session, nil +} + +func (s *m365SessionStore) consume(state string) (m365Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + + key := strings.TrimSpace(state) + session, ok := s.sessions[key] + if ok { + delete(s.sessions, key) + } + if !ok { + return m365Session{}, errM365StateNotFound + } + + if !session.ExpiresAt.IsZero() && time.Now().After(session.ExpiresAt) { + return m365Session{}, errM365StateExpired + } + + return session, nil +} + +func NewServerWithOptions(opts ServerOptions) *Server { + store := opts.Store + if store == nil { + store = NewTokenStore(DefaultTTL) + } + + s := NewServer(store, opts.GoogleClientID, opts.GoogleClientSecret, opts.GoogleRedirectURL) + s.m365Enabled = opts.M365Enabled + s.m365ClientID = strings.TrimSpace(opts.M365ClientID) + s.m365TenantID = strings.TrimSpace(opts.M365TenantID) + if s.m365TenantID == "" { + s.m365TenantID = defaultM365TenantID + } + s.publicBaseURL = strings.TrimRight(strings.TrimSpace(opts.PublicBaseURL), "/") + s.m365Sessions = newM365SessionStore() + s.mux.HandleFunc("/m365/sessions", s.handleM365Sessions) + s.mux.HandleFunc("/m365/start/", s.handleM365Start) + s.mux.HandleFunc("/m365/callback", s.handleM365Callback) + + return s +} + +func (s *Server) handleM365Sessions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if err := s.validateM365Config(); err != nil { + writeJSONError(w, http.StatusInternalServerError, err) + return + } + + var req struct { + ExpectedEmail string `json:"expected_email"` + ForceConsent bool `json:"force_consent"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, http.StatusBadRequest, err) + return + } + + expectedEmail := strings.ToLower(strings.TrimSpace(req.ExpectedEmail)) + if expectedEmail == "" { + writeJSONError(w, http.StatusBadRequest, errM365MissingEmail) + return + } + + session, err := s.createM365Session(expectedEmail, req.ForceConsent) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]any{ + "state": session.State, + "expected_email": session.ExpectedEmail, + "login_url": s.publicBaseURL + "/m365/start/" + session.State, + "status_url": s.publicBaseURL + "/status/" + session.State, + "expires_at": session.ExpiresAt.Format(time.RFC3339), + }); err != nil { + log.Printf("Error encoding m365 session response: %v", err) + } +} + +func (s *Server) handleM365Start(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + state := strings.TrimPrefix(r.URL.Path, "/m365/start/") + session, err := s.m365Sessions.get(state) + if err != nil { + writeJSONError(w, http.StatusNotFound, err) + return + } + + http.Redirect(w, r, session.AuthURL, http.StatusFound) +} + +func (s *Server) handleM365Callback(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if errParam := r.URL.Query().Get("error"); errParam != "" { + s.renderErrorPage(w, "Microsoft 365 authorization failed", http.StatusBadRequest) + return + } + + state := r.URL.Query().Get("state") + code := r.URL.Query().Get("code") + if state == "" || code == "" { + s.renderErrorPage(w, "Missing Microsoft 365 authorization state or code", http.StatusBadRequest) + return + } + + session, err := s.m365Sessions.consume(state) + if err != nil { + s.renderErrorPage(w, "Microsoft 365 login link expired or already used", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + token, err := s.exchangeM365Code(ctx, session, code) + if err != nil { + log.Printf("M365 token exchange failed for state %s: %v", state, err) + s.renderErrorPage(w, "Failed to exchange Microsoft 365 authorization code", http.StatusInternalServerError) + return + } + + email, err := fetchM365Email(ctx, token.AccessToken) + if err != nil { + log.Printf("M365 profile fetch failed for state %s: %v", state, err) + s.renderErrorPage(w, "Failed to validate Microsoft 365 account", http.StatusInternalServerError) + return + } + if err := validateM365Email(session.ExpectedEmail, email); err != nil { + log.Printf("M365 email mismatch for state %s: %v", state, err) + s.renderErrorPage(w, "Microsoft 365 account did not match expected email", http.StatusForbidden) + return + } + + s.store.Store(state, token) + s.renderSuccessPage(w, state) +} + +func (s *Server) validateM365Config() error { + if !s.m365Enabled { + return errM365Disabled + } + if s.m365ClientID == "" { + return errM365MissingClientID + } + if s.publicBaseURL == "" { + return errM365MissingBaseURL + } + + parsed, err := url.Parse(s.publicBaseURL) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return errM365MissingBaseURL + } + + return nil +} + +func (s *Server) createM365Session(expectedEmail string, forceConsent bool) (m365Session, error) { + state, verifier, challenge, err := newM365StateAndPKCE() + if err != nil { + return m365Session{}, err + } + + expiresAt := time.Now().UTC().Add(10 * time.Minute) + redirectURL := s.publicBaseURL + "/m365/callback" + cfg := s.m365OAuthConfig(redirectURL) + authURL := cfg.AuthCodeURL(state, m365AuthParams(forceConsent, challenge)...) + session := m365Session{ + State: state, + ExpectedEmail: expectedEmail, + AuthURL: authURL, + CodeVerifier: verifier, + ExpiresAt: expiresAt, + } + + s.m365Sessions.save(session) + + return session, nil +} + +func (s *Server) m365OAuthConfig(redirectURL string) oauth2.Config { + base := "https://login.microsoftonline.com/" + s.m365TenantID + "/oauth2/v2.0" + + return oauth2.Config{ + ClientID: s.m365ClientID, + Endpoint: oauth2.Endpoint{AuthURL: base + "/authorize", TokenURL: base + "/token"}, + RedirectURL: redirectURL, + Scopes: []string{"offline_access", "User.Read", "Mail.Read", "Calendars.Read"}, + } +} + +func (s *Server) exchangeM365Code(ctx context.Context, session m365Session, code string) (*oauth2.Token, error) { + cfg := s.m365OAuthConfig(s.publicBaseURL + "/m365/callback") + token, err := cfg.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", session.CodeVerifier)) + if err != nil { + return nil, fmt.Errorf("exchange m365 code: %w", err) + } + if token.RefreshToken == "" { + return nil, errors.New("m365 refresh token missing") + } + + return token, nil +} + +func m365AuthParams(forceConsent bool, challenge string) []oauth2.AuthCodeOption { + params := []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("code_challenge", challenge), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + } + if forceConsent { + params = append(params, oauth2.SetAuthURLParam("prompt", "consent")) + } + + return params +} + +func newM365StateAndPKCE() (string, string, string, error) { + state, err := randomURLToken() + if err != nil { + return "", "", "", err + } + + verifier, err := randomURLToken() + if err != nil { + return "", "", "", err + } + + sum := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(sum[:]) + + return state, verifier, challenge, nil +} + +func randomURLToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate random token: %w", err) + } + + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func fetchM365Email(ctx context.Context, accessToken string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://graph.microsoft.com/v1.0/me?$select=mail,userPrincipalName", nil) + if err != nil { + return "", fmt.Errorf("create m365 profile request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("fetch m365 profile: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("fetch m365 profile status: %d", resp.StatusCode) + } + + var me struct { + Mail string `json:"mail"` + UserPrincipalName string `json:"userPrincipalName"` //nolint:tagliatelle // Microsoft Graph field name. + } + if err := json.NewDecoder(resp.Body).Decode(&me); err != nil { + return "", fmt.Errorf("decode m365 profile: %w", err) + } + + email := strings.TrimSpace(me.Mail) + if email == "" { + email = strings.TrimSpace(me.UserPrincipalName) + } + if email == "" { + return "", errors.New("m365 profile missing email") + } + + return email, nil +} + +func validateM365Email(expected string, actual string) error { + want := strings.ToLower(strings.TrimSpace(expected)) + got := strings.ToLower(strings.TrimSpace(actual)) + if want == "" || got == "" || want != got { + return fmt.Errorf("%w: expected %s got %s", errM365EmailMismatch, want, got) + } + + return nil +} + +func writeJSONError(w http.ResponseWriter, status int, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if encodeErr := json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}); encodeErr != nil { + log.Printf("Error encoding error response: %v", encodeErr) + } +} diff --git a/auth-server/m365_handlers_test.go b/auth-server/m365_handlers_test.go new file mode 100644 index 00000000..28395e2a --- /dev/null +++ b/auth-server/m365_handlers_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestM365SessionsCreatesEnterpriseOneClickURL(t *testing.T) { + server := NewServerWithOptions(ServerOptions{ + Store: NewTokenStore(DefaultTTL), + GoogleClientID: "google-client", + GoogleClientSecret: "google-secret", + GoogleRedirectURL: "https://auth.hv.example/callback", + M365Enabled: true, + M365ClientID: "m365-client", + M365TenantID: "hapvida-tenant", + PublicBaseURL: "https://auth.hv.example", + }) + + req := httptest.NewRequest(http.MethodPost, "/m365/sessions", strings.NewReader(`{"expected_email":"Bernardo@Hapvida.com.br"}`)) + rec := httptest.NewRecorder() + + server.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + + loginURL, _ := body["login_url"].(string) + if !strings.HasPrefix(loginURL, "https://auth.hv.example/m365/start/") { + t.Fatalf("login_url = %q", loginURL) + } + if body["expected_email"] != "bernardo@hapvida.com.br" { + t.Fatalf("expected_email = %#v", body["expected_email"]) + } + if _, exists := body["code_verifier"]; exists { + t.Fatalf("code_verifier leaked in response: %s", rec.Body.String()) + } +} + +func TestM365StartRedirectsToMicrosoftAuthorize(t *testing.T) { + server := NewServerWithOptions(ServerOptions{ + Store: NewTokenStore(DefaultTTL), + GoogleClientID: "google-client", + GoogleClientSecret: "google-secret", + GoogleRedirectURL: "https://auth.hv.example/callback", + M365Enabled: true, + M365ClientID: "m365-client", + M365TenantID: "hapvida-tenant", + PublicBaseURL: "https://auth.hv.example", + }) + + createReq := httptest.NewRequest(http.MethodPost, "/m365/sessions", strings.NewReader(`{"expected_email":"bernardo@hapvida.com.br"}`)) + createRec := httptest.NewRecorder() + server.ServeHTTP(createRec, createReq) + + var body map[string]any + if err := json.Unmarshal(createRec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode create response: %v", err) + } + + startPath := strings.TrimPrefix(body["login_url"].(string), "https://auth.hv.example") + startReq := httptest.NewRequest(http.MethodGet, startPath, nil) + startRec := httptest.NewRecorder() + server.ServeHTTP(startRec, startReq) + + if startRec.Code != http.StatusFound { + t.Fatalf("status = %d body = %s", startRec.Code, startRec.Body.String()) + } + + location := startRec.Header().Get("Location") + for _, want := range []string{"https://login.microsoftonline.com/hapvida-tenant/oauth2/v2.0/authorize", "client_id=m365-client", "redirect_uri=https%3A%2F%2Fauth.hv.example%2Fm365%2Fcallback", "code_challenge_method=S256"} { + if !strings.Contains(location, want) { + t.Fatalf("redirect missing %s: %s", want, location) + } + } + for _, forbidden := range []string{"Mail.Send", "Calendars.ReadWrite"} { + if strings.Contains(location, forbidden) { + t.Fatalf("redirect contains forbidden scope %s: %s", forbidden, location) + } + } +} + +func TestM365SessionsFailClosedWhenServerConfigMissing(t *testing.T) { + server := NewServerWithOptions(ServerOptions{ + Store: NewTokenStore(DefaultTTL), + GoogleClientID: "google-client", + GoogleClientSecret: "google-secret", + GoogleRedirectURL: "https://auth.hv.example/callback", + M365Enabled: true, + PublicBaseURL: "https://auth.hv.example", + }) + + req := httptest.NewRequest(http.MethodPost, "/m365/sessions", strings.NewReader(`{"expected_email":"bernardo@hapvida.com.br"}`)) + rec := httptest.NewRecorder() + + server.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "token") { + t.Fatalf("unexpected sensitive body: %s", rec.Body.String()) + } +} diff --git a/auth-server/main.go b/auth-server/main.go index 3c710026..8daf3773 100644 --- a/auth-server/main.go +++ b/auth-server/main.go @@ -30,20 +30,28 @@ func main() { clientID := flag.String("client-id", "", "OAuth client ID") clientSecret := flag.String("client-secret", "", "OAuth client secret") redirectURL := flag.String("redirect-url", "", "OAuth redirect URL (defaults to http://localhost:{port}/callback)") + publicBaseURL := flag.String("public-base-url", "", "Public HTTPS base URL for this auth server (env WK_PUBLIC_BASE_URL; defaults redirect URLs when set)") + m365ClientID := flag.String("m365-client-id", "", "Microsoft 365 OAuth client ID (env WK_M365_CLIENT_ID)") + m365TenantID := flag.String("m365-tenant-id", "", "Microsoft 365 tenant ID (env WK_M365_TENANT_ID; default organizations)") credentialsFile := flag.String("credentials-file", "", "Path to OAuth credentials JSON file (workit format)") ttl := flag.Duration("ttl", DefaultTTL, "Token time-to-live") flag.Parse() - // Allow environment variables to override flags - if *clientID == "" { - *clientID = os.Getenv("WK_CLIENT_ID") - } - if *clientSecret == "" { - *clientSecret = os.Getenv("WK_CLIENT_SECRET") - } - if *redirectURL == "" { - *redirectURL = os.Getenv("WK_REDIRECT_URL") - } + resolved := resolveServerConfig(serverConfigInput{ + Port: *port, + ClientID: *clientID, + ClientSecret: *clientSecret, + RedirectURL: *redirectURL, + PublicBaseURL: *publicBaseURL, + M365ClientID: *m365ClientID, + M365TenantID: *m365TenantID, + }) + *clientID = resolved.clientID + *clientSecret = resolved.clientSecret + *redirectURL = resolved.redirectURL + *publicBaseURL = resolved.publicBaseURL + *m365ClientID = resolved.m365ClientID + *m365TenantID = resolved.m365TenantID // Load credentials from file if specified (fills empty client ID/secret) if *credentialsFile != "" { @@ -60,12 +68,16 @@ func main() { log.Printf("Loaded credentials from %s", *credentialsFile) } - // Validate required configuration - if *clientID == "" { - log.Fatal("OAuth client ID is required (--client-id, WK_CLIENT_ID, or --credentials-file)") + // Validate required configuration. Google OAuth is optional when the pod is deployed + // as an M365-only enterprise broker. + if *clientID == "" && *m365ClientID == "" { + log.Fatal("OAuth client ID is required (--client-id, WK_CLIENT_ID, --m365-client-id, or WK_M365_CLIENT_ID)") } - if *clientSecret == "" { - log.Fatal("OAuth client secret is required (--client-secret, WK_CLIENT_SECRET, or --credentials-file)") + if *clientID != "" && *clientSecret == "" { + log.Fatal("OAuth client secret is required for Google OAuth (--client-secret, WK_CLIENT_SECRET, or --credentials-file)") + } + if *m365ClientID != "" && *publicBaseURL == "" { + log.Fatal("Public base URL is required for M365 broker (--public-base-url, WK_PUBLIC_BASE_URL, or WK_CALLBACK_SERVER)") } // Default redirect URL if not specified @@ -79,7 +91,16 @@ func main() { defer store.StopCleanup() // Create server - server := NewServer(store, *clientID, *clientSecret, *redirectURL) + server := NewServerWithOptions(ServerOptions{ + Store: store, + GoogleClientID: *clientID, + GoogleClientSecret: *clientSecret, + GoogleRedirectURL: *redirectURL, + M365Enabled: *m365ClientID != "", + M365ClientID: *m365ClientID, + M365TenantID: *m365TenantID, + PublicBaseURL: *publicBaseURL, + }) // Create HTTP server httpServer := &http.Server{ @@ -107,6 +128,12 @@ func main() { // Start server log.Printf("Auth callback server starting on port %d", *port) log.Printf("Redirect URL: %s", *redirectURL) + if *publicBaseURL != "" { + log.Printf("Public base URL: %s", *publicBaseURL) + } + if *m365ClientID != "" { + log.Printf("M365 broker enabled for tenant: %s", firstNonEmpty(*m365TenantID, defaultM365TenantID)) + } log.Printf("Token TTL: %s", *ttl) if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { diff --git a/auth-server/main_config_test.go b/auth-server/main_config_test.go new file mode 100644 index 00000000..e2ff2718 --- /dev/null +++ b/auth-server/main_config_test.go @@ -0,0 +1,31 @@ +package main + +import "testing" + +func TestResolveServerConfigUsesDynamicPublicBaseURL(t *testing.T) { + t.Setenv("WK_PUBLIC_BASE_URL", "https://auth.hv.example/") + t.Setenv("WK_M365_CLIENT_ID", "m365-client") + t.Setenv("WK_M365_TENANT_ID", "tenant-id") + + cfg := resolveServerConfig(serverConfigInput{Port: 8080}) + + if cfg.publicBaseURL != "https://auth.hv.example" { + t.Fatalf("publicBaseURL = %q", cfg.publicBaseURL) + } + if cfg.redirectURL != "https://auth.hv.example/callback" { + t.Fatalf("redirectURL = %q", cfg.redirectURL) + } + if cfg.m365ClientID != "m365-client" || cfg.m365TenantID != "tenant-id" { + t.Fatalf("m365 config = %#v", cfg) + } +} + +func TestResolveServerConfigKeepsExplicitRedirectURL(t *testing.T) { + t.Setenv("WK_PUBLIC_BASE_URL", "https://auth.hv.example") + + cfg := resolveServerConfig(serverConfigInput{Port: 8080, RedirectURL: "https://custom.example/callback"}) + + if cfg.redirectURL != "https://custom.example/callback" { + t.Fatalf("redirectURL = %q", cfg.redirectURL) + } +} diff --git a/internal/cmd/auth_m365_link.go b/internal/cmd/auth_m365_link.go index 0ac7f475..982e1088 100644 --- a/internal/cmd/auth_m365_link.go +++ b/internal/cmd/auth_m365_link.go @@ -2,10 +2,12 @@ package cmd import ( "context" + "net/url" "os" "strings" "time" + "github.com/automagik-dev/workit/internal/googleauth" "github.com/automagik-dev/workit/internal/msauth" "github.com/automagik-dev/workit/internal/outfmt" "github.com/automagik-dev/workit/internal/ui" @@ -25,22 +27,45 @@ type AuthM365LoginLinkCmd struct { ForceConsent bool `name:"force-consent" help:"Force Microsoft consent screen"` } +func (c *AuthM365LoginLinkCmd) resolveBrokerURLs() (string, string, error) { + baseURL := strings.TrimSpace(c.BaseURL) + callbackURL := strings.TrimSpace(c.CallbackURL) + if baseURL == "" || callbackURL == "" { + callbackServer, err := googleauth.CallbackServerURL(baseURL) + if err != nil { + return "", "", err + } + + if baseURL == "" { + baseURL = strings.TrimRight(callbackServer, "/") + } + if callbackURL == "" { + callbackURL = strings.TrimRight(callbackServer, "/") + "/m365/callback" + } + } + + parsed, err := url.Parse(baseURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", "", usage("invalid m365 broker --base-url") + } + + return baseURL, callbackURL, nil +} + func (c *AuthM365LoginLinkCmd) Run(ctx context.Context, _ *RootFlags) error { email := strings.TrimSpace(c.Email) if email == "" { return usage("empty email") } - if strings.TrimSpace(c.BaseURL) == "" { - return usage("m365 login-link requires --base-url") - } - if strings.TrimSpace(c.CallbackURL) == "" { - return usage("m365 login-link requires --callback-url") + baseURL, callbackURL, err := c.resolveBrokerURLs() + if err != nil { + return err } session, err := createM365BrokerSession(ctx, msauth.BrokerSessionOptions{ ExpectedEmail: email, - BaseURL: c.BaseURL, - CallbackURL: c.CallbackURL, + BaseURL: baseURL, + CallbackURL: callbackURL, Readonly: true, ForceConsent: c.ForceConsent, TTL: c.TTL, diff --git a/internal/cmd/m365_login_link_test.go b/internal/cmd/m365_login_link_test.go index 556ee817..7818fc58 100644 --- a/internal/cmd/m365_login_link_test.go +++ b/internal/cmd/m365_login_link_test.go @@ -43,14 +43,34 @@ func TestAuthM365LoginLinkPrintsOneClickURL(t *testing.T) { } } -func TestAuthM365LoginLinkRequiresExplicitBrokerURLs(t *testing.T) { - _ = captureStderr(t, func() { - err := Execute([]string{"auth", "m365", "login-link", "bernardo@hapvida.com.br"}) - if err == nil { - t.Fatal("expected missing broker URL failure") - } - if !strings.Contains(err.Error(), "base-url") { - t.Fatalf("unexpected error: %v", err) - } +func TestAuthM365LoginLinkUsesCallbackServerDefaultWhenURLsOmitted(t *testing.T) { + origCreate := createM365BrokerSession + t.Cleanup(func() { createM365BrokerSession = origCreate }) + t.Setenv("WK_CALLBACK_SERVER", "https://auth.hv.example") + + var got msauth.BrokerSessionOptions + createM365BrokerSession = func(_ context.Context, opts msauth.BrokerSessionOptions) (msauth.BrokerSession, error) { + got = opts + return msauth.BrokerSession{ + State: "state", + ExpectedEmail: "bernardo@hapvida.com.br", + LoginURL: "https://auth.hv.example/m365/start/state", + ExpiresAt: time.Unix(1893456000, 0).UTC(), + }, nil + } + + _ = captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "auth", "m365", "login-link", "bernardo@hapvida.com.br"}); err != nil { + t.Fatalf("login-link: %v", err) + } + }) }) + + if got.BaseURL != "https://auth.hv.example" { + t.Fatalf("base url = %q", got.BaseURL) + } + if got.CallbackURL != "https://auth.hv.example/m365/callback" { + t.Fatalf("callback url = %q", got.CallbackURL) + } } From 830764ecfcf39d7617aa7d26cd42507f0b30203b Mon Sep 17 00:00:00 2001 From: Genie Automagik Date: Tue, 2 Jun 2026 19:06:19 +0000 Subject: [PATCH 2/3] fix: harden m365 auth server broker --- auth-server/Dockerfile | 5 +-- auth-server/README.md | 7 ++-- auth-server/dockerfile_test.go | 2 +- auth-server/m365.go | 14 ++++++-- auth-server/m365_handlers_test.go | 48 ++++++++++++++++++++++++++++ auth-server/main.go | 5 --- internal/cmd/auth_m365_link.go | 4 +++ internal/cmd/m365_login_link_test.go | 12 +++++++ 8 files changed, 81 insertions(+), 16 deletions(-) diff --git a/auth-server/Dockerfile b/auth-server/Dockerfile index 6986a2b0..15fac1f3 100644 --- a/auth-server/Dockerfile +++ b/auth-server/Dockerfile @@ -1,13 +1,14 @@ # syntax=docker/dockerfile:1.7 -FROM golang:1.25-alpine AS build +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS build +ARG TARGETARCH WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . ./ -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/workit-auth-server . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /out/workit-auth-server . FROM gcr.io/distroless/static-debian12:nonroot WORKDIR /app diff --git a/auth-server/README.md b/auth-server/README.md index 0b91ee52..963f0534 100644 --- a/auth-server/README.md +++ b/auth-server/README.md @@ -101,13 +101,10 @@ services: - WK_CLIENT_SECRET=${WK_CLIENT_SECRET} - WK_REDIRECT_URL=https://auth.example.com/callback restart: unless-stopped - healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"] - interval: 30s - timeout: 3s - retries: 3 ``` +The runtime image is distroless and has no shell utilities such as `wget`; configure Kubernetes/OCI HTTP probes against `/health` instead of a container-local shell healthcheck. + ### Reverse Proxy (nginx) ```nginx diff --git a/auth-server/dockerfile_test.go b/auth-server/dockerfile_test.go index 8a6fa8ee..d7854720 100644 --- a/auth-server/dockerfile_test.go +++ b/auth-server/dockerfile_test.go @@ -13,7 +13,7 @@ func TestDockerfileDocumentsEnterpriseM365EnvContract(t *testing.T) { } content := string(data) - for _, want := range []string{"WK_PUBLIC_BASE_URL", "WK_M365_CLIENT_ID", "WK_M365_TENANT_ID", "EXPOSE 8080", "workit-auth-server"} { + for _, want := range []string{"WK_PUBLIC_BASE_URL", "WK_M365_CLIENT_ID", "WK_M365_TENANT_ID", "TARGETARCH", "EXPOSE 8080", "workit-auth-server"} { if !strings.Contains(content, want) { t.Fatalf("Dockerfile missing %s", want) } diff --git a/auth-server/m365.go b/auth-server/m365.go index f0372f71..e2bdc98f 100644 --- a/auth-server/m365.go +++ b/auth-server/m365.go @@ -66,6 +66,13 @@ func (s *m365SessionStore) save(session m365Session) { s.sessions = make(map[string]m365Session) } + now := time.Now() + for state, existing := range s.sessions { + if !existing.ExpiresAt.IsZero() && now.After(existing.ExpiresAt) { + delete(s.sessions, state) + } + } + s.sessions[session.State] = session } @@ -142,6 +149,7 @@ func (s *Server) handleM365Sessions(w http.ResponseWriter, r *http.Request) { ExpectedEmail string `json:"expected_email"` ForceConsent bool `json:"force_consent"` } + r.Body = http.MaxBytesReader(w, r.Body, 4096) if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, http.StatusBadRequest, err) return @@ -180,7 +188,7 @@ func (s *Server) handleM365Start(w http.ResponseWriter, r *http.Request) { state := strings.TrimPrefix(r.URL.Path, "/m365/start/") session, err := s.m365Sessions.get(state) if err != nil { - writeJSONError(w, http.StatusNotFound, err) + s.renderErrorPage(w, "Microsoft 365 login link expired or not found", http.StatusNotFound) return } @@ -283,7 +291,7 @@ func (s *Server) m365OAuthConfig(redirectURL string) oauth2.Config { return oauth2.Config{ ClientID: s.m365ClientID, - Endpoint: oauth2.Endpoint{AuthURL: base + "/authorize", TokenURL: base + "/token"}, + Endpoint: oauth2.Endpoint{AuthURL: base + "/authorize", TokenURL: base + "/token", AuthStyle: oauth2.AuthStyleInParams}, RedirectURL: redirectURL, Scopes: []string{"offline_access", "User.Read", "Mail.Read", "Calendars.Read"}, } @@ -381,7 +389,7 @@ func validateM365Email(expected string, actual string) error { want := strings.ToLower(strings.TrimSpace(expected)) got := strings.ToLower(strings.TrimSpace(actual)) if want == "" || got == "" || want != got { - return fmt.Errorf("%w: expected %s got %s", errM365EmailMismatch, want, got) + return fmt.Errorf("%w: email mismatch", errM365EmailMismatch) } return nil diff --git a/auth-server/m365_handlers_test.go b/auth-server/m365_handlers_test.go index 28395e2a..1fcabe5a 100644 --- a/auth-server/m365_handlers_test.go +++ b/auth-server/m365_handlers_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" ) func TestM365SessionsCreatesEnterpriseOneClickURL(t *testing.T) { @@ -89,6 +90,53 @@ func TestM365StartRedirectsToMicrosoftAuthorize(t *testing.T) { } } +func TestM365StartUnknownStateRendersHTML(t *testing.T) { + server := NewServerWithOptions(ServerOptions{ + Store: NewTokenStore(DefaultTTL), + GoogleClientID: "google-client", + GoogleClientSecret: "google-secret", + GoogleRedirectURL: "https://auth.hv.example/callback", + M365Enabled: true, + M365ClientID: "m365-client", + M365TenantID: "hapvida-tenant", + PublicBaseURL: "https://auth.hv.example", + }) + + req := httptest.NewRequest(http.MethodGet, "/m365/start/missing", nil) + rec := httptest.NewRecorder() + server.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") { + t.Fatalf("content-type = %q", rec.Header().Get("Content-Type")) + } +} + +func TestValidateM365EmailDoesNotExposePII(t *testing.T) { + err := validateM365Email("bernardo@hapvida.com.br", "other@hapvida.com.br") + if err == nil { + t.Fatal("expected mismatch") + } + if strings.Contains(err.Error(), "bernardo") || strings.Contains(err.Error(), "other") { + t.Fatalf("PII leaked in error: %v", err) + } +} + +func TestM365SessionStorePrunesExpiredOnSave(t *testing.T) { + store := newM365SessionStore() + store.save(m365Session{State: "expired", ExpectedEmail: "pilot@example.com", ExpiresAt: time.Now().Add(-time.Minute)}) + store.save(m365Session{State: "fresh", ExpectedEmail: "pilot@example.com", ExpiresAt: time.Now().Add(time.Minute)}) + + if _, err := store.get("expired"); err == nil { + t.Fatal("expected expired session to be pruned") + } + if _, err := store.get("fresh"); err != nil { + t.Fatalf("fresh session: %v", err) + } +} + func TestM365SessionsFailClosedWhenServerConfigMissing(t *testing.T) { server := NewServerWithOptions(ServerOptions{ Store: NewTokenStore(DefaultTTL), diff --git a/auth-server/main.go b/auth-server/main.go index 8daf3773..977aed2c 100644 --- a/auth-server/main.go +++ b/auth-server/main.go @@ -80,11 +80,6 @@ func main() { log.Fatal("Public base URL is required for M365 broker (--public-base-url, WK_PUBLIC_BASE_URL, or WK_CALLBACK_SERVER)") } - // Default redirect URL if not specified - if *redirectURL == "" { - *redirectURL = fmt.Sprintf("http://localhost:%d/callback", *port) - } - // Create token store with TTL and start cleanup store := NewTokenStore(*ttl) store.StartCleanup(CleanupInterval) diff --git a/internal/cmd/auth_m365_link.go b/internal/cmd/auth_m365_link.go index 982e1088..87eefeee 100644 --- a/internal/cmd/auth_m365_link.go +++ b/internal/cmd/auth_m365_link.go @@ -48,6 +48,10 @@ func (c *AuthM365LoginLinkCmd) resolveBrokerURLs() (string, string, error) { if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", "", usage("invalid m365 broker --base-url") } + parsedCallback, err := url.Parse(callbackURL) + if err != nil || parsedCallback.Scheme == "" || parsedCallback.Host == "" { + return "", "", usage("invalid m365 broker --callback-url") + } return baseURL, callbackURL, nil } diff --git a/internal/cmd/m365_login_link_test.go b/internal/cmd/m365_login_link_test.go index 7818fc58..60c736ce 100644 --- a/internal/cmd/m365_login_link_test.go +++ b/internal/cmd/m365_login_link_test.go @@ -74,3 +74,15 @@ func TestAuthM365LoginLinkUsesCallbackServerDefaultWhenURLsOmitted(t *testing.T) t.Fatalf("callback url = %q", got.CallbackURL) } } + +func TestAuthM365LoginLinkRejectsInvalidCallbackURL(t *testing.T) { + _ = captureStderr(t, func() { + err := Execute([]string{"auth", "m365", "login-link", "bernardo@hapvida.com.br", "--base-url", "https://auth.hv.example", "--callback-url", "not-a-url"}) + if err == nil { + t.Fatal("expected invalid callback URL failure") + } + if !strings.Contains(err.Error(), "callback-url") { + t.Fatalf("unexpected error: %v", err) + } + }) +} From 0439945d14be03453515c0d66fb94a89e14ff27b Mon Sep 17 00:00:00 2001 From: Genie Automagik Date: Tue, 2 Jun 2026 19:25:36 +0000 Subject: [PATCH 3/3] fix: secure m365 broker session creation --- .deadcode-baseline.txt | 2 + auth-server/Dockerfile | 1 + auth-server/README.md | 2 + auth-server/config.go | 41 ++++++------ auth-server/dockerfile_test.go | 2 +- auth-server/handlers.go | 11 ++-- auth-server/m365.go | 35 +++++++--- auth-server/m365_handlers_test.go | 27 ++++++++ auth-server/main.go | 21 ++++-- auth-server/main_config_test.go | 3 +- internal/cmd/auth_m365_link.go | 88 ++++++++++++++++++++----- internal/cmd/m365_login_link_test.go | 97 +++++++++++++++++++++------- 12 files changed, 251 insertions(+), 79 deletions(-) diff --git a/.deadcode-baseline.txt b/.deadcode-baseline.txt index f8817f1d..c3b0f7f5 100644 --- a/.deadcode-baseline.txt +++ b/.deadcode-baseline.txt @@ -39,6 +39,8 @@ internal/googleauth/oauth_flow_manual_redirect.go:54:6: unreachable func: extrac internal/googleauth/scopes.go:34:6: unreachable func: ScopesForCommands internal/googleauth/scopes.go:72:6: unreachable func: AllScopes internal/googleauth/scopes.go:96:6: unreachable func: knownCommandNames +internal/msauth/broker.go:37:6: unreachable func: CreateBrokerSession +internal/msauth/broker.go:91:6: unreachable func: parseHTTPSURL internal/msauth/broker_store.go:28:6: unreachable func: NewMemoryBrokerStore internal/msauth/broker_store.go:32:29: unreachable func: MemoryBrokerStore.Save internal/msauth/broker_store.go:50:29: unreachable func: MemoryBrokerStore.Consume diff --git a/auth-server/Dockerfile b/auth-server/Dockerfile index 15fac1f3..5e8c75aa 100644 --- a/auth-server/Dockerfile +++ b/auth-server/Dockerfile @@ -17,6 +17,7 @@ COPY --from=build /out/workit-auth-server /app/workit-auth-server ENV WK_PUBLIC_BASE_URL="" ENV WK_M365_CLIENT_ID="" ENV WK_M365_TENANT_ID="organizations" +ENV WK_M365_BROKER_TOKEN="" EXPOSE 8080 USER nonroot:nonroot diff --git a/auth-server/README.md b/auth-server/README.md index 963f0534..69ea11ad 100644 --- a/auth-server/README.md +++ b/auth-server/README.md @@ -59,6 +59,7 @@ When users authenticate via the headless OAuth flow: | `WK_CALLBACK_SERVER` | Backward-compatible public base URL fallback | | `WK_M365_CLIENT_ID` | Microsoft Entra application/client ID for M365 broker | | `WK_M365_TENANT_ID` | Microsoft tenant ID; defaults to `organizations` | +| `WK_M365_BROKER_TOKEN` | Required bearer token for trusted callers creating `/m365/sessions` | Command-line flags take precedence over environment variables. @@ -80,6 +81,7 @@ docker run -p 8080:8080 \ -e WK_PUBLIC_BASE_URL="https://auth.hv.example" \ -e WK_M365_CLIENT_ID="" \ -e WK_M365_TENANT_ID="" \ + -e WK_M365_BROKER_TOKEN="" \ workit-auth-server ``` diff --git a/auth-server/config.go b/auth-server/config.go index 03c02bf7..6f6ec421 100644 --- a/auth-server/config.go +++ b/auth-server/config.go @@ -7,22 +7,24 @@ import ( ) type serverConfigInput struct { - Port int - ClientID string - ClientSecret string - RedirectURL string - PublicBaseURL string - M365ClientID string - M365TenantID string + Port int + ClientID string + ClientSecret string + RedirectURL string + PublicBaseURL string + M365ClientID string + M365TenantID string + M365AdminToken string } type serverConfig struct { - clientID string - clientSecret string - redirectURL string - publicBaseURL string - m365ClientID string - m365TenantID string + clientID string + clientSecret string + redirectURL string + publicBaseURL string + m365ClientID string + m365TenantID string + m365AdminToken string } func resolveServerConfig(input serverConfigInput) serverConfig { @@ -38,12 +40,13 @@ func resolveServerConfig(input serverConfigInput) serverConfig { } return serverConfig{ - clientID: firstNonEmpty(input.ClientID, os.Getenv("WK_CLIENT_ID")), - clientSecret: firstNonEmpty(input.ClientSecret, os.Getenv("WK_CLIENT_SECRET")), - redirectURL: redirectURL, - publicBaseURL: publicBaseURL, - m365ClientID: firstNonEmpty(input.M365ClientID, os.Getenv("WK_M365_CLIENT_ID")), - m365TenantID: firstNonEmpty(input.M365TenantID, os.Getenv("WK_M365_TENANT_ID")), + clientID: firstNonEmpty(input.ClientID, os.Getenv("WK_CLIENT_ID")), + clientSecret: firstNonEmpty(input.ClientSecret, os.Getenv("WK_CLIENT_SECRET")), + redirectURL: redirectURL, + publicBaseURL: publicBaseURL, + m365ClientID: firstNonEmpty(input.M365ClientID, os.Getenv("WK_M365_CLIENT_ID")), + m365TenantID: firstNonEmpty(input.M365TenantID, os.Getenv("WK_M365_TENANT_ID")), + m365AdminToken: firstNonEmpty(input.M365AdminToken, os.Getenv("WK_M365_BROKER_TOKEN"), os.Getenv("WK_BROKER_ADMIN_TOKEN")), } } diff --git a/auth-server/dockerfile_test.go b/auth-server/dockerfile_test.go index d7854720..224c7e59 100644 --- a/auth-server/dockerfile_test.go +++ b/auth-server/dockerfile_test.go @@ -13,7 +13,7 @@ func TestDockerfileDocumentsEnterpriseM365EnvContract(t *testing.T) { } content := string(data) - for _, want := range []string{"WK_PUBLIC_BASE_URL", "WK_M365_CLIENT_ID", "WK_M365_TENANT_ID", "TARGETARCH", "EXPOSE 8080", "workit-auth-server"} { + for _, want := range []string{"WK_PUBLIC_BASE_URL", "WK_M365_CLIENT_ID", "WK_M365_TENANT_ID", "WK_M365_BROKER_TOKEN", "TARGETARCH", "EXPOSE 8080", "workit-auth-server"} { if !strings.Contains(content, want) { t.Fatalf("Dockerfile missing %s", want) } diff --git a/auth-server/handlers.go b/auth-server/handlers.go index 588551ec..4d332e4b 100644 --- a/auth-server/handlers.go +++ b/auth-server/handlers.go @@ -20,11 +20,12 @@ type Server struct { mux *http.ServeMux exchangeFunc func(ctx context.Context, code string) (*oauth2.Token, error) - m365Enabled bool - m365ClientID string - m365TenantID string - publicBaseURL string - m365Sessions *m365SessionStore + m365Enabled bool + m365ClientID string + m365TenantID string + m365AdminToken string + publicBaseURL string + m365Sessions *m365SessionStore } // NewServer creates a new Server with the given configuration. diff --git a/auth-server/m365.go b/auth-server/m365.go index e2bdc98f..71fd2e52 100644 --- a/auth-server/m365.go +++ b/auth-server/m365.go @@ -19,13 +19,15 @@ import ( ) var ( - errM365Disabled = errors.New("m365 broker disabled") - errM365MissingClientID = errors.New("m365 client id missing") - errM365MissingBaseURL = errors.New("m365 public base url missing") - errM365MissingEmail = errors.New("m365 expected email missing") - errM365StateNotFound = errors.New("m365 broker state not found") - errM365StateExpired = errors.New("m365 broker state expired") - errM365EmailMismatch = errors.New("m365 authorized email mismatch") + errM365Disabled = errors.New("m365 broker disabled") + errM365MissingClientID = errors.New("m365 client id missing") + errM365MissingBaseURL = errors.New("m365 public base url missing") + errM365MissingEmail = errors.New("m365 expected email missing") + errM365StateNotFound = errors.New("m365 broker state not found") + errM365StateExpired = errors.New("m365 broker state expired") + errM365EmailMismatch = errors.New("m365 authorized email mismatch") + errM365MissingAdminToken = errors.New("m365 broker admin token missing") + errM365Unauthorized = errors.New("m365 broker session creation unauthorized") ) const defaultM365TenantID = "organizations" @@ -38,6 +40,7 @@ type ServerOptions struct { M365Enabled bool M365ClientID string M365TenantID string + M365AdminToken string PublicBaseURL string } @@ -122,6 +125,7 @@ func NewServerWithOptions(opts ServerOptions) *Server { s := NewServer(store, opts.GoogleClientID, opts.GoogleClientSecret, opts.GoogleRedirectURL) s.m365Enabled = opts.M365Enabled s.m365ClientID = strings.TrimSpace(opts.M365ClientID) + s.m365AdminToken = strings.TrimSpace(opts.M365AdminToken) s.m365TenantID = strings.TrimSpace(opts.M365TenantID) if s.m365TenantID == "" { s.m365TenantID = defaultM365TenantID @@ -144,6 +148,10 @@ func (s *Server) handleM365Sessions(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusInternalServerError, err) return } + if !s.authorizeM365SessionRequest(r) { + writeJSONError(w, http.StatusUnauthorized, errM365Unauthorized) + return + } var req struct { ExpectedEmail string `json:"expected_email"` @@ -251,8 +259,8 @@ func (s *Server) validateM365Config() error { if s.m365ClientID == "" { return errM365MissingClientID } - if s.publicBaseURL == "" { - return errM365MissingBaseURL + if s.m365AdminToken == "" { + return errM365MissingAdminToken } parsed, err := url.Parse(s.publicBaseURL) @@ -263,6 +271,15 @@ func (s *Server) validateM365Config() error { return nil } +func (s *Server) authorizeM365SessionRequest(r *http.Request) bool { + auth := strings.TrimSpace(r.Header.Get("Authorization")) + if !strings.HasPrefix(auth, "Bearer ") { + return false + } + + return strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) == s.m365AdminToken +} + func (s *Server) createM365Session(expectedEmail string, forceConsent bool) (m365Session, error) { state, verifier, challenge, err := newM365StateAndPKCE() if err != nil { diff --git a/auth-server/m365_handlers_test.go b/auth-server/m365_handlers_test.go index 1fcabe5a..4eb4d985 100644 --- a/auth-server/m365_handlers_test.go +++ b/auth-server/m365_handlers_test.go @@ -18,10 +18,12 @@ func TestM365SessionsCreatesEnterpriseOneClickURL(t *testing.T) { M365Enabled: true, M365ClientID: "m365-client", M365TenantID: "hapvida-tenant", + M365AdminToken: "admin-token", PublicBaseURL: "https://auth.hv.example", }) req := httptest.NewRequest(http.MethodPost, "/m365/sessions", strings.NewReader(`{"expected_email":"Bernardo@Hapvida.com.br"}`)) + req.Header.Set("Authorization", "Bearer admin-token") rec := httptest.NewRecorder() server.ServeHTTP(rec, req) @@ -47,6 +49,28 @@ func TestM365SessionsCreatesEnterpriseOneClickURL(t *testing.T) { } } +func TestM365SessionsRequireAdminBearerToken(t *testing.T) { + server := NewServerWithOptions(ServerOptions{ + Store: NewTokenStore(DefaultTTL), + GoogleClientID: "google-client", + GoogleClientSecret: "google-secret", + GoogleRedirectURL: "https://auth.hv.example/callback", + M365Enabled: true, + M365ClientID: "m365-client", + M365TenantID: "hapvida-tenant", + M365AdminToken: "admin-token", + PublicBaseURL: "https://auth.hv.example", + }) + + req := httptest.NewRequest(http.MethodPost, "/m365/sessions", strings.NewReader(`{"expected_email":"bernardo@hapvida.com.br"}`)) + rec := httptest.NewRecorder() + server.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } +} + func TestM365StartRedirectsToMicrosoftAuthorize(t *testing.T) { server := NewServerWithOptions(ServerOptions{ Store: NewTokenStore(DefaultTTL), @@ -56,10 +80,12 @@ func TestM365StartRedirectsToMicrosoftAuthorize(t *testing.T) { M365Enabled: true, M365ClientID: "m365-client", M365TenantID: "hapvida-tenant", + M365AdminToken: "admin-token", PublicBaseURL: "https://auth.hv.example", }) createReq := httptest.NewRequest(http.MethodPost, "/m365/sessions", strings.NewReader(`{"expected_email":"bernardo@hapvida.com.br"}`)) + createReq.Header.Set("Authorization", "Bearer admin-token") createRec := httptest.NewRecorder() server.ServeHTTP(createRec, createReq) @@ -99,6 +125,7 @@ func TestM365StartUnknownStateRendersHTML(t *testing.T) { M365Enabled: true, M365ClientID: "m365-client", M365TenantID: "hapvida-tenant", + M365AdminToken: "admin-token", PublicBaseURL: "https://auth.hv.example", }) diff --git a/auth-server/main.go b/auth-server/main.go index 977aed2c..f9c0fd02 100644 --- a/auth-server/main.go +++ b/auth-server/main.go @@ -33,18 +33,20 @@ func main() { publicBaseURL := flag.String("public-base-url", "", "Public HTTPS base URL for this auth server (env WK_PUBLIC_BASE_URL; defaults redirect URLs when set)") m365ClientID := flag.String("m365-client-id", "", "Microsoft 365 OAuth client ID (env WK_M365_CLIENT_ID)") m365TenantID := flag.String("m365-tenant-id", "", "Microsoft 365 tenant ID (env WK_M365_TENANT_ID; default organizations)") + m365AdminToken := flag.String("m365-broker-token", "", "Admin bearer token required to create Microsoft 365 broker sessions") credentialsFile := flag.String("credentials-file", "", "Path to OAuth credentials JSON file (workit format)") ttl := flag.Duration("ttl", DefaultTTL, "Token time-to-live") flag.Parse() resolved := resolveServerConfig(serverConfigInput{ - Port: *port, - ClientID: *clientID, - ClientSecret: *clientSecret, - RedirectURL: *redirectURL, - PublicBaseURL: *publicBaseURL, - M365ClientID: *m365ClientID, - M365TenantID: *m365TenantID, + Port: *port, + ClientID: *clientID, + ClientSecret: *clientSecret, + RedirectURL: *redirectURL, + PublicBaseURL: *publicBaseURL, + M365ClientID: *m365ClientID, + M365TenantID: *m365TenantID, + M365AdminToken: *m365AdminToken, }) *clientID = resolved.clientID *clientSecret = resolved.clientSecret @@ -52,6 +54,7 @@ func main() { *publicBaseURL = resolved.publicBaseURL *m365ClientID = resolved.m365ClientID *m365TenantID = resolved.m365TenantID + *m365AdminToken = resolved.m365AdminToken // Load credentials from file if specified (fills empty client ID/secret) if *credentialsFile != "" { @@ -79,6 +82,9 @@ func main() { if *m365ClientID != "" && *publicBaseURL == "" { log.Fatal("Public base URL is required for M365 broker (--public-base-url, WK_PUBLIC_BASE_URL, or WK_CALLBACK_SERVER)") } + if *m365ClientID != "" && *m365AdminToken == "" { + log.Fatal("M365 broker admin token is required (--m365-broker-token, WK_M365_BROKER_TOKEN, or WK_BROKER_ADMIN_TOKEN)") + } // Create token store with TTL and start cleanup store := NewTokenStore(*ttl) @@ -94,6 +100,7 @@ func main() { M365Enabled: *m365ClientID != "", M365ClientID: *m365ClientID, M365TenantID: *m365TenantID, + M365AdminToken: *m365AdminToken, PublicBaseURL: *publicBaseURL, }) diff --git a/auth-server/main_config_test.go b/auth-server/main_config_test.go index e2ff2718..6fe8d267 100644 --- a/auth-server/main_config_test.go +++ b/auth-server/main_config_test.go @@ -6,6 +6,7 @@ func TestResolveServerConfigUsesDynamicPublicBaseURL(t *testing.T) { t.Setenv("WK_PUBLIC_BASE_URL", "https://auth.hv.example/") t.Setenv("WK_M365_CLIENT_ID", "m365-client") t.Setenv("WK_M365_TENANT_ID", "tenant-id") + t.Setenv("WK_M365_BROKER_TOKEN", "broker-token") cfg := resolveServerConfig(serverConfigInput{Port: 8080}) @@ -15,7 +16,7 @@ func TestResolveServerConfigUsesDynamicPublicBaseURL(t *testing.T) { if cfg.redirectURL != "https://auth.hv.example/callback" { t.Fatalf("redirectURL = %q", cfg.redirectURL) } - if cfg.m365ClientID != "m365-client" || cfg.m365TenantID != "tenant-id" { + if cfg.m365ClientID != "m365-client" || cfg.m365TenantID != "tenant-id" || cfg.m365AdminToken != "broker-token" { t.Fatalf("m365 config = %#v", cfg) } } diff --git a/internal/cmd/auth_m365_link.go b/internal/cmd/auth_m365_link.go index 87eefeee..71eb8ee1 100644 --- a/internal/cmd/auth_m365_link.go +++ b/internal/cmd/auth_m365_link.go @@ -1,7 +1,11 @@ package cmd import ( + "bytes" "context" + "encoding/json" + "fmt" + "net/http" "net/url" "os" "strings" @@ -13,7 +17,7 @@ import ( "github.com/automagik-dev/workit/internal/ui" ) -var createM365BrokerSession = msauth.CreateBrokerSession +var createM365BrokerSessionOnServer = createRemoteM365BrokerSession type AuthM365Cmd struct { LoginLink AuthM365LoginLinkCmd `cmd:"" name:"login-link" help:"Create a one-click Microsoft 365 read-only login link"` @@ -23,25 +27,63 @@ type AuthM365LoginLinkCmd struct { Email string `arg:"" name:"email" help:"Expected Microsoft 365 account email"` BaseURL string `name:"base-url" help:"Public HTTPS broker base URL, e.g. https://login.workit.ai"` CallbackURL string `name:"callback-url" help:"Public HTTPS Microsoft OAuth callback URL"` + BrokerToken string `name:"broker-token" help:"Bearer token allowed to create sessions on the M365 broker"` TTL time.Duration `name:"ttl" help:"Login link validity duration" default:"10m"` ForceConsent bool `name:"force-consent" help:"Force Microsoft consent screen"` } +type remoteM365BrokerSessionRequest struct { + ExpectedEmail string `json:"expected_email"` + ForceConsent bool `json:"force_consent"` +} + +func createRemoteM365BrokerSession(ctx context.Context, baseURL string, brokerToken string, payload remoteM365BrokerSessionRequest) (msauth.BrokerSession, error) { + body, err := json.Marshal(payload) + if err != nil { + return msauth.BrokerSession{}, fmt.Errorf("encode m365 broker session request: %w", err) + } + + sessionURL := strings.TrimRight(baseURL, "/") + "/m365/sessions" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, sessionURL, bytes.NewReader(body)) + if err != nil { + return msauth.BrokerSession{}, fmt.Errorf("create m365 broker session request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+brokerToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return msauth.BrokerSession{}, fmt.Errorf("create m365 broker session: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return msauth.BrokerSession{}, fmt.Errorf("create m365 broker session: status %d", resp.StatusCode) + } + + var session msauth.BrokerSession + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + return msauth.BrokerSession{}, fmt.Errorf("decode m365 broker session: %w", err) + } + if strings.TrimSpace(session.LoginURL) == "" { + return msauth.BrokerSession{}, fmt.Errorf("decode m365 broker session: missing login_url") + } + + return session, nil +} + func (c *AuthM365LoginLinkCmd) resolveBrokerURLs() (string, string, error) { baseURL := strings.TrimSpace(c.BaseURL) callbackURL := strings.TrimSpace(c.CallbackURL) - if baseURL == "" || callbackURL == "" { - callbackServer, err := googleauth.CallbackServerURL(baseURL) + if baseURL == "" { + callbackServer, err := googleauth.CallbackServerURL("") if err != nil { return "", "", err } - - if baseURL == "" { - baseURL = strings.TrimRight(callbackServer, "/") - } - if callbackURL == "" { - callbackURL = strings.TrimRight(callbackServer, "/") + "/m365/callback" - } + baseURL = strings.TrimRight(callbackServer, "/") + } + if callbackURL == "" { + callbackURL = strings.TrimRight(baseURL, "/") + "/m365/callback" } parsed, err := url.Parse(baseURL) @@ -56,6 +98,21 @@ func (c *AuthM365LoginLinkCmd) resolveBrokerURLs() (string, string, error) { return baseURL, callbackURL, nil } +func (c *AuthM365LoginLinkCmd) resolveBrokerToken() (string, error) { + token := strings.TrimSpace(c.BrokerToken) + if token == "" { + token = strings.TrimSpace(os.Getenv("WK_M365_BROKER_TOKEN")) + } + if token == "" { + token = strings.TrimSpace(os.Getenv("WK_BROKER_ADMIN_TOKEN")) + } + if token == "" { + return "", usage("m365 login-link requires --broker-token or WK_M365_BROKER_TOKEN") + } + + return token, nil +} + func (c *AuthM365LoginLinkCmd) Run(ctx context.Context, _ *RootFlags) error { email := strings.TrimSpace(c.Email) if email == "" { @@ -65,14 +122,15 @@ func (c *AuthM365LoginLinkCmd) Run(ctx context.Context, _ *RootFlags) error { if err != nil { return err } + _ = callbackURL // Validated for Entra redirect configuration parity; server derives it from base URL. + brokerToken, err := c.resolveBrokerToken() + if err != nil { + return err + } - session, err := createM365BrokerSession(ctx, msauth.BrokerSessionOptions{ + session, err := createM365BrokerSessionOnServer(ctx, baseURL, brokerToken, remoteM365BrokerSessionRequest{ ExpectedEmail: email, - BaseURL: baseURL, - CallbackURL: callbackURL, - Readonly: true, ForceConsent: c.ForceConsent, - TTL: c.TTL, }) if err != nil { return err diff --git a/internal/cmd/m365_login_link_test.go b/internal/cmd/m365_login_link_test.go index 60c736ce..c563532d 100644 --- a/internal/cmd/m365_login_link_test.go +++ b/internal/cmd/m365_login_link_test.go @@ -2,6 +2,9 @@ package cmd import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -9,13 +12,17 @@ import ( "github.com/automagik-dev/workit/internal/msauth" ) -func TestAuthM365LoginLinkPrintsOneClickURL(t *testing.T) { - origCreate := createM365BrokerSession - t.Cleanup(func() { createM365BrokerSession = origCreate }) +func TestAuthM365LoginLinkPrintsServerCreatedOneClickURL(t *testing.T) { + origCreate := createM365BrokerSessionOnServer + t.Cleanup(func() { createM365BrokerSessionOnServer = origCreate }) - var got msauth.BrokerSessionOptions - createM365BrokerSession = func(_ context.Context, opts msauth.BrokerSessionOptions) (msauth.BrokerSession, error) { - got = opts + var gotBaseURL string + var gotToken string + var gotPayload remoteM365BrokerSessionRequest + createM365BrokerSessionOnServer = func(_ context.Context, baseURL string, brokerToken string, payload remoteM365BrokerSessionRequest) (msauth.BrokerSession, error) { + gotBaseURL = baseURL + gotToken = brokerToken + gotPayload = payload return msauth.BrokerSession{ State: "state", ExpectedEmail: "bernardo@hapvida.com.br", @@ -26,17 +33,17 @@ func TestAuthM365LoginLinkPrintsOneClickURL(t *testing.T) { out := captureStdout(t, func() { _ = captureStderr(t, func() { - 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 { + 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", "--broker-token", "secret-token"}); err != nil { t.Fatalf("login-link: %v", err) } }) }) - if got.ExpectedEmail != "bernardo@hapvida.com.br" || !got.Readonly { - t.Fatalf("options = %#v", got) + if gotPayload.ExpectedEmail != "bernardo@hapvida.com.br" || gotPayload.ForceConsent { + t.Fatalf("payload = %#v", gotPayload) } - if got.BaseURL != "https://login.workit.ai" || got.CallbackURL != "https://login.workit.ai/m365/callback" { - t.Fatalf("urls = %#v", got) + if gotBaseURL != "https://login.workit.ai" || gotToken != "secret-token" { + t.Fatalf("remote args = base %q token %q", gotBaseURL, gotToken) } if !strings.Contains(out, "https://login.workit.ai/m365/start/state") { t.Fatalf("missing login link: %s", out) @@ -44,13 +51,17 @@ func TestAuthM365LoginLinkPrintsOneClickURL(t *testing.T) { } func TestAuthM365LoginLinkUsesCallbackServerDefaultWhenURLsOmitted(t *testing.T) { - origCreate := createM365BrokerSession - t.Cleanup(func() { createM365BrokerSession = origCreate }) + origCreate := createM365BrokerSessionOnServer + t.Cleanup(func() { createM365BrokerSessionOnServer = origCreate }) t.Setenv("WK_CALLBACK_SERVER", "https://auth.hv.example") + t.Setenv("WK_M365_BROKER_TOKEN", "env-token") - var got msauth.BrokerSessionOptions - createM365BrokerSession = func(_ context.Context, opts msauth.BrokerSessionOptions) (msauth.BrokerSession, error) { - got = opts + var gotBaseURL string + createM365BrokerSessionOnServer = func(_ context.Context, baseURL string, brokerToken string, payload remoteM365BrokerSessionRequest) (msauth.BrokerSession, error) { + gotBaseURL = baseURL + if brokerToken != "env-token" { + t.Fatalf("broker token = %q", brokerToken) + } return msauth.BrokerSession{ State: "state", ExpectedEmail: "bernardo@hapvida.com.br", @@ -67,17 +78,14 @@ func TestAuthM365LoginLinkUsesCallbackServerDefaultWhenURLsOmitted(t *testing.T) }) }) - if got.BaseURL != "https://auth.hv.example" { - t.Fatalf("base url = %q", got.BaseURL) - } - if got.CallbackURL != "https://auth.hv.example/m365/callback" { - t.Fatalf("callback url = %q", got.CallbackURL) + if gotBaseURL != "https://auth.hv.example" { + t.Fatalf("base url = %q", gotBaseURL) } } func TestAuthM365LoginLinkRejectsInvalidCallbackURL(t *testing.T) { _ = captureStderr(t, func() { - err := Execute([]string{"auth", "m365", "login-link", "bernardo@hapvida.com.br", "--base-url", "https://auth.hv.example", "--callback-url", "not-a-url"}) + err := Execute([]string{"auth", "m365", "login-link", "bernardo@hapvida.com.br", "--base-url", "https://auth.hv.example", "--callback-url", "not-a-url", "--broker-token", "token"}) if err == nil { t.Fatal("expected invalid callback URL failure") } @@ -86,3 +94,48 @@ func TestAuthM365LoginLinkRejectsInvalidCallbackURL(t *testing.T) { } }) } + +func TestAuthM365LoginLinkRequiresBrokerToken(t *testing.T) { + _ = captureStderr(t, func() { + err := Execute([]string{"auth", "m365", "login-link", "bernardo@hapvida.com.br", "--base-url", "https://auth.hv.example"}) + if err == nil { + t.Fatal("expected missing broker token failure") + } + if !strings.Contains(err.Error(), "broker-token") { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestCreateRemoteM365BrokerSessionPostsToBrokerWithBearerToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/m365/sessions" { + t.Fatalf("path = %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer secret-token" { + t.Fatalf("authorization = %q", r.Header.Get("Authorization")) + } + var req remoteM365BrokerSessionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.ExpectedEmail != "bernardo@hapvida.com.br" { + t.Fatalf("payload = %#v", req) + } + _ = json.NewEncoder(w).Encode(msauth.BrokerSession{ + State: "state", + ExpectedEmail: "bernardo@hapvida.com.br", + LoginURL: "https://auth.hv.example/m365/start/state", + ExpiresAt: time.Unix(1893456000, 0).UTC(), + }) + })) + defer server.Close() + + session, err := createRemoteM365BrokerSession(context.Background(), server.URL, "secret-token", remoteM365BrokerSessionRequest{ExpectedEmail: "bernardo@hapvida.com.br"}) + if err != nil { + t.Fatalf("create remote session: %v", err) + } + if session.LoginURL != "https://auth.hv.example/m365/start/state" { + t.Fatalf("login url = %q", session.LoginURL) + } +}