Skip to content

Commit

Permalink
Add new Issuer and Principal abstractions (#558)
Browse files Browse the repository at this point in the history
Includes principal and issuer abstracts and an issuerpool

Signed-off-by: Nathan Smith <nathan@chainguard.dev>
  • Loading branch information
nsmith5 authored May 5, 2022
1 parent a08e6a7 commit 152c20d
Show file tree
Hide file tree
Showing 4 changed files with 327 additions and 0 deletions.
26 changes: 26 additions & 0 deletions pkg/identity/issuer.go
Original file line number Diff line number Diff line change
@@ -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)
}
59 changes: 59 additions & 0 deletions pkg/identity/issuerpool.go
Original file line number Diff line number Diff line change
@@ -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
}
213 changes: 213 additions & 0 deletions pkg/identity/issuerpool_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
29 changes: 29 additions & 0 deletions pkg/identity/principal.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 152c20d

Please sign in to comment.