Skip to content

Commit 830764e

Browse files
fix: harden m365 auth server broker
1 parent c77c9f1 commit 830764e

8 files changed

Lines changed: 81 additions & 16 deletions

File tree

auth-server/Dockerfile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
# syntax=docker/dockerfile:1.7
22

3-
FROM golang:1.25-alpine AS build
3+
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS build
4+
ARG TARGETARCH
45
WORKDIR /src
56

67
COPY go.mod go.sum ./
78
RUN go mod download
89

910
COPY . ./
10-
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/workit-auth-server .
11+
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /out/workit-auth-server .
1112

1213
FROM gcr.io/distroless/static-debian12:nonroot
1314
WORKDIR /app

auth-server/README.md

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,10 @@ services:
101101
- WK_CLIENT_SECRET=${WK_CLIENT_SECRET}
102102
- WK_REDIRECT_URL=https://auth.example.com/callback
103103
restart: unless-stopped
104-
healthcheck:
105-
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"]
106-
interval: 30s
107-
timeout: 3s
108-
retries: 3
109104
```
110105
106+
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.
107+
111108
### Reverse Proxy (nginx)
112109

113110
```nginx

auth-server/dockerfile_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ func TestDockerfileDocumentsEnterpriseM365EnvContract(t *testing.T) {
1313
}
1414

1515
content := string(data)
16-
for _, want := range []string{"WK_PUBLIC_BASE_URL", "WK_M365_CLIENT_ID", "WK_M365_TENANT_ID", "EXPOSE 8080", "workit-auth-server"} {
16+
for _, want := range []string{"WK_PUBLIC_BASE_URL", "WK_M365_CLIENT_ID", "WK_M365_TENANT_ID", "TARGETARCH", "EXPOSE 8080", "workit-auth-server"} {
1717
if !strings.Contains(content, want) {
1818
t.Fatalf("Dockerfile missing %s", want)
1919
}

auth-server/m365.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ func (s *m365SessionStore) save(session m365Session) {
6666
s.sessions = make(map[string]m365Session)
6767
}
6868

69+
now := time.Now()
70+
for state, existing := range s.sessions {
71+
if !existing.ExpiresAt.IsZero() && now.After(existing.ExpiresAt) {
72+
delete(s.sessions, state)
73+
}
74+
}
75+
6976
s.sessions[session.State] = session
7077
}
7178

@@ -142,6 +149,7 @@ func (s *Server) handleM365Sessions(w http.ResponseWriter, r *http.Request) {
142149
ExpectedEmail string `json:"expected_email"`
143150
ForceConsent bool `json:"force_consent"`
144151
}
152+
r.Body = http.MaxBytesReader(w, r.Body, 4096)
145153
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
146154
writeJSONError(w, http.StatusBadRequest, err)
147155
return
@@ -180,7 +188,7 @@ func (s *Server) handleM365Start(w http.ResponseWriter, r *http.Request) {
180188
state := strings.TrimPrefix(r.URL.Path, "/m365/start/")
181189
session, err := s.m365Sessions.get(state)
182190
if err != nil {
183-
writeJSONError(w, http.StatusNotFound, err)
191+
s.renderErrorPage(w, "Microsoft 365 login link expired or not found", http.StatusNotFound)
184192
return
185193
}
186194

@@ -283,7 +291,7 @@ func (s *Server) m365OAuthConfig(redirectURL string) oauth2.Config {
283291

284292
return oauth2.Config{
285293
ClientID: s.m365ClientID,
286-
Endpoint: oauth2.Endpoint{AuthURL: base + "/authorize", TokenURL: base + "/token"},
294+
Endpoint: oauth2.Endpoint{AuthURL: base + "/authorize", TokenURL: base + "/token", AuthStyle: oauth2.AuthStyleInParams},
287295
RedirectURL: redirectURL,
288296
Scopes: []string{"offline_access", "User.Read", "Mail.Read", "Calendars.Read"},
289297
}
@@ -381,7 +389,7 @@ func validateM365Email(expected string, actual string) error {
381389
want := strings.ToLower(strings.TrimSpace(expected))
382390
got := strings.ToLower(strings.TrimSpace(actual))
383391
if want == "" || got == "" || want != got {
384-
return fmt.Errorf("%w: expected %s got %s", errM365EmailMismatch, want, got)
392+
return fmt.Errorf("%w: email mismatch", errM365EmailMismatch)
385393
}
386394

387395
return nil

auth-server/m365_handlers_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/http/httptest"
77
"strings"
88
"testing"
9+
"time"
910
)
1011

1112
func TestM365SessionsCreatesEnterpriseOneClickURL(t *testing.T) {
@@ -89,6 +90,53 @@ func TestM365StartRedirectsToMicrosoftAuthorize(t *testing.T) {
8990
}
9091
}
9192

93+
func TestM365StartUnknownStateRendersHTML(t *testing.T) {
94+
server := NewServerWithOptions(ServerOptions{
95+
Store: NewTokenStore(DefaultTTL),
96+
GoogleClientID: "google-client",
97+
GoogleClientSecret: "google-secret",
98+
GoogleRedirectURL: "https://auth.hv.example/callback",
99+
M365Enabled: true,
100+
M365ClientID: "m365-client",
101+
M365TenantID: "hapvida-tenant",
102+
PublicBaseURL: "https://auth.hv.example",
103+
})
104+
105+
req := httptest.NewRequest(http.MethodGet, "/m365/start/missing", nil)
106+
rec := httptest.NewRecorder()
107+
server.ServeHTTP(rec, req)
108+
109+
if rec.Code != http.StatusNotFound {
110+
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
111+
}
112+
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
113+
t.Fatalf("content-type = %q", rec.Header().Get("Content-Type"))
114+
}
115+
}
116+
117+
func TestValidateM365EmailDoesNotExposePII(t *testing.T) {
118+
err := validateM365Email("bernardo@hapvida.com.br", "other@hapvida.com.br")
119+
if err == nil {
120+
t.Fatal("expected mismatch")
121+
}
122+
if strings.Contains(err.Error(), "bernardo") || strings.Contains(err.Error(), "other") {
123+
t.Fatalf("PII leaked in error: %v", err)
124+
}
125+
}
126+
127+
func TestM365SessionStorePrunesExpiredOnSave(t *testing.T) {
128+
store := newM365SessionStore()
129+
store.save(m365Session{State: "expired", ExpectedEmail: "pilot@example.com", ExpiresAt: time.Now().Add(-time.Minute)})
130+
store.save(m365Session{State: "fresh", ExpectedEmail: "pilot@example.com", ExpiresAt: time.Now().Add(time.Minute)})
131+
132+
if _, err := store.get("expired"); err == nil {
133+
t.Fatal("expected expired session to be pruned")
134+
}
135+
if _, err := store.get("fresh"); err != nil {
136+
t.Fatalf("fresh session: %v", err)
137+
}
138+
}
139+
92140
func TestM365SessionsFailClosedWhenServerConfigMissing(t *testing.T) {
93141
server := NewServerWithOptions(ServerOptions{
94142
Store: NewTokenStore(DefaultTTL),

auth-server/main.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,6 @@ func main() {
8080
log.Fatal("Public base URL is required for M365 broker (--public-base-url, WK_PUBLIC_BASE_URL, or WK_CALLBACK_SERVER)")
8181
}
8282

83-
// Default redirect URL if not specified
84-
if *redirectURL == "" {
85-
*redirectURL = fmt.Sprintf("http://localhost:%d/callback", *port)
86-
}
87-
8883
// Create token store with TTL and start cleanup
8984
store := NewTokenStore(*ttl)
9085
store.StartCleanup(CleanupInterval)

internal/cmd/auth_m365_link.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ func (c *AuthM365LoginLinkCmd) resolveBrokerURLs() (string, string, error) {
4848
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
4949
return "", "", usage("invalid m365 broker --base-url")
5050
}
51+
parsedCallback, err := url.Parse(callbackURL)
52+
if err != nil || parsedCallback.Scheme == "" || parsedCallback.Host == "" {
53+
return "", "", usage("invalid m365 broker --callback-url")
54+
}
5155

5256
return baseURL, callbackURL, nil
5357
}

internal/cmd/m365_login_link_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,15 @@ func TestAuthM365LoginLinkUsesCallbackServerDefaultWhenURLsOmitted(t *testing.T)
7474
t.Fatalf("callback url = %q", got.CallbackURL)
7575
}
7676
}
77+
78+
func TestAuthM365LoginLinkRejectsInvalidCallbackURL(t *testing.T) {
79+
_ = captureStderr(t, func() {
80+
err := Execute([]string{"auth", "m365", "login-link", "bernardo@hapvida.com.br", "--base-url", "https://auth.hv.example", "--callback-url", "not-a-url"})
81+
if err == nil {
82+
t.Fatal("expected invalid callback URL failure")
83+
}
84+
if !strings.Contains(err.Error(), "callback-url") {
85+
t.Fatalf("unexpected error: %v", err)
86+
}
87+
})
88+
}

0 commit comments

Comments
 (0)