Repository: goauthentik/authentik
Pull request: Addition of form_post OIDC response mode
# Details
Resolves #2748 

## Changes
### New Features
* Adds support for form_post response mode, required for Azure-compatibility

### Breaking Changes
None

## Additional Details
-



Problem statement:
FR: Support for response_mode: form_post
**Is your feature request related to a problem? Please describe.**
We have a client application that is meant to use Azure as an IDP, and I've managed to customize most of the authentik flow to be able to use Authentik as a provider. The last part of this is that the OpenID Connect/Oauth2-provider currently doesn't support response_mode: form_post, which would be needed for this compatibility.

**Describe the solution you'd like**
Modify the OpenID Connect/Oauth2 provider for use with the response mode form_post

**Additional context**
I think compatibility with applications that are made for Azure authentication would enhance the usability aspect of Authentik a great deal. I'll look into getting a PR together later this week/beginning of next week when I have some spare time, just thought I'd post here as well in case anyone else is interested/working on it/has a solution already.

Hints from issue discussion:
After looking at it for a bit, would maybe @BeryJu be able to provide a (very brief) representation of how the OIDC-flow, mostly the latter stages moves through the code? 

My thinking at the moment is I'd have to amend the ResponseMode in providers/oauth2/models.py with form_post, modifying OauthFulfillmentStage providers/oauth2/views/authorize.py to check for the response_mode and create a separate view for the "post"-redirect with an auto-submitting form for posting the data back to the RP.
I think I had a stash somewhere where I basically did that, but you're essentially correct, continue to check the `response_mode` like currently to make sure only valid options allowed.

For the auto-submitting form you can look at the SAML provider which already does this, see https://github.com/goauthentik/authentik/blob/master/authentik/providers/saml/views/flows.py#L86
Fantastic, thanks! I'll try to pull together a PR for this end of this or next week.
@BeryJu Correct me if I'm wrong, only been looking at this for a short while compared to you, but on the following lines: 

https://github.com/goauthentik/authentik/blob/da28bb7d3ce9fa558815bb927029f2e9e36e769f/authentik/providers/oauth2/views/authorize.py#L307-L315

The _if "response_mode" in query_dict_ is never going to be true, since the parameters passed to this view are in string form in the "query" parameter of the request, wouldn't it be either QueryDict(self.request.X['query']), parse_qs(query_dict['query']) or something like the following?

```python
if "response_mode" in query_dict['query']:
    response_mode = parse_qs(query_dict['query'])['response_mode']
```

Haven't tested this with multiple request/response types, only with the code id_token-flow 
Right, so far I've got the following:

- I've added FORM_POST as an option in the ResponseModes-model
- I've moved the part that parses the response_mode query parameter into OauthAuthorizationParams, it uses the same logic as the previous part with the addition of form_post
- I've modified the response_mode check in the URL generation stage of OauthFulfillmentStage to include form_post


[authentik/providers/oauth2/models.py:53](https://github.com/scheibling/authentik/blob/add980bb8f6971d8164c5a3733c826c7164102ed/authentik/providers/oauth2/models.py#L53)

[authentik/providers/oauth2/views/authorize.py:131-137](https://github.com/scheibling/authentik/blob/add980bb8f6971d8164c5a3733c826c7164102ed/authentik/providers/oauth2/views/authorize.py#L131-L137)

[authentik/providers/oauth2/views/authorize.py:345-356](https://github.com/scheibling/authentik/blob/add980bb8f6971d8164c5a3733c826c7164102ed/authentik/providers/oauth2/views/authorize.py#L345-L356)

The question now is how to do the redirect properly. I've tested this with a static HTML file under dist (just some JS code that takes the query parameters and posts them forwards) and it works with an Azure-compatible endpoint. 

I'm not very good with NodeJS, and I haven't been able to follow the entire chain to ak-stage-autosubmit and how it works in detail, but having checked the SAML provider I reckon you could probably solve this in one of two ways, either by keeping the redirect_url but modiying it to redirect to the "Post-Submit-Stage", alternatively find a way to display a view instead of doing the redirect. So far I haven't been able to get any of those two working, but I'll keep hammering on.
Hi, feel free to already create a PR for this, I think that would be a better context to discuss the actual implementation.

For the redirect part, you can pretty much just copy the SAML part as the OAuth Authorization View is also used within a flow, and the frontend will automatically deal with the rest.

For the other question above, I remember there being some code that deals with the mapping from the nested ?query to make it directly accessible, but I'll need to have another look
The first comment is less relevant now, I've commented out that bit since I moved the responsemode parsing a bit earlier in the process. 

I'll send a pull request later tonight or tomorrow morning!

Commit message: Added class OauthPostFulfillmentStage
Check response_mode in initialization

Patch to review:
diff --git a/authentik/providers/oauth2/models.py b/authentik/providers/oauth2/models.py
index 28a7ac91cbb1..f85887662ddb 100644
--- a/authentik/providers/oauth2/models.py
+++ b/authentik/providers/oauth2/models.py
@@ -50,6 +50,7 @@ class ResponseMode(models.TextChoices):
 
     QUERY = "query"
     FRAGMENT = "fragment"
+    FORM_POST = "form_post"
 
 
 class SubModes(models.TextChoices):
diff --git a/authentik/providers/oauth2/views/authorize.py b/authentik/providers/oauth2/views/authorize.py
index 859d711f4256..bcf6c0379b74 100644
--- a/authentik/providers/oauth2/views/authorize.py
+++ b/authentik/providers/oauth2/views/authorize.py
@@ -15,6 +15,11 @@
 from authentik.core.models import Application
 from authentik.events.models import Event, EventAction
 from authentik.events.utils import get_user
+from authentik.flows.challenge import (
+    Challenge, 
+    ChallengeResponse, 
+    ChallengeTypes
+)
 from authentik.flows.models import in_memory_stage
 from authentik.flows.planner import (
     PLAN_CONTEXT_APPLICATION,
@@ -22,7 +27,7 @@
     FlowPlan,
     FlowPlanner,
 )
-from authentik.flows.stage import StageView
+from authentik.flows.stage import StageView, ChallengeStageView
 from authentik.flows.views.executor import SESSION_KEY_PLAN
 from authentik.lib.utils.time import timedelta_from_string
 from authentik.lib.utils.urls import redirect_with_qs
@@ -50,6 +55,7 @@
 )
 from authentik.providers.oauth2.utils import HttpResponseRedirectScheme
 from authentik.providers.oauth2.views.userinfo import UserInfoView
+from authentik.providers.saml.views.flows import AutosubmitChallenge, AutoSubmitChallengeResponse
 from authentik.stages.consent.models import ConsentMode, ConsentStage
 from authentik.stages.consent.stage import (
     PLAN_CONTEXT_CONSENT_HEADER,
@@ -74,6 +80,7 @@ class OAuthAuthorizationParams:
     client_id: str
     redirect_uri: str
     response_type: str
+    response_mode: Optional[str]
     scope: list[str]
     state: str
     nonce: Optional[str]
@@ -125,11 +132,22 @@ def from_request(request: HttpRequest) -> "OAuthAuthorizationParams":
             LOGGER.warning("Invalid response type", type=response_type)
             raise AuthorizeError(redirect_uri, "unsupported_response_type", "", state)
 
+        # Validate and check the response_mode against the predefined dict
+        # Set to Query or Fragment if not defined in request
+        response_mode = query_dict.get('response_mode', False)
+
+        if response_mode not in ResponseMode.values:
+            response_mode = ResponseMode.QUERY
+
+            if grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
+                response_mode = ResponseMode.FRAGMENT
+
         max_age = query_dict.get("max_age")
         return OAuthAuthorizationParams(
             client_id=query_dict.get("client_id", ""),
             redirect_uri=redirect_uri,
             response_type=response_type,
+            response_mode=response_mode,
             grant_type=grant_type,
             scope=query_dict.get("scope", "").split(),
             state=state,
@@ -238,6 +256,131 @@ def create_code(self, request: HttpRequest) -> AuthorizationCode:
 
         return code
 
+class OAuthPostFulfillmentStage(ChallengeStageView):
+    response_class = AutoSubmitChallengeResponse
+
+    params: OAuthAuthorizationParams
+    provider: OAuth2Provider
+
+    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+        """final Stage of an OAuth2 Flow when using response_mode = form_post"""
+
+        if PLAN_CONTEXT_PARAMS not in self.executor.plan.context:
+            LOGGER.warning("Got to fulfillment stage with no pending context (POST STAGE)")
+            return HttpResponseBadRequest()
+
+        self.params: OAuthAuthorizationParams = self.executor.plan.context.pop(PLAN_CONTEXT_PARAMS)
+        application: Application = self.executor.plan.context.pop(PLAN_CONTEXT_APPLICATION)
+
+        self.provider = get_object_or_404(OAuth2Provider, pk=application.provider_id)
+
+        # At this point we don't need to check permissions anymore
+        if {PROMPT_NONE, PROMPT_CONSNET}.issubset(self.params.prompt):
+            raise AuthorizeError(
+                self.params.redirect_uri,
+                "consent_required",
+                self.params.grant_type,
+                self.params.state,
+            )
+
+        Event.new(
+            EventAction.AUTHORIZE_APPLICATION,
+            authorized_application=application,
+            flow=self.executor.plan.flow_pk,
+            scopes=", ".join(self.params.scope),
+        ).from_http(self.request)
+
+
+        if self.params.grant_type in [
+            GrantTypes.AUTHORIZATION_CODE,
+            GrantTypes.HYBRID,
+        ]:
+            code = self.params.create_code(self.request)
+            code.save(force_insert=True)
+
+        form_attrs = self.get_post_params(code)
+        form_attrs["code"] = code.code
+        form_attrs["state"] = [str(self.params.state) if self.params.state else ""]
+
+        return super().get(
+            self.request,
+            **{
+                "type": ChallengeTypes.NATIVE.value,
+                "component": "ak-stage-autosubmit",
+                "title": "Redirecting to %(app)s..." % {"app": application.name},
+                "url": self.params.redirect_uri,
+                "attrs": form_attrs
+            }
+        )
+
+    def get_challenge(self, *args, **kwargs) -> Challenge:
+        return AutosubmitChallenge(data=kwargs)
+
+    def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
+        # We'll never get here since the challenge redirects to the SP
+        return HttpResponseBadRequest()
+
+    def get_post_params(self, code: Optional[AuthorizationCode]) -> dict:
+        """Create implicit response's URL Fragment dictionary"""
+        query_fragment = {}
+
+        token = self.provider.create_refresh_token(
+            user=self.request.user,
+            scope=self.params.scope,
+            request=self.request,
+        )
+
+        # Check if response_type must include access_token in the response.
+        if self.params.response_type in [
+            ResponseTypes.ID_TOKEN_TOKEN,
+            ResponseTypes.CODE_ID_TOKEN_TOKEN,
+            ResponseTypes.ID_TOKEN,
+            ResponseTypes.CODE_TOKEN,
+        ]:
+            query_fragment["access_token"] = token.access_token
+
+        # We don't need id_token if it's an OAuth2 request.
+        if SCOPE_OPENID in self.params.scope:
+            id_token = token.create_id_token(
+                user=self.request.user,
+                request=self.request,
+            )
+            id_token.nonce = self.params.nonce
+
+            # Include at_hash when access_token is being returned.
+            if "access_token" in query_fragment:
+                id_token.at_hash = token.at_hash
+
+            if self.params.response_type in [
+                ResponseTypes.CODE_ID_TOKEN,
+                ResponseTypes.CODE_ID_TOKEN_TOKEN,
+            ]:
+                id_token.c_hash = code.c_hash
+
+            # Check if response_type must include id_token in the response.
+            if self.params.response_type in [
+                ResponseTypes.ID_TOKEN,
+                ResponseTypes.ID_TOKEN_TOKEN,
+                ResponseTypes.CODE_ID_TOKEN,
+                ResponseTypes.CODE_ID_TOKEN_TOKEN,
+            ]:
+                query_fragment["id_token"] = self.provider.encode(id_token.to_dict())
+            token.id_token = id_token
+
+        # Store the token.
+        token.save()
+
+        # Code parameter must be present if it's Hybrid Flow.
+        if self.params.grant_type == GrantTypes.HYBRID:
+            query_fragment["code"] = code.code
+
+        query_fragment["token_type"] = "bearer"  # nosec
+        query_fragment["expires_in"] = int(
+            timedelta_from_string(self.provider.access_code_validity).total_seconds()
+        )
+        query_fragment["state"] = self.params.state if self.params.state else ""
+
+        return query_fragment
 
 class OAuthFulfillmentStage(StageView):
     """Final stage, restores params from Flow."""
@@ -304,29 +447,22 @@ def create_response_uri(self) -> str:
                 code = self.params.create_code(self.request)
                 code.save(force_insert=True)
 
-            query_dict = self.request.POST if self.request.method == "POST" else self.request.GET
-            response_mode = ResponseMode.QUERY
-            # Get response mode from url param, otherwise decide based on grant type
-            if "response_mode" in query_dict:
-                response_mode = query_dict["response_mode"]
-            elif self.params.grant_type == GrantTypes.AUTHORIZATION_CODE:
-                response_mode = ResponseMode.QUERY
-            elif self.params.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
-                response_mode = ResponseMode.FRAGMENT
-
-            if response_mode == ResponseMode.QUERY:
+            # At this stage, self.params.response_mode is always going to be QUERY or FRAGMENT
+            if self.params.response_mode == ResponseMode.QUERY:
                 query_params["code"] = code.code
                 query_params["state"] = [str(self.params.state) if self.params.state else ""]
-
+            
                 uri = uri._replace(query=urlencode(query_params, doseq=True))
                 return urlunsplit(uri)
-            if response_mode == ResponseMode.FRAGMENT:
+
+            if self.params.response_mode == ResponseMode.FRAGMENT:
                 query_fragment = self.create_implicit_response(code)
 
                 uri = uri._replace(
                     fragment=uri.fragment + urlencode(query_fragment, doseq=True),
                 )
                 return urlunsplit(uri)
+
             raise OAuth2Error()
         except OAuth2Error as error:
             LOGGER.warning("Error when trying to create response uri", error=error)
@@ -502,7 +638,12 @@ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
                     mode=ConsentMode.ALWAYS_REQUIRE,
                 )
                 plan.append_stage(stage)
-        plan.append_stage(in_memory_stage(OAuthFulfillmentStage))
+
+        if self.params.response_mode == ResponseMode.FORM_POST:
+            plan.append_stage(in_memory_stage(OauthPostFulfillmentStage))
+        else:
+            plan.append_stage(in_memory_stage(OAuthFulfillmentStage))
+
         self.request.session[SESSION_KEY_PLAN] = plan
         return redirect_with_qs(
             "authentik_core:if-flow",


Code review comments:
diff_hunk: @@ -238,6 +256,131 @@ def create_code(self, request: HttpRequest) -> AuthorizationCode:
 
         return code
 
+class OAuthPostFulfillmentStage(ChallengeStageView):
path: authentik/providers/oauth2/views/authorize.py
text: @user1:
So this extra stage view shouldn't be required, imo it makes more sense to hook in here https://github.com/goauthentik/authentik/blob/master/authentik/providers/oauth2/views/authorize.py#L248, rename `redirect` to `get_response` and have it check the parameters to either return an Autosubmit Challenge or a redirect 

@author:
Yep, I agree, this was just a way of testing the concept on my part for now. I'll have a look at integrating it a bit better later today.


@author:
Right, had some more free time this afternoon. I've removed the separate class and moved the FORM_POST handling to create_response_uri() and redirect() respectively

I'll have to spend some more time going through a couple of more scenarios where form_post might want a non-implicit response, I've only been testing with an application of ours that has a very specific capability for OIDC (Azure) so far.

https://github.com/scheibling/authentik/blob/b7fd2b38935ed63a2bd1214f6c41d6ac2e6485b9/authentik/providers/oauth2/views/authorize.py#L265-L282

@author:
Looked at it with a little more sleep behind me, I'm going to work on the create_response_uri-part a bit more since the current code doesn't really take any of the response_type parameters into account. 

Form_post in particular can be requested with just the code and id_token parts by themselves, I'm gonna read up a bit on that part of the protocol when I get into work tomorrow. 

The original version was written as if fragment always just creates an implicit response with just an id_token and  query only provides a code, couldn't there be other combinations on those as well? 

I'll be back with an update once I'm up to speed with the latest!

@author:
Nope, apparently the code that's already in create_implicit_response works fine for form_post as well!