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{})),
+ )
+ })
+ }
+}