A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
1package main
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "os"
10 "time"
11)
12
13// Device authorization API types
14
15type DeviceCodeRequest struct {
16 DeviceName string `json:"device_name"`
17}
18
19type DeviceCodeResponse struct {
20 DeviceCode string `json:"device_code"`
21 UserCode string `json:"user_code"`
22 VerificationURI string `json:"verification_uri"`
23 ExpiresIn int `json:"expires_in"`
24 Interval int `json:"interval"`
25}
26
27type DeviceTokenRequest struct {
28 DeviceCode string `json:"device_code"`
29}
30
31type DeviceTokenResponse struct {
32 DeviceSecret string `json:"device_secret,omitempty"`
33 Handle string `json:"handle,omitempty"`
34 DID string `json:"did,omitempty"`
35 Error string `json:"error,omitempty"`
36}
37
38// AuthErrorResponse is the JSON error response from /auth/token
39type AuthErrorResponse struct {
40 Error string `json:"error"`
41 Message string `json:"message"`
42 LoginURL string `json:"login_url,omitempty"`
43}
44
45// ValidationResult represents the result of credential validation
46type ValidationResult struct {
47 Valid bool
48 OAuthSessionExpired bool
49 LoginURL string
50}
51
52// requestDeviceCode requests a device code from the AppView.
53// Returns the code response and resolved AppView URL.
54// Does not print anything — the caller controls UX.
55func requestDeviceCode(serverURL string) (*DeviceCodeResponse, string, error) {
56 appViewURL := buildAppViewURL(serverURL)
57 deviceName := hostname()
58
59 reqBody, _ := json.Marshal(DeviceCodeRequest{DeviceName: deviceName})
60 resp, err := http.Post(appViewURL+"/auth/device/code", "application/json", bytes.NewReader(reqBody))
61 if err != nil {
62 return nil, appViewURL, fmt.Errorf("failed to request device code: %w", err)
63 }
64 defer resp.Body.Close()
65
66 if resp.StatusCode != http.StatusOK {
67 body, _ := io.ReadAll(resp.Body)
68 return nil, appViewURL, fmt.Errorf("device code request failed: %s", string(body))
69 }
70
71 var codeResp DeviceCodeResponse
72 if err := json.NewDecoder(resp.Body).Decode(&codeResp); err != nil {
73 return nil, appViewURL, fmt.Errorf("failed to decode device code response: %w", err)
74 }
75
76 return &codeResp, appViewURL, nil
77}
78
79// pollDeviceToken polls the token endpoint until authorization completes.
80// Does not print anything — the caller controls UX.
81// Returns the account on success, or an error on timeout/failure.
82func pollDeviceToken(appViewURL string, codeResp *DeviceCodeResponse) (*Account, error) {
83 pollInterval := time.Duration(codeResp.Interval) * time.Second
84 timeout := time.Duration(codeResp.ExpiresIn) * time.Second
85 deadline := time.Now().Add(timeout)
86
87 for time.Now().Before(deadline) {
88 time.Sleep(pollInterval)
89
90 tokenReqBody, _ := json.Marshal(DeviceTokenRequest{DeviceCode: codeResp.DeviceCode})
91 tokenResp, err := http.Post(appViewURL+"/auth/device/token", "application/json", bytes.NewReader(tokenReqBody))
92 if err != nil {
93 continue
94 }
95
96 var tokenResult DeviceTokenResponse
97 if err := json.NewDecoder(tokenResp.Body).Decode(&tokenResult); err != nil {
98 tokenResp.Body.Close()
99 continue
100 }
101 tokenResp.Body.Close()
102
103 if tokenResult.Error == "authorization_pending" {
104 continue
105 }
106
107 if tokenResult.Error != "" {
108 return nil, fmt.Errorf("authorization failed: %s", tokenResult.Error)
109 }
110
111 return &Account{
112 Handle: tokenResult.Handle,
113 DID: tokenResult.DID,
114 DeviceSecret: tokenResult.DeviceSecret,
115 }, nil
116 }
117
118 return nil, fmt.Errorf("authorization timed out")
119}
120
121// validateCredentials checks if the credentials are still valid by making a test request
122func validateCredentials(appViewURL, handle, deviceSecret string) ValidationResult {
123 client := &http.Client{
124 Timeout: 5 * time.Second,
125 }
126
127 tokenURL := appViewURL + "/auth/token?service=" + appViewURL
128
129 req, err := http.NewRequest("GET", tokenURL, nil)
130 if err != nil {
131 return ValidationResult{Valid: false}
132 }
133
134 req.SetBasicAuth(handle, deviceSecret)
135
136 resp, err := client.Do(req)
137 if err != nil {
138 // Network error — assume credentials are valid but server unreachable
139 return ValidationResult{Valid: true}
140 }
141 defer resp.Body.Close()
142
143 if resp.StatusCode == http.StatusOK {
144 return ValidationResult{Valid: true}
145 }
146
147 if resp.StatusCode == http.StatusUnauthorized {
148 body, err := io.ReadAll(resp.Body)
149 if err == nil {
150 var authErr AuthErrorResponse
151 if json.Unmarshal(body, &authErr) == nil && authErr.Error == "oauth_session_expired" {
152 return ValidationResult{
153 Valid: false,
154 OAuthSessionExpired: true,
155 LoginURL: authErr.LoginURL,
156 }
157 }
158 }
159 return ValidationResult{Valid: false}
160 }
161
162 // Any other error = assume valid (don't re-auth on server issues)
163 return ValidationResult{Valid: true}
164}
165
166// hostname returns the machine hostname, or a fallback.
167func hostname() string {
168 name, err := os.Hostname()
169 if err != nil {
170 return "Unknown Device"
171 }
172 return name
173}