action.yml,src: add support for workload identity federation

Add support for workload identity federation based authentication.

Updates https://github.com/tailscale/corp/issues/31264

Signed-off-by: Mario Minardi <mario@tailscale.com>
This commit is contained in:
Mario Minardi 2025-10-21 17:15:06 -06:00
parent c8de7faf7a
commit ef403f0d06
No known key found for this signature in database
7 changed files with 190 additions and 50 deletions

View file

@ -1,5 +1,8 @@
name: "Integration Tests"
permissions:
id-token: write # This is required for requesting the JWT for workload identity
on:
pull_request:
workflow_dispatch:
@ -11,7 +14,7 @@ jobs:
# Matrix test for all supported platforms and architectures
integration-tests:
name: ${{ matrix.os }} (${{ matrix.arch }}) tailscale-${{ matrix.version }}
name: ${{ matrix.os }} (${{ matrix.arch }}) (${{ matrix.credential-type }}) tailscale-${{ matrix.version }}
strategy:
fail-fast: false
matrix:
@ -22,17 +25,20 @@ jobs:
arch: amd64
version: latest
ping: 100.99.0.2,lax-pve.pineapplefish.ts.net,lax-pve
credential-type: oauth
# Try unstable too
- os: ubuntu-latest
runner-os: Linux
arch: amd64
version: unstable
credential-type: oauth
# Try a pinned version
- os: ubuntu-latest
runner-os: Linux
arch: amd64
credential-type: oauth
# leave version blank to fall back to default
# Linux tests (ARM64)
@ -40,6 +46,7 @@ jobs:
runner-os: Linux
arch: arm64
version: latest
credential-type: oauth
# Windows tests (AMD64)
- os: windows-latest
@ -47,17 +54,14 @@ jobs:
arch: amd64
version: latest
ping: 100.99.0.2,lax-pve.pineapplefish.ts.net,lax-pve
- os: windows-latest
runner-os: Windows
arch: amd64
version: unstable
credential-type: oauth
# Windows tests (ARM64)
- os: windows-11-arm
runner-os: Windows
arch: arm64
version: latest
credential-type: oauth
# macOS 13 (AMD64)
- os: macos-13
@ -65,6 +69,7 @@ jobs:
arch: amd64
version: latest
ping: 100.99.0.2,lax-pve.pineapplefish.ts.net,lax-pve
credential-type: oauth
# macOS 14 (ARM)
- os: macos-14
@ -72,6 +77,7 @@ jobs:
arch: arm64
version: latest
ping: 100.99.0.2,lax-pve.pineapplefish.ts.net,lax-pve
credential-type: oauth
# macOS latest (ARM)
- os: macos-latest
@ -79,6 +85,30 @@ jobs:
arch: arm64
version: latest
ping: 100.99.0.2,lax-pve.pineapplefish.ts.net,lax-pve
credential-type: oauth
# Try workload identity for each platform
- os: macos-latest
runner-os: macOS
arch: amd64
version: latest
ping: 100.99.0.2,lax-pve.pineapplefish.ts.net,lax-pve
credential-type: workload-identity
- os: windows-latest
runner-os: Windows
arch: amd64
ping: 100.99.0.2,lax-pve.pineapplefish.ts.net,lax-pve
credential-type: workload-identity
# leave version blank to fall back to default
# Try adding in an unstable
- os: ubuntu-latest
runner-os: Linux
arch: amd64
version: unstable
credential-type: workload-identity
runs-on: ${{ matrix.os }}
@ -103,8 +133,9 @@ jobs:
id: tailscale-oauth
uses: ./
with:
oauth-client-id: ${{ secrets.TS_AUTH_KEYS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_AUTH_KEYS_OAUTH_CLIENT_SECRET }}
oauth-client-id: ${{ matrix.credential-type == 'oauth' && secrets.TS_AUTH_KEYS_OAUTH_CLIENT_ID || secrets.TS_WORKLOAD_IDENTITY_CLIENT_ID }}
oauth-secret: ${{ matrix.credential-type == 'oauth' && secrets.TS_AUTH_KEYS_OAUTH_CLIENT_SECRET || '' }}
audience: ${{ matrix.credential-type == 'workload-identity' && secrets.TS_AUDIENCE || ''}}
tags: "tag:ci"
version: "${{ matrix.version }}"
use-cache: false

View file

@ -14,29 +14,58 @@ by adding a step to your workflow.
Subsequent steps in the Action can then access nodes in your Tailnet.
oauth-client-id and oauth-secret are an [OAuth client](https://tailscale.com/s/oauth-clients/)
oauth-client-id and oauth-secret are an [OAuth client][kb-oauth-clients]
for the tailnet to be accessed. We recommend storing these as
[GitHub Encrypted Secrets.](https://docs.github.com/en/actions/security-guides/encrypted-secrets)
OAuth clients used for this purpose must have the
[`auth_keys` scope.](https://tailscale.com/kb/1215/oauth-clients#scopes)
[`auth_keys` scope.][kb-trust-credentials-scopes]
tags is a comma-separated list of one or more [ACL Tags](https://tailscale.com/kb/1068/acl-tags/)
tags is a comma-separated list of one or more [Tags][kb-tags]
for the node. At least one tag is required: an OAuth client is not associated
with any of the Users on the tailnet, it has to Tag its nodes.
Nodes created by this Action are [marked as Ephemeral](https://tailscale.com/s/ephemeral-nodes) to
Nodes created by this Action are [marked as Ephemeral][kb-ephemeral-nodes] to
and log out immediately after finishing their CI run, at which point they are automatically removed
by the coordination server. 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/)
by the coordination server. The nodes are also [marked Preapproved][kb-auth-keys]
on tailnets which use [Device Approval][kb-device-approval]
### Workload identity federation
[Workload identity federation][kb-workload-identity-federation] can also be used for authenticating nodes with your tailnet:
```yaml
- name: Tailscale
uses: tailscale/github-action@v4
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
audience: ${{ secrets.TS_AUDIENCE }}
tags: tag:ci
```
Workload identity federation requires the `id-token: write` [permission setting](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-cloud-providers#adding-permissions-settings) for the workflow:
```yaml
permissions:
id-token: write # This is required for the tailscale action to request a JWT from GitHub
```
OIDC federated identity credentials used for this purpose must have the [`auth_keys` scope.][kb-trust-credentials-scopes]
tags is a comma-separated list of one or more [Tags][kb-tags]
for the node. At least one tag is required: a federated identity is not associated
with any of the Users on the tailnet, it has to Tag its nodes.
> [!IMPORTANT]
> Tailscale version `1.90.1` or later is required for workload identity federation.
## Prerequisites
Before using the Tailscale GitHub Action, ensure you have the following:
1. A Tailscale account with <Role>Owner, Admin, or Network admin</Role> permissions.
1. A Tailscale account with Owner, Admin, or Network admin permissions.
1. A GitHub repository that you have admin access to (required to set up the GitHub Action).
1. At least one configured [tag][kb-tags].
1. An [OAuth client][kb-oauth-clients] ID and secret OR an [auth key][kb-auth-keys].
1. At least one configured [tag][kb-tags] if using OAuth or workload identity federation.
1. An [OAuth client][kb-oauth-clients] ID and secret, [federated identity][kb-workload-identity-federation] client ID and audience, OR an [auth key][kb-auth-keys].
1. A runner image version >= 2.237.1 (required to support running Node.js 24).
## Eventual consistency
@ -55,22 +84,21 @@ You can do this by adding a list of hosts to ping to the action configuration:
ping: 100.x.y.z,my-machine.my-tailnet.ts.net
```
or with the [tailscale ping](https://tailscale.com/kb/1080/cli#ping) command if you do not know the peers at the time of installing Tailscale in the workflow:
or with the [tailscale ping][kb-cli-ping] command if you do not know the peers at the time of installing Tailscale in the workflow:
```bash
tailscale ping my-target.my-tailnet.ts.net
```
The `ping` option will wait up to to 3 minutes for a connection (direct or relayed).
The `ping` option will wait up to 3 minutes for a connection (direct or relayed).
## Tailnet Lock
If you are using this Action in a [Tailnet
Lock](https://tailscale.com/kb/1226/tailnet-lock) enabled network, you need to:
If you are using this Action in a [Tailnet Lock][kb-tailnet-lock] enabled network, you need to:
- Authenticate using an ephemeral reusable [pre-signed auth key](https://tailscale.com/kb/1226/tailnet-lock#add-a-node-using-a-pre-signed-auth-key)
- Authenticate using an ephemeral reusable [pre-signed auth key][kb-tailnet-lock-pre-signed]
rather than an OAuth client.
- Specify a [state directory](https://tailscale.com/kb/1278/tailscaled#flags-to-tailscaled) for the
- Specify a [state directory][kb-tailscaled-flags] for the
client to store the Tailnet Key Authority data in.
```yaml
@ -139,4 +167,18 @@ the GitHub Action leaves tailscale binaries installed but stops the tailscale ba
### requested tags [tag:mytag] are invalid or not permitted
You may encounter this error when using an OAuth client. OAuth clients must have the [`auth_keys` scope](https://tailscale.com/kb/1215/oauth-clients#scopes) with one or more [tags](https://tailscale.com/kb/1068/acl-tags/), and the tags specified with `tags` must match all tags on the OAuth client.
You may encounter this error when using a trust credential (OAuth client or OIDC federated identity).
Trust credentials must have the writable [`auth_keys` scope][kb-trust-credentials-scopes] with one or more [tags][kb-tags],
and the tags specified with `tags` must match all tags on the trust credential or be tags owned by the tags on the trust credential.
[kb-auth-keys]: https://tailscale.com/kb/1085/auth-keys
[kb-cli-ping]: https://tailscale.com/kb/1080/cli#ping
[kb-device-approval]: https://tailscale.com/kb/1099/device-approval
[kb-ephemeral-nodes]: https://tailscale.com/kb/1111/ephemeral-nodes
[kb-oauth-clients]: https://tailscale.com/kb/1215/oauth-clients
[kb-tags]: https://tailscale.com/kb/1068/tags
[kb-tailnet-lock]: https://tailscale.com/kb/1226/tailnet-lock
[kb-tailnet-lock-pre-signed]: https://tailscale.com/kb/1226/tailnet-lock#add-a-node-using-a-pre-signed-auth-key
[kb-tailscaled-flags]: https://tailscale.com/kb/1278/tailscaled#flags-to-tailscaled
[kb-trust-credentials-scopes]: https://tailscale.com/kb/1623/trust-credentials#scopes
[kb-workload-identity-federation]: https://tailscale.com/kb/1581/workload-identity-federation

View file

@ -13,7 +13,10 @@ inputs:
required: false
deprecationMessage: 'An OAuth API client https://tailscale.com/s/oauth-clients is recommended instead of an authkey'
oauth-client-id:
description: 'Your Tailscale OAuth Client ID.'
description: 'Your Tailscale OAuth or OIDC Federated Identity clientID.'
required: false
audience:
description: 'Your Tailscale OIDC Federated Identity Audience'
required: false
oauth-secret:
description: 'Your Tailscale OAuth Client Secret.'
@ -24,7 +27,7 @@ inputs:
version:
description: 'Tailscale version to use. Specify `latest` to use the latest stable version, and `unstable` to use the latest development version.'
required: true
default: '1.88.3'
default: '1.90.4'
args:
description: 'Optional additional arguments to `tailscale up`.'
required: false
@ -66,4 +69,3 @@ runs:
using: 'node24'
main: 'dist/index.js'
post: 'dist/logout/index.js'

46
dist/index.js generated vendored
View file

@ -41124,6 +41124,7 @@ const crypto = __importStar(__nccwpck_require__(76982));
const fs = __importStar(__nccwpck_require__(79896));
const os = __importStar(__nccwpck_require__(70857));
const path = __importStar(__nccwpck_require__(16928));
const semver = __importStar(__nccwpck_require__(39318));
const promises_1 = __nccwpck_require__(16460);
const cmdTailscale = "tailscale";
const cmdTailscaleFullPath = "/usr/local/bin/tailscale";
@ -41265,11 +41266,12 @@ async function getInputs() {
}
}
const config = {
version: core.getInput("version") || "1.88.3",
version: core.getInput("version") || "1.90.4",
resolvedVersion: "",
arch: "",
authKey: authKey,
oauthClientId: core.getInput("oauth-client-id") || "",
audience: core.getInput("audience") || "",
oauthSecret: oauthSecret,
tags: core.getInput("tags") || "",
hostname: core.getInput("hostname") || "",
@ -41288,8 +41290,15 @@ async function getInputs() {
return config;
}
function validateAuth(config) {
if (!config.authKey && (!config.oauthSecret || !config.tags)) {
throw new Error("OAuth identity empty, please provide either an auth key or OAuth secret and tags.");
if (!config.authKey &&
(!config.oauthSecret || !config.tags) &&
(!config.audience || !config.oauthClientId || !config.tags)) {
throw new Error("Please provide either an auth key, OAuth secret and tags, or federated identity client ID and audience with tags.");
}
if (config.audience &&
semver.valid(config.version) &&
semver.gt("1.90.0", config.version)) {
throw new Error("Workload identity federation requires using tailscale version 1.90.0 or later.");
}
}
async function resolveVersion(version, runnerOS) {
@ -41637,12 +41646,29 @@ async function connectToTailscale(config, runnerOS) {
}
// Limit hostname to 63 characters (more will result in the error "not a valid DNS label")
hostname = hostname.substring(0, 63);
// Prepare auth and tags
let finalAuthKey = config.authKey;
const tagsArg = [];
if (config.oauthSecret) {
finalAuthKey = `${config.oauthSecret}?preauthorized=true&ephemeral=true`;
tagsArg.push(`--advertise-tags=${config.tags}`);
// Prepare auth and tags.
//
// Items higher in this list take precedence for auth:
// 1. Workload identity
// 2. OAuth client
// 3. Auth key
let authArgs;
let tagsArg = [];
authArgs = [`--authkey=${config.authKey}`];
if (config.audience || config.oauthSecret) {
tagsArg = [`--advertise-tags=${config.tags}`];
if (config.audience) {
const token = await core.getIDToken(config.audience);
authArgs = [
`--client-id=${config.oauthClientId}?preauthorized=true&ephemeral=true`,
`--id-token=${token}`,
];
}
else if (config.oauthSecret) {
authArgs = [
`--authkey=${config.oauthSecret}?preauthorized=true&ephemeral=true`,
];
}
}
// Platform-specific args
const platformArgs = [];
@ -41653,11 +41679,11 @@ async function connectToTailscale(config, runnerOS) {
const upArgs = [
"up",
...tagsArg,
`--authkey=${finalAuthKey}`,
`--hostname=${hostname}`,
"--accept-routes",
...platformArgs,
...config.args.split(" ").filter(Boolean),
...authArgs,
];
// Retry logic
for (let attempt = 1; attempt <= config.retry; attempt++) {

6
package-lock.json generated
View file

@ -13,7 +13,8 @@
"@actions/core": "^1.10.1",
"@actions/exec": "^1.1.1",
"@actions/github": "^6.0.1",
"@actions/tool-cache": "^2.0.1"
"@actions/tool-cache": "^2.0.1",
"semver": "^6.3.1"
},
"devDependencies": {
"@types/node": "^20.17.6",
@ -455,6 +456,7 @@
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz",
"integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^4.0.0",
"@octokit/graphql": "^7.1.0",
@ -1078,6 +1080,7 @@
"integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin-prettier.js"
},
@ -1183,6 +1186,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View file

@ -17,7 +17,8 @@
"@actions/core": "^1.10.1",
"@actions/exec": "^1.1.1",
"@actions/github": "^6.0.1",
"@actions/tool-cache": "^2.0.1"
"@actions/tool-cache": "^2.0.1",
"semver": "^6.3.1"
},
"devDependencies": {
"@types/node": "^20.17.6",

View file

@ -10,6 +10,7 @@ import * as crypto from "crypto";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import * as semver from "semver";
import { setTimeout as wait } from "timers/promises";
const cmdTailscale = "tailscale";
@ -30,6 +31,7 @@ interface TailscaleConfig {
arch: string;
authKey: string;
oauthClientId: string;
audience: string;
oauthSecret: string;
tags: string;
hostname: string;
@ -209,11 +211,12 @@ async function getInputs(): Promise<TailscaleConfig> {
}
const config = {
version: core.getInput("version") || "1.88.3",
version: core.getInput("version") || "1.90.4",
resolvedVersion: "",
arch: "",
authKey: authKey,
oauthClientId: core.getInput("oauth-client-id") || "",
audience: core.getInput("audience") || "",
oauthSecret: oauthSecret,
tags: core.getInput("tags") || "",
hostname: core.getInput("hostname") || "",
@ -237,9 +240,23 @@ async function getInputs(): Promise<TailscaleConfig> {
}
function validateAuth(config: TailscaleConfig): void {
if (!config.authKey && (!config.oauthSecret || !config.tags)) {
if (
!config.authKey &&
(!config.oauthSecret || !config.tags) &&
(!config.audience || !config.oauthClientId || !config.tags)
) {
throw new Error(
"OAuth identity empty, please provide either an auth key or OAuth secret and tags."
"Please provide either an auth key, OAuth secret and tags, or federated identity client ID and audience with tags."
);
}
if (
config.audience &&
semver.valid(config.version) &&
semver.gt("1.90.0", config.version)
) {
throw new Error(
"Workload identity federation requires using tailscale version 1.90.0 or later."
);
}
}
@ -677,15 +694,32 @@ async function connectToTailscale(
// Limit hostname to 63 characters (more will result in the error "not a valid DNS label")
hostname = hostname.substring(0, 63);
// Prepare auth and tags
let finalAuthKey = config.authKey;
const tagsArg: string[] = [];
// Prepare auth and tags.
//
// Items higher in this list take precedence for auth:
// 1. Workload identity
// 2. OAuth client
// 3. Auth key
let authArgs: string[];
let tagsArg: string[] = [];
if (config.oauthSecret) {
finalAuthKey = `${config.oauthSecret}?preauthorized=true&ephemeral=true`;
tagsArg.push(`--advertise-tags=${config.tags}`);
authArgs = [`--authkey=${config.authKey}`];
if (config.audience || config.oauthSecret) {
tagsArg = [`--advertise-tags=${config.tags}`];
if (config.audience) {
const token = await core.getIDToken(config.audience);
authArgs = [
`--client-id=${config.oauthClientId}?preauthorized=true&ephemeral=true`,
`--id-token=${token}`,
];
} else if (config.oauthSecret) {
authArgs = [
`--authkey=${config.oauthSecret}?preauthorized=true&ephemeral=true`,
];
}
}
// Platform-specific args
const platformArgs: string[] = [];
if (runnerOS === runnerWindows) {
@ -696,11 +730,11 @@ async function connectToTailscale(
const upArgs = [
"up",
...tagsArg,
`--authkey=${finalAuthKey}`,
`--hostname=${hostname}`,
"--accept-routes",
...platformArgs,
...config.args.split(" ").filter(Boolean),
...authArgs,
];
// Retry logic