From ff8bbcf343c818994c24bf82b1d16f824e575fac Mon Sep 17 00:00:00 2001
From: Timo Furrer <tuxtimo@gmail.com>
Date: Fri, 14 Mar 2025 12:47:14 +0100
Subject: [PATCH] Implement support for `id_tokens` setups

This change set implements support to configure `id_tokens` in a
somewhat user-friendly way. Due to the lacking support of map nodes as
input values multiple inputs are needed to configure it's support.

Limitation: https://gitlab.com/gitlab-org/gitlab/-/issues/452451

Closes https://gitlab.com/components/opentofu/-/issues/49

Changelog: added
---
 .gitlab/README.md.template                    |  54 +++++++++
 .gitlab/scripts/update-opentofu-versions.sh   |   2 +-
 README.md                                     |  54 +++++++++
 templates/__internal_id_tokens_base_job.yml   |  21 ++++
 templates/apply.yml                           |  39 ++++++
 templates/destroy.yml                         |  39 ++++++
 templates/full-pipeline.yml                   |  36 ++++++
 templates/graph.yml                           |  47 ++++++++
 templates/job-templates.yml                   |  36 ++++++
 templates/plan.yml                            |  40 ++++++-
 templates/test.yml                            |  39 ++++++
 templates/validate-plan-apply.yml             |  30 +++++
 templates/validate-plan-destroy.yml           |  30 +++++
 templates/validate-plan.yml                   |  27 +++++
 templates/validate.yml                        |  42 ++++++-
 tests/iac-id-tokens/backend.tf                |   3 +
 tests/iac-id-tokens/id-tokens-setup.sh        |   1 +
 tests/iac-id-tokens/main.tf                   |  41 +++++++
 .../iac-id-tokens/modules/random-pet/main.tf  |  12 ++
 .../modules/random-pet/outputs.tf             |   3 +
 .../modules/random-pet/variables.tf           |   4 +
 tests/iac-id-tokens/tests/main.tftest.hcl     |   6 +
 .../varfile.integration-test.tfvars           |   1 +
 .../integration-tests/IdTokens.gitlab-ci.yml  | 112 ++++++++++++++++++
 tests/integration.gitlab-ci.yml               |  18 +++
 25 files changed, 734 insertions(+), 3 deletions(-)
 create mode 100644 templates/__internal_id_tokens_base_job.yml
 create mode 100644 tests/iac-id-tokens/backend.tf
 create mode 100644 tests/iac-id-tokens/id-tokens-setup.sh
 create mode 100644 tests/iac-id-tokens/main.tf
 create mode 100644 tests/iac-id-tokens/modules/random-pet/main.tf
 create mode 100644 tests/iac-id-tokens/modules/random-pet/outputs.tf
 create mode 100644 tests/iac-id-tokens/modules/random-pet/variables.tf
 create mode 100644 tests/iac-id-tokens/tests/main.tftest.hcl
 create mode 100644 tests/iac-id-tokens/varfile.integration-test.tfvars
 create mode 100644 tests/integration-tests/IdTokens.gitlab-ci.yml

diff --git a/.gitlab/README.md.template b/.gitlab/README.md.template
index 1793fc3..5c9e3a1 100644
--- a/.gitlab/README.md.template
+++ b/.gitlab/README.md.template
@@ -199,6 +199,60 @@ include:
 stages: [validate, build, deploy]
 ```
 
+### Configure `id_tokens`
+
+> [!note]
+> Due to [lacking support](https://gitlab.com/gitlab-org/gitlab/-/issues/452451)
+> of map nodes as input value types the configuration for `id_tokens` is somewhat special,
+> but nonetheless super easy. Read along!
+
+To configure [`id_tokens`](https://docs.gitlab.com/ci/yaml/#id_tokens) support you need these
+three things:
+
+1. set the `enable_id_tokens` input to `true`.
+2. configure the `.gitlab-tofu:id_tokens` job with your desired `id_tokens` setup.
+3. (optionally) provide the `.gitlab/ci/setup-id-tokens.sh` script to configure things,
+   like assuming IAM roles.
+
+An example setup may look like this:
+
+```yaml
+include:
+  - component: $CI_SERVER_FQDN/components/opentofu/validate-plan-apply@<VERSION>
+    inputs:
+      # The version must currently be specified explicitly as an input,
+      # to find the correctly associated images. # This can be removed
+      # once https://gitlab.com/gitlab-org/gitlab/-/issues/438275 is solved.
+      version: <VERSION> # component version
+      opentofu_version: <OPENTOFU_VERSION>
+      enable_id_tokens: true
+
+stages: [validate, build, deploy]
+
+.gitlab-tofu:id_tokens:
+  id_tokens:
+    GITLAB_OIDC_TOKEN:
+      aud: https://gitlab.com
+```
+
+Then in the `.gitlab/ci/setup-id-tokens.sh` script you might assume a AWS IAM role:
+
+```shell
+apk add --no-cache aws-cli
+export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" \
+    $(aws sts assume-role-with-web-identity \
+        --role-arn ${GITLAB_CI_ROLE_ARN} \
+        --role-session-name "GitLabRunner-${CI_PROJECT_ID}-${CI_PIPELINE_ID}" \
+        --web-identity-token ${GITLAB_OIDC_TOKEN} \
+        --duration-seconds 3600 \
+        --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' \
+        --output text))
+aws sts get-caller-identity
+```
+
+You might configure the name of the `id_tokens` job and the setup script location
+with the `id_tokens_base_job_name` and `id_tokens_setup_script` inputs, respectively.
+
 ### Access to Terraform Module Registry
 
 Similar to automatically configuring the [GitLab-managed Terraform state backend]
diff --git a/.gitlab/scripts/update-opentofu-versions.sh b/.gitlab/scripts/update-opentofu-versions.sh
index bfd5470..417a7a8 100755
--- a/.gitlab/scripts/update-opentofu-versions.sh
+++ b/.gitlab/scripts/update-opentofu-versions.sh
@@ -8,7 +8,7 @@ project_dir="$script_dir/../.."
 echo "Updating template files ..."
 
 templates="templates/*.yml"
-templates_exclude="templates/delete-state.yml templates/module-release.yml"
+templates_exclude="templates/delete-state.yml templates/module-release.yml templates/__internal_id_tokens_base_job.yml"
 
 for relative_template_file in $templates; do
   if echo "$templates_exclude" | grep -q "$relative_template_file"; then continue; fi
diff --git a/README.md b/README.md
index 6c89664..4f490c5 100644
--- a/README.md
+++ b/README.md
@@ -201,6 +201,60 @@ include:
 stages: [validate, build, deploy]
 ```
 
+### Configure `id_tokens`
+
+> [!note]
+> Due to [lacking support](https://gitlab.com/gitlab-org/gitlab/-/issues/452451)
+> of map nodes as input value types the configuration for `id_tokens` is somewhat special,
+> but nonetheless super easy. Read along!
+
+To configure [`id_tokens`](https://docs.gitlab.com/ci/yaml/#id_tokens) support you need these
+three things:
+
+1. set the `enable_id_tokens` input to `true`.
+2. configure the `.gitlab-tofu:id_tokens` job with your desired `id_tokens` setup.
+3. (optionally) provide the `.gitlab/ci/setup-id-tokens.sh` script to configure things,
+   like assuming IAM roles.
+
+An example setup may look like this:
+
+```yaml
+include:
+  - component: $CI_SERVER_FQDN/components/opentofu/validate-plan-apply@<VERSION>
+    inputs:
+      # The version must currently be specified explicitly as an input,
+      # to find the correctly associated images. # This can be removed
+      # once https://gitlab.com/gitlab-org/gitlab/-/issues/438275 is solved.
+      version: <VERSION> # component version
+      opentofu_version: <OPENTOFU_VERSION>
+      enable_id_tokens: true
+
+stages: [validate, build, deploy]
+
+.gitlab-tofu:id_tokens:
+  id_tokens:
+    GITLAB_OIDC_TOKEN:
+      aud: https://gitlab.com
+```
+
+Then in the `.gitlab/ci/setup-id-tokens.sh` script you might assume a AWS IAM role:
+
+```shell
+apk add --no-cache aws-cli
+export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" \
+    $(aws sts assume-role-with-web-identity \
+        --role-arn ${GITLAB_CI_ROLE_ARN} \
+        --role-session-name "GitLabRunner-${CI_PROJECT_ID}-${CI_PIPELINE_ID}" \
+        --web-identity-token ${GITLAB_OIDC_TOKEN} \
+        --duration-seconds 3600 \
+        --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' \
+        --output text))
+aws sts get-caller-identity
+```
+
+You might configure the name of the `id_tokens` job and the setup script location
+with the `id_tokens_base_job_name` and `id_tokens_setup_script` inputs, respectively.
+
 ### Access to Terraform Module Registry
 
 Similar to automatically configuring the [GitLab-managed Terraform state backend]
diff --git a/templates/__internal_id_tokens_base_job.yml b/templates/__internal_id_tokens_base_job.yml
new file mode 100644
index 0000000..8775b65
--- /dev/null
+++ b/templates/__internal_id_tokens_base_job.yml
@@ -0,0 +1,21 @@
+spec:
+  inputs:
+    # NOTE: see the using templates for their description
+    as: {type: string}
+    id_tokens_base_job_name: {type: string}
+    id_tokens_setup_script: {type: string}
+
+---
+
+
+# NOTE: okay, this is bad, but it's what we got.
+# We have to outsource this job (see the using templates for why we have it in the first place)
+# because GitLab ALWAYS evaluates jobs, even if they are never used.
+# This implies that all base jobs have to exist.
+# And the only way for GitLab NOT to parse a job is to NOT include it.
+# Thus, we simply do not include this if we don't have to.
+'.$[[ inputs.as ]]:id_tokens-setup:true':
+  extends: $[[ inputs.id_tokens_base_job_name ]]
+  before_script:
+    - test -f "$[[ inputs.id_tokens_setup_script ]]" && . $[[ inputs.id_tokens_setup_script ]]
+
diff --git a/templates/apply.yml b/templates/apply.yml
index be1c76c..2539887 100644
--- a/templates/apply.yml
+++ b/templates/apply.yml
@@ -99,10 +99,49 @@ spec:
       default: false
       type: boolean
       description: 'Whether to automatically define the HTTP backend configuration block.'
+    enable_id_tokens:
+      default: false
+      type: boolean
+      description: |
+       Whether to enable `id_tokens` support for the job.
+       This works by extending a hidden base job configuration with the `id_tokens` field.
+       The job should only contain the `id_tokens` field, the rest may be overridden.
+       The job name can be configured with `id_tokens_base_job_name` if necessary.
+       If the script given in `id_tokens_setup_script` exists, it will be sourced so that setup actions can be performed,
+       like assuming an IAM role of your cloud provider.
+    id_tokens_base_job_name:
+      default: '.gitlab-tofu:id_tokens'
+      type: string
+      description: 'Name of the hidden base job containing the `id_tokens` configuration to extend this job from. Make sure to only configure `id_tokens`, everything else might be overridden.'
+    id_tokens_setup_script:
+      default: '.gitlab/ci/setup-id-tokens.sh'
+      type: string
+      description: 'Path to a shell script that is sourced when `enable_id_tokens` is `true`.'
 
 ---
 
+include:
+  # NOTE: see the note in that template file for what he heck is going on here.
+  - local: '/templates/__internal_id_tokens_base_job.yml'
+    rules:
+      - if: '"$[[ inputs.enable_id_tokens ]]" == "true"'
+    inputs:
+      as: $[[ inputs.as ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
+
+# NOTE: due to the lacking support of providing map nodes as input values
+# we need to hack support for id_tokens together. It's even worse that id_tokens
+# do NOT have a consistent type - or rather it's child nodes - for example, the `aud`
+# can be a literal string value, a variable or an array. Limitations, bear with me.
+# See https://gitlab.com/gitlab-org/gitlab/-/issues/452451
+'.$[[ inputs.as ]]:id_tokens-setup:false':
+  extends: null
+
 '$[[ inputs.as ]]':
+  extends:
+    # NOTE: see the comment above. This is to support the `id_tokens` setup.
+    - '.$[[ inputs.as ]]:id_tokens-setup:$[[ inputs.enable_id_tokens ]]'
   stage: $[[ inputs.stage ]]
   environment:
     name: $GITLAB_TOFU_STATE_NAME
diff --git a/templates/destroy.yml b/templates/destroy.yml
index 45e0997..f70df31 100644
--- a/templates/destroy.yml
+++ b/templates/destroy.yml
@@ -103,10 +103,49 @@ spec:
       default: false
       type: boolean
       description: 'Whether to automatically define the HTTP backend configuration block.'
+    enable_id_tokens:
+      default: false
+      type: boolean
+      description: |
+       Whether to enable `id_tokens` support for the job.
+       This works by extending a hidden base job configuration with the `id_tokens` field.
+       The job should only contain the `id_tokens` field, the rest may be overridden.
+       The job name can be configured with `id_tokens_base_job_name` if necessary.
+       If the script given in `id_tokens_setup_script` exists, it will be sourced so that setup actions can be performed,
+       like assuming an IAM role of your cloud provider.
+    id_tokens_base_job_name:
+      default: '.gitlab-tofu:id_tokens'
+      type: string
+      description: 'Name of the hidden base job containing the `id_tokens` configuration to extend this job from. Make sure to only configure `id_tokens`, everything else might be overridden.'
+    id_tokens_setup_script:
+      default: '.gitlab/ci/setup-id-tokens.sh'
+      type: string
+      description: 'Path to a shell script that is sourced when `enable_id_tokens` is `true`.'
 
 ---
 
+include:
+  # NOTE: see the note in that template file for what he heck is going on here.
+  - local: '/templates/__internal_id_tokens_base_job.yml'
+    rules:
+      - if: '"$[[ inputs.enable_id_tokens ]]" == "true"'
+    inputs:
+      as: $[[ inputs.as ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
+
+# NOTE: due to the lacking support of providing map nodes as input values
+# we need to hack support for id_tokens together. It's even worse that id_tokens
+# do NOT have a consistent type - or rather it's child nodes - for example, the `aud`
+# can be a literal string value, a variable or an array. Limitations, bear with me.
+# See https://gitlab.com/gitlab-org/gitlab/-/issues/452451
+'.$[[ inputs.as ]]:id_tokens-setup:false':
+  extends: null
+
 '$[[ inputs.as ]]':
+  extends:
+    # NOTE: see the comment above. This is to support the `id_tokens` setup.
+    - '.$[[ inputs.as ]]:id_tokens-setup:$[[ inputs.enable_id_tokens ]]'
   stage: $[[ inputs.stage ]]
   environment:
     name: $GITLAB_TOFU_STATE_NAME
diff --git a/templates/full-pipeline.yml b/templates/full-pipeline.yml
index 290e81b..98403b1 100644
--- a/templates/full-pipeline.yml
+++ b/templates/full-pipeline.yml
@@ -202,6 +202,24 @@ spec:
       default: false
       type: boolean
       description: 'Whether to automatically define the HTTP backend configuration block.'
+    enable_id_tokens:
+      default: false
+      type: boolean
+      description: |
+       Whether to enable `id_tokens` support for the pipeline.
+       This works by extending a hidden base job configuration with the `id_tokens` field.
+       The job should only contain the `id_tokens` field, the rest may be overridden.
+       The job name can be configured with `id_tokens_base_job_name` if necessary.
+       If the script given in `id_tokens_setup_script` exists, it will be sourced so that setup actions can be performed,
+       like assuming an IAM role of your cloud provider.
+    id_tokens_base_job_name:
+      default: '.gitlab-tofu:id_tokens'
+      type: string
+      description: 'Name of the hidden base job containing the `id_tokens` configuration to extend the jobs from. Make sure to only configure `id_tokens`, everything else might be overridden.'
+    id_tokens_setup_script:
+      default: '.gitlab/ci/setup-id-tokens.sh'
+      type: string
+      description: 'Path to a shell script that is sourced when `enable_id_tokens` is `true`.'
 
 ---
 
@@ -241,6 +259,9 @@ include:
       auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
   - local: '/templates/test.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "true"'
@@ -265,6 +286,9 @@ include:
       auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
   - local: '/templates/plan.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -289,6 +313,9 @@ include:
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       allow_developer_role: $[[ inputs.allow_developer_role_to_plan ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
   - local: '/templates/apply.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -310,6 +337,9 @@ include:
       auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
   - local: '/templates/destroy.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -330,6 +360,9 @@ include:
       auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
   - local: '/templates/delete-state.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -418,6 +451,9 @@ stages:
           auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
           allow_developer_role_to_plan: $[[ inputs.allow_developer_role_to_plan ]]
           auto_define_backend: $[[ inputs.auto_define_backend ]]
+          enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+          id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+          id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
           trigger_in_child_pipeline: false
     forward:
       yaml_variables: true
diff --git a/templates/graph.yml b/templates/graph.yml
index c78cde0..ed881e5 100644
--- a/templates/graph.yml
+++ b/templates/graph.yml
@@ -80,6 +80,13 @@ spec:
       default: []
       type: array
       description: 'Defines the `needs` of the job.'
+    rules:
+      # FIXME: eventually, we'll want to define `null` as the default,
+      # but this is NOT support yet, see
+      # https://gitlab.com/gitlab-org/gitlab/-/issues/440468
+      default: [{when: on_success}]
+      type: array
+      description: 'Defines the `rules` of the job.'
     cache_policy:
       default: pull-push
       type: string
@@ -100,12 +107,52 @@ spec:
       default: false
       type: boolean
       description: 'Whether to automatically define the HTTP backend configuration block.'
+    enable_id_tokens:
+      default: false
+      type: boolean
+      description: |
+       Whether to enable `id_tokens` support for the job.
+       This works by extending a hidden base job configuration with the `id_tokens` field.
+       The job should only contain the `id_tokens` field, the rest may be overridden.
+       The job name can be configured with `id_tokens_base_job_name` if necessary.
+       If the script given in `id_tokens_setup_script` exists, it will be sourced so that setup actions can be performed,
+       like assuming an IAM role of your cloud provider.
+    id_tokens_base_job_name:
+      default: '.gitlab-tofu:id_tokens'
+      type: string
+      description: 'Name of the hidden base job containing the `id_tokens` configuration to extend this job from. Make sure to only configure `id_tokens`, everything else might be overridden.'
+    id_tokens_setup_script:
+      default: '.gitlab/ci/setup-id-tokens.sh'
+      type: string
+      description: 'Path to a shell script that is sourced when `enable_id_tokens` is `true`.'
 
 ---
 
+include:
+  # NOTE: see the note in that template file for what he heck is going on here.
+  - local: '/templates/__internal_id_tokens_base_job.yml'
+    rules:
+      - if: '"$[[ inputs.enable_id_tokens ]]" == "true"'
+    inputs:
+      as: $[[ inputs.as ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
+
+# NOTE: due to the lacking support of providing map nodes as input values
+# we need to hack support for id_tokens together. It's even worse that id_tokens
+# do NOT have a consistent type - or rather it's child nodes - for example, the `aud`
+# can be a literal string value, a variable or an array. Limitations, bear with me.
+# See https://gitlab.com/gitlab-org/gitlab/-/issues/452451
+'.$[[ inputs.as ]]:id_tokens-setup:false':
+  extends: null
+
 '$[[ inputs.as ]]':
+  extends:
+    # NOTE: see the comment above. This is to support the `id_tokens` setup.
+    - '.$[[ inputs.as ]]:id_tokens-setup:$[[ inputs.enable_id_tokens ]]'
   stage: $[[ inputs.stage ]]
   needs: $[[ inputs.needs ]]
+  rules: $[[ inputs.rules ]]
   cache:
     key: "$__CACHE_KEY_HACK"
     policy: $[[ inputs.cache_policy ]]
diff --git a/templates/job-templates.yml b/templates/job-templates.yml
index eaa2d30..6f69de6 100644
--- a/templates/job-templates.yml
+++ b/templates/job-templates.yml
@@ -107,6 +107,24 @@ spec:
       default: false
       type: boolean
       description: 'Whether to automatically define the HTTP backend configuration block.'
+    enable_id_tokens:
+      default: false
+      type: boolean
+      description: |
+       Whether to enable `id_tokens` support for the pipeline.
+       This works by extending a hidden base job configuration with the `id_tokens` field.
+       The job should only contain the `id_tokens` field, the rest may be overridden.
+       The job name can be configured with `id_tokens_base_job_name` if necessary.
+       If the script given in `id_tokens_setup_script` exists, it will be sourced so that setup actions can be performed,
+       like assuming an IAM role of your cloud provider.
+    id_tokens_base_job_name:
+      default: '.gitlab-tofu:id_tokens'
+      type: string
+      description: 'Name of the hidden base job containing the `id_tokens` configuration to extend the jobs from. Make sure to only configure `id_tokens`, everything else might be overridden.'
+    id_tokens_setup_script:
+      default: '.gitlab/ci/setup-id-tokens.sh'
+      type: string
+      description: 'Path to a shell script that is sourced when `enable_id_tokens` is `true`.'
 
 ---
 
@@ -139,6 +157,9 @@ include:
       auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
   - local: '/templates/graph.yml'
     inputs:
       as: '$[[ inputs.job_name_prefix ]]graph'
@@ -154,6 +175,9 @@ include:
       auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
   - local: '/templates/test.yml'
     inputs:
       as: '$[[ inputs.job_name_prefix ]]test'
@@ -171,6 +195,9 @@ include:
       auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
   - local: '/templates/plan.yml'
     inputs:
       as: '$[[ inputs.job_name_prefix ]]plan'
@@ -191,6 +218,9 @@ include:
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       allow_developer_role: $[[ inputs.allow_developer_role_to_plan ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
   - local: '/templates/apply.yml'
     inputs:
       as: '$[[ inputs.job_name_prefix ]]apply'
@@ -209,6 +239,9 @@ include:
       auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
   - local: '/templates/destroy.yml'
     inputs:
       as: '$[[ inputs.job_name_prefix ]]destroy'
@@ -227,6 +260,9 @@ include:
       auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
   - local: '/templates/delete-state.yml'
     inputs:
       as: '$[[ inputs.job_name_prefix ]]delete-state'
diff --git a/templates/plan.yml b/templates/plan.yml
index 73b4a51..52b2186 100644
--- a/templates/plan.yml
+++ b/templates/plan.yml
@@ -113,9 +113,37 @@ spec:
       default: false
       type: boolean
       description: 'Whether to automatically define the HTTP backend configuration block.'
+    enable_id_tokens:
+      default: false
+      type: boolean
+      description: |
+       Whether to enable `id_tokens` support for the job.
+       This works by extending a hidden base job configuration with the `id_tokens` field.
+       The job should only contain the `id_tokens` field, the rest may be overridden.
+       The job name can be configured with `id_tokens_base_job_name` if necessary.
+       If the script given in `id_tokens_setup_script` exists, it will be sourced so that setup actions can be performed,
+       like assuming an IAM role of your cloud provider.
+    id_tokens_base_job_name:
+      default: '.gitlab-tofu:id_tokens'
+      type: string
+      description: 'Name of the hidden base job containing the `id_tokens` configuration to extend this job from. Make sure to only configure `id_tokens`, everything else might be overridden.'
+    id_tokens_setup_script:
+      default: '.gitlab/ci/setup-id-tokens.sh'
+      type: string
+      description: 'Path to a shell script that is sourced when `enable_id_tokens` is `true`.'
 
 ---
 
+include:
+  # NOTE: see the note in that template file for what he heck is going on here.
+  - local: '/templates/__internal_id_tokens_base_job.yml'
+    rules:
+      - if: '"$[[ inputs.enable_id_tokens ]]" == "true"'
+    inputs:
+      as: $[[ inputs.as ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
+
 # NOTE: the two following jobs are necessary to implement the abstraction logic
 # required for the `warning_on_non_empty_plan` input.
 # Without any kind of flow control support for the GitLab CI YAML we cannot infer
@@ -142,11 +170,21 @@ spec:
     # but we still want to upload all the artifacts.
     when: always
 
+# NOTE: due to the lacking support of providing map nodes as input values
+# we need to hack support for id_tokens together. It's even worse that id_tokens
+# do NOT have a consistent type - or rather it's child nodes - for example, the `aud`
+# can be a literal string value, a variable or an array. Limitations, bear with me.
+# See https://gitlab.com/gitlab-org/gitlab/-/issues/452451
+'.$[[ inputs.as ]]:id_tokens-setup:false':
+  extends: null
+
 '$[[ inputs.as ]]':
-  stage: $[[ inputs.stage ]]
   extends:
     # NOTE: see the comment above. This is to support the `warning_on_non_empty_plan` input.
     - '.$[[ inputs.as ]]:detailed_exitcode:warning:$[[ inputs.warning_on_non_empty_plan ]]'
+    # NOTE: see the comment above. This is to support the `id_tokens` setup.
+    - '.$[[ inputs.as ]]:id_tokens-setup:$[[ inputs.enable_id_tokens ]]'
+  stage: $[[ inputs.stage ]]
   environment:
     name: $[[ inputs.state_name ]]
     action: prepare
diff --git a/templates/test.yml b/templates/test.yml
index a76e329..124ff37 100644
--- a/templates/test.yml
+++ b/templates/test.yml
@@ -102,13 +102,52 @@ spec:
       default: false
       type: boolean
       description: 'Whether to automatically define the HTTP backend configuration block.'
+    enable_id_tokens:
+      default: false
+      type: boolean
+      description: |
+       Whether to enable `id_tokens` support for the job.
+       This works by extending a hidden base job configuration with the `id_tokens` field.
+       The job should only contain the `id_tokens` field, the rest may be overridden.
+       The job name can be configured with `id_tokens_base_job_name` if necessary.
+       If the script given in `id_tokens_setup_script` exists, it will be sourced so that setup actions can be performed,
+       like assuming an IAM role of your cloud provider.
+    id_tokens_base_job_name:
+      default: '.gitlab-tofu:id_tokens'
+      type: string
+      description: 'Name of the hidden base job containing the `id_tokens` configuration to extend this job from. Make sure to only configure `id_tokens`, everything else might be overridden.'
+    id_tokens_setup_script:
+      default: '.gitlab/ci/setup-id-tokens.sh'
+      type: string
+      description: 'Path to a shell script that is sourced when `enable_id_tokens` is `true`.'
 
 ---
 
+include:
+  # NOTE: see the note in that template file for what he heck is going on here.
+  - local: '/templates/__internal_id_tokens_base_job.yml'
+    rules:
+      - if: '"$[[ inputs.enable_id_tokens ]]" == "true"'
+    inputs:
+      as: $[[ inputs.as ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
+
+# NOTE: due to the lacking support of providing map nodes as input values
+# we need to hack support for id_tokens together. It's even worse that id_tokens
+# do NOT have a consistent type - or rather it's child nodes - for example, the `aud`
+# can be a literal string value, a variable or an array. Limitations, bear with me.
+# See https://gitlab.com/gitlab-org/gitlab/-/issues/452451
+'.$[[ inputs.as ]]:id_tokens-setup:false':
+  extends: null
+
 '$[[ inputs.as ]]':
   stage: $[[ inputs.stage ]]
   needs: $[[ inputs.needs ]]
   rules: $[[ inputs.rules ]]
+  extends:
+    # NOTE: see the comment above. This is to support the `id_tokens` setup.
+    - '.$[[ inputs.as ]]:id_tokens-setup:$[[ inputs.enable_id_tokens ]]'
   cache:
     key: "$__CACHE_KEY_HACK"
     policy: $[[ inputs.cache_policy ]]
diff --git a/templates/validate-plan-apply.yml b/templates/validate-plan-apply.yml
index f1c5005..f72f9c9 100644
--- a/templates/validate-plan-apply.yml
+++ b/templates/validate-plan-apply.yml
@@ -166,6 +166,24 @@ spec:
       default: false
       type: boolean
       description: 'Whether to automatically define the HTTP backend configuration block.'
+    enable_id_tokens:
+      default: false
+      type: boolean
+      description: |
+       Whether to enable `id_tokens` support for the pipeline.
+       This works by extending a hidden base job configuration with the `id_tokens` field.
+       The job should only contain the `id_tokens` field, the rest may be overridden.
+       The job name can be configured with `id_tokens_base_job_name` if necessary.
+       If the script given in `id_tokens_setup_script` exists, it will be sourced so that setup actions can be performed,
+       like assuming an IAM role of your cloud provider.
+    id_tokens_base_job_name:
+      default: '.gitlab-tofu:id_tokens'
+      type: string
+      description: 'Name of the hidden base job containing the `id_tokens` configuration to extend the jobs from. Make sure to only configure `id_tokens`, everything else might be overridden.'
+    id_tokens_setup_script:
+      default: '.gitlab/ci/setup-id-tokens.sh'
+      type: string
+      description: 'Path to a shell script that is sourced when `enable_id_tokens` is `true`.'
 
 ---
 
@@ -206,6 +224,9 @@ include:
       auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
   - local: '/templates/plan.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -231,6 +252,9 @@ include:
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       allow_developer_role: $[[ inputs.allow_developer_role_to_plan ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
   - local: '/templates/apply.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -253,6 +277,9 @@ include:
       auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
 
 
 # NOTE: the following configuration is only used if `trigger_in_child_pipeline` is enabled.
@@ -311,6 +338,9 @@ stages:
           auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
           allow_developer_role_to_plan: $[[ inputs.allow_developer_role_to_plan ]]
           auto_define_backend: $[[ inputs.auto_define_backend ]]
+          enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+          id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+          id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
           trigger_in_child_pipeline: false
     forward:
       yaml_variables: true
diff --git a/templates/validate-plan-destroy.yml b/templates/validate-plan-destroy.yml
index 1d303fd..b21f69b 100644
--- a/templates/validate-plan-destroy.yml
+++ b/templates/validate-plan-destroy.yml
@@ -172,6 +172,24 @@ spec:
       default: false
       type: boolean
       description: 'Whether to automatically define the HTTP backend configuration block.'
+    enable_id_tokens:
+      default: false
+      type: boolean
+      description: |
+       Whether to enable `id_tokens` support for the pipeline.
+       This works by extending a hidden base job configuration with the `id_tokens` field.
+       The job should only contain the `id_tokens` field, the rest may be overridden.
+       The job name can be configured with `id_tokens_base_job_name` if necessary.
+       If the script given in `id_tokens_setup_script` exists, it will be sourced so that setup actions can be performed,
+       like assuming an IAM role of your cloud provider.
+    id_tokens_base_job_name:
+      default: '.gitlab-tofu:id_tokens'
+      type: string
+      description: 'Name of the hidden base job containing the `id_tokens` configuration to extend the jobs from. Make sure to only configure `id_tokens`, everything else might be overridden.'
+    id_tokens_setup_script:
+      default: '.gitlab/ci/setup-id-tokens.sh'
+      type: string
+      description: 'Path to a shell script that is sourced when `enable_id_tokens` is `true`.'
 
 ---
 
@@ -212,6 +230,9 @@ include:
       auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
   - local: '/templates/plan.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -238,6 +259,9 @@ include:
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       allow_developer_role: $[[ inputs.allow_developer_role_to_plan ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
   - local: '/templates/destroy.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -261,6 +285,9 @@ include:
       auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
   - local: '/templates/delete-state.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -344,6 +371,9 @@ stages:
           auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
           allow_developer_role_to_plan: $[[ inputs.allow_developer_role_to_plan ]]
           auto_define_backend: $[[ inputs.auto_define_backend ]]
+          enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+          id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+          id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
           trigger_in_child_pipeline: false
     forward:
       yaml_variables: true
diff --git a/templates/validate-plan.yml b/templates/validate-plan.yml
index ed976b6..136e9d7 100644
--- a/templates/validate-plan.yml
+++ b/templates/validate-plan.yml
@@ -150,6 +150,24 @@ spec:
       default: false
       type: boolean
       description: 'Whether to automatically define the HTTP backend configuration block.'
+    enable_id_tokens:
+      default: false
+      type: boolean
+      description: |
+       Whether to enable `id_tokens` support for the pipeline.
+       This works by extending a hidden base job configuration with the `id_tokens` field.
+       The job should only contain the `id_tokens` field, the rest may be overridden.
+       The job name can be configured with `id_tokens_base_job_name` if necessary.
+       If the script given in `id_tokens_setup_script` exists, it will be sourced so that setup actions can be performed,
+       like assuming an IAM role of your cloud provider.
+    id_tokens_base_job_name:
+      default: '.gitlab-tofu:id_tokens'
+      type: string
+      description: 'Name of the hidden base job containing the `id_tokens` configuration to extend the jobs from. Make sure to only configure `id_tokens`, everything else might be overridden.'
+    id_tokens_setup_script:
+      default: '.gitlab/ci/setup-id-tokens.sh'
+      type: string
+      description: 'Path to a shell script that is sourced when `enable_id_tokens` is `true`.'
 
 ---
 
@@ -190,6 +208,9 @@ include:
       auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
   - local: '/templates/plan.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -215,6 +236,9 @@ include:
       auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
       allow_developer_role: $[[ inputs.allow_developer_role_to_plan ]]
       auto_define_backend: $[[ inputs.auto_define_backend ]]
+      enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
 
 
 # NOTE: the following configuration is only used if `trigger_in_child_pipeline` is enabled.
@@ -270,6 +294,9 @@ stages:
           auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
           allow_developer_role_to_plan: $[[ inputs.allow_developer_role_to_plan ]]
           auto_define_backend: $[[ inputs.auto_define_backend ]]
+          enable_id_tokens: $[[ inputs.enable_id_tokens ]]
+          id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+          id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
           trigger_in_child_pipeline: false
     forward:
       yaml_variables: true
diff --git a/templates/validate.yml b/templates/validate.yml
index 14154a1..ef0a042 100644
--- a/templates/validate.yml
+++ b/templates/validate.yml
@@ -99,10 +99,50 @@ spec:
       default: false
       type: boolean
       description: 'Whether to automatically define the HTTP backend configuration block.'
-
+    enable_id_tokens:
+      default: false
+      type: boolean
+      description: |
+       Whether to enable `id_tokens` support for the job.
+       This works by extending a hidden base job configuration with the `id_tokens` field.
+       The job should only contain the `id_tokens` field, the rest may be overridden.
+       The job name can be configured with `id_tokens_base_job_name` if necessary.
+       If the script given in `id_tokens_setup_script` exists, it will be sourced so that setup actions can be performed,
+       like assuming an IAM role of your cloud provider.
+    id_tokens_base_job_name:
+      default: '.gitlab-tofu:id_tokens'
+      type: string
+      description: 'Name of the hidden base job containing the `id_tokens` configuration to extend this job from. Make sure to only configure `id_tokens`, everything else might be overridden.'
+    id_tokens_setup_script:
+      default: '.gitlab/ci/setup-id-tokens.sh'
+      type: string
+      description: 'Path to a shell script that is sourced when `enable_id_tokens` is `true`.'
 ---
 
+include:
+  # NOTE: see the note in that template file for what he heck is going on here.
+  - local: '/templates/__internal_id_tokens_base_job.yml'
+    rules:
+      - if: '"$[[ inputs.enable_id_tokens ]]" == "true"'
+    inputs:
+      as: $[[ inputs.as ]]
+      id_tokens_base_job_name: $[[ inputs.id_tokens_base_job_name ]]
+      id_tokens_setup_script: $[[ inputs.id_tokens_setup_script ]]
+
+# NOTE: due to the lacking support of providing map nodes as input values
+# we need to hack support for id_tokens together. It's even worse that id_tokens
+# do NOT have a consistent type - or rather it's child nodes - for example, the `aud`
+# can be a literal string value, a variable or an array. Limitations, bear with me.
+# See https://gitlab.com/gitlab-org/gitlab/-/issues/452451
+# Oh and yes, it gets worse, the `true` counterpart for this job is in __internal_id_tokens_base_job.yml
+# See the note there for why ...
+'.$[[ inputs.as ]]:id_tokens-setup:false':
+  extends: null
+
 '$[[ inputs.as ]]':
+  extends:
+    # NOTE: see the comment above. This is to support the `id_tokens` setup.
+    - '.$[[ inputs.as ]]:id_tokens-setup:$[[ inputs.enable_id_tokens ]]'
   stage: $[[ inputs.stage ]]
   rules: $[[ inputs.rules ]]
   cache:
diff --git a/tests/iac-id-tokens/backend.tf b/tests/iac-id-tokens/backend.tf
new file mode 100644
index 0000000..1736bf1
--- /dev/null
+++ b/tests/iac-id-tokens/backend.tf
@@ -0,0 +1,3 @@
+terraform {
+  backend "http" {}
+}
diff --git a/tests/iac-id-tokens/id-tokens-setup.sh b/tests/iac-id-tokens/id-tokens-setup.sh
new file mode 100644
index 0000000..77f3959
--- /dev/null
+++ b/tests/iac-id-tokens/id-tokens-setup.sh
@@ -0,0 +1 @@
+touch id-tokens-setup-ran
diff --git a/tests/iac-id-tokens/main.tf b/tests/iac-id-tokens/main.tf
new file mode 100644
index 0000000..aa8705c
--- /dev/null
+++ b/tests/iac-id-tokens/main.tf
@@ -0,0 +1,41 @@
+module "random_pet" {
+  source = "./modules/random-pet"
+}
+
+resource "local_file" "foo" {
+  content  = "foo!"
+  filename = "${path.module}/foo.bar"
+}
+
+locals {
+  ts = plantimestamp()
+}
+
+// NOTE: always force a change.
+resource "null_resource" "this" {
+  triggers = {
+    timestamp = local.ts
+  }
+}
+
+variable "ci_project_name" {
+  type    = string
+  default = "default"
+}
+
+variable "test_variable" {
+  type    = string
+  default = "default value"
+}
+
+output "project_name" {
+  value = var.ci_project_name
+}
+
+output "test_variable" {
+  value = var.test_variable
+}
+
+output "this_always_changes" {
+  value = local.ts
+}
diff --git a/tests/iac-id-tokens/modules/random-pet/main.tf b/tests/iac-id-tokens/modules/random-pet/main.tf
new file mode 100644
index 0000000..382c4e6
--- /dev/null
+++ b/tests/iac-id-tokens/modules/random-pet/main.tf
@@ -0,0 +1,12 @@
+terraform {
+  required_providers {
+    random = {
+      source  = "hashicorp/random"
+      version = "3.1.2"
+    }
+  }
+}
+
+resource "random_pet" "random_pet" {
+  length = var.length
+}
diff --git a/tests/iac-id-tokens/modules/random-pet/outputs.tf b/tests/iac-id-tokens/modules/random-pet/outputs.tf
new file mode 100644
index 0000000..6a77100
--- /dev/null
+++ b/tests/iac-id-tokens/modules/random-pet/outputs.tf
@@ -0,0 +1,3 @@
+output "random_pet" {
+  value = random_pet.random_pet.id
+}
diff --git a/tests/iac-id-tokens/modules/random-pet/variables.tf b/tests/iac-id-tokens/modules/random-pet/variables.tf
new file mode 100644
index 0000000..3e65d0a
--- /dev/null
+++ b/tests/iac-id-tokens/modules/random-pet/variables.tf
@@ -0,0 +1,4 @@
+variable "length" {
+  default = 1
+  type    = number
+}
\ No newline at end of file
diff --git a/tests/iac-id-tokens/tests/main.tftest.hcl b/tests/iac-id-tokens/tests/main.tftest.hcl
new file mode 100644
index 0000000..fedf96e
--- /dev/null
+++ b/tests/iac-id-tokens/tests/main.tftest.hcl
@@ -0,0 +1,6 @@
+run "test" {
+  assert {
+    condition     = file(local_file.foo.filename) == "foo!"
+    error_message = "Incorrect content in ${local_file.foo.filename}"
+  }
+}
diff --git a/tests/iac-id-tokens/varfile.integration-test.tfvars b/tests/iac-id-tokens/varfile.integration-test.tfvars
new file mode 100644
index 0000000..a9e6ed5
--- /dev/null
+++ b/tests/iac-id-tokens/varfile.integration-test.tfvars
@@ -0,0 +1 @@
+test_variable = "varfile integration test"
diff --git a/tests/integration-tests/IdTokens.gitlab-ci.yml b/tests/integration-tests/IdTokens.gitlab-ci.yml
new file mode 100644
index 0000000..7d4078d
--- /dev/null
+++ b/tests/integration-tests/IdTokens.gitlab-ci.yml
@@ -0,0 +1,112 @@
+include:
+  - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/validate@$CI_COMMIT_SHA
+    inputs:
+      image_registry_base: $GITLAB_OPENTOFU_IMAGE_BASE
+      version: $CI_COMMIT_SHA
+      base_os: $GITLAB_OPENTOFU_BASE_IMAGE_OS
+      opentofu_version: $OPENTOFU_VERSION
+      root_dir: $TEST_GITLAB_TOFU_ROOT_DIR
+      stage: validate
+      state_name: $TEST_GITLAB_TOFU_STATE_NAME
+      rules: [{when: always}]
+      enable_id_tokens: true
+      id_tokens_setup_script: $TEST_GITLAB_TOFU_ROOT_DIR/id-tokens-setup.sh
+
+  - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/test@$CI_COMMIT_SHA
+    inputs:
+      image_registry_base: $GITLAB_OPENTOFU_IMAGE_BASE
+      version: $CI_COMMIT_SHA
+      base_os: $GITLAB_OPENTOFU_BASE_IMAGE_OS
+      opentofu_version: $OPENTOFU_VERSION
+      root_dir: $TEST_GITLAB_TOFU_ROOT_DIR
+      stage: test
+      state_name: $TEST_GITLAB_TOFU_STATE_NAME
+      rules: [{when: always}]
+      enable_id_tokens: true
+      id_tokens_setup_script: $TEST_GITLAB_TOFU_ROOT_DIR/id-tokens-setup.sh
+
+  - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/graph@$CI_COMMIT_SHA
+    inputs:
+      image_registry_base: $GITLAB_OPENTOFU_IMAGE_BASE
+      version: $CI_COMMIT_SHA
+      base_os: $GITLAB_OPENTOFU_BASE_IMAGE_OS
+      opentofu_version: $OPENTOFU_VERSION
+      root_dir: $TEST_GITLAB_TOFU_ROOT_DIR
+      stage: graph
+      state_name: $TEST_GITLAB_TOFU_STATE_NAME
+      rules: [{when: always}]
+      enable_id_tokens: true
+      id_tokens_setup_script: $TEST_GITLAB_TOFU_ROOT_DIR/id-tokens-setup.sh
+
+  - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/plan@$CI_COMMIT_SHA
+    inputs:
+      image_registry_base: $GITLAB_OPENTOFU_IMAGE_BASE
+      version: $CI_COMMIT_SHA
+      base_os: $GITLAB_OPENTOFU_BASE_IMAGE_OS
+      opentofu_version: $OPENTOFU_VERSION
+      root_dir: $TEST_GITLAB_TOFU_ROOT_DIR
+      stage: plan
+      state_name: $TEST_GITLAB_TOFU_STATE_NAME
+      rules: [{when: always}]
+      enable_id_tokens: true
+      id_tokens_setup_script: $TEST_GITLAB_TOFU_ROOT_DIR/id-tokens-setup.sh
+
+  - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/apply@$CI_COMMIT_SHA
+    inputs:
+      image_registry_base: $GITLAB_OPENTOFU_IMAGE_BASE
+      version: $CI_COMMIT_SHA
+      base_os: $GITLAB_OPENTOFU_BASE_IMAGE_OS
+      opentofu_version: $OPENTOFU_VERSION
+      root_dir: $TEST_GITLAB_TOFU_ROOT_DIR
+      stage: apply
+      state_name: $TEST_GITLAB_TOFU_STATE_NAME
+      rules: [{when: always}]
+      enable_id_tokens: true
+      id_tokens_setup_script: $TEST_GITLAB_TOFU_ROOT_DIR/id-tokens-setup.sh
+
+  - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/destroy@$CI_COMMIT_SHA
+    inputs:
+      image_registry_base: $GITLAB_OPENTOFU_IMAGE_BASE
+      version: $CI_COMMIT_SHA
+      base_os: $GITLAB_OPENTOFU_BASE_IMAGE_OS
+      opentofu_version: $OPENTOFU_VERSION
+      root_dir: $TEST_GITLAB_TOFU_ROOT_DIR
+      stage: destroy
+      state_name: $TEST_GITLAB_TOFU_STATE_NAME
+      rules: [{when: always}]
+      enable_id_tokens: true
+      id_tokens_setup_script: $TEST_GITLAB_TOFU_ROOT_DIR/id-tokens-setup.sh
+
+  # For CI Terraform state cleanup
+  - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/delete-state@$CI_COMMIT_SHA
+    inputs:
+      state_name: $TEST_GITLAB_TOFU_STATE_NAME
+      rules: [{when: always}]
+
+stages: [validate, test, graph, plan, apply, destroy, cleanup]
+
+.gitlab-tofu:id_tokens:
+  id_tokens:
+    GITLAB_OIDC_TOKEN:
+      aud: https://gitlab.com
+
+.check-id-tokens-setup-ran: &check_id_tokens_setup_ran
+  - test -f id-tokens-setup-ran
+
+validate:
+  after_script: *check_id_tokens_setup_ran
+
+test:
+  after_script: *check_id_tokens_setup_ran
+
+graph:
+  after_script: *check_id_tokens_setup_ran
+
+plan:
+  after_script: *check_id_tokens_setup_ran
+
+apply:
+  after_script: *check_id_tokens_setup_ran
+
+destroy:
+  after_script: *check_id_tokens_setup_ran
diff --git a/tests/integration.gitlab-ci.yml b/tests/integration.gitlab-ci.yml
index f5ed0b8..6f3be6d 100644
--- a/tests/integration.gitlab-ci.yml
+++ b/tests/integration.gitlab-ci.yml
@@ -175,3 +175,21 @@ destroy-job-template:
         GITLAB_OPENTOFU_BASE_IMAGE_OS:
           - alpine
           - debian
+
+id-tokens:
+  stage: test-integration
+  variables:
+    OPENTOFU_VERSION: $LATEST_OPENTOFU_VERSION
+    TEST_GITLAB_TOFU_STATE_NAME: ci-integration-$CI_JOB_NAME_SLUG-$CI_PIPELINE_IID-$CI_NODE_INDEX
+    TEST_GITLAB_TOFU_ROOT_DIR: tests/iac-id-tokens
+  trigger:
+    include: tests/integration-tests/$PIPELINE_NAME.gitlab-ci.yml
+    strategy: depend
+  parallel:
+    matrix:
+      - PIPELINE_NAME:
+          - IdTokens
+        GITLAB_OPENTOFU_BASE_IMAGE_OS:
+          - alpine
+          - debian
+
-- 
GitLab