Skip to content

Commit 2f08602

Browse files
committed
feat(cli): add OIDC federated authentication support for CI/CD runners
Enable CLI commands to authenticate using OIDC tokens provided by CI/CD runners (GitHub Actions, GitLab, etc.) when no explicit auth token is configured. This allows attestation commands to leverage the runner's federated identity, simplifying authentication setup in CI/CD pipelines. Signed-off-by: Miguel Martinez <migmartri@gmail.com> Signed-off-by: Miguel Martinez <miguel@chainloop.dev> Entire-Checkpoint: 619ec71d895d
1 parent b0e7af4 commit 2f08602

18 files changed

Lines changed: 106 additions & 200 deletions

app/cli/cmd/attestation_add.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2024-2025 The Chainloop Authors.
2+
// Copyright 2024-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -53,7 +53,8 @@ func newAttestationAddCmd() *cobra.Command {
5353
Use: "add",
5454
Short: "add a material to the attestation",
5555
Annotations: map[string]string{
56-
useAPIToken: "true",
56+
useAPIToken: "true",
57+
supportsFederatedAuthAnnotation: "true",
5758
},
5859
Example: ` # Add a material to the attestation that is defined in the contract
5960
chainloop attestation add --name <material-name> --value <material-value>

app/cli/cmd/attestation_init.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ func newAttestationInitCmd() *cobra.Command {
4242
Use: "init",
4343
Short: "start attestation crafting process",
4444
Annotations: map[string]string{
45-
useAPIToken: trueString,
46-
confirmWhenUserToken: trueString,
45+
useAPIToken: trueString,
46+
supportsFederatedAuthAnnotation: trueString,
47+
confirmWhenUserToken: trueString,
4748
},
4849
PreRunE: func(_ *cobra.Command, _ []string) error {
4950
if workflowName == "" {

app/cli/cmd/attestation_push.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ func newAttestationPushCmd() *cobra.Command {
6262
# Or alternatively
6363
chainloop attestation push --annotation key=value,key2=value2`,
6464
Annotations: map[string]string{
65-
useAPIToken: "true",
65+
useAPIToken: "true",
66+
supportsFederatedAuthAnnotation: "true",
6667
},
6768
RunE: func(cmd *cobra.Command, _ []string) error {
6869
info, err := executableInfo()

app/cli/cmd/attestation_reset.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2024 The Chainloop Authors.
2+
// Copyright 2024-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -31,7 +31,8 @@ func newAttestationResetCmd() *cobra.Command {
3131
Use: "reset",
3232
Short: "mark current attestation process as canceled or failed",
3333
Annotations: map[string]string{
34-
useAPIToken: "true",
34+
useAPIToken: "true",
35+
supportsFederatedAuthAnnotation: "true",
3536
},
3637
PreRunE: func(cmd *cobra.Command, args []string) error {
3738
if trigger != triggerFailed && trigger != triggerCanceled {

app/cli/cmd/attestation_status.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2024-2025 The Chainloop Authors.
2+
// Copyright 2024-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -41,7 +41,8 @@ func newAttestationStatusCmd() *cobra.Command {
4141
Use: "status",
4242
Short: "check the status of the current attestation process",
4343
Annotations: map[string]string{
44-
useAPIToken: "true",
44+
useAPIToken: "true",
45+
supportsFederatedAuthAnnotation: "true",
4546
},
4647
RunE: func(cmd *cobra.Command, args []string) error {
4748
a, err := action.NewAttestationStatus(

app/cli/cmd/root.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
3434
"github.com/chainloop-dev/chainloop/app/cli/pkg/plugins"
3535
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
36+
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter"
3637
"github.com/chainloop-dev/chainloop/pkg/grpcconn"
3738
"github.com/chainloop-dev/chainloop/pkg/policies/engine/builtins"
3839
"github.com/rs/zerolog"
@@ -66,7 +67,8 @@ const (
6667
// Follow the convention stated on https://consoledonottrack.com/
6768
doNotTrackEnv = "DO_NOT_TRACK"
6869

69-
trueString = "true"
70+
trueString = "true"
71+
supportsFederatedAuthAnnotation = "supportsFederatedAuth"
7072
)
7173

7274
var telemetryWg sync.WaitGroup
@@ -119,6 +121,15 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command {
119121
return err
120122
}
121123

124+
// If the auth token is not set and the command supports federated auth, we try to discover the runner and use the federated token for the runner if available
125+
if authToken == "" && cmdSupportsFederatedAuth(cmd) {
126+
r := crafter.DiscoverRunner(authToken, logger)
127+
if r.IsAuthenticated() && r.FederatedToken() != nil {
128+
logger.Debug().Str("runner", r.ID().String()).Msg("using federated auth token")
129+
authToken = r.FederatedToken().RawToken
130+
}
131+
}
132+
122133
var opts = []grpcconn.Option{
123134
grpcconn.WithInsecure(apiInsecure()),
124135
}
@@ -497,6 +508,10 @@ func isAPITokenPreferred(cmd *cobra.Command) bool {
497508
return cmd.Annotations[useAPIToken] == trueString
498509
}
499510

511+
func cmdSupportsFederatedAuth(cmd *cobra.Command) bool {
512+
return cmd.Annotations[supportsFederatedAuthAnnotation] == trueString
513+
}
514+
500515
func getConfigDir(appName string) string {
501516
return filepath.Join(xdg.ConfigHome, appName)
502517
}

app/cli/internal/token/token.go

Lines changed: 11 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2024-2025 The Chainloop Authors.
2+
// Copyright 2024-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -111,64 +111,21 @@ func Parse(token string) (*ParsedToken, error) {
111111
pToken.ID = userID
112112
}
113113
case federatedTokenAudience:
114-
if isGitLabFederatedToken(claims) {
115-
pToken.TokenType = v1.Attestation_Auth_AUTH_TYPE_FEDERATED
116-
if issuer, ok := claims["iss"].(string); ok {
117-
pToken.ID = issuer
118-
}
114+
pToken.TokenType = v1.Attestation_Auth_AUTH_TYPE_FEDERATED
115+
if issuer, ok := claims["iss"].(string); ok {
116+
pToken.ID = issuer
117+
} else {
118+
return nil, nil
119119
}
120120
default:
121121
return nil, nil
122122
}
123123

124-
return pToken, nil
125-
}
126-
127-
// Checks if the claims contain at least 10 custom GitLab ID token claims.
128-
// Reference: https://docs.gitlab.com/ci/secrets/id_token_authentication/
129-
func isGitLabFederatedToken(claims jwt.MapClaims) bool {
130-
gitlabClaims := []string{
131-
"namespace_id",
132-
"namespace_path",
133-
"project_id",
134-
"project_path",
135-
"user_id",
136-
"user_login",
137-
"user_email",
138-
"user_access_level",
139-
"user_identities",
140-
"pipeline_id",
141-
"pipeline_source",
142-
"job_id",
143-
"ref",
144-
"ref_type",
145-
"ref_path",
146-
"ref_protected",
147-
"groups_direct",
148-
"environment",
149-
"environment_protected",
150-
"deployment_tier",
151-
"deployment_action",
152-
"runner_id",
153-
"runner_environment",
154-
"sha",
155-
"ci_config_ref_uri",
156-
"ci_config_sha",
157-
"project_visibility",
158-
}
159-
160-
requiredClaims := 10
161-
162-
// Count how many GitLab-specific claims are present
163-
found := 0
164-
for _, claim := range gitlabClaims {
165-
if _, exists := claims[claim]; exists {
166-
found++
167-
if found >= requiredClaims {
168-
return true
169-
}
170-
}
124+
// Avoid returning partially filled tokens that would create invalid auth metadata
125+
// in crafting state (e.g. type=UNSPECIFIED or empty ID).
126+
if pToken.TokenType == v1.Attestation_Auth_AUTH_TYPE_UNSPECIFIED || pToken.ID == "" {
127+
return nil, nil
171128
}
172129

173-
return found >= requiredClaims
130+
return pToken, nil
174131
}
Lines changed: 13 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2024 The Chainloop Authors.
2+
// Copyright 2024-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -18,8 +18,6 @@ package token
1818
import (
1919
"testing"
2020

21-
"github.com/golang-jwt/jwt/v4"
22-
2321
v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2422
"github.com/stretchr/testify/assert"
2523
)
@@ -55,6 +53,18 @@ func TestParse(t *testing.T) {
5553
TokenType: v1.Attestation_Auth_AUTH_TYPE_FEDERATED,
5654
},
5755
},
56+
{
57+
name: "federated github token",
58+
token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tIiwiYXVkIjoiY2hhaW5sb29wIiwicmVwb3NpdG9yeSI6Im1hdGlhc2luc2F1cnJhbGRlL3Byb2plY3QiLCJzdWIiOiJyZXBvOm1hdGlhc2luc2F1cnJhbGRlL3Byb2plY3Q6cmVmOnJlZnMvaGVhZHMvbWFpbiJ9.signature",
59+
want: &ParsedToken{
60+
ID: "https://token.actions.githubusercontent.com",
61+
TokenType: v1.Attestation_Auth_AUTH_TYPE_FEDERATED,
62+
},
63+
},
64+
{
65+
name: "federated token without issuer",
66+
token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjaGFpbmxvb3AifQ.signature",
67+
},
5868
{
5969
name: "old api token (without orgID)",
6070
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJjcC5jaGFpbmxvb3AiLCJhdWQiOlsiYXBpLXRva2VuLWF1dGguY2hhaW5sb29wIl0sImp0aSI6ImQ0ZTBlZTVlLTk3MTMtNDFkMi05ZmVhLTBiZGIxNDAzMzA4MSJ9.IOd3JIHPwfo9ihU20kvRwLIQJcQtTvp-ajlGqlCD4Es",
@@ -83,123 +93,3 @@ func TestParse(t *testing.T) {
8393
})
8494
}
8595
}
86-
87-
func TestIsGitLabFederatedToken(t *testing.T) {
88-
tests := []struct {
89-
name string
90-
claims jwt.MapClaims
91-
want bool
92-
}{
93-
{
94-
name: "empty claims",
95-
claims: jwt.MapClaims{},
96-
want: false,
97-
},
98-
{
99-
name: "exactly 10 gitlab",
100-
claims: jwt.MapClaims{
101-
"namespace_id": "4243",
102-
"namespace_path": "chainloop",
103-
"project_id": "4242",
104-
"project_path": "chainloop/project",
105-
"user_id": "123",
106-
"user_login": "gitlab-ci-token",
107-
"user_email": "ci@gitlab.com",
108-
"pipeline_id": "101",
109-
"job_id": "202",
110-
"ref": "main",
111-
},
112-
want: true,
113-
},
114-
{
115-
name: "9 gitlab claims",
116-
claims: jwt.MapClaims{
117-
"namespace_id": "4243",
118-
"namespace_path": "chainloop",
119-
"project_id": "4242",
120-
"project_path": "chainloop/project",
121-
"user_id": "123",
122-
"user_login": "gitlab-ci-token",
123-
"pipeline_id": "101",
124-
"job_id": "202",
125-
"ref": "main",
126-
},
127-
want: false,
128-
},
129-
{
130-
name: "all gitlab claims",
131-
claims: jwt.MapClaims{
132-
"namespace_id": "4243",
133-
"namespace_path": "chainloop",
134-
"project_id": "4242",
135-
"project_path": "chainloop/project",
136-
"user_id": "123",
137-
"user_login": "gitlab-ci-token",
138-
"user_email": "ci@gitlab.com",
139-
"user_access_level": "developer",
140-
"pipeline_id": "101",
141-
"pipeline_source": "push",
142-
"job_id": "202",
143-
"ref": "main",
144-
"ref_type": "branch",
145-
"ref_protected": true,
146-
"groups_direct": []string{"group1"},
147-
"environment": "production",
148-
"environment_protected": true,
149-
"deployment_tier": "production",
150-
"deployment_action": "deploy",
151-
"runner_id": "runner-1",
152-
"runner_environment": "production",
153-
"sha": "abc123",
154-
"ci_config_ref_uri": "https://gitlab.com",
155-
"ci_config_sha": "config-abc123",
156-
"project_visibility": "public",
157-
},
158-
want: true,
159-
},
160-
{
161-
name: "10 gitlab claims mixed with non-gitlab claims",
162-
claims: jwt.MapClaims{
163-
"namespace_id": "4243",
164-
"namespace_path": "chainloop",
165-
"project_id": "4242",
166-
"project_path": "chainloop/project",
167-
"user_id": "123",
168-
"user_login": "gitlab-ci-token",
169-
"user_email": "ci@gitlab.com",
170-
"pipeline_id": "101",
171-
"job_id": "202",
172-
"ref": "main",
173-
"custom_claim_1": "value1",
174-
"custom_claim_2": "value2",
175-
"custom_claim_3": "value3",
176-
},
177-
want: true,
178-
},
179-
{
180-
name: "9 gitlab claims mixed with non-gitlab claims",
181-
claims: jwt.MapClaims{
182-
"namespace_id": "4243",
183-
"namespace_path": "chainloop",
184-
"project_id": "4242",
185-
"project_path": "chainloop/project",
186-
"user_id": "123",
187-
"user_login": "gitlab-ci-token",
188-
"pipeline_id": "101",
189-
"job_id": "202",
190-
"ref": "main",
191-
"custom_claim_1": "value1",
192-
"custom_claim_2": "value2",
193-
"custom_claim_3": "value3",
194-
},
195-
want: false,
196-
},
197-
}
198-
199-
for _, tt := range tests {
200-
t.Run(tt.name, func(t *testing.T) {
201-
got := isGitLabFederatedToken(tt.claims)
202-
assert.Equal(t, tt.want, got)
203-
})
204-
}
205-
}

pkg/attestation/crafter/runner.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
2525
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners"
2626
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification"
27+
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/oidc"
2728
"github.com/rs/zerolog"
2829
)
2930

@@ -63,6 +64,8 @@ type SupportedRunner interface {
6364
// attestationViewURL is an optional URL to view the attestation details.
6465
// Returns nil if platform doesn't support reporting.
6566
Report(tableOutput []byte, attestationViewURL string) error
67+
68+
FederatedToken() *oidc.Token
6669
}
6770

6871
type RunnerM map[schemaapi.CraftingSchema_Runner_RunnerType]SupportedRunner

pkg/attestation/crafter/runners/azurepipeline.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,14 @@ import (
2525
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification"
2626
)
2727

28-
type AzurePipeline struct{}
28+
type AzurePipeline struct {
29+
*Generic
30+
}
2931

3032
func NewAzurePipeline() *AzurePipeline {
31-
return &AzurePipeline{}
33+
return &AzurePipeline{
34+
NewGeneric(),
35+
}
3236
}
3337

3438
func (r *AzurePipeline) ID() schemaapi.CraftingSchema_Runner_RunnerType {

0 commit comments

Comments
 (0)