diff --git a/.github/ISSUE_TEMPLATE/00_bug_report.yml b/.github/ISSUE_TEMPLATE/00_bug_report.yml deleted file mode 100644 index e6337467a..000000000 --- a/.github/ISSUE_TEMPLATE/00_bug_report.yml +++ /dev/null @@ -1,149 +0,0 @@ -name: "Bug" -labels: ["bug"] -description: "For when something is there, but doesn't work how it should." -body: - - type: textarea - id: community-note - attributes: - label: Community Note - description: Please keep this note for the community - value: | - * Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request. - * Please do not leave _+1_ or _me too_ comments, they generate extra noise for issue followers and do not help prioritize the request. - * If you are interested in working on this issue or have submitted a pull request, please leave a comment. - Before submitting a bug report, we ask that you first [search existing issues](https://github.com/okta/terraform-provider-okta/issues) and [pull requests](https://github.com/okta/terraform-provider-okta/pulls) to see if someone else may have experienced the same issue or may have already submitted a fix for it. This helps to keep all relevant information in one place, including any potential workarounds. - - ### A Note on Terraform Core Issues - - We also ask that you consider whether your issue may be related to Terraform Core. If you are running into one of the following scenarios, we recommend [opening an issue](https://github.com/hashicorp/terraform/issues/new/choose) in the Terraform Core repository instead: - - * [Configuration Language](https://developer.hashicorp.com/terraform/language) or resource ordering issues - * [State](https://developer.hashicorp.com/terraform/language/state) and [State Backend](https://developer.hashicorp.com/terraform/language/backend) issues - * [Provisioner](https://developer.hashicorp.com/terraform/language/resources/provisioners/syntax) issues - * [Registry](https://registry.terraform.io/) issues - * Issues that span resources across multiple providers - validations: - required: true - - type: textarea - id: terraform-version - attributes: - label: Terraform Version & Okta Provider Version(s) - description: | - Please run [`terraform --version`](https://developer.hashicorp.com/terraform/cli/commands/version) to show the Terraform core version and provider version(s) and paste the output here. - If you are not running the latest version of Terraform or the provider, please upgrade because your issue may have already been fixed. - value: | - Terraform vX.X.X - on - + provider registry.terraform.io/providers/okta/okta/ vX.X.X - - validations: - required: true - - type: textarea - id: affected-resources - attributes: - label: Affected Resource(s) - description: Please list the affected resources and data sources. Use `okta_*` if all resources or data sources are affected. - validations: - required: true - - type: dropdown - id: can_be_done_in_admin_ui - attributes: - label: Can this be done in the Admin UI? - description: | - Indicate to the maintainers and community as to whether this issue can be accomplished in the Okta Admin UI. This helps us understand whether this is a limitation of the provider or a limitation of the Okta API. - options: - - "No" - - "Yes" - - "Unsure" - multiple: false - validations: - required: true - - - type: dropdown - id: can_be_done_in_api_call - attributes: - label: Can this be done in the actual API call? - description: | - Indicate to the maintainers and community as to whether this issue can be accomplished in the Okta API. This helps us understand whether this is a limitation of the provider or a limitation of the Okta API. - options: - - "No" - - "Yes" - - "Unsure" - multiple: false - validations: - required: true - - - type: textarea - id: customer_info - attributes: - label: Customer Information - description: | - Please provide your organization name, and whether you are a paid customer or using the free developer edition. - value: | - Organization Name: - Paid Customer: - - - type: textarea - id: terraform-configuration - attributes: - label: Terraform Configuration - description: | - Copy-paste your Terraform configurations here as [markdown code blocks](https://help.github.com/articles/basic-writing-and-formatting-syntax/#quoting-code). - - For large Terraform configs, please use a service like Dropbox and share a link to the ZIP file. For security, you can also encrypt the files using our GPG public key: https://www.hashicorp.com/security - - If reproducing the bug involves modifying the config file (that is, apply a config, change a value, apply the config again, see the bug), then please include both: - - * the version of the config before the change, and - * the version of the config after the change. - value: | - ```tf - ``` - - type: textarea - id: debug-output - attributes: - label: Debug Output - description: | - Please provide a link to a GitHub Gist containing your complete [debug output](https://www.terraform.io/docs/internals/debugging.html). Please **don't** paste the debug output in the issue; just paste a link to the Gist. - Use TF_LOG=DEBUG terraform apply to generate debug logs. - validations: - required: true - - - type: textarea - id: expected-behavior - attributes: - label: Expected Behavior - description: What should have happened? - validations: - required: true - - - type: textarea - id: actual-behavior - attributes: - label: Actual Behavior - description: What actually happened? - validations: - required: true - - - type: textarea - id: steps-to-reproduce - attributes: - label: Steps to reproduce - description: Please list the steps required to reproduce the issue. - value: 1. `terraform apply` - validations: - required: true - - - type: textarea - id: important-factoids - attributes: - label: Important Factoids - description: | - Is there anything atypical about your accounts that we should know? For example: authenticating as a user instead of a service account? - - - type: textarea - id: references - attributes: - label: References - description: | - Are there any other GitHub issues (open or closed) or pull requests that should be [linked](https://help.github.com/articles/basic-writing-and-formatting-syntax/#referencing-issues-and-pull-requests) here? Vendor documentation? \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/01_question.yml b/.github/ISSUE_TEMPLATE/01_question.yml deleted file mode 100644 index e49577fb6..000000000 --- a/.github/ISSUE_TEMPLATE/01_question.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: "Question" -labels: ["question"] -description: "If you have a question, please check out our [community resources](https://www.terraform.io/docs/extend/community/index.html)!" -body: - - type: markdown - attributes: - value: | - Issues on GitHub are intended to be related to bugs or feature requests with provider codebase, so we recommend using our other community resources instead of asking here 👍. - - If you have a support request or question please submit them to one of these resources: - - * [Terraform community resources](https://www.terraform.io/docs/extend/community/index.html) - * [HashiCorp support](https://support.hashicorp.com) (Terraform Enterprise customers) - * [Okta support](https://support.okta.com/) for paid customers, else users can use the [Okta Community](https://devforum.okta.com/) for questions and support requests. - - type: textarea - id: question - attributes: - label: Question - value: Please use the resources listed above instead of submitting a question with this form. - validations: - required: true - - - type: textarea - id: customer_info - attributes: - label: Customer Information - description: | - Please provide your organization name, and whether you are a paid customer or using the free developer edition. - value: | - Organization Name: - Paid Customer: \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/02_documentation.yml b/.github/ISSUE_TEMPLATE/02_documentation.yml deleted file mode 100644 index b390263be..000000000 --- a/.github/ISSUE_TEMPLATE/02_documentation.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Report a Documentation Error -description: Choose this option if you've found an error in the provider documentation or contributor guides. -labels: - - documentation -body: - - type: markdown - attributes: - value: | - ## Thank you for raising a documentation issue! - - This form is meant to alert the maintainers to issues with the provider documentation found on the [Terraform Registry](https://registry.terraform.io/providers/okta/okta/latest) (such as resource and data source documentation, guides, and examples), or the [contributors guide](https://github.com/okta/terraform-provider-okta/blob/master/.github/CONTRIBUTING.md). - - We ask that you first [search existing issues](https://github.com/okta/terraform-provider-okta/labels/documentation-bug) and [pull requests](https://github.com/okta/terraform-provider-okta/pulls) to see if someone else may have already noticed the same issue or has already submitted a fix for it. - - - type: textarea - id: registry_link - attributes: - label: Documentation Link(s) - description: | - Please link to the affected page(s) on the Terraform Registry or [contributors guide](https://github.com/okta/terraform-provider-okta/blob/master/.github/CONTRIBUTING.md). - validations: - required: true - - - type: textarea - id: description - attributes: - label: Description - description: | - Please leave a brief description of the documentation issue(s), including what the documentation currently says and, if possible, what it should say. - validations: - required: true - - - type: textarea - id: customer_info - attributes: - label: Customer Information - description: | - Please provide your organization name, and whether you are a paid customer or using the free developer edition. - value: | - Organization Name: - Paid Customer: - - - type: textarea - id: references - attributes: - label: References - description: | - Where possible, please supply links to Okta documentation and/or other GitHub issues or pull requests that give additional context. - - [Information about referencing Github Issues](https://help.github.com/articles/basic-writing-and-formatting-syntax/#referencing-issues-and-pull-requests) - validations: - required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/03_feature_request.yml b/.github/ISSUE_TEMPLATE/03_feature_request.yml deleted file mode 100644 index 78b944ae8..000000000 --- a/.github/ISSUE_TEMPLATE/03_feature_request.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: "Enhancement" -labels: ["enhancement"] -description: "For when something (a resource, field, etc.) is missing, and should be added." -body: - - type: textarea - id: community-note - attributes: - label: Community Note - description: Please keep this note for the community. - value: | - * Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request. - * Please do not leave _+1_ or _me too_ comments, they generate extra noise for issue followers and do not help prioritize the request. - * If you are interested in working on this issue or have submitted a pull request, please leave a comment. - * If an issue is assigned to a user, that user is claiming responsibility for the issue. - * [OKTA support]() Customers can ask to reach out to Okta Developer Support Engineer/Customer Success Engineer to expedite investigation and resolution of this issue. - validations: - required: true - - - type: textarea - id: description - attributes: - label: Description - description: Please leave a helpful description of the feature request here. Including use cases and why it would help you is a great way to convince maintainers to spend time on it. - validations: - required: true - - - type: textarea - id: affected-resources - attributes: - label: New or Affected Resource(s) - description: Please list the new or affected resources and data sources. Use okta_* if all resources or data sources are affected. - value: | - * okta_XXXXX - validations: - required: true - - - type: textarea - id: customer_info - attributes: - label: Customer Information - description: | - Please provide your organization name, and whether you are a paid customer or using the free developer edition. - value: | - Organization Name: - Paid Customer: - - - type: textarea - id: terraform-configuration - attributes: - label: Potential Terraform Configuration - description: | - Propose what you think the configuration to take advantage of this feature should look like. We may not use it verbatim, but it's helpful in understanding your intent. - - Use [markdown code blocks](https://help.github.com/articles/basic-writing-and-formatting-syntax/#quoting-code) to format the configuration. - value: | - ```tf - ``` - - type: textarea - id: references - attributes: - label: References - description: | - Are there any other GitHub issues (open or closed) or pull requests that should be [linked](https://help.github.com/articles/basic-writing-and-formatting-syntax/#referencing-issues-and-pull-requests) here? Vendor documentation? \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/04_other.yml b/.github/ISSUE_TEMPLATE/04_other.yml deleted file mode 100644 index 990b19a7b..000000000 --- a/.github/ISSUE_TEMPLATE/04_other.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Other -description: Choose this option if your issue does not fit any of the other forms. -body: - - type: markdown - attributes: - value: | - ## Thank you for raising an issue! - - This form is meant as a catch-all for issues that do not fit into one of the other existing forms. By nature this form is much more freeform, so providing a bit of additional information, context, or reference material is very much appreciated. - - Before submission, we ask that you first [search existing issues](https://github.com/okta/terraform-provider-okta/issues) and [pull requests](https://github.com/okta/terraform-provider-okta/pulls) to see if someone else may have already noticed whatever it is you're reporting, or has already worked on a relevant change. - - - type: textarea - id: description - attributes: - label: Description - description: | - Please provide a brief description of what you're looking to report to the maintainers. - validations: - required: true - - - type: textarea - id: references - attributes: - label: Important Facts and References - description: | - Where possible, please supply links to documentation and/or other GitHub issues or pull requests that give additional context. Any other helpful or relevant information may also be provided in this field. - - [Information about referencing Github Issues](https://help.github.com/articles/basic-writing-and-formatting-syntax/#referencing-issues-and-pull-requests) - validations: - required: false - - - type: textarea - id: customer_info - attributes: - label: Customer Information - description: | - Please provide your organization name, and whether you are a paid customer or using the free developer edition. - value: | - Organization Name: - Paid Customer: - - - type: dropdown - id: will_contribute - attributes: - label: Would you like to implement a relevant change? - description: | - Indicate to the maintainers and community as to whether you plan to implement a change related to this (you can update this later if you change your mind). This helps prevent duplication of effort, as many of our contributors look for recently filed issues as a source for their next contribution. - - If this would be your first contribution, refer to the [contributors guide](https://github.com/okta/terraform-provider-okta/blob/master/.github/CONTRIBUTING.md) for tips on getting started. - options: - - "No" - - "Yes" - multiple: false - validations: - required: true \ No newline at end of file diff --git a/.github/workflows/pull_request_reviewer.yml b/.github/workflows/pull_request_reviewer.yml deleted file mode 100644 index 4487c964a..000000000 --- a/.github/workflows/pull_request_reviewer.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: "Pull Request Reviewer" -on: - pull_request_target: - types: [opened, ready_for_review, reopened] - -permissions: - pull-requests: write - -jobs: - add-reviewer: - runs-on: ubuntu-latest - steps: - - uses: kentaro-m/auto-assign-action@v2.0.0 - with: - configuration-path: ".github/reviewer-lottery.yml" - repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/release-tfe.yml b/.github/workflows/release-tfe.yml new file mode 100644 index 000000000..d83cb6125 --- /dev/null +++ b/.github/workflows/release-tfe.yml @@ -0,0 +1,122 @@ +# Publish provider to Lambda Labs Terraform Enterprise private registry +# +# This workflow builds and uploads the provider to terraform.lambdalabs.cloud +# when a tag matching "v*" is pushed. +# +# Required secrets: +# GPG_PRIVATE_KEY_TFE - GPG private key (no passphrase) for signing +# TFE_TOKEN - TFE API token with registry management permissions +# +name: Release to TFE + +on: + push: + tags: + - "v*" + # Allow manual trigger for re-publishing + workflow_dispatch: + inputs: + tag: + description: 'Git tag to publish (e.g., v1.0.0)' + required: true + type: string + +permissions: + contents: read + +env: + TFE_HOST: terraform.lambdalabs.cloud + TFE_ORG: lambdacloud + PROVIDER_NAME: okta + +jobs: + release-tfe: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.tag || github.ref }} + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: "go.mod" + cache: true + + - name: Import GPG key + id: import_gpg + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY_TFE }} + # No passphrase for CI/CD key + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean --skip=publish --parallelism=2 + env: + GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} + + - name: Export GPG public key + run: | + gpg --armor --export ${{ steps.import_gpg.outputs.fingerprint }} > dist/gpg-public-key.asc + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install dependencies + run: pip install requests + + - name: Extract version + id: version + run: | + if [ -n "${{ inputs.tag }}" ]; then + VERSION="${{ inputs.tag }}" + else + VERSION="${GITHUB_REF#refs/tags/}" + fi + # Remove 'v' prefix if present + VERSION="${VERSION#v}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Get short GPG key-id + id: gpg_short + run: | + # TFE expects the short key-id (last 16 characters of fingerprint) + FULL_FP="${{ steps.import_gpg.outputs.fingerprint }}" + SHORT_ID=$(echo "$FULL_FP" | tail -c 17 | head -c 16) + echo "Full fingerprint: $FULL_FP" + echo "Short key-id: $SHORT_ID" + echo "key_id=$SHORT_ID" >> $GITHUB_OUTPUT + + - name: Upload to TFE + env: + TFE_TOKEN: ${{ secrets.TFE_TOKEN }} + GPG_KEY_ID: ${{ steps.gpg_short.outputs.key_id }} + run: | + python3 scripts/upload_to_tfe.py --version "${{ steps.version.outputs.version }}" + + - name: Summary + run: | + echo "## Provider Published to TFE" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Registry URL:** https://${{ env.TFE_HOST }}/app/${{ env.TFE_ORG }}/registry/providers/private/${{ env.TFE_ORG }}/${{ env.PROVIDER_NAME }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Usage" >> $GITHUB_STEP_SUMMARY + echo '```hcl' >> $GITHUB_STEP_SUMMARY + echo 'terraform {' >> $GITHUB_STEP_SUMMARY + echo ' required_providers {' >> $GITHUB_STEP_SUMMARY + echo ' okta = {' >> $GITHUB_STEP_SUMMARY + echo ' source = "${{ env.TFE_HOST }}/${{ env.TFE_ORG }}/okta"' >> $GITHUB_STEP_SUMMARY + echo ' version = "${{ steps.version.outputs.version }}"' >> $GITHUB_STEP_SUMMARY + echo ' }' >> $GITHUB_STEP_SUMMARY + echo ' }' >> $GITHUB_STEP_SUMMARY + echo '}' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index c496889c9..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,54 +0,0 @@ -# This GitHub action can publish assets for release when a tag is created. -# Currently its setup to run on any tag that matches the pattern "v*" (ie. v0.1.0). -# -# This uses an action (hashicorp/ghaction-import-gpg) that assumes you set your -# private key in the `GPG_PRIVATE_KEY` secret and passphrase in the `PASSPHRASE` -# secret. If you would rather own your own GPG handling, please fork this action -# or use an alternative one for key handling. -# -# You will need to pass the `--batch` flag to `gpg` in your signing step -# in `goreleaser` to indicate this is being used in a non-interactive mode. -# -name: release -on: - push: - tags: - - "v*" -jobs: - goreleaser: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Unshallow - run: git fetch --prune --unshallow - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: "go.mod" - cache: true - - - name: Run VCR acceptance tests - run: time make smoke-test-play-vcr-acc - timeout-minutes: 30 - - - name: Import GPG key - id: import_gpg - uses: - crazy-max/ghaction-import-gpg@v6 - # These secrets will need to be configured for the repository: - with: - gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} - passphrase: ${{ secrets.PASSPHRASE }} - - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6.4.0 - with: - version: "v2.12.7" - args: release --clean --parallelism 2 - env: - GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} - # GitHub sets this automatically - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index 443cb4b30..000000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Mark stale issues and pull requests - -on: - schedule: - - cron: '0 0 * * *' - -jobs: - stale: - runs-on: ubuntu-latest - - steps: - - uses: actions/stale@v10 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Comment or this will be closed in 35 days' - days-before-stale: 30 - days-before-close: 35 - remove-stale-when-updated: true - exempt-issue-labels: 'no-stalebot' - exempt-pr-labels: 'no-stalebot' - stale-issue-label: 'Stale' - stale-pr-label: 'Stale' - any-of-labels: 'waiting-response' - labels-to-remove-when-unstale: 'waiting-response' - close-pr-label: 'stalebot-closed' diff --git a/.github/workflows/unstale.yml b/.github/workflows/unstale.yml deleted file mode 100644 index 0214c56ab..000000000 --- a/.github/workflows/unstale.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Remove Label on Issue Comment - -permissions: - issues: write - -on: - issue_comment: - types: [created] - -jobs: - issue_commented: - name: Issue comment - if: ${{ !github.event.issue.pull_request }} - runs-on: ubuntu-latest - steps: - - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const labelToRemove = 'waiting-response'; - const issueNumber = context.issue.number; - const owner = context.repo.owner; - const repo = context.repo.repo; - - const { data: labels } = await github.rest.issues.listLabelsOnIssue({ - owner: owner, - repo: repo, - issue_number: issueNumber - }); - - const labelNames = labels.map(label => label.name); - - if (labelNames.includes(labelToRemove)) { - await github.rest.issues.removeLabel({ - issue_number: issueNumber, - owner: owner, - repo: repo, - name: labelToRemove - }); - } - # pr_commented: - # # This job only runs for pull request comments - # name: PR comment - # if: ${{ github.event.issue.pull_request }} - # runs-on: ubuntu-latest - # steps: - # - run: | - # echo A comment on PR $NUMBER - # env: - # NUMBER: ${{ github.event.issue.number }} diff --git a/docs/PUBLISHING_TO_TFE.md b/docs/PUBLISHING_TO_TFE.md new file mode 100644 index 000000000..fdb4be5bb --- /dev/null +++ b/docs/PUBLISHING_TO_TFE.md @@ -0,0 +1,459 @@ +# Publishing terraform-provider-okta to Terraform Enterprise + +This document describes how to publish the Okta Terraform provider to our private Terraform Enterprise registry at `terraform.lambdalabs.cloud`. + +## Overview + +Publishing a provider to TFE requires: +1. Building binaries for all supported platforms +2. Creating checksums and GPG signatures +3. Uploading the GPG public key to TFE (one-time setup) +4. Creating a provider and version in the registry +5. Uploading all artifacts via the TFE API + +## Prerequisites + +### Tools Required +- **Go** (1.21+) +- **GoReleaser** (v2): `go install github.com/goreleaser/goreleaser/v2@latest` +- **GPG**: For signing releases +- **Python 3**: For the upload script (with `requests` library) +- **curl** and **jq**: For API interactions + +### GPG Key Setup + +Create a GPG key without a passphrase for CI/CD use: + +```bash +cat > /tmp/gpg-batch < private-key.asc +``` + +### TFE API Token + +Generate a TFE API token with "Manage Private Registry" permissions from: +`https://terraform.lambdalabs.cloud/app/settings/tokens` + +## Manual Publishing Process + +### Step 1: Create Git Tag + +```bash +git tag v1.0.0 +``` + +### Step 2: Build with GoReleaser + +```bash +export GPG_FINGERPRINT="YOUR_GPG_KEY_ID" +goreleaser release --clean --skip=publish --parallelism=1 +``` + +This creates in `dist/`: +- `terraform-provider-okta_VERSION_OS_ARCH.zip` (14 platform binaries) +- `terraform-provider-okta_VERSION_SHA256SUMS` +- `terraform-provider-okta_VERSION_SHA256SUMS.sig` + +### Step 3: Export GPG Public Key + +```bash +gpg --armor --export $GPG_FINGERPRINT > dist/gpg-public-key.asc +``` + +### Step 4: Upload to TFE + +Run the upload script: +```bash +python3 dist/upload_platforms.py +``` + +Or manually via API (see detailed steps below). + +## TFE API Upload Steps + +### 1. Create Provider (one-time) + +```bash +curl -s --header "Authorization: Bearer $TOKEN" \ + --header "Content-Type: application/vnd.api+json" \ + --request POST \ + --data '{"data":{"type":"registry-providers","attributes":{"name":"okta","namespace":"lambdacloud","registry-name":"private"}}}' \ + "https://terraform.lambdalabs.cloud/api/v2/organizations/lambdacloud/registry-providers" +``` + +### 2. Upload GPG Key (one-time per key) + +```bash +# Create payload +python3 -c " +import json +with open('dist/gpg-public-key.asc', 'r') as f: + key = f.read() +payload = {'data': {'type': 'gpg-keys', 'attributes': {'namespace': 'lambdacloud', 'ascii-armor': key}}} +with open('gpg-key-payload.json', 'w') as f: + json.dump(payload, f) +" + +curl -s --header "Authorization: Bearer $TOKEN" \ + --header "Content-Type: application/vnd.api+json" \ + --request POST \ + --data @gpg-key-payload.json \ + "https://terraform.lambdalabs.cloud/api/registry/private/v2/gpg-keys" +``` + +### 3. Create Provider Version + +```bash +curl -s --header "Authorization: Bearer $TOKEN" \ + --header "Content-Type: application/vnd.api+json" \ + --request POST \ + --data '{"data":{"type":"registry-provider-versions","attributes":{"version":"1.0.0","key-id":"YOUR_GPG_KEY_ID","protocols":["5.0"]}}}' \ + "https://terraform.lambdalabs.cloud/api/v2/organizations/lambdacloud/registry-providers/private/lambdacloud/okta/versions" +``` + +This returns upload URLs for `shasums-upload` and `shasums-sig-upload`. + +### 4. Upload Checksums + +```bash +curl -s --request PUT --upload-file "dist/terraform-provider-okta_1.0.0_SHA256SUMS" "$SHASUMS_UPLOAD_URL" +curl -s --request PUT --upload-file "dist/terraform-provider-okta_1.0.0_SHA256SUMS.sig" "$SHASUMS_SIG_UPLOAD_URL" +``` + +### 5. Create Platforms and Upload Binaries + +For each platform (linux_amd64, darwin_arm64, etc.): + +```bash +# Create platform +curl -s --header "Authorization: Bearer $TOKEN" \ + --header "Content-Type: application/vnd.api+json" \ + --request POST \ + --data '{"data":{"type":"registry-provider-version-platforms","attributes":{"os":"linux","arch":"amd64","shasum":"HASH_FROM_SHA256SUMS","filename":"terraform-provider-okta_1.0.0_linux_amd64.zip"}}}' \ + "https://terraform.lambdalabs.cloud/api/v2/organizations/lambdacloud/registry-providers/private/lambdacloud/okta/versions/1.0.0/platforms" + +# Upload binary using the returned provider-binary-upload URL +curl -s --request PUT --upload-file "dist/terraform-provider-okta_1.0.0_linux_amd64.zip" "$BINARY_UPLOAD_URL" +``` + +## GitHub Actions Workflow + +Create `.github/workflows/release.yml`: + +```yaml +name: Release to TFE + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - name: Import GPG key + id: import_gpg + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: release --clean --skip=publish --parallelism=2 + env: + GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} + + - name: Export GPG public key + run: | + gpg --armor --export ${{ steps.import_gpg.outputs.fingerprint }} > dist/gpg-public-key.asc + + - name: Upload to TFE + env: + TFE_TOKEN: ${{ secrets.TFE_TOKEN }} + TFE_HOST: terraform.lambdalabs.cloud + TFE_ORG: lambdacloud + PROVIDER_NAME: okta + GPG_KEY_ID: ${{ steps.import_gpg.outputs.fingerprint }} + run: | + # Extract version from tag + VERSION=${GITHUB_REF#refs/tags/v} + + # Install requests + pip install requests + + # Run upload script + python3 scripts/upload_to_tfe.py --version "$VERSION" +``` + +### Upload Script for CI + +Create `scripts/upload_to_tfe.py`: + +```python +#!/usr/bin/env python3 +"""Upload Terraform provider to TFE private registry.""" +import argparse +import json +import os +import sys +import requests + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--version', required=True, help='Provider version (without v prefix)') + args = parser.parse_args() + + # Configuration from environment + token = os.environ['TFE_TOKEN'] + host = os.environ.get('TFE_HOST', 'terraform.lambdalabs.cloud') + org = os.environ.get('TFE_ORG', 'lambdacloud') + provider = os.environ.get('PROVIDER_NAME', 'okta') + gpg_key_id = os.environ['GPG_KEY_ID'] + version = args.version + dist_dir = 'dist' + + api = f"https://{host}/api/v2" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/vnd.api+json" + } + + print(f"==> Uploading {provider} v{version} to {host}/{org}") + + # Step 1: Ensure provider exists + print("==> Creating provider (if not exists)...") + resp = requests.post( + f"{api}/organizations/{org}/registry-providers", + headers=headers, + json={ + "data": { + "type": "registry-providers", + "attributes": { + "name": provider, + "namespace": org, + "registry-name": "private" + } + } + } + ) + if resp.status_code == 201: + print(" Provider created") + elif resp.status_code == 422: + print(" Provider already exists") + else: + print(f" Response: {resp.status_code}") + + # Step 2: Upload GPG key (idempotent - TFE handles duplicates) + print("==> Uploading GPG key...") + gpg_key_path = os.path.join(dist_dir, 'gpg-public-key.asc') + with open(gpg_key_path) as f: + gpg_key = f.read() + + resp = requests.post( + f"https://{host}/api/registry/private/v2/gpg-keys", + headers=headers, + json={ + "data": { + "type": "gpg-keys", + "attributes": { + "namespace": org, + "ascii-armor": gpg_key + } + } + } + ) + if resp.status_code in [200, 201]: + print(f" GPG key uploaded: {gpg_key_id}") + else: + print(f" GPG key response: {resp.status_code} - {resp.text[:200]}") + + # Step 3: Create version + print(f"==> Creating version {version}...") + resp = requests.post( + f"{api}/organizations/{org}/registry-providers/private/{org}/{provider}/versions", + headers=headers, + json={ + "data": { + "type": "registry-provider-versions", + "attributes": { + "version": version, + "key-id": gpg_key_id, + "protocols": ["5.0"] + } + } + } + ) + + if resp.status_code != 201: + print(f" ERROR: {resp.status_code} - {resp.text}") + sys.exit(1) + + version_data = resp.json() + shasums_upload = version_data['data']['links']['shasums-upload'] + shasums_sig_upload = version_data['data']['links']['shasums-sig-upload'] + print(f" Version created") + + # Step 4: Upload checksums + print("==> Uploading checksums...") + shasums_file = os.path.join(dist_dir, f"terraform-provider-{provider}_{version}_SHA256SUMS") + shasums_sig_file = os.path.join(dist_dir, f"terraform-provider-{provider}_{version}_SHA256SUMS.sig") + + with open(shasums_file, 'rb') as f: + requests.put(shasums_upload, data=f) + print(" SHA256SUMS uploaded") + + with open(shasums_sig_file, 'rb') as f: + requests.put(shasums_sig_upload, data=f) + print(" SHA256SUMS.sig uploaded") + + # Step 5: Upload platforms + print("==> Uploading platform binaries...") + with open(shasums_file) as f: + lines = f.readlines() + + for line in lines: + parts = line.strip().split() + if len(parts) != 2: + continue + + sha_hash, filename = parts + name_parts = filename.replace('.zip', '').split('_') + if len(name_parts) < 4: + continue + + os_name = name_parts[-2] + arch = name_parts[-1] + + print(f" {os_name}_{arch}...", end=' ', flush=True) + + resp = requests.post( + f"{api}/organizations/{org}/registry-providers/private/{org}/{provider}/versions/{version}/platforms", + headers=headers, + json={ + "data": { + "type": "registry-provider-version-platforms", + "attributes": { + "os": os_name, + "arch": arch, + "shasum": sha_hash, + "filename": filename + } + } + } + ) + + if resp.status_code != 201: + print(f"ERROR: {resp.text[:100]}") + continue + + upload_url = resp.json()['data']['links']['provider-binary-upload'] + binary_path = os.path.join(dist_dir, filename) + + with open(binary_path, 'rb') as f: + requests.put(upload_url, data=f) + print("done") + + print() + print(f"==> Upload complete!") + print(f"==> https://{host}/app/{org}/registry/providers/private/{org}/{provider}") + +if __name__ == '__main__': + main() +``` + +### Required GitHub Secrets + +Configure these secrets in your repository settings: + +| Secret | Description | +|--------|-------------| +| `GPG_PRIVATE_KEY` | ASCII-armored GPG private key (output of `gpg --armor --export-secret-keys KEY_ID`) | +| `TFE_TOKEN` | TFE API token with registry management permissions | + +### Creating a Release + +1. Update version as needed +2. Create and push a tag: + ```bash + git tag v1.0.1 + git push origin v1.0.1 + ``` +3. GitHub Actions will automatically build and publish to TFE + +## Using the Provider + +Once published, use the provider in your Terraform configurations: + +```hcl +terraform { + required_providers { + okta = { + source = "terraform.lambdalabs.cloud/lambdacloud/okta" + version = "~> 1.0" + } + } +} + +provider "okta" { + # Configuration options +} +``` + +## Troubleshooting + +### "GPG signing failed" +- Ensure the GPG key has no passphrase for CI/CD use +- Verify `GPG_FINGERPRINT` environment variable is set correctly + +### "Provider not found" in Terraform +- Verify the source matches: `terraform.lambdalabs.cloud/lambdacloud/okta` +- Check that all platform binaries were uploaded successfully +- Ensure your Terraform CLI is configured to use the private registry + +### API 404 errors +- GPG keys endpoint is at `/api/registry/private/v2/gpg-keys` (not `/api/v2/organizations/.../gpg-keys`) +- Verify organization name is `lambdacloud` (not `lambdalabs`) + +### Build resource usage +- Use `--parallelism=1` or `--parallelism=2` to limit concurrent builds +- This prevents system resource exhaustion on smaller machines + +## Reference + +- [HashiCorp: Publishing Providers](https://developer.hashicorp.com/terraform/registry/providers/publishing) +- [TFE API: Private Registry](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/providers) +- [GoReleaser Configuration](https://goreleaser.com/customization/) diff --git a/okta/api/idaas.go b/okta/api/idaas.go index 9d587e00e..320e54f7d 100644 --- a/okta/api/idaas.go +++ b/okta/api/idaas.go @@ -103,14 +103,49 @@ func NewOktaIDaaSAPIClient(c *OktaAPIConfig) (client OktaIDaaSClient, err error) return } - httpClient := v3Client.GetConfig().HTTPClient - v2Client, err := oktaV2SDKClient(httpClient, c) + // For V2 SDK with DPoP (PrivateKey auth), use a non-retryable HTTP client. + // The retryablehttp library reuses the same request on retries, which causes + // "DPoP proof JWT has already been used" errors because the same JWT is sent twice. + // The V2 SDK has its own retry logic in doWithRetries() that properly regenerates + // DPoP JWTs on each retry attempt. + var v2HttpClient *http.Client + if c.PrivateKey != "" { + // Use a simple HTTP client without retryablehttp wrapper for DPoP auth + v2HttpClient = cleanhttp.DefaultClient() + logLevel := strings.ToLower(os.Getenv("TF_LOG")) + debugHttpRequests := (logLevel == "1" || logLevel == "debug" || logLevel == "trace") + if debugHttpRequests { + //lint:ignore SA1019 used in developer mode only + v2HttpClient.Transport = logging.NewTransport("Okta", v2HttpClient.Transport) + } else { + v2HttpClient.Transport = logging.NewSubsystemLoggingHTTPTransport("Okta", v2HttpClient.Transport) + } + // Add transport governor if configured + if c.MaxAPICapacity > 0 && c.MaxAPICapacity < 100 { + apiMutex, err := apimutex.NewAPIMutex(c.MaxAPICapacity) + if err != nil { + return nil, err + } + v2HttpClient.Transport = transport.NewGovernedTransport(v2HttpClient.Transport, apiMutex, c.Logger) + } + c.Logger.Info("v2 running with non-retryable http client for DPoP authentication (v2 SDK has its own retry logic)") + } else { + // Use the same HTTP client as V3 for non-DPoP auth + v2HttpClient = v3Client.GetConfig().HTTPClient + } + + v2Client, err := oktaV2SDKClient(v2HttpClient, c) if err != nil { return } re := v2Client.CloneRequestExecutor() - re.SetHTTPTransport(v3Client.GetConfig().HTTPClient.Transport) + // For DPoP auth, use the non-retryable transport; otherwise use V3's transport + if c.PrivateKey != "" { + re.SetHTTPTransport(v2HttpClient.Transport) + } else { + re.SetHTTPTransport(v3Client.GetConfig().HTTPClient.Transport) + } supClient := &sdk.APISupplement{ RequestExecutor: re, } diff --git a/scripts/upload_to_tfe.py b/scripts/upload_to_tfe.py new file mode 100755 index 000000000..fdfac535d --- /dev/null +++ b/scripts/upload_to_tfe.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Upload Terraform provider to TFE private registry. + +Usage: + export TFE_TOKEN="your-token" + export GPG_KEY_ID="your-gpg-key-id" + python3 scripts/upload_to_tfe.py --version 1.0.0 + +Environment Variables: + TFE_TOKEN - TFE API token (required) + GPG_KEY_ID - GPG key fingerprint used for signing (required) + TFE_HOST - TFE hostname (default: terraform.lambdalabs.cloud) + TFE_ORG - TFE organization (default: lambdacloud) + PROVIDER_NAME - Provider name (default: okta) +""" +import argparse +import os +import sys + +import requests + + +def main(): + parser = argparse.ArgumentParser(description='Upload Terraform provider to TFE') + parser.add_argument('--version', required=True, help='Provider version (without v prefix)') + parser.add_argument('--dist-dir', default='dist', help='Directory containing build artifacts') + args = parser.parse_args() + + # Configuration from environment + token = os.environ.get('TFE_TOKEN') + if not token: + print("ERROR: TFE_TOKEN environment variable is required") + sys.exit(1) + + gpg_key_id = os.environ.get('GPG_KEY_ID') + if not gpg_key_id: + print("ERROR: GPG_KEY_ID environment variable is required") + sys.exit(1) + + # TFE expects the short key-id (last 16 characters of fingerprint) + if len(gpg_key_id) > 16: + gpg_key_id = gpg_key_id[-16:] + print(f" Using short key-id: {gpg_key_id}") + + host = os.environ.get('TFE_HOST', 'terraform.lambdalabs.cloud') + org = os.environ.get('TFE_ORG', 'lambdacloud') + provider = os.environ.get('PROVIDER_NAME', 'okta') + version = args.version + dist_dir = args.dist_dir + + api = f"https://{host}/api/v2" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/vnd.api+json" + } + + print(f"==> Uploading {provider} v{version} to {host}/{org}") + + # Step 1: Ensure provider exists + print("==> Step 1: Creating provider (if not exists)...") + resp = requests.post( + f"{api}/organizations/{org}/registry-providers", + headers=headers, + json={ + "data": { + "type": "registry-providers", + "attributes": { + "name": provider, + "namespace": org, + "registry-name": "private" + } + } + } + ) + if resp.status_code == 201: + print(" Provider created") + elif resp.status_code == 422: + print(" Provider already exists") + else: + print(f" Response: {resp.status_code} - {resp.text[:200]}") + + # Step 2: Upload GPG key (idempotent - TFE handles duplicates) + print("==> Step 2: Uploading GPG key...") + gpg_key_path = os.path.join(dist_dir, 'gpg-public-key.asc') + if not os.path.exists(gpg_key_path): + print(f" ERROR: GPG public key not found at {gpg_key_path}") + sys.exit(1) + + with open(gpg_key_path) as f: + gpg_key = f.read() + + # Note: GPG keys use a different API endpoint + resp = requests.post( + f"https://{host}/api/registry/private/v2/gpg-keys", + headers=headers, + json={ + "data": { + "type": "gpg-keys", + "attributes": { + "namespace": org, + "ascii-armor": gpg_key + } + } + } + ) + if resp.status_code in [200, 201]: + print(f" GPG key uploaded: {gpg_key_id}") + elif 'already exists' in resp.text.lower() or resp.status_code == 422: + print(f" GPG key already exists") + else: + print(f" WARNING: GPG key upload response: {resp.status_code} - {resp.text[:200]}") + + # Step 3: Create version + print(f"==> Step 3: Creating version {version}...") + resp = requests.post( + f"{api}/organizations/{org}/registry-providers/private/{org}/{provider}/versions", + headers=headers, + json={ + "data": { + "type": "registry-provider-versions", + "attributes": { + "version": version, + "key-id": gpg_key_id, + "protocols": ["5.0"] + } + } + } + ) + + if resp.status_code != 201: + print(f" ERROR: {resp.status_code} - {resp.text}") + sys.exit(1) + + version_data = resp.json() + shasums_upload = version_data['data']['links']['shasums-upload'] + shasums_sig_upload = version_data['data']['links']['shasums-sig-upload'] + print(f" Version created") + + # Step 4: Upload checksums + print("==> Step 4: Uploading checksums...") + shasums_file = os.path.join(dist_dir, f"terraform-provider-{provider}_{version}_SHA256SUMS") + shasums_sig_file = os.path.join(dist_dir, f"terraform-provider-{provider}_{version}_SHA256SUMS.sig") + + if not os.path.exists(shasums_file): + print(f" ERROR: SHA256SUMS not found at {shasums_file}") + sys.exit(1) + + with open(shasums_file, 'rb') as f: + resp = requests.put(shasums_upload, data=f) + if resp.status_code != 200: + print(f" ERROR uploading SHA256SUMS: {resp.status_code}") + sys.exit(1) + print(" SHA256SUMS uploaded") + + with open(shasums_sig_file, 'rb') as f: + resp = requests.put(shasums_sig_upload, data=f) + if resp.status_code != 200: + print(f" ERROR uploading SHA256SUMS.sig: {resp.status_code}") + sys.exit(1) + print(" SHA256SUMS.sig uploaded") + + # Step 5: Upload platforms + print("==> Step 5: Uploading platform binaries...") + with open(shasums_file) as f: + lines = f.readlines() + + success_count = 0 + error_count = 0 + + for line in lines: + parts = line.strip().split() + if len(parts) != 2: + continue + + sha_hash, filename = parts + name_parts = filename.replace('.zip', '').split('_') + if len(name_parts) < 4: + print(f" Skipping {filename} - cannot parse OS/arch") + continue + + os_name = name_parts[-2] + arch = name_parts[-1] + + print(f" {os_name}_{arch}...", end=' ', flush=True) + + resp = requests.post( + f"{api}/organizations/{org}/registry-providers/private/{org}/{provider}/versions/{version}/platforms", + headers=headers, + json={ + "data": { + "type": "registry-provider-version-platforms", + "attributes": { + "os": os_name, + "arch": arch, + "shasum": sha_hash, + "filename": filename + } + } + } + ) + + if resp.status_code != 201: + print(f"ERROR: {resp.text[:100]}") + error_count += 1 + continue + + upload_url = resp.json()['data']['links']['provider-binary-upload'] + binary_path = os.path.join(dist_dir, filename) + + if not os.path.exists(binary_path): + print(f"ERROR: Binary not found at {binary_path}") + error_count += 1 + continue + + with open(binary_path, 'rb') as f: + resp = requests.put(upload_url, data=f) + if resp.status_code != 200: + print(f"ERROR uploading: {resp.status_code}") + error_count += 1 + continue + + print("done") + success_count += 1 + + print() + print(f"==> Upload complete! ({success_count} succeeded, {error_count} failed)") + print(f"==> https://{host}/app/{org}/registry/providers/private/{org}/{provider}") + + if error_count > 0: + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/sdk/v2_requestExecutor.go b/sdk/v2_requestExecutor.go index ee016d48a..dbd065698 100644 --- a/sdk/v2_requestExecutor.go +++ b/sdk/v2_requestExecutor.go @@ -17,11 +17,13 @@ import ( "errors" "fmt" "io" + "log" "net/http" urlpkg "net/url" "reflect" "strconv" "strings" + "sync" "time" "unicode/utf8" @@ -35,12 +37,72 @@ import ( goCache "github.com/patrickmn/go-cache" ) +// tokenAcquisitionMutex protects concurrent token acquisition to prevent race conditions +// when multiple goroutines try to get a new access token simultaneously +var tokenAcquisitionMutex sync.Mutex + const ( AccessTokenCacheKey = "OKTA_ACCESS_TOKEN" DpopAccessTokenNonce = "DPOP_OKTA_ACCESS_TOKEN_NONCE" DpopAccessTokenPrivateKey = "DPOP_OKTA_ACCESS_TOKEN_PRIVATE_KEY" ) +// applyTokenToRequest applies cached token and DPoP headers to the request. +// Returns true if a cached token was found and applied, false otherwise. +func applyTokenToRequest(req *http.Request, tokenCache *goCache.Cache, method, URL string) (bool, error) { + accessToken, hasToken := tokenCache.Get(AccessTokenCacheKey) + if !hasToken || accessToken == "" { + return false, nil + } + + accessTokenWithTokenType := accessToken.(string) + req.Header.Add("Authorization", accessTokenWithTokenType) + + nonce, hasNonce := tokenCache.Get(DpopAccessTokenNonce) + if hasNonce && nonce != "" { + privateKey, ok := tokenCache.Get(DpopAccessTokenPrivateKey) + if ok && privateKey != nil { + res := strings.Split(accessTokenWithTokenType, " ") + if len(res) != 2 { + return false, errors.New("unidentified access token") + } + dpopJWT, err := generateDpopJWT(privateKey.(*rsa.PrivateKey), method, URL, nonce.(string), res[1]) + if err != nil { + return false, err + } + req.Header.Set("Dpop", dpopJWT) + req.Header.Set("x-okta-user-agent-extended", "isDPoP:true") + } else { + return false, errors.New("using Dpop but signing key not found") + } + } + return true, nil +} + +// cacheNewToken stores the access token and DPoP-related values in the cache. +func cacheNewToken(tokenCache *goCache.Cache, accessToken *RequestAccessToken, nonce string, privateKey *rsa.PrivateKey) { + // Trim a couple of seconds off calculated expiry so cache expiry + // occurs before Okta server side expiry. + expiration := accessToken.ExpiresIn - 2 + tokenCache.Set(AccessTokenCacheKey, fmt.Sprintf("%v %v", accessToken.TokenType, accessToken.AccessToken), time.Second*time.Duration(expiration)) + tokenCache.Set(DpopAccessTokenNonce, nonce, time.Second*time.Duration(expiration)) + tokenCache.Set(DpopAccessTokenPrivateKey, privateKey, time.Second*time.Duration(expiration)) +} + +// setDpopHeaders sets the DPoP headers on the request for DPoP token types. +func setDpopHeaders(req *http.Request, accessToken *RequestAccessToken, privateKey *rsa.PrivateKey, method, URL, nonce string) error { + req.Header.Set("Authorization", fmt.Sprintf("%v %v", accessToken.TokenType, accessToken.AccessToken)) + if accessToken.TokenType == "DPoP" { + dpopJWT, err := generateDpopJWT(privateKey, method, URL, nonce, accessToken.AccessToken) + if err != nil { + return err + } + req.Header.Set("Dpop", dpopJWT) + req.Header.Set("x-okta-user-agent-extended", "isDPoP:true") + } + return nil +} + type RequestExecutor struct { httpClient *http.Client config *config @@ -146,68 +208,58 @@ func NewPrivateKeyAuth(config PrivateKeyAuthConfig) *PrivateKeyAuth { } func (a *PrivateKeyAuth) Authorize(method, URL string) error { - accessToken, hasToken := a.tokenCache.Get(AccessTokenCacheKey) - if hasToken && accessToken != "" { - accessTokenWithTokenType := accessToken.(string) - a.req.Header.Add("Authorization", accessTokenWithTokenType) - nonce, hasNonce := a.tokenCache.Get(DpopAccessTokenNonce) - if hasNonce && nonce != "" { - privateKey, ok := a.tokenCache.Get(DpopAccessTokenPrivateKey) - if ok && privateKey != nil { - res := strings.Split(accessTokenWithTokenType, " ") - if len(res) != 2 { - return errors.New("unidentified access token") - } - dpopJWT, err := generateDpopJWT(privateKey.(*rsa.PrivateKey), method, URL, nonce.(string), res[1]) - if err != nil { - return err - } - a.req.Header.Set("Dpop", dpopJWT) - a.req.Header.Set("x-okta-user-agent-extended", "isDPoP:true") - } else { - return errors.New("using Dpop but signing key not found") - } - } - } else { - if a.privateKeySigner == nil { - var err error - a.privateKeySigner, err = CreateKeySigner(a.privateKey, a.privateKeyId) - if err != nil { - return err - } - } + // Try to use cached token first + applied, err := applyTokenToRequest(a.req, a.tokenCache, method, URL) + if err != nil { + return err + } + if applied { + return nil + } - clientAssertion, err := CreateClientAssertion(a.orgURL, a.clientId, a.privateKeySigner) - if err != nil { - return err - } + // No cached token - need to acquire one with mutex protection + tokenAcquisitionMutex.Lock() - accessToken, nonce, privateKey, err := getAccessTokenForPrivateKey(a.httpClient, a.orgURL, clientAssertion, a.scopes, a.maxRetries, a.maxBackoff, a.clientId, a.privateKeySigner) + // Double-check if another goroutine already acquired the token while we waited + applied, err = applyTokenToRequest(a.req, a.tokenCache, method, URL) + if err != nil { + tokenAcquisitionMutex.Unlock() + return err + } + if applied { + tokenAcquisitionMutex.Unlock() + return nil + } + + // We need to acquire a new token + defer tokenAcquisitionMutex.Unlock() + + if a.privateKeySigner == nil { + a.privateKeySigner, err = CreateKeySigner(a.privateKey, a.privateKeyId) if err != nil { return err } + } - if accessToken == nil { - return errors.New("empty access token") - } + clientAssertion, err := CreateClientAssertion(a.orgURL, a.clientId, a.privateKeySigner) + if err != nil { + return err + } - a.req.Header.Set("Authorization", fmt.Sprintf("%v %v", accessToken.TokenType, accessToken.AccessToken)) - if accessToken.TokenType == "DPoP" { - dpopJWT, err := generateDpopJWT(privateKey, method, URL, nonce, accessToken.AccessToken) - if err != nil { - return err - } - a.req.Header.Set("Dpop", dpopJWT) - a.req.Header.Set("x-okta-user-agent-extended", "isDPoP:true") - } + newAccessToken, nonce, privateKey, err := getAccessTokenForPrivateKey(a.httpClient, a.orgURL, clientAssertion, a.scopes, a.maxRetries, a.maxBackoff, a.clientId, a.privateKeySigner) + if err != nil { + return err + } - // Trim a couple of seconds off calculated expiry so cache expiry - // occures before Okta server side expiry. - expiration := accessToken.ExpiresIn - 2 - a.tokenCache.Set(AccessTokenCacheKey, fmt.Sprintf("%v %v", accessToken.TokenType, accessToken.AccessToken), time.Second*time.Duration(expiration)) - a.tokenCache.Set(DpopAccessTokenNonce, nonce, time.Second*time.Duration(expiration)) - a.tokenCache.Set(DpopAccessTokenPrivateKey, privateKey, time.Second*time.Duration(expiration)) + if newAccessToken == nil { + return errors.New("empty access token") } + + if err := setDpopHeaders(a.req, newAccessToken, privateKey, method, URL, nonce); err != nil { + return err + } + + cacheNewToken(a.tokenCache, newAccessToken, nonce, privateKey) return nil } @@ -247,55 +299,46 @@ func NewJWTAuth(config JWTAuthConfig) *JWTAuth { } func (a *JWTAuth) Authorize(method, URL string) error { - accessToken, hasToken := a.tokenCache.Get(AccessTokenCacheKey) - if hasToken && accessToken != "" { - accessTokenWithTokenType := accessToken.(string) - a.req.Header.Add("Authorization", accessTokenWithTokenType) - nonce, hasNonce := a.tokenCache.Get(DpopAccessTokenNonce) - if hasNonce && nonce != "" { - privateKey, ok := a.tokenCache.Get(DpopAccessTokenPrivateKey) - if ok && privateKey != nil { - res := strings.Split(accessTokenWithTokenType, " ") - if len(res) != 2 { - return errors.New("unidentified access token") - } - dpopJWT, err := generateDpopJWT(privateKey.(*rsa.PrivateKey), method, URL, nonce.(string), res[1]) - if err != nil { - return err - } - a.req.Header.Set("Dpop", dpopJWT) - a.req.Header.Set("x-okta-user-agent-extended", "isDPoP:true") - } else { - return errors.New("using Dpop but signing key not found") - } - } - } else { - accessToken, nonce, privateKey, err := getAccessTokenForPrivateKey(a.httpClient, a.orgURL, a.clientAssertion, a.scopes, a.maxRetries, a.maxBackoff, "", nil) - if err != nil { - return err - } + // Try to use cached token first + applied, err := applyTokenToRequest(a.req, a.tokenCache, method, URL) + if err != nil { + return err + } + if applied { + return nil + } - if accessToken == nil { - return errors.New("empty access token") - } + // No cached token - need to acquire one with mutex protection + tokenAcquisitionMutex.Lock() - a.req.Header.Set("Authorization", fmt.Sprintf("%v %v", accessToken.TokenType, accessToken.AccessToken)) - if accessToken.TokenType == "DPoP" { - dpopJWT, err := generateDpopJWT(privateKey, method, URL, nonce, accessToken.AccessToken) - if err != nil { - return err - } - a.req.Header.Set("Dpop", dpopJWT) - a.req.Header.Set("x-okta-user-agent-extended", "isDPoP:true") - } + // Double-check if another goroutine already acquired the token while we waited + applied, err = applyTokenToRequest(a.req, a.tokenCache, method, URL) + if err != nil { + tokenAcquisitionMutex.Unlock() + return err + } + if applied { + tokenAcquisitionMutex.Unlock() + return nil + } + + // We need to acquire a new token + defer tokenAcquisitionMutex.Unlock() + + newAccessToken, nonce, privateKey, err := getAccessTokenForPrivateKey(a.httpClient, a.orgURL, a.clientAssertion, a.scopes, a.maxRetries, a.maxBackoff, "", nil) + if err != nil { + return err + } + + if newAccessToken == nil { + return errors.New("empty access token") + } - // Trim a couple of seconds off calculated expiry so cache expiry - // occures before Okta server side expiry. - expiration := accessToken.ExpiresIn - 2 - a.tokenCache.Set(AccessTokenCacheKey, fmt.Sprintf("%v %v", accessToken.TokenType, accessToken.AccessToken), time.Second*time.Duration(expiration)) - a.tokenCache.Set(DpopAccessTokenNonce, nonce, time.Second*time.Duration(expiration)) - a.tokenCache.Set(DpopAccessTokenPrivateKey, privateKey, time.Second*time.Duration(expiration)) + if err := setDpopHeaders(a.req, newAccessToken, privateKey, method, URL, nonce); err != nil { + return err } + + cacheNewToken(a.tokenCache, newAccessToken, nonce, privateKey) return nil } @@ -498,10 +541,21 @@ func generateDpopJWT(privateKey *rsa.PrivateKey, httpMethod, URL, nonce, accessT if err != nil { return "", err } + + // Per RFC 9449, the htu claim must not include query or fragment parts + // Strip query parameters from URL for the htu claim + htuURL := URL + if parsedURL, err := urlpkg.Parse(URL); err == nil { + parsedURL.RawQuery = "" + parsedURL.Fragment = "" + htuURL = parsedURL.String() + } + log.Printf("[DEBUG] generateDpopJWT: URL=%s, htuURL=%s, method=%s", URL, htuURL, httpMethod) + dpopClaims := DpopClaims{ ID: uuid.New().String(), HTTPMethod: httpMethod, - HTTPURI: URL, + HTTPURI: htuURL, IssuedAt: jwt.NewNumericDate(time.Now()), Nonce: nonce, } @@ -755,16 +809,28 @@ func (re *RequestExecutor) doWithRetries(ctx context.Context, req *http.Request) ctx: ctx, maxRetries: re.config.Okta.Client.RateLimit.MaxRetries, } + firstAttempt := true + nonceRetry := false // Track if this is a nonce-only retry (don't need full re-auth) operation := func() error { + // Increment retry count on any retry, not just 429s + // This ensures DPoP JWT is regenerated for EOF/network retries too + if !firstAttempt { + bOff.retryCount++ + } + firstAttempt = false + + log.Printf("[DEBUG] doWithRetries: retryCount=%d, nonceRetry=%v, URL=%s", bOff.retryCount, nonceRetry, req.URL.String()) + // Always rewind the request body when non-nil. if bodyReader != nil { req.Body = bodyReader() } - // Re-authorize the request to create a new DPoP JWT and access token - if bOff.retryCount > 0 && re.config.Okta.Client.AuthorizationMode == "PrivateKey" || re.config.Okta.Client.AuthorizationMode == "JWT" { - // Clear the token cache to force fresh authorization - // This will get a new access token and potentially a new nonce + // Re-authorize the request to create a new DPoP JWT and access token on retries + // Skip full re-auth for nonce-only retries since we already updated the DPoP header + // This handles 429 rate limits, EOF errors, and other retriable failures + if bOff.retryCount > 0 && !nonceRetry && (re.config.Okta.Client.AuthorizationMode == "PrivateKey" || re.config.Okta.Client.AuthorizationMode == "JWT") { + // Clear the token cache to force fresh authorization on retry re.tokenCache.Delete(AccessTokenCacheKey) re.tokenCache.Delete(DpopAccessTokenNonce) re.tokenCache.Delete(DpopAccessTokenPrivateKey) @@ -787,16 +853,12 @@ func (re *RequestExecutor) doWithRetries(ctx context.Context, req *http.Request) req.Header.Set("x-okta-user-agent-extended", userAgentExt) } - if bodyReader != nil { - req.Body = bodyReader() - } - } else { - // Reuse the existing request headers and body - req.Header = req.Header.Clone() if bodyReader != nil { req.Body = bodyReader() } } + // Reset nonce retry flag after we've used it - next iteration should do full re-auth if needed + nonceRetry = false resp, err = re.httpClient.Do(req.WithContext(ctx)) if errors.Is(err, io.EOF) { // retry on EOF errors, which might be caused by network connectivity issues @@ -805,6 +867,46 @@ func (re *RequestExecutor) doWithRetries(ctx context.Context, req *http.Request) // this is error is considered to be permanent and should not be retried return backoff.Permanent(err) } + + if resp.StatusCode == http.StatusBadRequest { + bodyBytes, _ := io.ReadAll(resp.Body) + resp.Body.Close() + log.Printf("[DEBUG] Got 400 error: URL=%s, body=%s", req.URL.String(), string(bodyBytes)) + resp.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + + // Handle DPoP nonce requirement from Okta + // When the server requires a nonce, it returns use_dpop_nonce error with Dpop-Nonce header + if strings.Contains(string(bodyBytes), "use_dpop_nonce") { + newNonce := resp.Header.Get("Dpop-Nonce") + if newNonce != "" && (re.config.Okta.Client.AuthorizationMode == "PrivateKey" || re.config.Okta.Client.AuthorizationMode == "JWT") { + log.Printf("[DEBUG] Received use_dpop_nonce error, retrying with nonce: %s", newNonce) + // Get the cached private key to regenerate DPoP JWT with new nonce + privateKey, ok := re.tokenCache.Get(DpopAccessTokenPrivateKey) + if ok && privateKey != nil { + // Get the access token for the ath claim + accessTokenWithType, _ := re.tokenCache.Get(AccessTokenCacheKey) + var accessToken string + if accessTokenWithType != nil { + parts := strings.Split(accessTokenWithType.(string), " ") + if len(parts) == 2 { + accessToken = parts[1] + } + } + // Regenerate DPoP JWT with the server-provided nonce + dpopJWT, err := generateDpopJWT(privateKey.(*rsa.PrivateKey), req.Method, req.URL.String(), newNonce, accessToken) + if err == nil { + req.Header.Set("Dpop", dpopJWT) + // Update cached nonce for future requests + re.tokenCache.Set(DpopAccessTokenNonce, newNonce, goCache.DefaultExpiration) + // Mark as nonce-only retry to skip full re-auth + nonceRetry = true + // Signal retry + return errors.New("dpop nonce required, retrying") + } + } + } + } + } if !tooManyRequests(resp) { return nil } @@ -819,9 +921,9 @@ func (re *RequestExecutor) doWithRetries(ctx context.Context, req *http.Request) backoffDuration = re.config.Okta.Client.RateLimit.MaxBackoff } bOff.backoffDuration = time.Second * time.Duration(backoffDuration) - bOff.retryCount++ + // Note: retryCount is now incremented at the start of the operation for all retry types req.Header.Add("X-Okta-Retry-For", resp.Header.Get("X-Okta-Request-Id")) - req.Header.Add("X-Okta-Retry-Count", fmt.Sprint(bOff.retryCount)) + req.Header.Add("X-Okta-Retry-Count", fmt.Sprint(bOff.retryCount+1)) return errors.New("too many requests") } err = backoff.Retry(operation, bOff)