Skip to content

Commit

Permalink
Add identity package
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
Nathan Smith committed May 5, 2022
1 parent 05aa4bb commit b69f357
Show file tree
Hide file tree
Showing 4 changed files with 270 additions and 0 deletions.
11 changes: 11 additions & 0 deletions pkg/identity/issuer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package identity

import "context"

type Issuer interface {
// Can this Issuer authenticate the given issuer URL?
Match(ctx context.Context, url string) bool

// Authenticate ID token and return Principal on success
Authenticate(ctx context.Context, token string) (Principal, error)
}
45 changes: 45 additions & 0 deletions pkg/identity/issuerpool.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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
}
199 changes: 199 additions & 0 deletions pkg/identity/issuerpool_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package identity

import (
"context"
"crypto/x509"
"errors"
"testing"
)

type dummyPrincipal struct {
name string
}

func (d dummyPrincipal) Name(ctx context.Context) string {
return d.name
}

func (d dummyPrincipal) Embed(ctx context.Context, cert x509.Certificate) (x509.Certificate, error) {
return cert, nil
}

type dummyIssuer struct {
match func(context.Context, string) bool
auth func(context.Context, string) (Principal, error)
}

func (d dummyIssuer) Match(ctx context.Context, url string) bool {
return d.match(ctx, url)
}

func (d dummyIssuer) Authenticate(ctx context.Context, token string) (Principal, error) {
return d.auth(ctx, token)
}

func TestIssuerPool(t *testing.T) {
var (
// Example principals
alice = dummyPrincipal{`alice`}
bob = dummyPrincipal{`bob`}

// Example issuers
bobIfExampleCom = dummyIssuer{
match: func(_ context.Context, url string) bool {
return url == `example.com`
},
auth: func(_ context.Context, tok string) (Principal, error) {
return bob, nil
},
}
aliceIfOtherCom = dummyIssuer{
match: func(_ context.Context, url string) bool {
return url == `other.com`
},
auth: func(_ context.Context, tok string) (Principal, error) {
return alice, nil
},
}
matchThenRejectAll = dummyIssuer{
match: func(_ context.Context, url string) bool {
return true
},
auth: func(_ context.Context, tok 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)
}
}
})
}
}
15 changes: 15 additions & 0 deletions pkg/identity/principal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package identity

import (
"context"
"crypto/x509"
)

type Principal interface {
// URI, email etc of principal (usually matches `sub` from ID token)
Name(ctx context.Context) string

// Embed all SubjectNameAlt and custom x509 extension information into
// certificate.
Embed(ctx context.Context, cert x509.Certificate) (x509.Certificate, error)
}

0 comments on commit b69f357

Please sign in to comment.