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/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..5e8c75aa 100644 --- a/auth-server/Dockerfile +++ b/auth-server/Dockerfile @@ -1,39 +1,24 @@ -# Build stage -FROM golang:1.25-alpine AS builder +# syntax=docker/dockerfile:1.7 -WORKDIR /app +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS build +ARG TARGETARCH +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=${TARGETARCH} 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" +ENV WK_M365_BROKER_TOKEN="" 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..69ea11ad 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,12 @@ 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` | +| `WK_M365_BROKER_TOKEN` | Required bearer token for trusted callers creating `/m365/sessions` | Command-line flags take precedence over environment variables. @@ -68,14 +76,17 @@ 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="" \ + -e WK_M365_BROKER_TOKEN="" \ + workit-auth-server ``` +For Google relay compatibility, also set `WK_CLIENT_ID`, `WK_CLIENT_SECRET`, and optionally `WK_REDIRECT_URL`. + ## Deployment ### Docker Compose Example @@ -92,13 +103,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/config.go b/auth-server/config.go new file mode 100644 index 00000000..6f6ec421 --- /dev/null +++ b/auth-server/config.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +type serverConfigInput struct { + 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 + m365AdminToken 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")), + m365AdminToken: firstNonEmpty(input.M365AdminToken, os.Getenv("WK_M365_BROKER_TOKEN"), os.Getenv("WK_BROKER_ADMIN_TOKEN")), + } +} + +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..224c7e59 --- /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", "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 ab149448..4d332e4b 100644 --- a/auth-server/handlers.go +++ b/auth-server/handlers.go @@ -19,6 +19,13 @@ 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 + 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 new file mode 100644 index 00000000..71fd2e52 --- /dev/null +++ b/auth-server/m365.go @@ -0,0 +1,421 @@ +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") + errM365MissingAdminToken = errors.New("m365 broker admin token missing") + errM365Unauthorized = errors.New("m365 broker session creation unauthorized") +) + +const defaultM365TenantID = "organizations" + +type ServerOptions struct { + Store *TokenStore + GoogleClientID string + GoogleClientSecret string + GoogleRedirectURL string + M365Enabled bool + M365ClientID string + M365TenantID string + M365AdminToken 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) + } + + 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 +} + +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.m365AdminToken = strings.TrimSpace(opts.M365AdminToken) + 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 + } + if !s.authorizeM365SessionRequest(r) { + writeJSONError(w, http.StatusUnauthorized, errM365Unauthorized) + return + } + + var req struct { + 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 + } + + 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 { + s.renderErrorPage(w, "Microsoft 365 login link expired or not found", http.StatusNotFound) + 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.m365AdminToken == "" { + return errM365MissingAdminToken + } + + parsed, err := url.Parse(s.publicBaseURL) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return errM365MissingBaseURL + } + + 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 { + 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", AuthStyle: oauth2.AuthStyleInParams}, + 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: email mismatch", errM365EmailMismatch) + } + + 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..4eb4d985 --- /dev/null +++ b/auth-server/m365_handlers_test.go @@ -0,0 +1,188 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +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", + 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) + + 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 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), + 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", + }) + + 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) + + 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 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", + M365AdminToken: "admin-token", + 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), + 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..f9c0fd02 100644 --- a/auth-server/main.go +++ b/auth-server/main.go @@ -30,20 +30,31 @@ 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)") + 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() - // 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, + M365AdminToken: *m365AdminToken, + }) + *clientID = resolved.clientID + *clientSecret = resolved.clientSecret + *redirectURL = resolved.redirectURL + *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 != "" { @@ -60,17 +71,19 @@ 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)") } - - // Default redirect URL if not specified - if *redirectURL == "" { - *redirectURL = fmt.Sprintf("http://localhost:%d/callback", *port) + 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 @@ -79,7 +92,17 @@ 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, + M365AdminToken: *m365AdminToken, + PublicBaseURL: *publicBaseURL, + }) // Create HTTP server httpServer := &http.Server{ @@ -107,6 +130,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..6fe8d267 --- /dev/null +++ b/auth-server/main_config_test.go @@ -0,0 +1,32 @@ +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") + t.Setenv("WK_M365_BROKER_TOKEN", "broker-token") + + 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" || cfg.m365AdminToken != "broker-token" { + 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..71eb8ee1 100644 --- a/internal/cmd/auth_m365_link.go +++ b/internal/cmd/auth_m365_link.go @@ -1,17 +1,23 @@ package cmd import ( + "bytes" "context" + "encoding/json" + "fmt" + "net/http" + "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" ) -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"` @@ -21,29 +27,110 @@ 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 == "" { + callbackServer, err := googleauth.CallbackServerURL("") + if err != nil { + return "", "", err + } + baseURL = strings.TrimRight(callbackServer, "/") + } + if callbackURL == "" { + callbackURL = strings.TrimRight(baseURL, "/") + "/m365/callback" + } + + parsed, err := url.Parse(baseURL) + 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 +} + +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 == "" { return usage("empty email") } - if strings.TrimSpace(c.BaseURL) == "" { - return usage("m365 login-link requires --base-url") + baseURL, callbackURL, err := c.resolveBrokerURLs() + if err != nil { + return err } - if strings.TrimSpace(c.CallbackURL) == "" { - return usage("m365 login-link requires --callback-url") + _ = 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: c.BaseURL, - CallbackURL: c.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 556ee817..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,31 +33,109 @@ 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) } } -func TestAuthM365LoginLinkRequiresExplicitBrokerURLs(t *testing.T) { +func TestAuthM365LoginLinkUsesCallbackServerDefaultWhenURLsOmitted(t *testing.T) { + 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 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", + 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 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"}) + 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 missing broker URL failure") + t.Fatal("expected invalid callback URL failure") } - if !strings.Contains(err.Error(), "base-url") { + if !strings.Contains(err.Error(), "callback-url") { t.Fatalf("unexpected error: %v", err) } }) } + +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) + } +}