|
| 1 | +// Copyright (c) Microsoft Corporation. |
| 2 | +// Licensed under the MIT license. |
| 3 | + |
| 4 | +package integration |
| 5 | + |
| 6 | +import ( |
| 7 | + "context" |
| 8 | + "crypto" |
| 9 | + "crypto/x509" |
| 10 | + "encoding/json" |
| 11 | + "fmt" |
| 12 | + "net/http" |
| 13 | + "os" |
| 14 | + "sync" |
| 15 | + |
| 16 | + "github.com/AzureAD/microsoft-authentication-library-for-go/apps/confidential" |
| 17 | +) |
| 18 | + |
| 19 | +// Key Vault URLs |
| 20 | +const ( |
| 21 | + msidLabVault = "https://msidlabs.vault.azure.net" |
| 22 | + msalTeamVault = "https://id4skeyvault.vault.azure.net" |
| 23 | +) |
| 24 | + |
| 25 | +// Authentication constants |
| 26 | +const ( |
| 27 | + microsoftAuthorityHost = "https://login.microsoftonline.com/" |
| 28 | + microsoftAuthority = microsoftAuthorityHost + "microsoft.onmicrosoft.com" |
| 29 | + organizationsAuthority = microsoftAuthorityHost + "organizations/" |
| 30 | + |
| 31 | + msIDlabDefaultScope = "https://request.msidlab.com/.default" |
| 32 | + graphDefaultScope = "https://graph.windows.net/.default" |
| 33 | + |
| 34 | + defaultClientId = "f62c5ae3-bf3a-4af5-afa8-a68b800396e9" |
| 35 | + |
| 36 | + pemFile = "../../../cert.pem" |
| 37 | + ccaPemFile = "../../../ccaCert.pem" |
| 38 | +) |
| 39 | + |
| 40 | +// Key Vault secret names - user configs |
| 41 | +const ( |
| 42 | + UserPublicCloud = "User-PublicCloud-Config" |
| 43 | + UserFedDefault = "User-Federated-Config" |
| 44 | + UserB2C = "MSAL-USER-B2C-JSON" |
| 45 | + UserArlington = "MSAL-USER-Arlington-JSON" |
| 46 | + UserCIAM = "MSAL-USER-CIAM-JSON" |
| 47 | +) |
| 48 | + |
| 49 | +// Key Vault secret names - app configs |
| 50 | +const ( |
| 51 | + AppPCAClient = "App-PCAClient-Config" |
| 52 | + AppWebAPI = "App-WebAPI-Config" |
| 53 | + AppS2S = "App-S2S-Config" |
| 54 | +) |
| 55 | + |
| 56 | +// UserConfig represents user configuration from Key Vault |
| 57 | +type UserConfig struct { |
| 58 | + AppID string `json:"appId,omitempty"` |
| 59 | + ObjectID string `json:"objectId,omitempty"` |
| 60 | + UserType string `json:"userType,omitempty"` |
| 61 | + DisplayName string `json:"displayName,omitempty"` |
| 62 | + Licenses string `json:"licenses,omitempty"` |
| 63 | + Upn string `json:"upn,omitempty"` |
| 64 | + MFA string `json:"mfa,omitempty"` |
| 65 | + ProtectionPolicy string `json:"protectionPolicy,omitempty"` |
| 66 | + HomeDomain string `json:"homeDomain,omitempty"` |
| 67 | + HomeUPN string `json:"homeUPN,omitempty"` |
| 68 | + B2CProvider string `json:"b2cProvider,omitempty"` |
| 69 | + LabName string `json:"labName,omitempty"` |
| 70 | + LastUpdatedBy string `json:"lastUpdatedBy,omitempty"` |
| 71 | + LastUpdatedDate string `json:"lastUpdatedDate,omitempty"` |
| 72 | + TenantID string `json:"tenantId,omitempty"` |
| 73 | + FederationProvider string `json:"federationProvider,omitempty"` |
| 74 | + password string // cached password, fetched lazily |
| 75 | +} |
| 76 | + |
| 77 | +// AppConfig represents app configuration from Key Vault |
| 78 | +type AppConfig struct { |
| 79 | + AppType string `json:"appType,omitempty"` |
| 80 | + AppName string `json:"appName,omitempty"` |
| 81 | + AppID string `json:"appId,omitempty"` |
| 82 | + RedirectURI string `json:"redirectUri,omitempty"` |
| 83 | + Authority string `json:"authority,omitempty"` |
| 84 | + LabName string `json:"labName,omitempty"` |
| 85 | + ClientSecret string `json:"clientSecret,omitempty"` |
| 86 | + SecretName string `json:"secretName,omitempty"` |
| 87 | +} |
| 88 | + |
| 89 | +// labResponse is the container for JSON from Key Vault |
| 90 | +type labResponse struct { |
| 91 | + User *UserConfig `json:"user,omitempty"` |
| 92 | + App *AppConfig `json:"app,omitempty"` |
| 93 | +} |
| 94 | + |
| 95 | +// Package-level cache and Key Vault clients |
| 96 | +var ( |
| 97 | + userCache = make(map[string]*UserConfig) |
| 98 | + appCache = make(map[string]*AppConfig) |
| 99 | + cacheMu sync.RWMutex |
| 100 | + |
| 101 | + msidClient confidential.Client |
| 102 | + msalClient confidential.Client |
| 103 | + clientInitMu sync.Once |
| 104 | + |
| 105 | + httpClient = http.Client{} |
| 106 | +) |
| 107 | + |
| 108 | +type labClient struct { |
| 109 | + app confidential.Client |
| 110 | +} |
| 111 | + |
| 112 | +func newLabClient() (*labClient, error) { |
| 113 | + cert, privateKey, err := getCertDataFromFile(pemFile) |
| 114 | + if err != nil { |
| 115 | + return nil, fmt.Errorf("could not get cert data: %w", err) |
| 116 | + } |
| 117 | + |
| 118 | + cred, err := confidential.NewCredFromCert(cert, privateKey) |
| 119 | + if err != nil { |
| 120 | + return nil, fmt.Errorf("could not create a cred from the cert: %w", err) |
| 121 | + } |
| 122 | + |
| 123 | + app, err := confidential.New(microsoftAuthority, defaultClientId, cred, confidential.WithX5C()) |
| 124 | + if err != nil { |
| 125 | + return nil, err |
| 126 | + } |
| 127 | + |
| 128 | + return &labClient{app: app}, nil |
| 129 | +} |
| 130 | + |
| 131 | +func (l *labClient) labAccessToken() (string, error) { |
| 132 | + scopes := []string{msIDlabDefaultScope} |
| 133 | + result, err := l.app.AcquireTokenSilent(context.Background(), scopes) |
| 134 | + if err != nil { |
| 135 | + result, err = l.app.AcquireTokenByCredential(context.Background(), scopes) |
| 136 | + if err != nil { |
| 137 | + return "", fmt.Errorf("AcquireTokenByCredential() error: %w", err) |
| 138 | + } |
| 139 | + } |
| 140 | + return result.AccessToken, nil |
| 141 | +} |
| 142 | + |
| 143 | +// initKeyVaultClients initializes the Key Vault access clients using cert auth |
| 144 | +func initKeyVaultClients() error { |
| 145 | + var initErr error |
| 146 | + clientInitMu.Do(func() { |
| 147 | + cert, privateKey, err := getCertDataFromFile(pemFile) |
| 148 | + if err != nil { |
| 149 | + initErr = fmt.Errorf("failed to load cert: %w", err) |
| 150 | + return |
| 151 | + } |
| 152 | + |
| 153 | + cred, err := confidential.NewCredFromCert(cert, privateKey) |
| 154 | + if err != nil { |
| 155 | + initErr = fmt.Errorf("failed to create cert credential: %w", err) |
| 156 | + return |
| 157 | + } |
| 158 | + |
| 159 | + // Client for MSID Lab vault (passwords) |
| 160 | + msidClient, err = confidential.New( |
| 161 | + microsoftAuthority, |
| 162 | + defaultClientId, |
| 163 | + cred, |
| 164 | + confidential.WithX5C(), |
| 165 | + ) |
| 166 | + if err != nil { |
| 167 | + initErr = fmt.Errorf("failed to create MSID client: %w", err) |
| 168 | + return |
| 169 | + } |
| 170 | + |
| 171 | + // Client for MSAL Team vault (configs) - same client works for both |
| 172 | + msalClient = msidClient |
| 173 | + }) |
| 174 | + return initErr |
| 175 | +} |
| 176 | + |
| 177 | +// GetSecret retrieves a secret from Key Vault by name |
| 178 | +func GetSecret(ctx context.Context, vaultURL, secretName string) (string, error) { |
| 179 | + if err := initKeyVaultClients(); err != nil { |
| 180 | + return "", err |
| 181 | + } |
| 182 | + |
| 183 | + scope := vaultURL + "/.default" |
| 184 | + result, err := msalClient.AcquireTokenSilent(ctx, []string{scope}) |
| 185 | + if err != nil { |
| 186 | + result, err = msalClient.AcquireTokenByCredential(ctx, []string{scope}) |
| 187 | + if err != nil { |
| 188 | + return "", fmt.Errorf("failed to acquire Key Vault token: %w", err) |
| 189 | + } |
| 190 | + } |
| 191 | + |
| 192 | + // Use Azure SDK or direct REST call to get secret |
| 193 | + // For simplicity, showing the pattern - you'll need to add the actual Key Vault SDK call |
| 194 | + secretURL := fmt.Sprintf("%s/secrets/%s?api-version=7.4", vaultURL, secretName) |
| 195 | + |
| 196 | + req, err := http.NewRequestWithContext(ctx, "GET", secretURL, nil) |
| 197 | + if err != nil { |
| 198 | + return "", err |
| 199 | + } |
| 200 | + req.Header.Set("Authorization", "Bearer "+result.AccessToken) |
| 201 | + |
| 202 | + resp, err := httpClient.Do(req) |
| 203 | + if err != nil { |
| 204 | + return "", fmt.Errorf("key Vault request failed: %w", err) |
| 205 | + } |
| 206 | + defer resp.Body.Close() |
| 207 | + |
| 208 | + if resp.StatusCode != 200 { |
| 209 | + return "", fmt.Errorf("key Vault returned status %d", resp.StatusCode) |
| 210 | + } |
| 211 | + |
| 212 | + var kvResp struct { |
| 213 | + Value string `json:"value"` |
| 214 | + } |
| 215 | + if err := json.NewDecoder(resp.Body).Decode(&kvResp); err != nil { |
| 216 | + return "", err |
| 217 | + } |
| 218 | + |
| 219 | + return kvResp.Value, nil |
| 220 | +} |
| 221 | + |
| 222 | +// GetUserConfig retrieves and caches user configuration from Key Vault |
| 223 | +func GetUserConfig(secretName string) (*UserConfig, error) { |
| 224 | + cacheMu.RLock() |
| 225 | + if user, ok := userCache[secretName]; ok { |
| 226 | + cacheMu.RUnlock() |
| 227 | + return user, nil |
| 228 | + } |
| 229 | + cacheMu.RUnlock() |
| 230 | + |
| 231 | + ctx := context.Background() |
| 232 | + secretValue, err := GetSecret(ctx, msalTeamVault, secretName) |
| 233 | + if err != nil { |
| 234 | + return nil, fmt.Errorf("failed to get user config %s: %w", secretName, err) |
| 235 | + } |
| 236 | + |
| 237 | + var resp labResponse |
| 238 | + if err := json.Unmarshal([]byte(secretValue), &resp); err != nil { |
| 239 | + return nil, fmt.Errorf("failed to parse user config: %w", err) |
| 240 | + } |
| 241 | + |
| 242 | + if resp.User == nil { |
| 243 | + return nil, fmt.Errorf("no user data in secret %s", secretName) |
| 244 | + } |
| 245 | + |
| 246 | + cacheMu.Lock() |
| 247 | + userCache[secretName] = resp.User |
| 248 | + cacheMu.Unlock() |
| 249 | + |
| 250 | + return resp.User, nil |
| 251 | +} |
| 252 | + |
| 253 | +// GetAppConfig retrieves and caches app configuration from Key Vault |
| 254 | +func GetAppConfig(secretName string) (*AppConfig, error) { |
| 255 | + cacheMu.RLock() |
| 256 | + if app, ok := appCache[secretName]; ok { |
| 257 | + cacheMu.RUnlock() |
| 258 | + return app, nil |
| 259 | + } |
| 260 | + cacheMu.RUnlock() |
| 261 | + |
| 262 | + ctx := context.Background() |
| 263 | + secretValue, err := GetSecret(ctx, msalTeamVault, secretName) |
| 264 | + if err != nil { |
| 265 | + return nil, fmt.Errorf("failed to get app config %s: %w", secretName, err) |
| 266 | + } |
| 267 | + |
| 268 | + var resp labResponse |
| 269 | + if err := json.Unmarshal([]byte(secretValue), &resp); err != nil { |
| 270 | + return nil, fmt.Errorf("failed to parse app config: %w", err) |
| 271 | + } |
| 272 | + |
| 273 | + if resp.App == nil { |
| 274 | + return nil, fmt.Errorf("no app data in secret %s", secretName) |
| 275 | + } |
| 276 | + |
| 277 | + cacheMu.Lock() |
| 278 | + appCache[secretName] = resp.App |
| 279 | + cacheMu.Unlock() |
| 280 | + |
| 281 | + return resp.App, nil |
| 282 | +} |
| 283 | + |
| 284 | +// GetPassword retrieves the user's password from MSID Lab Key Vault |
| 285 | +// This is a method on UserConfig for convenience, fetched lazily |
| 286 | +func (u *UserConfig) GetPassword() (string, error) { |
| 287 | + if u.password != "" { |
| 288 | + return u.password, nil |
| 289 | + } |
| 290 | + |
| 291 | + if u.LabName == "" { |
| 292 | + return "", fmt.Errorf("user has no lab name for password lookup") |
| 293 | + } |
| 294 | + |
| 295 | + ctx := context.Background() |
| 296 | + password, err := GetSecret(ctx, msidLabVault, u.LabName) |
| 297 | + if err != nil { |
| 298 | + return "", fmt.Errorf("failed to get password for %s: %w", u.LabName, err) |
| 299 | + } |
| 300 | + |
| 301 | + u.password = password |
| 302 | + return password, nil |
| 303 | +} |
| 304 | + |
| 305 | +// getCertDataFromFile loads certificate and private key from a PEM file |
| 306 | +func getCertDataFromFile(filePath string) ([]*x509.Certificate, crypto.PrivateKey, error) { |
| 307 | + data, err := os.ReadFile(filePath) |
| 308 | + if err != nil { |
| 309 | + return nil, nil, fmt.Errorf("error reading certificate file: %w", err) |
| 310 | + } |
| 311 | + |
| 312 | + cert, privateKey, err := confidential.CertFromPEM(data, "") |
| 313 | + if err != nil { |
| 314 | + return nil, nil, fmt.Errorf("error parsing certificate: %w", err) |
| 315 | + } |
| 316 | + |
| 317 | + return cert, privateKey, nil |
| 318 | +} |
0 commit comments