diff --git a/api/gen/proto/go/teleport/workloadidentity/v1/join_attrs.pb.go b/api/gen/proto/go/teleport/workloadidentity/v1/join_attrs.pb.go index 3e857a22d6afc..1a9b1978765f5 100644 --- a/api/gen/proto/go/teleport/workloadidentity/v1/join_attrs.pb.go +++ b/api/gen/proto/go/teleport/workloadidentity/v1/join_attrs.pb.go @@ -64,7 +64,9 @@ type JoinAttrs struct { // Attributes that are specific to the Kubernetes (`kubernetes`) join method. Kubernetes *JoinAttrsKubernetes `protobuf:"bytes,12,opt,name=kubernetes,proto3" json:"kubernetes,omitempty"` // Attributes that are specific to the Oracle (`oracle`) join method. - Oracle *JoinAttrsOracle `protobuf:"bytes,13,opt,name=oracle,proto3" json:"oracle,omitempty"` + Oracle *JoinAttrsOracle `protobuf:"bytes,13,opt,name=oracle,proto3" json:"oracle,omitempty"` + // Attributes that are specific to the Azure Devops (`azure_devops`) join method. + AzureDevops *JoinAttrsAzureDevops `protobuf:"bytes,14,opt,name=azure_devops,json=azureDevops,proto3" json:"azure_devops,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -190,6 +192,13 @@ func (x *JoinAttrs) GetOracle() *JoinAttrsOracle { return nil } +func (x *JoinAttrs) GetAzureDevops() *JoinAttrsAzureDevops { + if x != nil { + return x.AzureDevops + } + return nil +} + // The collection of attributes that result from the join process but are not // specific to any particular join method. type JoinAttrsMeta struct { @@ -1524,11 +1533,195 @@ func (x *JoinAttrsOracle) GetInstanceId() string { return "" } +// Attributes that are specific to the Azure Devops (`azure_devops`) join method. +type JoinAttrsAzureDevops struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Attributes specific to joins that occur with the pipeline ID token. + Pipeline *JoinAttrsAzureDevopsPipeline `protobuf:"bytes,1,opt,name=pipeline,proto3" json:"pipeline,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *JoinAttrsAzureDevops) Reset() { + *x = JoinAttrsAzureDevops{} + mi := &file_teleport_workloadidentity_v1_join_attrs_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *JoinAttrsAzureDevops) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*JoinAttrsAzureDevops) ProtoMessage() {} + +func (x *JoinAttrsAzureDevops) ProtoReflect() protoreflect.Message { + mi := &file_teleport_workloadidentity_v1_join_attrs_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use JoinAttrsAzureDevops.ProtoReflect.Descriptor instead. +func (*JoinAttrsAzureDevops) Descriptor() ([]byte, []int) { + return file_teleport_workloadidentity_v1_join_attrs_proto_rawDescGZIP(), []int{17} +} + +func (x *JoinAttrsAzureDevops) GetPipeline() *JoinAttrsAzureDevopsPipeline { + if x != nil { + return x.Pipeline + } + return nil +} + +// Attributes that are specific to the Azure DevOps join method when the +// pipeline ID token is used for authentication +type JoinAttrsAzureDevopsPipeline struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The `sub` claim of the Azure DevOps pipeline ID token that was used to join. + Sub string `protobuf:"bytes,1,opt,name=sub,proto3" json:"sub,omitempty"` + // The name of the organization that the pipeline is running within. + OrganizationName string `protobuf:"bytes,2,opt,name=organization_name,json=organizationName,proto3" json:"organization_name,omitempty"` + // The name of the project that the pipeline is running within. + ProjectName string `protobuf:"bytes,3,opt,name=project_name,json=projectName,proto3" json:"project_name,omitempty"` + // The name of the pipeline that is running. + PipelineName string `protobuf:"bytes,4,opt,name=pipeline_name,json=pipelineName,proto3" json:"pipeline_name,omitempty"` + // The ID of the organization that the pipeline is running within. + OrganizationId string `protobuf:"bytes,5,opt,name=organization_id,json=organizationId,proto3" json:"organization_id,omitempty"` + // The ID of the project that the pipeline is running within. + ProjectId string `protobuf:"bytes,6,opt,name=project_id,json=projectId,proto3" json:"project_id,omitempty"` + // The ID of the pipeline that is running. + DefinitionId string `protobuf:"bytes,7,opt,name=definition_id,json=definitionId,proto3" json:"definition_id,omitempty"` + // The ID of the repository that the pipeline is running within. + RepositoryId string `protobuf:"bytes,8,opt,name=repository_id,json=repositoryId,proto3" json:"repository_id,omitempty"` + // The version of the repository that the pipeline is running against. + // For Git this will be the commit SHA. + RepositoryVersion string `protobuf:"bytes,9,opt,name=repository_version,json=repositoryVersion,proto3" json:"repository_version,omitempty"` + // The ref of the repository that the pipeline is running against. + RepositoryRef string `protobuf:"bytes,10,opt,name=repository_ref,json=repositoryRef,proto3" json:"repository_ref,omitempty"` + // The ID of the run that is being executed. + RunId string `protobuf:"bytes,11,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *JoinAttrsAzureDevopsPipeline) Reset() { + *x = JoinAttrsAzureDevopsPipeline{} + mi := &file_teleport_workloadidentity_v1_join_attrs_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *JoinAttrsAzureDevopsPipeline) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*JoinAttrsAzureDevopsPipeline) ProtoMessage() {} + +func (x *JoinAttrsAzureDevopsPipeline) ProtoReflect() protoreflect.Message { + mi := &file_teleport_workloadidentity_v1_join_attrs_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use JoinAttrsAzureDevopsPipeline.ProtoReflect.Descriptor instead. +func (*JoinAttrsAzureDevopsPipeline) Descriptor() ([]byte, []int) { + return file_teleport_workloadidentity_v1_join_attrs_proto_rawDescGZIP(), []int{18} +} + +func (x *JoinAttrsAzureDevopsPipeline) GetSub() string { + if x != nil { + return x.Sub + } + return "" +} + +func (x *JoinAttrsAzureDevopsPipeline) GetOrganizationName() string { + if x != nil { + return x.OrganizationName + } + return "" +} + +func (x *JoinAttrsAzureDevopsPipeline) GetProjectName() string { + if x != nil { + return x.ProjectName + } + return "" +} + +func (x *JoinAttrsAzureDevopsPipeline) GetPipelineName() string { + if x != nil { + return x.PipelineName + } + return "" +} + +func (x *JoinAttrsAzureDevopsPipeline) GetOrganizationId() string { + if x != nil { + return x.OrganizationId + } + return "" +} + +func (x *JoinAttrsAzureDevopsPipeline) GetProjectId() string { + if x != nil { + return x.ProjectId + } + return "" +} + +func (x *JoinAttrsAzureDevopsPipeline) GetDefinitionId() string { + if x != nil { + return x.DefinitionId + } + return "" +} + +func (x *JoinAttrsAzureDevopsPipeline) GetRepositoryId() string { + if x != nil { + return x.RepositoryId + } + return "" +} + +func (x *JoinAttrsAzureDevopsPipeline) GetRepositoryVersion() string { + if x != nil { + return x.RepositoryVersion + } + return "" +} + +func (x *JoinAttrsAzureDevopsPipeline) GetRepositoryRef() string { + if x != nil { + return x.RepositoryRef + } + return "" +} + +func (x *JoinAttrsAzureDevopsPipeline) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + var File_teleport_workloadidentity_v1_join_attrs_proto protoreflect.FileDescriptor const file_teleport_workloadidentity_v1_join_attrs_proto_rawDesc = "" + "\n" + - "-teleport/workloadidentity/v1/join_attrs.proto\x12\x1cteleport.workloadidentity.v1\"\xc2\a\n" + + "-teleport/workloadidentity/v1/join_attrs.proto\x12\x1cteleport.workloadidentity.v1\"\x99\b\n" + "\tJoinAttrs\x12?\n" + "\x04meta\x18\x01 \x01(\v2+.teleport.workloadidentity.v1.JoinAttrsMetaR\x04meta\x12E\n" + "\x06gitlab\x18\x02 \x01(\v2-.teleport.workloadidentity.v1.JoinAttrsGitLabR\x06gitlab\x12E\n" + @@ -1545,7 +1738,8 @@ const file_teleport_workloadidentity_v1_join_attrs_proto_rawDesc = "" + "\n" + "kubernetes\x18\f \x01(\v21.teleport.workloadidentity.v1.JoinAttrsKubernetesR\n" + "kubernetes\x12E\n" + - "\x06oracle\x18\r \x01(\v2-.teleport.workloadidentity.v1.JoinAttrsOracleR\x06oracle\"X\n" + + "\x06oracle\x18\r \x01(\v2-.teleport.workloadidentity.v1.JoinAttrsOracleR\x06oracle\x12U\n" + + "\fazure_devops\x18\x0e \x01(\v22.teleport.workloadidentity.v1.JoinAttrsAzureDevopsR\vazureDevops\"X\n" + "\rJoinAttrsMeta\x12&\n" + "\x0fjoin_token_name\x18\x01 \x01(\tR\rjoinTokenName\x12\x1f\n" + "\vjoin_method\x18\x02 \x01(\tR\n" + @@ -1652,7 +1846,23 @@ const file_teleport_workloadidentity_v1_join_attrs_proto_rawDesc = "" + "tenancy_id\x18\x01 \x01(\tR\ttenancyId\x12%\n" + "\x0ecompartment_id\x18\x02 \x01(\tR\rcompartmentId\x12\x1f\n" + "\vinstance_id\x18\x03 \x01(\tR\n" + - "instanceIdBdZbgithub.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1;workloadidentityv1b\x06proto3" + "instanceId\"n\n" + + "\x14JoinAttrsAzureDevops\x12V\n" + + "\bpipeline\x18\x01 \x01(\v2:.teleport.workloadidentity.v1.JoinAttrsAzureDevopsPipelineR\bpipeline\"\xa4\x03\n" + + "\x1cJoinAttrsAzureDevopsPipeline\x12\x10\n" + + "\x03sub\x18\x01 \x01(\tR\x03sub\x12+\n" + + "\x11organization_name\x18\x02 \x01(\tR\x10organizationName\x12!\n" + + "\fproject_name\x18\x03 \x01(\tR\vprojectName\x12#\n" + + "\rpipeline_name\x18\x04 \x01(\tR\fpipelineName\x12'\n" + + "\x0forganization_id\x18\x05 \x01(\tR\x0eorganizationId\x12\x1d\n" + + "\n" + + "project_id\x18\x06 \x01(\tR\tprojectId\x12#\n" + + "\rdefinition_id\x18\a \x01(\tR\fdefinitionId\x12#\n" + + "\rrepository_id\x18\b \x01(\tR\frepositoryId\x12-\n" + + "\x12repository_version\x18\t \x01(\tR\x11repositoryVersion\x12%\n" + + "\x0erepository_ref\x18\n" + + " \x01(\tR\rrepositoryRef\x12\x15\n" + + "\x06run_id\x18\v \x01(\tR\x05runIdBdZbgithub.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1;workloadidentityv1b\x06proto3" var ( file_teleport_workloadidentity_v1_join_attrs_proto_rawDescOnce sync.Once @@ -1666,7 +1876,7 @@ func file_teleport_workloadidentity_v1_join_attrs_proto_rawDescGZIP() []byte { return file_teleport_workloadidentity_v1_join_attrs_proto_rawDescData } -var file_teleport_workloadidentity_v1_join_attrs_proto_msgTypes = make([]protoimpl.MessageInfo, 17) +var file_teleport_workloadidentity_v1_join_attrs_proto_msgTypes = make([]protoimpl.MessageInfo, 19) var file_teleport_workloadidentity_v1_join_attrs_proto_goTypes = []any{ (*JoinAttrs)(nil), // 0: teleport.workloadidentity.v1.JoinAttrs (*JoinAttrsMeta)(nil), // 1: teleport.workloadidentity.v1.JoinAttrsMeta @@ -1685,6 +1895,8 @@ var file_teleport_workloadidentity_v1_join_attrs_proto_goTypes = []any{ (*JoinAttrsKubernetesServiceAccount)(nil), // 14: teleport.workloadidentity.v1.JoinAttrsKubernetesServiceAccount (*JoinAttrsKubernetes)(nil), // 15: teleport.workloadidentity.v1.JoinAttrsKubernetes (*JoinAttrsOracle)(nil), // 16: teleport.workloadidentity.v1.JoinAttrsOracle + (*JoinAttrsAzureDevops)(nil), // 17: teleport.workloadidentity.v1.JoinAttrsAzureDevops + (*JoinAttrsAzureDevopsPipeline)(nil), // 18: teleport.workloadidentity.v1.JoinAttrsAzureDevopsPipeline } var file_teleport_workloadidentity_v1_join_attrs_proto_depIdxs = []int32{ 1, // 0: teleport.workloadidentity.v1.JoinAttrs.meta:type_name -> teleport.workloadidentity.v1.JoinAttrsMeta @@ -1700,14 +1912,16 @@ var file_teleport_workloadidentity_v1_join_attrs_proto_depIdxs = []int32{ 12, // 10: teleport.workloadidentity.v1.JoinAttrs.gcp:type_name -> teleport.workloadidentity.v1.JoinAttrsGCP 15, // 11: teleport.workloadidentity.v1.JoinAttrs.kubernetes:type_name -> teleport.workloadidentity.v1.JoinAttrsKubernetes 16, // 12: teleport.workloadidentity.v1.JoinAttrs.oracle:type_name -> teleport.workloadidentity.v1.JoinAttrsOracle - 11, // 13: teleport.workloadidentity.v1.JoinAttrsGCP.gce:type_name -> teleport.workloadidentity.v1.JoinAttrsGCPGCE - 14, // 14: teleport.workloadidentity.v1.JoinAttrsKubernetes.service_account:type_name -> teleport.workloadidentity.v1.JoinAttrsKubernetesServiceAccount - 13, // 15: teleport.workloadidentity.v1.JoinAttrsKubernetes.pod:type_name -> teleport.workloadidentity.v1.JoinAttrsKubernetesPod - 16, // [16:16] is the sub-list for method output_type - 16, // [16:16] is the sub-list for method input_type - 16, // [16:16] is the sub-list for extension type_name - 16, // [16:16] is the sub-list for extension extendee - 0, // [0:16] is the sub-list for field type_name + 17, // 13: teleport.workloadidentity.v1.JoinAttrs.azure_devops:type_name -> teleport.workloadidentity.v1.JoinAttrsAzureDevops + 11, // 14: teleport.workloadidentity.v1.JoinAttrsGCP.gce:type_name -> teleport.workloadidentity.v1.JoinAttrsGCPGCE + 14, // 15: teleport.workloadidentity.v1.JoinAttrsKubernetes.service_account:type_name -> teleport.workloadidentity.v1.JoinAttrsKubernetesServiceAccount + 13, // 16: teleport.workloadidentity.v1.JoinAttrsKubernetes.pod:type_name -> teleport.workloadidentity.v1.JoinAttrsKubernetesPod + 18, // 17: teleport.workloadidentity.v1.JoinAttrsAzureDevops.pipeline:type_name -> teleport.workloadidentity.v1.JoinAttrsAzureDevopsPipeline + 18, // [18:18] is the sub-list for method output_type + 18, // [18:18] is the sub-list for method input_type + 18, // [18:18] is the sub-list for extension type_name + 18, // [18:18] is the sub-list for extension extendee + 0, // [0:18] is the sub-list for field type_name } func init() { file_teleport_workloadidentity_v1_join_attrs_proto_init() } @@ -1721,7 +1935,7 @@ func file_teleport_workloadidentity_v1_join_attrs_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_workloadidentity_v1_join_attrs_proto_rawDesc), len(file_teleport_workloadidentity_v1_join_attrs_proto_rawDesc)), NumEnums: 0, - NumMessages: 17, + NumMessages: 19, NumExtensions: 0, NumServices: 0, }, diff --git a/api/proto/teleport/workloadidentity/v1/join_attrs.proto b/api/proto/teleport/workloadidentity/v1/join_attrs.proto index 4fa2d0c1f9909..ee78bed71402b 100644 --- a/api/proto/teleport/workloadidentity/v1/join_attrs.proto +++ b/api/proto/teleport/workloadidentity/v1/join_attrs.proto @@ -47,6 +47,8 @@ message JoinAttrs { JoinAttrsKubernetes kubernetes = 12; // Attributes that are specific to the Oracle (`oracle`) join method. JoinAttrsOracle oracle = 13; + // Attributes that are specific to the Azure Devops (`azure_devops`) join method. + JoinAttrsAzureDevops azure_devops = 14; } // The collection of attributes that result from the join process but are not @@ -322,3 +324,37 @@ message JoinAttrsOracle { // The ID of the instance. string instance_id = 3; } + +// Attributes that are specific to the Azure Devops (`azure_devops`) join method. +message JoinAttrsAzureDevops { + // Attributes specific to joins that occur with the pipeline ID token. + JoinAttrsAzureDevopsPipeline pipeline = 1; +} + +// Attributes that are specific to the Azure DevOps join method when the +// pipeline ID token is used for authentication +message JoinAttrsAzureDevopsPipeline { + // The `sub` claim of the Azure DevOps pipeline ID token that was used to join. + string sub = 1; + // The name of the organization that the pipeline is running within. + string organization_name = 2; + // The name of the project that the pipeline is running within. + string project_name = 3; + // The name of the pipeline that is running. + string pipeline_name = 4; + // The ID of the organization that the pipeline is running within. + string organization_id = 5; + // The ID of the project that the pipeline is running within. + string project_id = 6; + // The ID of the pipeline that is running. + string definition_id = 7; + // The ID of the repository that the pipeline is running within. + string repository_id = 8; + // The version of the repository that the pipeline is running against. + // For Git this will be the commit SHA. + string repository_version = 9; + // The ref of the repository that the pipeline is running against. + string repository_ref = 10; + // The ID of the run that is being executed. + string run_id = 11; +} diff --git a/go.mod b/go.mod index a2522506c5e01..2b6eef87fe1bd 100644 --- a/go.mod +++ b/go.mod @@ -112,6 +112,7 @@ require ( github.com/ghodss/yaml v1.0.0 github.com/go-git/go-git/v5 v5.16.0 github.com/go-jose/go-jose/v3 v3.0.4 + github.com/go-jose/go-jose/v4 v4.0.5 github.com/go-ldap/ldap/v3 v3.4.11 github.com/go-logr/logr v1.4.2 github.com/go-mysql-org/go-mysql v1.9.1 // replaced @@ -359,7 +360,6 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect - github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/analysis v0.23.0 // indirect diff --git a/lib/azuredevops/azuredevops.go b/lib/azuredevops/azuredevops.go new file mode 100644 index 0000000000000..1abb290966daa --- /dev/null +++ b/lib/azuredevops/azuredevops.go @@ -0,0 +1,90 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package azuredevops + +import ( + "github.com/zitadel/oidc/v3/pkg/oidc" + + workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" +) + +// IDTokenClaims for the pipeline OIDC ID Token issued by Azure Devops +type IDTokenClaims struct { + oidc.TokenClaims + // Sub provides some information about the Azure Devops pipeline run. + // Example: + // p://noahstride0304/testing-azure-devops-join/strideynet.azure-devops-testing + Sub string `json:"sub"` + // OrganizationName is the name of the Azure Devops organization the project + // and pipeline belongs to. This name is extracted from the Sub. + OrganizationName string `json:"-"` + // ProjectName is the name of the Azure Devops project the pipeline belongs + // to. This name is extracted from the Sub. + ProjectName string `json:"-"` + // PipelineName is the name of the Azure Devops pipeline that the token + // belongs to. This name is extracted from the Sub. + PipelineName string `json:"-"` + + // OrganizationID is the ID of the organization the pipeline belongs to. + OrganizationID string `json:"org_id"` + // ProjectID is the ID of the project the pipeline belongs to. + ProjectID string `json:"prj_id"` + // DefinitionID is the ID of the pipeline definition. + DefinitionID string `json:"def_id"` + // RepositoryID is the ID of the repository. This is not a UUID as the other + // ID fields. Example: + // strideynet/azure-devops-testing + RepositoryID string `json:"rpo_id"` + // RepositoryURI is the URI of the repository. + RepositoryURI string `json:"rpo_uri"` + // RepositoryVersion is the "version" of the repository the pipeline is + // running against. For a git repo, this is the commit sha. + RepositoryVersion string `json:"rpo_ver"` + // RepositoryRef is the reference that the pipeline is running + // against. Example: + // refs/heads/main + RepositoryRef string `json:"rpo_ref"` + // RunID is the ID of the pipeline run that the token belongs to. + RunID string `json:"run_id"` +} + +func (c *IDTokenClaims) GetSubject() string { + return c.Sub +} + +// JoinAttrs returns the protobuf representation of the attested identity. +// This is used for auditing and for evaluation of WorkloadIdentity rules and +// templating. +func (c *IDTokenClaims) JoinAttrs() *workloadidentityv1pb.JoinAttrsAzureDevops { + return &workloadidentityv1pb.JoinAttrsAzureDevops{ + Pipeline: &workloadidentityv1pb.JoinAttrsAzureDevopsPipeline{ + Sub: c.Sub, + OrganizationName: c.OrganizationName, + ProjectName: c.ProjectName, + PipelineName: c.PipelineName, + OrganizationId: c.OrganizationID, + ProjectId: c.ProjectID, + DefinitionId: c.DefinitionID, + RepositoryId: c.RepositoryID, + RepositoryVersion: c.RepositoryVersion, + RepositoryRef: c.RepositoryRef, + RunId: c.RunID, + }, + } +} diff --git a/lib/azuredevops/token_source.go b/lib/azuredevops/token_source.go new file mode 100644 index 0000000000000..0b21970481815 --- /dev/null +++ b/lib/azuredevops/token_source.go @@ -0,0 +1,122 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package azuredevops + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/gravitational/trace" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" +) + +// IDTokenSource allows a Azure Devops OIDC token to be fetched whilst within a +// Pipelines execution. +type IDTokenSource struct { + // getEnv is a function that returns a string from the environment, usually + // os.Getenv except in tests. + getEnv func(key string) string + httpClient *http.Client +} + +// GetIDToken attempts to fetch a Azure Devops OIDC token from the environment. +func (its *IDTokenSource) GetIDToken(ctx context.Context) (string, error) { + tok := its.getEnv("SYSTEM_ACCESSTOKEN") + if tok == "" { + return "", trace.BadParameter( + "SYSTEM_ACCESSTOKEN environment variable missing", + ) + } + + rawBaseURL := its.getEnv("SYSTEM_OIDCREQUESTURI") + if rawBaseURL == "" { + return "", trace.BadParameter( + "SYSTEM_OIDCREQUESTURI environment variable missing", + ) + } + + idToken, err := its.exchangeToken(ctx, tok, rawBaseURL) + if err != nil { + return "", trace.Wrap(err, "exchanging token") + } + + return idToken, nil +} + +// See https://learn.microsoft.com/en-us/rest/api/azure/devops/distributedtask/oidctoken/create?view=azure-devops-rest-7.1&preserve-view=true +type createOidctokenResp struct { + OIDCToken string `json:"oidcToken"` +} + +func (its *IDTokenSource) exchangeToken( + ctx context.Context, accessToken string, rawBaseURL string, +) (string, error) { + // Exchange Access Token for OIDC token using Oidctoken - Create API + // https://learn.microsoft.com/en-us/rest/api/azure/devops/distributedtask/oidctoken/create?view=azure-devops-rest-7.1&preserve-view=true + apiURL, err := url.Parse(rawBaseURL) + if err != nil { + return "", trace.Wrap(err, "parsing base URL") + } + query := apiURL.Query() + query.Set("api-version", "7.1") + apiURL.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, apiURL.String(), nil, + ) + if err != nil { + return "", trace.Wrap(err, "creating request for token") + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Set("Content-Type", "application/json") + + res, err := its.httpClient.Do(req) + if err != nil { + return "", trace.Wrap(err, "making request for token") + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return "", trace.BadParameter( + "received status code %d, expected 200", res.StatusCode, + ) + } + + var data createOidctokenResp + if err := json.NewDecoder(res.Body).Decode(&data); err != nil { + return "", trace.Wrap(err) + } + + if data.OIDCToken == "" { + return "", trace.BadParameter("resp did not include oidc token") + } + return data.OIDCToken, nil +} + +// NewIDTokenSource builds a helper that can extract a Azure Devops OIDC token +// from the environment, using `getEnv`. +func NewIDTokenSource(getEnv func(key string) string) *IDTokenSource { + return &IDTokenSource{ + getEnv: getEnv, + httpClient: otelhttp.DefaultClient, + } +} diff --git a/lib/azuredevops/token_source_test.go b/lib/azuredevops/token_source_test.go new file mode 100644 index 0000000000000..144cb721c270b --- /dev/null +++ b/lib/azuredevops/token_source_test.go @@ -0,0 +1,67 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package azuredevops + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIDTokenSource(t *testing.T) { + t.Parallel() + ctx := context.Background() + + mux := http.NewServeMux() + mux.HandleFunc("/oidctoken", func(w http.ResponseWriter, req *http.Request) { + // Check the request + require.Equal(t, http.MethodPost, req.Method) + authHeader := req.Header.Get("Authorization") + require.NotEmpty(t, authHeader) + require.Equal(t, "Bearer FAKE_ACCESS_TOKEN", authHeader) + require.Equal(t, "7.1", req.URL.Query().Get("api-version")) + // Send response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + resp := createOidctokenResp{ + OIDCToken: "FAKE_ID_TOKEN", + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + fakeEnv := map[string]string{ + "SYSTEM_ACCESSTOKEN": "FAKE_ACCESS_TOKEN", + "SYSTEM_OIDCREQUESTURI": srv.URL + "/oidctoken", + } + getFakeEnv := func(key string) string { + return fakeEnv[key] + } + + idTokenSource := NewIDTokenSource(getFakeEnv) + + got, err := idTokenSource.GetIDToken(ctx) + require.NoError(t, err) + require.Equal(t, "FAKE_ID_TOKEN", got) +} diff --git a/lib/azuredevops/token_validator.go b/lib/azuredevops/token_validator.go new file mode 100644 index 0000000000000..c2b35f83a1642 --- /dev/null +++ b/lib/azuredevops/token_validator.go @@ -0,0 +1,156 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package azuredevops + +import ( + "context" + "fmt" + "net/url" + "strings" + "time" + + "github.com/gravitational/trace" + "github.com/zitadel/oidc/v3/pkg/client" + "github.com/zitadel/oidc/v3/pkg/client/rp" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" +) + +// providerTimeout is the maximum time allowed to fetch provider metadata before +// giving up. +const providerTimeout = 15 * time.Second + +// audience is the static value that Azure DevOps uses for the `aud` claim in +// issued ID Tokens. Unfortunately, this cannot be changed. +const audience = "api://AzureADTokenExchange" + +func issuerURL( + organizationID string, + overrideHost string, + insecure bool, +) string { + scheme := "https" + if insecure { + scheme = "http" + } + host := "vstoken.dev.azure.com" + if overrideHost != "" { + host = overrideHost + } + issuerURL := url.URL{ + Scheme: scheme, + Host: host, + Path: fmt.Sprintf("/%s", organizationID), + } + return issuerURL.String() +} + +// IDTokenValidator validates an Azure Devops issued ID Token. +type IDTokenValidator struct { + insecureDiscovery bool + overrideDiscoveryHost string +} + +// NewIDTokenValidator returns an initialized IDTokenValidator +func NewIDTokenValidator() *IDTokenValidator { + return &IDTokenValidator{} +} + +// Validate validates an Azure Devops issued ID token. +func (id *IDTokenValidator) Validate( + ctx context.Context, organizationID, token string, +) (*IDTokenClaims, error) { + timeoutCtx, cancel := context.WithTimeout(ctx, providerTimeout) + defer cancel() + + issuer := issuerURL( + organizationID, id.overrideDiscoveryHost, id.insecureDiscovery, + ) + // TODO(noah): It'd be nice to cache the OIDC discovery document fairly + // aggressively across join tokens since this isn't going to change very + // regularly. + dc, err := client.Discover(timeoutCtx, issuer, otelhttp.DefaultClient) + if err != nil { + return nil, trace.Wrap(err, "discovering oidc document") + } + + // TODO(noah): Ideally we'd cache the remote keyset across joins/join tokens + // based on the issuer. + ks := rp.NewRemoteKeySet(otelhttp.DefaultClient, dc.JwksURI) + verifier := rp.NewIDTokenVerifier(issuer, audience, ks) + // TODO(noah): It'd be ideal if we could extend the verifier to use an + // injected "now" time. + + claims, err := rp.VerifyIDToken[*IDTokenClaims](timeoutCtx, token, verifier) + if err != nil { + return nil, trace.Wrap(err, "verifying token") + } + + parsed, err := parseSubClaim(claims.Sub) + if err != nil { + return nil, trace.Wrap(err, "parsing sub claim") + } + claims.OrganizationName = parsed.organizationName + claims.ProjectName = parsed.projectName + claims.PipelineName = parsed.pipelineName + + if claims.OrganizationID != organizationID { + return nil, trace.BadParameter( + "organization ID in token (%s) does not match configured (%s)", + claims.OrganizationID, organizationID, + ) + } + + return claims, nil +} + +type parsedSubClaim struct { + organizationName string + projectName string + pipelineName string +} + +func parseSubClaim(sub string) (parsedSubClaim, error) { + parsed, err := url.Parse(sub) + if err != nil { + return parsedSubClaim{}, trace.Wrap(err, "parsing as url") + } + + // Special p:// scheme indicates this is a Pipeline ID token rather than + // a service connection ID token (which starts sc://). + if parsed.Scheme != "p" { + return parsedSubClaim{}, trace.BadParameter( + "id token is not of pipeline kind (sub: %q)", sub, + ) + } + + out := parsedSubClaim{organizationName: parsed.Host} + // Now we need to handle the path, which is something like + // /project-name/pipeline-name + path, _ := strings.CutPrefix(parsed.Path, "/") + split := strings.Split(path, "/") + if len(split) != 2 { + return parsedSubClaim{}, trace.BadParameter( + "subject malformed, expected 2 path elements (sub: %q)", sub, + ) + } + out.projectName = split[0] + out.pipelineName = split[1] + + return out, nil +} diff --git a/lib/azuredevops/token_validator_test.go b/lib/azuredevops/token_validator_test.go new file mode 100644 index 0000000000000..b31397c29f846 --- /dev/null +++ b/lib/azuredevops/token_validator_test.go @@ -0,0 +1,279 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package azuredevops + +import ( + "context" + "crypto" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v3/pkg/oidc" + + "github.com/gravitational/teleport/lib/cryptosuites" +) + +type fakeIDP struct { + t *testing.T + signer jose.Signer + publicKey crypto.PublicKey + server *httptest.Server + kid string +} + +func newFakeIDP(t *testing.T) *fakeIDP { + privateKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.RSA2048) + require.NoError(t, err) + + kid := "xyzzy" + + signer, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.RS256, Key: privateKey}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", kid), + ) + require.NoError(t, err) + + f := &fakeIDP{ + signer: signer, + publicKey: privateKey.Public(), + t: t, + kid: kid, + } + + providerMux := http.NewServeMux() + providerMux.HandleFunc( + "/{orgid}/.well-known/openid-configuration", + f.handleOpenIDConfig, + ) + providerMux.HandleFunc( + "/.well-known/jwks", + f.handleJWKSEndpoint, + ) + + srv := httptest.NewServer(providerMux) + t.Cleanup(srv.Close) + f.server = srv + return f +} + +func (f *fakeIDP) issuer(orgID string) string { + return f.server.URL + "/" + orgID +} + +func (f *fakeIDP) handleOpenIDConfig(w http.ResponseWriter, r *http.Request) { + response := map[string]interface{}{ + "claims_supported": []string{ + "sub", + "aud", + "exp", + "iat", + "iss", + "nbf", + }, + "id_token_signing_alg_values_supported": []string{"RS256"}, + "issuer": f.issuer(r.PathValue("orgid")), + "jwks_uri": f.server.URL + "/.well-known/jwks", + "response_types_supported": []string{"id_token"}, + "scopes_supported": []string{"openid"}, + "subject_types_supported": []string{"public", "pairwise"}, + } + responseBytes, err := json.Marshal(response) + require.NoError(f.t, err) + _, err = w.Write(responseBytes) + require.NoError(f.t, err) +} + +func (f *fakeIDP) handleJWKSEndpoint(w http.ResponseWriter, r *http.Request) { + responseBytes, err := f.jwks() + require.NoError(f.t, err) + _, err = w.Write(responseBytes) + require.NoError(f.t, err) +} + +func (f *fakeIDP) jwks() ([]byte, error) { + jwks := jose.JSONWebKeySet{ + Keys: []jose.JSONWebKey{ + { + Key: f.publicKey, + KeyID: f.kid, + }, + }, + } + responseBytes, err := json.Marshal(jwks) + if err != nil { + return nil, err + } + return responseBytes, nil +} + +func (f *fakeIDP) issueToken( + t *testing.T, + issuer, + audience, + sub string, + orgID string, + issuedAt time.Time, + expiry time.Time, +) string { + stdClaims := jwt.Claims{ + Issuer: issuer, + Subject: sub, + Audience: jwt.Audience{audience}, + IssuedAt: jwt.NewNumericDate(issuedAt), + NotBefore: jwt.NewNumericDate(issuedAt), + Expiry: jwt.NewNumericDate(expiry), + } + customClaims := map[string]interface{}{ + "org_id": orgID, + } + token, err := jwt.Signed(f.signer). + Claims(stdClaims). + Claims(customClaims). + Serialize() + require.NoError(t, err) + + return token +} + +func TestIDTokenValidator_Validate(t *testing.T) { + t.Parallel() + idp := newFakeIDP(t) + goodOrgId := "0000-1111-2222-3333-4444" + tests := []struct { + name string + assertError require.ErrorAssertionFunc + want *IDTokenClaims + token string + }{ + { + name: "success", + assertError: require.NoError, + token: idp.issueToken( + t, + idp.issuer(goodOrgId), + audience, + "p://noahstride0304/testing-azure-devops-join/strideynet.azure-devops-testing", + goodOrgId, + time.Now().Add(-5*time.Minute), + time.Now().Add(5*time.Minute), + ), + want: &IDTokenClaims{ + Sub: "p://noahstride0304/testing-azure-devops-join/strideynet.azure-devops-testing", + OrganizationID: goodOrgId, + OrganizationName: "noahstride0304", + ProjectName: "testing-azure-devops-join", + PipelineName: "strideynet.azure-devops-testing", + }, + }, + { + name: "expired", + assertError: require.Error, + token: idp.issueToken( + t, + idp.issuer(goodOrgId), + audience, + "p://noahstride0304/testing-azure-devops-join/strideynet.azure-devops-testing", + goodOrgId, + time.Now().Add(-15*time.Minute), + time.Now().Add(-5*time.Minute), + ), + }, + { + name: "future", + assertError: require.Error, + token: idp.issueToken( + t, + idp.issuer(goodOrgId), + audience, + "p://noahstride0304/testing-azure-devops-join/strideynet.azure-devops-testing", + goodOrgId, + time.Now().Add(10*time.Minute), + time.Now().Add(20*time.Minute), + ), + }, + { + name: "invalid issuer", + assertError: require.Error, + token: idp.issueToken( + t, + idp.issuer("0000-bad-0000"), + audience, + "p://noahstride0304/testing-azure-devops-join/strideynet.azure-devops-testing", + goodOrgId, + time.Now().Add(-5*time.Minute), + time.Now().Add(5*time.Minute), + ), + }, + { + name: "invalid audience", + assertError: require.Error, + token: idp.issueToken( + t, + idp.issuer(goodOrgId), + "wrong-audience", + "p://noahstride0304/testing-azure-devops-join/strideynet.azure-devops-testing", + goodOrgId, + time.Now().Add(-5*time.Minute), + time.Now().Add(5*time.Minute), + ), + }, + { + name: "mismatched org id", + assertError: func(t require.TestingT, err error, i ...interface{}) { + require.Error(t, err) + require.ErrorContains(t, err, "organization ID in token") + }, + token: idp.issueToken( + t, + idp.issuer(goodOrgId), + audience, + "p://noahstride0304/testing-azure-devops-join/strideynet.azure-devops-testing", + "bad-org-id", + time.Now().Add(-5*time.Minute), + time.Now().Add(5*time.Minute), + ), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + v := NewIDTokenValidator() + v.insecureDiscovery = true + v.overrideDiscoveryHost = idp.server.Listener.Addr().String() + + claims, err := v.Validate( + ctx, + goodOrgId, + tt.token, + ) + tt.assertError(t, err) + require.Empty(t, + cmp.Diff(claims, tt.want, cmpopts.IgnoreTypes(oidc.TokenClaims{})), + ) + }) + } +}