Repository: osbuild/osbuild-composer
Pull request #1475 (2021-06-15 17:48:35)

rpm_ostree_stage: Minor fixes to comments
Just a drive by commit while I was reading the code.



Patch:
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index aee608c30..c84e46989 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -132,9 +132,6 @@ New OSTree:
     - libvirt.sh
     - aws.sh
     - azure.sh
-    - api.sh azure
-    - api.sh aws
-    - api.sh gcp
     - vmware.sh
 
 Integration:
@@ -165,6 +162,42 @@ Integration:
         INTERNAL_NETWORK: ["true"]
         DISTRO_CODE: ["rhel_90"]
 
+
+.API_TESTS: &API_TESTS
+  TARGET:
+    - azure
+    - aws
+    - gcp
+    - aws.s3
+
+API:
+  stage: test
+  extends: .terraform
+  variables:
+    EXTRA_REPO_PATH_SEGMENT: "gitlab/"
+  script:
+    - schutzbot/deploy.sh
+    - /usr/libexec/tests/osbuild-composer/api.sh ${TARGET}
+  parallel:
+    matrix:
+      - <<: *API_TESTS
+        RUNNER:
+          - aws/fedora-32-x86_64
+          - aws/fedora-33-x86_64
+          # See COMPOSER-919
+          # - aws/fedora-34-x86_64
+          - openstack/centos-stream-8-x86_64
+      - <<: *API_TESTS
+        RUNNER:
+          - aws/rhel-8-x86_64
+          - aws/rhel-8.4-x86_64
+        INTERNAL_NETWORK: ["true"]
+      - <<: *API_TESTS
+        RUNNER:
+          - aws/rhel-8-x86_64
+        INTERNAL_NETWORK: ["true"]
+        DISTRO_CODE: ["rhel_90"]
+
 finish:
   stage: finish
   tags:
diff --git a/cmd/osbuild-composer/composer.go b/cmd/osbuild-composer/composer.go
index eafc7c5a5..ec8e7e8b6 100644
--- a/cmd/osbuild-composer/composer.go
+++ b/cmd/osbuild-composer/composer.go
@@ -68,7 +68,7 @@ func NewComposer(config *ComposerConfigFile, stateDir, cacheDir string, logger *
 		return nil, fmt.Errorf("cannot create jobqueue: %v", err)
 	}
 
-	c.workers = worker.NewServer(c.logger, jobs, artifactsDir)
+	c.workers = worker.NewServer(c.logger, jobs, artifactsDir, c.config.WorkerAPI.IdentityFilter)
 
 	return &c, nil
 }
@@ -135,17 +135,21 @@ func (c *Composer) InitLocalWorker(l net.Listener) {
 }
 
 func (c *Composer) InitRemoteWorkers(cert, key string, l net.Listener) error {
-	tlsConfig, err := createTLSConfig(&connectionConfig{
-		CACertFile:     c.config.Worker.CA,
-		ServerKeyFile:  key,
-		ServerCertFile: cert,
-		AllowedDomains: c.config.Worker.AllowedDomains,
-	})
-	if err != nil {
-		return fmt.Errorf("Error creating TLS configuration for remote worker API: %v", err)
-	}
+	if len(c.config.WorkerAPI.IdentityFilter) > 0 {
+		c.workerListener = l
+	} else {
+		tlsConfig, err := createTLSConfig(&connectionConfig{
+			CACertFile:     c.config.Worker.CA,
+			ServerKeyFile:  key,
+			ServerCertFile: cert,
+			AllowedDomains: c.config.Worker.AllowedDomains,
+		})
+		if err != nil {
+			return fmt.Errorf("Error creating TLS configuration for remote worker API: %v", err)
+		}
 
-	c.workerListener = tls.NewListener(l, tlsConfig)
+		c.workerListener = tls.NewListener(l, tlsConfig)
+	}
 
 	return nil
 }
diff --git a/cmd/osbuild-composer/config.go b/cmd/osbuild-composer/config.go
index f77af1384..6a2f0c9f5 100644
--- a/cmd/osbuild-composer/config.go
+++ b/cmd/osbuild-composer/config.go
@@ -18,6 +18,9 @@ type ComposerConfigFile struct {
 	ComposerAPI struct {
 		IdentityFilter []string `toml:"identity_filter"`
 	} `toml:"composer_api"`
+	WorkerAPI struct {
+		IdentityFilter []string `toml:"identity_filter"`
+	} `toml:"worker_api"`
 }
 
 func LoadConfig(name string) (*ComposerConfigFile, error) {
diff --git a/cmd/osbuild-worker/jobimpl-osbuild-koji.go b/cmd/osbuild-worker/jobimpl-osbuild-koji.go
index e23ee46fb..c3d50249e 100644
--- a/cmd/osbuild-worker/jobimpl-osbuild-koji.go
+++ b/cmd/osbuild-worker/jobimpl-osbuild-koji.go
@@ -85,15 +85,27 @@ func (impl *OSBuildKojiJobImpl) Run(job worker.Job) error {
 		return err
 	}
 
-	exports := []string{"assembler"} // NOTE: Koji API doesn't support new image types yet
 	if initArgs.KojiError == "" {
+		exports := args.Exports
+		if len(exports) == 0 {
+			// job did not define exports, likely coming from an older version of composer
+			// fall back to default "assembler"
+			exports = []string{"assembler"}
+		} else if len(exports) > 1 {
+			// this worker only supports returning one (1) export
+			return fmt.Errorf("at most one build artifact can be exported")
+		}
 		result.OSBuildOutput, err = RunOSBuild(args.Manifest, impl.Store, outputDirectory, exports, os.Stderr)
 		if err != nil {
 			return err
 		}
 
+		// NOTE: Currently OSBuild supports multiple exports, but this isn't used
+		// by any of the image types and it can't be specified during the request.
+		// Use the first (and presumably only) export for the imagePath.
+		exportPath := exports[0]
 		if result.OSBuildOutput.Success {
-			f, err := os.Open(path.Join(outputDirectory, args.ImageName))
+			f, err := os.Open(path.Join(outputDirectory, exportPath, args.ImageName))
 			if err != nil {
 				return err
 			}
diff --git a/cmd/osbuild-worker/jobimpl-osbuild.go b/cmd/osbuild-worker/jobimpl-osbuild.go
index 17bb7522e..a9b1e4c00 100644
--- a/cmd/osbuild-worker/jobimpl-osbuild.go
+++ b/cmd/osbuild-worker/jobimpl-osbuild.go
@@ -219,7 +219,7 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 				key = uuid.New().String()
 			}
 
-			_, err = a.Upload(path.Join(outputDirectory, options.Filename), options.Bucket, key)
+			_, err = a.Upload(path.Join(outputDirectory, exportPath, options.Filename), options.Bucket, key)
 			if err != nil {
 				appendTargetError(osbuildJobResult, err)
 				return nil
@@ -241,6 +241,34 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 				Region: options.Region,
 			}))
 
+			osbuildJobResult.Success = true
+			osbuildJobResult.UploadStatus = "success"
+		case *target.AWSS3TargetOptions:
+			a, err := awsupload.New(options.Region, options.AccessKeyID, options.SecretAccessKey)
+			if err != nil {
+				appendTargetError(osbuildJobResult, err)
+				return nil
+			}
+
+			key := options.Key
+			if key == "" {
+				key = uuid.New().String()
+			}
+			key += "-" + options.Filename
+
+			_, err = a.Upload(path.Join(outputDirectory, exportPath, options.Filename), options.Bucket, key)
+			if err != nil {
+				appendTargetError(osbuildJobResult, err)
+				return nil
+			}
+			url, err := a.S3ObjectPresignedURL(options.Bucket, key)
+			if err != nil {
+				appendTargetError(osbuildJobResult, err)
+				return nil
+			}
+
+			osbuildJobResult.TargetResults = append(osbuildJobResult.TargetResults, target.NewAWSS3TargetResult(&target.AWSS3TargetResultOptions{URL: url}))
+
 			osbuildJobResult.Success = true
 			osbuildJobResult.UploadStatus = "success"
 		case *target.AzureTargetOptions:
@@ -259,7 +287,7 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 			const azureMaxUploadGoroutines = 4
 			err = azureStorageClient.UploadPageBlob(
 				metadata,
-				path.Join(outputDirectory, options.Filename),
+				path.Join(outputDirectory, exportPath, options.Filename),
 				azureMaxUploadGoroutines,
 			)
 
@@ -280,7 +308,7 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 			}
 
 			log.Printf("[GCP] 🚀 Uploading image to: %s/%s", options.Bucket, options.Object)
-			_, err = g.StorageObjectUpload(ctx, path.Join(outputDirectory, options.Filename),
+			_, err = g.StorageObjectUpload(ctx, path.Join(outputDirectory, exportPath, options.Filename),
 				options.Bucket, options.Object, map[string]string{gcp.MetadataKeyImageName: args.Targets[0].ImageName})
 			if err != nil {
 				appendTargetError(osbuildJobResult, err)
@@ -421,7 +449,7 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 					ContainerName:  storageContainer,
 					BlobName:       blobName,
 				},
-				path.Join(outputDirectory, options.Filename),
+				path.Join(outputDirectory, exportPath, options.Filename),
 				azure.DefaultUploadThreads,
 			)
 			if err != nil {
diff --git a/cmd/osbuild-worker/main.go b/cmd/osbuild-worker/main.go
index 574b2aa56..61a15ff60 100644
--- a/cmd/osbuild-worker/main.go
+++ b/cmd/osbuild-worker/main.go
@@ -15,6 +15,7 @@ import (
 
 	"github.com/BurntSushi/toml"
 
+	"github.com/osbuild/osbuild-composer/internal/common"
 	"github.com/osbuild/osbuild-composer/internal/upload/azure"
 	"github.com/osbuild/osbuild-composer/internal/upload/koji"
 	"github.com/osbuild/osbuild-composer/internal/worker"
@@ -90,12 +91,16 @@ func main() {
 		Azure *struct {
 			Credentials string `toml:"credentials"`
 		} `toml:"azure"`
+		Authentication *struct {
+			OAuthURL         string `toml:"oauth_url"`
+			OfflineTokenPath string `toml:"offline_token"`
+		} `toml:"authentication"`
 	}
 	var unix bool
 	flag.BoolVar(&unix, "unix", false, "Interpret 'address' as a path to a unix domain socket instead of a network address")
 
 	flag.Usage = func() {
-		fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [-unix] address\n", os.Args[0])
+		fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [-unix] address basepath\n", os.Args[0])
 		flag.PrintDefaults()
 		os.Exit(0)
 	}
@@ -142,6 +147,21 @@ func main() {
 	var client *worker.Client
 	if unix {
 		client = worker.NewClientUnix(address)
+	} else if config.Authentication != nil && config.Authentication.OfflineTokenPath != "" {
+		t, err := ioutil.ReadFile(config.Authentication.OfflineTokenPath)
+		if err != nil {
+			log.Fatalf("Could not read offline token: %v", err)
+		}
+		token := string(t)
+
+		if config.Authentication.OAuthURL == "" {
+			log.Fatal("OAuth URL should be specified together with the offline token")
+		}
+
+		client, err = worker.NewClient("https://"+address, nil, &token, &config.Authentication.OAuthURL)
+		if err != nil {
+			log.Fatalf("Error creating worker client: %v", err)
+		}
 	} else {
 		conf, err := createTLSConfig(&connectionConfig{
 			CACertFile:     "/etc/osbuild-composer/ca-crt.pem",
@@ -152,7 +172,7 @@ func main() {
 			log.Fatalf("Error creating TLS config: %v", err)
 		}
 
-		client, err = worker.NewClient("https://"+address, conf)
+		client, err = worker.NewClient("https://"+address, conf, nil, nil)
 		if err != nil {
 			log.Fatalf("Error creating worker client: %v", err)
 		}
@@ -209,7 +229,7 @@ func main() {
 
 	for {
 		fmt.Println("Waiting for a new job...")
-		job, err := client.RequestJob(acceptedJobTypes)
+		job, err := client.RequestJob(acceptedJobTypes, common.CurrentArch())
 		if err != nil {
 			log.Fatal(err)
 		}
diff --git a/distribution/Dockerfile-worker b/distribution/Dockerfile-worker
index ee02a91de..4c4376828 100644
--- a/distribution/Dockerfile-worker
+++ b/distribution/Dockerfile-worker
@@ -8,7 +8,7 @@ RUN dnf install -y qemu-img osbuild osbuild-ostree
 RUN mkdir -p "/usr/libexec/osbuild-composer"
 RUN mkdir -p "/etc/osbuild-composer/"
 RUN mkdir -p "/run/osbuild-composer/"
-RUN mkdir -p "/var/cache/osbuild-composer/"
+RUN mkdir -p "/var/cache/osbuild-worker/"
 RUN mkdir -p "/var/lib/osbuild-composer/"
 COPY --from=builder /opt/app-root/src/go/bin/osbuild-worker /usr/libexec/osbuild-composer/
 
diff --git a/docs/news/unreleased/hosted-edge-commit.md b/docs/news/unreleased/hosted-edge-commit.md
new file mode 100644
index 000000000..932f69056
--- /dev/null
+++ b/docs/news/unreleased/hosted-edge-commit.md
@@ -0,0 +1,5 @@
+# Build Edge commits in Image Builder and upload to S3
+
+Edge commit image types can now be built through the Cloud API (Image Builder).  Edge commits are uploaded to an S3 bucket and are downloadable through a presigned URL that is available for up to 7 days.
+
+PR: https://github.com/osbuild/osbuild-composer/pull/1439
diff --git a/docs/news/unreleased/worker-oauth2-support.md b/docs/news/unreleased/worker-oauth2-support.md
new file mode 100644
index 000000000..776b2be5a
--- /dev/null
+++ b/docs/news/unreleased/worker-oauth2-support.md
@@ -0,0 +1,5 @@
+# Workers: oauth2 support
+
+This change is mainly targeted for getting composer into `cloud.redhat.com`. It
+allows remote workers to connect to composer starting from a refresh token, and
+is offered as an alternative to the client certificate authentication.
diff --git a/internal/cloudapi/openapi.gen.go b/internal/cloudapi/openapi.gen.go
index 484acb143..f1ebf7db8 100644
--- a/internal/cloudapi/openapi.gen.go
+++ b/internal/cloudapi/openapi.gen.go
@@ -20,6 +20,17 @@ import (
 	"strings"
 )
 
+// AWSS3UploadRequestOptions defines model for AWSS3UploadRequestOptions.
+type AWSS3UploadRequestOptions struct {
+	Region string                    `json:"region"`
+	S3     AWSUploadRequestOptionsS3 `json:"s3"`
+}
+
+// AWSS3UploadStatus defines model for AWSS3UploadStatus.
+type AWSS3UploadStatus struct {
+	Url string `json:"url"`
+}
+
 // AWSUploadRequestOptions defines model for AWSUploadRequestOptions.
 type AWSUploadRequestOptions struct {
 	Ec2    AWSUploadRequestOptionsEc2 `json:"ec2"`
@@ -144,6 +155,7 @@ type GCPUploadStatus struct {
 type ImageRequest struct {
 	Architecture  string        `json:"architecture"`
 	ImageType     string        `json:"image_type"`
+	Ostree        *OSTree       `json:"ostree,omitempty"`
 	Repositories  []Repository  `json:"repositories"`
 	UploadRequest UploadRequest `json:"upload_request"`
 }
@@ -167,6 +179,12 @@ const (
 	ImageStatusValue_uploading   ImageStatusValue = "uploading"
 )
 
+// OSTree defines model for OSTree.
+type OSTree struct {
+	Ref *string `json:"ref,omitempty"`
+	Url *string `json:"url,omitempty"`
+}
+
 // Repository defines model for Repository.
 type Repository struct {
 	Baseurl    *string `json:"baseurl,omitempty"`
@@ -202,9 +220,10 @@ type UploadTypes string
 
 // List of UploadTypes
 const (
-	UploadTypes_aws   UploadTypes = "aws"
-	UploadTypes_azure UploadTypes = "azure"
-	UploadTypes_gcp   UploadTypes = "gcp"
+	UploadTypes_aws    UploadTypes = "aws"
+	UploadTypes_aws_s3 UploadTypes = "aws.s3"
+	UploadTypes_azure  UploadTypes = "azure"
+	UploadTypes_gcp    UploadTypes = "gcp"
 )
 
 // Version defines model for Version.
@@ -868,52 +887,54 @@ func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler {
 // Base64 encoded, gzipped, json marshaled Swagger object
 var swaggerSpec = []string{
 
-	"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",
+	"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",
+	"FIgoAAA=",
 }
 
 // GetSwagger returns the Swagger specification corresponding to the generated code
diff --git a/internal/cloudapi/openapi.yml b/internal/cloudapi/openapi.yml
index ba10faf25..9c76b7531 100644
--- a/internal/cloudapi/openapi.yml
+++ b/internal/cloudapi/openapi.yml
@@ -121,6 +121,7 @@ components:
         options:
           oneOf:
             - $ref: '#/components/schemas/AWSUploadStatus'
+            - $ref: '#/components/schemas/AWSS3UploadStatus'
             - $ref: '#/components/schemas/GCPUploadStatus'
             - $ref: '#/components/schemas/AzureUploadStatus'
     AWSUploadStatus:
@@ -135,6 +136,13 @@ components:
         region:
           type: string
           example: 'eu-west-1'
+    AWSS3UploadStatus:
+      type: object
+      required:
+        - url
+      properties:
+        url:
+          type: string
     GCPUploadStatus:
       type: object
       required:
@@ -187,6 +195,8 @@ components:
           type: array
           items:
             $ref: '#/components/schemas/Repository'
+        ostree:
+          $ref: '#/components/schemas/OSTree'
         upload_request:
           $ref: '#/components/schemas/UploadRequest'
     Repository:
@@ -219,11 +229,12 @@ components:
         options:
           oneOf:
             -  $ref: '#/components/schemas/AWSUploadRequestOptions'
+            -  $ref: '#/components/schemas/AWSS3UploadRequestOptions'
             -  $ref: '#/components/schemas/GCPUploadRequestOptions'
             -  $ref: '#/components/schemas/AzureUploadRequestOptions'
     UploadTypes:
       type: string
-      enum: ['aws', 'gcp', 'azure']
+      enum: ['aws', 'aws.s3', 'gcp', 'azure']
     AWSUploadRequestOptions:
       type: object
       required:
@@ -238,6 +249,17 @@ components:
           $ref: '#/components/schemas/AWSUploadRequestOptionsS3'
         ec2:
           $ref: '#/components/schemas/AWSUploadRequestOptionsEc2'
+    AWSS3UploadRequestOptions:
+      type: object
+      required:
+        - region
+        - s3
+      properties:
+        region:
+          type: string
+          example: 'eu-west-1'
+        s3:
+          $ref: '#/components/schemas/AWSUploadRequestOptionsS3'
     AWSUploadRequestOptionsS3:
       type: object
       required:
@@ -378,6 +400,14 @@ components:
           example: ['postgres']
           items:
             type: string
+    OSTree:
+      type: object
+      properties:
+        url:
+          type: string
+        ref:
+          type: string
+          example: ['rhel/8/x86_64/edge']
     Subscription:
       type: object
       required:
diff --git a/internal/cloudapi/server.go b/internal/cloudapi/server.go
index 0850c1347..2ae38cbaf 100644
--- a/internal/cloudapi/server.go
+++ b/internal/cloudapi/server.go
@@ -19,6 +19,7 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/distroregistry"
+	"github.com/osbuild/osbuild-composer/internal/ostree"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 	"github.com/osbuild/osbuild-composer/internal/target"
 	"github.com/osbuild/osbuild-composer/internal/worker"
@@ -34,16 +35,6 @@ type Server struct {
 
 type contextKey int
 
-const (
-	identityHeaderKey contextKey = iota
-)
-
-type IdentityHeader struct {
-	Identity struct {
-		AccountNumber string `json:"account_number"`
-	} `json:"identity"`
-}
-
 // NewServer creates a new cloud server
 func NewServer(workers *worker.Server, rpmMetadata rpmmd.RPMMD, distros *distroregistry.Registry) *Server {
 	server := &Server{
@@ -72,6 +63,13 @@ func (server *Server) Handler(path string, identityFilter []string) http.Handler
 
 func (server *Server) VerifyIdentityHeader(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		const identityHeaderKey contextKey = iota
+		type identityHeader struct {
+			Identity struct {
+				AccountNumber string `json:"account_number"`
+			} `json:"identity"`
+		}
+
 		idHeaderB64 := r.Header["X-Rh-Identity"]
 		if len(idHeaderB64) != 1 {
 			http.Error(w, "Auth header is not present", http.StatusNotFound)
@@ -84,7 +82,7 @@ func (server *Server) VerifyIdentityHeader(next http.Handler) http.Handler {
 			return
 		}
 
-		var idHeader IdentityHeader
+		var idHeader identityHeader
 		err = json.Unmarshal([]byte(strings.TrimSuffix(fmt.Sprintf("%s", b64Result), "\n")), &idHeader)
 		if err != nil {
 			http.Error(w, "Auth header has incorrect format", http.StatusNotFound)
@@ -139,6 +137,7 @@ func (server *Server) Compose(w http.ResponseWriter, r *http.Request) {
 	type imageRequest struct {
 		manifest distro.Manifest
 		arch     string
+		exports  []string
 	}
 	imageRequests := make([]imageRequest, len(request.ImageRequests))
 	var targets []*target.Target
@@ -199,6 +198,28 @@ func (server *Server) Compose(w http.ResponseWriter, r *http.Request) {
 			}
 		}
 
+		// set default ostree ref, if one not provided
+		ostreeOptions := ir.Ostree
+		if ostreeOptions == nil || ostreeOptions.Ref == nil {
+			imageOptions.OSTree = distro.OSTreeImageOptions{Ref: imageType.OSTreeRef()}
+		} else if !ostree.VerifyRef(*ostreeOptions.Ref) {
+			http.Error(w, fmt.Sprintf("Invalid OSTree ref: %s", *ostreeOptions.Ref), http.StatusBadRequest)
+			return
+		} else {
+			imageOptions.OSTree = distro.OSTreeImageOptions{Ref: *ostreeOptions.Ref}
+		}
+
+		var parent string
+		if ostreeOptions != nil && ostreeOptions.Url != nil {
+			imageOptions.OSTree.URL = *ostreeOptions.Url
+			parent, err = ostree.ResolveRef(imageOptions.OSTree.URL, imageOptions.OSTree.Ref)
+			if err != nil {
+				http.Error(w, fmt.Sprintf("Error resolving OSTree repo %s: %s", imageOptions.OSTree.URL, err), http.StatusBadRequest)
+				return
+			}
+			imageOptions.OSTree.Parent = parent
+		}
+
 		manifest, err := imageType.Manifest(nil, imageOptions, repositories, pkgSpecSets, manifestSeed)
 		if err != nil {
 			http.Error(w, fmt.Sprintf("Failed to get manifest for for %s/%s/%s: %s", ir.ImageType, ir.Architecture, request.Distribution, err), http.StatusBadRequest)
@@ -207,6 +228,7 @@ func (server *Server) Compose(w http.ResponseWriter, r *http.Request) {
 
 		imageRequests[i].manifest = manifest
 		imageRequests[i].arch = arch.Name()
+		imageRequests[i].exports = imageType.Exports()
 
 		uploadRequest := ir.UploadRequest
 		/* oneOf is not supported by the openapi generator so marshal and unmarshal the uploadrequest based on the type */
@@ -243,6 +265,31 @@ func (server *Server) Compose(w http.ResponseWriter, r *http.Request) {
 				t.ImageName = key
 			}
 
+			targets = append(targets, t)
+		} else if uploadRequest.Type == UploadTypes_aws_s3 {
+			var awsS3UploadOptions AWSS3UploadRequestOptions
+			jsonUploadOptions, err := json.Marshal(uploadRequest.Options)
+			if err != nil {
+				http.Error(w, "Unable to marshal aws upload request", http.StatusInternalServerError)
+				return
+			}
+			err = json.Unmarshal(jsonUploadOptions, &awsS3UploadOptions)
+			if err != nil {
+				http.Error(w, "Unable to unmarshal aws upload request", http.StatusInternalServerError)
+				return
+			}
+
+			key := fmt.Sprintf("composer-api-%s", uuid.New().String())
+			t := target.NewAWSS3Target(&target.AWSS3TargetOptions{
+				Filename:        imageType.Filename(),
+				Region:          awsS3UploadOptions.Region,
+				AccessKeyID:     awsS3UploadOptions.S3.AccessKeyId,
+				SecretAccessKey: awsS3UploadOptions.S3.SecretAccessKey,
+				Bucket:          awsS3UploadOptions.S3.Bucket,
+				Key:             key,
+			})
+			t.ImageName = key
+
 			targets = append(targets, t)
 		} else if uploadRequest.Type == UploadTypes_gcp {
 			var gcpUploadOptions GCPUploadRequestOptions
@@ -328,6 +375,7 @@ func (server *Server) Compose(w http.ResponseWriter, r *http.Request) {
 	id, err := server.workers.EnqueueOSBuild(ir.arch, &worker.OSBuildJob{
 		Manifest: ir.manifest,
 		Targets:  targets,
+		Exports:  ir.exports,
 	})
 	if err != nil {
 		http.Error(w, "Failed to enqueue manifest", http.StatusInternalServerError)
@@ -379,6 +427,12 @@ func (server *Server) ComposeStatus(w http.ResponseWriter, r *http.Request, id s
 				Ami:    awsOptions.Ami,
 				Region: awsOptions.Region,
 			}
+		case "org.osbuild.aws.s3":
+			uploadType = UploadTypes_aws_s3
+			awsOptions := tr.Options.(*target.AWSS3TargetResultOptions)
+			uploadOptions = AWSS3UploadStatus{
+				Url: awsOptions.URL,
+			}
 		case "org.osbuild.gcp":
 			uploadType = UploadTypes_gcp
 			gcpOptions := tr.Options.(*target.GCPTargetResultOptions)
diff --git a/internal/kojiapi/server.go b/internal/kojiapi/server.go
index 78d67a7e0..416a5e32e 100644
--- a/internal/kojiapi/server.go
+++ b/internal/kojiapi/server.go
@@ -85,6 +85,7 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 		manifest distro.Manifest
 		arch     string
 		filename string
+		exports  []string
 	}
 
 	imageRequests := make([]imageRequest, len(request.ImageRequests))
@@ -138,6 +139,7 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 		imageRequests[i].manifest = manifest
 		imageRequests[i].arch = arch.Name()
 		imageRequests[i].filename = imageType.Filename()
+		imageRequests[i].exports = imageType.Exports()
 
 		kojiFilenames[i] = fmt.Sprintf(
 			"%s-%s-%s.%s%s",
@@ -165,6 +167,7 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 		id, err := h.server.workers.EnqueueOSBuildKoji(ir.arch, &worker.OSBuildKojiJob{
 			Manifest:      ir.manifest,
 			ImageName:     ir.filename,
+			Exports:       ir.exports,
 			KojiServer:    request.Koji.Server,
 			KojiDirectory: kojiDirectory,
 			KojiFilename:  kojiFilenames[i],
diff --git a/internal/mocks/rpmmd/fixtures.go b/internal/mocks/rpmmd/fixtures.go
index 9f1fc2bc9..f2bbea684 100644
--- a/internal/mocks/rpmmd/fixtures.go
+++ b/internal/mocks/rpmmd/fixtures.go
@@ -57,7 +57,7 @@ func createBaseWorkersFixture(tmpdir string) *worker.Server {
 	if err != nil {
 		panic(err)
 	}
-	return worker.NewServer(nil, q, "")
+	return worker.NewServer(nil, q, "", []string{})
 }
 
 func createBaseDepsolveFixture() []rpmmd.PackageSpec {
diff --git a/internal/osbuild1/rpm_ostree_stage.go b/internal/osbuild1/rpm_ostree_stage.go
index f43984159..c20726d61 100644
--- a/internal/osbuild1/rpm_ostree_stage.go
+++ b/internal/osbuild1/rpm_ostree_stage.go
@@ -1,13 +1,14 @@
 package osbuild1
 
-// The RPM-OSTree stage describes how to transform the imgae into an OSTree.
+// RPMOSTreeStageOptions configures the invocation of the `rpm-ostree`
+// process for generating an ostree commit.
 type RPMOSTreeStageOptions struct {
 	EtcGroupMembers []string `json:"etc_group_members,omitempty"`
 }
 
 func (RPMOSTreeStageOptions) isStageOptions() {}
 
-// NewLocaleStage creates a new Locale Stage object.
+// NewRPMOSTreeStage creates a new rpm-ostree Stage object.
 func NewRPMOSTreeStage(options *RPMOSTreeStageOptions) *Stage {
 	return &Stage{
 		Name:    "org.osbuild.rpm-ostree",
diff --git a/internal/ostree/ostree.go b/internal/ostree/ostree.go
new file mode 100644
index 000000000..20822ada0
--- /dev/null
+++ b/internal/ostree/ostree.go
@@ -0,0 +1,53 @@
+package ostree
+
+import (
+	"encoding/hex"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"path"
+	"regexp"
+	"strings"
+)
+
+var ostreeRefRE = regexp.MustCompile(`^(?:[\w\d][-._\w\d]*\/)*[\w\d][-._\w\d]*$`)
+
+type OSTreeRequest struct {
+	URL    string `json:"url"`
+	Ref    string `json:"ref"`
+	Parent string `json:"parent"`
+}
+
+func VerifyRef(ref string) bool {
+	if len(ref) > 0 && ostreeRefRE.MatchString(ref) {
+		return true
+	}
+	return false
+}
+
+func ResolveRef(location, ref string) (string, error) {
+	u, err := url.Parse(location)
+	if err != nil {
+		return "", err
+	}
+	u.Path = path.Join(u.Path, "refs/heads/", ref)
+	resp, err := http.Get(u.String())
+	if err != nil {
+		return "", err
+	}
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("ostree repository %q returned status: %s", u.String(), resp.Status)
+	}
+	body, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return "", err
+	}
+	parent := strings.TrimSpace(string(body))
+	// Check that this is at least a hex string.
+	_, err = hex.DecodeString(parent)
+	if err != nil {
+		return "", fmt.Errorf("ostree repository %q returned invalid reference", u.String())
+	}
+	return parent, nil
+}
diff --git a/internal/target/aws_target.go b/internal/target/aws_target.go
index 0410776fb..095a31f78 100644
--- a/internal/target/aws_target.go
+++ b/internal/target/aws_target.go
@@ -26,3 +26,28 @@ func (AWSTargetResultOptions) isTargetResultOptions() {}
 func NewAWSTargetResult(options *AWSTargetResultOptions) *TargetResult {
 	return newTargetResult("org.osbuild.aws", options)
 }
+
+type AWSS3TargetOptions struct {
+	Filename        string `json:"filename"`
+	Region          string `json:"region"`
+	AccessKeyID     string `json:"accessKeyID"`
+	SecretAccessKey string `json:"secretAccessKey"`
+	Bucket          string `json:"bucket"`
+	Key             string `json:"key"`
+}
+
+func (AWSS3TargetOptions) isTargetOptions() {}
+
+func NewAWSS3Target(options *AWSS3TargetOptions) *Target {
+	return newTarget("org.osbuild.aws.s3", options)
+}
+
+type AWSS3TargetResultOptions struct {
+	URL string `json:"url"`
+}
+
+func (AWSS3TargetResultOptions) isTargetResultOptions() {}
+
+func NewAWSS3TargetResult(options *AWSS3TargetResultOptions) *TargetResult {
+	return newTargetResult("org.osbuild.aws.s3", options)
+}
diff --git a/internal/target/target.go b/internal/target/target.go
index 4bdd60771..ed7f7f7c5 100644
--- a/internal/target/target.go
+++ b/internal/target/target.go
@@ -69,6 +69,8 @@ func UnmarshalTargetOptions(targetName string, rawOptions json.RawMessage) (Targ
 		options = new(AzureTargetOptions)
 	case "org.osbuild.aws":
 		options = new(AWSTargetOptions)
+	case "org.osbuild.aws.s3":
+		options = new(AWSS3TargetOptions)
 	case "org.osbuild.gcp":
 		options = new(GCPTargetOptions)
 	case "org.osbuild.azure.image":
diff --git a/internal/target/targetresult.go b/internal/target/targetresult.go
index ed1c820e2..8f417418e 100644
--- a/internal/target/targetresult.go
+++ b/internal/target/targetresult.go
@@ -47,6 +47,8 @@ func UnmarshalTargetResultOptions(trName string, rawOptions json.RawMessage) (Ta
 	switch trName {
 	case "org.osbuild.aws":
 		options = new(AWSTargetResultOptions)
+	case "org.osbuild.aws.s3":
+		options = new(AWSS3TargetResultOptions)
 	case "org.osbuild.gcp":
 		options = new(GCPTargetResultOptions)
 	case "org.osbuild.azure.image":
diff --git a/internal/upload/awsupload/awsupload.go b/internal/upload/awsupload/awsupload.go
index cd8c60df9..ba2e7efa8 100644
--- a/internal/upload/awsupload/awsupload.go
+++ b/internal/upload/awsupload/awsupload.go
@@ -284,3 +284,17 @@ func (a *AWS) Register(name, bucket, key string, shareWith []string, rpmArch str
 
 	return registerOutput.ImageId, nil
 }
+
+func (a *AWS) S3ObjectPresignedURL(bucket, objectKey string) (string, error) {
+	log.Printf("[AWS] 📋 Generating Presigned URL for S3 object %s/%s", bucket, objectKey)
+	req, _ := a.s3.GetObjectRequest(&s3.GetObjectInput{
+		Bucket: aws.String(bucket),
+		Key:    aws.String(objectKey),
+	})
+	url, err := req.Presign(7 * 24 * time.Hour) // maximum allowed
+	if err != nil {
+		return "", err
+	}
+	log.Print("[AWS] 🎉 S3 Presigned URL ready")
+	return url, nil
+}
diff --git a/internal/weldr/api.go b/internal/weldr/api.go
index 169c9854a..6fd746c34 100644
--- a/internal/weldr/api.go
+++ b/internal/weldr/api.go
@@ -4,7 +4,6 @@ import (
 	"archive/tar"
 	"bytes"
 	"crypto/rand"
-	"encoding/hex"
 	"encoding/json"
 	errors_package "errors"
 	"fmt"
@@ -33,6 +32,7 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/jobqueue"
 	osbuild "github.com/osbuild/osbuild-composer/internal/osbuild1"
+	"github.com/osbuild/osbuild-composer/internal/ostree"
 	"github.com/osbuild/osbuild-composer/internal/reporegistry"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 	"github.com/osbuild/osbuild-composer/internal/store"
@@ -93,7 +93,6 @@ func (api *API) systemRepoNames() (names []string) {
 }
 
 var ValidBlueprintName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
-var ValidOSTreeRef = regexp.MustCompile(`^(?:[\w\d][-._\w\d]*\/)*[\w\d][-._\w\d]*$`)
 
 func New(rpmmd rpmmd.RPMMD, arch distro.Arch, distro distro.Distro, repoRegistry *reporegistry.RepoRegistry, logger *log.Logger, store *store.Store, workers *worker.Server, compatOutputDir string) *API {
 	api := &API{
@@ -377,18 +376,6 @@ func verifyStringsWithRegex(writer http.ResponseWriter, strings []string, re *re
 	return true
 }
 
-func verifyOSTreeRef(writer http.ResponseWriter, ref string, re *regexp.Regexp) bool {
-	if len(ref) > 0 && re.MatchString(ref) {
-		return true
-	}
-	errors := responseError{
-		ID:  "InvalidChars",
-		Msg: "Invalid ostree ref",
-	}
-	statusResponseError(writer, http.StatusBadRequest, errors)
-	return false
-}
-
 func statusResponseError(writer http.ResponseWriter, code int, errors ...responseError) {
 	type reply struct {
 		Status bool            `json:"status"`
@@ -1847,32 +1834,6 @@ func (api *API) blueprintsTagHandler(writer http.ResponseWriter, request *http.R
 	statusResponseOK(writer)
 }
 
-func ostreeResolveRef(location, ref string) (string, error) {
-	u, err := url.Parse(location)
-	if err != nil {
-		return "", err
-	}
-	u.Path = path.Join(u.Path, "refs/heads/", ref)
-	resp, err := http.Get(u.String())
-	if err != nil {
-		return "", err
-	}
-	if resp.StatusCode != http.StatusOK {
-		return "", fmt.Errorf("ostree repository %q returned status: %s", u.String(), resp.Status)
-	}
-	body, err := ioutil.ReadAll(resp.Body)
-	if err != nil {
-		return "", err
-	}
-	parent := strings.TrimSpace(string(body))
-	// Check that this is at least a hex string.
-	_, err = hex.DecodeString(parent)
-	if err != nil {
-		return "", fmt.Errorf("ostree repository %q returned invalid reference", u.String())
-	}
-	return parent, nil
-}
-
 func (api *API) depsolveBlueprintForImageType(bp *blueprint.Blueprint, imageType distro.ImageType) (map[string][]rpmmd.PackageSpec, error) {
 	packageSets := imageType.PackageSets(*bp)
 	packageSpecSets := make(map[string][]rpmmd.PackageSpec)
@@ -1899,20 +1860,14 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 		return
 	}
 
-	type OSTreeRequest struct {
-		URL    string `json:"url"`
-		Ref    string `json:"ref"`
-		Parent string `json:"parent"`
-	}
-
 	// https://weldr.io/lorax/pylorax.api.html#pylorax.api.v0.v0_compose_start
 	type ComposeRequest struct {
-		BlueprintName string         `json:"blueprint_name"`
-		ComposeType   string         `json:"compose_type"`
-		Size          uint64         `json:"size"`
-		OSTree        OSTreeRequest  `json:"ostree"`
-		Branch        string         `json:"branch"`
-		Upload        *uploadRequest `json:"upload"`
+		BlueprintName string               `json:"blueprint_name"`
+		ComposeType   string               `json:"compose_type"`
+		Size          uint64               `json:"size"`
+		OSTree        ostree.OSTreeRequest `json:"ostree"`
+		Branch        string               `json:"branch"`
+		Upload        *uploadRequest       `json:"upload"`
 	}
 	type ComposeReply struct {
 		BuildID uuid.UUID `json:"build_id"`
@@ -1954,7 +1909,12 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 	// set default ostree ref, if one not provided
 	if cr.OSTree.Ref == "" {
 		cr.OSTree.Ref = imageType.OSTreeRef()
-	} else if !verifyOSTreeRef(writer, cr.OSTree.Ref, ValidOSTreeRef) {
+	} else if !ostree.VerifyRef(cr.OSTree.Ref) {
+		errors := responseError{
+			ID:  "InvalidChars",
+			Msg: "Invalid ostree ref",
+		}
+		statusResponseError(writer, http.StatusBadRequest, errors)
 		return
 	}
 
@@ -2011,7 +1971,7 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 			parent = "02604b2da6e954bd34b8b82a835e5a77d2b60ffa"
 		} else {
 			// Resolve the URL and get the parent commit
-			parent, err = ostreeResolveRef(cr.OSTree.URL, cr.OSTree.Ref)
+			parent, err = ostree.ResolveRef(cr.OSTree.URL, cr.OSTree.Ref)
 			if err != nil {
 				errors := responseError{
 					ID:  "OSTreeCommitError",
diff --git a/internal/worker/api/api.go b/internal/worker/api/api.go
index d379f8f2a..72bf9a544 100644
--- a/internal/worker/api/api.go
+++ b/internal/worker/api/api.go
@@ -3,3 +3,4 @@
 package api
 
 const BasePath = "/api/worker/v1"
+const CloudBasePath = "/api/composer-worker/v1"
diff --git a/internal/worker/client.go b/internal/worker/client.go
index fcb0697c6..be1856a28 100644
--- a/internal/worker/client.go
+++ b/internal/worker/client.go
@@ -10,15 +10,29 @@ import (
 	"net"
 	"net/http"
 	"net/url"
+	"strings"
+	"sync"
+	"time"
 
 	"github.com/google/uuid"
-	"github.com/osbuild/osbuild-composer/internal/common"
+
 	"github.com/osbuild/osbuild-composer/internal/worker/api"
 )
 
+type bearerToken struct {
+	AccessToken     string `json:"access_token"`
+	ValidForSeconds int    `json:"expires_in"`
+}
+
 type Client struct {
-	server    *url.URL
-	requester *http.Client
+	server           *url.URL
+	requester        *http.Client
+	offlineToken     *string
+	oAuthURL         *string
+	lastTokenRefresh *time.Time
+	bearerToken      *bearerToken
+
+	tokenMu *sync.Mutex
 }
 
 type Job interface {
@@ -33,7 +47,7 @@ type Job interface {
 }
 
 type job struct {
-	requester        *http.Client
+	client           *Client
 	id               uuid.UUID
 	location         string
 	artifactLocation string
@@ -42,24 +56,33 @@ type job struct {
 	dynamicArgs      []json.RawMessage
 }
 
-func NewClient(baseURL string, conf *tls.Config) (*Client, error) {
+func NewClient(baseURL string, conf *tls.Config, offlineToken, oAuthURL *string) (*Client, error) {
 	server, err := url.Parse(baseURL)
 	if err != nil {
 		return nil, err
 	}
 
-	server, err = server.Parse(api.BasePath + "/")
+	bp := api.BasePath
+	if offlineToken != nil {
+		bp = api.CloudBasePath
+	}
+	server, err = server.Parse(bp + "/")
 	if err != nil {
 		panic(err)
 	}
 
-	requester := &http.Client{
-		Transport: &http.Transport{
+	if conf != nil && offlineToken != nil {
+		return nil, fmt.Errorf("error creating client, both tls and oauth are enabled")
+	}
+
+	requester := &http.Client{}
+	if conf != nil {
+		requester.Transport = &http.Transport{
 			TLSClientConfig: conf,
-		},
+		}
 	}
 
-	return &Client{server, requester}, nil
+	return &Client{server, requester, offlineToken, oAuthURL, nil, nil, &sync.Mutex{}}, nil
 }
 
 func NewClientUnix(path string) *Client {
@@ -81,10 +104,65 @@ func NewClientUnix(path string) *Client {
 		},
 	}
 
-	return &Client{server, requester}
+	return &Client{server, requester, nil, nil, nil, nil, nil}
+}
+
+// Note: Only call this function with Client.tokenMu locked!
+func (c *Client) refreshBearerToken() error {
+	if c.offlineToken == nil || c.oAuthURL == nil {
+		return fmt.Errorf("No offline token or oauth url available")
+	}
+
+	data := url.Values{}
+	data.Set("grant_type", "refresh_token")
+	data.Set("client_id", "rhsm-api")
+	data.Set("refresh_token", *c.offlineToken)
+
+	t := time.Now()
+	resp, err := http.Post(*c.oAuthURL, "application/x-www-form-urlencoded", strings.NewReader(data.Encode()))
+	if err != nil {
+		return err
+	}
+
+	var bt bearerToken
+	err = json.NewDecoder(resp.Body).Decode(&bt)
+	if err != nil {
+		return err
+	}
+
+	c.bearerToken = &bt
+	c.lastTokenRefresh = &t
+	return nil
+}
+
+func (c *Client) NewRequest(method, url string, body io.Reader) (*http.Request, error) {
+	req, err := http.NewRequest(method, url, body)
+	if err != nil {
+		return nil, err
+	}
+
+	// If we're using OAUTH, add the Bearer token
+	if c.offlineToken != nil {
+		// make sure we have a valid token
+		var d time.Duration
+		c.tokenMu.Lock()
+		defer c.tokenMu.Unlock()
+		if c.lastTokenRefresh != nil {
+			d = time.Since(*c.lastTokenRefresh)
+		}
+		if c.bearerToken == nil || d.Seconds() >= (float64(c.bearerToken.ValidForSeconds)*0.8) {
+			err = c.refreshBearerToken()
+			if err != nil {
+				return nil, err
+			}
+		}
+
+		req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.bearerToken.AccessToken))
+	}
+	return req, nil
 }
 
-func (c *Client) RequestJob(types []string) (Job, error) {
+func (c *Client) RequestJob(types []string, arch string) (Job, error) {
 	url, err := c.server.Parse("jobs")
 	if err != nil {
 		// This only happens when "jobs" cannot be parsed.
@@ -94,13 +172,19 @@ func (c *Client) RequestJob(types []string) (Job, error) {
 	var buf bytes.Buffer
 	err = json.NewEncoder(&buf).Encode(api.RequestJobJSONRequestBody{
 		Types: types,
-		Arch:  common.CurrentArch(),
+		Arch:  arch,
 	})
 	if err != nil {
 		panic(err)
 	}
 
-	response, err := c.requester.Post(url.String(), "application/json", &buf)
+	req, err := c.NewRequest("POST", url.String(), &buf)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Add("Content-Type", "application/json")
+
+	response, err := c.requester.Do(req)
 	if err != nil {
 		return nil, fmt.Errorf("error requesting job: %v", err)
 	}
@@ -127,7 +211,7 @@ func (c *Client) RequestJob(types []string) (Job, error) {
 	}
 
 	return &job{
-		requester:        c.requester,
+		client:           c,
 		id:               jr.Id,
 		jobType:          jr.Type,
 		args:             jr.Args,
@@ -174,14 +258,14 @@ func (j *job) Update(result interface{}) error {
 		panic(err)
 	}
 
-	req, err := http.NewRequest("PATCH", j.location, &buf)
+	req, err := j.client.NewRequest("PATCH", j.location, &buf)
 	if err != nil {
 		panic(err)
 	}
 
 	req.Header.Add("Content-Type", "application/json")
 
-	response, err := j.requester.Do(req)
+	response, err := j.client.requester.Do(req)
 	if err != nil {
 		return fmt.Errorf("error fetching job info: %v", err)
 	}
@@ -195,7 +279,12 @@ func (j *job) Update(result interface{}) error {
 }
 
 func (j *job) Canceled() (bool, error) {
-	response, err := j.requester.Get(j.location)
+	req, err := j.client.NewRequest("GET", j.location, nil)
+	if err != nil {
+		return false, err
+	}
+
+	response, err := j.client.requester.Do(req)
 	if err != nil {
 		return false, fmt.Errorf("error fetching job info: %v", err)
 	}
@@ -229,14 +318,14 @@ func (j *job) UploadArtifact(name string, reader io.Reader) error {
 		panic(err)
 	}
 
-	req, err := http.NewRequest("PUT", loc.String(), reader)
+	req, err := j.client.NewRequest("PUT", loc.String(), reader)
 	if err != nil {
 		return fmt.Errorf("cannot create request: %v", err)
 	}
 
 	req.Header.Add("Content-Type", "application/octet-stream")
 
-	response, err := j.requester.Do(req)
+	response, err := j.client.requester.Do(req)
 	if err != nil {
 		return fmt.Errorf("error uploading artifact: %v", err)
 	}
diff --git a/internal/worker/json.go b/internal/worker/json.go
index 753731852..896703443 100644
--- a/internal/worker/json.go
+++ b/internal/worker/json.go
@@ -45,6 +45,7 @@ type KojiInitJobResult struct {
 type OSBuildKojiJob struct {
 	Manifest      distro.Manifest `json:"manifest"`
 	ImageName     string          `json:"image_name"`
+	Exports       []string        `json:"exports"`
 	KojiServer    string          `json:"koji_server"`
 	KojiDirectory string          `json:"koji_directory"`
 	KojiFilename  string          `json:"koji_filename"`
diff --git a/internal/worker/server.go b/internal/worker/server.go
index 727de708a..43f8a6a00 100644
--- a/internal/worker/server.go
+++ b/internal/worker/server.go
@@ -2,6 +2,7 @@ package worker
 
 import (
 	"context"
+	"encoding/base64"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -11,6 +12,7 @@ import (
 	"net/http"
 	"os"
 	"path"
+	"strings"
 	"sync"
 	"time"
 
@@ -22,9 +24,10 @@ import (
 )
 
 type Server struct {
-	jobs         jobqueue.JobQueue
-	logger       *log.Logger
-	artifactsDir string
+	jobs           jobqueue.JobQueue
+	logger         *log.Logger
+	artifactsDir   string
+	identityFilter []string
 
 	// Currently running jobs. Workers are not handed job ids, but
 	// independent tokens which serve as an indirection. This enables
@@ -48,12 +51,14 @@ type JobStatus struct {
 
 var ErrTokenNotExist = errors.New("worker token does not exist")
 
-func NewServer(logger *log.Logger, jobs jobqueue.JobQueue, artifactsDir string) *Server {
+func NewServer(logger *log.Logger, jobs jobqueue.JobQueue, artifactsDir string, identityFilter []string) *Server {
+
 	return &Server{
-		jobs:         jobs,
-		logger:       logger,
-		artifactsDir: artifactsDir,
-		running:      make(map[uuid.UUID]uuid.UUID),
+		jobs:           jobs,
+		logger:         logger,
+		artifactsDir:   artifactsDir,
+		identityFilter: identityFilter,
+		running:        make(map[uuid.UUID]uuid.UUID),
 	}
 }
 
@@ -68,14 +73,57 @@ func (s *Server) Handler() http.Handler {
 		e.DefaultHTTPErrorHandler(err, c)
 	}
 
+	var mws []echo.MiddlewareFunc
+	if len(s.identityFilter) > 0 {
+		mws = append(mws, s.VerifyIdentityHeader)
+	}
+
 	handler := apiHandlers{
 		server: s,
 	}
-	api.RegisterHandlers(e.Group(api.BasePath), &handler)
+	api.RegisterHandlers(e.Group(api.BasePath, mws...), &handler)
+	api.RegisterHandlers(e.Group(api.CloudBasePath, mws...), &handler)
 
 	return e
 }
 
+func (s *Server) VerifyIdentityHeader(nextHandler echo.HandlerFunc) echo.HandlerFunc {
+	return func(ctx echo.Context) error {
+		type identityHeader struct {
+			Identity struct {
+				AccountNumber string `json:"account_number"`
+			} `json:"identity"`
+		}
+
+		request := ctx.Request()
+
+		idHeaderB64 := request.Header["X-Rh-Identity"]
+		if len(idHeaderB64) != 1 {
+			return echo.NewHTTPError(http.StatusNotFound, "Auth header is not present")
+		}
+
+		b64Result, err := base64.StdEncoding.DecodeString(idHeaderB64[0])
+		if err != nil {
+			return echo.NewHTTPError(http.StatusNotFound, "Auth header has incorrect format")
+		}
+
+		var idHeader identityHeader
+		err = json.Unmarshal([]byte(strings.TrimSuffix(fmt.Sprintf("%s", b64Result), "\n")), &idHeader)
+		if err != nil {
+			return echo.NewHTTPError(http.StatusNotFound, "Auth header has incorrect format")
+		}
+
+		for _, i := range s.identityFilter {
+			if idHeader.Identity.AccountNumber == i {
+				ctx.Set("IdentityHeader", idHeader)
+				return nextHandler(ctx)
+
+			}
+		}
+		return echo.NewHTTPError(http.StatusNotFound, "Account not allowed")
+	}
+}
+
 func (s *Server) EnqueueOSBuild(arch string, job *OSBuildJob) (uuid.UUID, error) {
 	return s.jobs.Enqueue("osbuild:"+arch, job, nil)
 }
@@ -300,10 +348,15 @@ func (h *apiHandlers) RequestJob(ctx echo.Context) error {
 		return err
 	}
 
+	basePath := api.BasePath
+	if strings.HasPrefix(ctx.Path(), api.CloudBasePath) {
+		basePath = api.CloudBasePath
+	}
+
 	return ctx.JSON(http.StatusCreated, requestJobResponse{
 		Id:               jobId,
-		Location:         fmt.Sprintf("%s/jobs/%v", api.BasePath, token),
-		ArtifactLocation: fmt.Sprintf("%s/jobs/%v/artifacts/", api.BasePath, token),
+		Location:         fmt.Sprintf("%s/jobs/%v", basePath, token),
+		ArtifactLocation: fmt.Sprintf("%s/jobs/%v/artifacts/", basePath, token),
 		Type:             jobType,
 		Args:             jobArgs,
 		DynamicArgs:      dynamicJobArgs,


Test patch:
diff --git a/internal/ostree/ostree_test.go b/internal/ostree/ostree_test.go
new file mode 100644
index 000000000..2ca929c62
--- /dev/null
+++ b/internal/ostree/ostree_test.go
@@ -0,0 +1,84 @@
+package ostree
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestOstreeResolveRef(t *testing.T) {
+	goodRef := "5330bb1b8820944567f519de66ad6354c729b6b490dea1c5a7ba320c9f147c58"
+	badRef := "<html>not a ref</html>"
+
+	handler := http.NewServeMux()
+	handler.HandleFunc("/refs/heads/rhel/8/x86_64/edge", func(w http.ResponseWriter, r *http.Request) {
+		http.NotFound(w, r)
+	})
+	handler.HandleFunc("/refs/heads/test_forbidden", func(w http.ResponseWriter, r *http.Request) {
+		http.Error(w, "", http.StatusForbidden)
+	})
+	handler.HandleFunc("/refs/heads/get_bad_ref", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprint(w, badRef)
+	})
+
+	handler.HandleFunc("/refs/heads/test_redir", func(w http.ResponseWriter, r *http.Request) {
+		http.Redirect(w, r, "/refs/heads/valid/ostree/ref", http.StatusFound)
+	})
+	handler.HandleFunc("/refs/heads/valid/ostree/ref", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprint(w, goodRef)
+	})
+
+	srv := httptest.NewServer(handler)
+	defer srv.Close()
+
+	type input struct {
+		location string
+		ref      string
+	}
+	validCases := map[input]string{
+		{srv.URL, "test_redir"}:       goodRef,
+		{srv.URL, "valid/ostree/ref"}: goodRef,
+	}
+	for in, expOut := range validCases {
+		out, err := ResolveRef(in.location, in.ref)
+		assert.NoError(t, err)
+		assert.Equal(t, expOut, out)
+	}
+
+	errCases := map[input]string{
+		{"not-a-url", "a-bad-ref"}:             "Get \"not-a-url/refs/heads/a-bad-ref\": unsupported protocol scheme \"\"",
+		{"http://0.0.0.0:10/repo", "whatever"}: "Get \"http://0.0.0.0:10/repo/refs/heads/whatever\": dial tcp 0.0.0.0:10: connect: connection refused",
+		{srv.URL, "rhel/8/x86_64/edge"}:        fmt.Sprintf("ostree repository \"%s/refs/heads/rhel/8/x86_64/edge\" returned status: 404 Not Found", srv.URL),
+		{srv.URL, "test_forbidden"}:            fmt.Sprintf("ostree repository \"%s/refs/heads/test_forbidden\" returned status: 403 Forbidden", srv.URL),
+		{srv.URL, "get_bad_ref"}:               fmt.Sprintf("ostree repository \"%s/refs/heads/get_bad_ref\" returned invalid reference", srv.URL),
+	}
+	for in, expMsg := range errCases {
+		_, err := ResolveRef(in.location, in.ref)
+		assert.EqualError(t, err, expMsg)
+	}
+}
+
+func TestVerifyRef(t *testing.T) {
+	cases := map[string]bool{
+		"a_perfectly_valid_ref": true,
+		"another/valid/ref":     true,
+		"this-one-has/all.the/_valid-/characters/even/_numbers_42": true,
+		"rhel/8/aarch64/edge": true,
+		"1337":                true,
+		"1337/but/also/more":  true,
+		"_good_start/ref":     true,
+		"/bad/ref":            false,
+		"invalid)characters":  false,
+		"this/was/doing/fine/until/the/very/end/": false,
+		"-bad_start/ref":     false,
+		".another/bad/start": false,
+		"how/about/now?":     false,
+	}
+
+	for in, expOut := range cases {
+		assert.Equal(t, expOut, VerifyRef(in), in)
+	}
+}
diff --git a/internal/test/helpers.go b/internal/test/helpers.go
index a194e33f1..e4e9e948c 100644
--- a/internal/test/helpers.go
+++ b/internal/test/helpers.go
@@ -50,9 +50,14 @@ func externalRequest(method, path, body string) *http.Response {
 	return resp
 }
 
-func internalRequest(api http.Handler, method, path, body string) *http.Response {
+func internalRequest(api http.Handler, method, path, body string, header map[string]string) *http.Response {
 	req := httptest.NewRequest(method, path, bytes.NewReader([]byte(body)))
 	req.Header.Set("Content-Type", "application/json")
+
+	for k, h := range header {
+		req.Header.Set(k, h)
+	}
+
 	resp := httptest.NewRecorder()
 	api.ServeHTTP(resp, req)
 
@@ -66,10 +71,14 @@ func SendHTTP(api http.Handler, external bool, method, path, body string) *http.
 		}
 		return externalRequest(method, path, body)
 	} else {
-		return internalRequest(api, method, path, body)
+		return internalRequest(api, method, path, body, map[string]string{})
 	}
 }
 
+func SendHTTPWithHeader(api http.Handler, method, path, body string, header map[string]string) *http.Response {
+	return internalRequest(api, method, path, body, header)
+}
+
 // this function serves to drop fields that shouldn't be tested from the unmarshalled json objects
 func dropFields(obj interface{}, fields ...string) {
 	switch v := obj.(type) {
diff --git a/internal/weldr/api_test.go b/internal/weldr/api_test.go
index 82c7a6b47..b8c07403c 100644
--- a/internal/weldr/api_test.go
+++ b/internal/weldr/api_test.go
@@ -28,7 +28,6 @@ import (
 
 	"github.com/BurntSushi/toml"
 	"github.com/google/go-cmp/cmp"
-	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
@@ -1357,55 +1356,3 @@ func TestModulesList(t *testing.T) {
 		test.TestRoute(t, api, true, "GET", c.Path, ``, c.ExpectedStatus, c.ExpectedJSON)
 	}
 }
-
-func TestOstreeResolveRef(t *testing.T) {
-	goodRef := "5330bb1b8820944567f519de66ad6354c729b6b490dea1c5a7ba320c9f147c58"
-	badRef := "<html>not a ref</html>"
-
-	handler := http.NewServeMux()
-	handler.HandleFunc("/refs/heads/rhel/8/x86_64/edge", func(w http.ResponseWriter, r *http.Request) {
-		http.NotFound(w, r)
-	})
-	handler.HandleFunc("/refs/heads/test_forbidden", func(w http.ResponseWriter, r *http.Request) {
-		http.Error(w, "", http.StatusForbidden)
-	})
-	handler.HandleFunc("/refs/heads/get_bad_ref", func(w http.ResponseWriter, r *http.Request) {
-		fmt.Fprint(w, badRef)
-	})
-
-	handler.HandleFunc("/refs/heads/test_redir", func(w http.ResponseWriter, r *http.Request) {
-		http.Redirect(w, r, "/refs/heads/valid/ostree/ref", http.StatusFound)
-	})
-	handler.HandleFunc("/refs/heads/valid/ostree/ref", func(w http.ResponseWriter, r *http.Request) {
-		fmt.Fprint(w, goodRef)
-	})
-
-	srv := httptest.NewServer(handler)
-	defer srv.Close()
-
-	type input struct {
-		location string
-		ref      string
-	}
-	validCases := map[input]string{
-		{srv.URL, "test_redir"}:       goodRef,
-		{srv.URL, "valid/ostree/ref"}: goodRef,
-	}
-	for in, expOut := range validCases {
-		out, err := ostreeResolveRef(in.location, in.ref)
-		assert.NoError(t, err)
-		assert.Equal(t, expOut, out)
-	}
-
-	errCases := map[input]string{
-		{"not-a-url", "a-bad-ref"}:             "Get \"not-a-url/refs/heads/a-bad-ref\": unsupported protocol scheme \"\"",
-		{"http://0.0.0.0:10/repo", "whatever"}: "Get \"http://0.0.0.0:10/repo/refs/heads/whatever\": dial tcp 0.0.0.0:10: connect: connection refused",
-		{srv.URL, "rhel/8/x86_64/edge"}:        fmt.Sprintf("ostree repository \"%s/refs/heads/rhel/8/x86_64/edge\" returned status: 404 Not Found", srv.URL),
-		{srv.URL, "test_forbidden"}:            fmt.Sprintf("ostree repository \"%s/refs/heads/test_forbidden\" returned status: 403 Forbidden", srv.URL),
-		{srv.URL, "get_bad_ref"}:               fmt.Sprintf("ostree repository \"%s/refs/heads/get_bad_ref\" returned invalid reference", srv.URL),
-	}
-	for in, expMsg := range errCases {
-		_, err := ostreeResolveRef(in.location, in.ref)
-		assert.EqualError(t, err, expMsg)
-	}
-}
diff --git a/internal/worker/server_test.go b/internal/worker/server_test.go
index 6b23a2e26..a7609362c 100644
--- a/internal/worker/server_test.go
+++ b/internal/worker/server_test.go
@@ -2,13 +2,17 @@ package worker_test
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"io/ioutil"
 	"net/http"
+	"net/http/httptest"
 	"os"
+	"strings"
 	"testing"
 
 	"github.com/google/uuid"
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
 	"github.com/osbuild/osbuild-composer/internal/distro"
@@ -18,12 +22,12 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/worker"
 )
 
-func newTestServer(t *testing.T, tempdir string) *worker.Server {
+func newTestServer(t *testing.T, tempdir string, identities []string) *worker.Server {
 	q, err := fsjobqueue.New(tempdir)
 	if err != nil {
 		t.Fatalf("error creating fsjobqueue: %v", err)
 	}
-	return worker.NewServer(nil, q, "")
+	return worker.NewServer(nil, q, "", identities)
 }
 
 // Ensure that the status request returns OK.
@@ -32,7 +36,7 @@ func TestStatus(t *testing.T) {
 	require.NoError(t, err)
 	defer os.RemoveAll(tempdir)
 
-	server := newTestServer(t, tempdir)
+	server := newTestServer(t, tempdir, []string{})
 	handler := server.Handler()
 	test.TestRoute(t, handler, false, "GET", "/api/worker/v1/status", ``, http.StatusOK, `{"status":"OK"}`, "message")
 }
@@ -63,7 +67,7 @@ func TestErrors(t *testing.T) {
 	defer os.RemoveAll(tempdir)
 
 	for _, c := range cases {
-		server := newTestServer(t, tempdir)
+		server := newTestServer(t, tempdir, []string{})
 		handler := server.Handler()
 		test.TestRoute(t, handler, false, c.Method, c.Path, c.Body, c.ExpectedStatus, "{}", "message")
 	}
@@ -87,7 +91,7 @@ func TestCreate(t *testing.T) {
 	if err != nil {
 		t.Fatalf("error creating osbuild manifest: %v", err)
 	}
-	server := newTestServer(t, tempdir)
+	server := newTestServer(t, tempdir, []string{})
 	handler := server.Handler()
 
 	_, err = server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest})
@@ -116,7 +120,7 @@ func TestCancel(t *testing.T) {
 	if err != nil {
 		t.Fatalf("error creating osbuild manifest: %v", err)
 	}
-	server := newTestServer(t, tempdir)
+	server := newTestServer(t, tempdir, []string{})
 	handler := server.Handler()
 
 	jobId, err := server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest})
@@ -157,7 +161,7 @@ func TestUpdate(t *testing.T) {
 	if err != nil {
 		t.Fatalf("error creating osbuild manifest: %v", err)
 	}
-	server := newTestServer(t, tempdir)
+	server := newTestServer(t, tempdir, []string{})
 	handler := server.Handler()
 
 	jobId, err := server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest})
@@ -186,7 +190,7 @@ func TestArgs(t *testing.T) {
 	tempdir, err := ioutil.TempDir("", "worker-tests-")
 	require.NoError(t, err)
 	defer os.RemoveAll(tempdir)
-	server := newTestServer(t, tempdir)
+	server := newTestServer(t, tempdir, []string{})
 
 	job := worker.OSBuildJob{
 		Manifest:  manifest,
@@ -226,7 +230,7 @@ func TestUpload(t *testing.T) {
 	if err != nil {
 		t.Fatalf("error creating osbuild manifest: %v", err)
 	}
-	server := newTestServer(t, tempdir)
+	server := newTestServer(t, tempdir, []string{})
 	handler := server.Handler()
 
 	jobID, err := server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest})
@@ -241,3 +245,112 @@ func TestUpload(t *testing.T) {
 
 	test.TestRoute(t, handler, false, "PUT", fmt.Sprintf("/api/worker/v1/jobs/%s/artifacts/foobar", token), `this is my artifact`, http.StatusOK, `?`)
 }
+
+func TestIdentities(t *testing.T) {
+	tempdir, err := ioutil.TempDir("", "worker-tests-")
+	require.NoError(t, err)
+	defer os.RemoveAll(tempdir)
+
+	// distroStruct := test_distro.New()
+	// arch, err := distroStruct.GetArch(test_distro.TestArchName)
+	// require.NoError(t, err)
+	// imageType, err := arch.GetImageType(test_distro.TestImageTypeName)
+	// require.NoError(t, err)
+	// manifest, err := imageType.Manifest(nil, distro.ImageOptions{Size: imageType.Size(0)}, nil, nil, 0)
+	// require.NoError(t, err)
+
+	server := newTestServer(t, tempdir, []string{"000000"})
+	handler := server.Handler()
+
+	// _, err := server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest})
+	// require.NoError(t, err)
+
+	test.TestRoute(t, handler, false, "GET", "/api/worker/v1/status", ``, http.StatusNotFound, `{"message":"Auth header is not present"}`, "message")
+
+	header := map[string]string{
+		"x-rh-identity": "eyJlbnRpdGxlbWVudHMiOnsiaW5zaWdodHMiOnsiaXNfZW50aXRsZWQiOnRydWV9LCJzbWFydF9tYW5hZ2VtZW50Ijp7ImlzX2VudGl0bGVkIjp0cnVlfSwib3BlbnNoaWZ0Ijp7ImlzX2VudGl0bGVkIjp0cnVlfSwiaHlicmlkIjp7ImlzX2VudGl0bGVkIjp0cnVlfSwibWlncmF0aW9ucyI6eyJpc19lbnRpdGxlZCI6dHJ1ZX0sImFuc2libGUiOnsiaXNfZW50aXRsZWQiOnRydWV9fSwiaWRlbnRpdHkiOnsiYWNjb3VudF9udW1iZXIiOiIwMDAwMDMiLCJ0eXBlIjoiVXNlciIsInVzZXIiOnsidXNlcm5hbWUiOiJ1c2VyIiwiZW1haWwiOiJ1c2VyQHVzZXIudXNlciIsImZpcnN0X25hbWUiOiJ1c2VyIiwibGFzdF9uYW1lIjoidXNlciIsImlzX2FjdGl2ZSI6dHJ1ZSwiaXNfb3JnX2FkbWluIjp0cnVlLCJpc19pbnRlcm5hbCI6dHJ1ZSwibG9jYWxlIjoiZW4tVVMifSwiaW50ZXJuYWwiOnsib3JnX2lkIjoiMDAwMDAwIn19fQo=",
+	}
+	response := test.SendHTTPWithHeader(handler, "GET", "/api/worker/v1/status", ``, header)
+	assert.Equal(t, 404, response.StatusCode, "status mismatch")
+
+	header = map[string]string{
+		"x-rh-identity": "eyJlbnRpdGxlbWVudHMiOnsiaW5zaWdodHMiOnsiaXNfZW50aXRsZWQiOnRydWV9LCJzbWFydF9tYW5hZ2VtZW50Ijp7ImlzX2VudGl0bGVkIjp0cnVlfSwib3BlbnNoaWZ0Ijp7ImlzX2VudGl0bGVkIjp0cnVlfSwiaHlicmlkIjp7ImlzX2VudGl0bGVkIjp0cnVlfSwibWlncmF0aW9ucyI6eyJpc19lbnRpdGxlZCI6dHJ1ZX0sImFuc2libGUiOnsiaXNfZW50aXRsZWQiOnRydWV9fSwiaWRlbnRpdHkiOnsiYWNjb3VudF9udW1iZXIiOiIwMDAwMDAiLCJ0eXBlIjoiVXNlciIsInVzZXIiOnsidXNlcm5hbWUiOiJ1c2VyIiwiZW1haWwiOiJ1c2VyQHVzZXIudXNlciIsImZpcnN0X25hbWUiOiJ1c2VyIiwibGFzdF9uYW1lIjoidXNlciIsImlzX2FjdGl2ZSI6dHJ1ZSwiaXNfb3JnX2FkbWluIjp0cnVlLCJpc19pbnRlcm5hbCI6dHJ1ZSwibG9jYWxlIjoiZW4tVVMifSwiaW50ZXJuYWwiOnsib3JnX2lkIjoiMDAwMDAwIn19fQ==",
+	}
+
+	response = test.SendHTTPWithHeader(handler, "GET", "/api/worker/v1/status", ``, header)
+	assert.Equal(t, 200, response.StatusCode, "status mismatch")
+}
+
+func TestOAuth(t *testing.T) {
+	tempdir, err := ioutil.TempDir("", "worker-tests-")
+	require.NoError(t, err)
+	defer os.RemoveAll(tempdir)
+
+	q, err := fsjobqueue.New(tempdir)
+	require.NoError(t, err)
+	workerServer := worker.NewServer(nil, q, tempdir, []string{"000000"})
+	handler := workerServer.Handler()
+
+	workSrv := httptest.NewServer(handler)
+	defer workSrv.Close()
+
+	/* Start a server which will act as a proxy, adding a valid identity header  */
+	proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, "Bearer accessToken!", r.Header.Get("Authorization"))
+		r.Header.Set("x-rh-identity", "eyJlbnRpdGxlbWVudHMiOnsiaW5zaWdodHMiOnsiaXNfZW50aXRsZWQiOnRydWV9LCJzbWFydF9tYW5hZ2VtZW50Ijp7ImlzX2VudGl0bGVkIjp0cnVlfSwib3BlbnNoaWZ0Ijp7ImlzX2VudGl0bGVkIjp0cnVlfSwiaHlicmlkIjp7ImlzX2VudGl0bGVkIjp0cnVlfSwibWlncmF0aW9ucyI6eyJpc19lbnRpdGxlZCI6dHJ1ZX0sImFuc2libGUiOnsiaXNfZW50aXRsZWQiOnRydWV9fSwiaWRlbnRpdHkiOnsiYWNjb3VudF9udW1iZXIiOiIwMDAwMDAiLCJ0eXBlIjoiVXNlciIsInVzZXIiOnsidXNlcm5hbWUiOiJ1c2VyIiwiZW1haWwiOiJ1c2VyQHVzZXIudXNlciIsImZpcnN0X25hbWUiOiJ1c2VyIiwibGFzdF9uYW1lIjoidXNlciIsImlzX2FjdGl2ZSI6dHJ1ZSwiaXNfb3JnX2FkbWluIjp0cnVlLCJpc19pbnRlcm5hbCI6dHJ1ZSwibG9jYWxlIjoiZW4tVVMifSwiaW50ZXJuYWwiOnsib3JnX2lkIjoiMDAwMDAwIn19fQ==")
+		handler.ServeHTTP(w, r)
+	}))
+	defer proxySrv.Close()
+
+	offlineToken := "someOfflineToken"
+	/* Start oauth srv supplying the bearer token */
+	oauthSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, "POST", r.Method)
+		err = r.ParseForm()
+		require.NoError(t, err)
+
+		require.Equal(t, "refresh_token", r.FormValue("grant_type"))
+		require.Equal(t, "rhsm-api", r.FormValue("client_id"))
+		require.Equal(t, offlineToken, r.FormValue("refresh_token"))
+
+		bt := struct {
+			AccessToken     string `json:"access_token"`
+			ValidForSeconds int    `json:"expires_in"`
+		}{
+			"accessToken!",
+			900,
+		}
+
+		w.WriteHeader(http.StatusOK)
+		w.Header().Set("Content-Type", "application/json")
+		err = json.NewEncoder(w).Encode(bt)
+		require.NoError(t, err)
+	}))
+	defer oauthSrv.Close()
+
+	distroStruct := test_distro.New()
+	arch, err := distroStruct.GetArch(test_distro.TestArchName)
+	if err != nil {
+		t.Fatalf("error getting arch from distro: %v", err)
+	}
+	imageType, err := arch.GetImageType(test_distro.TestImageTypeName)
+	if err != nil {
+		t.Fatalf("error getting image type from arch: %v", err)
+	}
+	manifest, err := imageType.Manifest(nil, distro.ImageOptions{Size: imageType.Size(0)}, nil, nil, 0)
+	if err != nil {
+		t.Fatalf("error creating osbuild manifest: %v", err)
+	}
+
+	_, err = workerServer.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest})
+	require.NoError(t, err)
+
+	client, err := worker.NewClient(proxySrv.URL, nil, &offlineToken, &oauthSrv.URL)
+	require.NoError(t, err)
+	job, err := client.RequestJob([]string{"osbuild"}, arch.Name())
+	require.NoError(t, err)
+	r := strings.NewReader("artifact contents")
+	require.NoError(t, job.UploadArtifact("some-artifact", r))
+	c, err := job.Canceled()
+	require.False(t, c)
+}
diff --git a/test/cases/api.sh b/test/cases/api.sh
index 5fb1004e9..f1f8d41cb 100755
--- a/test/cases/api.sh
+++ b/test/cases/api.sh
@@ -36,6 +36,7 @@ fi
 CLOUD_PROVIDER_AWS="aws"
 CLOUD_PROVIDER_GCP="gcp"
 CLOUD_PROVIDER_AZURE="azure"
+CLOUD_PROVIDER_AWS_S3="aws.s3"
 
 CLOUD_PROVIDER=${1:-$CLOUD_PROVIDER_AWS}
 
@@ -53,8 +54,15 @@ case $CLOUD_PROVIDER in
   "$CLOUD_PROVIDER_AZURE")
     echo "Testing Azure"
     ;;
+  "$CLOUD_PROVIDER_AWS_S3")
+    echo "Testing S3 bucket upload"
+    if [[ $ID != "rhel" ]]; then
+        echo "Skipped. S3 upload test is only tested on RHEL (testing only image type: rhel-edge-commit)."
+        exit 0
+    fi
+    ;;
   *)
-    echo "Unknown cloud provider '$CLOUD_PROVIDER'. Supported are '$CLOUD_PROVIDER_AWS', '$CLOUD_PROVIDER_GCP'"
+    echo "Unknown cloud provider '$CLOUD_PROVIDER'. Supported are '$CLOUD_PROVIDER_AWS', '$CLOUD_PROVIDER_AWS_S3', '$CLOUD_PROVIDER_GCP', '$CLOUD_PROVIDER_AZURE'"
     exit 1
     ;;
 esac
@@ -79,7 +87,7 @@ function checkEnvAzure() {
 }
 
 case $CLOUD_PROVIDER in
-  "$CLOUD_PROVIDER_AWS")
+  "$CLOUD_PROVIDER_AWS" | "$CLOUD_PROVIDER_AWS_S3")
     checkEnvAWS
     ;;
   "$CLOUD_PROVIDER_GCP")
@@ -112,6 +120,28 @@ function cleanupAWS() {
   fi
 }
 
+function cleanupAWSS3() {
+  local S3_URL
+  S3_URL=$(echo "$UPLOAD_OPTIONS" | jq -r '.url')
+
+  # extract filename component from URL
+  local S3_FILENAME
+  S3_FILENAME=$(echo "${S3_URL}" | grep -oP '(?<=/)[^/]+(?=\?)')
+
+  # prepend bucket
+  local S3_URI
+  S3_URI="s3://${AWS_BUCKET}/${S3_FILENAME}"
+
+  # since this function can be called at any time, ensure that we don't expand unbound variables
+  AWS_CMD="${AWS_CMD:-}"
+
+  if [ -n "$AWS_CMD" ]; then
+    set +e
+    $AWS_CMD s3 rm "${S3_URI}"
+    set -e
+  fi
+}
+
 function cleanupGCP() {
   # since this function can be called at any time, ensure that we don't expand unbound variables
   GCP_CMD="${GCP_CMD:-}"
@@ -149,6 +179,9 @@ function cleanup() {
     "$CLOUD_PROVIDER_AWS")
       cleanupAWS
       ;;
+    "$CLOUD_PROVIDER_AWS_S3")
+      cleanupAWSS3
+      ;;
     "$CLOUD_PROVIDER_GCP")
       cleanupGCP
       ;;
@@ -219,7 +252,7 @@ gpgkey=https://packages.microsoft.com/keys/microsoft.asc" | sudo tee /etc/yum.re
 }
 
 case $CLOUD_PROVIDER in
-  "$CLOUD_PROVIDER_AWS")
+  "$CLOUD_PROVIDER_AWS" | "$CLOUD_PROVIDER_AWS_S3")
     installClientAWS
     ;;
   "$CLOUD_PROVIDER_GCP")
@@ -332,6 +365,40 @@ function createReqFileAWS() {
 EOF
 }
 
+function createReqFileAWSS3() {
+  cat > "$REQUEST_FILE" << EOF
+{
+  "distribution": "$DISTRO",
+  "customizations": {
+    "packages": [
+      "postgresql"
+    ]
+  },
+  "image_requests": [
+    {
+      "architecture": "$ARCH",
+      "image_type": "rhel-edge-commit",
+      "repositories": $(jq ".\"$ARCH\"" /usr/share/tests/osbuild-composer/repositories/"$DISTRO".json),
+      "ostree": {
+        "ref": "test/rhel/8/edge"
+      },
+      "upload_request": {
+          "type": "aws.s3",
+          "options": {
+            "region": "${AWS_REGION}",
+            "s3": {
+              "access_key_id": "${AWS_ACCESS_KEY_ID}",
+              "secret_access_key": "${AWS_SECRET_ACCESS_KEY}",
+              "bucket": "${AWS_BUCKET}"
+            }
+          }
+      }
+    }
+  ]
+}
+EOF
+}
+
 function createReqFileGCP() {
   # constrains for GCP resource IDs:
   # - max 62 characters
@@ -401,6 +468,9 @@ case $CLOUD_PROVIDER in
   "$CLOUD_PROVIDER_AWS")
     createReqFileAWS
     ;;
+  "$CLOUD_PROVIDER_AWS_S3")
+    createReqFileAWSS3
+    ;;
   "$CLOUD_PROVIDER_GCP")
     createReqFileGCP
     ;;
@@ -481,6 +551,15 @@ function checkUploadStatusOptionsAWS() {
   test "$REGION" = "$AWS_REGION"
 }
 
+function checkUploadStatusOptionsAWSS3() {
+  local S3_URL
+  S3_URL=$(echo "$UPLOAD_OPTIONS" | jq -r '.url')
+
+  # S3 URL contains region and bucket name
+  echo "$S3_URL" | grep -F "$AWS_BUCKET" -
+  echo "$S3_URL" | grep -F "$AWS_REGION" -
+}
+
 function checkUploadStatusOptionsGCP() {
   GCP_PROJECT=$(jq -r '.project_id' "$GOOGLE_APPLICATION_CREDENTIALS")
 
@@ -504,6 +583,9 @@ case $CLOUD_PROVIDER in
   "$CLOUD_PROVIDER_AWS")
     checkUploadStatusOptionsAWS
     ;;
+  "$CLOUD_PROVIDER_AWS_S3")
+    checkUploadStatusOptionsAWSS3
+    ;;
   "$CLOUD_PROVIDER_GCP")
     checkUploadStatusOptionsGCP
     ;;
@@ -583,6 +665,19 @@ function verifyInAWS() {
   fi
 }
 
+
+# Verify tarball in AWS S3
+function verifyInAWSS3() {
+  local S3_URL
+  S3_URL=$(echo "$UPLOAD_OPTIONS" | jq -r '.url')
+
+  # Download the commit using the Presigned URL
+  curl "${S3_URL}" --output "${WORKDIR}/edge-commit.tar"
+
+  # Verify that the commit contains the ref we defined in the request
+  tar tvf "${WORKDIR}/edge-commit.tar" "repo/refs/heads/test/rhel/8/edge"
+}
+
 # Verify image in Compute Engine on GCP
 function verifyInGCP() {
   # Authenticate
@@ -650,6 +745,9 @@ case $CLOUD_PROVIDER in
   "$CLOUD_PROVIDER_AWS")
     verifyInAWS
     ;;
+  "$CLOUD_PROVIDER_AWS_S3")
+    verifyInAWSS3
+    ;;
   "$CLOUD_PROVIDER_GCP")
     verifyInGCP
     ;;