Repository: sigstore/fulcio
Pull request #1724 (2024-07-09 21:56:29)

Bump google.golang.org/grpc from 1.64.0 to 1.64.1 in the go_modules group
Bumps the go_modules group with 1 update: [google.golang.org/grpc](https://github.com/grpc/grpc-go).

Updates `google.golang.org/grpc` from 1.64.0 to 1.64.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/grpc/grpc-go/releases">google.golang.org/grpc's releases</a>.</em></p>
<blockquote>
<h2>Release 1.64.1</h2>
<h1>Dependencies</h1>
<ul>
<li>Update x/net/http2 to address <a href="https://nvd.nist.gov/vuln/detail/CVE-2023-45288">CVE-2023-45288</a> (<a href="https://redirect.github.com/grpc/grpc-go/issues/7352">#7352</a>)</li>
<li>metadata: remove String method from MD to make printing consistent (<a href="https://redirect.github.com/grpc/grpc-go/issues/7374">#7374</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/grpc/grpc-go/commit/4d833de0e0ea4345c2f8333f5e1d1155f93c5222"><code>4d833de</code></a> Change version to 1.64.1 (<a href="https://redirect.github.com/grpc/grpc-go/issues/7381">#7381</a>)</li>
<li><a href="https://github.com/grpc/grpc-go/commit/e9193a42987f93cf634aedb7c6f32d607281f8c4"><code>e9193a4</code></a> *: update deps (<a href="https://redirect.github.com/grpc/grpc-go/issues/7375">#7375</a>)</li>
<li><a href="https://github.com/grpc/grpc-go/commit/ab292411ddc0f3b7a7786754d1fe05264c3021eb"><code>ab29241</code></a> metadata: remove String method (<a href="https://redirect.github.com/grpc/grpc-go/issues/7374">#7374</a>)</li>
<li><a href="https://github.com/grpc/grpc-go/commit/355b9a557b63896fc569d8be1518905eabde0026"><code>355b9a5</code></a> Change version to 1.64.1-dev (<a href="https://redirect.github.com/grpc/grpc-go/issues/7219">#7219</a>)</li>
<li>See full diff in <a href="https://github.com/grpc/grpc-go/compare/v1.64.0...v1.64.1">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=google.golang.org/grpc&package-manager=go_modules&previous-version=1.64.0&new-version=1.64.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will remove the ignore condition of the specified dependency and ignore conditions
You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/sigstore/fulcio/network/alerts).

</details>


Patch:
diff --git a/go.mod b/go.mod
index 1969ef1..1a30392 100644
--- a/go.mod
+++ b/go.mod
@@ -39,7 +39,7 @@ require (
 	go.uber.org/zap v1.27.0
 	google.golang.org/api v0.185.0
 	google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3
-	google.golang.org/grpc v1.64.0
+	google.golang.org/grpc v1.64.1
 	google.golang.org/protobuf v1.34.2
 	gopkg.in/yaml.v3 v3.0.1
 	sigs.k8s.io/release-utils v0.8.2
diff --git a/go.sum b/go.sum
index 1086ff4..aa35df7 100644
--- a/go.sum
+++ b/go.sum
@@ -511,8 +511,8 @@ google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQ
 google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
 google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
 google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
-google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
-google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
+google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
+google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
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"`