Skip to content
Merged
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
97 changes: 12 additions & 85 deletions app/cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ import (
"github.com/chainloop-dev/chainloop/app/cli/internal/action"
"github.com/chainloop-dev/chainloop/app/cli/internal/telemetry"
"github.com/chainloop-dev/chainloop/app/cli/internal/telemetry/posthog"
token "github.com/chainloop-dev/chainloop/app/cli/internal/token"
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
"github.com/chainloop-dev/chainloop/pkg/grpcconn"
"github.com/golang-jwt/jwt/v4"
"github.com/rs/zerolog"
"github.com/spf13/cobra"
"github.com/spf13/viper"
Expand All @@ -59,9 +59,6 @@ const (
appName = "chainloop"
//nolint:gosec
tokenEnvVarName = "CHAINLOOP_TOKEN"
userAudience = "user-auth.chainloop"
//nolint:gosec
apiTokenAudience = "api-token-auth.chainloop"
// Follow the convention stated on https://consoledonottrack.com/
doNotTrackEnv = "DO_NOT_TRACK"

Expand All @@ -70,12 +67,6 @@ const (

var telemetryWg sync.WaitGroup

type parsedToken struct {
id string
orgID string
tokenType string
}

// Environment variable prefix for vipers
const envPrefix = "CHAINLOOP"

Expand Down Expand Up @@ -114,7 +105,7 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command {
logger.Warn().Msg("API contacted in insecure mode")
}

token, isUserToken, err := loadControlplaneAuthToken(cmd)
authToken, isUserToken, err := loadControlplaneAuthToken(cmd)
if err != nil {
return err
}
Expand All @@ -132,12 +123,12 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command {
// If no organization is set in local configuration, we load it from server and save it
orgName := viper.GetString(confOptions.organization.viperKey)
if orgName == "" {
conn, err := grpcconn.New(controlplaneURL, token, opts...)
conn, err := grpcconn.New(controlplaneURL, authToken, opts...)
if err != nil {
return err
}

currentContext, err := action.NewConfigCurrentContext(newActionOpts(logger, conn, token)).Run()
currentContext, err := action.NewConfigCurrentContext(newActionOpts(logger, conn, authToken)).Run()
if err == nil && currentContext.CurrentMembership != nil {
if err := setLocalOrganization(currentContext.CurrentMembership.Org.Name); err != nil {
return fmt.Errorf("writing config file: %w", err)
Expand All @@ -158,11 +149,11 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command {
}
}

conn, err := grpcconn.New(controlplaneURL, token, opts...)
conn, err := grpcconn.New(controlplaneURL, authToken, opts...)
if err != nil {
return err
}
actionOpts = newActionOpts(logger, conn, token)
actionOpts = newActionOpts(logger, conn, authToken)

if !isTelemetryDisabled() {
logger.Debug().Msg("Telemetry enabled, to disable it use DO_NOT_TRACK=1")
Expand All @@ -180,13 +171,13 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command {
go func() {
// For telemetry reasons we parse the token to know the type of token is being used when executing the CLI
// Once we have the token type we can send it to the telemetry service by injecting it on the context
token, err := parseToken(token)
authToken, err := token.Parse(authToken)
if err != nil {
logger.Debug().Err(err).Msg("parsing token for telemetry")
return
}

err = recordCommand(cmd, token)
err = recordCommand(cmd, authToken)
if err != nil {
logger.Debug().Err(err).Msg("sending command to telemetry")
}
Expand Down Expand Up @@ -386,70 +377,6 @@ func loadControlplaneAuthToken(cmd *cobra.Command) (string, bool, error) {
return userToken, true, nil
}

// parseToken the token and return the type of token. At the moment in Chainloop we have 3 types of tokens:
// 1. User account token
// 2. API token
// Each one of them have an associated audience claim that we use to identify the type of token. If the token is not
// present, nor we cannot match it with one of the expected audience, return nil.
func parseToken(token string) (*parsedToken, error) {
if token == "" {
return nil, nil
}

// Create a parser without claims validation
parser := jwt.NewParser(jwt.WithoutClaimsValidation())

// Parse the token without verification
t, _, err := parser.ParseUnverified(token, jwt.MapClaims{})
if err != nil {
return nil, err
}

// Extract generic claims otherwise, we would have to parse
// the token again to get the claims for each type
claims, ok := t.Claims.(jwt.MapClaims)
if !ok {
return nil, nil
}

// Get the audience claim
val, ok := claims["aud"]
if !ok || val == nil {
return nil, nil
}

// Ensure audience is an array of interfaces
// Chainloop only has one audience per token
aud, ok := val.([]interface{})
if !ok || len(aud) == 0 {
return nil, nil
}

// Initialize parsedToken
pToken := &parsedToken{}

// Determine the type of token based on the audience.
switch aud[0].(string) {
case apiTokenAudience:
pToken.tokenType = "api-token"
if tokenID, ok := claims["jti"].(string); ok {
pToken.id = tokenID
}
if orgID, ok := claims["org_id"].(string); ok {
pToken.orgID = orgID
}
case userAudience:
pToken.tokenType = "user"
if userID, ok := claims["user_id"].(string); ok {
pToken.id = userID
}
default:
return nil, nil
}

return pToken, nil
}

var (
// Posthog API key and endpoint are not sensitive information it represents Chainloop's Posthog instance.
// It can be overridden by the user if they want to use their own instance of Posthog or deactivated by setting
Expand All @@ -460,7 +387,7 @@ var (
)

// recordCommand sends the command to the telemetry service
func recordCommand(executedCmd *cobra.Command, authInfo *parsedToken) error {
func recordCommand(executedCmd *cobra.Command, authInfo *token.ParsedToken) error {
telemetryClient, err := posthog.NewClient(posthogAPIKey, posthogEndpoint)
if err != nil {
logger.Debug().Err(err).Msgf("creating telemetry client: %v", err)
Expand All @@ -476,9 +403,9 @@ func recordCommand(executedCmd *cobra.Command, authInfo *parsedToken) error {

// It tries to extract the token from the context and add it to the tags. If it fails, it will ignore it.
if authInfo != nil {
tags["token_type"] = authInfo.tokenType
tags["user_id"] = authInfo.id
tags["org_id"] = authInfo.orgID
tags["token_type"] = authInfo.TokenType.String()
tags["user_id"] = authInfo.ID
tags["org_id"] = authInfo.OrgID
}

if err = cmdTracker.Track(executedCmd.Context(), extractCmdLineFromCommand(executedCmd), tags); err != nil {
Expand Down
30 changes: 30 additions & 0 deletions app/cli/internal/action/attestation_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"fmt"
"strconv"

"github.com/chainloop-dev/chainloop/app/cli/internal/token"
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter"
Expand Down Expand Up @@ -214,6 +215,14 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun
attestationID = workflowRun.GetId()
}

var authInfo *clientAPI.Attestation_Auth
if action.AuthTokenRaw != "" {
authInfo, err = extractAuthInfo(action.AuthTokenRaw)
if err != nil {
return "", err
}
}

// Initialize the local attestation crafter
// NOTE: important to run this initialization here since workflowMeta is populated
// with the workflowRunId that comes from the control plane
Expand All @@ -228,6 +237,7 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun
TimestampAuthorityURL: timestampAuthorityURL,
SigningCAName: signingCAName,
},
Auth: authInfo,
}

if err := action.c.Init(ctx, initOpts); err != nil {
Expand Down Expand Up @@ -326,3 +336,23 @@ func groupMaterialToCraftingSchemaMaterial(gm *v1.PolicyGroup_Material, group *v
Optional: gm.Optional,
}, nil
}

func extractAuthInfo(authToken string) (*clientAPI.Attestation_Auth, error) {
if authToken == "" {
return nil, errors.New("empty token")
}

parsed, err := token.Parse(authToken)
if err != nil {
return nil, fmt.Errorf("failed to parse token: %w", err)
}

if parsed == nil {
return nil, errors.New("could not determine auth type from token")
}

return &clientAPI.Attestation_Auth{
Type: parsed.TokenType,
Id: parsed.ID,
}, nil
}
102 changes: 102 additions & 0 deletions app/cli/internal/token/token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//
// Copyright 2024-2025 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package token

import (
v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
"github.com/golang-jwt/jwt/v4"
)

const (
UserAudience = "user-auth.chainloop"
APIAudience = "api-token-auth.chainloop"
)

type ParsedToken struct {
ID string
OrgID string
TokenType v1.Attestation_Auth_AuthType
}

const (
userAudience = "user-auth.chainloop"
//nolint:gosec
apiTokenAudience = "api-token-auth.chainloop"
)

// Parse the token and return the type of token. At the moment in Chainloop we have 3 types of tokens:
Copy link
Member

Choose a reason for hiding this comment

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

If we add the Federated one, we will have 3 types and this sentence will be true 😆

// 1. User account token
// 2. API token
// Each one of them have an associated audience claim that we use to identify the type of token. If the token is not
// present, nor we cannot match it with one of the expected audience, return nil.
func Parse(token string) (*ParsedToken, error) {
if token == "" {
return nil, nil
}

// Create a parser without claims validation
parser := jwt.NewParser(jwt.WithoutClaimsValidation())

// Parse the token without verification
t, _, err := parser.ParseUnverified(token, jwt.MapClaims{})
if err != nil {
return nil, err
}

// Extract generic claims otherwise, we would have to parse
// the token again to get the claims for each type
claims, ok := t.Claims.(jwt.MapClaims)
if !ok {
return nil, nil
}

// Get the audience claim
val, ok := claims["aud"]
if !ok || val == nil {
return nil, nil
}

// Ensure audience is an array of interfaces
// Chainloop only has one audience per token
aud, ok := val.([]interface{})
if !ok || len(aud) == 0 {
return nil, nil
}

// Initialize parsedToken
pToken := &ParsedToken{}

// Determine the type of token based on the audience.
switch aud[0].(string) {
case apiTokenAudience:
pToken.TokenType = v1.Attestation_Auth_AUTH_TYPE_API_TOKEN
if tokenID, ok := claims["jti"].(string); ok {
pToken.ID = tokenID
}
if orgID, ok := claims["org_id"].(string); ok {
pToken.OrgID = orgID
}
case userAudience:
pToken.TokenType = v1.Attestation_Auth_AUTH_TYPE_USER
if userID, ok := claims["user_id"].(string); ok {
pToken.ID = userID
}
default:
return nil, nil
}

return pToken, nil
}
Loading
Loading