init push
This commit is contained in:
332
internal/service/microsoft_sso.go
Normal file
332
internal/service/microsoft_sso.go
Normal file
@@ -0,0 +1,332 @@
|
||||
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[:])
|
||||
}
|
||||
Reference in New Issue
Block a user