diff --git a/README.md b/README.md index 04f6ba7..b67e61c 100644 --- a/README.md +++ b/README.md @@ -24,62 +24,6 @@ module "static-site" { } ``` -## Requirements - -| Name | Version | -| ------------------------------------------------------------------------ | ------------- | -| [terraform](#requirement_terraform) | >= 1.1, < 2.0 | -| [aws](#requirement_aws) | ~> 4.32 | - -## Providers - -| Name | Version | -| ------------------------------------------------ | ------- | -| [aws](#provider_aws) | ~> 4.32 | - -## Modules - -| Name | Source | Version | -| -------------------------------------------------------------------- | ----------------------------------- | ------- | -| [certificate](#module_certificate) | terraform-aws-modules/acm/aws | 4.3.1 | -| [gitlab](#module_gitlab) | ./modules/gitlab | n/a | -| [s3_bucket](#module_s3_bucket) | terraform-aws-modules/s3-bucket/aws | 3.6.0 | - -## Resources - -| Name | Type | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| [aws_cloudfront_distribution.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_distribution) | resource | -| [aws_cloudfront_origin_access_identity.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_origin_access_identity) | resource | -| [aws_iam_access_key.deploy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_access_key) | resource | -| [aws_iam_user.deploy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_user) | resource | -| [aws_iam_user_policy.deploy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_user_policy) | resource | -| [aws_route53_record.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource | -| [aws_iam_policy_document.bucket_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.deploy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | - -## Inputs - -| Name | Description | Type | Default | Required | -| --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------- | ------------------ | :------: | -| [cloudfront_price_class](#input_cloudfront_price_class) | n/a | `string` | `"PriceClass_100"` | no | -| [domain_zone_id](#input_domain_zone_id) | The ID of the hosted zone for domain | `string` | n/a | yes | -| [domains](#input_domains) | List of domain aliases. You can also specify wildcard eg.: `*.example.com` | `list(string)` | n/a | yes | -| [gitlab_environment](#input_gitlab_environment) | n/a | `string` | `"*"` | no | -| [gitlab_project_id](#input_gitlab_project_id) | n/a | `string` | `null` | no | -| [logs_bucket](#input_logs_bucket) | n/a | `string` | `null` | no | -| [s3_bucket_name](#input_s3_bucket_name) | n/a | `string` | n/a | yes | -| [tags](#input_tags) | n/a | `map(string)` | `{}` | no | - -## Outputs - -| Name | Description | -| ----------------------------------------------------------------------------------------------------------------------------- | ----------- | -| [aws_access_key_id](#output_aws_access_key_id) | n/a | -| [aws_cloudfront_distribution_id](#output_aws_cloudfront_distribution_id) | n/a | -| [aws_s3_bucket_name](#output_aws_s3_bucket_name) | n/a | -| [aws_secret_access_key](#output_aws_secret_access_key) | n/a | - ## Requirements @@ -87,30 +31,33 @@ module "static-site" { |------|---------| | [terraform](#requirement\_terraform) | >= 1.5, < 2.0 | | [aws](#requirement\_aws) | ~> 5.27 | -| [gitlab](#requirement\_gitlab) | >= 15.7, < 18.0 | +| [gitlab](#requirement\_gitlab) | >= 15.7, < 19.0 | ## Providers | Name | Version | |------|---------| -| [aws](#provider\_aws) | ~> 5.27 | -| [gitlab](#provider\_gitlab) | >= 15.7, < 18.0 | +| [aws](#provider\_aws) | 5.61.0 | +| [gitlab](#provider\_gitlab) | 17.2.0 | ## Modules | Name | Source | Version | |------|--------|---------| -| [certificate](#module\_certificate) | terraform-aws-modules/acm/aws | 5.1.1 | +| [certificate](#module\_certificate) | terraform-aws-modules/acm/aws | 5.2.0 | | [gitlab](#module\_gitlab) | ./modules/gitlab | n/a | -| [s3\_bucket](#module\_s3\_bucket) | terraform-aws-modules/s3-bucket/aws | 4.8.0 | +| [oidc](#module\_oidc) | ./modules/oidc | n/a | +| [s3\_bucket](#module\_s3\_bucket) | terraform-aws-modules/s3-bucket/aws | 4.11.0 | ## Resources | Name | Type | |------|------| +| [aws_cloudfront_cache_policy.oidc](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_cache_policy) | resource | | [aws_cloudfront_distribution.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_distribution) | resource | | [aws_cloudfront_origin_access_control.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_origin_access_control) | resource | | [aws_cloudfront_origin_access_identity.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_origin_access_identity) | resource | +| [aws_cloudfront_origin_request_policy.oidc](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_origin_request_policy) | resource | | [aws_cloudfront_response_headers_policy.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_response_headers_policy) | resource | | [aws_iam_access_key.deploy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_access_key) | resource | | [aws_iam_role.deploy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | @@ -158,6 +105,7 @@ module "static-site" { | [logs\_bucket\_domain\_name](#input\_logs\_bucket\_domain\_name) | n/a | `string` | `null` | no | | [max\_ttl](#input\_max\_ttl) | Maximum amount of time that you want objects to stay in a CloudFront cache | `number` | `86400` | no | | [min\_ttl](#input\_min\_ttl) | Minimum amount of time that you want objects to stay in a CloudFront cache | `number` | `0` | no | +| [oidc](#input\_oidc) | List of OIDC providers |
list(object({
application_name = string
application_id = string
client_secret = string
auth_url = string
token_url = string
session_druation = optional(number, 12 * 3600)
}))
| `[]` | no | | [origin\_path](#input\_origin\_path) | Cloudfront origin path | `string` | `""` | no | | [override\_status\_code\_403](#input\_override\_status\_code\_403) | Override status code for 403 error | `number` | `403` | no | | [override\_status\_code\_404](#input\_override\_status\_code\_404) | Override status code for 404 error | `number` | `200` | no | @@ -182,5 +130,6 @@ module "static-site" { | [aws\_s3\_bucket\_name](#output\_aws\_s3\_bucket\_name) | n/a | | [aws\_s3\_bucket\_regional\_domain\_name](#output\_aws\_s3\_bucket\_regional\_domain\_name) | n/a | | [aws\_secret\_access\_key](#output\_aws\_secret\_access\_key) | n/a | +| [oidc\_callback\_url](#output\_oidc\_callback\_url) | n/a | | [s3\_kms\_key\_arn](#output\_s3\_kms\_key\_arn) | n/a | - \ No newline at end of file + diff --git a/main.tf b/main.tf index 6f496f2..d99ce7c 100644 --- a/main.tf +++ b/main.tf @@ -232,6 +232,51 @@ data "aws_cloudfront_cache_policy" "managed_caching_disabled" { name = "Managed-CachingDisabled" } +resource "aws_cloudfront_cache_policy" "oidc" { + count = length(var.oidc) == 0 ? 0 : 1 + + name = "no-cache-oidc-policy" + comment = "Disable caching for OIDC" + default_ttl = 0 + min_ttl = 0 + max_ttl = 0 + + parameters_in_cache_key_and_forwarded_to_origin { + cookies_config { + cookie_behavior = "none" + } + + headers_config { + header_behavior = "none" + } + + query_strings_config { + query_string_behavior = "none" + } + + #enable_accept_encoding_gzip = true + } +} + +resource "aws_cloudfront_origin_request_policy" "oidc" { + count = length(var.oidc) == 0 ? 0 : 1 + + name = "oidc-origin-policy" + comment = "Forward all cookies and query strings for OIDC" + + cookies_config { + cookie_behavior = "all" + } + + headers_config { + header_behavior = "none" + } + + query_strings_config { + query_string_behavior = "all" + } +} + resource "aws_cloudfront_distribution" "this" { comment = local.main_domain @@ -243,6 +288,22 @@ resource "aws_cloudfront_distribution" "this" { origin_path = var.origin_path } + dynamic "origin" { + for_each = length(var.oidc) == 0 ? [] : [1] + + content { + domain_name = split("/", module.oidc.oidc_callback_url_base)[2] + origin_id = "api-gateway-origin" + + custom_origin_config { + http_port = 80 + https_port = 443 + origin_protocol_policy = "https-only" + origin_ssl_protocols = ["TLSv1.2"] + } + } + } + dynamic "origin" { for_each = var.proxy_paths @@ -265,18 +326,26 @@ resource "aws_cloudfront_distribution" "this" { is_ipv6_enabled = true default_root_object = "index.html" - custom_error_response { - error_caching_min_ttl = 3000 - error_code = 404 - response_code = var.override_status_code_404 - response_page_path = "/index.html" - } + dynamic "custom_error_response" { + for_each = length(var.oidc) > 0 ? [] : [ + { + error_code = 404 + response_code = var.override_status_code_404 + response_page_path = "/index.html" + }, + { + error_code = 403 + response_code = var.override_status_code_403 + response_page_path = "/index.html" + } + ] - custom_error_response { - error_caching_min_ttl = 3000 - error_code = 403 - response_code = var.override_status_code_403 - response_page_path = "/index.html" + content { + error_caching_min_ttl = 3000 + error_code = custom_error_response.value.error_code + response_code = custom_error_response.value.response_code + response_page_path = custom_error_response.value.response_page_path + } } default_cache_behavior { @@ -289,7 +358,7 @@ resource "aws_cloudfront_distribution" "this" { query_string = false cookies { - forward = "none" + forward = length(var.oidc) == 0 ? "none" : "all" } } @@ -298,6 +367,15 @@ resource "aws_cloudfront_distribution" "this" { default_ttl = var.default_ttl max_ttl = var.max_ttl + dynamic "lambda_function_association" { + for_each = module.oidc.lambda_edge_function_arn != null ? [module.oidc.lambda_edge_function_arn] : [] + content { + event_type = "viewer-request" + lambda_arn = lambda_function_association.value + include_body = false + } + } + dynamic "function_association" { for_each = concat( var.functions.viewer_request == null ? [] : [ @@ -321,6 +399,25 @@ resource "aws_cloudfront_distribution" "this" { } } + dynamic "ordered_cache_behavior" { + for_each = length(var.oidc) == 0 ? [] : [1] + + content { + path_pattern = "/callback*" + target_origin_id = "api-gateway-origin" + + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + + viewer_protocol_policy = "redirect-to-https" + + compress = true + + cache_policy_id = aws_cloudfront_cache_policy.oidc[0].id + origin_request_policy_id = aws_cloudfront_origin_request_policy.oidc[0].id + } + } + dynamic "ordered_cache_behavior" { for_each = var.proxy_paths diff --git a/modules/gitlab/README.md b/modules/gitlab/README.md index c6044d7..7bf8d48 100644 --- a/modules/gitlab/README.md +++ b/modules/gitlab/README.md @@ -2,47 +2,6 @@ This module will setup GitLab CI variables for static website deployment. -## Requirements - -| Name | Version | -| ------------------------------------------------------------------------ | ------------- | -| [terraform](#requirement_terraform) | >= 1.1, < 2.0 | -| [gitlab](#requirement_gitlab) | >= 15.7, < 18.0 | - -## Providers - -| Name | Version | -| --------------------------------------------------------- | ------- | -| [gitlab](#provider_gitlab) | >= 15.7, < 18.0 | - -## Modules - -No modules. - -## Resources - -| Name | Type | -| ---------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| [gitlab_project_variable.cloudfront_distribution_id](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/project_variable) | resource | -| [gitlab_project_variable.s3_bucket](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/project_variable) | resource | -| [gitlab_project_variable.site_aws_access_key_id](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/project_variable) | resource | -| [gitlab_project_variable.site_aws_secret_access_key](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/project_variable) | resource | -| [gitlab_project.this](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/data-sources/project) | data source | - -## Inputs - -| Name | Description | Type | Default | Required | -| --------------------------------------------------------------------------------------------------------------------------- | ----------- | -------- | ------- | :------: | -| [aws_access_key_id](#input_aws_access_key_id) | n/a | `string` | n/a | yes | -| [aws_cloudfront_distribution_id](#input_aws_cloudfront_distribution_id) | n/a | `string` | n/a | yes | -| [aws_s3_bucket_name](#input_aws_s3_bucket_name) | n/a | `string` | n/a | yes | -| [aws_secret_access_key](#input_aws_secret_access_key) | n/a | `string` | n/a | yes | -| [gitlab_environment](#input_gitlab_environment) | n/a | `string` | `"*"` | no | -| [gitlab_project_id](#input_gitlab_project_id) | n/a | `string` | n/a | yes | - -## Outputs - -No outputs. ## Requirements @@ -50,13 +9,13 @@ No outputs. | Name | Version | |------|---------| | [terraform](#requirement\_terraform) | >= 1.5, < 2.0 | -| [gitlab](#requirement\_gitlab) | >= 15.7, < 18.0 | +| [gitlab](#requirement\_gitlab) | >= 15.7, < 19.0 | ## Providers | Name | Version | |------|---------| -| [gitlab](#provider\_gitlab) | >= 15.7, < 18.0 | +| [gitlab](#provider\_gitlab) | >= 15.7, < 19.0 | ## Modules @@ -95,4 +54,4 @@ No modules. ## Outputs No outputs. - + \ No newline at end of file diff --git a/modules/gitlab/main.tf b/modules/gitlab/main.tf index e49a442..6285336 100644 --- a/modules/gitlab/main.tf +++ b/modules/gitlab/main.tf @@ -2,7 +2,7 @@ locals { cicd_variable_flat_list = flatten([ for project_id in var.gitlab_project_ids : [ for variable in var.extra_gitlab_cicd_variables : { - id = "${project_id}-${variable.key}" + id = "${project_id}-${variable.key}" project_id = project_id variable = variable } diff --git a/modules/oidc/README.md b/modules/oidc/README.md new file mode 100644 index 0000000..a4d8450 --- /dev/null +++ b/modules/oidc/README.md @@ -0,0 +1,111 @@ +# Terraform module for static site hosting configuring OIDC authentication + +This module will configure OIDC authentication for the application. + +## Usage + +```terraform +locals { + application_domain = "www.example.com" +} + +module "oidc" { + source = "./modules/oidc" + + providers = { + aws = aws + aws.us_east_1 = aws.us_east_1 + } + + oidc = [ + { + application_name = "first_provider" + application_id = "APPLICATION_ID_1" + client_secret = "CLIENT_SECRET_1 + auth_url = "https://gitlab.example.com/oauth/authorize" + token_url = "https://gitlab.example.com/oauth/token" + session_duration = 30 * 86400 + }, + { + application_name = "second_provider" + application_id = "APPLICATION_ID_2" + client_secret = "CLIENT_SECRET_2 + auth_url = "https://gitlab.com/oauth/authorize" + token_url = "https://gitlab.com/oauth/token" + } + ] + application_domain = local.application_domain + project_name = replace(local.applicaton_domain, ".", "-") +} +``` + +Variable `oidc` accepts list of providers application details. When mutliple OIDC providers is present, the first is picked up automatically. To choose another provider go to URL `https://www.example.com/?auth=second_provider` + +## TODO +Generate an HTML file on S3 populeted with links to pick a provider. Redirect user when no session cookie is present. +```html +

Choose authentication method

+ +``` + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.5, < 2.0 | +| [archive](#requirement\_archive) | ~> 2.7 | +| [aws](#requirement\_aws) | ~> 5.27 | +| [random](#requirement\_random) | 3.7.2 | + +## Providers + +| Name | Version | +|------|---------| +| [archive](#provider\_archive) | ~> 2.7 | +| [aws](#provider\_aws) | ~> 5.27 | +| [aws.us\_east\_1](#provider\_aws.us\_east\_1) | ~> 5.27 | +| [random](#provider\_random) | 3.7.2 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_iam_role.lambda_oidc](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy_attachment.lambda_edge](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_lambda_function.edge_auth](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_function.oidc_callback](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_function_url.oidc_callback](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function_url) | resource | +| [random_string.session_secret](https://registry.terraform.io/providers/hashicorp/random/3.7.2/docs/resources/string) | resource | +| [archive_file.callback_lambda_zip](https://registry.terraform.io/providers/hashicorp/archive/latest/docs/data-sources/file) | data source | +| [archive_file.edge_lambda_zip](https://registry.terraform.io/providers/hashicorp/archive/latest/docs/data-sources/file) | data source | +| [aws_iam_policy_document.lambda_assume](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [application\_domain](#input\_application\_domain) | Application domain for redirect after oidc login | `string` | n/a | yes | +| [oidc](#input\_oidc) | List of OIDC providers |
list(object({
application_name = string
application_id = string
client_secret = string
auth_url = string
token_url = string
session_duration = optional(number, 12 * 3600)
}))
| `[]` | no | +| [project\_name](#input\_project\_name) | Prefix for naming the resources | `string` | `"static-site"` | no | +| [tags](#input\_tags) | Resources tags map | `map(string)` | `{}` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [lambda\_edge\_function\_arn](#output\_lambda\_edge\_function\_arn) | ARN Lambda funkce pro edge použití | +| [oidc\_callback\_url](#output\_oidc\_callback\_url) | Callback URL pro OIDC redirect | +| [oidc\_callback\_url\_base](#output\_oidc\_callback\_url\_base) | Base URL for OIDC callback endpoint | + diff --git a/modules/oidc/callback.tf b/modules/oidc/callback.tf new file mode 100644 index 0000000..9d1857e --- /dev/null +++ b/modules/oidc/callback.tf @@ -0,0 +1,32 @@ +# Callback Lambda +data "archive_file" "callback_lambda_zip" { + count = local.enabled ? 1 : 0 + type = "zip" + source_file = "${path.module}/lambda/callback/index.js" + output_path = "${path.module}/lambda/callback.zip" +} + +resource "aws_lambda_function" "oidc_callback" { + count = local.enabled ? 1 : 0 + function_name = "${var.project_name}-oidc-callback" + role = aws_iam_role.lambda_oidc[0].arn + handler = "index.handler" + runtime = "nodejs22.x" + filename = data.archive_file.callback_lambda_zip[0].output_path + source_code_hash = data.archive_file.callback_lambda_zip[0].output_base64sha256 + publish = true + + environment { + variables = { + OIDC_CONFIG_JSON = local.oidc_config_json + } + } + + tags = var.tags +} + +resource "aws_lambda_function_url" "oidc_callback" { + count = local.enabled ? 1 : 0 + function_name = aws_lambda_function.oidc_callback[0].function_name + authorization_type = "NONE" +} diff --git a/modules/oidc/edge.tf b/modules/oidc/edge.tf new file mode 100644 index 0000000..9cd5d1d --- /dev/null +++ b/modules/oidc/edge.tf @@ -0,0 +1,36 @@ +# Edge Lambda +data "archive_file" "edge_lambda_zip" { + count = local.enabled ? 1 : 0 + type = "zip" + output_path = "${path.module}/lambda/edge_auth.zip" + + source { + filename = "index.js" + content = file("${path.module}/lambda/edge_auth/index.js") + } + + source { + filename = "config.json" + content = local.oidc_config_json + } +} + +resource "aws_lambda_function" "edge_auth" { + count = local.enabled ? 1 : 0 + provider = aws.us_east_1 + function_name = "${var.project_name}-oidc-auth" + role = aws_iam_role.lambda_oidc[0].arn + handler = "index.handler" + runtime = "nodejs22.x" + filename = data.archive_file.edge_lambda_zip[0].output_path + source_code_hash = data.archive_file.edge_lambda_zip[0].output_base64sha256 + publish = true + + lifecycle { + ignore_changes = [ + source_code_hash, + ] + } + + tags = var.tags +} diff --git a/modules/oidc/lambda/callback/index.js b/modules/oidc/lambda/callback/index.js new file mode 100644 index 0000000..36622f6 --- /dev/null +++ b/modules/oidc/lambda/callback/index.js @@ -0,0 +1,143 @@ +'use strict'; + +const https = require('https'); +const querystring = require('querystring'); +const crypto = require('crypto'); + +const config = JSON.parse(process.env.OIDC_CONFIG_JSON || '{}'); + +exports.handler = (event, context, callback) => { + //console.log('Callback Lambda - Received event:', JSON.stringify(event, null, 2)); + + // API Gateway payload 2.0 format + const query = event.queryStringParameters || {}; + const headers = event.headers || {}; + const cookies = event.cookies || []; + const providerKey = query.auth; + const code = query.code; + const state = query.state; + + //console.log('Callback Lambda - Query params:', { providerKey, code, state }); + //console.log('Callback Lambda - Headers:', JSON.stringify(headers, null, 2)); + //console.log('Callback Lambda - Cookies from event.cookies:', cookies); + + if (!providerKey || !config[providerKey]) { + console.log('Callback Lambda - No matching provider for:', providerKey); + return callback(null, { + statusCode: 403, + body: 'No matching OIDC provider.', + }); + } + + const provider = config[providerKey]; + + if (!code || !state) { + //console.log('Callback Lambda - Missing code or state:', { code, state }); + return callback(null, { + statusCode: 400, + body: 'Missing code or state.', + }); + } + + // Validate cookie state + let stateCookie = null; + if (cookies.length > 0) { + //console.log('Callback Lambda - Processing cookies:', cookies); + const stateCookieEntry = cookies.find(c => c.startsWith('state=')); + if (stateCookieEntry) { + stateCookie = stateCookieEntry.split('=')[1]; + //console.log('Callback Lambda - Found state cookie:', stateCookie); + } else { + //console.log('Callback Lambda - State cookie not found in:', cookies); + } + } else { + console.log('Callback Lambda - No cookies present in event.cookies'); + } + + if (stateCookie !== state) { + //console.log('Callback Lambda - State validation failed. Expected:', stateCookie, 'Got:', state); + return callback(null, { + statusCode: 400, + body: 'Invalid state parameter.', + }); + } + + console.log('Callback Lambda - State validation successful'); + + // Exchange the authorization code for a token + const tokenData = querystring.stringify({ + client_id: provider.client_id, + client_secret: provider.client_secret, + code, + grant_type: 'authorization_code', + redirect_uri: provider.redirect_uri, + }); + + const url = new URL(provider.token_url); + const options = { + hostname: url.hostname, + path: url.pathname + url.search, + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(tokenData), + }, + }; + + console.log('Callback Lambda - Requesting token from:', provider.token_url); + + const tokenRequest = https.request(options, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + const json = JSON.parse(data); + console.log('Callback Lambda - Token response:', json); + if (json.error) { + console.log('Callback Lambda - Token exchange failed:', json.error); + return callback(null, { + statusCode: 500, + body: `Token exchange failed: ${json.error}`, + }); + } + + // Create a session cookie + const redirectUrl = new URL(provider.redirect_after_login); + const cookieDomain = redirectUrl.hostname; + const sessionValue = json.access_token; + const signature = crypto + .createHmac('sha256', provider.session_secret) + .update(sessionValue) + .digest('hex'); + const sessionCookie = `session=${sessionValue}.${signature}; Domain=${cookieDomain}; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=${provider.session_duration}`; + + //console.log('Callback Lambda - Setting session cookie:', sessionCookie); + return callback(null, { + statusCode: 302, + headers: { + Location: provider.redirect_after_login, + 'Set-Cookie': sessionCookie, + }, + body: '', + }); + } catch (e) { + console.log('Callback Lambda - Invalid token response:', e.message); + return callback(null, { + statusCode: 500, + body: 'Invalid token response.', + }); + } + }); + }); + + tokenRequest.on('error', err => { + console.log('Callback Lambda - Token request failed:', err.message); + return callback(null, { + statusCode: 500, + body: `Token request failed: ${err.message}`, + }); + }); + + tokenRequest.write(tokenData); + tokenRequest.end(); +}; diff --git a/modules/oidc/lambda/edge_auth/index.js b/modules/oidc/lambda/edge_auth/index.js new file mode 100644 index 0000000..311edfa --- /dev/null +++ b/modules/oidc/lambda/edge_auth/index.js @@ -0,0 +1,145 @@ +'use strict'; + +const crypto = require('crypto'); +const querystring = require('querystring'); + +const config = require('./config.json'); + +exports.handler = (event, context, callback) => { + try { + console.log('Edge Lambda - Received event:', JSON.stringify(event, null, 2)); + + // Verify event format + if (!event.Records || !event.Records[0] || !event.Records[0].cf || !event.Records[0].cf.request) { + console.error('Edge Lambda - Invalid event format:', JSON.stringify(event, null, 2)); + return callback(null, { + status: '400', + statusDescription: 'Bad Request', + body: 'Invalid event format', + }); + } + + const request = event.Records[0].cf.request; + const headers = request.headers || {}; + const query = request.querystring || ''; + const params = querystring.parse(query); + const providerKey = params.auth; + + console.log('Edge Lambda - Query params:', { providerKey }); + + // Extract session cookie + let session = null; + if (headers.cookie && Array.isArray(headers.cookie)) { + //console.log('Edge Lambda - Cookies:', headers.cookie); + for (const cookie of headers.cookie) { + const cookieValue = cookie.value || ''; + //console.log('Edge Lambda - Processing cookie string:', cookieValue); + // Split cookies delimited by semicolon or space + const cookieEntries = cookieValue.split('; ').map(entry => entry.trim()); + for (const entry of cookieEntries) { + if (entry.startsWith('session=')) { + session = entry.split('=')[1]; + //console.log('Edge Lambda - Extracted session cookie:', session); + break; + } + } + if (session) break; + } + } else { + console.log('Edge Lambda - No cookie header present or not an array'); + } + + // Validate session cookie if present + if (session) { + //console.log('Edge Lambda - Found session cookie:', session); + // Check session cookie format (value.signature) + if (!session.includes('.')) { + console.log('Edge Lambda - Invalid session cookie format:', session); + } else { + const [value, signature] = session.split('.'); + //console.log('Edge Lambda - Session cookie parts - Value:', value, 'Signature:', signature); + + // Validate for selected provider if providerKey is present + if (providerKey && config[providerKey]) { + const provider = config[providerKey]; + //console.log('Edge Lambda - Provider config:', JSON.stringify(provider, null, 2)); + const expectedSignature = crypto + .createHmac('sha256', provider.session_secret || '') + .update(value) + .digest('hex'); + console.log('Edge Lambda - Validating session for provider:', providerKey); + //console.log('Edge Lambda - Expected signature:', expectedSignature, 'Got:', signature); + if (signature === expectedSignature) { + console.log('Edge Lambda - Session validated successfully for provider:', providerKey); + return callback(null, request); + } else { + console.log('Edge Lambda - Session validation failed for provider:', providerKey); + } + } else { + // Loop through providers if providerKey is not present + console.log('Edge Lambda - No providerKey, trying all providers'); + for (const key of Object.keys(config)) { + const provider = config[key]; + //console.log('Edge Lambda - Provider config for', key, ':', JSON.stringify(provider, null, 2)); + const expectedSignature = crypto + .createHmac('sha256', provider.session_secret || '') + .update(value) + .digest('hex'); + //console.log(`Edge Lambda - Validating session for provider: ${key}, Expected signature: ${expectedSignature}, Got: ${signature}`); + if (signature === expectedSignature) { + console.log('Edge Lambda - Session validated successfully for provider:', key); + return callback(null, request); + } + } + console.log('Edge Lambda - Session validation failed for all providers'); + } + } + } else { + console.log('Edge Lambda - No session cookie found'); + } + + // Redirect to OIDC if session is invalid or not present + //console.log('Edge Lambda - Config:', JSON.stringify(config, null, 2)); + const defaultProviderKey = providerKey || Object.keys(config)[0]; + if (!defaultProviderKey || !config[defaultProviderKey]) { + console.log('Edge Lambda - No matching provider for:', defaultProviderKey); + return callback(null, { + status: '403', + statusDescription: 'Forbidden', + body: 'No matching OIDC provider.', + }); + } + + const provider = config[defaultProviderKey]; + const state = crypto.randomBytes(16).toString('hex'); + const loginUrl = `${provider.auth_url}?` + + `client_id=${encodeURIComponent(provider.client_id)}` + + `&redirect_uri=${encodeURIComponent(provider.redirect_uri)}` + + `&response_type=code&scope=openid&state=${state}`; + + console.log('Edge Lambda - Redirecting to OIDC provider:', loginUrl); + console.log('Edge Lambda - Setting state cookie:', state); + + return callback(null, { + status: '302', + statusDescription: 'Found', + headers: { + location: [{ + key: 'Location', + value: loginUrl, + }], + 'set-cookie': [{ + key: 'Set-Cookie', + value: `state=${state}; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=300`, + }], + }, + }); + } catch (error) { + console.error('Edge Lambda - Error:', error.message, error.stack); + return callback(null, { + status: '500', + statusDescription: 'Internal Server Error', + body: 'Edge Lambda failed to process the request', + }); + } +}; diff --git a/modules/oidc/outputs.tf b/modules/oidc/outputs.tf new file mode 100644 index 0000000..42c19e4 --- /dev/null +++ b/modules/oidc/outputs.tf @@ -0,0 +1,14 @@ +output "lambda_edge_function_arn" { + value = local.enabled ? aws_lambda_function.edge_auth[0].qualified_arn : null + description = "ARN Lambda funkce pro edge použití" +} + +output "oidc_callback_url_base" { + value = local.enabled ? aws_lambda_function_url.oidc_callback[0].function_url : null + description = "Base URL for OIDC callback endpoint" +} + +output "oidc_callback_url" { + value = local.enabled ? "${aws_lambda_function_url.oidc_callback[0].function_url}/callback" : null + description = "Callback URL pro OIDC redirect" +} diff --git a/modules/oidc/shared.tf b/modules/oidc/shared.tf new file mode 100644 index 0000000..0e2444c --- /dev/null +++ b/modules/oidc/shared.tf @@ -0,0 +1,48 @@ +locals { + enabled = length(var.oidc) > 0 + + oidc_config = { + for cfg in var.oidc : cfg.application_name => { + client_id = cfg.application_id + client_secret = cfg.client_secret + auth_url = cfg.auth_url + token_url = cfg.token_url + redirect_uri = "https://${var.application_domain}/callback?auth=${cfg.application_name}" + session_secret = random_string.session_secret.result + redirect_after_login = "https://${var.application_domain}" + session_duration = cfg.session_duration + } + } + + oidc_config_json = local.enabled ? jsonencode(local.oidc_config) : null +} + +resource "random_string" "session_secret" { + length = 64 + special = true +} + +data "aws_iam_policy_document" "lambda_assume" { + count = local.enabled ? 1 : 0 + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com", "edgelambda.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "lambda_oidc" { + count = local.enabled ? 1 : 0 + name = "zvirt-${var.project_name}-oidc" + assume_role_policy = data.aws_iam_policy_document.lambda_assume[0].json + + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "lambda_edge" { + count = local.enabled ? 1 : 0 + role = aws_iam_role.lambda_oidc[0].name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} diff --git a/modules/oidc/variables.tf b/modules/oidc/variables.tf new file mode 100644 index 0000000..5536bf9 --- /dev/null +++ b/modules/oidc/variables.tf @@ -0,0 +1,29 @@ +variable "application_domain" { + type = string + description = "Application domain for redirect after oidc login" +} + +variable "project_name" { + description = "Prefix for naming the resources" + type = string + default = "static-site" +} + +variable "oidc" { + description = "List of OIDC providers" + type = list(object({ + application_name = string + application_id = string + client_secret = string + auth_url = string + token_url = string + session_duration = optional(number, 12 * 3600) + })) + default = [] +} + +variable "tags" { + description = "Resources tags map" + type = map(string) + default = {} +} diff --git a/modules/oidc/versions.tf b/modules/oidc/versions.tf new file mode 100644 index 0000000..6ab9f14 --- /dev/null +++ b/modules/oidc/versions.tf @@ -0,0 +1,19 @@ +terraform { + required_version = ">= 1.5, < 2.0" + + required_providers { + archive = { + source = "hashicorp/archive" + version = "~> 2.7" + } + aws = { + source = "hashicorp/aws" + version = "~> 5.27" + configuration_aliases = [aws, aws.us_east_1] + } + random = { + source = "hashicorp/random" + version = "3.7.2" + } + } +} diff --git a/oidc.tf b/oidc.tf new file mode 100644 index 0000000..19c39d8 --- /dev/null +++ b/oidc.tf @@ -0,0 +1,13 @@ +module "oidc" { + source = "./modules/oidc" + + providers = { + aws = aws + aws.us_east_1 = aws.us_east_1 + } + + oidc = var.oidc + application_domain = local.main_domain + project_name = replace(local.main_domain_sanitized, ".", "-") + tags = local.tags +} diff --git a/outputs.tf b/outputs.tf index 52db0c4..59638eb 100644 --- a/outputs.tf +++ b/outputs.tf @@ -22,6 +22,11 @@ output "aws_s3_bucket_arn" { output "aws_s3_bucket_regional_domain_name" { value = module.s3_bucket.s3_bucket_bucket_regional_domain_name } + output "s3_kms_key_arn" { value = var.encrypt_with_kms ? aws_kms_key.this[0].arn : null } + +output "oidc_callback_url" { + value = module.oidc.oidc_callback_url_base != null ? module.oidc.oidc_callback_url_base : null +} diff --git a/variables.tf b/variables.tf index fe0b352..44f6e24 100644 --- a/variables.tf +++ b/variables.tf @@ -257,3 +257,16 @@ variable "extra_gitlab_cicd_variables" { default = [] description = "List of additional gitlab CI/CD variables" } + +variable "oidc" { + description = "List of OIDC providers" + type = list(object({ + application_name = string + application_id = string + client_secret = string + auth_url = string + token_url = string + session_druation = optional(number, 12 * 3600) + })) + default = [] +}