From 152c20daa7852ec5552bb5dfce0eec4421f3674e Mon Sep 17 00:00:00 2001 From: Nathan Smith <12156185+nsmith5@users.noreply.github.com> Date: Thu, 5 May 2022 16:01:01 -0700 Subject: [PATCH] Add new Issuer and Principal abstractions (#558) Includes principal and issuer abstracts and an issuerpool Signed-off-by: Nathan Smith --- pkg/identity/issuer.go | 26 ++++ pkg/identity/issuerpool.go | 59 +++++++++ pkg/identity/issuerpool_test.go | 213 ++++++++++++++++++++++++++++++++ pkg/identity/principal.go | 29 +++++ 4 files changed, 327 insertions(+) create mode 100644 pkg/identity/issuer.go create mode 100644 pkg/identity/issuerpool.go create mode 100644 pkg/identity/issuerpool_test.go create mode 100644 pkg/identity/principal.go diff --git a/pkg/identity/issuer.go b/pkg/identity/issuer.go new file mode 100644 index 000000000..1392c9f6d --- /dev/null +++ b/pkg/identity/issuer.go @@ -0,0 +1,26 @@ +// 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 identity + +import "context" + +type Issuer interface { + // Match checks if this issuer can authenticate tokens from a given issuer URL + Match(ctx context.Context, url string) bool + + // Authenticate ID token and return Principal on success. The ID token's signature + // is verified in the call -- invalid signature must result in an error. + Authenticate(ctx context.Context, token string) (Principal, error) +} diff --git a/pkg/identity/issuerpool.go b/pkg/identity/issuerpool.go new file mode 100644 index 000000000..fcaa136f8 --- /dev/null +++ b/pkg/identity/issuerpool.go @@ -0,0 +1,59 @@ +// 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 identity + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +type IssuerPool []Issuer + +func (p IssuerPool) Authenticate(ctx context.Context, token string) (Principal, error) { + url, err := extractIssuerURL(token) + if err != nil { + return nil, err + } + + for _, issuer := range p { + if issuer.Match(ctx, url) { + return issuer.Authenticate(ctx, token) + } + } + return nil, fmt.Errorf("failed to match issuer URL %s from token with any configured providers", url) +} + +func extractIssuerURL(token string) (string, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "", fmt.Errorf("oidc: malformed jwt, expected 3 parts got %d", len(parts)) + } + + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", fmt.Errorf("oidc: malformed jwt payload: %w", err) + } + + var payload struct { + Issuer string `json:"iss"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + return "", fmt.Errorf("oidc: failed to unmarshal claims: %w", err) + } + return payload.Issuer, nil +} diff --git a/pkg/identity/issuerpool_test.go b/pkg/identity/issuerpool_test.go new file mode 100644 index 000000000..de87e64cd --- /dev/null +++ b/pkg/identity/issuerpool_test.go @@ -0,0 +1,213 @@ +// 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 identity + +import ( + "context" + "crypto/x509" + "errors" + "testing" +) + +type testPrincipal struct { + name string +} + +func (p testPrincipal) Name(ctx context.Context) string { + return p.name +} + +func (p testPrincipal) Embed(ctx context.Context, cert *x509.Certificate) error { + return nil +} + +type testIssuer struct { + match func(context.Context, string) bool + auth func(context.Context, string) (Principal, error) +} + +func (i testIssuer) Match(ctx context.Context, url string) bool { + return i.match(ctx, url) +} + +func (i testIssuer) Authenticate(ctx context.Context, token string) (Principal, error) { + return i.auth(ctx, token) +} + +func TestIssuerPool(t *testing.T) { + var ( + // Example principals + alice = testPrincipal{`alice`} + bob = testPrincipal{`bob`} + + // Example issuers + bobIfExampleCom = testIssuer{ + match: func(_ context.Context, url string) bool { + return url == `example.com` + }, + auth: func(context.Context, string) (Principal, error) { + return bob, nil + }, + } + aliceIfOtherCom = testIssuer{ + match: func(_ context.Context, url string) bool { + return url == `other.com` + }, + auth: func(context.Context, string) (Principal, error) { + return alice, nil + }, + } + matchThenRejectAll = testIssuer{ + match: func(context.Context, string) bool { + return true + }, + auth: func(context.Context, string) (Principal, error) { + return nil, errors.New(`boooooo`) + }, + } + + // Example tokens + // iss == example.com + exampleToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJleGFtcGxlLmNvbSJ9.eBJFurm45FSlxt9c7r339xkQC7yqn2O9SlBldCFAQhk` + // iss == other.com + otherToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJvdGhlci5jb20ifQ.GtTvBmBvm0kPIfBctKDD1GDavmtlQXBQIDjGg6k2kOA` + // iss == bad.com + badToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiYWQuY29tIn0.aW-Zyc3JTnqI0uqc1VzNY9_5BhmhXmUksGaFEiiZCHU` + // bad format token + badFormatToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.??.aW-Zyc3JTnqI0uqc1VzNY9_5BhmhXmUksGaFEiiZCHU` + ) + + tests := map[string]struct { + Pool IssuerPool + Token string + ExpectedPrincipal Principal + WantErr bool + }{ + `example.com only pool should allow example.com tokens`: { + Pool: IssuerPool{bobIfExampleCom}, + Token: exampleToken, + ExpectedPrincipal: bob, + WantErr: false, + }, + `example.com only pool should not allow other.com tokens`: { + Pool: IssuerPool{bobIfExampleCom}, + Token: otherToken, + WantErr: true, + }, + `example.com and other.com pool should match other.com token to alice`: { + Pool: IssuerPool{bobIfExampleCom, aliceIfOtherCom}, + Token: otherToken, + ExpectedPrincipal: alice, + WantErr: false, + }, + `example.com and other.com pool should match example.com token to bob`: { + Pool: IssuerPool{bobIfExampleCom, aliceIfOtherCom}, + Token: exampleToken, + ExpectedPrincipal: bob, + WantErr: false, + }, + `example.com and other.com pool should reject bad.com token`: { + Pool: IssuerPool{bobIfExampleCom, aliceIfOtherCom}, + Token: badToken, + WantErr: true, + }, + `example.com and other.com pool should reject badly formatted token`: { + Pool: IssuerPool{bobIfExampleCom, aliceIfOtherCom}, + Token: badFormatToken, + WantErr: true, + }, + `empty pool should never authenticate`: { + Pool: IssuerPool{}, + Token: exampleToken, + WantErr: true, + }, + `match then reject all pool should never authenticate`: { + Pool: IssuerPool{matchThenRejectAll}, + Token: exampleToken, + WantErr: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + principal, err := test.Pool.Authenticate(ctx, test.Token) + if err != nil { + if !test.WantErr { + t.Error("Didn't expect error", err) + } + } else { + if principal != test.ExpectedPrincipal { + t.Errorf("Got principal %s, but wanted %s", principal.Name(ctx), test.ExpectedPrincipal.Name(ctx)) + } + } + }) + } +} + +func TestExtractIssuerURL(t *testing.T) { + tests := map[string]struct { + Token string + ExpectedURL string + WantErr bool + }{ + `issuer example.com`: { + // Valid token (HS256 with `derp` secret) and iss = example.com + Token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJleGFtcGxlLmNvbSJ9.LGkuVtRymNgdZFn4v_jRJCJVwdt1wZDw588tbXC8VTU`, + ExpectedURL: `example.com`, + WantErr: false, + }, + `no issuer claim`: { + // Valid JWT but no `iss` claim. Claims are {"foo": "bar"}. + Token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.kOu-Qu-GoCH3G70LKrm_W9DJj2MpF4C5QweznLgGZgc`, + WantErr: true, + }, + `Not enough token parts`: { + // Has 2 parts instead of 3 + Token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ`, + WantErr: true, + }, + `Too many token parts`: { + // Has 4 parts instead of 3 + Token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.eyJmb28iOiJiYXIifQ.eyJmb28iOiJiYXIifQ`, + WantErr: true, + }, + `Bad claims base64 encoding`: { + // ??? are illegal base64 url safe characters + Token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.???.kOu-Qu-GoCH3G70LKrm_W9DJj2MpF4C5QweznLgGZgc`, + WantErr: true, + }, + `Bad claims JSON format`: { + // fXs decodes to `}{` which is note valid JSON + Token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.fXs.kOu-Qu-GoCH3G70LKrm_W9DJj2MpF4C5QweznLgGZgc`, + WantErr: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + gotURL, err := extractIssuerURL(test.Token) + if err != nil { + if !test.WantErr { + t.Error(err) + } + } else { + if gotURL != test.ExpectedURL { + t.Errorf("Wanted %s and got %s for issuer url", test.ExpectedURL, gotURL) + } + } + }) + } +} diff --git a/pkg/identity/principal.go b/pkg/identity/principal.go new file mode 100644 index 000000000..f706082f6 --- /dev/null +++ b/pkg/identity/principal.go @@ -0,0 +1,29 @@ +// 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 identity + +import ( + "context" + "crypto/x509" +) + +type Principal interface { + // URI, email etc of principal + Name(ctx context.Context) string + + // Embed all SubjectAltName and custom x509 extension information into + // certificate. + Embed(ctx context.Context, cert *x509.Certificate) error +}