Skip to content

Commit

Permalink
Add Username scoped to domain OIDC type
Browse files Browse the repository at this point in the history
This implements the second part of #398, adding support for OIDC
subjects that are simply usernames. A configured domain will be appended
to the username and included as a SAN email address.

Like #455, token issuers must partially match the configured domain. The
top level and second level domain must match, and it's expected that we
validate ownership for what's configured in the issuer and domain
fields.

Signed-off-by: Hayden Blauzvern <hblauzvern@google.com>
  • Loading branch information
haydentherapper committed Mar 14, 2022
1 parent 8bc09cf commit 9029402
Show file tree
Hide file tree
Showing 6 changed files with 322 additions and 6 deletions.
169 changes: 166 additions & 3 deletions pkg/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,172 @@ func TestMissingRootFails(t *testing.T) {

// oidcTestContainer holds values needed for each API test invocation
type oidcTestContainer struct {
Signer jose.Signer
Issuer string
Subject string
Signer jose.Signer
Issuer string
Subject string
ExpectedSubject string
}

// customClaims holds additional JWT claims for email-based OIDC tokens
type customClaims struct {
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}

// Tests API for email and username subject types
func TestAPIWithEmail(t *testing.T) {
emailSigner, emailIssuer := newOIDCIssuer(t)
usernameSigner, usernameIssuer := newOIDCIssuer(t)

issuerDomain, err := url.Parse(usernameIssuer)
if err != nil {
t.Fatal("Issuer URL could not be parsed", err)
}

// Create a FulcioConfig that supports these issuers.
cfg, err := config.Read([]byte(fmt.Sprintf(`{
"OIDCIssuers": {
%q: {
"IssuerURL": %q,
"ClientID": "sigstore",
"Type": "email"
},
%q: {
"IssuerURL": %q,
"ClientID": "sigstore",
"SubjectDomain": %q,
"Type": "username"
}
}
}`, emailIssuer, emailIssuer, usernameIssuer, usernameIssuer, issuerDomain.Hostname())))
if err != nil {
t.Fatalf("config.Read() = %v", err)
}

emailSubject := "foo@example.com"
usernameSubject := "foo"
expectedUsernamedSubject := fmt.Sprintf("%s@%s", usernameSubject, issuerDomain.Hostname())

for _, c := range []oidcTestContainer{
{
Signer: emailSigner, Issuer: emailIssuer, Subject: emailSubject, ExpectedSubject: emailSubject,
},
{
Signer: usernameSigner, Issuer: usernameIssuer, Subject: usernameSubject, ExpectedSubject: expectedUsernamedSubject,
}} {
// Create an OIDC token using this issuer's signer.
tok, err := jwt.Signed(c.Signer).Claims(jwt.Claims{
Issuer: c.Issuer,
IssuedAt: jwt.NewNumericDate(time.Now()),
Expiry: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
Subject: c.Subject,
Audience: jwt.Audience{"sigstore"},
}).Claims(customClaims{Email: c.Subject, EmailVerified: true}).CompactSerialize()
if err != nil {
t.Fatalf("CompactSerialize() = %v", err)
}

// Stand up an ephemeral CA we can use for signing certificate requests.
eca, err := ephemeralca.NewEphemeralCA()
if err != nil {
t.Fatalf("ephemeralca.NewEphemeralCA() = %v", err)
}

ctlogServer := fakeCTLogServer(t)
if ctlogServer == nil {
t.Fatalf("Failed to create the fake ctlog server")
}

// Create a test HTTP server to host our API.
h := New(ctl.New(ctlogServer.URL), eca)
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// For each request, infuse context with our snapshot of the FulcioConfig.
ctx = config.With(ctx, cfg)

h.ServeHTTP(rw, r.WithContext(ctx))
}))
t.Cleanup(server.Close)

// Create an API client that speaks to the API endpoint we created above.
u, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("url.Parse() = %v", err)
}
client := NewClient(u)

// Sign the subject with our keypair, and provide the public key
// for verification.
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("GenerateKey() = %v", err)
}
pubBytes, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
t.Fatalf("x509.MarshalPKIXPublicKey() = %v", err)
}
hash := sha256.Sum256([]byte(c.Subject))
proof, err := ecdsa.SignASN1(rand.Reader, priv, hash[:])
if err != nil {
t.Fatalf("SignASN1() = %v", err)
}

// Hit the API to have it sign our certificate.
resp, err := client.SigningCert(CertificateRequest{
PublicKey: Key{
Content: pubBytes,
},
SignedEmailAddress: proof,
}, tok)
if err != nil {
t.Fatalf("SigningCert() = %v", err)
}

if string(resp.SCT) == "" {
t.Error("Did not get SCT")
}

// Check that we get the CA root back as well.
root, err := client.RootCert()
if err != nil {
t.Fatal("Failed to get Root", err)
}
if root == nil {
t.Fatal("Got nil root back")
}
if len(root.ChainPEM) == 0 {
t.Fatal("Got back empty chain")
}
block, rest := pem.Decode(root.ChainPEM)
if block == nil {
t.Fatal("Did not find PEM data")
}
if len(rest) != 0 {
t.Fatal("Got more than bargained for, should only have one cert")
}
if block.Type != "CERTIFICATE" {
t.Fatalf("Unexpected root type, expected CERTIFICATE, got %s", block.Type)
}
rootCert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("Failed to parse the received root cert: %v", err)
}
if !rootCert.Equal(eca.RootCA) {
t.Errorf("Root CA does not match, wanted %+v got %+v", eca.RootCA, rootCert)
}
// Compare leaf certificate values
block, _ = pem.Decode(resp.CertPEM)
leafCert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("Failed to parse the received leaf cert: %v", err)
}
if len(leafCert.EmailAddresses) != 1 {
t.Fatalf("Unexpected length of leaf certificate URIs, expected 1, got %d", len(leafCert.URIs))
}
if leafCert.EmailAddresses[0] != c.ExpectedSubject {
t.Fatalf("Subjects do not match: Expected %v, got %v", c.ExpectedSubject, leafCert.EmailAddresses[0])
}
}
}

// Tests API for SPIFFE and URI subject types
Expand Down
2 changes: 2 additions & 0 deletions pkg/api/ca.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,8 @@ func ExtractSubject(ctx context.Context, tok *oidc.IDToken, publicKey crypto.Pub
return challenges.Kubernetes(ctx, tok, publicKey, challenge)
case config.IssuerTypeURI:
return challenges.URI(ctx, tok, publicKey, challenge)
case config.IssuerTypeUsername:
return challenges.Username(ctx, tok, publicKey, challenge)
default:
return nil, fmt.Errorf("unsupported issuer: %s", iss.Type)
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/ca/x509ca/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ func MakeX509(subject *challenges.ChallengeResult) (*x509.Certificate, error) {
return nil, ca.ValidationError(err)
}
cert.URIs = []*url.URL{subjectURI}
case challenges.UsernameValue:
cert.EmailAddresses = []string{subject.Value}
}
cert.ExtraExtensions = append(IssuerExtension(subject.Issuer), AdditionalExtensions(subject)...)
return cert, nil
Expand Down
54 changes: 51 additions & 3 deletions pkg/challenges/challenges.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const (
GithubWorkflowValue
KubernetesValue
URIValue
UsernameValue
)

// All hostnames for subject and issuer OIDC claims must have at least a
Expand Down Expand Up @@ -273,6 +274,50 @@ func URI(ctx context.Context, principal *oidc.IDToken, pubKey crypto.PublicKey,
}, nil
}

func Username(ctx context.Context, principal *oidc.IDToken, pubKey crypto.PublicKey, challenge []byte) (*ChallengeResult, error) {
username := principal.Subject

cfg, ok := config.FromContext(ctx).GetIssuer(principal.Issuer)
if !ok {
return nil, errors.New("invalid configuration for OIDC ID Token issuer")
}

// The domain in the configuration must match the domain (excluding the subdomain) of the issuer
// In order to declare this configuration, a test must have been done to prove ownership
// over both the issuer and domain configuration values.
// Valid examples:
// * domain = https://example.com/users/user1, issuer = https://accounts.example.com
// * domain = https://accounts.example.com/users/user1, issuer = https://accounts.example.com
// * domain = https://users.example.com/users/user1, issuer = https://accounts.example.com
uIssuer, err := url.Parse(cfg.IssuerURL)
if err != nil {
return nil, err
}
if err := isDomainAllowed(cfg.SubjectDomain, uIssuer.Hostname()); err != nil {
return nil, err
}

// Check the proof - A signature over the OIDC token subject
if err := CheckSignature(pubKey, challenge, username); err != nil {
return nil, err
}

issuer, err := oauthflow.IssuerFromIDToken(principal, cfg.IssuerClaim)
if err != nil {
return nil, err
}

emailSubject := fmt.Sprintf("%s@%s", username, cfg.SubjectDomain)

// Now issue cert!
return &ChallengeResult{
Issuer: issuer,
PublicKey: pubKey,
TypeVal: UsernameValue,
Value: emailSubject,
}, nil
}

func kubernetesToken(token *oidc.IDToken) (string, error) {
// Extract custom claims
var claims struct {
Expand Down Expand Up @@ -363,13 +408,16 @@ func isSpiffeIDAllowed(host, spiffeID string) bool {
// isURISubjectAllowed compares the subject and issuer URIs,
// returning an error if the scheme or the hostnames do not match
func isURISubjectAllowed(subject, issuer *url.URL) error {
subjectHostname := subject.Hostname()
issuerHostname := issuer.Hostname()

if subject.Scheme != issuer.Scheme {
return fmt.Errorf("subject (%s) and issuer (%s) URI schemes do not match", subject.Scheme, issuer.Scheme)
}

return isDomainAllowed(subject.Hostname(), issuer.Hostname())
}

// isDomainAllowed compares two hostnames, returning an error if the
// top-level and second-level domains do not match
func isDomainAllowed(subjectHostname, issuerHostname string) error {
// If the hostnames exactly match, return early
if subjectHostname == issuerHostname {
return nil
Expand Down
99 changes: 99 additions & 0 deletions pkg/challenges/challenges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,44 @@ func TestURI(t *testing.T) {
}
}

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)
username := "foobar"
usernameWithEmail := "foobar@example.com"
issuer := "https://accounts.example.com"
token := &oidc.IDToken{Subject: username, Issuer: issuer}

priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
failErr(t, err)
h := sha256.Sum256([]byte(username))
signature, err := priv.Sign(rand.Reader, h[:], crypto.SHA256)
failErr(t, err)

result, err := Username(ctx, token, priv.Public(), signature)
if err != nil {
t.Errorf("Expected test success, got %v", err)
}
if result.Issuer != issuer {
t.Errorf("Expected issuer %s, got %s", issuer, result.Issuer)
}
if result.Value != usernameWithEmail {
t.Errorf("Expected subject %s, got %s", usernameWithEmail, result.Value)
}
if result.TypeVal != UsernameValue {
t.Errorf("Expected type %v, got %v", UsernameValue, result.TypeVal)
}
}

func Test_isURISubjectAllowed(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -177,6 +215,67 @@ func Test_isURISubjectAllowed(t *testing.T) {
}
}

func Test_isDomainAllowed(t *testing.T) {
tests := []struct {
name string
subject string // Parsed to url.URL
issuer string // Parsed to url.URL
want error
}{{
name: "match",
subject: "accounts.example.com",
issuer: "accounts.example.com",
want: nil,
}, {
name: "issuer subdomain",
subject: "example.com",
issuer: "accounts.example.com",
want: nil,
}, {
name: "subject subdomain",
subject: "profiles.example.com",
issuer: "example.com",
want: nil,
}, {
name: "subdomain mismatch",
subject: "profiles.example.com",
issuer: "accounts.example.com",
want: nil,
}, {
name: "subject domain too short",
subject: "example",
issuer: "example.com",
want: fmt.Errorf("subject URI hostname too short: example"),
}, {
name: "issuer domain too short",
subject: "example.com",
issuer: "issuer",
want: fmt.Errorf("issuer URI hostname too short: issuer"),
}, {
name: "domain mismatch",
subject: "example.com",
issuer: "otherexample.com",
want: fmt.Errorf("subject and issuer hostnames do not match: example.com, otherexample.com"),
}, {
name: "top level domain mismatch",
subject: "example.com",
issuer: "example.org",
want: fmt.Errorf("subject and issuer hostnames do not match: example.com, example.org"),
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isDomainAllowed(tt.subject, tt.issuer)
if got == nil && tt.want != nil ||
got != nil && tt.want == nil {
t.Errorf("isURISubjectAllowed() = %v, want %v", got, tt.want)
}
if got != nil && tt.want != nil && got.Error() != tt.want.Error() {
t.Errorf("isURISubjectAllowed() = %v, want %v", got, tt.want)
}
})
}
}

func failErr(t *testing.T, err error) {
if err != nil {
t.Fatal(err)
Expand Down

0 comments on commit 9029402

Please sign in to comment.