From 5476c5851ee57e6d4b7a4e80c79ca1f103c603d7 Mon Sep 17 00:00:00 2001 From: Sam Linville Date: Fri, 11 Jul 2025 17:20:46 -0400 Subject: [PATCH 1/2] add oidc workload identity federation flow to github action updates --- .github/workflows/tailscale.yml | 30 ++++++++++- README.md | 15 +++++- action.yml | 92 ++++++++++++++++++++++++++++++++- 3 files changed, 132 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tailscale.yml b/.github/workflows/tailscale.yml index aaa128e..57b376f 100644 --- a/.github/workflows/tailscale.yml +++ b/.github/workflows/tailscale.yml @@ -1,5 +1,8 @@ name: tailscale +permissions: + id-token: write # This is required for requesting the JWT + on: workflow_dispatch: push: @@ -15,12 +18,25 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macos-latest, windows-11-arm] cache: ['false', 'true'] + auth: ['oauth', 'oauth-legacy', 'oidc'] runs-on: ${{ matrix.os }} steps: - name: Check out code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Tailscale Action + - name: Tailscale Action (OAuth) + if: matrix.auth == 'oauth' + uses: ./ + with: + oauth-client-id: ${{ secrets.TS_OAUTH_GRANULAR_CLIENT_ID }} + oauth-secret: ${{ secrets.TS_OAUTH_GRANULAR_SECRET }} + tags: tag:ci + use-cache: ${{ matrix.cache }} + + # This job runs as a sanity check to ensure we have not broken the ability for OAuth clients using + # our legacy scopes to successfully connect to tailnets using this action. + - name: Tailscale Action (OAuth legacy) + if: matrix.auth == 'oauth-legacy' uses: ./ with: oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} @@ -28,6 +44,16 @@ jobs: tags: tag:ci use-cache: ${{ matrix.cache }} + - name: Tailscale Action (OIDC) + if: matrix.auth == 'oidc' + uses: ./ + with: + oidc-client-id: ${{ secrets.TS_OIDC_CLIENT_ID }} + oidc-aud-claim: ${{ secrets.TS_OIDC_AUD_CLAIM }} + tags: tag:ci + tailnet: ${{ secrets.TS_TAILNET_NAME }} + use-cache: ${{ matrix.cache }} + - name: check for tailscale connection shell: bash run: @@ -41,4 +67,4 @@ jobs: echo "::error::Unexpected extra files: $extra_files" exit 1 fi - + \ No newline at end of file diff --git a/README.md b/README.md index 57d306f..8542dc7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,19 @@ be automatically removed by the coordination server a short time after they finish their run. The nodes are also [marked Preapproved](https://tailscale.com/kb/1085/auth-keys/) on tailnets which use [Device Approval](https://tailscale.com/kb/1099/device-approval/) +## Authenticate with GitHub's OIDC provider (alpha) + +The Tailscale GitHub action can use an OIDC token provided by GitHub to authenticate the workflow to your tailnet. This functionality is currently available as a private alpha. To join the alpha program, contact your account rep or email `sam@tailscale.com`. + +```yaml + - name: Tailscale + uses: tailscale/github-action@v3 + with: + oidc-client-id: ${{ secrets.TS_OIDC_CLIENT_ID }} + oidc-aud-claim: ${{ secrets.TS_OIDC_AUD_CLAIM }} + tags: tag:ci +``` + ## Tailnet Lock If you are using this Action in a [Tailnet @@ -92,4 +105,4 @@ You can opt in to caching Tailscale binaries by passing `'true'` to the `use-cac oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} use-cache: 'true' -``` +``` \ No newline at end of file diff --git a/action.yml b/action.yml index 26bcf45..e59aa42 100644 --- a/action.yml +++ b/action.yml @@ -17,6 +17,15 @@ inputs: oauth-secret: description: 'Your Tailscale OAuth Client Secret.' required: false + oidc-client-id: + description: 'Your Tailscale OIDC Client ID.' + required: false + oidc-aud-claim: + description: 'Your Tailscale OIDC audience claim' + required: false + tailnet: + description: 'Your Tailscale tailnet name (e.g., example.com)' + required: false tags: description: 'Comma separated list of Tags to be applied to nodes. The OAuth client must have permission to apply these tags.' required: false @@ -66,12 +75,82 @@ runs: echo "::error title=⛔ error hint::Support Linux, Windows, and macOS Only" exit 1 - name: Check Auth Info Empty - if: ${{ inputs.authkey == '' && (inputs['oauth-secret'] == '' || inputs.tags == '') }} + if: ${{ inputs.authkey == '' && (inputs['oauth-secret'] == '' || inputs.tags == '') && (inputs['oidc-client-id'] == '' || inputs['oidc-aud-claim'] == '') }} shell: bash run: | - echo "::error title=⛔ error hint::OAuth identity empty, Maybe you need to populate it in the Secrets for your workflow, see more in https://docs.github.com/en/actions/security-guides/encrypted-secrets and https://tailscale.com/s/oauth-clients" + echo "::error title=⛔ error hint::OAuth and OIDC identity are empty, Maybe you need to populate it in the Secrets for your workflow, see more in https://docs.github.com/en/actions/security-guides/encrypted-secrets and https://tailscale.com/s/oauth-clients" exit 1 + - name: get OIDC token from GitHub Actions + if: ${{ inputs['oidc-client-id'] != '' && inputs['oidc-aud-claim'] != '' && inputs['oauth-secret'] == '' }} + shell: bash + run: | + JWT=$(curl -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${{ inputs['oidc-aud-claim'] }}" | jq -r '.value') + if [ -z "$JWT" ]; then + echo "::error title=⛔ error hint::Failed to get JWT from GitHub Actions OIDC provider" + exit 1 + fi + echo "::add-mask::$JWT" # Mask the JWT in the logs + echo "JWT=$JWT" >> $GITHUB_ENV + + - name: Exchange JWT OIDC token and get authkey + if: ${{ inputs['oidc-client-id'] != '' && inputs['oidc-aud-claim'] != '' }} + shell: bash + run: | + echo "Exchanging JWT OIDC token for short-lived API token..." + RESPONSE=$(curl -X POST https://api.tailscale.com/api/v2/oauth/token-exchange \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=${{ inputs['oidc-client-id'] }}" \ + -d "jwt=$JWT") + export ACCESS_TOKEN=$(echo $RESPONSE | jq -r '.access_token') + echo "::add-mask::$ACCESS_TOKEN" # Mask the access token in the logs + if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then + echo "::error title=⛔ error hint::Failed to get ACCESS_TOKEN from OIDC token exchange, response: $RESPONSE" + exit 1 + fi + + echo "Retrieving authkey from Tailscale API" + + # Convert comma-separated tags to JSON array format + TAGS_JSON=$(echo "${{ inputs.tags }}" | sed 's/,/","/g' | sed 's/^/["/' | sed 's/$/"]/') + + # Make the API call and capture both the response and curl info + CURL_OUTPUT=$(curl -s -w "\n%{http_code}" -X POST https://api.tailscale.com/api/v2/tailnet/${{ inputs.tailnet }}/keys \ + --header 'Content-Type: application/json' \ + --header "Authorization: Bearer ${ACCESS_TOKEN}" \ + --data "{ + \"keyType\": \"auth\", + \"capabilities\": { + \"devices\": { + \"create\": { + \"reusable\": false, + \"ephemeral\": true, + \"preauthorized\": true, + \"tags\": $TAGS_JSON + } + } + }, + \"expirySeconds\": 3600 + }") + + # Extract HTTP status code (last line) and JSON response (everything else) + HTTP_STATUS=$(echo "$CURL_OUTPUT" | tail -n1) + JSON_RESPONSE=$(echo "$CURL_OUTPUT" | sed '$d') + echo "::add-mask::$JSON_RESPONSE" # Mask the JSON response in the logs + + if [ "$HTTP_STATUS" != "200" ]; then + echo "::error title=⛔ error hint::Tailscale API returned HTTP $HTTP_STATUS" + exit 1 + fi + + export AUTHKEY=$(echo "$JSON_RESPONSE" | jq -r '.key') + echo "::add-mask::$AUTHKEY" # Mask the auth key in the logs + if [ -z "$AUTHKEY" ] || [ "$AUTHKEY" = "null" ]; then + echo "::error title=⛔ error hint::Failed to get AUTHKEY from Tailscale API" + exit 1 + fi + echo "AUTHKEY=$AUTHKEY" >> $GITHUB_ENV + - name: Set Resolved Version shell: bash run: | @@ -310,6 +389,15 @@ runs: TAILSCALE_AUTHKEY="${{ inputs['oauth-secret'] }}?preauthorized=true&ephemeral=true" TAGS_ARG="--advertise-tags=${{ inputs.tags }}" fi + + if [ -n "${{ inputs['oidc-aud-claim'] }}" ] && [ -n "${{ inputs['oidc-client-id'] }}" ]; then + if [ -z "$AUTHKEY" ]; then + echo "::error title=⛔ error hint::Failed to get AUTHKEY from OIDC token exchange" + exit 1 + fi + TAILSCALE_AUTHKEY="$AUTHKEY" + TAGS_ARG="--advertise-tags=${{ inputs.tags }}" + fi if [ "${{ runner.os }}" != "Windows" ]; then MAYBE_SUDO="sudo -E" fi From 8a5e5e971a3197cb4ed8a48a13363b3afb23a911 Mon Sep 17 00:00:00 2001 From: Sam Linville Date: Tue, 12 Aug 2025 09:16:12 -0400 Subject: [PATCH 2/2] Update action.yml Co-authored-by: Mario Minardi --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index e59aa42..9f4574b 100644 --- a/action.yml +++ b/action.yml @@ -75,7 +75,7 @@ runs: echo "::error title=⛔ error hint::Support Linux, Windows, and macOS Only" exit 1 - name: Check Auth Info Empty - if: ${{ inputs.authkey == '' && (inputs['oauth-secret'] == '' || inputs.tags == '') && (inputs['oidc-client-id'] == '' || inputs['oidc-aud-claim'] == '') }} + if: ${{ inputs.authkey == '' && (inputs.tags == '' || ((inputs['oauth-secret'] == '') && (inputs['oidc-client-id'] == '' || inputs['oidc-aud-claim'] == '')) }} shell: bash run: | echo "::error title=⛔ error hint::OAuth and OIDC identity are empty, Maybe you need to populate it in the Secrets for your workflow, see more in https://docs.github.com/en/actions/security-guides/encrypted-secrets and https://tailscale.com/s/oauth-clients"