mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-20 16:40:29 +00:00
174 lines
4.8 KiB
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
|
|
}
|