Files
at-container-registry/cmd/credential-helper/device_auth.go

174 lines
4.8 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
// Device authorization API types
type DeviceCodeRequest struct {
DeviceName string `json:"device_name"`
}
type DeviceCodeResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
type DeviceTokenRequest struct {
DeviceCode string `json:"device_code"`
}
type DeviceTokenResponse struct {
DeviceSecret string `json:"device_secret,omitempty"`
Handle string `json:"handle,omitempty"`
DID string `json:"did,omitempty"`
Error string `json:"error,omitempty"`
}
// AuthErrorResponse is the JSON error response from /auth/token
type AuthErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
LoginURL string `json:"login_url,omitempty"`
}
// ValidationResult represents the result of credential validation
type ValidationResult struct {
Valid bool
OAuthSessionExpired bool
LoginURL string
}
// requestDeviceCode requests a device code from the AppView.
// Returns the code response and resolved AppView URL.
// Does not print anything — the caller controls UX.
func requestDeviceCode(serverURL string) (*DeviceCodeResponse, string, error) {
appViewURL := buildAppViewURL(serverURL)
deviceName := hostname()
reqBody, _ := json.Marshal(DeviceCodeRequest{DeviceName: deviceName})
resp, err := http.Post(appViewURL+"/auth/device/code", "application/json", bytes.NewReader(reqBody))
if err != nil {
return nil, appViewURL, fmt.Errorf("failed to request device code: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, appViewURL, fmt.Errorf("device code request failed: %s", string(body))
}
var codeResp DeviceCodeResponse
if err := json.NewDecoder(resp.Body).Decode(&codeResp); err != nil {
return nil, appViewURL, fmt.Errorf("failed to decode device code response: %w", err)
}
return &codeResp, appViewURL, nil
}
// pollDeviceToken polls the token endpoint until authorization completes.
// Does not print anything — the caller controls UX.
// Returns the account on success, or an error on timeout/failure.
func pollDeviceToken(appViewURL string, codeResp *DeviceCodeResponse) (*Account, error) {
pollInterval := time.Duration(codeResp.Interval) * time.Second
timeout := time.Duration(codeResp.ExpiresIn) * time.Second
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
time.Sleep(pollInterval)
tokenReqBody, _ := json.Marshal(DeviceTokenRequest{DeviceCode: codeResp.DeviceCode})
tokenResp, err := http.Post(appViewURL+"/auth/device/token", "application/json", bytes.NewReader(tokenReqBody))
if err != nil {
continue
}
var tokenResult DeviceTokenResponse
if err := json.NewDecoder(tokenResp.Body).Decode(&tokenResult); err != nil {
tokenResp.Body.Close()
continue
}
tokenResp.Body.Close()
if tokenResult.Error == "authorization_pending" {
continue
}
if tokenResult.Error != "" {
return nil, fmt.Errorf("authorization failed: %s", tokenResult.Error)
}
return &Account{
Handle: tokenResult.Handle,
DID: tokenResult.DID,
DeviceSecret: tokenResult.DeviceSecret,
}, nil
}
return nil, fmt.Errorf("authorization timed out")
}
// validateCredentials checks if the credentials are still valid by making a test request
func validateCredentials(appViewURL, handle, deviceSecret string) ValidationResult {
client := &http.Client{
Timeout: 5 * time.Second,
}
tokenURL := appViewURL + "/auth/token?service=" + appViewURL
req, err := http.NewRequest("GET", tokenURL, nil)
if err != nil {
return ValidationResult{Valid: false}
}
req.SetBasicAuth(handle, deviceSecret)
resp, err := client.Do(req)
if err != nil {
// Network error — assume credentials are valid but server unreachable
return ValidationResult{Valid: true}
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return ValidationResult{Valid: true}
}
if resp.StatusCode == http.StatusUnauthorized {
body, err := io.ReadAll(resp.Body)
if err == nil {
var authErr AuthErrorResponse
if json.Unmarshal(body, &authErr) == nil && authErr.Error == "oauth_session_expired" {
return ValidationResult{
Valid: false,
OAuthSessionExpired: true,
LoginURL: authErr.LoginURL,
}
}
}
return ValidationResult{Valid: false}
}
// Any other error = assume valid (don't re-auth on server issues)
return ValidationResult{Valid: true}
}
// hostname returns the machine hostname, or a fallback.
func hostname() string {
name, err := os.Hostname()
if err != nil {
return "Unknown Device"
}
return name
}