-
Notifications
You must be signed in to change notification settings - Fork 24
feat(acrauth): add ACR login helper that writes container auth files #321
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
38ad2fe
9dda67a
61471d2
e6d42e1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| // Copyright 2025 Microsoft Corporation | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package acrauth | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "net/url" | ||
| "time" | ||
|
|
||
| "github.com/Azure/azure-sdk-for-go/sdk/azcore" | ||
| "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" | ||
| "github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry" | ||
| ) | ||
|
|
||
| // NullGUIDUsername is the username ACR expects when the password is a refresh token. | ||
| const NullGUIDUsername = "00000000-0000-0000-0000-000000000000" | ||
|
|
||
| const armScope = "https://management.azure.com/.default" | ||
|
|
||
| // ExchangeForRefreshToken trades an Entra token for an ACR refresh token, which is what | ||
| // container tooling stores as the registry password. | ||
| func ExchangeForRefreshToken(ctx context.Context, cred azcore.TokenCredential, acrFQDN string) (string, error) { | ||
| endpoint, err := url.Parse(fmt.Sprintf("https://%s", acrFQDN)) | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to parse ACR endpoint: %w", err) | ||
| } | ||
|
|
||
| client, err := azcontainerregistry.NewAuthenticationClient(endpoint.String(), nil) | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to create ACR authentication client: %w", err) | ||
| } | ||
|
|
||
| armToken, err := cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{armScope}}) | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to get ARM token: %w", err) | ||
| } | ||
|
|
||
| response, err := client.ExchangeAADAccessTokenForACRRefreshToken( | ||
| ctx, | ||
| azcontainerregistry.PostContentSchemaGrantTypeAccessToken, | ||
| endpoint.Hostname(), | ||
| &azcontainerregistry.AuthenticationClientExchangeAADAccessTokenForACRRefreshTokenOptions{ | ||
| AccessToken: &armToken.Token, | ||
| }, | ||
| ) | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to exchange AAD access token for ACR refresh token: %w", err) | ||
| } | ||
| if response.RefreshToken == nil || *response.RefreshToken == "" { | ||
| return "", errors.New("got an empty response when exchanging AAD access token for ACR refresh token") | ||
| } | ||
|
|
||
| return *response.RefreshToken, nil | ||
| } | ||
|
|
||
| const ( | ||
| retryAttempts = 5 | ||
| retryInitial = 2 * time.Second | ||
| retryFactor = 2 | ||
| ) | ||
|
|
||
| // ExchangeForRefreshTokenWithRetry retries the exchange, which fails transiently while a | ||
| // freshly-granted role assignment propagates. | ||
| func ExchangeForRefreshTokenWithRetry(ctx context.Context, cred azcore.TokenCredential, acrFQDN string) (string, error) { | ||
| var lastErr error | ||
| delay := retryInitial | ||
|
|
||
| for attempt := range retryAttempts { | ||
|
weherdh marked this conversation as resolved.
|
||
| if attempt > 0 { | ||
| select { | ||
| case <-ctx.Done(): | ||
| return "", fmt.Errorf("failed to exchange ACR refresh token: %w: last exchange error: %w", ctx.Err(), lastErr) | ||
| case <-time.After(delay): | ||
| } | ||
| delay *= retryFactor | ||
| } | ||
|
|
||
| token, err := ExchangeForRefreshToken(ctx, cred, acrFQDN) | ||
| if err == nil { | ||
| return token, nil | ||
| } | ||
| lastErr = err | ||
| } | ||
|
|
||
| return "", fmt.Errorf("failed to exchange ACR refresh token after %d attempts: %w", retryAttempts, lastErr) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| // Copyright 2025 Microsoft Corporation | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package acrauth | ||
|
|
||
| import ( | ||
| "encoding/base64" | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "io/fs" | ||
| "os" | ||
| "path/filepath" | ||
| ) | ||
|
|
||
| // authFileMode keeps the credential readable only by its owner. | ||
| const authFileMode fs.FileMode = 0o600 | ||
|
|
||
| // UpsertCredential adds or replaces the entry for one registry in a container auth file, | ||
| // leaving every other registry and any fields we don't model untouched. | ||
| func UpsertCredential(path, registry, username, password string) error { | ||
| if registry == "" { | ||
| return errors.New("registry must not be empty") | ||
| } | ||
|
|
||
| document, err := readAuthFile(path) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| auths, ok := document["auths"].(map[string]any) | ||
| if !ok { | ||
| auths = map[string]any{} | ||
| } | ||
|
|
||
| entry, ok := auths[registry].(map[string]any) | ||
| if !ok { | ||
| entry = map[string]any{} | ||
| } | ||
| entry["auth"] = base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This preserves an existing |
||
|
|
||
| auths[registry] = entry | ||
| document["auths"] = auths | ||
|
|
||
| return writeAuthFile(path, document) | ||
| } | ||
|
|
||
| func readAuthFile(path string) (map[string]any, error) { | ||
| raw, err := os.ReadFile(path) | ||
| if errors.Is(err, fs.ErrNotExist) { | ||
| return map[string]any{}, nil | ||
| } | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to read auth file %q: %w", path, err) | ||
| } | ||
| if len(raw) == 0 { | ||
| return map[string]any{}, nil | ||
| } | ||
|
|
||
| var document map[string]any | ||
| if err := json.Unmarshal(raw, &document); err != nil { | ||
| return nil, fmt.Errorf("failed to parse auth file %q: %w", path, err) | ||
| } | ||
| if document == nil { | ||
| document = map[string]any{} | ||
| } | ||
| return document, nil | ||
| } | ||
|
|
||
| // writeAuthFile writes through a temporary file in the same directory so a failure part-way | ||
| // through cannot leave the tool holding a truncated credential file. A bind-mounted auth file | ||
| // cannot be renamed over, so that path falls back to a non-atomic in-place write. | ||
| func writeAuthFile(path string, document map[string]any) error { | ||
| raw, err := json.MarshalIndent(document, "", " ") | ||
| if err != nil { | ||
| return fmt.Errorf("failed to serialize auth file: %w", err) | ||
| } | ||
|
|
||
| dir := filepath.Dir(path) | ||
| if err := os.MkdirAll(dir, 0o755); err != nil { | ||
| return fmt.Errorf("failed to create directory %q: %w", dir, err) | ||
| } | ||
|
|
||
| // CreateTemp opens with mode 0600, so the credential is never briefly world-readable. | ||
| tmp, err := os.CreateTemp(dir, ".auth-*.json") | ||
| if err != nil { | ||
| return fmt.Errorf("failed to create temporary auth file: %w", err) | ||
| } | ||
| defer func() { | ||
| _ = os.Remove(tmp.Name()) | ||
| }() | ||
|
|
||
| if _, err := tmp.Write(raw); err != nil { | ||
| _ = tmp.Close() | ||
| return fmt.Errorf("failed to write temporary auth file: %w", err) | ||
| } | ||
| if err := tmp.Close(); err != nil { | ||
| return fmt.Errorf("failed to close temporary auth file: %w", err) | ||
| } | ||
|
|
||
| if err := os.Rename(tmp.Name(), path); err != nil { | ||
| // A bind-mounted auth file cannot be renamed over (EBUSY), so write through it instead. | ||
| if fallbackErr := os.WriteFile(path, raw, authFileMode); fallbackErr != nil { | ||
| return fmt.Errorf("failed to replace auth file %q: %w: in-place write also failed: %w", path, err, fallbackErr) | ||
| } | ||
| // WriteFile only applies the mode when it creates the file, so an existing one keeps its own. | ||
| if chmodErr := os.Chmod(path, authFileMode); chmodErr != nil { | ||
| return fmt.Errorf("failed to restrict permissions on auth file %q: %w", path, chmodErr) | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All five new Go files have CRLF line endings.
gofmt -l tools/acrauth/*.golists every file, andgo tool golangci-lint run ./tools/acrauth/...fails thegciformatter check. Please run the repository formatter and commit the LF-normalized files so the verify job can pass.