Skip to content

Commit e902cf8

Browse files
authored
feat: Add support for skipping email_verified claim requirement per issuer (#2220)
Fixes #2219 Some enterprise identity providers like Microsoft Entra (Azure AD) and ADFS don't include the email_verified claim in their OIDC tokens because email verification happens through their centralized identity management processes rather than during OIDC token issuance. This caused Fulcio to reject valid tokens from these providers, impacting some enterprise deployments. Added a new optional SkipEmailVerification boolean field to the OIDCIssuer configuration struct. When set to true for a specific issuer, Fulcio will skip the email_verified claim check during principal creation while still validating email format and embedding it in the certificate. This approach maintains security by default (the field defaults to false) while allowing operators to explicitly opt-in for trusted internal identity providers. The implementation moves the issuer configuration lookup earlier in PrincipalFromIDToken so we can access the SkipEmailVerification flag before checking email_verified. For meta-issuers with wildcard patterns, the flag is propagated when constructing concrete issuer configurations. Added tests coverage for scenarios for tokens with missing email_verified claims, explicit false values, and true values when skip is enabled. Updated config parsing tests to verify the new field can be read from both YAML and JSON configurations. Signed-off-by: Morgan Jones <[email protected]>
1 parent c0fc26c commit e902cf8

File tree

11 files changed

+184
-36
lines changed

11 files changed

+184
-36
lines changed

fulcio.proto

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,4 +241,7 @@ message OIDCIssuer {
241241
string issuer_type = 6;
242242
// The expected subject domain. Only present when the OIDC issuer issues tokens for URI or username identities.
243243
string subject_domain = 7;
244+
// Whether to skip email verification for this issuer.
245+
// Only applicable to email-type issuers from trusted internal identity providers.
246+
bool skip_email_verification = 8;
244247
}

fulcio.swagger.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,10 @@
245245
"subjectDomain": {
246246
"type": "string",
247247
"description": "The expected subject domain. Only present when the OIDC issuer issues tokens for URI or username identities."
248+
},
249+
"skipEmailVerification": {
250+
"type": "boolean",
251+
"description": "Whether to skip email verification for this issuer.\nOnly applicable to email-type issuers from trusted internal identity providers."
248252
}
249253
},
250254
"description": "Metadata about an OIDC issuer."

pkg/config/config.go

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ type OIDCIssuer struct {
133133
// This is used to trust the TLS certificate signed by an internal CA when interacting
134134
// with some OIDC providers, preventing x509 certificate verification failures.
135135
CACert string `json:"CACert,omitempty" yaml:"ca-cert,omitempty"`
136+
137+
// SkipEmailVerification skips the email_verified claim check for email-type issuers.
138+
// This should only be set to true for trusted internal identity providers (e.g., Microsoft Entra, ADFS)
139+
// that perform email verification through their own processes but don't include the email_verified claim.
140+
SkipEmailVerification bool `json:"SkipEmailVerification,omitempty" yaml:"skip-email-verification,omitempty"`
136141
}
137142

138143
func metaRegex(issuer string) (*regexp.Regexp, error) {
@@ -168,12 +173,13 @@ func (fc *FulcioConfig) GetIssuer(issuerURL string) (OIDCIssuer, bool) {
168173
// If it matches, then return a concrete OIDCIssuer
169174
// configuration for this issuer URL.
170175
return OIDCIssuer{
171-
IssuerURL: issuerURL,
172-
ClientID: iss.ClientID,
173-
Type: iss.Type,
174-
IssuerClaim: iss.IssuerClaim,
175-
SubjectDomain: iss.SubjectDomain,
176-
CIProvider: iss.CIProvider,
176+
IssuerURL: issuerURL,
177+
ClientID: iss.ClientID,
178+
Type: iss.Type,
179+
IssuerClaim: iss.IssuerClaim,
180+
SubjectDomain: iss.SubjectDomain,
181+
CIProvider: iss.CIProvider,
182+
SkipEmailVerification: iss.SkipEmailVerification,
177183
}, true
178184
}
179185
}
@@ -256,24 +262,26 @@ func (fc *FulcioConfig) ToIssuers() []*fulciogrpc.OIDCIssuer {
256262

257263
for _, cfgIss := range fc.OIDCIssuers {
258264
issuer := &fulciogrpc.OIDCIssuer{
259-
Issuer: &fulciogrpc.OIDCIssuer_IssuerUrl{IssuerUrl: cfgIss.IssuerURL},
260-
Audience: cfgIss.ClientID,
261-
SpiffeTrustDomain: cfgIss.SPIFFETrustDomain,
262-
ChallengeClaim: issuerToChallengeClaim(cfgIss.Type, cfgIss.ChallengeClaim),
263-
IssuerType: cfgIss.Type.String(),
264-
SubjectDomain: cfgIss.SubjectDomain,
265+
Issuer: &fulciogrpc.OIDCIssuer_IssuerUrl{IssuerUrl: cfgIss.IssuerURL},
266+
Audience: cfgIss.ClientID,
267+
SpiffeTrustDomain: cfgIss.SPIFFETrustDomain,
268+
ChallengeClaim: issuerToChallengeClaim(cfgIss.Type, cfgIss.ChallengeClaim),
269+
IssuerType: cfgIss.Type.String(),
270+
SubjectDomain: cfgIss.SubjectDomain,
271+
SkipEmailVerification: cfgIss.SkipEmailVerification,
265272
}
266273
issuers = append(issuers, issuer)
267274
}
268275

269276
for metaIss, cfgIss := range fc.MetaIssuers {
270277
issuer := &fulciogrpc.OIDCIssuer{
271-
Issuer: &fulciogrpc.OIDCIssuer_WildcardIssuerUrl{WildcardIssuerUrl: metaIss},
272-
Audience: cfgIss.ClientID,
273-
SpiffeTrustDomain: cfgIss.SPIFFETrustDomain,
274-
ChallengeClaim: issuerToChallengeClaim(cfgIss.Type, cfgIss.ChallengeClaim),
275-
IssuerType: cfgIss.Type.String(),
276-
SubjectDomain: cfgIss.SubjectDomain,
278+
Issuer: &fulciogrpc.OIDCIssuer_WildcardIssuerUrl{WildcardIssuerUrl: metaIss},
279+
Audience: cfgIss.ClientID,
280+
SpiffeTrustDomain: cfgIss.SPIFFETrustDomain,
281+
ChallengeClaim: issuerToChallengeClaim(cfgIss.Type, cfgIss.ChallengeClaim),
282+
IssuerType: cfgIss.Type.String(),
283+
SubjectDomain: cfgIss.SubjectDomain,
284+
SkipEmailVerification: cfgIss.SkipEmailVerification,
277285
}
278286
issuers = append(issuers, issuer)
279287
}

pkg/config/config_network_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ func TestLoadYamlConfig(t *testing.T) {
4949
if got.IssuerURL != "https://accounts.google.com" {
5050
t.Errorf("expected https://accounts.google.com, got %s", got.IssuerURL)
5151
}
52-
if got := len(cfg.OIDCIssuers); got != 1 {
53-
t.Errorf("expected 1 issuer, got %d", got)
52+
if got := len(cfg.OIDCIssuers); got != 2 {
53+
t.Errorf("expected 2 issuers, got %d", got)
5454
}
5555

5656
got, ok = cfg.GetIssuer("https://oidc.eks.fantasy-land.amazonaws.com/id/CLUSTERIDENTIFIER")
@@ -102,8 +102,8 @@ func TestLoadJsonConfig(t *testing.T) {
102102
if got.IssuerURL != "https://accounts.google.com" {
103103
t.Errorf("expected https://accounts.google.com, got %s", got.IssuerURL)
104104
}
105-
if got := len(cfg.OIDCIssuers); got != 1 {
106-
t.Errorf("expected 1 issuer, got %d", got)
105+
if got := len(cfg.OIDCIssuers); got != 2 {
106+
t.Errorf("expected 2 issuers, got %d", got)
107107
}
108108

109109
got, ok = cfg.GetIssuer("https://oidc.eks.fantasy-land.amazonaws.com/id/CLUSTERIDENTIFIER")

pkg/config/config_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ oidc-issuers:
5050
client-id: foo
5151
type: email
5252
challenge-claim: email
53+
https://internal.example.com:
54+
issuer-url: https://internal.example.com
55+
client-id: foo
56+
type: email
57+
challenge-claim: email
58+
skip-email-verification: true
5359
meta-issuers:
5460
https://oidc.eks.*.amazonaws.com/id/*:
5561
client-id: bar
@@ -68,6 +74,13 @@ var validJSONCfg = `
6874
"ClientID": "foo",
6975
"Type": "email",
7076
"ChallengeClaim": "email"
77+
},
78+
"https://internal.example.com": {
79+
"IssuerURL": "https://internal.example.com",
80+
"ClientID": "foo",
81+
"Type": "email",
82+
"ChallengeClaim": "email",
83+
"SkipEmailVerification": true
7184
}
7285
},
7386
"MetaIssuers": {
@@ -511,6 +524,42 @@ func Test_validateAllowedDomain(t *testing.T) {
511524
}
512525
}
513526

527+
func TestSkipEmailVerificationParsing(t *testing.T) {
528+
yamlConfig := `
529+
oidc-issuers:
530+
https://internal.example.com:
531+
issuer-url: https://internal.example.com
532+
client-id: sigstore
533+
type: email
534+
skip-email-verification: true
535+
https://public.example.com:
536+
issuer-url: https://public.example.com
537+
client-id: sigstore
538+
type: email
539+
`
540+
541+
cfg, err := parseConfig([]byte(yamlConfig))
542+
if err != nil {
543+
t.Fatalf("failed to parse config: %v", err)
544+
}
545+
546+
internalIssuer, ok := cfg.OIDCIssuers["https://internal.example.com"]
547+
if !ok {
548+
t.Fatal("internal issuer not found")
549+
}
550+
if !internalIssuer.SkipEmailVerification {
551+
t.Error("expected SkipEmailVerification to be true for internal issuer")
552+
}
553+
554+
publicIssuer, ok := cfg.OIDCIssuers["https://public.example.com"]
555+
if !ok {
556+
t.Fatal("public issuer not found")
557+
}
558+
if publicIssuer.SkipEmailVerification {
559+
t.Error("expected SkipEmailVerification to be false (default) for public issuer")
560+
}
561+
}
562+
514563
func Test_issuerToChallengeClaim(t *testing.T) {
515564
if claim := issuerToChallengeClaim(IssuerTypeEmail, ""); claim != "email" {
516565
t.Fatalf("expected email subject claim for email issuer, got %s", claim)

pkg/generated/protobuf/fulcio.pb.go

Lines changed: 16 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/generated/protobuf/fulcio_grpc.pb.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/generated/protobuf/legacy/fulcio_legacy.pb.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/generated/protobuf/legacy/fulcio_legacy_grpc.pb.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/identity/email/principal.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,21 @@ func PrincipalFromIDToken(ctx context.Context, token *oidc.IDToken) (identity.Pr
3838
if err != nil {
3939
return nil, err
4040
}
41-
if !emailVerified {
41+
42+
cfg, ok := config.FromContext(ctx).GetIssuer(token.Issuer)
43+
if !ok {
44+
return nil, errors.New("invalid configuration for OIDC ID Token issuer")
45+
}
46+
47+
// Check email_verified claim unless the issuer is configured to skip verification
48+
if !cfg.SkipEmailVerification && !emailVerified {
4249
return nil, errors.New("email_verified claim was false")
4350
}
4451

4552
if !govalidator.IsEmail(emailAddress) {
4653
return nil, fmt.Errorf("email address is not valid")
4754
}
4855

49-
cfg, ok := config.FromContext(ctx).GetIssuer(token.Issuer)
50-
if !ok {
51-
return nil, errors.New("invalid configuration for OIDC ID Token issuer")
52-
}
53-
5456
issuer, err := oauthflow.IssuerFromIDToken(token, cfg.IssuerClaim)
5557
if err != nil {
5658
return nil, err

0 commit comments

Comments
 (0)