External Authentication Extension Tutorial

With External Authentication (or Inbound SSO), an external auth provider like Okta or Amazon Cognito will hold the user credentials that can log into the Q2 system. However, the Q2 interface still has plenty of places the user will interact with to alter these details, (we call these RequestTypes), such as the Change Password interface.

Note

External Auth is not the same thing as (External MFA). For clarification, see our guide on the differences between External Auth and External MFA.

First, let’s ensure all the sql stored procedures for local testing are up to date:

$ q2 setup_db

Then, let’s create a new extension:

q2 create_extension
New Extension Name: ExternalAuth
What type of extension are you creating?

    1) Online (default)
    2) SSO (Third Party Integration)
    3) Ardent (API)
    4) Q2Console (Backoffice)
    5) Central (Legacy Backoffice)
    6) Adapter <--------
    7) Audit Action
    8) Custom Health Check
    9) Message Bus
    10) Caliper API Custom Endpoint
    11) Base Extension

Please make a selection and press Return [1]: 6

Select adapter type to generate


    1) Authentication Token
    2) External Authentication <--------
    3) FX Rate
    4) Check Image
    5) Account Details
    6) Statement Image
    7) Domestic Wire
    8) International Wire
    9) Realtime Payments

Please make a selection and press Return: 2

This should generate a lot of files (more than 30). Most of these are in a subdirectory called routes, in which each file corresponds to a RequestType you can hook into. In all likelihood, you are not going to want to upgrade all of them, but specific third party vendors may need a different set, so we make them all available. Later on, we’ll enable specific RequestTypes to use your adapter rather than the default Q2 functionality. But first, we need to enable external_authentication at a system level. For that, we can use the q2 sandbox external_auth entrypoint:

$ q2 sandbox external_auth enable
Invalidating HQ
Enabled

Ths enables two System Properties:

External Auth System Properties

Property Name

Definition

EnableExternalEndUserAuthentication

The master switch enabling the functionality

ExternalEndUserAuthenticationRequiresSSOIdentifier

Only logins in Q2_SSOUserLogon will use this system

With those enabled, we need to register our test user to take part in enabled RequestTypes.

Let’s add our user to the Q2_SSOUserLogon table so it will get called during the workflow:

$ q2 db add_sso_user_logon retail0 myFakeGuid

Typically this is where we would decide which RequestTypes we need to implement, then start building those handlers. For the purposes of this tutorial, we’ll build two, Check Password for handling username and password on the Q2 side, and Logon User External for logging in through another service.

Check Password

Route API Docs: Check Password

Take a look at the generated CheckPassword route (located in ExternalAuth/routes/check_password.py):

from __future__ import annotations
from q2_sdk.hq.models.external_auth.check_password import Request, Response
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from ..extension import ExternalAuthHandler


async def route(self: ExternalAuthHandler, request: Request) -> Response:
    """
    Successful responses return Response.get_success()
    Failed responses return Response.get_failure()
    """

    raise NotImplementedError

Clearly, this doesn’t do anything yet. However, that Request and Response refer specifically to what HQ will send to your extension and what it will expect back. This true for each RequestType, and more specific details can be found in the External Auth Models Reference Page.

Before we start building the handling code, let’s enable the CheckPassword RequestType for use by any users referenced in the Q2_SSOUserLogon table, to which we just added our test user:

$ q2 db set_external_auth_config CheckPassword --enabled

Note

To see which RequestTypes your sandbox has enabled, you can use the q2 db get_external_auth_config entrypoint

And register your adapter as the handler for all the RequestTypes:

$ q2 install

We’re ready! Let’s ensure everything is set up correctly. Start your server:

$ q2 run

And try to log in to Online Banking. From the front end, it should look like a login failure:

../../../_images/check_password_failure.png

But the real test is whether your server is called. In your terminal logs you should see the following:

ExternalAuthType.CheckPassword is not implemented by your handler

Failed successfully! Let’s implement a success instead. Change your route code to this:

...

async def route(self: ExternalAuthHandler, request: Request) -> Response:
    """
    Successful responses return Response.get_success()
    Failed responses return Response.get_failure()
    """

    return Response.get_success()

And when you try to log in to Online Banking again, you should be allowed in.

Logon User External

Route Api Docs:

When using an outside service for storing credentials, there are two calls made during the login flow. The first is called InitiateLogonUserExternal. This follows the OpenID Spec, and corresponds to the authorize endpoint (requiring a state, client_id, and redirect_uri)

The second is called LogonUserExternal (Without the Initiate prefix). This corresponds to the token endpoint. All that needs to be returned from this is the Foreign Key that ties together the Q2 User with the external system. Typically this is a guid, but could be something else. In the Q2 Database, it’s stored in the Q2_SSOUserLogon table in the SSOIdentifier field. We defined this as MyFakeGuid in the section above, but we’ll update it appropriately in just a moment.

Thankfully, the SDK makes this simple for the happy path. Assuming you are using a standard OAuth2 compliant service, you can define your configuration in the extension and write a very minimal amount of code. We’re going to use Q2Developer.com for this purpose today.

Before we start building the handling code, let’s enable the SSOAuth RequestType for use by any users referenced in the Q2_SSOUserLogon table:

$ q2 db set_external_auth_config SSOAuth --enabled

Go ahead and start your server:

$ q2 run

And hit the special external sso login page, located at your sandbox’s normal online banking location, but rather than ending in /uux.aspx, it ends in /login?sso. For instance, if your typical login is https://stack.q2developer.com/sdk/finame/ardent/uux.aspx, for this you would hit https://stack.q2developer.com/sdk/finame/ardent/login?sso.

Unsurprisingly, that should fail:

{
    "statusCode": 500,
    "error": "Internal Server Error",
    "message": "An internal server error occurred"
}

But, in your terminal logs you should see:

ExternalAuthType.InitiateLogonUserExternal is not implemented by your handler

Nice. We’re being called. Just have to implement the Oauth2 logic flow. Your existing sso_auth route should look something like this:

from __future__ import annotations
from q2_sdk.hq.models.external_auth.logon_user_external import Request, Response
from q2_sdk.hq.models.external_auth.initiate_logon_user_external import Request as InitiateRequest, Response as InitiateResponse
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from ..extension import ExternalAuthHandler


async def authorize_route(self: ExternalAuthHandler, request: InitiateRequest) -> InitiateResponse:
    """
    Successful responses return InitiateResponse.get_success()
    Failed responses return InitiateResponse.get_failure()
    """

    ## For Oauth2, uncomment this section and set extension's OAUTH2_CONFIG attribute
    # return await self.authorize_with_oauth2()

    raise NotImplementedError

async def token_route(self: ExternalAuthHandler, request: Request) -> Response:
    """
    Successful responses return Response.get_success()
    Failed responses return Response.get_failure()
    """

    ## For Oauth2, uncomment this section and set extension's OAUTH2_CONFIG attribute
    # access_token = await self.get_oauth2_access_token(request)
    # uid = access_token["sub"]
    # return Response.get_success(uid)

    raise NotImplementedError

As instructed in the code comments, let’s remove the NotImplementedError lines and uncomment the other sections to transform it into this:

from __future__ import annotations
from q2_sdk.hq.models.external_auth.logon_user_external import Request, Response
from q2_sdk.hq.models.external_auth.initiate_logon_user_external import Request as InitiateRequest, Response as InitiateResponse
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from ..extension import ExternalAuthHandler


async def authorize_route(self: ExternalAuthHandler, request: InitiateRequest) -> InitiateResponse:
    """
    Successful responses return InitiateResponse.get_success()
    Failed responses return InitiateResponse.get_failure()
    """

    return await self.authorize_with_oauth2()

async def token_route(self: ExternalAuthHandler, request: Request) -> Response:
    """
    Successful responses return Response.get_success()
    Failed responses return Response.get_failure()
    """

    access_token = await self.get_oauth2_access_token(request)
    uid = access_token["sub"]
    return Response.get_success(uid)

And now we need to update the OAUTH2_CONFIG variable in the extension.py file.

By default that looks like this:

    OAUTH2_CONFIG = Oauth2Config(
        "client_id",
        "client_secret",
        "token_url",
        "authorize_url",
        "callback_url",
        jwks_url="optional_jwks_url",
    )

Let’s log in to https://q2developer.com and find the correct values for our use case. (Be sure to uncomment the Oauth2Config import line at the top of the file)

    OAUTH2_CONFIG = Oauth2Config(
        "984a2702-2cdc-4aa6-ae2d-aaab3726c955",
        "72cc47c7795e3477e1e6cccf29d2aa45",
        "https://q2developer.com/oauth2/token",
        "https://q2developer.com/oauth2/authorize",
        "https://stack.q2developer.com/sdk/tigermfa/ardent/uux.aspx",
        jwks_url="https://q2developer.com/.well-known/jwks",
        # audience = "MyJWTAudienceValue"
    )

Q2Developer.com doesn’t specify an aud claim, so we do not specify one in the config. If your IdP does then you can add the audience parameter to the config and the aud claim will be checked to ensure it has the same value as the audience parameter in the config.

And as simple as that we are now able to log in using an external source of truth.

After typing in your q2developer.com credentials, you should be redirected to the landing page of your sandbox stack. Nice work!