package service import ( "context" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "wucher/internal/config" "wucher/internal/resilience" ) type MicrosoftTokenSet struct { AccessToken string RefreshToken string IDToken string TokenType string Scope string ExpiresIn int64 RefreshTokenExpiresIn int64 } type MicrosoftSSOClient struct { cfg config.MicrosoftSSOConfig client *http.Client executor *resilience.Executor configResolver func(context.Context) (config.MicrosoftSSOConfig, error) } func NewMicrosoftSSO(cfg config.MicrosoftSSOConfig) *MicrosoftSSOClient { return &MicrosoftSSOClient{ cfg: cfg, client: &http.Client{ Timeout: 10 * time.Second, }, } } func (m *MicrosoftSSOClient) WithExecutor(executor *resilience.Executor) *MicrosoftSSOClient { if m == nil { return nil } m.executor = executor return m } func (m *MicrosoftSSOClient) WithConfigResolver(resolver func(context.Context) (config.MicrosoftSSOConfig, error)) *MicrosoftSSOClient { if m == nil { return nil } m.configResolver = resolver return m } func (m *MicrosoftSSOClient) AuthCodeURL(state string) string { cfg, err := m.currentConfig(context.Background()) if err != nil || !isMicrosoftSSOAuthConfigComplete(cfg) { return "" } scopes := ensureMicrosoftScopes(cfg.Scopes) q := url.Values{} q.Set("client_id", cfg.ClientID) q.Set("response_type", "code") q.Set("redirect_uri", cfg.RedirectURL) q.Set("response_mode", "query") q.Set("scope", strings.Join(scopes, " ")) q.Set("state", state) q.Set("prompt", "select_account") return fmt.Sprintf("%s%s/oauth2/v2.0/authorize?%s", cfg.Authority, cfg.TenantID, q.Encode()) } func (m *MicrosoftSSOClient) LogoutURL(postLogoutRedirectURL string) string { cfg, err := m.currentConfig(context.Background()) if err != nil || !isMicrosoftSSOAuthConfigComplete(cfg) { return "" } q := url.Values{} if redirect := strings.TrimSpace(postLogoutRedirectURL); redirect != "" { q.Set("post_logout_redirect_uri", redirect) } base := fmt.Sprintf("%s%s/oauth2/v2.0/logout", cfg.Authority, cfg.TenantID) if encoded := q.Encode(); encoded != "" { return base + "?" + encoded } return base } func (m *MicrosoftSSOClient) ExchangeCode(ctx context.Context, code string) (MicrosoftClaims, error) { _, claims, err := m.ExchangeCodeAndTokens(ctx, code, "") return claims, err } func (m *MicrosoftSSOClient) ExchangeCodeAndTokens(ctx context.Context, code, codeVerifier string) (MicrosoftTokenSet, MicrosoftClaims, error) { set, err := resilience.Do(ctx, m.executor, func(ctx context.Context) (MicrosoftTokenSet, error) { if code == "" { return MicrosoftTokenSet{}, errors.New("missing code") } cfg, err := m.currentConfig(ctx) if err != nil || !isMicrosoftSSOConfigComplete(cfg) { return MicrosoftTokenSet{}, errors.New("sso not configured") } scopes := ensureMicrosoftScopes(cfg.Scopes) form := url.Values{} form.Set("client_id", cfg.ClientID) form.Set("client_secret", cfg.ClientSecret) form.Set("grant_type", "authorization_code") form.Set("code", code) form.Set("redirect_uri", cfg.RedirectURL) form.Set("scope", strings.Join(scopes, " ")) if strings.TrimSpace(codeVerifier) != "" { form.Set("code_verifier", strings.TrimSpace(codeVerifier)) } tokenURL := fmt.Sprintf("%s%s/oauth2/v2.0/token", cfg.Authority, cfg.TenantID) return m.exchangeTokenForm(ctx, tokenURL, form) }) if err != nil { return MicrosoftTokenSet{}, MicrosoftClaims{}, err } claims, err := parseMicrosoftClaimsFromIDToken(set.IDToken) if err != nil { return MicrosoftTokenSet{}, MicrosoftClaims{}, err } return set, claims, nil } func (m *MicrosoftSSOClient) RefreshAccessToken(ctx context.Context, refreshToken, codeVerifier string) (MicrosoftTokenSet, error) { return resilience.Do(ctx, m.executor, func(ctx context.Context) (MicrosoftTokenSet, error) { if strings.TrimSpace(refreshToken) == "" { return MicrosoftTokenSet{}, errors.New("missing refresh token") } cfg, err := m.currentConfig(ctx) if err != nil || !isMicrosoftSSOConfigComplete(cfg) { return MicrosoftTokenSet{}, errors.New("sso not configured") } scopes := ensureMicrosoftScopes(cfg.Scopes) form := url.Values{} form.Set("client_id", cfg.ClientID) form.Set("client_secret", cfg.ClientSecret) form.Set("grant_type", "refresh_token") form.Set("refresh_token", strings.TrimSpace(refreshToken)) form.Set("redirect_uri", cfg.RedirectURL) form.Set("scope", strings.Join(scopes, " ")) if strings.TrimSpace(codeVerifier) != "" { form.Set("code_verifier", strings.TrimSpace(codeVerifier)) } tokenURL := fmt.Sprintf("%s%s/oauth2/v2.0/token", cfg.Authority, cfg.TenantID) return m.exchangeTokenForm(ctx, tokenURL, form) }) } func (m *MicrosoftSSOClient) exchangeTokenForm(ctx context.Context, tokenURL string, form url.Values) (MicrosoftTokenSet, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) if err != nil { return MicrosoftTokenSet{}, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := m.client.Do(req) if err != nil { return MicrosoftTokenSet{}, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { bodyStr := string(body) if resp.StatusCode == http.StatusUnauthorized && strings.Contains(bodyStr, "invalid_client") { return MicrosoftTokenSet{}, fmt.Errorf( "token exchange failed: invalid_client (check MS_ENTRA_CLIENT_SECRET uses secret VALUE, not secret ID): %s", bodyStr, ) } return MicrosoftTokenSet{}, &resilience.HTTPStatusError{ Operation: "token exchange failed", StatusCode: resp.StatusCode, Body: bodyStr, } } var token struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` IDToken string `json:"id_token"` TokenType string `json:"token_type"` Scope string `json:"scope"` ExpiresIn int64 `json:"expires_in"` RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in"` } if err := json.Unmarshal(body, &token); err != nil { return MicrosoftTokenSet{}, err } if strings.TrimSpace(token.IDToken) == "" { return MicrosoftTokenSet{}, errors.New("missing id_token") } if strings.TrimSpace(token.AccessToken) == "" { return MicrosoftTokenSet{}, errors.New("missing access_token") } return MicrosoftTokenSet{ AccessToken: strings.TrimSpace(token.AccessToken), RefreshToken: strings.TrimSpace(token.RefreshToken), IDToken: strings.TrimSpace(token.IDToken), TokenType: strings.TrimSpace(token.TokenType), Scope: strings.TrimSpace(token.Scope), ExpiresIn: token.ExpiresIn, RefreshTokenExpiresIn: token.RefreshTokenExpiresIn, }, nil } func (m *MicrosoftSSOClient) currentConfig(ctx context.Context) (config.MicrosoftSSOConfig, error) { if m == nil { return config.MicrosoftSSOConfig{}, errors.New("sso client is nil") } if m.configResolver == nil { return m.cfg, nil } return m.configResolver(ctx) } func isMicrosoftSSOConfigComplete(cfg config.MicrosoftSSOConfig) bool { return strings.TrimSpace(cfg.TenantID) != "" && strings.TrimSpace(cfg.ClientID) != "" && strings.TrimSpace(cfg.ClientSecret) != "" && strings.TrimSpace(cfg.RedirectURL) != "" && strings.TrimSpace(cfg.Authority) != "" && len(ensureMicrosoftScopes(cfg.Scopes)) > 0 } func IsMicrosoftSSOConfigComplete(cfg config.MicrosoftSSOConfig) bool { return isMicrosoftSSOConfigComplete(cfg) } func isMicrosoftSSOAuthConfigComplete(cfg config.MicrosoftSSOConfig) bool { return strings.TrimSpace(cfg.TenantID) != "" && strings.TrimSpace(cfg.ClientID) != "" && strings.TrimSpace(cfg.RedirectURL) != "" && strings.TrimSpace(cfg.Authority) != "" && len(ensureMicrosoftScopes(cfg.Scopes)) > 0 } func ensureMicrosoftScopes(scopes []string) []string { merged := make([]string, 0, len(scopes)+5) seen := map[string]struct{}{} appendScope := func(v string) { v = strings.TrimSpace(v) if v == "" { return } key := strings.ToLower(v) if _, ok := seen[key]; ok { return } seen[key] = struct{}{} merged = append(merged, v) } for i := range scopes { appendScope(scopes[i]) } appendScope("openid") appendScope("profile") appendScope("email") appendScope("offline_access") appendScope("User.Read") return merged } func parseMicrosoftClaimsFromIDToken(idToken string) (MicrosoftClaims, error) { claimsMap, err := parseJWTClaims(idToken) if err != nil { return MicrosoftClaims{}, err } sub, _ := claimsMap["sub"].(string) email, _ := claimsMap["email"].(string) name, _ := claimsMap["name"].(string) if email == "" { email, _ = claimsMap["preferred_username"].(string) } tid, _ := claimsMap["tid"].(string) oid, _ := claimsMap["oid"].(string) sid, _ := claimsMap["sid"].(string) return MicrosoftClaims{ Subject: strings.TrimSpace(sub), Email: strings.TrimSpace(email), Name: strings.TrimSpace(name), TenantID: strings.TrimSpace(tid), ObjectID: strings.TrimSpace(oid), SessionID: strings.TrimSpace(sid), }, nil } func parseJWTClaims(token string) (map[string]any, error) { parts := strings.Split(token, ".") if len(parts) < 2 { return nil, errors.New("invalid jwt") } payload, err := decodeSegment(parts[1]) if err != nil { return nil, err } var claims map[string]any if err := json.Unmarshal(payload, &claims); err != nil { return nil, err } return claims, nil } func decodeSegment(seg string) ([]byte, error) { seg = strings.ReplaceAll(seg, "-", "+") seg = strings.ReplaceAll(seg, "_", "/") switch len(seg) % 4 { case 2: seg += "==" case 3: seg += "=" } return io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(seg))) } func microsoftPKCEChallengeS256(verifier string) string { h := sha256.Sum256([]byte(strings.TrimSpace(verifier))) return base64.RawURLEncoding.EncodeToString(h[:]) }