···22# Triggers on version tags and builds cross-platform binaries using buildah
3344when:
55- - event: ["manual"]
55+ - event: ["push"]
66 tag: ["v*"]
7788-engine: "nixery"
99-1010-dependencies:
1111- nixpkgs:
1212- - buildah
1313- - gnugrep # Required for tag detection
88+engine: "kubernetes"
1491510environment:
1611 IMAGE_REGISTRY: atcr.io
1712 IMAGE_USER: evan.jarrett.net
18131914steps:
2020- - name: Get tag for current commit
2121- command: |
2222- # Fetch tags (shallow clone doesn't include them by default)
2323- git fetch --tags
2424-2525- # Find the tag that points to the current commit
2626- TAG=$(git tag --points-at HEAD | grep -E '^v[0-9]' | head -n1)
2727-2828- if [ -z "$TAG" ]; then
2929- echo "Error: No version tag found for current commit"
3030- echo "Available tags:"
3131- git tag
3232- echo "Current commit:"
3333- git rev-parse HEAD
3434- exit 1
3535- fi
3636-3737- echo "Building version: $TAG"
3838- echo "$TAG" > .version
39154016 - name: Setup build environment
4117 command: |
···53295430 - name: Build and push AppView image
5531 command: |
5656- TAG=$(cat .version)
5757-3232+ echo ${TANGLED_REF_NAME}
5833 buildah bud \
5934 --storage-driver vfs \
6060- --tag ${IMAGE_REGISTRY}/${IMAGE_USER}/atcr-appview:${TAG} \
3535+ --tag ${IMAGE_REGISTRY}/${IMAGE_USER}/atcr-appview:${TANGLED_REF_NAME} \
6136 --tag ${IMAGE_REGISTRY}/${IMAGE_USER}/atcr-appview:latest \
6237 --file ./Dockerfile.appview \
6338 .
···68436944 - name: Build and push Hold image
7045 command: |
7171- TAG=$(cat .version)
7272-4646+ echo ${TANGLED_REF_NAME}
7347 buildah bud \
7448 --storage-driver vfs \
7575- --tag ${IMAGE_REGISTRY}/${IMAGE_USER}/atcr-hold:${TAG} \
4949+ --tag ${IMAGE_REGISTRY}/${IMAGE_USER}/atcr-hold:${TANGLED_REF_NAME} \
7650 --tag ${IMAGE_REGISTRY}/${IMAGE_USER}/atcr-hold:latest \
7751 --file ./Dockerfile.hold \
7852 .
+4
cmd/appview/serve.go
···409409 // Basic Auth token endpoint (supports device secrets and app passwords)
410410 tokenHandler := token.NewHandler(issuer, deviceStore)
411411412412+ // Register OAuth session checker for device auth validation
413413+ // This ensures device secrets only work when the linked OAuth session exists
414414+ tokenHandler.SetOAuthSessionChecker(oauthStore)
415415+412416 // Register token post-auth callback for profile management
413417 // This decouples the token package from AppView-specific dependencies
414418 tokenHandler.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error {
+79-6
cmd/credential-helper/main.go
···6767 Error string `json:"error,omitempty"`
6868}
69697070+// AuthErrorResponse is the JSON error response from /auth/token
7171+type AuthErrorResponse struct {
7272+ Error string `json:"error"`
7373+ Message string `json:"message"`
7474+ LoginURL string `json:"login_url,omitempty"`
7575+}
7676+7777+// ValidationResult represents the result of credential validation
7878+type ValidationResult struct {
7979+ Valid bool
8080+ OAuthSessionExpired bool
8181+ LoginURL string
8282+}
8383+7084var (
7185 version = "dev"
7286 commit = "none"
···123137124138 // If credentials exist, validate them
125139 if found && deviceConfig.DeviceSecret != "" {
126126- if !validateCredentials(appViewURL, deviceConfig.Handle, deviceConfig.DeviceSecret) {
140140+ result := validateCredentials(appViewURL, deviceConfig.Handle, deviceConfig.DeviceSecret)
141141+ if !result.Valid {
142142+ if result.OAuthSessionExpired {
143143+ // OAuth session expired - need to re-authenticate via browser
144144+ // Device secret is still valid, just need to restore OAuth session
145145+ fmt.Fprintf(os.Stderr, "OAuth session expired. Opening browser to re-authenticate...\n")
146146+147147+ loginURL := result.LoginURL
148148+ if loginURL == "" {
149149+ loginURL = appViewURL + "/auth/oauth/login"
150150+ }
151151+152152+ // Try to open browser
153153+ if err := openBrowser(loginURL); err != nil {
154154+ fmt.Fprintf(os.Stderr, "Could not open browser automatically.\n")
155155+ fmt.Fprintf(os.Stderr, "Please visit: %s\n", loginURL)
156156+ } else {
157157+ fmt.Fprintf(os.Stderr, "Please complete authentication in your browser.\n")
158158+ }
159159+160160+ // Wait for user to complete OAuth flow, then retry
161161+ fmt.Fprintf(os.Stderr, "Waiting for authentication")
162162+ for i := 0; i < 60; i++ { // Wait up to 2 minutes
163163+ time.Sleep(2 * time.Second)
164164+ fmt.Fprintf(os.Stderr, ".")
165165+166166+ // Retry validation
167167+ retryResult := validateCredentials(appViewURL, deviceConfig.Handle, deviceConfig.DeviceSecret)
168168+ if retryResult.Valid {
169169+ fmt.Fprintf(os.Stderr, "\n✓ Re-authenticated successfully!\n")
170170+ goto credentialsValid
171171+ }
172172+ }
173173+ fmt.Fprintf(os.Stderr, "\nAuthentication timed out. Please try again.\n")
174174+ os.Exit(1)
175175+ }
176176+177177+ // Generic auth failure - delete credentials and re-authorize
127178 fmt.Fprintf(os.Stderr, "Stored credentials for %s are invalid or expired\n", appViewURL)
128179 // Delete the invalid credentials
129180 delete(allCreds.Credentials, appViewURL)
···134185 found = false
135186 }
136187 }
188188+credentialsValid:
137189138190 if !found || deviceConfig.DeviceSecret == "" {
139191 // No credentials for this AppView
···550602}
551603552604// validateCredentials checks if the credentials are still valid by making a test request
553553-func validateCredentials(appViewURL, handle, deviceSecret string) bool {
605605+func validateCredentials(appViewURL, handle, deviceSecret string) ValidationResult {
554606 // Call /auth/token to validate device secret and get JWT
555607 // This is the proper way to validate credentials - /v2/ requires JWT, not Basic Auth
556608 client := &http.Client{
···562614563615 req, err := http.NewRequest("GET", tokenURL, nil)
564616 if err != nil {
565565- return false
617617+ return ValidationResult{Valid: false}
566618 }
567619568620 // Set basic auth with device credentials
···572624 if err != nil {
573625 // Network error - assume credentials are valid but server unreachable
574626 // Don't trigger re-auth on network issues
575575- return true
627627+ return ValidationResult{Valid: true}
576628 }
577629 defer resp.Body.Close()
578630579631 // 200 = valid credentials
580580- // 401 = invalid/expired credentials
632632+ if resp.StatusCode == http.StatusOK {
633633+ return ValidationResult{Valid: true}
634634+ }
635635+636636+ // 401 = check if it's OAuth session expired
637637+ if resp.StatusCode == http.StatusUnauthorized {
638638+ // Try to parse JSON error response
639639+ body, err := io.ReadAll(resp.Body)
640640+ if err == nil {
641641+ var authErr AuthErrorResponse
642642+ if json.Unmarshal(body, &authErr) == nil && authErr.Error == "oauth_session_expired" {
643643+ return ValidationResult{
644644+ Valid: false,
645645+ OAuthSessionExpired: true,
646646+ LoginURL: authErr.LoginURL,
647647+ }
648648+ }
649649+ }
650650+ // Generic auth failure
651651+ return ValidationResult{Valid: false}
652652+ }
653653+581654 // Any other error = assume valid (don't re-auth on server issues)
582582- return resp.StatusCode == http.StatusOK
655655+ return ValidationResult{Valid: true}
583656}
+14
pkg/appview/db/oauth_store.go
···212212 return &sessionData, sessionID, nil
213213}
214214215215+// HasSessionForDID checks if an OAuth session exists for the given DID
216216+// This is a lightweight check used by the token handler to verify device auth
217217+func (s *OAuthStore) HasSessionForDID(ctx context.Context, did string) bool {
218218+ var count int
219219+ err := s.db.QueryRowContext(ctx, `
220220+ SELECT COUNT(*) FROM oauth_sessions WHERE account_did = ?
221221+ `, did).Scan(&count)
222222+ if err != nil {
223223+ slog.Debug("Failed to check session existence", "did", did, "error", err)
224224+ return false
225225+ }
226226+ return count > 0
227227+}
228228+215229// CleanupOldSessions removes sessions older than the specified duration
216230func (s *OAuthStore) CleanupOldSessions(ctx context.Context, olderThan time.Duration) error {
217231 cutoff := time.Now().Add(-olderThan)
+31
pkg/appview/storage/manifest_store.go
···143143 isManifestList := strings.Contains(manifestRecord.MediaType, "manifest.list") ||
144144 strings.Contains(manifestRecord.MediaType, "image.index")
145145146146+ // Validate manifest list child references
147147+ // Reject manifest lists that reference non-existent child manifests
148148+ // This matches Docker Hub/ECR behavior and prevents users from accidentally pushing
149149+ // manifest lists where the underlying images don't exist
150150+ if isManifestList {
151151+ for _, ref := range manifestRecord.Manifests {
152152+ // Check if referenced manifest exists in user's PDS
153153+ refDigest, err := digest.Parse(ref.Digest)
154154+ if err != nil {
155155+ return "", fmt.Errorf("invalid digest in manifest list: %s", ref.Digest)
156156+ }
157157+158158+ exists, err := s.Exists(ctx, refDigest)
159159+ if err != nil {
160160+ return "", fmt.Errorf("failed to check manifest reference: %w", err)
161161+ }
162162+163163+ if !exists {
164164+ platform := "unknown"
165165+ if ref.Platform != nil {
166166+ platform = fmt.Sprintf("%s/%s", ref.Platform.OS, ref.Platform.Architecture)
167167+ }
168168+ slog.Warn("Manifest list references non-existent child manifest",
169169+ "repository", s.ctx.Repository,
170170+ "missingDigest", ref.Digest,
171171+ "platform", platform)
172172+ return "", distribution.ErrManifestBlobUnknown{Digest: refDigest}
173173+ }
174174+ }
175175+ }
176176+146177 if !isManifestList && s.blobStore != nil && manifestRecord.Config != nil && manifestRecord.Config.Digest != "" {
147178 labels, err := s.extractConfigLabels(ctx, manifestRecord.Config.Digest)
148179 if err != nil {