Repository: sigstore/fulcio
Pull request #1729 (2024-07-10 22:48:34)

CiProvider as a new OIDCIssuer type
This adds a new generic CI provider so that new CI providers can be added via configuration without any code changes. The existing CI providers will be migrated over.

Ref: https://github.com/sigstore/fulcio/issues/1111
Add back #1679

Signed-off-by: Javan lacerda <javanlacerda@google.com>




Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ff1fce5..e76d6dc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,13 @@
+# v1.5.1
+
+## Bug Fixes
+
+* Surface the right `Name()` from our principal. (#1726)
+
+## Contributors
+
+* Matt Moore
+
 # v1.5.0
 
 ## Features
diff --git a/pkg/certificate/extensions.go b/pkg/certificate/extensions.go
index 38f80d5..584aac9 100644
--- a/pkg/certificate/extensions.go
+++ b/pkg/certificate/extensions.go
@@ -69,69 +69,69 @@ type Extensions struct {
 	// Deprecated
 	// Triggering event of the Github Workflow. Matches the `event_name` claim of ID
 	// tokens from Github Actions
-	GithubWorkflowTrigger string // OID 1.3.6.1.4.1.57264.1.2
+	GithubWorkflowTrigger string `json:"GithubWorkflowTrigger,omitempty" yaml:"github-workflow-trigger,omitempty"` // OID 1.3.6.1.4.1.57264.1.2
 
 	// Deprecated
 	// SHA of git commit being built in Github Actions. Matches the `sha` claim of ID
 	// tokens from Github Actions
-	GithubWorkflowSHA string // OID 1.3.6.1.4.1.57264.1.3
+	GithubWorkflowSHA string `json:"GithubWorkflowSHA,omitempty" yaml:"github-workflow-sha,omitempty"` // OID 1.3.6.1.4.1.57264.1.3
 
 	// Deprecated
 	// Name of Github Actions Workflow. Matches the `workflow` claim of the ID
 	// tokens from Github Actions
-	GithubWorkflowName string // OID 1.3.6.1.4.1.57264.1.4
+	GithubWorkflowName string `json:"GithubWorkflowName,omitempty" yaml:"github-workflow-name,omitempty"` // OID 1.3.6.1.4.1.57264.1.4
 
 	// Deprecated
 	// Repository of the Github Actions Workflow. Matches the `repository` claim of the ID
 	// tokens from Github Actions
-	GithubWorkflowRepository string // OID 1.3.6.1.4.1.57264.1.5
+	GithubWorkflowRepository string `json:"GithubWorkflowRepository,omitempty" yaml:"github-workflow-repository,omitempty"` // OID 1.3.6.1.4.1.57264.1.5
 
 	// Deprecated
 	// Git Ref of the Github Actions Workflow. Matches the `ref` claim of the ID tokens
 	// from Github Actions
-	GithubWorkflowRef string // 1.3.6.1.4.1.57264.1.6
+	GithubWorkflowRef string `json:"GithubWorkflowRef,omitempty" yaml:"github-workflow-ref,omitempty"` // 1.3.6.1.4.1.57264.1.6
 
 	// Reference to specific build instructions that are responsible for signing.
-	BuildSignerURI string // 1.3.6.1.4.1.57264.1.9
+	BuildSignerURI string `json:"BuildSignerURI,omitempty" yaml:"build-signer-uri,omitempty"` // 1.3.6.1.4.1.57264.1.9
 
 	// Immutable reference to the specific version of the build instructions that is responsible for signing.
-	BuildSignerDigest string // 1.3.6.1.4.1.57264.1.10
+	BuildSignerDigest string `json:"BuildSignerDigest,omitempty" yaml:"build-signer-digest,omitempty"` // 1.3.6.1.4.1.57264.1.10
 
 	// Specifies whether the build took place in platform-hosted cloud infrastructure or customer/self-hosted infrastructure.
-	RunnerEnvironment string // 1.3.6.1.4.1.57264.1.11
+	RunnerEnvironment string `json:"RunnerEnvironment,omitempty" yaml:"runner-environment,omitempty"` // 1.3.6.1.4.1.57264.1.11
 
 	// Source repository URL that the build was based on.
-	SourceRepositoryURI string // 1.3.6.1.4.1.57264.1.12
+	SourceRepositoryURI string `json:"SourceRepositoryURI,omitempty" yaml:"source-repository-uri,omitempty"` // 1.3.6.1.4.1.57264.1.12
 
 	// Immutable reference to a specific version of the source code that the build was based upon.
-	SourceRepositoryDigest string // 1.3.6.1.4.1.57264.1.13
+	SourceRepositoryDigest string `json:"SourceRepositoryDigest,omitempty" yaml:"source-repository-digest,omitempty"` // 1.3.6.1.4.1.57264.1.13
 
 	// Source Repository Ref that the build run was based upon.
-	SourceRepositoryRef string // 1.3.6.1.4.1.57264.1.14
+	SourceRepositoryRef string `json:"SourceRepositoryRef,omitempty" yaml:"source-repository-ref,omitempty"` // 1.3.6.1.4.1.57264.1.14
 
 	// Immutable identifier for the source repository the workflow was based upon.
-	SourceRepositoryIdentifier string // 1.3.6.1.4.1.57264.1.15
+	SourceRepositoryIdentifier string `json:"SourceRepositoryIdentifier,omitempty" yaml:"source-repository-identifier,omitempty"` // 1.3.6.1.4.1.57264.1.15
 
 	// Source repository owner URL of the owner of the source repository that the build was based on.
-	SourceRepositoryOwnerURI string // 1.3.6.1.4.1.57264.1.16
+	SourceRepositoryOwnerURI string `json:"SourceRepositoryOwnerURI,omitempty" yaml:"source-repository-owner-uri,omitempty"` // 1.3.6.1.4.1.57264.1.16
 
 	// Immutable identifier for the owner of the source repository that the workflow was based upon.
-	SourceRepositoryOwnerIdentifier string // 1.3.6.1.4.1.57264.1.17
+	SourceRepositoryOwnerIdentifier string `json:"SourceRepositoryOwnerIdentifier,omitempty" yaml:"source-repository-owner-identifier,omitempty"` // 1.3.6.1.4.1.57264.1.17
 
 	// Build Config URL to the top-level/initiating build instructions.
-	BuildConfigURI string // 1.3.6.1.4.1.57264.1.18
+	BuildConfigURI string `json:"BuildConfigURI,omitempty" yaml:"build-config-uri,omitempty"` // 1.3.6.1.4.1.57264.1.18
 
 	// Immutable reference to the specific version of the top-level/initiating build instructions.
-	BuildConfigDigest string // 1.3.6.1.4.1.57264.1.19
+	BuildConfigDigest string `json:"BuildConfigDigest,omitempty" yaml:"build-config-digest,omitempty"` // 1.3.6.1.4.1.57264.1.19
 
 	// Event or action that initiated the build.
-	BuildTrigger string // 1.3.6.1.4.1.57264.1.20
+	BuildTrigger string `json:"BuildTrigger,omitempty" yaml:"build-trigger,omitempty"` // 1.3.6.1.4.1.57264.1.20
 
 	// Run Invocation URL to uniquely identify the build execution.
-	RunInvocationURI string // 1.3.6.1.4.1.57264.1.21
+	RunInvocationURI string `json:"RunInvocationURI,omitempty" yaml:"run-invocation-uri,omitempty"` // 1.3.6.1.4.1.57264.1.21
 
 	// Source repository visibility at the time of signing the certificate.
-	SourceRepositoryVisibilityAtSigning string // 1.3.6.1.4.1.57264.1.22
+	SourceRepositoryVisibilityAtSigning string `json:"SourceRepositoryVisibilityAtSigning,omitempty" yaml:"source-repository-visibility-at-signing,omitempty"` // 1.3.6.1.4.1.57264.1.22
 }
 
 func (e Extensions) Render() ([]pkix.Extension, error) {
diff --git a/pkg/challenges/challenges.go b/pkg/challenges/challenges.go
index dda3298..143fdaa 100644
--- a/pkg/challenges/challenges.go
+++ b/pkg/challenges/challenges.go
@@ -27,6 +27,7 @@ import (
 	"github.com/sigstore/fulcio/pkg/config"
 	"github.com/sigstore/fulcio/pkg/identity"
 	"github.com/sigstore/fulcio/pkg/identity/buildkite"
+	"github.com/sigstore/fulcio/pkg/identity/ciprovider"
 	"github.com/sigstore/fulcio/pkg/identity/email"
 	"github.com/sigstore/fulcio/pkg/identity/github"
 	"github.com/sigstore/fulcio/pkg/identity/gitlabcom"
@@ -75,6 +76,8 @@ func PrincipalFromIDToken(ctx context.Context, tok *oidc.IDToken) (identity.Prin
 		principal, err = uri.PrincipalFromIDToken(ctx, tok)
 	case config.IssuerTypeUsername:
 		principal, err = username.PrincipalFromIDToken(ctx, tok)
+	case config.IssuerTypeCIProvider:
+		principal, err = ciprovider.WorkflowPrincipalFromIDToken(ctx, tok)
 	default:
 		return nil, fmt.Errorf("unsupported issuer: %s", iss.Type)
 	}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 6a6aca7..6b5f01f 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -21,6 +21,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"html/template"
 	"net/http"
 	"net/url"
 	"os"
@@ -31,6 +32,7 @@ import (
 
 	"github.com/coreos/go-oidc/v3/oidc"
 	lru "github.com/hashicorp/golang-lru"
+	"github.com/sigstore/fulcio/pkg/certificate"
 	fulciogrpc "github.com/sigstore/fulcio/pkg/generated/protobuf"
 	"github.com/sigstore/fulcio/pkg/log"
 	"github.com/spiffe/go-spiffe/v2/spiffeid"
@@ -60,12 +62,33 @@ type FulcioConfig struct {
 	// * https://container.googleapis.com/v1/projects/mattmoor-credit/locations/us-west1-b/clusters/tenant-cluster
 	MetaIssuers map[string]OIDCIssuer `json:"MetaIssuers,omitempty" yaml:"meta-issuers,omitempty"`
 
+	// It defines metadata to be used for the CIProvider identity provider principal.
+	// The CI provider has a generic logic for ci providers, this metadata is used
+	// to define the right behavior for each ci provider that is defined
+	// on the configuration file
+	CIIssuerMetadata map[string]IssuerMetadata `json:"CIIssuerMetadata,omitempty" yaml:"ci-issuer-metadata,omitempty"`
+
 	// verifiers is a fixed mapping from our OIDCIssuers to their OIDC verifiers.
 	verifiers map[string][]*verifierWithConfig
 	// lru is an LRU cache of recently used verifiers for our meta issuers.
 	lru *lru.TwoQueueCache
 }
 
+type IssuerMetadata struct {
+	// Defaults contains key-value pairs that can be used for filling the templates from ExtensionTemplates
+	// If a key cannot be found on the token claims, the template will use the defaults
+	DefaultTemplateValues map[string]string `json:"DefaultTemplateValues,omitempty" yaml:"default-template-values,omitempty"`
+	// ExtensionTemplates contains a mapping between certificate extension and token claim
+	// Provide either strings following https://pkg.go.dev/text/template syntax,
+	// e.g "{{ .url }}/{{ .repository }}"
+	// or non-templated strings with token claim keys to be replaced,
+	// e.g "job_workflow_sha"
+	ExtensionTemplates certificate.Extensions `json:"ExtensionTemplates,omitempty" yaml:"extension-templates,omitempty"`
+	// Template for the Subject Alternative Name extension
+	// It's typically the same value as Build Signer URI
+	SubjectAlternativeNameTemplate string `json:"SubjectAlternativeNameTemplate,omitempty" yaml:"subject-alternative-name-template,omitempty"`
+}
+
 type OIDCIssuer struct {
 	// The expected issuer of an OIDC token
 	IssuerURL string `json:"IssuerURL,omitempty" yaml:"issuer-url,omitempty"`
@@ -74,6 +97,8 @@ type OIDCIssuer struct {
 	// Used to determine the subject of the certificate and if additional
 	// certificate values are needed
 	Type IssuerType `json:"Type" yaml:"type,omitempty"`
+	// CIProvider is an optional configuration to map token claims to extensions for CI workflows
+	CIProvider string `json:"CIProvider,omitempty" yaml:"ci-provider,omitempty"`
 	// Optional, if the issuer is in a different claim in the OIDC token
 	IssuerClaim string `json:"IssuerClaim,omitempty" yaml:"issuer-claim,omitempty"`
 	// The domain that must be present in the subject for 'uri' issuer types
@@ -284,6 +309,7 @@ const (
 	IssuerTypeSpiffe            = "spiffe"
 	IssuerTypeURI               = "uri"
 	IssuerTypeUsername          = "username"
+	IssuerTypeCIProvider        = "ci-provider"
 )
 
 func parseConfig(b []byte) (cfg *FulcioConfig, err error) {
@@ -391,7 +417,7 @@ func validateConfig(conf *FulcioConfig) error {
 		}
 	}
 
-	return nil
+	return validateCIIssuerMetadata(conf)
 }
 
 var DefaultConfig = &FulcioConfig{
@@ -432,6 +458,34 @@ func FromContext(ctx context.Context) *FulcioConfig {
 	return untyped.(*FulcioConfig)
 }
 
+// It checks that the templates defined are parseable
+// We should check it during the service bootstrap to avoid errors further
+func validateCIIssuerMetadata(fulcioConfig *FulcioConfig) error {
+
+	checkParse := func(temp string) error {
+		t := template.New("").Option("missingkey=error")
+		_, err := t.Parse(temp)
+		return err
+	}
+
+	for _, ciIssuerMetadata := range fulcioConfig.CIIssuerMetadata {
+		v := reflect.ValueOf(ciIssuerMetadata.ExtensionTemplates)
+		for i := 0; i < v.NumField(); i++ {
+			s := v.Field(i).String()
+			err := checkParse(s)
+			if err != nil {
+				return err
+			}
+		}
+
+		err := checkParse(ciIssuerMetadata.SubjectAlternativeNameTemplate)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 // Load a config from disk, or use defaults
 func Load(configPath string) (*FulcioConfig, error) {
 	if _, err := os.Stat(configPath); os.IsNotExist(err) {
@@ -516,6 +570,8 @@ func issuerToChallengeClaim(issType IssuerType, challengeClaim string) string {
 		return "email"
 	case IssuerTypeGithubWorkflow:
 		return "sub"
+	case IssuerTypeCIProvider:
+		return "sub"
 	case IssuerTypeCodefreshWorkflow:
 		return "sub"
 	case IssuerTypeChainguard:
diff --git a/pkg/identity/ciprovider/issuer.go b/pkg/identity/ciprovider/issuer.go
new file mode 100644
index 0000000..ce82d35
--- /dev/null
+++ b/pkg/identity/ciprovider/issuer.go
@@ -0,0 +1,39 @@
+// Copyright 2024 The Sigstore 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 ciprovider
+
+import (
+	"context"
+
+	"github.com/sigstore/fulcio/pkg/config"
+	"github.com/sigstore/fulcio/pkg/identity"
+	"github.com/sigstore/fulcio/pkg/identity/base"
+)
+
+type ciProviderIssuer struct {
+	identity.Issuer
+}
+
+func Issuer(issuerURL string) identity.Issuer {
+	return &ciProviderIssuer{base.Issuer(issuerURL)}
+}
+
+func (e *ciProviderIssuer) Authenticate(ctx context.Context, token string, opts ...config.InsecureOIDCConfigOption) (identity.Principal, error) {
+	idtoken, err := identity.Authorize(ctx, token, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return WorkflowPrincipalFromIDToken(ctx, idtoken)
+}
diff --git a/pkg/identity/ciprovider/principal.go b/pkg/identity/ciprovider/principal.go
new file mode 100644
index 0000000..fb94df3
--- /dev/null
+++ b/pkg/identity/ciprovider/principal.go
@@ -0,0 +1,157 @@
+// Copyright 2024 The Sigstore 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 ciprovider
+
+import (
+	"bytes"
+	"context"
+	"crypto/x509"
+	"fmt"
+	"html/template"
+	"net/url"
+	"reflect"
+	"strings"
+
+	"github.com/coreos/go-oidc/v3/oidc"
+	"github.com/sigstore/fulcio/pkg/config"
+	"github.com/sigstore/fulcio/pkg/identity"
+)
+
+func mapValuesToString(claims map[string]interface{}) map[string]string {
+	newMap := make(map[string]string)
+	for k, v := range claims {
+		newMap[k] = fmt.Sprintf("%s", v)
+	}
+	return newMap
+}
+
+func getTokenClaims(token *oidc.IDToken) (map[string]string, error) {
+	var tokenClaims map[string]interface{}
+	if err := token.Claims(&tokenClaims); err != nil {
+		return nil, err
+	}
+	return mapValuesToString(tokenClaims), nil
+}
+
+// It makes string interpolation for a given string by using the
+// templates syntax https://pkg.go.dev/text/template
+func applyTemplateOrReplace(extValueTemplate string, tokenClaims map[string]string, issuerMetadata map[string]string) (string, error) {
+
+	// Here we merge the data from was claimed by the id token with the
+	// default data provided by the yaml file.
+	// The order here matter because we want to override the claimed data
+	// with the default data.
+	// The default data will have priority over the claimed data.
+	mergedData := make(map[string]string)
+	for k, v := range tokenClaims {
+		mergedData[k] = v
+	}
+	for k, v := range issuerMetadata {
+		mergedData[k] = v
+	}
+
+	if strings.Contains(extValueTemplate, "{{") {
+		var doc bytes.Buffer
+		// This option forces to having the claim that is required
+		// for the template
+		t := template.New("").Option("missingkey=error")
+		// It shouldn't raise error since we already checked all
+		// templates in validateCIIssuerMetadata functions in config.go
+		p, err := t.Parse(extValueTemplate)
+		if err != nil {
+			return "", err
+		}
+		err = p.Execute(&doc, mergedData)
+		if err != nil {
+			return "", err
+		}
+		return doc.String(), nil
+	}
+	claimValue, ok := mergedData[extValueTemplate]
+	if !ok {
+		return "", fmt.Errorf("value <%s> not present in either claims or defaults", extValueTemplate)
+	}
+	return claimValue, nil
+}
+
+type ciPrincipal struct {
+	Token          *oidc.IDToken
+	ClaimsMetadata config.IssuerMetadata
+}
+
+func WorkflowPrincipalFromIDToken(ctx context.Context, token *oidc.IDToken) (identity.Principal, error) {
+	cfg := config.FromContext(ctx)
+	issuerCfg, ok := cfg.GetIssuer(token.Issuer)
+	if !ok {
+		return nil, fmt.Errorf("configuration can not be loaded for issuer %v", token.Issuer)
+	}
+	return ciPrincipal{
+		token,
+		cfg.CIIssuerMetadata[issuerCfg.CIProvider],
+	}, nil
+}
+
+func (principal ciPrincipal) Name(_ context.Context) string {
+	return principal.Token.Subject
+}
+
+func (principal ciPrincipal) Embed(_ context.Context, cert *x509.Certificate) error {
+
+	claimsTemplates := principal.ClaimsMetadata.ExtensionTemplates
+	defaults := principal.ClaimsMetadata.DefaultTemplateValues
+	claims, err := getTokenClaims(principal.Token)
+	if err != nil {
+		return err
+	}
+	subjectAlternativeName, err := applyTemplateOrReplace(principal.ClaimsMetadata.SubjectAlternativeNameTemplate, claims, defaults)
+	if err != nil {
+		return err
+	}
+	sanURL, err := url.Parse(subjectAlternativeName)
+	if err != nil {
+		return err
+	}
+	uris := []*url.URL{sanURL}
+	cert.URIs = uris
+	// We should use value.Elem() here as we need a
+	// addressable reference of the templates for applying the SetString().
+	v := reflect.ValueOf(&claimsTemplates).Elem()
+	// Type of the reflect value is needed as it is necessary
+	// for getting the field name.
+	vType := v.Type()
+	for i := 0; i < v.NumField(); i++ {
+		s := v.Field(i).String() // value of each field, e.g the template string
+		// We check the field name to avoid to apply the template for the Issuer
+		// Issuer field should always come from the token issuer
+		if s == "" || vType.Field(i).Name == "Issuer" {
+			continue
+		}
+		extValue, err := applyTemplateOrReplace(s, claims, defaults)
+		if err != nil {
+			return err
+		}
+		v.Field(i).SetString(extValue)
+	}
+
+	// Guarantees to set the extension issuer as the token issuer
+	// regardless of whether this field has been set before
+	claimsTemplates.Issuer = principal.Token.Issuer
+	// Embed additional information into custom extensions
+	cert.ExtraExtensions, err = claimsTemplates.Render()
+	if err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/pkg/server/issuer_pool.go b/pkg/server/issuer_pool.go
index 18349f2..61e05fa 100644
--- a/pkg/server/issuer_pool.go
+++ b/pkg/server/issuer_pool.go
@@ -19,6 +19,7 @@ import (
 	"github.com/sigstore/fulcio/pkg/identity"
 	"github.com/sigstore/fulcio/pkg/identity/buildkite"
 	"github.com/sigstore/fulcio/pkg/identity/chainguard"
+	"github.com/sigstore/fulcio/pkg/identity/ciprovider"
 	"github.com/sigstore/fulcio/pkg/identity/codefresh"
 	"github.com/sigstore/fulcio/pkg/identity/email"
 	"github.com/sigstore/fulcio/pkg/identity/github"
@@ -57,6 +58,8 @@ func getIssuer(meta string, i config.OIDCIssuer) identity.Issuer {
 		return email.Issuer(issuerURL)
 	case config.IssuerTypeGithubWorkflow:
 		return github.Issuer(issuerURL)
+	case config.IssuerTypeCIProvider:
+		return ciprovider.Issuer(issuerURL)
 	case config.IssuerTypeGitLabPipeline:
 		return gitlabcom.Issuer(issuerURL)
 	case config.IssuerTypeBuildkiteJob:


Test patch:
diff --git a/pkg/config/config_network_test.go b/pkg/config/config_network_test.go
index 5280818..4e00720 100644
--- a/pkg/config/config_network_test.go
+++ b/pkg/config/config_network_test.go
@@ -25,6 +25,7 @@ import (
 
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
+	"github.com/sigstore/fulcio/pkg/certificate"
 )
 
 func TestLoad(t *testing.T) {
@@ -68,6 +69,61 @@ func TestLoad(t *testing.T) {
 	}
 }
 
+func TestParseTemplate(t *testing.T) {
+
+	validTemplate := "{{.foobar}}"
+	invalidTemplate := "{{.foobar}"
+	ciissuerMetadata := make(map[string]IssuerMetadata)
+	ciissuerMetadata["github"] = IssuerMetadata{
+		ExtensionTemplates: certificate.Extensions{
+			BuildTrigger: invalidTemplate,
+		},
+	}
+	fulcioConfig := &FulcioConfig{
+		CIIssuerMetadata: ciissuerMetadata,
+	}
+	// BuildTrigger as a invalid template should raise an error
+	err := validateCIIssuerMetadata(fulcioConfig)
+	if err == nil {
+		t.Error("invalid template should raise an error")
+	}
+	ciissuerMetadata["github"] = IssuerMetadata{
+		ExtensionTemplates: certificate.Extensions{
+			BuildTrigger: validTemplate,
+		},
+	}
+	fulcioConfig = &FulcioConfig{
+		CIIssuerMetadata: ciissuerMetadata,
+	}
+	// BuildTrigger as a valid template shouldn't raise an error
+	err = validateCIIssuerMetadata(fulcioConfig)
+	if err != nil {
+		t.Error("valid template shouldn't raise an error, error: %w", err)
+	}
+	ciissuerMetadata["github"] = IssuerMetadata{
+		SubjectAlternativeNameTemplate: invalidTemplate,
+	}
+	fulcioConfig = &FulcioConfig{
+		CIIssuerMetadata: ciissuerMetadata,
+	}
+	// A SAN as a invalid template should raise an error
+	err = validateCIIssuerMetadata(fulcioConfig)
+	if err == nil {
+		t.Error("invalid SAN should raise an error")
+	}
+	ciissuerMetadata["github"] = IssuerMetadata{
+		SubjectAlternativeNameTemplate: invalidTemplate,
+	}
+	fulcioConfig = &FulcioConfig{
+		CIIssuerMetadata: ciissuerMetadata,
+	}
+	// A SAN as a valid template should raise an error
+	err = validateCIIssuerMetadata(fulcioConfig)
+	if err == nil {
+		t.Error("valid SAN shouldn't raise an error")
+	}
+}
+
 func TestLoadDefaults(t *testing.T) {
 	td := t.TempDir()
 
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 4c09676..390bd6f 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -492,6 +492,9 @@ func Test_issuerToChallengeClaim(t *testing.T) {
 	if claim := issuerToChallengeClaim(IssuerTypeGithubWorkflow, ""); claim != "sub" {
 		t.Fatalf("expected sub subject claim for GitHub issuer, got %s", claim)
 	}
+	if claim := issuerToChallengeClaim(IssuerTypeCIProvider, ""); claim != "sub" {
+		t.Fatalf("expected sub subject claim for CI issuer, got %s", claim)
+	}
 	if claim := issuerToChallengeClaim(IssuerTypeGitLabPipeline, ""); claim != "sub" {
 		t.Fatalf("expected sub subject claim for GitLab issuer, got %s", claim)
 	}
diff --git a/pkg/identity/ciprovider/issuer_test.go b/pkg/identity/ciprovider/issuer_test.go
new file mode 100644
index 0000000..1d8e605
--- /dev/null
+++ b/pkg/identity/ciprovider/issuer_test.go
@@ -0,0 +1,100 @@
+// Copyright 2024 The Sigstore 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 ciprovider
+
+import (
+	"context"
+	"encoding/json"
+	"testing"
+
+	"github.com/coreos/go-oidc/v3/oidc"
+	"github.com/sigstore/fulcio/pkg/config"
+	"github.com/sigstore/fulcio/pkg/identity"
+)
+
+func TestIssuer(t *testing.T) {
+	ctx := context.Background()
+	url := "test-issuer-url"
+	issuer := Issuer(url)
+
+	// test the Match function
+	t.Run("match", func(t *testing.T) {
+		if matches := issuer.Match(ctx, url); !matches {
+			t.Fatal("expected url to match but it doesn't")
+		}
+		if matches := issuer.Match(ctx, "some-other-url"); matches {
+			t.Fatal("expected match to fail but it didn't")
+		}
+	})
+
+	t.Run("authenticate", func(t *testing.T) {
+		token := &oidc.IDToken{
+			Issuer:  "https://iss.example.com",
+			Subject: "repo:sigstore/fulcio:ref:refs/heads/main",
+		}
+		claims, err := json.Marshal(map[string]interface{}{
+			"aud":                   "sigstore",
+			"event_name":            "push",
+			"exp":                   0,
+			"iss":                   "https://token.actions.githubusercontent.com",
+			"job_workflow_ref":      "sigstore/fulcio/.github/workflows/foo.yaml@refs/heads/main",
+			"job_workflow_sha":      "example-sha",
+			"ref":                   "refs/heads/main",
+			"repository":            "sigstore/fulcio",
+			"repository_id":         "12345",
+			"repository_owner":      "username",
+			"repository_owner_id":   "345",
+			"repository_visibility": "public",
+			"run_attempt":           "1",
+			"run_id":                "42",
+			"runner_environment":    "cloud-hosted",
+			"sha":                   "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+			"sub":                   "repo:sigstore/fulcio:ref:refs/heads/main",
+			"workflow":              "foo",
+			"workflow_ref":          "sigstore/other/.github/workflows/foo.yaml@refs/heads/main",
+			"workflow_sha":          "example-sha-other",
+		})
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		withClaims(token, claims)
+		ctx := context.TODO()
+		OIDCIssuers :=
+			map[string]config.OIDCIssuer{
+				token.Issuer: {
+					IssuerURL:  token.Issuer,
+					Type:       config.IssuerTypeCIProvider,
+					CIProvider: "github-workflow",
+					ClientID:   "sigstore",
+				},
+			}
+		cfg := &config.FulcioConfig{
+			OIDCIssuers: OIDCIssuers,
+		}
+		ctx = config.With(ctx, cfg)
+		identity.Authorize = func(_ context.Context, _ string, _ ...config.InsecureOIDCConfigOption) (*oidc.IDToken, error) {
+			return token, nil
+		}
+		principal, err := issuer.Authenticate(ctx, "token")
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if principal.Name(ctx) != "repo:sigstore/fulcio:ref:refs/heads/main" {
+			t.Fatalf("got unexpected name %s", principal.Name(ctx))
+		}
+	})
+}
diff --git a/pkg/identity/ciprovider/principal_test.go b/pkg/identity/ciprovider/principal_test.go
new file mode 100644
index 0000000..aa387f9
--- /dev/null
+++ b/pkg/identity/ciprovider/principal_test.go
@@ -0,0 +1,422 @@
+// Copyright 2024 The Sigstore 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 ciprovider
+
+import (
+	"bytes"
+	"context"
+	"crypto/x509"
+	"encoding/asn1"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"reflect"
+	"testing"
+	"unsafe"
+
+	"github.com/coreos/go-oidc/v3/oidc"
+	"github.com/sigstore/fulcio/pkg/certificate"
+	"github.com/sigstore/fulcio/pkg/config"
+)
+
+func TestWorkflowPrincipalFromIDToken(t *testing.T) {
+	tests := map[string]struct {
+		ExpectedPrincipal ciPrincipal
+	}{
+		`Github workflow challenge should have all Github workflow extensions and issuer set`: {
+			ExpectedPrincipal: ciPrincipal{
+				ClaimsMetadata: config.IssuerMetadata{
+					ExtensionTemplates: certificate.Extensions{
+						Issuer:                              "issuer",
+						GithubWorkflowTrigger:               "event_name",
+						GithubWorkflowSHA:                   "sha",
+						GithubWorkflowName:                  "workflow",
+						GithubWorkflowRepository:            "repository",
+						GithubWorkflowRef:                   "ref",
+						BuildSignerURI:                      "{{ .url }}/{{ .job_workflow_ref }}",
+						BuildSignerDigest:                   "job_workflow_sha",
+						RunnerEnvironment:                   "runner_environment",
+						SourceRepositoryURI:                 "{{ .url }}/{{ .repository }}",
+						SourceRepositoryDigest:              "sha",
+						SourceRepositoryRef:                 "ref",
+						SourceRepositoryIdentifier:          "repository_id",
+						SourceRepositoryOwnerURI:            "{{ .url }}/{{ .repository_owner }}",
+						SourceRepositoryOwnerIdentifier:     "repository_owner_id",
+						BuildConfigURI:                      "{{ .url }}/{{ .workflow_ref }}",
+						BuildConfigDigest:                   "workflow_sha",
+						BuildTrigger:                        "event_name",
+						RunInvocationURI:                    "{{ .url }}/{{ .repository }}/actions/runs/{{ .run_id }}/attempts/{{ .run_attempt }}",
+						SourceRepositoryVisibilityAtSigning: "repository_visibility",
+					},
+					DefaultTemplateValues: map[string]string{
+						"url": "https://github.com",
+					},
+					SubjectAlternativeNameTemplate: "{{.url}}/{{.job_workflow_ref}}",
+				},
+			},
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			claims, err := json.Marshal(map[string]interface{}{
+				"issuer":                "https://token.actions.githubusercontent.com",
+				"event_name":            "trigger",
+				"sha":                   "sha",
+				"workflow":              "workflowname",
+				"repository":            "repository",
+				"ref":                   "ref",
+				"job_workflow_sha":      "jobWorkflowSha",
+				"job_workflow_ref":      "jobWorkflowRef",
+				"runner_environment":    "runnerEnv",
+				"repository_id":         "repoID",
+				"repository_owner":      "repoOwner",
+				"repository_owner_id":   "repoOwnerID",
+				"workflow_ref":          "workflowRef",
+				"workflow_sha":          "workflowSHA",
+				"run_id":                "runID",
+				"run_attempt":           "runAttempt",
+				"repository_visibility": "public",
+			})
+			if err != nil {
+				t.Fatal(err)
+			}
+			token := &oidc.IDToken{}
+			withClaims(token, claims)
+
+			test.ExpectedPrincipal.Token = token
+			ctx := context.TODO()
+			OIDCIssuers :=
+				map[string]config.OIDCIssuer{
+					token.Issuer: {
+						IssuerURL:  token.Issuer,
+						Type:       config.IssuerTypeCIProvider,
+						CIProvider: "github-workflow",
+						ClientID:   "sigstore",
+					},
+				}
+			meta := make(map[string]config.IssuerMetadata)
+			meta["github-workflow"] = test.ExpectedPrincipal.ClaimsMetadata
+			cfg := &config.FulcioConfig{
+				OIDCIssuers:      OIDCIssuers,
+				CIIssuerMetadata: meta,
+			}
+			ctx = config.With(ctx, cfg)
+			principal, err := WorkflowPrincipalFromIDToken(ctx, token)
+			if err != nil {
+				t.Fatal(err)
+			}
+			if !reflect.DeepEqual(principal, test.ExpectedPrincipal) {
+				t.Error("Principals should be equals")
+			}
+		})
+	}
+
+}
+
+// reflect hack because "claims" field is unexported by oidc IDToken
+// https://github.com/coreos/go-oidc/pull/329
+func withClaims(token *oidc.IDToken, data []byte) {
+	val := reflect.Indirect(reflect.ValueOf(token))
+	member := val.FieldByName("claims")
+	pointer := unsafe.Pointer(member.UnsafeAddr())
+	realPointer := (*[]byte)(pointer)
+	*realPointer = data
+}
+
+func TestName(t *testing.T) {
+	tests := map[string]struct {
+		Claims     map[string]interface{}
+		ExpectName string
+	}{
+		`Valid token authenticates with correct claims`: {
+			Claims: map[string]interface{}{
+				"aud":                   "sigstore",
+				"event_name":            "push",
+				"exp":                   "0",
+				"iss":                   "https://token.actions.githubusercontent.com",
+				"job_workflow_ref":      "sigstore/fulcio/.github/workflows/foo.yaml@refs/heads/main",
+				"job_workflow_sha":      "example-sha",
+				"ref":                   "refs/heads/main",
+				"repository":            "sigstore/fulcio",
+				"repository_id":         "12345",
+				"repository_owner":      "username",
+				"repository_owner_id":   "345",
+				"repository_visibility": "public",
+				"run_attempt":           "1",
+				"run_id":                "42",
+				"runner_environment":    "cloud-hosted",
+				"sha":                   "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+				"sub":                   "repo:sigstore/fulcio:ref:refs/heads/main",
+				"workflow":              "foo",
+				"workflow_ref":          "sigstore/other/.github/workflows/foo.yaml@refs/heads/main",
+				"workflow_sha":          "example-sha-other",
+			},
+			ExpectName: "repo:sigstore/fulcio:ref:refs/heads/main",
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			token := &oidc.IDToken{
+				Issuer:  test.Claims["iss"].(string),
+				Subject: test.Claims["sub"].(string),
+			}
+			claims, err := json.Marshal(test.Claims)
+			if err != nil {
+				t.Fatal(err)
+			}
+			withClaims(token, claims)
+			ctx := context.TODO()
+			OIDCIssuers :=
+				map[string]config.OIDCIssuer{
+					token.Issuer: {
+						IssuerURL:  token.Issuer,
+						Type:       config.IssuerTypeCIProvider,
+						CIProvider: "ci-provider",
+						ClientID:   "sigstore",
+					},
+				}
+			cfg := &config.FulcioConfig{
+				OIDCIssuers: OIDCIssuers,
+			}
+			ctx = config.With(ctx, cfg)
+			principal, err := WorkflowPrincipalFromIDToken(ctx, token)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			gotName := principal.Name(context.TODO())
+			if gotName != test.ExpectName {
+				t.Error("name should match sub claim")
+			}
+		})
+	}
+}
+
+func TestApplyTemplateOrReplace(t *testing.T) {
+
+	tokenClaims := map[string]string{
+		"aud":                   "sigstore",
+		"event_name":            "push",
+		"exp":                   "0",
+		"iss":                   "https://token.actions.githubusercontent.com",
+		"job_workflow_ref":      "sigstore/fulcio/.github/workflows/foo.yaml@refs/heads/main",
+		"job_workflow_sha":      "example-sha",
+		"ref":                   "refs/heads/main",
+		"repository":            "sigstore/fulcio",
+		"repository_id":         "12345",
+		"repository_owner":      "username",
+		"repository_owner_id":   "345",
+		"repository_visibility": "public",
+		"run_attempt":           "1",
+		"run_id":                "42",
+		"runner_environment":    "cloud-hosted",
+		"sha":                   "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+		"sub":                   "repo:sigstore/fulcio:ref:refs/heads/main",
+		"workflow":              "foo",
+		"workflow_ref":          "sigstore/other/.github/workflows/foo.yaml@refs/heads/main",
+		"workflow_sha":          "example-sha-other",
+	}
+	issuerMetadata := map[string]string{
+		"url": "https://github.com",
+	}
+
+	tests := map[string]struct {
+		Template       string
+		ExpectedResult string
+		ExpectErr      bool
+	}{
+		`Valid template`: {
+			Template:       "{{ .url }}/{{ .repository }}/actions/runs/{{ .run_id }}/attempts/{{ .run_attempt }}",
+			ExpectedResult: "https://github.com/sigstore/fulcio/actions/runs/42/attempts/1",
+			ExpectErr:      false,
+		},
+		`Empty template`: {
+			Template:       "{{}}",
+			ExpectedResult: "",
+			ExpectErr:      true,
+		},
+		`Missing key for template`: {
+			Template:       "{{ .foo }}",
+			ExpectedResult: "",
+			ExpectErr:      true,
+		},
+		`Empty string`: {
+			Template:       "",
+			ExpectedResult: "",
+			ExpectErr:      true,
+		},
+		`Replaceable string`: {
+			Template:       "job_workflow_ref",
+			ExpectedResult: "sigstore/fulcio/.github/workflows/foo.yaml@refs/heads/main",
+			ExpectErr:      false,
+		},
+		`Missing string`: {
+			Template:       "bar",
+			ExpectedResult: "",
+			ExpectErr:      true,
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			res, err := applyTemplateOrReplace(test.Template, tokenClaims, issuerMetadata)
+			if res != test.ExpectedResult {
+				t.Errorf("expected result don't matches: Expected %s, received: %s",
+					test.ExpectedResult, res)
+			}
+			if (err != nil) != test.ExpectErr {
+				t.Errorf("should raise an error don't matches: Expected %v, received: %v",
+					test.ExpectErr, err != nil)
+			}
+		})
+	}
+}
+
+func TestEmbed(t *testing.T) {
+	tests := map[string]struct {
+		WantFacts map[string]func(x509.Certificate) error
+		Principal ciPrincipal
+	}{
+		`Github workflow challenge should have all Github workflow extensions and issuer set`: {
+			WantFacts: map[string]func(x509.Certificate) error{
+				`Certifificate should have correct issuer`:                       factDeprecatedExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1}, "https://token.actions.githubusercontent.com"),
+				`Certificate has correct trigger extension`:                      factDeprecatedExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 2}, "trigger"),
+				`Certificate has correct SHA extension`:                          factDeprecatedExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 3}, "sha"),
+				`Certificate has correct workflow extension`:                     factDeprecatedExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 4}, "workflowname"),
+				`Certificate has correct repository extension`:                   factDeprecatedExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 5}, "repository"),
+				`Certificate has correct ref extension`:                          factDeprecatedExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 6}, "ref"),
+				`Certificate has correct issuer (v2) extension`:                  factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 8}, "https://token.actions.githubusercontent.com"),
+				`Certificate has correct builder signer URI extension`:           factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 9}, "https://github.com/jobWorkflowRef"),
+				`Certificate has correct builder signer digest extension`:        factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 10}, "jobWorkflowSha"),
+				`Certificate has correct runner environment extension`:           factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 11}, "runnerEnv"),
+				`Certificate has correct source repo URI extension`:              factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 12}, "https://github.com/repository"),
+				`Certificate has correct source repo digest extension`:           factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 13}, "sha"),
+				`Certificate has correct source repo ref extension`:              factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 14}, "ref"),
+				`Certificate has correct source repo ID extension`:               factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 15}, "repoID"),
+				`Certificate has correct source repo owner URI extension`:        factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 16}, "https://github.com/repoOwner"),
+				`Certificate has correct source repo owner ID extension`:         factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 17}, "repoOwnerID"),
+				`Certificate has correct build config URI extension`:             factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 18}, "https://github.com/workflowRef"),
+				`Certificate has correct build config digest extension`:          factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 19}, "workflowSHA"),
+				`Certificate has correct build trigger extension`:                factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 20}, "trigger"),
+				`Certificate has correct run invocation ID extension`:            factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 21}, "https://github.com/repository/actions/runs/runID/attempts/runAttempt"),
+				`Certificate has correct source repository visibility extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 22}, "public"),
+			},
+			Principal: ciPrincipal{
+				ClaimsMetadata: config.IssuerMetadata{
+					ExtensionTemplates: certificate.Extensions{
+						GithubWorkflowTrigger:               "event_name",
+						GithubWorkflowSHA:                   "sha",
+						GithubWorkflowName:                  "workflow",
+						GithubWorkflowRepository:            "repository",
+						GithubWorkflowRef:                   "ref",
+						BuildSignerURI:                      "{{ .url }}/{{ .job_workflow_ref }}",
+						BuildSignerDigest:                   "job_workflow_sha",
+						RunnerEnvironment:                   "runner_environment",
+						SourceRepositoryURI:                 "{{ .url }}/{{ .repository }}",
+						SourceRepositoryDigest:              "sha",
+						SourceRepositoryRef:                 "ref",
+						SourceRepositoryIdentifier:          "repository_id",
+						SourceRepositoryOwnerURI:            "{{ .url }}/{{ .repository_owner }}",
+						SourceRepositoryOwnerIdentifier:     "repository_owner_id",
+						BuildConfigURI:                      "{{ .url }}/{{ .workflow_ref }}",
+						BuildConfigDigest:                   "workflow_sha",
+						BuildTrigger:                        "event_name",
+						RunInvocationURI:                    "{{ .url }}/{{ .repository }}/actions/runs/{{ .run_id }}/attempts/{{ .run_attempt }}",
+						SourceRepositoryVisibilityAtSigning: "repository_visibility",
+					},
+					DefaultTemplateValues: map[string]string{
+						"url": "https://github.com",
+					},
+					SubjectAlternativeNameTemplate: "{{.url}}/{{.job_workflow_ref}}",
+				},
+			},
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			var cert x509.Certificate
+			claims, err := json.Marshal(map[string]interface{}{
+				"event_name":            "trigger",
+				"sha":                   "sha",
+				"workflow":              "workflowname",
+				"repository":            "repository",
+				"ref":                   "ref",
+				"job_workflow_sha":      "jobWorkflowSha",
+				"job_workflow_ref":      "jobWorkflowRef",
+				"runner_environment":    "runnerEnv",
+				"repository_id":         "repoID",
+				"repository_owner":      "repoOwner",
+				"repository_owner_id":   "repoOwnerID",
+				"workflow_ref":          "workflowRef",
+				"workflow_sha":          "workflowSHA",
+				"run_id":                "runID",
+				"run_attempt":           "runAttempt",
+				"repository_visibility": "public",
+			})
+			if err != nil {
+				t.Fatal(err)
+			}
+			token := &oidc.IDToken{}
+			token.Issuer = "https://token.actions.githubusercontent.com"
+			withClaims(token, claims)
+
+			test.Principal.Token = token
+			err = test.Principal.Embed(context.TODO(), &cert)
+			if err != nil {
+				t.Error(err)
+			}
+			for factName, fact := range test.WantFacts {
+				t.Run(factName, func(t *testing.T) {
+					if err := fact(cert); err != nil {
+						t.Error(err)
+					}
+				})
+			}
+		})
+	}
+}
+
+func factExtensionIs(oid asn1.ObjectIdentifier, value string) func(x509.Certificate) error {
+	return func(cert x509.Certificate) error {
+		for _, ext := range cert.ExtraExtensions {
+			if ext.Id.Equal(oid) {
+				var strVal string
+				_, _ = asn1.Unmarshal(ext.Value, &strVal)
+				if value != strVal {
+					return fmt.Errorf("expected oid %v to be %s, but got %s", oid, value, strVal)
+				}
+				return nil
+			}
+		}
+		return errors.New("extension not set")
+	}
+}
+
+func factDeprecatedExtensionIs(oid asn1.ObjectIdentifier, value string) func(x509.Certificate) error {
+	return func(cert x509.Certificate) error {
+		for _, ext := range cert.ExtraExtensions {
+			if ext.Id.Equal(oid) {
+				if !bytes.Equal(ext.Value, []byte(value)) {
+					return fmt.Errorf("expected oid %v to be %s, but got %s", oid, value, ext.Value)
+				}
+				return nil
+			}
+		}
+		return errors.New("extension not set")
+	}
+}
diff --git a/pkg/server/grpc_server_test.go b/pkg/server/grpc_server_test.go
index 9990831..56f05b7 100644
--- a/pkg/server/grpc_server_test.go
+++ b/pkg/server/grpc_server_test.go
@@ -53,6 +53,7 @@ import (
 
 	"github.com/sigstore/fulcio/pkg/ca"
 	"github.com/sigstore/fulcio/pkg/ca/ephemeralca"
+	"github.com/sigstore/fulcio/pkg/certificate"
 	"github.com/sigstore/fulcio/pkg/config"
 	"github.com/sigstore/fulcio/pkg/generated/protobuf"
 	"github.com/sigstore/fulcio/pkg/identity"
@@ -199,6 +200,7 @@ func TestGetConfiguration(t *testing.T) {
 	_, gitLabIssuer := newOIDCIssuer(t)
 	_, codefreshIssuer := newOIDCIssuer(t)
 	_, chainguardIssuer := newOIDCIssuer(t)
+	_, ciProviderIssuer := newOIDCIssuer(t)
 
 	issuerDomain, err := url.Parse(usernameIssuer)
 	if err != nil {
@@ -254,6 +256,11 @@ func TestGetConfiguration(t *testing.T) {
 				"IssuerURL": %q,
 				"ClientID": "sigstore",
 				"Type": "chainguard-identity"
+			},
+			%q: {
+				"IssuerURL": %q,
+				"ClientID": "sigstore",
+				"Type": "ci-provider"
 			}
 		},
 		"MetaIssuers": {
@@ -271,6 +278,7 @@ func TestGetConfiguration(t *testing.T) {
 		gitLabIssuer, gitLabIssuer,
 		codefreshIssuer, codefreshIssuer,
 		chainguardIssuer, chainguardIssuer,
+		ciProviderIssuer, ciProviderIssuer,
 		k8sIssuer)))
 	if err != nil {
 		t.Fatalf("config.Read() = %v", err)
@@ -291,7 +299,7 @@ func TestGetConfiguration(t *testing.T) {
 		t.Fatal("GetConfiguration failed", err)
 	}
 
-	if got, want := len(config.Issuers), 10; got != want {
+	if got, want := len(config.Issuers), 11; got != want {
 		t.Fatalf("expected %d issuers, got %d", want, got)
 	}
 
@@ -299,7 +307,7 @@ func TestGetConfiguration(t *testing.T) {
 		emailIssuer: true, spiffeIssuer: true, uriIssuer: true,
 		usernameIssuer: true, k8sIssuer: true, gitHubIssuer: true,
 		buildkiteIssuer: true, gitLabIssuer: true, codefreshIssuer: true,
-		chainguardIssuer: true,
+		chainguardIssuer: true, ciProviderIssuer: true,
 	}
 	for _, iss := range config.Issuers {
 		var issURL string
@@ -1123,6 +1131,178 @@ func TestAPIWithGitHub(t *testing.T) {
 	}
 }
 
+// Tests API for CiProvider subject types
+func TestAPIWithCiProvider(t *testing.T) {
+	ciProviderSigner, ciProviderIssuer := newOIDCIssuer(t)
+	// Create a FulcioConfig that supports these issuers.
+	cfg, err := config.Read([]byte(fmt.Sprintf(`{
+		"OIDCIssuers": {
+			%q: {
+				"IssuerURL": %q,
+				"ClientID": "sigstore",
+				"Type": "ci-provider",
+				"CIProvider": "github-workflow"
+			}
+        }
+	}`, ciProviderIssuer, ciProviderIssuer)))
+	if err != nil {
+		t.Fatalf("config.Read() = %v", err)
+	}
+	claims := githubClaims{
+		JobWorkflowRef:       "job/workflow/ref",
+		Sha:                  "sha",
+		EventName:            "trigger",
+		Repository:           "sigstore/fulcio",
+		Workflow:             "workflow",
+		Ref:                  "refs/heads/main",
+		JobWorkflowSha:       "example-sha",
+		RunnerEnvironment:    "cloud-hosted",
+		RepositoryID:         "12345",
+		RepositoryOwner:      "username",
+		RepositoryOwnerID:    "345",
+		RepositoryVisibility: "public",
+		WorkflowRef:          "sigstore/other/.github/workflows/foo.yaml@refs/heads/main",
+		WorkflowSha:          "example-sha-other",
+		RunID:                "42",
+		RunAttempt:           "1",
+	}
+	githubSubject := fmt.Sprintf("repo:%s:ref:%s", claims.Repository, claims.Ref)
+	// Create an OIDC token using this issuer's signer.
+	tok, err := jwt.Signed(ciProviderSigner).Claims(jwt.Claims{
+		Issuer:   ciProviderIssuer,
+		IssuedAt: jwt.NewNumericDate(time.Now()),
+		Expiry:   jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
+		Subject:  githubSubject,
+		Audience: jwt.Audience{"sigstore"},
+	}).Claims(&claims).Serialize()
+	if err != nil {
+		t.Fatalf("Serialize() = %v", err)
+	}
+
+	ctClient, eca := createCA(cfg, t)
+	ctx := context.Background()
+	cfg.CIIssuerMetadata = make(map[string]config.IssuerMetadata)
+	cfg.CIIssuerMetadata["github-workflow"] = config.IssuerMetadata{
+		ExtensionTemplates: certificate.Extensions{
+			Issuer:                              "issuer",
+			GithubWorkflowTrigger:               "event_name",
+			GithubWorkflowSHA:                   "sha",
+			GithubWorkflowName:                  "workflow",
+			GithubWorkflowRepository:            "repository",
+			GithubWorkflowRef:                   "ref",
+			BuildSignerURI:                      "{{ .url }}/{{ .job_workflow_ref }}",
+			BuildSignerDigest:                   "job_workflow_sha",
+			RunnerEnvironment:                   "runner_environment",
+			SourceRepositoryURI:                 "{{ .url }}/{{ .repository }}",
+			SourceRepositoryDigest:              "sha",
+			SourceRepositoryRef:                 "ref",
+			SourceRepositoryIdentifier:          "repository_id",
+			SourceRepositoryOwnerURI:            "{{ .url }}/{{ .repository_owner }}",
+			SourceRepositoryOwnerIdentifier:     "repository_owner_id",
+			BuildConfigURI:                      "{{ .url }}/{{ .workflow_ref }}",
+			BuildConfigDigest:                   "workflow_sha",
+			BuildTrigger:                        "event_name",
+			RunInvocationURI:                    "{{ .url }}/{{ .repository }}/actions/runs/{{ .run_id }}/attempts/{{ .run_attempt }}",
+			SourceRepositoryVisibilityAtSigning: "repository_visibility",
+		},
+		DefaultTemplateValues: map[string]string{
+			"url": "https://github.com",
+		},
+		SubjectAlternativeNameTemplate: "{{.url}}/{{.job_workflow_ref}}",
+	}
+
+	server, conn := setupGRPCForTest(t, cfg, ctClient, eca)
+	defer func() {
+		server.Stop()
+		conn.Close()
+	}()
+	client := protobuf.NewCAClient(conn)
+	pubBytes, proof := generateKeyAndProof(githubSubject, t)
+	// Hit the API to have it sign our certificate.
+	resp, err := client.CreateSigningCertificate(ctx, &protobuf.CreateSigningCertificateRequest{
+		Credentials: &protobuf.Credentials{
+			Credentials: &protobuf.Credentials_OidcIdentityToken{
+				OidcIdentityToken: tok,
+			},
+		},
+		Key: &protobuf.CreateSigningCertificateRequest_PublicKeyRequest{
+			PublicKeyRequest: &protobuf.PublicKeyRequest{
+				PublicKey: &protobuf.PublicKey{
+					Content: pubBytes,
+				},
+				ProofOfPossession: proof,
+			},
+		},
+	})
+	if err != nil {
+		t.Fatalf("SigningCert() = %v", err)
+	}
+	leafCert := verifyResponse(resp, eca, ciProviderIssuer, t)
+	// Expect URI values
+	if len(leafCert.URIs) != 1 {
+		t.Fatalf("unexpected length of leaf certificate URIs, expected 1, got %d", len(leafCert.URIs))
+	}
+	githubURL := fmt.Sprintf("https://github.com/%s", claims.JobWorkflowRef)
+	githubURI, err := url.Parse(githubURL)
+	if err != nil {
+		t.Fatalf("failed to parse expected url")
+	}
+	if *leafCert.URIs[0] != *githubURI {
+		t.Fatalf("URIs do not match: Expected %v, got %v", githubURI, leafCert.URIs[0])
+	}
+	// Verify custom OID values
+	deprecatedExpectedExts := map[int]string{
+		2: claims.EventName,
+		3: claims.Sha,
+		4: claims.Workflow,
+		5: claims.Repository,
+		6: claims.Ref,
+	}
+	for o, value := range deprecatedExpectedExts {
+		ext, found := findCustomExtension(leafCert, asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, o})
+		if !found {
+			t.Fatalf("expected extension in custom OID 1.3.6.1.4.1.57264.1.%d", o)
+		}
+		if string(ext.Value) != value {
+			t.Fatalf("unexpected extension value, expected %s, got %s", value, ext.Value)
+		}
+	}
+	url := "https://github.com/"
+	expectedExts := map[int]string{
+		9:  url + claims.JobWorkflowRef,
+		10: claims.JobWorkflowSha,
+		11: claims.RunnerEnvironment,
+		12: url + claims.Repository,
+		13: claims.Sha,
+		14: claims.Ref,
+		15: claims.RepositoryID,
+		16: url + claims.RepositoryOwner,
+		17: claims.RepositoryOwnerID,
+		18: url + claims.WorkflowRef,
+		19: claims.WorkflowSha,
+		20: claims.EventName,
+		21: url + claims.Repository + "/actions/runs/" + claims.RunID + "/attempts/" + claims.RunAttempt,
+		22: claims.RepositoryVisibility,
+	}
+	for o, value := range expectedExts {
+		ext, found := findCustomExtension(leafCert, asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, o})
+		if !found {
+			t.Fatalf("expected extension in custom OID 1.3.6.1.4.1.57264.1.%d", o)
+		}
+		var extValue string
+		rest, err := asn1.Unmarshal(ext.Value, &extValue)
+		if err != nil {
+			t.Fatalf("error unmarshalling extension: :%v", err)
+		}
+		if len(rest) != 0 {
+			t.Fatal("error unmarshalling extension, rest is not 0")
+		}
+		if string(extValue) != value {
+			t.Fatalf("unexpected extension value, expected %s, got %s", value, extValue)
+		}
+	}
+}
+
 // gitlabClaims holds the additional JWT claims for GitLab OIDC tokens
 type gitlabClaims struct {
 	ProjectPath       string `json:"project_path"`