You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@openwhisk.apache.org by ra...@apache.org on 2020/04/18 20:26:48 UTC

[openwhisk] branch master updated: Allow OPTIONS response on web actions before checking for authentication requirement.

This is an automated email from the ASF dual-hosted git repository.

rabbah pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/openwhisk.git


The following commit(s) were added to refs/heads/master by this push:
     new 0069fd3  Allow OPTIONS response on web actions before checking for authentication requirement.
0069fd3 is described below

commit 0069fd36af8838ef26230d6f01a71da35cc2d26e
Author: Rodric Rabbah <ro...@gmail.com>
AuthorDate: Tue Apr 14 22:57:12 2020 -0400

    Allow OPTIONS response on web actions before checking for authentication requirement.
---
 .../openwhisk/core/controller/WebActions.scala     | 182 ++++++++++---------
 .../core/controller/test/WebActionsApiTests.scala  | 192 ++++++++++++++-------
 2 files changed, 223 insertions(+), 151 deletions(-)

diff --git a/core/controller/src/main/scala/org/apache/openwhisk/core/controller/WebActions.scala b/core/controller/src/main/scala/org/apache/openwhisk/core/controller/WebActions.scala
index a62a5cb..17536a5 100644
--- a/core/controller/src/main/scala/org/apache/openwhisk/core/controller/WebActions.scala
+++ b/core/controller/src/main/scala/org/apache/openwhisk/core/controller/WebActions.scala
@@ -175,7 +175,7 @@ protected[core] object WhiskWebActionsApi extends Directives {
   }
 
   /**
-   * Supported extensions, their default projection and transcoder to complete a request.
+   * Supported extensions and transcoder to complete a request.
    *
    * @param extension  the supported media types for action response
    * @param transcoder the HTTP decoder and terminator for the extension
@@ -450,11 +450,6 @@ trait WhiskWebActionsApi
    * extension is one of supported media types. An example is ".json" for a JSON response or ".html" for
    * an text/html response.
    *
-   * Optionally, the result form the action may be projected based on a named property. As in
-   * /web/some-namespace/some-package/some-action/some-property. If the property
-   * does not exist in the result then a NotFound error is generated. A path of properties may
-   * be supplied to project nested properties.
-   *
    * Actions may be exposed to this web proxy by adding an annotation ("export" -> true).
    */
   def routes(user: Option[Identity])(implicit transid: TransactionId): Route = {
@@ -498,27 +493,43 @@ trait WhiskWebActionsApi
           validateSize(isWhithinRange(e.contentLengthOption.getOrElse(0)))(transid, jsonPrettyPrinter) {
             requestMethodParamsAndPath { context =>
               provide(fullyQualifiedActionName(actionName)) { fullActionName =>
-                onComplete(verifyWebAction(fullActionName, onBehalfOf.isDefined)) {
+                onComplete(verifyWebAction(fullActionName)) {
                   case Success((actionOwnerIdentity, action)) =>
-                    val requiredAuthOk =
-                      requiredWhiskAuthSuccessful(action.annotations, context.headers).getOrElse(true)
-                    if (!requiredAuthOk) {
-                      logging.debug(
-                        this,
-                        "web action with require-whisk-auth was invoked without a matching x-require-whisk-auth header value")
-                      terminate(Unauthorized)
-                    } else if (!action.annotations
-                                 .getAs[Boolean](Annotations.WebCustomOptionsAnnotationName)
-                                 .getOrElse(false)) {
+                    val actionDelegatesCors =
+                      !action.annotations.getAs[Boolean](Annotations.WebCustomOptionsAnnotationName).getOrElse(false)
+
+                    if (actionDelegatesCors) {
                       respondWithHeaders(defaultCorsResponse(context.headers)) {
                         if (context.method == OPTIONS) {
                           complete(OK, HttpEntity.Empty)
                         } else {
-                          extractEntityAndProcessRequest(actionOwnerIdentity, action, extension, onBehalfOf, context, e)
+                          extractEntityAndProcessRequest(
+                            confirmAuthenticated(action.annotations, context.headers, onBehalfOf).getOrElse(true),
+                            actionOwnerIdentity,
+                            action,
+                            extension,
+                            onBehalfOf,
+                            context,
+                            e)
                         }
                       }
                     } else {
-                      extractEntityAndProcessRequest(actionOwnerIdentity, action, extension, onBehalfOf, context, e)
+                      val allowedToProceed = if (context.method != OPTIONS) {
+                        confirmAuthenticated(action.annotations, context.headers, onBehalfOf).getOrElse(true)
+                      } else {
+                        // invoke the action for OPTIONS even if user is not authorized
+                        // so that action can respond to option request
+                        true
+                      }
+
+                      extractEntityAndProcessRequest(
+                        allowedToProceed,
+                        actionOwnerIdentity,
+                        action,
+                        extension,
+                        onBehalfOf,
+                        context,
+                        e)
                     }
 
                   case Failure(t: RejectRequest) =>
@@ -549,12 +560,11 @@ trait WhiskWebActionsApi
    *         not entitled (throttled), package/action not found, action not web enabled,
    *         or request overrides final parameters
    */
-  private def verifyWebAction(actionName: FullyQualifiedEntityName, authenticated: Boolean)(
-    implicit transid: TransactionId) = {
+  private def verifyWebAction(actionName: FullyQualifiedEntityName)(implicit transid: TransactionId) = {
 
     // lookup the identity for the action namespace
     identityLookup(actionName.path.root) flatMap { actionOwnerIdentity =>
-      confirmExportedAction(actionLookup(actionName), authenticated) flatMap { a =>
+      confirmExportedAction(actionLookup(actionName)) flatMap { a =>
         checkEntitlement(actionOwnerIdentity, a) map { _ =>
           (actionOwnerIdentity, a)
         }
@@ -562,7 +572,8 @@ trait WhiskWebActionsApi
     }
   }
 
-  private def extractEntityAndProcessRequest(actionOwnerIdentity: Identity,
+  private def extractEntityAndProcessRequest(authorizedToProceed: Boolean,
+                                             actionOwnerIdentity: Identity,
                                              action: WhiskActionMetaData,
                                              extension: MediaExtension,
                                              onBehalfOf: Option[Identity],
@@ -573,40 +584,46 @@ trait WhiskWebActionsApi
       processRequest(actionOwnerIdentity, action, extension, onBehalfOf, context.withBody(body), isRawHttpAction)
     }
 
-    provide(action.annotations.getAs[Boolean](Annotations.RawHttpAnnotationName).getOrElse(false)) { isRawHttpAction =>
-      httpEntity match {
-        case Empty =>
-          process(None, isRawHttpAction)
+    if (authorizedToProceed) {
+      provide(action.annotations.getAs[Boolean](Annotations.RawHttpAnnotationName).getOrElse(false)) {
+        isRawHttpAction =>
+          httpEntity match {
+            case Empty =>
+              process(None, isRawHttpAction)
+
+            case HttpEntity.Strict(ct, json) if WhiskWebActionsApi.isJsonFamily(ct.mediaType) && !isRawHttpAction =>
+              if (json.nonEmpty) {
+                entity(as[JsValue]) { body =>
+                  process(Some(body), isRawHttpAction)
+                }
+              } else {
+                process(None, isRawHttpAction)
+              }
 
-        case HttpEntity.Strict(ct, json) if WhiskWebActionsApi.isJsonFamily(ct.mediaType) && !isRawHttpAction =>
-          if (json.nonEmpty) {
-            entity(as[JsValue]) { body =>
-              process(Some(body), isRawHttpAction)
-            }
-          } else {
-            process(None, isRawHttpAction)
-          }
+            case HttpEntity.Strict(ContentType(MediaTypes.`application/x-www-form-urlencoded`, _), _)
+                if !isRawHttpAction =>
+              entity(as[FormData]) { form =>
+                val body = form.fields.toMap.toJson.asJsObject
+                process(Some(body), isRawHttpAction)
+              }
 
-        case HttpEntity.Strict(ContentType(MediaTypes.`application/x-www-form-urlencoded`, _), _) if !isRawHttpAction =>
-          entity(as[FormData]) { form =>
-            val body = form.fields.toMap.toJson.asJsObject
-            process(Some(body), isRawHttpAction)
-          }
+            case HttpEntity.Strict(contentType, data) =>
+              // for legacy, we are encoding application/json still
+              if (contentType.mediaType.binary || contentType.mediaType == `application/json`) {
+                Try(JsString(Base64.getEncoder.encodeToString(data.toArray))) match {
+                  case Success(bytes) => process(Some(bytes), isRawHttpAction)
+                  case Failure(t)     => terminate(BadRequest, Messages.unsupportedContentType(contentType.mediaType))
+                }
+              } else {
+                val str = JsString(data.utf8String)
+                process(Some(str), isRawHttpAction)
+              }
 
-        case HttpEntity.Strict(contentType, data) =>
-          // for legacy, we are encoding application/json still
-          if (contentType.mediaType.binary || contentType.mediaType == `application/json`) {
-            Try(JsString(Base64.getEncoder.encodeToString(data.toArray))) match {
-              case Success(bytes) => process(Some(bytes), isRawHttpAction)
-              case Failure(t)     => terminate(BadRequest, Messages.unsupportedContentType(contentType.mediaType))
-            }
-          } else {
-            val str = JsString(data.utf8String)
-            process(Some(str), isRawHttpAction)
+            case _ => terminate(BadRequest, Messages.unsupportedContentType)
           }
-
-        case _ => terminate(BadRequest, Messages.unsupportedContentType)
       }
+    } else {
+      terminate(Unauthorized)
     }
   }
 
@@ -648,9 +665,8 @@ trait WhiskWebActionsApi
             val resultPath = if (activation.response.isSuccess) {
               List.empty
             } else {
-              // the activation produced an error response: therefore ignore
-              // the requested projection and unwrap the error instead
-              // and attempt to handle it per the desired response type (extension)
+              // the activation produced an error response, so look for an error property
+              // in the response, unwrap it and use it to terminate the response
               List(ActivationResponse.ERROR_FIELD)
             }
 
@@ -717,24 +733,19 @@ trait WhiskWebActionsApi
 
   /**
    * Checks if an action is exported (i.e., carries the required annotation).
+   * This function does not check if web action requires authentication.
    */
-  private def confirmExportedAction(actionLookup: Future[WhiskActionMetaData], authenticated: Boolean)(
+  private def confirmExportedAction(actionLookup: Future[WhiskActionMetaData])(
     implicit transid: TransactionId): Future[WhiskActionMetaData] = {
     actionLookup flatMap { action =>
-      val requiresAuthenticatedUser =
-        action.annotations.getAs[Boolean](Annotations.RequireWhiskAuthAnnotation).getOrElse(false)
       val isExported = action.annotations.getAs[Boolean](Annotations.WebActionAnnotationName).getOrElse(false)
 
-      if ((isExported && requiresAuthenticatedUser && authenticated) ||
-          (isExported && !requiresAuthenticatedUser)) {
+      if (isExported) {
         logging.debug(this, s"${action.fullyQualifiedName(true)} is exported")
         Future.successful(action)
-      } else if (!isExported) {
+      } else {
         logging.debug(this, s"${action.fullyQualifiedName(true)} not exported")
         Future.failed(RejectRequest(NotFound))
-      } else {
-        logging.debug(this, s"${action.fullyQualifiedName(true)} requires authentication")
-        Future.failed(RejectRequest(Unauthorized))
       }
     }
   }
@@ -751,30 +762,33 @@ trait WhiskWebActionsApi
   }
 
   /**
-   * Checks if "require-whisk-auth" authentication is needed, and if so, authenticate the request.
-   * NOTE: Only number or string JSON "require-whisk-auth" annotation values are supported.
+   * Checks if an action requires authenticate and is authenticated (i.e., carries the required annotation).
+   * This function assumes the action is a web action.
    *
-   * @param annotations - web action annotations
-   * @param reqHeaders  - web action invocation request headers
-   * @return Option[Boolean]
-   *         None if annotations does not include require-whisk-auth (i.e., auth test not needed)
-   *         Some(true) if annotations includes require-whisk-auth and its value matches the request header `X-Require-Whisk-Auth` value
-   *         Some(false) if annotations includes require-whisk-auth and the request does not include the header `X-Require-Whisk-Auth`
-   *         Some(false) if annotations includes require-whisk-auth and its value does not match the request header `X-Require-Whisk-Auth` value
+   * @param annotations the web action annotations
+   * @param reqHeaders the web action invocation request headers
+   * @param authenticatedUser true if this request is from an authenticated whisk user
+   * @return None if web annotation does not specify an authentication scheme
+   *         Some(true) if web annotation includes require-whisk-auth and value matches the request header `X-Require-Whisk-Auth` value
+   *         Some(true) if web annotation requires an authenticated whisk user and that user has already authenticated
+   *         Some(false) if web annotation includes require-whisk-auth and the request does not include the header `X-Require-Whisk-Auth`
+   *         Some(false) if web annotation includes require-whisk-auth and its value does not match the request header `X-Require-Whisk-Auth` value
    */
-  private def requiredWhiskAuthSuccessful(annotations: Parameters, reqHeaders: Seq[HttpHeader]): Option[Boolean] = {
+  private def confirmAuthenticated(annotations: Parameters,
+                                   reqHeaders: Seq[HttpHeader],
+                                   authenticatedUser: Option[Identity]): Option[Boolean] = {
+    def checkAuthHeader(expected: String): Boolean = {
+      reqHeaders.find(_.is(WhiskAction.requireWhiskAuthHeader)).map(_.value == expected).getOrElse(false)
+    }
+
     annotations
       .get(Annotations.RequireWhiskAuthAnnotation)
-      .flatMap {
-        case JsString(authStr) => Some(authStr)
-        case JsNumber(authNum) => Some(authNum.toString)
-        case _                 => None
-      }
-      .map { reqWhiskAuthAnnotationStr =>
-        reqHeaders
-          .find(_.is(WhiskAction.requireWhiskAuthHeader))
-          .map(_.value == reqWhiskAuthAnnotationStr)
-          .getOrElse(false) // false => when no x-require-whisk-auth header is present
+      .map {
+        case JsString(auth)           => checkAuthHeader(auth) // allowed if auth matches header
+        case JsNumber(auth)           => checkAuthHeader(auth.toString) // allowed if auth matches header
+        case JsTrue | JsBoolean(true) => authenticatedUser.isDefined // allowed if user already authenticated
+        case _                        => false // not allowed, something is not right
       }
   }
+
 }
diff --git a/tests/src/test/scala/org/apache/openwhisk/core/controller/test/WebActionsApiTests.scala b/tests/src/test/scala/org/apache/openwhisk/core/controller/test/WebActionsApiTests.scala
index e91b5a1..dd7ce68 100644
--- a/tests/src/test/scala/org/apache/openwhisk/core/controller/test/WebActionsApiTests.scala
+++ b/tests/src/test/scala/org/apache/openwhisk/core/controller/test/WebActionsApiTests.scala
@@ -135,7 +135,6 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac
   var failThrottleForSubject: Option[Subject] = None // toggle to cause throttle to fail for subject
   var failCheckEntitlement = false // toggle to cause entitlement to fail
   var actionResult: Option[JsObject] = None
-  var requireAuthenticationAsBoolean = true // toggle value set in require-whisk-auth annotation (true or requireAuthenticationKey)
   var testParametersInInvokeAction = true // toggle to test parameter in invokeAction
   var requireAuthenticationKey = "example-web-action-api-key"
   var invocationCount = 0
@@ -165,7 +164,6 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac
     failThrottleForSubject = None
     failCheckEntitlement = false
     actionResult = None
-    requireAuthenticationAsBoolean = true
     testParametersInInvokeAction = true
     assert(invocationsAllowed == invocationCount, "allowed invoke count did not match actual")
     cleanup()
@@ -393,53 +391,97 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac
         }
     }
 
-    it should s"reject requests when authentication is required but none given (auth? ${creds.isDefined})" in {
+    it should s"reject requests when whisk authentication is required but none given (auth? ${creds.isDefined})" in {
       implicit val tid = transid()
 
+      val entityName = MakeName.next("export")
+      val action =
+        stubAction(
+          proxyNamespace,
+          entityName,
+          customOptions = false,
+          requireAuthentication = true,
+          requireAuthenticationAsBoolean = true)
+      val path = action.fullyQualifiedName(false)
+      put(entityStore, action)
+
       allowedMethods.foreach { m =>
-        Seq(true, false).foreach { useReqWhiskAuthBool =>
-          requireAuthenticationAsBoolean = useReqWhiskAuthBool
+        m(s"$testRoutePath/${path}.json") ~> Route.seal(routes(creds)) ~> check {
+          if (m === Options) {
+            status should be(OK) // options response is always present regardless of auth
+            header("Access-Control-Allow-Origin").get.toString shouldBe "Access-Control-Allow-Origin: *"
+            header("Access-Control-Allow-Methods").get.toString shouldBe "Access-Control-Allow-Methods: OPTIONS, GET, DELETE, POST, PUT, HEAD, PATCH"
+            header("Access-Control-Request-Headers") shouldBe empty
+          } else if (creds.isEmpty) {
+            status should be(Unauthorized) // if user is not authenticated, reject all requests
+          } else {
+            invocationsAllowed += 1
+            status should be(OK)
+            val response = responseAs[JsObject]
+            response shouldBe JsObject(
+              "pkg" -> s"$systemId/proxy".toJson,
+              "action" -> entityName.asString.toJson,
+              "content" -> metaPayload(m.method.name.toLowerCase, JsObject.empty, creds, pkgName = "proxy"))
+            response
+              .fields("content")
+              .asJsObject
+              .fields(webApiDirectives.namespace) shouldBe creds.get.namespace.name.toJson
+          }
         }
+      }
+    }
 
-        val entityName = MakeName.next("export")
-        val action = stubAction(
+    it should s"reject requests when x-authentication is required but none given (auth? ${creds.isDefined})" in {
+      implicit val tid = transid()
+
+      val entityName = MakeName.next("export")
+      val action =
+        stubAction(
           proxyNamespace,
           entityName,
+          customOptions = false,
           requireAuthentication = true,
-          requireAuthenticationAsBoolean = requireAuthenticationAsBoolean)
-        val path = action.fullyQualifiedName(false)
+          requireAuthenticationAsBoolean = false)
+      val path = action.fullyQualifiedName(false)
+      put(entityStore, action)
 
-        put(entityStore, action)
+      allowedMethods.foreach { m =>
+        // web action require-whisk-auth is set, but the header X-Require-Whisk-Auth value does not match
+        m(s"$testRoutePath/${path}.json") ~> addHeader(
+          WhiskAction.requireWhiskAuthHeader,
+          requireAuthenticationKey + "-bad") ~> Route
+          .seal(routes(creds)) ~> check {
+          if (m == Options) {
+            status should be(OK) // options should always respond
+            header("Access-Control-Allow-Origin").get.toString shouldBe "Access-Control-Allow-Origin: *"
+            header("Access-Control-Allow-Methods").get.toString shouldBe "Access-Control-Allow-Methods: OPTIONS, GET, DELETE, POST, PUT, HEAD, PATCH"
+            header("Access-Control-Request-Headers") shouldBe empty
+          } else {
+            status should be(Unauthorized)
+          }
+        }
 
-        if (requireAuthenticationAsBoolean) {
-          if (creds.isDefined) {
-            val user = creds.get
-            invocationsAllowed += 1
-            m(s"$testRoutePath/${path}.json") ~> Route
-              .seal(routes(creds)) ~> check {
-              status should be(OK)
-              val response = responseAs[JsObject]
-              response shouldBe JsObject(
-                "pkg" -> s"$systemId/proxy".toJson,
-                "action" -> entityName.asString.toJson,
-                "content" -> metaPayload(m.method.name.toLowerCase, JsObject.empty, creds, pkgName = "proxy"))
-              response
-                .fields("content")
-                .asJsObject
-                .fields(webApiDirectives.namespace) shouldBe user.namespace.name.toJson
-            }
+        // web action require-whisk-auth is set, but the header X-Require-Whisk-Auth value is not set
+        m(s"$testRoutePath/${path}.json") ~> Route.seal(routes(creds)) ~> check {
+          if (m == Options) {
+            status should be(OK) // options should always respond
+            header("Access-Control-Allow-Origin").get.toString shouldBe "Access-Control-Allow-Origin: *"
+            header("Access-Control-Allow-Methods").get.toString shouldBe "Access-Control-Allow-Methods: OPTIONS, GET, DELETE, POST, PUT, HEAD, PATCH"
+            header("Access-Control-Request-Headers") shouldBe empty
           } else {
-            m(s"$testRoutePath/${path}.json") ~> Route.seal(routes(creds)) ~> check {
-              status should be(Unauthorized)
-            }
+            status should be(Unauthorized)
           }
-        } else if (creds.isDefined) {
-          val user = creds.get
-          invocationsAllowed += 1
+        }
 
-          // web action require-whisk-auth is set and the header X-Require-Whisk-Auth value does not matches
-          m(s"$testRoutePath/${path}.json") ~> addHeader("X-Require-Whisk-Auth", requireAuthenticationKey) ~> Route
-            .seal(routes(creds)) ~> check {
+        m(s"$testRoutePath/${path}.json") ~> addHeader(WhiskAction.requireWhiskAuthHeader, requireAuthenticationKey) ~> Route
+          .seal(routes(creds)) ~> check {
+          if (m == Options) {
+            status should be(OK) // options should always respond
+            header("Access-Control-Allow-Origin").get.toString shouldBe "Access-Control-Allow-Origin: *"
+            header("Access-Control-Allow-Methods").get.toString shouldBe "Access-Control-Allow-Methods: OPTIONS, GET, DELETE, POST, PUT, HEAD, PATCH"
+            header("Access-Control-Request-Headers") shouldBe empty
+          } else {
+            invocationsAllowed += 1
             status should be(OK)
             val response = responseAs[JsObject]
             response shouldBe JsObject(
@@ -450,22 +492,13 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac
                 JsObject.empty,
                 creds,
                 pkgName = "proxy",
-                headers = List(RawHeader("X-Require-Whisk-Auth", requireAuthenticationKey))))
-            response
-              .fields("content")
-              .asJsObject
-              .fields(webApiDirectives.namespace) shouldBe user.namespace.name.toJson
-          }
-
-          // web action require-whisk-auth is set, but the header X-Require-Whisk-Auth value does not match
-          m(s"$testRoutePath/${path}.json") ~> addHeader("X-Require-Whisk-Auth", requireAuthenticationKey + "-bad") ~> Route
-            .seal(routes(creds)) ~> check {
-            status should be(Unauthorized)
-          }
-        } else {
-          // web action require-whisk-auth is set, but the header X-Require-Whisk-Auth value is not set
-          m(s"$testRoutePath/${path}.json") ~> Route.seal(routes(creds)) ~> check {
-            status should be(Unauthorized)
+                headers = List(RawHeader(WhiskAction.requireWhiskAuthHeader, requireAuthenticationKey))))
+            if (creds.isDefined) {
+              response
+                .fields("content")
+                .asJsObject
+                .fields(webApiDirectives.namespace) shouldBe creds.get.namespace.name.toJson
+            }
           }
         }
       }
@@ -824,7 +857,7 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac
       }
     }
 
-    it should s"not project a field from the result object (auth? ${creds.isDefined})" in {
+    it should s"pass the unmatched segment to the action (auth? ${creds.isDefined})" in {
       implicit val tid = transid()
 
       Seq(s"$systemId/proxy/export_c.json/content").foreach { path =>
@@ -845,10 +878,10 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac
       }
     }
 
-    it should s"reject when projecting a field from the result object that does not exist (auth? ${creds.isDefined})" in {
+    it should s"respond with error when expected text property does not exist (auth? ${creds.isDefined})" in {
       implicit val tid = transid()
 
-      Seq(s"$systemId/proxy/export_c.text/foobar", s"$systemId/proxy/export_c.text/content/z/x").foreach { path =>
+      Seq(s"$systemId/proxy/export_c.text").foreach { path =>
         allowedMethods.foreach { m =>
           invocationsAllowed += 1
 
@@ -862,11 +895,10 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac
       }
     }
 
-    it should s"not project an http response (auth? ${creds.isDefined})" in {
+    it should s"use action status code and headers to terminate an http response (auth? ${creds.isDefined})" in {
       implicit val tid = transid()
 
-      // http extension does not project
-      Seq(s"$systemId/proxy/export_c.http/content/response").foreach { path =>
+      Seq(s"$systemId/proxy/export_c.http").foreach { path =>
         allowedMethods.foreach { m =>
           actionResult = Some(
             JsObject(
@@ -882,7 +914,7 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac
       }
     }
 
-    it should s"use default projection for extension (auth? ${creds.isDefined})" in {
+    it should s"use default field projection for extension (auth? ${creds.isDefined})" in {
       implicit val tid = transid()
 
       Seq(s"$systemId/proxy/export_c.http").foreach { path =>
@@ -1378,10 +1410,10 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac
       }
     }
 
-    it should s"handle an activation that results in application error and response matches extension (auth? ${creds.isDefined})" in {
+    it should s"handle an activation that results in application error (auth? ${creds.isDefined})" in {
       implicit val tid = transid()
 
-      Seq(s"$systemId/proxy/export_c.http", s"$systemId/proxy/export_c.http/ignoreme").foreach { path =>
+      Seq(s"$systemId/proxy/export_c.http").foreach { path =>
         allowedMethods.foreach { m =>
           invocationsAllowed += 1
           actionResult = Some(
@@ -1398,10 +1430,10 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac
       }
     }
 
-    it should s"handle an activation that results in application error but where response does not match extension (auth? ${creds.isDefined})" in {
+    it should s"handle an activation that results in application error that does not match .json extension (auth? ${creds.isDefined})" in {
       implicit val tid = transid()
 
-      Seq(s"$systemId/proxy/export_c.json", s"$systemId/proxy/export_c.json/ignoreme").foreach { path =>
+      Seq(s"$systemId/proxy/export_c.json").foreach { path =>
         allowedMethods.foreach { m =>
           invocationsAllowed += 1
           actionResult = Some(JsObject("application_error" -> "bad response type".toJson))
@@ -1417,7 +1449,7 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac
     it should s"handle an activation that results in developer or system error (auth? ${creds.isDefined})" in {
       implicit val tid = transid()
 
-      Seq(s"$systemId/proxy/export_c.json", s"$systemId/proxy/export_c.json/ignoreme", s"$systemId/proxy/export_c.text")
+      Seq(s"$systemId/proxy/export_c.json", s"$systemId/proxy/export_c.text")
         .foreach { path =>
           Seq("developer_error", "whisk_error").foreach { e =>
             allowedMethods.foreach { m =>
@@ -1627,11 +1659,11 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac
       }
     }
 
-    it should s"invoke action with options verb with custom options (auth? ${creds.isDefined})" in {
+    it should s"respond with custom options (auth? ${creds.isDefined})" in {
       implicit val tid = transid()
 
       Seq(s"$systemId/proxy/export_c.http").foreach { path =>
-        invocationsAllowed += 1
+        invocationsAllowed += 1 // custom options means action is invoked
         actionResult =
           Some(JsObject("headers" -> JsObject("Access-Control-Allow-Methods" -> "OPTIONS, GET, PATCH".toJson)))
 
@@ -1645,6 +1677,32 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac
       }
     }
 
+    it should s"respond with custom options even when authentication is required but missing (auth? ${creds.isDefined})" in {
+      implicit val tid = transid()
+
+      val entityName = MakeName.next("export")
+      val action =
+        stubAction(
+          proxyNamespace,
+          entityName,
+          customOptions = true,
+          requireAuthentication = true,
+          requireAuthenticationAsBoolean = true)
+      val path = action.fullyQualifiedName(false)
+      put(entityStore, action)
+
+      invocationsAllowed += 1 // custom options means action is invoked
+      actionResult =
+        Some(JsObject("headers" -> JsObject("Access-Control-Allow-Methods" -> "OPTIONS, GET, PATCH".toJson)))
+
+      // the added headers should be ignored
+      Options(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
+        header("Access-Control-Allow-Origin") shouldBe empty
+        header("Access-Control-Allow-Methods").get.toString shouldBe "Access-Control-Allow-Methods: OPTIONS, GET, PATCH"
+        header("Access-Control-Request-Headers") shouldBe empty
+      }
+    }
+
     it should s"support multiple values for headers (auth? ${creds.isDefined})" in {
       implicit val tid = transid()
 
@@ -1660,7 +1718,7 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac
       }
     }
 
-    it should s"invoke action with options verb without custom options (auth? ${creds.isDefined})" in {
+    it should s"invoke action and respond with default options headers (auth? ${creds.isDefined})" in {
       implicit val tid = transid()
 
       put(entityStore, stubAction(proxyNamespace, EntityName("export_without_custom_options"), false))