333 lines
10 KiB
Go
333 lines
10 KiB
Go
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[:])
|
|
}
|