Desktop and Application Streaming

Creating a self-service portal for Amazon WorkSpaces end users

When our customers are deploying Amazon WorkSpaces, they often ask:

  • How can my users manage their own WorkSpaces instances?
  • How can my administrators manage all of our instances?

What they’re asking is how those two groups of users can perform certian tasks without access to the AWS Management Console. With potentially thousands of users accessing the console, the issue is maintaining the appropriate AWS Identity and Access Management (IAM) roles, policies, users, and groups at that scale.

This blog post shows you how to build a serverless end user portal for WorkSpaces that authenticates to your existing corporate Active Directory. Therefore, it requires minimal effort to add users. You simply add them to an existing Active Directory group. In reality, the solution described in this post could be used for any AWS service or combination of services.

Given these requirements, this blog post discusses building the following:

  • A single-page web application hosted on Amazon S3. This is one HTML file that handles both the user and admin use cases, which are a JavaScript file with a few functions in it, and a style sheet. You could also include Amazon CloudFront, which provides custom domains, an HTTPS front end, and AWS Certificate Manager (ACM) support.
  • Authentication, which is handled by an Amazon Cognito User Pool federated via SAML 2.0 with Active Directory. This could be on premises or hosted in AWS. Two groups are defined in Active Directory. One contains regular WorkSpaces end users, and another contains the administrators. This group membership information is returned to the application as part of the SAML assertion, so that we can modify behavior of the application appropriately. End users see only their own WorkSpaces instances, while administrators see all instances in the fleet.
  • A couple of API Gateway definitions that link to AWS Lambda functions. One definition retrieves a list of WorkSpaces instances. The other takes specific actions, such as rebooting or stopping an instance when the user chooses those options in their browser. API Gateway uses Amazon Cognito to make sure that only authorized users are able to call the API operations.
  • Two additional Lambda functions. One function scans the available AWS Regions to look for WorkSpaces instances and collect metadata. The other function removes stale information from the database. So if there is an instance with an entry that doesn’t exist it, gets removed. These functions are triggered periodicallyby Amazon CloudWatch Events.
  • An Amazon DynamoDB database for storing all the WorkSpaces instance information.

Here’s a diagram of how everything should look together after it’s built:

One decision made for the purposes of this post was to not use Amazon CloudWatch rules to trigger Lambda functions. This was to keep the database information fresh. The WorkSpaces service creates specific events when instances change state. Changing states is when they’re created, deleted, or otherwise changed in the console, or by using API calls.

However, there are quite a few other states that an instance can be in that do not trigger events. In those cases, you must poll the WorkSpaces API. Instead of creating CloudWatch Events for this and using a Lambda function that scans all WorkSpaces, it was more efficient to have Lambda do all the work.

There is another reason to use a Lambda function. You must create CloudWatch Events triggers in every AWS Region where you run your WorkSpaces instances. For more efficient deployment, the solution in this post uses a single Lambda function in one Region that queries all the Regions where you have your WorkSpaces deployed. You can either have the function scan all the Regions, or you can specify the ones in use.

Creating each part of the application

This blog post discusses creating each part of the application. While you aren’t required to perform these tasks in a particular order, they’re discussed in this post as follows:

  1. AWS CloudFormation
  2. IAM permissions
  3. DynamoDB table definition
  4. Lambda function: Find Workspaces instances
  5. Lambda function: Workspaces instance reaper
  6. Lambda function: Get a list of Workspaces instances
  7. Lambda function: Perform an action on a Workspaces instance
  8. AD FS configuration
  9. Amazon Cognito User Pool definition
  10. Amazon Cognito SAML federation
  11. API Gateway definition
  12. Single-page web application hosted in S3

1. AWS CloudFormation

Here is a useful AWS CloudFormation template that builds most of this application for you. However, you must do the following manually:

  • Create the Amazon Cognito User Pool domain name. It must be unique within the AWS Region. Set it up in the single-page web application and in your federation with Active Directory.
  • Create an app client in the Amazon Cognito User Pool, and put it into the web application too.
  • Federate the Amazon Cognito User Pool in Active Directory.
  • Update the Amazon Cognito app client settings. For more information, see the Amazon Cognito User Pool definition section in this blog post.

You must provide a unique S3 bucket name when deploying the template. If you’re deploying the template multiple times, add a suffix so that you can identify which deployed components belong to your stack. Otherwise, the suffix that the AWS CloudFormation template asks for is optional. You also must manually copy the HTML and associated files into the S3 bucket after modifying them with the appropriate links for the API Gateway. For more information, see the 12. Single-page web application hosted in Amazon S3 section of this blog post.

The outputs from the AWS CloudFormation template provide you the information that you need to enter into the HTML and JavaScript files.

The end of this post shows how the portal behaves when a user or an administrator logs in, what they see, and what happens when they perform an operation on a WorkSpaces instance.

2. IAM permissions

Each of the Lambda functions must access Amazon DynamoDB and call the WorkSpaces API. For this post, I could have created an individual role for each Lambda function. But because they all perform similar actions, I opted to use a single IAM policywhich allows the Lambda functions to perform specific actions on all Workspaces instances within the account; access and update the DynamoDB table; create log entries in CloudWatch Logs; discover valid regions; and access KMS keys.

Note that in the DynamoDB section of the policy, you must modify the account number and possibly the Region name. If you use a different DynamoDB table name, you must change that too. Each of the Lambda functions accept an environment variable called DynamoDBTableName. So if you do decide to use a different table name, remember to set that environment variable, rather than modifying the code. However, modifying the code is an option for you.

{
  "Version": "2012-10-17",
  "Statement": [
    {
        "Action": [
            "workspaces:DescribeWorkspaces",
            "workspaces:RebootWorkspaces",
            "workspaces:RebuildWorkspaces",
            "workspaces:TerminateWorkspaces",
            "workspaces:DescribeWorkspaceDirectories",
            "workspaces:StopWorkspaces",
            "workspaces:StartWorkspaces",
            "workspaces:DescribeWorkspacesConnectionStatus",
            "kms:ListKeys",
            "kms:ListAliases",
            "kms:DescribeKey"
        ],
        "Effect": "Allow",
        "Resource": "*"
    },
    {
        "Action": [
            "dynamodb:DeleteItem",
            "dynamodb:GetItem",
            "dynamodb:PutItem",
            "dynamodb:UpdateItem",
            "dynamodb:Scan"
        ],
        "Effect": "Allow",
        "Resource": "arn:aws:dynamodb:ap-southeast-2:111122223333:table/WorkspacesPortal"
    },
    {
        "Action": [
            "logs:CreateLogGroup",
            "logs:CreateLogStream",
            "logs:PutLogEvents"
        ],
        "Effect": "Allow",
        "Resource": "*"
    },
    {
        "Action": "ec2:DescribeRegions",
        "Effect": "Allow",
        "Resource": "*"
    }
  ]
}

3. DynamoDB table definition

Create a DynamoDB table called WorkspacesPortalwith a primary key called WorkspaceId, which is a string. Use the default settings for everything else. The database performance numbers may need to be modified depending on the size of your environment.

4. Lambda function: Find WorkSpaces instances

This post doesn’t provide a code listing for the function. To download the code, use this link. Create a Lambda function that uses the code with the following settings:

  • Runtime: Python 3.6
  • Role: The IAM role that you previously created with the template
  • Timeout: 5 minutes

As mentioned earlier in this post, rather than create CloudWatch Event triggers in every Region where you run WorkSpaces, this function scans each Region looking for new WorkSpaces instances. When it finds them, it places them into the DynamoDB table so that the rest of this portal application can retrieve information about them.

This post uses the DynamoDB table rather than query the WorkSpaces API whenever a user or administrator uses the portal. If you have more than a few dozen simultaneous users, you may reach API call limits. Therefore, it makes sense to store the data in a database. This also makes retrieving the WorkSpaces instance information run more efficiently. This is primarily because the functions won’t need to poll every Region looking for instances. The disadvantage is that the information in the database may be stale. Therefore, when you configure the CloudWatch Events triggers for this Lambda function, set it to the appropriate interval. 5 to 15 minutes should be fine.

By default, the Lambda function scans all Regions looking for WorkSpaces instances. It gets the Region list from the EC2 DescribeRegions API call, which is why it’s in the IAM policy. Some Regions don’t have the WorkSpaces service deployed, so errors for this appear in the logs. That is normal. If you know the Regions where you have WorkSpaces instances deployed, or you don’t want to scan all Regions because it takes longer, you can tell the Lambda function which Regions to scan. To do this, create an environment variable called REGIONLISTand enter a comma-separated list of Regions into the value of that variable. For example, enter ap-southeast-2, us-east-1to look only at the Asia Pacific (Sydney) and US East (N. Virginia) Regions. If you don’t specify Regions in the environment variable, and the EC2 DescribeRegions API call fails, the function scans the us-east-1 Region.

5. Lambda function: WorkSpaces instance reaper

To download this code, use this link. The function requires the following settings:

  • Runtime: Python 3.6
  • Role: The IAM role that you previously created with the template
  • Timeout: 5 minutes

This function scans the database and checks that the instances listed haven’t been decommissioned. This means that they are still active in the WorkSpaces service. If they can’t be found, then they are removed from the database.

You could trigger the reaper process by using a CloudWatch Event based on the WorkSpace instance termination. But as mentioned before, I’ve opted not to do that. Instead, I use a single Lambda function rather than deploy event configuration items in multiple Regions.

6. Lambda function: get a list of WorkSpacesinstances

To download this code, use this link. The function requires the following settings:

  • Runtime: Python 3.6
  • Role: The IAM role that you previously created with the template
  • Timeout: 10 seconds

When either a user or administrator arrives at the portal page, the front-end JavaScript calls API Gateway, which then triggers this function. It scans the database looking for instances that belong to the user. This is because the user may have multiple instances in one or more Regions. In the case of an administrator, it returns a complete list of all instances.

Within the code, it interprets the JWT that is passed in as the Authorization header. The code shows whether the caller is an end user, an administrator, or both, and then acts accordingly. The front end might call the API operation twice. The first time, to get a list of user-owned instances. The second time, to get the full list. Keep in mind that a user can be both and end user and an administrator. There’s also a flag to indicate whether to return the full list or not. This is checked to make sure that only administrators can retrieve the entire list.

7. Lambda function: perform an action on a WorkSpaces instance

To download this code, use this link. The function requires the following settings:

  • Runtime: Python 3.6
  • Role: The IAM role that you previously created with the template
  • Timeout: 10 seconds

This function calls the WorkSpaces API operation, and performs an action as requested by the user on a specific instance. That might be to reboot, rebuild, start, stop, or terminate.

Again, check that the user is allowed to act on the instance they have indicated. End users may only perform actions on instances that are assigned to them. Administrators may act on any instance. The code also checks to ensure that actions are valid. For example, you can’t start or stop instances that are Always On. This ability applies only to Hourly WorkSpaces instances.

8. AD FS (Active Directory Federation Services) configuration

Create two groups in Active Directory. One whose membership defines end users of the WorkSpaces services, and another which contains administrators. The names of the groups are not critical, as these are abstracted within the SAML response. The following example uses WorkspacesUsers and WorkspacesAdmin. Return a flag to indicate whether the user being authenticated by Active Directory is a member of the end user group (UserGroupMember) or the administration group (AdminGroupMember).

To do this, modify your AD FS Claim Issuance Policy for the appropriate relying party trust. You must have at least the following rules defined. I show the raw rule language rather than providing step-by-step instructions, which allows you to use the copy-and-paste the rule. If you’re configuring this step-by-step, then the rule language isn’t needed.

Rule Number Rule Name Claim Type Rule Language
1 Name ID (Incoming)
Name ID
Claim Type: Windows account name Name Format:Persistent Identifier c:[Type == “http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname”] => issue(Type = “http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier”, Issuer = c.Issuer, OriginalIssuer = c.OriginalIssuer, Value = c.Value, ValueType = c.ValueType, Properties[“http://schemas.xmlsoap.org/ws/2005/05/identity/claimproperties/format”] = “urn:oasis:names:tc:SAML:2.0:nameid-format:entity”);
2 User Group Member (Outgoing)
Group
User’s Group:DOMAIN\WorkspacesUsers
(modify domain name as necessary)
Outgoing claim value:UserGroupMember c:[Type == “http://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid”, Value == “S-1-5-21-3633098732-2300341766-3654018414-1108”, Issuer == “AD AUTHORITY”] => issue(Type = “http://schemas.xmlsoap.org/claims/Group”, Value = “UserGroupMember”, Issuer = c.Issuer, OriginalIssuer = c.OriginalIssuer, ValueType = c.ValueType);
3 Admin Group Member (Outgoing)
Group
User’s Group:DOMAIN\WorkspacesAdmin
(modify domain name as necessary)
Outgoing claim value:AdminGroupMember c:[Type == “http://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid”, Value == “S-1-5-21-3633098732-2300341766-3654018414-1109”, Issuer == “AD AUTHORITY”] => issue(Type = “http://schemas.xmlsoap.org/claims/Group”, Value = “AdminGroupMember”, Issuer = c.Issuer, OriginalIssuer = c.OriginalIssuer, ValueType = c.ValueType);

Add the Amazon Cognito User Pool, which you create in the next section, as the relying party on the Identifiers tab in the AD FS console. The correct format for the URN is:

urn:amazon:cognito:sp:<user_pool_id>

On the Endpoints tab, specify the consumer endpoint for Amazon Cognito to use as a SAML Assertion Consumer and a POST binding. You must provide the domain prefix, which you configure later in this post, and the AWS Region that you’re working in. The format for the URL is shown as follows:

https://<domain_prefix>.auth.<region>.amazoncognito.com/saml2/idpresponse

9. Amazon Cognito User Pool definition

Create an Amazon Cognito User Pool and do the following:

  • Allow users to log in using their user name. Don’t require them to sign in with a verified email address, phone number, or preferred user name. This is not required as part of the SAML 2.0 federation.
  • Make sure that email is selected as a required attribute.
  • Add a custom attribute called AD Groups. This is what is returned as part of the SAML assertion.
  • Make sure that the attribute is mutable. This means that it can be changed once it is created for each user. Otherwise, users can log in only once, as the attribute can’t be changed after creation.
  • Keep password strength settings as the default. Because you’re federating with SAML, these are not used. Similarly, you can keep the default for the MFA and verification settings, the message customizations, and the workflow triggers.
  • Add tags if you require them. This is highly recommended.
  • When asked to remember user’s devices, choose No.
  • Add an app client, but do not generate a client secret. Enable sign-in for service-based authentication (ADMIN_NO_SRP_AUTH). Create any app name that you want, but WorkspacesPortal is recommended as it will be clear later what this is for.

If you’ve used AWS CloudFormation up to this point, continue with the instructionsin the rest of this blog post:

Go to App integration and Domain name, and create a unique domain name for your application. You need this when you create the single-page web application. The domain name must be unique within the AWS Region.

10. Amazon Cognito SAML federation

Once the User Pool is created, we can federate it with your SAML provider. In the Federationsection, choose Identity providers, SAML. Give the provider a name and upload or link to the metadata document for your provider. If you’re not the Active Directory administrator, then ask them for this. You may be able to download it from the AD FS front-end server by using the following URL:

https://<ADFS external hostname>/FederationMetadata/2007-06/FederationMetadata.xml

 

Next, make sure that the attributes returned by the SAML assertion are mapped correctly. Create two attribute mappings for your SAML provider as follows:

  • SAML attributehttp://schemas.xmlsoap.org/claims/Group
    User pool attributecustom:ADGroups
  • SAML attributehttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress
    User pool attribute: Email

In the App client section, check the attribute read and write permissions to make sure that custom:ADGroups is enabled for both reading and writing.

Finally, go to App integration and App client settings. Enable the federated user provider that you just created, and enable the following OAuth 2.0 settings:

  • Authorization code grant
  • Implicit grant
  • email, as an OAuth scope
  • openid
  • aws.cognito.signin.user.admin
  • profile

Enter your callback URL. This must match exactly with what the single-page web application uses and it takes the form:

https://s3-<AWS Region name>.amazonaws.com/<S3 bucket name>/index.html 

The instructions for creating and populating the S3 bucket are below.

Note in the following screenshot all the various things checked, according to the previous list.

Now, save the App Client settings.

Note

If you’ve never worked with SAML or identity providers in IAM, then this excellent, self-paced workshop is recommended: Choose Your Own SAML Adventure: A Self-Directed Journey to AWS Identity Federation Mastery. It illustrates useful AWS identity provider scenarios and shows how to configure Active Directory and AD FS.

11. API Gateway definition

At this point, if you used the AWS CloudFormation template to create the portal you can skip over this section – the API Gateway definition has been done for you.

In API Gateway, create a new API. Create an authorizer that uses the Amazon Cognito User Pool that you just created. The TokenSource is Authorization. The authorizer name can be anything.

Next, create two resources: one called admin, and a second called user. Both require GETAPI operations. The adminGET API operation  calls the Lambda function that performs   on WorkSpaces instances. The userGET method calls the Lambda function that lists WorkSpaces instances. In the Method Request configuration, select the authorizer that you previously created.

Otherwise, each is configured identically. Each must use the Amazon Cognito User Pool created earlier as the authorization. Both integration requests must use Lambda Proxy integration. You must also enable CORS (Cross-Origin Resource Sharing) for both API operations as well – this informs the browser that an application running in one domain (the originating web page) has permission to access resources from a server at a different origin (API Gateway).

Now, deploy the API. Note the invoke URL, because you need that to build the single-page web application below.

12. Single-page web application hosted in Amazon S3

Create an Amazon S3 bucket with a name of your choice. You need the name and Region to correctly configure the SAML authentication callback.

Next, download the index.htmlstyles.css and utils.js files. Rename them to index.html, styles.cssand utils.js, unless you want to modify the references within those files to each other.

Then, edit utils.jsand modify the following elements:

  • CognitoDomainName— This is the name of the Amazon Cognito domain name that you created for the app integration earlier.
  • CognitoClientId— This is the unique identifier for the App Client that you created in the Amazon Cognito definition. It looks something like this: y2nritqei3hssmgm5re2sixns.
  • APIGatewayId— This is the unique identifier for the API Gateway that you created. This looks something like this: h2atuuqnn9.
  • RegionName— Set this to the Region name that you’re operating in, for example, ap-southeast-2. For a list of the AWS Region names, see AWS Regions and Endpoints.
  • S3BucketName— Update this with the name of the bucket that you created earlier.

After you do this, modify the Callback URLsetting in the App client settings in Amazon Cognito. Amazon Cognito passes this to the SAML 2.0 endpoint so that it returns to your webpage.

https://s3-<RegionName>.amazonaws.com/<S3BucketName>/index.html

How the portal works

How does this work when a user accesses the portal page?

  • When the page loads, we call the login() function, which is defined in utils.js.
  • This checks to see if we have a JWT stored in the browser called “WorkspacesAccessToken“. If not, and there is no authentication token in the URL (if we haven’t been called by the SAML callback), then we redirect to the page defined in the “authRedirect” variable, which points to SAML authentication page hosted by Amazon Cognito.
  • On that page, the user can choose which IdP to authenticate with. In the previous example, there is only one so users choose that one.
  • They are then redirected to the authentication page hosted by your SAML provider. One of the parameters passed to it is the Amazon Cognito callback URL.
  • The user enters their user name and password. If they are valid, the SAML provider generates a JWT and passes that back to Amazon Cognito. This includes the ADGroups information that we require to determine if the user is an end-user or an administrator.
  • Amazon Cognito calls our application back at the URL that you configured, which is the single-page application hosted in Amazon S3. In a more complex app, you might call a different page. You don’t have to return to the same place.
  • The login() function checks to see if we have a valid JWT in local storage (which we don’t) but now we have a token in the URL.
  • The code pulls the token apart, specifically looking for the “id_token” part. This is placed in local storage in the browser as “WorkspacesAccessToken” so that if the user refreshes the page we don’t have to authenticate them again.
  • Now that we have a valid token login() checks to make sure it hasn’t expired (we do this every time – even on new login in case the URL has been cached by the browser) and if it has expired we go back and authenticate the user again.
  • In the token, there is an element called “UserGroupMember” if this is an end-user, an element called “AdminGroupMember” if this is an administrator or we may have both elements.
  • Based on the groups that the user is a member of, we call the GetWorkspacesDetail() function, which has a parameter of True or False – either we’re an administrator user or we are not. The end-user and administrator requests are done in parallel. There is not a concern about a user trying to impersonate an administrator – if the JWT isn’t valid (that is, it doesn’t represent credentials for an administrator) it gets picked up by Amazon Cognito because you send the JWT using the “Authorization” HTTP header to API Gateway and from there onto the Lambda functions.
  • In the GetWorkspacesDetail() function we call the API Gateway endpoint defined in the “USER_API_URL” variable and wait for the Lambda function at the back end to return a list of Workspaces instances that we’re allowed to see (as a user or an administrator or both). When that result arrives, we render it into the appropriate div on the page
  • Finally, if the user chooses one of the actions for a Workspaces instance (Reboot, Rebuild, Start, Stop, etc.) we call the WorkspacesAction() function. This calls the API Gateway endpoint defined in the “ADMIN_API_URL” variable and executes the Lambda function linked to that.
  • When WorkspacesAction() gets a response from API Gateway it renders that in the appropriate div so that the user knows that something has happened.
  • Finally, we call API Gateway again to refresh the information that we have on all the instances.

Logging in as an individual user with only a single WorkSpaces instance available to you, you see something similar to the following screenshot. The options are different depending on whether the instance is in hourly mode or not and whether it is currently stopped or not.

If you’re logging in as an administrator, then you see all of the WorkSpaces instances across all of the available Regions. Here, there are a few more options available that aren’t shown to users and they change based on the state of the instance.

Conclusion

In this post, I’ve showed you how to create a serverless end-user and administrator-facing portal to control Workspaces instances that uses your enterprise Active Directory as an authentication source. This lets your users have control over Workspaces without having access to the AWS console.