-
Notifications
You must be signed in to change notification settings - Fork 137
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Username scoped to domain OIDC type
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 <[email protected]>
- Loading branch information
1 parent
b22fb53
commit 3c373e5
Showing
6 changed files
with
322 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 := "[email protected]" | ||
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 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 := "[email protected]" | ||
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 | ||
|
@@ -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) | ||
|
Oops, something went wrong.