Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
6 changes: 4 additions & 2 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"image": "mcr.microsoft.com/devcontainers/universal:4",
"image": "mcr.microsoft.com/devcontainers/universal:6",
"features": {
"ghcr.io/devcontainers/features/go:1": {},
"ghcr.io/devcontainers/features/go:1": {
"version": "1.26"
},
"ghcr.io/devcontainers/features/node:1": {}
},
"postCreateCommand": "./.devcontainer/postCreateCommand.sh"
Expand Down
1 change: 1 addition & 0 deletions api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func TestApiPing(t *testing.T) {
nil,
nil,
nil,
nil,
)

r.ServeHTTP(rr, req)
Expand Down
40 changes: 40 additions & 0 deletions api/jwks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package api

import (
"net/http"

"github.com/semaphoreui/semaphore/pkg/jwt"
"github.com/semaphoreui/semaphore/util"
log "github.com/sirupsen/logrus"
)

type JwksController struct {
signer jwt.Signer
}

func NewJwksController(signer jwt.Signer) *JwksController {
return &JwksController{signer: signer}
}

// GetJWKS serves the JSON Web Key Set.
func (c *JwksController) GetJWKS(w http.ResponseWriter, _ *http.Request) {
if util.Config == nil || util.Config.JWT == nil || !util.Config.JWT.Enabled {
http.NotFound(w, nil)
return
}

if c.signer == nil {
http.NotFound(w, nil)
return
}

body, err := c.signer.JWKS()
if err != nil {
log.WithError(err).WithField("context", "jwt").Error("failed to marshal JWKS")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
}
57 changes: 57 additions & 0 deletions api/jwks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package api

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/semaphoreui/semaphore/pkg/jwt"
"github.com/semaphoreui/semaphore/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestJwksController_GetJWKS(t *testing.T) {
signer := newTestSigner(t)
controller := NewJwksController(signer)

t.Run("enabled", func(t *testing.T) {
util.Config = &util.ConfigType{
JWT: &util.JWTConfig{Enabled: true},
}

req := httptest.NewRequest(http.MethodGet, "/.well-known/jwks.json", nil)
rr := httptest.NewRecorder()

controller.GetJWKS(rr, req)

require.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
assert.Contains(t, rr.Body.String(), signer.KeyID())
})

t.Run("disabled", func(t *testing.T) {
util.Config = &util.ConfigType{
JWT: &util.JWTConfig{Enabled: false},
}

req := httptest.NewRequest(http.MethodGet, "/.well-known/jwks.json", nil)
rr := httptest.NewRecorder()

controller.GetJWKS(rr, req)

assert.Equal(t, http.StatusNotFound, rr.Code)
})
}

func newTestSigner(t *testing.T) jwt.Signer {
t.Helper()

pemBytes, err := jwt.GenerateKeyPEM()
require.NoError(t, err)

signer, err := jwt.NewECDSASignerFromPEM(pemBytes, jwt.SignerOptions{})
require.NoError(t, err)

return signer
}
7 changes: 6 additions & 1 deletion api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
taskServices "github.com/semaphoreui/semaphore/services/tasks"

"github.com/semaphoreui/semaphore/api/tasks"
"github.com/semaphoreui/semaphore/pkg/jwt"
"github.com/semaphoreui/semaphore/pkg/tz"
log "github.com/sirupsen/logrus"

Expand Down Expand Up @@ -88,12 +89,14 @@ func Route(
accessKeyService server.AccessKeyService,
environmentService server.EnvironmentService,
subscriptionService pro_interfaces.SubscriptionService,
jwtSigner jwt.Signer,
runnerService server.RunnerService,
workflowService pro_interfaces.WorkflowService,
) *mux.Router {

projectController := &projects.ProjectController{ProjectService: projectService}
runnerController := runners.NewRunnerController(store, taskPool, encryptionService)
runnerController := runners.NewRunnerController(store, taskPool, encryptionService, jwtSigner)
jwksController := NewJwksController(jwtSigner)
integrationController := NewIntegrationController(integrationService)
environmentController := projects.NewEnvironmentController(store, encryptionService, accessKeyService, environmentService, secretStorageService)
secretStorageController := projects.NewSecretStorageController(store, secretStorageService)
Expand Down Expand Up @@ -138,6 +141,8 @@ func Route(

r.Use(mux.CORSMethodMiddleware(r))

r.Path("/.well-known/jwks.json").Methods("GET", "HEAD").HandlerFunc(jwksController.GetJWKS)

pingRouter := r.Path(webPath + "api/ping").Subrouter()
pingRouter.Use(plainTextMiddleware)
pingRouter.Methods("GET", "HEAD").HandlerFunc(pongHandler)
Expand Down
39 changes: 36 additions & 3 deletions api/runners/runners.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

"github.com/semaphoreui/semaphore/api/helpers"
"github.com/semaphoreui/semaphore/db"
"github.com/semaphoreui/semaphore/pkg/jwt"
"github.com/semaphoreui/semaphore/pkg/task_logger"
"github.com/semaphoreui/semaphore/services/runners"
"github.com/semaphoreui/semaphore/services/server"
Expand Down Expand Up @@ -101,13 +102,15 @@ type RunnerController struct {
runnerRepo db.RunnerManager
taskPool *tasks.TaskPool
encryptionService server.AccessKeyEncryptionService
signer jwt.Signer
}

func NewRunnerController(runnerRepo db.RunnerManager, taskPool *tasks.TaskPool, encryptionService server.AccessKeyEncryptionService) *RunnerController {
func NewRunnerController(runnerRepo db.RunnerManager, taskPool *tasks.TaskPool, encryptionService server.AccessKeyEncryptionService, signer jwt.Signer) *RunnerController {
return &RunnerController{
runnerRepo: runnerRepo,
taskPool: taskPool,
encryptionService: encryptionService,
signer: signer,
}
}

Expand Down Expand Up @@ -161,7 +164,7 @@ func (c *RunnerController) GetRunner(w http.ResponseWriter, r *http.Request) {

if tsk.Task.Status == task_logger.TaskWaitingStatus || tsk.Task.Status == task_logger.TaskStartingStatus {

data.NewJobs = append(data.NewJobs, runners.JobData{
jobData := runners.JobData{
Username: tsk.Username,
IncomingVersion: tsk.IncomingVersion,
Alias: tsk.Alias,
Expand All @@ -171,7 +174,37 @@ func (c *RunnerController) GetRunner(w http.ResponseWriter, r *http.Request) {
InventoryRepository: tsk.Inventory.Repository,
Repository: tsk.Repository,
Environment: tsk.Environment,
})
}

if c.signer != nil && tsk.Template.JWTParams != nil && tsk.Template.JWTParams.Enabled {
ttl, terr := tsk.Template.JWTParams.ParsedTTL()
if terr != nil {
log.WithError(terr).WithFields(log.Fields{
"task_id": tsk.Task.ID,
"template_id": tsk.Template.ID,
"context": "jwt",
}).Error("invalid template jwt_params.ttl; skipping token issuance")
} else {
token, err := c.signer.Sign(jwt.TaskInfo{
TaskID: tsk.Task.ID,
ProjectID: tsk.Task.ProjectID,
TemplateID: tsk.Template.ID,
UserID: tsk.Task.UserID,
Audience: jwt.Audience(tsk.Template.JWTParams.Audience),
TTL: ttl,
})
if err != nil {
log.WithError(err).WithFields(log.Fields{
"task_id": tsk.Task.ID,
"context": "jwt",
}).Error("failed to sign task JWT")
} else {
jobData.JWT = token
}
}
}

data.NewJobs = append(data.NewJobs, jobData)

if tsk.Inventory.SSHKeyID != nil {
err := c.encryptionService.DeserializeSecret(&tsk.Inventory.SSHKey)
Expand Down
11 changes: 11 additions & 0 deletions api/system_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ type SystemInfo struct {
Teams *util.TeamsConfig `json:"teams"`
Roles []db.Role `json:"roles"`
BoltdbUsed bool `json:"boltdb_used"`
JWT SystemInfoJWT `json:"jwt"`
}

// SystemInfoJWT exposes the global JWT configuration for the WebUI.
type SystemInfoJWT struct {
Enabled bool `json:"enabled"`
MaxTTL string `json:"max_ttl,omitempty"`
}

func NewSystemInfoController(subscriptionService pro_interfaces.SubscriptionService) *SystemInfoController {
Expand Down Expand Up @@ -106,6 +113,10 @@ func (c *SystemInfoController) GetSystemInfo(w http.ResponseWriter, r *http.Requ
Teams: util.Config.Teams,
Roles: roles,
BoltdbUsed: util.Config.Dialect == "bolt",
JWT: SystemInfoJWT{
Enabled: util.Config.JWT.Enabled,
MaxTTL: util.Config.JWT.MaxTTL,
},
}

helpers.WriteJSON(w, http.StatusOK, body)
Expand Down
7 changes: 7 additions & 0 deletions cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ func Execute() {
func runService() {
store := createStore("root")

jwtSigner, jwtErr := util.InitJWTSignerFromStore(store)
if jwtErr != nil {
log.WithError(jwtErr).Fatal("failed to initialise JWT signer")
}

initSyslog(util.Config.Syslog)

// Initialize HA node identity before any component that uses it.
Expand Down Expand Up @@ -147,6 +152,7 @@ func runService() {
encryptionService,
accessKeyInstallationService,
logWriteService,
jwtSigner,
)

// The workflow service orchestrates workflow runs and launches each node's
Expand Down Expand Up @@ -266,6 +272,7 @@ func runService() {
accessKeyService,
environmentService,
subscriptionService,
jwtSigner,
runnerService,
workflowService,
)
Expand Down
26 changes: 26 additions & 0 deletions config.schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,32 @@ properties:
type: string
description: Bootstrap token used by runners to register with the server.

# ==========================================================================
# JWT issuance for task executions
# ==========================================================================
jwt_enabled:
type: boolean
description: >
When true, Semaphore mints a short-lived JWT for each task run and exposes
its public key via /.well-known/jwks.json.
jwt_issuer:
type: string
description: Value emitted in the `iss` claim of issued JWTs.
jwt_default_ttl:
Comment on lines +157 to +165

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document JWT config under the actual jwt object

These schema properties are top-level jwt_enabled/jwt_issuer keys, but the new config field is JWT *JWTConfig with JSON name jwt, so the runtime expects jwt: { enabled, issuer, default_ttl, max_ttl }. Users following this schema can create a validating config that is silently ignored by ConfigInit, leaving JWT disabled and the UI hidden.

Useful? React with 👍 / 👎.

type: string
pattern: "^[0-9]+(|s|m|h)$"
default: "1h"
description: >
Default lifetime of an issued task JWT, as a Go duration (e.g. 30m, 1h).
Overridden per-template by `jwt_params.ttl`.
jwt_max_ttl:
type: string
pattern: "^[0-9]+(s|m|h)$"
default: "24h"
description: >
Hard upper bound on per-template JWT TTL, as a Go duration. Any
configured `jwt_params.ttl` above this is rejected.

# ==========================================================================
# Feature toggles
# ==========================================================================
Expand Down
1 change: 1 addition & 0 deletions db/Migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ func GetMigrations(dialect string) []Migration {
{Version: "2.18.2"},
{Version: "2.18.4"},
{Version: "2.18.5"},
{Version: "2.18.6"},
{Version: "2.18.7"},
{Version: "2.18.15"},
{Version: "2.19.2"},
Expand Down
6 changes: 6 additions & 0 deletions db/Template.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ type Template struct {

AllowOverrideBranchInTask bool `db:"allow_override_branch_in_task" json:"allow_override_branch_in_task,omitempty"`
AllowParallelTasks bool `db:"allow_parallel_tasks" json:"allow_parallel_tasks,omitempty"`

JWTParams *TemplateJWTParams `db:"jwt_params" json:"jwt_params,omitempty"`
}

type TemplateWithPerms struct {
Expand Down Expand Up @@ -239,6 +241,10 @@ func (tpl *Template) Validate() error {
}
}

if err := tpl.JWTParams.Validate(); err != nil {
return err
}

return nil
}

Expand Down
Loading
Loading