diff --git a/pkg/challenges/challenges.go b/pkg/challenges/challenges.go index d308b0705..b545d88fc 100644 --- a/pkg/challenges/challenges.go +++ b/pkg/challenges/challenges.go @@ -22,71 +22,22 @@ import ( "crypto/x509" "errors" "fmt" - "net/url" "strings" - "github.com/sigstore/fulcio/pkg/ca/x509ca" "github.com/sigstore/fulcio/pkg/config" "github.com/sigstore/fulcio/pkg/identity" "github.com/sigstore/fulcio/pkg/identity/email" "github.com/sigstore/fulcio/pkg/identity/github" "github.com/sigstore/fulcio/pkg/identity/kubernetes" "github.com/sigstore/fulcio/pkg/identity/spiffe" + "github.com/sigstore/fulcio/pkg/identity/uri" + "github.com/sigstore/fulcio/pkg/identity/username" "github.com/coreos/go-oidc/v3/oidc" "github.com/sigstore/sigstore/pkg/cryptoutils" "github.com/sigstore/sigstore/pkg/signature" ) -type ChallengeType int - -const ( - URIValue ChallengeType = iota - UsernameValue -) - -type ChallengeResult struct { - Issuer string - TypeVal ChallengeType - - // Value configures what will be set for SubjectAlternativeName in - // the certificate issued. - Value string - - // subject or email from the id token. This must be the thing - // signed in the proof of possession! - subject string -} - -func (cr *ChallengeResult) Name(context.Context) string { - return cr.subject -} - -func (cr *ChallengeResult) Embed(ctx context.Context, cert *x509.Certificate) error { - switch cr.TypeVal { - case URIValue: - subjectURI, err := url.Parse(cr.Value) - if err != nil { - return err - } - cert.URIs = []*url.URL{subjectURI} - case UsernameValue: - cert.EmailAddresses = []string{cr.Value} - } - - exts := x509ca.Extensions{ - Issuer: cr.Issuer, - } - - var err error - cert.ExtraExtensions, err = exts.Render() - if err != nil { - return err - } - - return nil -} - // CheckSignature verifies a challenge, a signature over the subject or email // of an OIDC token func CheckSignature(pub crypto.PublicKey, proof []byte, subject string) error { @@ -98,60 +49,6 @@ func CheckSignature(pub crypto.PublicKey, proof []byte, subject string) error { return verifier.VerifySignature(bytes.NewReader(proof), strings.NewReader(subject)) } -func uri(ctx context.Context, principal *oidc.IDToken) (identity.Principal, error) { - uriWithSubject := principal.Subject - - cfg, ok := config.FromContext(ctx).GetIssuer(principal.Issuer) - if !ok { - return nil, errors.New("invalid configuration for OIDC ID Token issuer") - } - - // The subject hostname must exactly match the subject domain from the configuration - uSubject, err := url.Parse(uriWithSubject) - if err != nil { - return nil, err - } - uDomain, err := url.Parse(cfg.SubjectDomain) - if err != nil { - return nil, err - } - if uSubject.Scheme != uDomain.Scheme { - return nil, fmt.Errorf("subject URI scheme (%s) must match expected domain URI scheme (%s)", uSubject.Scheme, uDomain.Scheme) - } - if uSubject.Hostname() != uDomain.Hostname() { - return nil, fmt.Errorf("subject hostname (%s) must match expected domain (%s)", uSubject.Hostname(), uDomain.Hostname()) - } - - return &ChallengeResult{ - Issuer: principal.Issuer, - TypeVal: URIValue, - Value: uriWithSubject, - subject: uriWithSubject, - }, nil -} - -func username(ctx context.Context, principal *oidc.IDToken) (identity.Principal, error) { - username := principal.Subject - - if strings.Contains(username, "@") { - return nil, errors.New("username cannot contain @ character") - } - - cfg, ok := config.FromContext(ctx).GetIssuer(principal.Issuer) - if !ok { - return nil, errors.New("invalid configuration for OIDC ID Token issuer") - } - - emailSubject := fmt.Sprintf("%s@%s", username, cfg.SubjectDomain) - - return &ChallengeResult{ - Issuer: principal.Issuer, - TypeVal: UsernameValue, - Value: emailSubject, - subject: username, - }, nil -} - func PrincipalFromIDToken(ctx context.Context, tok *oidc.IDToken) (identity.Principal, error) { iss, ok := config.FromContext(ctx).GetIssuer(tok.Issuer) if !ok { @@ -169,9 +66,9 @@ func PrincipalFromIDToken(ctx context.Context, tok *oidc.IDToken) (identity.Prin case config.IssuerTypeKubernetes: principal, err = kubernetes.PrincipalFromIDToken(ctx, tok) case config.IssuerTypeURI: - principal, err = uri(ctx, tok) + principal, err = uri.PrincipalFromIDToken(ctx, tok) case config.IssuerTypeUsername: - principal, err = username(ctx, tok) + principal, err = username.PrincipalFromIDToken(ctx, tok) default: return nil, fmt.Errorf("unsupported issuer: %s", iss.Type) } diff --git a/pkg/challenges/challenges_test.go b/pkg/challenges/challenges_test.go index e7cbe8a83..2d43603a9 100644 --- a/pkg/challenges/challenges_test.go +++ b/pkg/challenges/challenges_test.go @@ -16,246 +16,17 @@ package challenges import ( - "bytes" - "context" "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/sha256" - "crypto/x509" - "encoding/asn1" - "errors" - "fmt" - "net/url" "testing" - "github.com/coreos/go-oidc/v3/oidc" - "github.com/google/go-cmp/cmp" - "github.com/sigstore/fulcio/pkg/config" "github.com/sigstore/sigstore/pkg/cryptoutils" ) -func TestEmbedChallengeResult(t *testing.T) { - tests := map[string]struct { - Challenge ChallengeResult - WantErr bool - WantFacts map[string]func(x509.Certificate) error - }{ - `Good URI value`: { - Challenge: ChallengeResult{ - Issuer: `foo.example.com`, - TypeVal: URIValue, - Value: "https://foo.example.com", - }, - WantErr: false, - WantFacts: map[string]func(x509.Certificate) error{ - `Issuer is foo.example.com`: factIssuerIs(`foo.example.com`), - `SAN is https://foo.example.com`: func(cert x509.Certificate) error { - WantURI, err := url.Parse("https://foo.example.com") - if err != nil { - return err - } - if len(cert.URIs) != 1 { - return errors.New("no URI SAN set") - } - if diff := cmp.Diff(cert.URIs[0], WantURI); diff != "" { - return errors.New(diff) - } - return nil - }, - }, - }, - `Bad URI value fails`: { - Challenge: ChallengeResult{ - Issuer: `foo.example.com`, - TypeVal: URIValue, - Value: "\nnoooooo", - }, - WantErr: true, - }, - `Good username value`: { - Challenge: ChallengeResult{ - Issuer: `foo.example.com`, - TypeVal: UsernameValue, - Value: "name@foo.example.com", - }, - WantErr: false, - WantFacts: map[string]func(x509.Certificate) error{ - `Issuer is foo.example.com`: factIssuerIs(`foo.example.com`), - `SAN is name@foo.example.com`: func(cert x509.Certificate) error { - if len(cert.EmailAddresses) != 1 { - return errors.New("no email SAN set") - } - if cert.EmailAddresses[0] != "name@foo.example.com" { - return errors.New("wrong email") - } - return nil - }, - }, - }, - `No issuer should fail to render extensions`: { - Challenge: ChallengeResult{ - Issuer: ``, - TypeVal: URIValue, - Value: "https://foo.example.com/foo/bar", - }, - WantErr: true, - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - var cert x509.Certificate - err := test.Challenge.Embed(context.TODO(), &cert) - if err != nil { - if !test.WantErr { - t.Error(err) - } - return - } else if test.WantErr { - t.Error("expected error") - } - for factName, fact := range test.WantFacts { - t.Run(factName, func(t *testing.T) { - if err := fact(cert); err != nil { - t.Error(err) - } - }) - } - }) - } -} - -func factIssuerIs(issuer string) func(x509.Certificate) error { - return factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1}, issuer) -} - -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) { - 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") - } -} - -func TestURI(t *testing.T) { - cfg := &config.FulcioConfig{ - OIDCIssuers: map[string]config.OIDCIssuer{ - "https://accounts.example.com": { - IssuerURL: "https://accounts.example.com", - ClientID: "sigstore", - SubjectDomain: "https://example.com", - Type: config.IssuerTypeURI, - }, - }, - } - ctx := config.With(context.Background(), cfg) - subject := "https://example.com/users/1" - issuer := "https://accounts.example.com" - token := &oidc.IDToken{Subject: subject, Issuer: issuer} - - principal, err := uri(ctx, token) - if err != nil { - t.Errorf("Expected test success, got %v", err) - } - if principal.Name(ctx) != token.Subject { - t.Errorf("Expected subject %v, got %v", token.Subject, principal.Name(ctx)) - } - raw, ok := principal.(*ChallengeResult) - if !ok { - t.Fatal("expected principal to be a ChallengeResult") - } - if raw.Issuer != issuer { - t.Errorf("Expected issuer %s, got %s", issuer, raw.Issuer) - } - if raw.Value != subject { - t.Errorf("Expected subject value %s, got %s", subject, raw.Value) - } - if raw.TypeVal != URIValue { - t.Errorf("Expected type %v, got %v", URIValue, raw.TypeVal) - } - if raw.subject != token.Subject { - t.Errorf("Expected subject %v, got %v", token.Subject, raw.subject) - } -} - -func TestUsername(t *testing.T) { - cfg := &config.FulcioConfig{ - OIDCIssuers: map[string]config.OIDCIssuer{ - "https://accounts.example.com": { - IssuerURL: "https://accounts.example.com", - ClientID: "sigstore", - SubjectDomain: "example.com", - Type: config.IssuerTypeUsername, - }, - }, - } - ctx := config.With(context.Background(), cfg) - usernameVal := "foobar" - usernameWithEmail := "foobar@example.com" - issuer := "https://accounts.example.com" - token := &oidc.IDToken{Subject: usernameVal, Issuer: issuer} - - principal, err := username(ctx, token) - if err != nil { - t.Errorf("Expected test success, got %v", err) - } - if principal.Name(ctx) != token.Subject { - t.Errorf("Expected subject %s, got %s", token.Subject, principal.Name(ctx)) - } - raw, ok := principal.(*ChallengeResult) - if !ok { - t.Fatal("expected principal to be a ChallengeResult") - } - - if raw.Issuer != issuer { - t.Errorf("Expected issuer %s, got %s", issuer, raw.Issuer) - } - if raw.Value != usernameWithEmail { - t.Errorf("Expected subject value %s, got %s", usernameWithEmail, raw.Value) - } - if raw.TypeVal != UsernameValue { - t.Errorf("Expected type %v, got %v", UsernameValue, raw.TypeVal) - } - if raw.subject != token.Subject { - t.Errorf("Expected subject %s, got %s", token.Subject, raw.subject) - } -} - -func TestUsernameInvalidChar(t *testing.T) { - cfg := &config.FulcioConfig{ - OIDCIssuers: map[string]config.OIDCIssuer{ - "https://accounts.example.com": { - IssuerURL: "https://accounts.example.com", - ClientID: "sigstore", - SubjectDomain: "example.com", - Type: config.IssuerTypeUsername, - }, - }, - } - ctx := config.With(context.Background(), cfg) - usernameVal := "foobar@example.com" - issuer := "https://accounts.example.com" - token := &oidc.IDToken{Subject: usernameVal, Issuer: issuer} - - _, err := username(ctx, token) - if err == nil { - t.Errorf("expected test failure, got no error") - } - msg := "username cannot contain @ character" - if err.Error() != msg { - t.Errorf("unexpected test failure message, got %s, expected %s", err.Error(), msg) - } -} - func failErr(t *testing.T, err error) { if err != nil { t.Fatal(err) diff --git a/pkg/identity/uri/principal.go b/pkg/identity/uri/principal.go new file mode 100644 index 000000000..5a260aa16 --- /dev/null +++ b/pkg/identity/uri/principal.go @@ -0,0 +1,84 @@ +// Copyright 2022 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 uri + +import ( + "context" + "crypto/x509" + "errors" + "fmt" + "net/url" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/sigstore/fulcio/pkg/ca/x509ca" + "github.com/sigstore/fulcio/pkg/config" + "github.com/sigstore/fulcio/pkg/identity" +) + +type principal struct { + issuer string + uri string +} + +func PrincipalFromIDToken(ctx context.Context, token *oidc.IDToken) (identity.Principal, error) { + uriWithSubject := token.Subject + + cfg, ok := config.FromContext(ctx).GetIssuer(token.Issuer) + if !ok { + return nil, errors.New("invalid configuration for OIDC ID Token issuer") + } + + // The subject hostname must exactly match the subject domain from the configuration + uSubject, err := url.Parse(uriWithSubject) + if err != nil { + return nil, err + } + uDomain, err := url.Parse(cfg.SubjectDomain) + if err != nil { + return nil, err + } + if uSubject.Scheme != uDomain.Scheme { + return nil, fmt.Errorf("subject URI scheme (%s) must match expected domain URI scheme (%s)", uSubject.Scheme, uDomain.Scheme) + } + if uSubject.Hostname() != uDomain.Hostname() { + return nil, fmt.Errorf("subject hostname (%s) must match expected domain (%s)", uSubject.Hostname(), uDomain.Hostname()) + } + + return principal{ + issuer: token.Issuer, + uri: uriWithSubject, + }, nil +} + +func (p principal) Name(context.Context) string { + return p.uri +} + +func (p principal) Embed(ctx context.Context, cert *x509.Certificate) error { + subjectURI, err := url.Parse(p.uri) + if err != nil { + return err + } + cert.URIs = []*url.URL{subjectURI} + + cert.ExtraExtensions, err = x509ca.Extensions{ + Issuer: p.issuer, + }.Render() + if err != nil { + return err + } + + return nil +} diff --git a/pkg/identity/uri/principal_test.go b/pkg/identity/uri/principal_test.go new file mode 100644 index 000000000..c72451d16 --- /dev/null +++ b/pkg/identity/uri/principal_test.go @@ -0,0 +1,222 @@ +// Copyright 2022 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 uri + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/asn1" + "errors" + "fmt" + "net/url" + "testing" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/google/go-cmp/cmp" + "github.com/sigstore/fulcio/pkg/config" +) + +func TestPrincipalFromIDToken(t *testing.T) { + tests := map[string]struct { + Token *oidc.IDToken + Principal principal + WantErr bool + }{ + `Valid token authenticates with correct claims`: { + Token: &oidc.IDToken{Issuer: "https://accounts.example.com", Subject: "https://example.com/users/1"}, + Principal: principal{ + issuer: "https://accounts.example.com", + uri: "https://example.com/users/1", + }, + WantErr: false, + }, + `Issuer URL mismatch should error`: { + Token: &oidc.IDToken{Issuer: "https://notaccounts.example.com", Subject: "https://example.com/users/1"}, + WantErr: true, + }, + `Incorrect subject domain hostname should error`: { + Token: &oidc.IDToken{Issuer: "https://accounts.example.com", Subject: "https://notexample.com/users/1"}, + WantErr: true, + }, + `Incorrect subject domain scheme should error`: { + Token: &oidc.IDToken{Issuer: "https://accounts.example.com", Subject: "http://example.com/users/1"}, + WantErr: true, + }, + `Invalid uri should error`: { + Token: &oidc.IDToken{Issuer: "https://accounts.example.com", Subject: "not\n#a#uri"}, + WantErr: true, + }, + } + + cfg := &config.FulcioConfig{ + OIDCIssuers: map[string]config.OIDCIssuer{ + "https://accounts.example.com": { + IssuerURL: "https://accounts.example.com", + ClientID: "sigstore", + SubjectDomain: "https://example.com", + Type: config.IssuerTypeURI, + }, + }, + } + ctx := config.With(context.Background(), cfg) + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + untyped, err := PrincipalFromIDToken(ctx, test.Token) + if err != nil { + if !test.WantErr { + t.Fatal("didn't expect error", err) + } + return + } + if err == nil && test.WantErr { + t.Fatal("expected error but got none") + } + + p, ok := untyped.(principal) + if !ok { + t.Errorf("Got wrong principal type %v", untyped) + } + if p != test.Principal { + t.Errorf("got %v principal and expected %v", p, test.Principal) + } + }) + } +} + +func TestName(t *testing.T) { + tests := map[string]struct { + Token *oidc.IDToken + ExpectedName string + }{ + `Valid token authenticates with correct claims`: { + Token: &oidc.IDToken{Issuer: "https://accounts.example.com", Subject: "https://example.com/users/1"}, + ExpectedName: "https://example.com/users/1", + }, + } + + cfg := &config.FulcioConfig{ + OIDCIssuers: map[string]config.OIDCIssuer{ + "https://accounts.example.com": { + IssuerURL: "https://accounts.example.com", + ClientID: "sigstore", + SubjectDomain: "https://example.com", + Type: config.IssuerTypeURI, + }, + }, + } + ctx := config.With(context.Background(), cfg) + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + p, err := PrincipalFromIDToken(ctx, test.Token) + if err != nil { + t.Fatal("didn't expect error", err) + } + + if p.Name(ctx) != test.ExpectedName { + t.Errorf("got %v principal name and expected %v", p.Name(ctx), test.ExpectedName) + } + }) + } + +} + +func TestEmbed(t *testing.T) { + tests := map[string]struct { + Principal principal + WantErr bool + WantFacts map[string]func(x509.Certificate) error + }{ + `Valid uri challenge`: { + Principal: principal{ + issuer: `https://accounts.example.com`, + uri: `https://example.com/users/1`, + }, + WantErr: false, + WantFacts: map[string]func(x509.Certificate) error{ + `Issuer is example.com`: factIssuerIs(`https://accounts.example.com`), + `SAN is https://example.com/users/1`: func(cert x509.Certificate) error { + WantURI, err := url.Parse("https://example.com/users/1") + if err != nil { + return err + } + if len(cert.URIs) != 1 { + return errors.New("no URI SAN set") + } + if diff := cmp.Diff(cert.URIs[0], WantURI); diff != "" { + return errors.New(diff) + } + return nil + }, + }, + }, + `invalid uri fails`: { + Principal: principal{ + issuer: `example.com`, + uri: "\nbadurl", + }, + WantErr: true, + }, + `Empty issuer url should fail to render extensions`: { + Principal: principal{ + issuer: "", + uri: "https://example.com/foo/bar", + }, + WantErr: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var cert x509.Certificate + err := test.Principal.Embed(context.TODO(), &cert) + if err != nil { + if !test.WantErr { + t.Error(err) + } + return + } else if test.WantErr { + t.Error("expected error") + } + for factName, fact := range test.WantFacts { + t.Run(factName, func(t *testing.T) { + if err := fact(cert); err != nil { + t.Error(err) + } + }) + } + }) + } +} + +func factIssuerIs(issuer string) func(x509.Certificate) error { + return factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1}, issuer) +} + +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) { + 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/identity/username/principal.go b/pkg/identity/username/principal.go new file mode 100644 index 000000000..630e94b2d --- /dev/null +++ b/pkg/identity/username/principal.go @@ -0,0 +1,73 @@ +// Copyright 2022 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 username + +import ( + "context" + "crypto/x509" + "errors" + "fmt" + "strings" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/sigstore/fulcio/pkg/ca/x509ca" + "github.com/sigstore/fulcio/pkg/config" + "github.com/sigstore/fulcio/pkg/identity" +) + +type principal struct { + issuer string + username string + emailAddress string +} + +func PrincipalFromIDToken(ctx context.Context, token *oidc.IDToken) (identity.Principal, error) { + username := token.Subject + + if strings.Contains(username, "@") { + return nil, errors.New("username cannot contain @ character") + } + + cfg, ok := config.FromContext(ctx).GetIssuer(token.Issuer) + if !ok { + return nil, errors.New("invalid configuration for OIDC ID Token issuer") + } + + emailSubject := fmt.Sprintf("%s@%s", username, cfg.SubjectDomain) + + return principal{ + issuer: token.Issuer, + username: username, + emailAddress: emailSubject, + }, nil +} + +func (p principal) Name(context.Context) string { + return p.username +} + +func (p principal) Embed(ctx context.Context, cert *x509.Certificate) error { + cert.EmailAddresses = []string{p.emailAddress} + + var err error + cert.ExtraExtensions, err = x509ca.Extensions{ + Issuer: p.issuer, + }.Render() + if err != nil { + return err + } + + return nil +} diff --git a/pkg/identity/username/principal_test.go b/pkg/identity/username/principal_test.go new file mode 100644 index 000000000..158373f20 --- /dev/null +++ b/pkg/identity/username/principal_test.go @@ -0,0 +1,205 @@ +// Copyright 2022 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 username + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/asn1" + "errors" + "fmt" + "testing" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/google/go-cmp/cmp" + "github.com/sigstore/fulcio/pkg/config" +) + +func TestPrincipalFromIDToken(t *testing.T) { + tests := map[string]struct { + Token *oidc.IDToken + Principal principal + WantErr bool + }{ + `Valid token authenticates with correct claims`: { + Token: &oidc.IDToken{Issuer: "https://accounts.example.com", Subject: "alice"}, + Principal: principal{ + issuer: "https://accounts.example.com", + username: "alice", + emailAddress: "alice@example.com", + }, + WantErr: false, + }, + `username cannot contain @`: { + Token: &oidc.IDToken{Issuer: "https://accounts.example.com", Subject: "alice@example.com"}, + WantErr: true, + }, + `invalid issuer should error`: { + Token: &oidc.IDToken{Issuer: "https://notaccounts.example.com", Subject: "alice"}, + WantErr: true, + }, + } + + cfg := &config.FulcioConfig{ + OIDCIssuers: map[string]config.OIDCIssuer{ + "https://accounts.example.com": { + IssuerURL: "https://accounts.example.com", + ClientID: "sigstore", + SubjectDomain: "example.com", + Type: config.IssuerTypeUsername, + }, + }, + } + ctx := config.With(context.Background(), cfg) + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + untyped, err := PrincipalFromIDToken(ctx, test.Token) + if err != nil { + if !test.WantErr { + t.Fatal("didn't expect error", err) + } + return + } + if err == nil && test.WantErr { + t.Fatal("expected error but got none") + } + + p, ok := untyped.(principal) + if !ok { + t.Errorf("Got wrong principal type %v", untyped) + } + if p != test.Principal { + t.Errorf("got %v principal and expected %v", p, test.Principal) + } + }) + } +} + +func TestName(t *testing.T) { + tests := map[string]struct { + Token *oidc.IDToken + ExpectedName string + }{ + `Valid token authenticates with correct claims`: { + Token: &oidc.IDToken{Issuer: "https://accounts.example.com", Subject: "alice"}, + ExpectedName: "alice", + }, + } + + cfg := &config.FulcioConfig{ + OIDCIssuers: map[string]config.OIDCIssuer{ + "https://accounts.example.com": { + IssuerURL: "https://accounts.example.com", + ClientID: "sigstore", + SubjectDomain: "example.com", + Type: config.IssuerTypeUsername, + }, + }, + } + ctx := config.With(context.Background(), cfg) + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + p, err := PrincipalFromIDToken(ctx, test.Token) + if err != nil { + t.Fatal("didn't expect error", err) + } + + if p.Name(ctx) != test.ExpectedName { + t.Errorf("got %v principal name and expected %v", p.Name(ctx), test.ExpectedName) + } + }) + } + +} + +func TestEmbed(t *testing.T) { + tests := map[string]struct { + Principal principal + WantErr bool + WantFacts map[string]func(x509.Certificate) error + }{ + `Valid uri challenge`: { + Principal: principal{ + issuer: `https://accounts.example.com`, + username: "alice", + emailAddress: "alice@example.com", + }, + WantErr: false, + WantFacts: map[string]func(x509.Certificate) error{ + `Issuer is example.com`: factIssuerIs(`https://accounts.example.com`), + `SAN is alice@example.com`: func(cert x509.Certificate) error { + if len(cert.EmailAddresses) != 1 { + return errors.New("no URI SAN set") + } + if diff := cmp.Diff(cert.EmailAddresses[0], "alice@example.com"); diff != "" { + return errors.New(diff) + } + return nil + }, + }, + }, + `Empty issuer url should fail to render extensions`: { + Principal: principal{ + issuer: "", + emailAddress: "alice@example.com", + username: "alice", + }, + WantErr: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var cert x509.Certificate + err := test.Principal.Embed(context.TODO(), &cert) + if err != nil { + if !test.WantErr { + t.Error(err) + } + return + } else if test.WantErr { + t.Error("expected error") + } + for factName, fact := range test.WantFacts { + t.Run(factName, func(t *testing.T) { + if err := fact(cert); err != nil { + t.Error(err) + } + }) + } + }) + } +} + +func factIssuerIs(issuer string) func(x509.Certificate) error { + return factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1}, issuer) +} + +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) { + 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") + } +}