Insights

How to Implement SMART-on-FHIR with AWS HealthLake Using Terraform

Author
Kuba Czaplicki
Published
January 10, 2025
Last update
January 10, 2025

Table of Contents

Key Takeaways

  1. FHIR is a medical data exchange standard, and AWS HealthLake is an AWS-managed service that provides FHIR server capabilities;
  2. SMART-on-FHIR is an OAuth2 implementation that provides standardized access to medical data;
  3. HealthLake is encrypted by default using AWS-managed keys, though you can provide your own customer-managed keys;
  4. The data can be exported to AWS S3;
  5. HealthLake can utilize Cognito and AWS Lambda to provide SMART-on-FHIR capabilities.

Is Your HealthTech Product Built for Success in Digital Health?

Download the Playbook

Healthcare applications need authentication to protect sensitive patient data. This is a clear fact, but putting authentication into practice can be challenging.

As FHIR expert Darren Devitt wrote in his post about 10 hard truths you won’t learn in the FHIR documentation:

You will almost certainly have to build a proxy in front of your FHIR server.

It’s a common use case that your proxy or backend must authorize with a lot of FHIR servers for data exchange and interoperability purposes. Having a common protocol simplifies it significantly.

This is where SMART-on-FHIR comes in — a standardized approach for healthcare applications to authenticate and access patient data securely. By implementing this with AWS HealthLake, we can create applications that comply with healthcare standards while leveraging AWS services working alongside HealthLake.

SMART-on-FHIR represents a streamlined OAuth 2.0 implementation that sits on top of the FHIR server.

When choosing a FHIR server provider, it’s important to remember that implementations vary significantly between major cloud providers and open-source alternatives. Before making your selection, carefully evaluate whether specific features you need, such as FHIR subscriptions or SMART-on-FHIR, are supported by your chosen provider.

HealthLake offers a comprehensive suite of services, including machine learning integration, data export capabilities to S3, and default encryption with both AWS-managed and customer-provided keys, as well as REST API functionality and numerous other features. For a more detailed understanding, I strongly recommend reviewing the documentation, which, although lengthy, provides extensive guides and implementation details.

Scheme

This diagram illustrates the workflow of a SMART-on-FHIR application interfacing with Amazon HealthLake.

The sequence follows these steps:

  • The application initiates communication through a discovery endpoint (1)
  • It receives metadata in response (2)
  • The application requests and obtains an authentication token from the Cognito user pool (3,4)
  • Using the received token, it submits a data request (5)

The token validation process includes:

  • Submission of request to HealthLake (5)
  • HealthLake initiates validation through Lambda (6)
  • Optional public key exchange with Cognito (7,8)
  • Lambda returns the validation outcome (9)
  • Upon successful validation, HealthLake transmits FHIR data to the application (10)

The AWS infrastructure is compatible with both on-premises and AWS-hosted Electronic Health Record (EHR) systems.

a scheme showing the AWS infrastructure

If you prefer to skip the complete tutorial and use the modularized solution instead, you can find it in the HealthStack repository.

Prerequisites

  • Terraform installed on your system (version 1.9 was used for this tutorial)
  • AWS admin access credentials (access key and secret key) configured for authentication

Chapter 1: HealthLake

AWS HealthLake is a HIPAA-eligible service that provides a fully managed FHIR server. It stores medical data in NDJSON format and offers a REST API for interacting with health data. While its API handles basic queries, complex analysis requires exporting data to S3 and using tools like Athena or Amazon Comprehend Medical.

First step is to create an AWS HealthLake datastore (awscc_healthlake_fhir_datastore) using Terraform with basic configuration:

resource "awscc_healthlake_fhir_datastore" "this" {
  datastore_name         = "my-fhir-datastore"
  datastore_type_version = "R4"
  preload_data_config = {
    preload_data_type = "SYNTHEA"
  }
  
  sse_configuration = {
    kms_encryption_config = {
      cmk_type   = "AWS_OWNED_KMS_KEY"
      kms_key_id = null
    }
  }

Notice it uses the awscc provider, not the regular aws one.

It provides us with basic HealthLake configuration with AWS managed key encryption, supported R4 (the only one supported standard) and preloaded test data by SYNTHEA which provides quality healthcare data. You can also use AWS Customer managed keys and initialize without preloaded data.

To provide Customer-Managed keys you need to create a KMS resource with appropriate roles to manage it. Neither this nor the S3 configuration for exporting will be covered in the scope of this article but to do so you can use this HeathLake module.

Chapter 2: Cognito

AWS Cognito is a managed authentication service that handles user management and authentication for web and mobile applications. In this tutorial, it’s used to implement SMART-on-FHIR authentication, which is an OAuth2-based protocol specifically designed for healthcare applications. Cognito provides essential features like user pools for managing user directories, resource servers for defining access scopes, and client applications that can authenticate users — all crucial components for securing access to healthcare data through FHIR APIs.

An Amazon Cognito User Pool (aws_cognito_user_pool) is a secure user directory that manages authentication and authorization for web and mobile applications. It handles essential user management tasks like registration, sign-in, and access control.

## Cognito ##
resource "aws_cognito_user_pool" "main" {
  name                = "my-cognito-user-pool"
  deletion_protection = "INACTIVE" // or "ACTIVE"
  mfa_configuration   = "OFF"      // or "ON" / "OPTIONAL"

  alias_attributes         = ["email"]
  auto_verified_attributes = ["email"]

  account_recovery_setting {
    recovery_mechanism {
      name     = "verified_email"
      priority = 1
    }
  }

  admin_create_user_config {
    allow_admin_create_user_only = true
  }

  email_configuration {
    email_sending_account = "COGNITO_DEFAULT"
  }

  password_policy {
    minimum_length                   = 8
    require_lowercase                = true
    require_numbers                  = true
    require_symbols                  = true
    require_uppercase                = true
    temporary_password_validity_days = 7
  }

  schema {
    attribute_data_type      = "String"
    developer_only_attribute = false
    mutable                  = true
    name                     = "email"
    required                 = true

    string_attribute_constraints {
      max_length = "2048"
      min_length = "0"
    }
  }

  user_attribute_update_settings {
    attributes_require_verification_before_update = ["email"]
  }

  username_configuration {
    case_sensitive = false
  }

  verification_message_template {
    default_email_option = "CONFIRM_WITH_CODE"
  }
}

This Cognito User Pool configuration creates a user directory with email-based authentication, password requirements, email verification, and other general capabilities that can be adjusted.

Next we create Cognito User Pool Domain (aws_cognito_user_pool_domain) which provides a dedicated URL endpoint where users can sign in and manage their authentication.

resource "aws_cognito_user_pool_domain" "this" {
  domain       = "smart-fhir-test"
  user_pool_id = aws_cognito_user_pool.main.id
}

At this point, we have defined the user pool and its domain, which we will use to access authentication services.

Going further, we need to define resource servers. A Resource Server in OAuth 2.0 and Cognito is a server that hosts protected resources and handles requests using access tokens. It determines the available “scopes” which define specific permissions for accessing these resources.

resource "aws_cognito_resource_server" "launch" {
  identifier   = "launch"
  name         = "launch"
  user_pool_id = aws_cognito_user_pool.main.id

  scope {
    scope_name        = "patient"
    scope_description = "Request patient data"
  }
}

resource "aws_cognito_resource_server" "system" {
  identifier   = "system"
  name         = "system"
  user_pool_id = aws_cognito_user_pool.main.id

  scope {
    scope_name        = "*.*"
    scope_description = "Full system access"
  }
}

resource "aws_cognito_resource_server" "patient" {
  identifier   = "patient"
  name         = "patient"
  user_pool_id = aws_cognito_user_pool.main.id

  scope {
    scope_name        = "*.read"
    scope_description = "Read patient data"
  }
}

The next step is to define the client for your user pool.

A Cognito User Pool client (aws_cognito_user_pool_client) represents an application that needs to authenticate users through your Cognito User Pool. It acts as a registered application that can access the authentication services.

resource "aws_cognito_user_pool_client" "client" {
  name         = "my-cognito-client"
  user_pool_id = aws_cognito_user_pool.main.id

  generate_secret = true

  prevent_user_existence_errors        = "ENABLED"
  
  // ["code"] for auth code
  allowed_oauth_flows                  = ["client_credentials"]
  allowed_oauth_flows_user_pool_client = true

  // scopes for client credentials flow
  allowed_oauth_scopes = [
    "launch/patient",
    "system/*.*",
    "patient/*.read"
  ]

  // scopes for auth code flow
  // allowed_oauth_scopes = [
  //  "openid",
  //  "profile",
  //  "email",
  //  "phone",
  //  "launch/patient",
  //  "system/*.*",
  //  "patient/*.read"
  // ]

  callback_urls = ["https://localhost"]
  logout_urls   = []

  supported_identity_providers = ["COGNITO"]

  # token validity
  access_token_validity  = 60 // 1 hour
  id_token_validity      = 60 // 1 hour
  refresh_token_validity = 60 // 1 hour

  read_attributes = [
    "address", "birthdate", "email", "email_verified", "family_name",
    "gender", "given_name", "locale", "middle_name", "name", "nickname",
    "phone_number", "phone_number_verified", "picture", "preferred_username",
    "profile", "updated_at", "website", "zoneinfo"
  ]

  write_attributes = [
    "address", "birthdate", "email", "family_name", "gender", "given_name",
    "locale", "middle_name", "name", "nickname", "phone_number", "picture",
    "preferred_username", "profile", "updated_at", "website", "zoneinfo"
  ]

  token_validity_units {
    access_token  = "minutes"
    id_token      = "minutes"
    refresh_token = "minutes"
  }

  depends_on = [
    aws_cognito_resource_server.launch,
    aws_cognito_resource_server.system,
    aws_cognito_resource_server.patient
  ]
}

Note: The commented section allows you to define authentication code-based flow, but we’ll use client credentials in this tutorial.

For testing purposes, you can add a test user (aws_cognito_user) to your user domain.

resource "aws_cognito_user" "test_user" {
  user_pool_id = aws_cognito_user_pool.main.id
  username     = "test"
  password     = "Password1234!"

  attributes = {
    preferred_username = "testadmin"
    email              = "test@test.com"
    email_verified     = true
  }
}

Chapter 3: Lambda

AWS Lambda is one of the most popular services, and I assume most of you have encountered or heard about it. For those unfamiliar with it, AWS Lambda is a service that essentially runs code — specifically, a function. We need to validate credentials with Cognito and encode them in a JWT token that we will return to our service. AWS Lambda is perfectly suited for this task.

Now we’ll create a Lambda function to handle token generation and authentication using Python.
Here’s the project structure:

module
│───lambda
│   ├── package
│   │   ├── lambda_function.py
│   └── requirements.txt
└─── main.tf

Required dependencies in requirements.txt:

PyJWT==2.8.0
python-jose[cryptography]==3.3.0
urllib3>=2.0.7

Here’s a sample Python script we can use for authentication:

import base64
import logging
import json
import os
import urllib.request
from typing import Dict, Any
from datetime import datetime
import time
from jose import jwt
from jose.exceptions import JWTError

# configure logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)

# get environment variables
CLIENT_ID = os.environ['CLIENT_ID']
CLIENT_SECRET = os.environ['CLIENT_SECRET']
JWKS_URI = os.environ['JWKS_URI']
USER_ROLE_ARN = os.environ['USER_ROLE_ARN']
USER_POOL_ID = os.environ['USER_POOL_ID']

class TokenValidationError(Exception):
    """Custom exception for token validation errors"""
    pass

def validate_token_claims(decoded_token: Dict[str, Any], datastore_endpoint: str) -> Dict[str, Any]:
    """
    Validate and format the required claims according to HealthLake's expected format:
    {
        "iss": "authorization-server-endpoint",
        "aud": "healthlake-datastore-endpoint",
        "iat": timestamp,
        "nbf": timestamp,
        "exp": timestamp,
        "isAuthorized": "true",
        "uid": "user-identifier",
        "scope": "system/*.*"
    }
    """
    current_time = int(time.time())

    # extract base claims
    mapped_token = {
        "iss": decoded_token.get('iss'),
        "aud": datastore_endpoint,  # Set to HealthLake datastore endpoint
        "iat": decoded_token.get('iat', current_time),
        "nbf": decoded_token.get('iat', current_time),  # Use iat if nbf not present
        "exp": decoded_token.get('exp'),
        "isAuthorized": "true",  # String "true" as per example
        "uid": decoded_token.get('sub', decoded_token.get('username', '')),  # Use sub or username as uid
        "scope": decoded_token.get('scope', '')
    }

    # validate required claims
    required_claims = ['aud', 'nbf', 'exp', 'scope']
    missing_claims = [claim for claim in required_claims if not mapped_token.get(claim)]
    if missing_claims:
        raise TokenValidationError(f"Missing required claims: {', '.join(missing_claims)}")

    # validate timestamps
    if current_time > mapped_token['exp']:
        raise TokenValidationError("Token has expired")
    if current_time < mapped_token['nbf']:
        raise TokenValidationError("Token is not yet valid")

    # validate scope format and presence
    scopes = mapped_token['scope'].split()
    if not scopes:
        raise TokenValidationError("Token has empty scope")

    # validate at least one FHIR resource scope exists
    valid_scope_prefixes = ('user/', 'system/', 'patient/', 'launch/')
    has_fhir_scope = any(
        scope.startswith(valid_scope_prefixes)
        for scope in scopes
    )
    if not has_fhir_scope:
        raise TokenValidationError("Token missing required FHIR resource scope")

    logger.info(f"Final mapped token: {json.dumps(mapped_token, default=str)}")
    return mapped_token

def decode_token(token: str) -> Dict[str, Any]:
    """Decode and validate the JWT token"""
    try:
        headers = jwt.get_unverified_headers(token)
        kid = headers.get('kid')
        if not kid:
            raise TokenValidationError("No 'kid' found in token headers")

        jwks = fetch_jwks()
        public_key = get_public_key(kid, jwks)

        decoded = jwt.decode(
            token,
            public_key,
            algorithms=['RS256'],
            options={
                'verify_exp': True,
                'verify_aud': False  # we handle audience validation separately
            }
        )

        logger.info(f"Token decoded successfully: {json.dumps(decoded, default=str)}")
        return decoded

    except JWTError as e:
        logger.error(f"JWT validation error: {str(e)}")
        raise TokenValidationError(f"Token validation failed: {str(e)}")
    except Exception as e:
        logger.error(f"Token decoding error: {str(e)}")
        raise TokenValidationError(f"Token decoding failed: {str(e)}")

def fetch_jwks() -> Dict[str, Any]:
    """Fetch the JWKS from the authorization server"""
    try:
        with urllib.request.urlopen(JWKS_URI) as response:
            return json.loads(response.read().decode('utf-8'))
    except Exception as e:
        logger.error(f"Error fetching JWKS: {str(e)}")
        raise TokenValidationError(f"Failed to fetch JWKS: {str(e)}")

def get_public_key(kid: str, jwks: Dict[str, Any]) -> str:
    """Get the public key matching the key ID from JWKS"""
    for key in jwks.get('keys', []):
        if key.get('kid') == kid:
            return json.dumps(key)
    raise TokenValidationError(f"No matching key found for kid: {kid}")

def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
    """
    Lambda handler for SMART on FHIR token validation
    Expected output format:
    {
        "authPayload": {
            "iss": "https://authorization-server-endpoint/oauth2/token",
            "aud": "https://healthlake.region.amazonaws.com/datastore/id/r4/",
            "iat": 1677115637,
            "nbf": 1677115637,
            "exp": 1997877061,
            "isAuthorized": "true",
            "uid": "100101",
            "scope": "system/*.*"
        },
        "iamRoleARN": "iam-role-arn"
    }
    """
    try:
        # validate input
        required_fields = ['datastoreEndpoint', 'operationName', 'bearerToken']
        if not all(field in event for field in required_fields):
            raise ValueError(f"Missing required fields: {', '.join(required_fields)}")

        logger.info(f"Processing request for endpoint: {event['datastoreEndpoint']}, "
                   f"operation: {event['operationName']}")

        # extract token from bearer string
        bearer_token = event['bearerToken']
        token = bearer_token[7:] if bearer_token.startswith('Bearer ') else bearer_token

        # decode and validate token
        decoded_token = decode_token(token)

        # format claims to match expected output
        auth_payload = validate_token_claims(decoded_token, event['datastoreEndpoint'])

        return {
            'authPayload': auth_payload,
            'iamRoleARN': USER_ROLE_ARN
        }

    except TokenValidationError as e:
        logger.error(f"Token validation error: {str(e)}")
        return {
            'authPayload': {
                'isAuthorized': "false",
                'error': str(e)
            }
        }
    except Exception as e:
        logger.error(f"Unexpected error: {str(e)}")
        return {
            'authPayload': {
                'isAuthorized': "false",
                'error': f"Internal error: {str(e)}"
            }
        }

To deploy the lambda function, we need to install additional packages and prepare a package directory for zipping and uploading. Use these commands:

python3.11 -m venv venv
source venv/bin/activate

pip3 install \
--platform manylinux2014_x86_64 \
--target=package \
--implementation cp \
--python-version 3.11 \
--only-binary=:all: --upgrade -r requirements.txt

Next, we’ll configure Lambda by defining the zip process, function settings, and required roles and privileges.

## Lambda ##
data "aws_region" "current" {}
data "aws_caller_identity" "current" {}

data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "lambda/package"
  output_path = "lambda/lambda_function.zip"
  excludes    = ["__pycache__", "*.pyc", "*.dist-info"]
}

resource "aws_lambda_function" "token_validator" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "smart-fhir-auth-lambda"
  role             = aws_iam_role.lambda_role.arn
  handler          = "lambda_function.lambda_handler"
  source_code_hash = filebase64sha256(data.archive_file.lambda_zip.output_path)
  runtime          = "python3.11"
  timeout          = 30
  memory_size      = 128
  architectures    = ["x86_64"]

  environment {
    variables = {
      CLIENT_ID     = aws_cognito_user_pool_client.client.id
      CLIENT_SECRET = aws_cognito_user_pool_client.client.client_secret
      JWKS_URI      = "https://cognito-idp.${data.aws_region.current.name}.amazonaws.com/${aws_cognito_user_pool.main.id}/.well-known/jwks.json"
      USER_ROLE_ARN = aws_iam_role.healthlake_service_role.arn
      USER_POOL_ID  = aws_cognito_user_pool.main.id
    }
  }
}

resource "aws_iam_role" "healthlake_service_role" {
  name        = "my-healthlake-service-role"
  description = "Service role for AWS HealthLake FHIR operations"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Service = "healthlake.amazonaws.com"
        }
        Action = "sts:AssumeRole"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "healthlake_policy" {
  role       = aws_iam_role.healthlake_service_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonHealthLakeFullAccess"
}

resource "aws_iam_role" "lambda_role" {
  name = "smart-fhir-auth-lambda-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
  role       = aws_iam_role.lambda_role.name
}

resource "aws_iam_role_policy" "cognito_access" {
  name = "smart-fhir-auth-lambda-cognito-access"
  role = aws_iam_role.lambda_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "cognito-idp:GetUser"
        ]
        Resource = "*"
      }
    ]
  })
}

resource "aws_lambda_permission" "healthlake" {
  statement_id  = "healthlake"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.token_validator.function_name
  principal     = "healthlake.amazonaws.com"
}

Chapter 4: Update HealthLake resource

Update you first resource with provided identity config:

## HealthLake ##
resource "awscc_healthlake_fhir_datastore" "this" {
  datastore_name         = "my-fhir-datastore"
  datastore_type_version = "R4"
  preload_data_config = {
    preload_data_type = "SYNTHEA"
  }

  identity_provider_configuration = {
    authorization_strategy             = "SMART_ON_FHIR_V1"
    fine_grained_authorization_enabled = true
    idp_lambda_arn                     = aws_lambda_function.token_validator.arn
    metadata = jsonencode({
      issuer                 = "https://${aws_cognito_user_pool_domain.this.domain}.auth.${data.aws_region.current.name}.amazoncognito.com"
      authorization_endpoint = "https://${aws_cognito_user_pool_domain.this.domain}.auth.${data.aws_region.current.name}.amazoncognito.com/oauth2/authorize"
      token_endpoint         = "https://${aws_cognito_user_pool_domain.this.domain}.auth.${data.aws_region.current.name}.amazoncognito.com/oauth2/token"
      jwks_uri               = "https://cognito-idp.${data.aws_region.current.name}.amazonaws.com/${aws_cognito_user_pool.main.id}/.well-known/jwks.json"

      response_types_supported = ["code", "token"]
      response_modes_supported = ["query", "fragment", "form_post"]

      grant_types_supported = [
        "authorization_code",
        "implicit",
        "refresh_token",
        "password",
        "client_credentials"
      ]

      subject_types_supported = ["public"]

      scopes_supported = [
        "openid",
        "profile",
        "email",
        "phone",
        "launch/patient",
        "system/*.*",
        "patient/*.read"
      ]

      token_endpoint_auth_methods_supported = [
        "client_secret_basic",
        "client_secret_post"
      ]

      claims_supported = [
        "ver",
        "jti",
        "iss",
        "aud",
        "iat",
        "exp",
        "cid",
        "uid",
        "scp",
        "sub"
      ]

      code_challenge_methods_supported = ["S256"]

      registration_endpoint  = "https://${aws_cognito_user_pool_domain.this.domain}.auth.${data.aws_region.current.name}.amazoncognito.com/oauth2/register"
      management_endpoint    = "https://${aws_cognito_user_pool_domain.this.domain}.auth.${data.aws_region.current.name}.amazoncognito.com/oauth2/userInfo"
      introspection_endpoint = "https://${aws_cognito_user_pool_domain.this.domain}.auth.${data.aws_region.current.name}.amazoncognito.com/oauth2/introspect"
      revocation_endpoint    = "https://${aws_cognito_user_pool_domain.this.domain}.auth.${data.aws_region.current.name}.amazoncognito.com/oauth2/revoke"

      revocation_endpoint_auth_methods_supported = [
        "client_secret_basic",
        "client_secret_post",
        "client_secret_jwt",
        "private_key_jwt",
        "none"
      ]

      request_parameter_supported = true

      request_object_signing_alg_values_supported = [
        "HS256",
        "HS384",
        "HS512",
        "RS256",
        "RS384",
        "RS512",
        "ES256",
        "ES384",
        "ES512"
      ]

      capabilities = [
        "launch-ehr",
        "sso-openid-connect",
        "client-public"
      ]
    })
  }

  sse_configuration = {
    kms_encryption_config = {
      cmk_type   = "AWS_OWNED_KMS_KEY"
      kms_key_id = null
    }
  }

  lifecycle {
    ignore_changes = [identity_provider_configuration]
  }
}

Chapter 5: Deployment

To make the Terraform configuration more useful, add these outputs:

output "cognito_scopes" {
  description = "Cognito scopes"
  value = {
    launch  = aws_cognito_resource_server.launch.id
    system  = aws_cognito_resource_server.system.id
    patient = aws_cognito_resource_server.patient.id
  }
}

output "cognito_oauth_endpoints" {
  description = "OAuth endpoints for Cognito"
  value = {
    authorization = "https://${aws_cognito_user_pool_domain.this.domain}.auth.${data.aws_region.current.name}.amazoncognito.com/oauth2/authorize"
    token         = "https://${aws_cognito_user_pool_domain.this.domain}.auth.${data.aws_region.current.name}.amazoncognito.com/oauth2/token"
    userinfo      = "https://${aws_cognito_user_pool_domain.this.domain}.auth.${data.aws_region.current.name}.amazoncognito.com/oauth2/userInfo"
    jwks          = "https://cognito-idp.${data.aws_region.current.name}.amazonaws.com/${aws_cognito_user_pool.main.id}/.well-known/jwks.json"
  }
}

output "cognito_domain" {
  description = "Cognito domain"
  value       = "https://${aws_cognito_user_pool_domain.this.domain}.auth.${data.aws_region.current.name}.amazoncognito.com"
}

output "cognito_client_credentials" {
  description = "Cognito Client credentials"
  value = {
    client_id     = aws_cognito_user_pool_client.client.id
    client_secret = aws_cognito_user_pool_client.client.client_secret
  }
  sensitive = true
}
output "datastore_endpoint" {
  value = awscc_healthlake_fhir_datastore.this.datastore_endpoint
}

output "datastore_arn" {
  value = awscc_healthlake_fhir_datastore.this.datastore_arn
}

Follow these steps to ensure your Terraform configuration is properly formatted and validated:

terraform fmt      # format configuration files
terraform init     # download and set up providers
terraform validate # check if configuration is correct

It’s time to deploy our infrastructure by running:

terraform apply

As I mentioned it will take a while, in my case it was 28 minutes. So you can take a break and have a cup of coffee.

This is a log from my deployment:

awscc_healthlake_fhir_datastore.this: Creation complete after 28m24s

You should see something like this in outputs:

Outputs:

cognito_client_credentials = <sensitive>
cognito_domain = "https://smart-fhir-test.auth.us-east-1.amazoncognito.com"
cognito_oauth_endpoints = {
  "authorization" = "https://smart-fhir-test.auth.us-east-1.amazoncognito.com/oauth2/authorize"
  "jwks" = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_oP3ig4SGy/.well-known/jwks.json"
  "token" = "https://smart-fhir-test.auth.us-east-1.amazoncognito.com/oauth2/token"
  "userinfo" = "https://smart-fhir-test.auth.us-east-1.amazoncognito.com/oauth2/userInfo"
}
cognito_scopes = {
  "launch" = "us-east-1_oP3ig4SGy|launch"
  "patient" = "us-east-1_oP3ig4SGy|patient"
  "system" = "us-east-1_oP3ig4SGy|system"
}
datastore_arn = "arn:aws:healthlake:us-east-1:9...3:datastore/fhir/9c3...6b6"
datastore_endpoint = "https://healthlake.us-east-1.amazonaws.com/datastore/9c3...6b6/r4/"

Note datastore_endpoint and cognito_oauth_endpoints.token. You will also need cognito_client_credentials, but as it is sensitive variable you have to:

terraform output -json cognito_client_credentials #=>
{"client_id":"401...st9","client_secret":"1j3...2nf"}

Chapter 6: Testing

Postman

If you want to test SMART, you can use Postman to do soo. (have a look at screens below)

Here is what you need to do:

  • Open Postman and check “Authorization” tab (under URL placeholder)
  • Choose OAuth 2.0 in Auth Type
  • Scroll down and under “Configure New Token” fill credentials:
    - Token Name — ex. “test-token”
    - Grant Type — Client Credentials
    - Access Token URL — ex. https://smart-fhir-test.auth.us-east-1.amazoncognito.com/oauth2/tokenyou get it from cognito_oauth_endpoints.token Terraform output
    - Client ID — 401…st9 — sensitive value you’ve printed out
    — Client Secret — 1j3…2nf — sensitive value you’ve printed out
    — Scope — ex. system/*.*
    — State — ex. 1231234
  • Click “Get New Access Token button”
  • You should get JWT token, click button to use it
  • Make a a test request for instance GET https://healthlake.us-east-1.amazonaws.com/datastore/<YOUR_DATASTORE_ID>/r4/Patient

VSCode HTTP

I like to use VSCode REST Client extension and it is perfectly valid for testing authentication:

# Set your environment variables
@baseUrl = https://smart-fhir-test.auth.us-east-1.amazoncognito.com
@clientId = 401...st9
@clientSecret = 1j39...nf
@fhirEndpoint = https://healthlake.us-east-1.amazonaws.com/datastore/9c3...6b6/r4/

### Get Token using Client Credentials
# @name getToken
POST {{baseUrl}}/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&scope=system/*.*
&client_id={{clientId}}
&client_secret={{clientSecret}}

### Test FHIR API with Token
@token = {{getToken.response.body.access_token}}

GET {{fhirEndpoint}}/Patient
Authorization: Bearer {{token}}

Summary

This guide walked through implementing SMART-on-FHIR authentication with AWS HealthLake using Terraform. We covered:

  1. Setting up AWS HealthLake with basic configuration and encryption
  2. Implementing authentication using AWS Cognito:
    -Creating user pools for managing users
    -Configuring resource servers for SMART-on-FHIR scopes
    -Setting up client applications
  3. Building a Lambda function for token validation and authentication, including:
    -JWT token validation
    -SMART-on-FHIR specific claim validation
    -Error handling and security controls
  4. Deploying the infrastructure with proper IAM roles and permissions

Full code is available in this Github Gist 🚀

For a ready-to-use implementation, check out the Momentum’s HealthStack repository.

Remember that AWS HealthLake deployment takes 20–30 minutes, so plan accordingly.

I hope this article helped you! By following this guide, you've equipped your healthcare application with a robust, standardized authentication mechanism, enhancing both security and interoperability.

So You're Looking to Elevate Your Healthcare Solutions?

At Momentum, we specialize in integrating cutting-edge technologies to streamline healthcare operations and enhance patient outcomes. Whether you're implementing SMART on FHIR or exploring other innovative solutions, our team is here to guide you every step of the way. Let's transform your vision into impactful results, shall we? Contact us today to learn more!

Stay ahead in HealthTech. Subscribe for exclusive industry news, insights, and updates.

Be the first to know about newest advancements, get expert insights, and learn about leading  trends in the landscape of health technology. Sign up for our HealthTech Newsletter for your dose of news.

Oops, something went wrong
Your message couldn't come through. The data you provided seems to be insufficient or incorrect. Please make sure everything is in place and try again.

Read more

5 Trends in Web App Development for Telemedicine You Can’t Ignore

|
January 8, 2025

Empowering Healthcare with Advanced Interoperability Tools

Aleksander Cudny
|
December 17, 2024

The Human Side of AI: Why Explainability Matters in Healthcare

Piotr Sędzik
|
December 9, 2024

Guide to EHR Integration: Better Healthcare Systems for Seamless Patient Care

|
December 5, 2024

Let's Create the Future of Health Together

Looking for a partner who not only understands your challenges but anticipates your future needs? Get in touch, and let’s build something extraordinary in the world of digital health.

Kuba Czaplicki