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
+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 = []
+}