Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions docs/admin-guide/security/jwt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Task JWT issuance

Semaphore can mint a short-lived [JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519)
for every task execution. The token is signed by Semaphore and exposed to the
playbook (or shell/Terraform/PowerShell/Python script) as the
`SEMAPHORE_JWT` environment variable.

Together with the [JWKS endpoint](#jwks-endpoint) that Semaphore publishes, the
token lets external systems authenticate a task without any pre-shared secret.

This page describes the **server-side configuration**. For per-template
configuration and consumption inside a task, see the
[user guide page on task JWTs](/user-guide/task-templates/jwt).

______________________________________________________________________

## How it works

```mermaid
sequenceDiagram
participant U as User / schedule
participant S as Semaphore server
participant J as Task (playbook / script)
participant V as External system (e.g. OpenBao)

U->>S: Start task
S->>S: Mint JWT (signed with ECDSA P-256)
S->>J: Run task with SEMAPHORE_JWT=<token>
J->>V: Exchange token for credentials
V->>S: Fetch JWKS from /.well-known/jwks.json
V->>V: Verify signature, iss, aud, exp & claims
V-->>J: Returns secret
```

Signing uses an **ECDSA P-256** key pair. The private key is generated on first
use, encrypted with the same `access_key_encryption` key that protects other
secrets, and stored in the Semaphore database. The public key is served via
the JWKS endpoint.

______________________________________________________________________

## Configuration

JWT issuance is **disabled by default**. Enable it in your `config.json`:

```json
{
"jwt": {
"enabled": true,
"issuer": "https://semaphore.example.com",
"default_ttl": "1h",
"max_ttl": "24h"
}
}
```

| Option | Default | Description |
| ----------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `jwt.enabled` | `false` | When `false`, no tokens are minted and the JWKS endpoint returns `404`. |
| `jwt.issuer` | _none_ | Value emitted in the `iss` claim. Set this to a stable URL that identifies your Semaphore instance - external systems use it as a trust anchor. |
| `jwt.default_ttl` | `1h` | Token lifetime used when a template does not override it. Accepts Go-style durations (`30m`, `1h`, `90m`, ...). |
| `jwt.max_ttl` | `24h` | Maximum lifetime a token can have. Templates can't override the TTL with a value higher than this. |

:::tip
The signing key is encrypted at rest with the
[`access_key_encryption`](/admin-guide/configuration/config-file) key. Make
sure this option is configured **before** you enable JWTs. The key is
generated on first start and can not be re-encrypted afterwards.
:::

______________________________________________________________________

## JWKS endpoint

When JWT issuance is enabled, Semaphore exposes its public signing key at:

```
GET /.well-known/jwks.json
```

The response follows [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517)
and can be consumed directly by the JWT verifier:

```bash
curl https://semaphore.example.com/.well-known/jwks.json
```

```json
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"kid": "...",
"use": "sig",
"alg": "ES256",
"x": "...",
"y": "..."
}
]
}
```

______________________________________________________________________

## Key rotation

The signing key is created automatically when starting Semaphore with the JWT feature enabled.
To rotate it, remove the `jwt_signing_key` row from the
`option` table and restart Semaphore.
A fresh key pair will be created automatically.

Because rotation invalidates all previously issued tokens, do this only when
no existing token is in use anymore (e.g. no active running tasks)
166 changes: 166 additions & 0 deletions docs/user-guide/task-templates/jwt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Task JWTs

When [JWT issuance is enabled on the server](/admin-guide/security/jwt),
a template can mint a short-lived, signed token for every task it spawns.
The token is exposed to the running playbook or script as the
`SEMAPHORE_JWT` environment variable and can be exchanged for credentials
with any system that supports JWT authentication –
such as OpenBao or HashiCorp Vault.

The advantage over a long-lived secret stored in the
[key store](/user-guide/key-store) is that every task gets a **fresh token
that identifies the exact task run** (project, template, user id) and
expires shortly after the task finishes.

## Enabling JWTs on a template

In the template form, scroll to the **JWT** section (it only appears when the
administrator has [enabled JWT issuance](/admin-guide/security/jwt)) and
tick **JWT enabled**.

You can configure the following options per template:

| Field | Description |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Audience | One or more strings emitted in the `aud` claim. Set this to the identifier(s) your downstream system expects (for example the OpenBao server URL). Up to 32 entries are supported. |
| TTL | Token lifetime as a duration (`30s`, `10m`, `1h`, ...). When left empty, the global `jwt.default_ttl` is used. The TTL must not exceed the global `jwt.max_ttl`. |

## Token claims

Each token carries the following claims that you can rely on when granting
access in the downstream system:

| Claim | Example | Notes |
| ------------- | ----------------------------- | -------------------------------------------------- |
| `iss` | `https://semaphore.example.com` | Configured by the administrator. |
| `aud` | `https://bao.example.com` | From the template's audience list. |
| `sub` | `task:1234` | Unique per task run. |
| `iat` / `nbf` / `exp` | | Standard timing claims. |
| `jti` | | Unique token identifier. |
| `project_id` | `7` | Project the template lives in. |
| `template_id` | `42` | The template that produced the task. |
| `user_id` | `67` | User who launched the task (omitted for scheduled / integration runs) |

Use these claims to **scope** access on the consuming side. For example an
OpenBao role that only accepts tokens with `project_id = 7` and a specific
`template_id`.

## Using the token inside a task

Semaphore exports the token as `SEMAPHORE_JWT` in the environment of the
task process.

```bash
#!/usr/bin/env bash

# Bash example
echo "Look at my fancy token: $SEMAPHORE_JWT"
```

```yaml
# Ansible example
- name: Read secret from OpenBao KVv2 via JWT auth
ansible.builtin.set_fact:
openbao_secret_value: >-
{{ lookup(
'community.hashi_vault.hashi_vault',
secret='kv/data/semaphore/demo:value',
auth_method='jwt',
url='https://bao.example.com',
role_id=bao_role,
jwt=lookup('ansible.builtin.env', 'SEMAPHORE_JWT')
) }}
```

______________________________________________________________________

## Example: OpenBao

The following walk-through configures OpenBao to trust Semaphore's JWTs and
exchanges them for a demo password.
Replace `semaphore.example.com` and `bao.example.com` with your own hostnames.

### 1. Configure the JWT auth method

Enable the JWT auth method and point it at the JWKS endpoint of your
Semaphore instance. OpenBao uses the public key it fetches there to verify
every token.

```shell
bao auth enable jwt

bao write auth/jwt/config \
jwks_url="https://semaphore.example.com/.well-known/jwks.json" \
bound_issuer="https://semaphore.example.com"
```

### 2. Define a policy

Grant the permissions a task needs. The example below allows reading the
demo credential located under `kv/data/semaphore/demo`:

```shell
bao policy write semaphore-demo-policy - <<EOF
path "kv/data/semaphore/demo" {
capabilities = ["read"]
}
EOF
```

### 3. Define an OpenBao role bound to a template

An OpenBao role decides **which Semaphore tasks** are allowed to assume which
policy. Use the Semaphore-specific claims (`project_id`, `template_id`, ...)
as `bound_claims` so that only the intended template can use the role:

```shell
bao write auth/jwt/role/semaphore-demo-role - <<EOF
{
"role_type": "jwt",
"user_claim": "sub",
"bound_audiences": "https://bao.example.com",
"bound_claims": {
"project_id": "7",
"template_id": "42"
},
"policies": ["semaphore-demo-policy"],
}
EOF
```

Always restrict each role with at least a `project_id` or `template_id`
claim. Without a binding, **any** JWT issued by your Semaphore instance
could assume the role.

A full list of supported configuration parameters can be found [here](https://openbao.org/api-docs/auth/jwt/#createupdate-role)

### 4. Configure the template

On the Semaphore template that runs the deploy playbook:

- Tick **JWT enabled**.
- Set **Audience** to `https://bao.example.com` – this matches
`bound_audiences` in the OpenBao role.
- Optionally set **TTL** to `15m` so the token expires shortly after the
task finishes.

### 5. Use the token in the task

```yaml
- hosts: localhost
gather_facts: false
tasks:
- name: Read secret from OpenBao KVv2 via JWT auth
ansible.builtin.set_fact:
openbao_secret_value: >-
{{ lookup(
'community.hashi_vault.hashi_vault',
secret='kv/data/semaphore/demo:value',
auth_method='jwt',
url='https://bao.example.com',
role_id='semaphore-demo-role',
jwt=lookup('ansible.builtin.env', 'SEMAPHORE_JWT')
) }}
```

The task now authenticates against OpenBao without any pre-shared secret :tada:
2 changes: 2 additions & 0 deletions sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const sidebars = {
link: { type: 'doc', id: 'admin-guide/security' },
items: [
'admin-guide/security/network',
'admin-guide/security/jwt',
// 'admin-guide/security/kerberos',
],
},
Expand Down Expand Up @@ -150,6 +151,7 @@ const sidebars = {
items: [
'user-guide/task-templates/survey-vars',
'user-guide/task-templates/prompts',
'user-guide/task-templates/jwt',
],
},
{
Expand Down