Skip to content

Commit fa25204

Browse files
committed
Add multiauth keychains
Adds support for multiauth keychaings for chainguard authentication. We primarily want this to allow authentication for multiple hosts (e.g. staging and test instances). This creates a new URL-based schema for specifying Chainguard identities of the form `chainguard://uidp@cgr.dev?iss=issuer.enforce.dev` that encodes all of the identity information we need into a string that we can pass into the CHAINGUARD_IDENTITY environment var. Backwards compatibility was kept with the existing env var format.
1 parent b14dddc commit fa25204

File tree

9 files changed

+295
-7
lines changed

9 files changed

+295
-7
lines changed

cmd/apk/main.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,15 @@ func main() {
4747
}
4848
log.Printf("listening on %s", port)
4949

50-
// TODO: Auth.
5150
opt := []apk.Option{apk.WithUserAgent(userAgent)}
5251
if *auth || os.Getenv("AUTH") == "keychain" {
5352
opt = append(opt, apk.WithKeychain(gcrane.Keychain))
5453
}
5554
if cgid := os.Getenv("CHAINGUARD_IDENTITY"); cgid != "" {
56-
cgauth := apk.NewChainguardIdentityAuth(cgid, "https://issuer.enforce.dev", "apk.cgr.dev")
55+
cgauth, err := apk.NewChainguardMultiKeychain(cgid, "https://issuer.enforce.dev", "apk.cgr.dev")
56+
if err != nil {
57+
log.Fatalf("error creating apk auth keychain: %v", err)
58+
}
5759
opt = append(opt, apk.WithAuth(cgauth))
5860
}
5961
if eg := os.Getenv("EXAMPLES"); eg != "" {

cmd/oci/main.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ func main() {
5050
opt := []explore.Option{explore.WithUserAgent(userAgent)}
5151
kcs := []authn.Keychain{}
5252
if cgid := os.Getenv("CHAINGUARD_IDENTITY"); cgid != "" {
53-
cgauth := explore.NewChainguardIdentityAuth(cgid, "https://issuer.enforce.dev", "cgr.dev")
53+
cgauth, err := explore.NewChainguardMultiKeychain(cgid, "https://issuer.enforce.dev", "cgr.dev")
54+
if err != nil {
55+
log.Fatalf("error creating OCI auth keychain: %v", err)
56+
}
5457
kcs = append(kcs, cgauth)
5558
}
5659
if *auth || os.Getenv("AUTH") == "keychain" {

internal/apk/auth.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"time"
99

1010
"chainguard.dev/sdk/sts"
11+
"github.com/jonjohnsonjr/dagdotdev/internal/chainguard"
1112
"golang.org/x/time/rate"
1213
"google.golang.org/api/idtoken"
1314
)
@@ -33,6 +34,33 @@ func NewChainguardIdentityAuth(identity, issuer, audience string) Authenticator
3334
}
3435
}
3536

37+
// NewChainguardIdentityAuthFromURL parses a URL of the form uidp@cgr.dev?iss=issuer.enforce.dev
38+
func NewChainguardIdentityAuthFromURL(raw string) (Authenticator, error) {
39+
id, err := chainguard.ParseIdentity(raw)
40+
if err != nil {
41+
return nil, err
42+
}
43+
44+
return NewChainguardIdentityAuth(id.ID, id.Issuer, id.Audience), nil
45+
}
46+
47+
func NewChainguardMultiKeychain(raw string, defaultIssuer string, defaultAudience string) (Authenticator, error) {
48+
var ks []Authenticator
49+
for _, s := range strings.Split(raw, ",") {
50+
if strings.HasPrefix(s, "chainguard://") {
51+
k, err := NewChainguardIdentityAuthFromURL(s)
52+
if err != nil {
53+
return nil, fmt.Errorf("parsing %q: %w", s, err)
54+
}
55+
ks = append(ks, k)
56+
} else {
57+
// Not URL format, fallback to basic identity format.
58+
ks = append(ks, NewChainguardIdentityAuth(s, defaultIssuer, defaultAudience))
59+
}
60+
}
61+
return NewMultiAuthenticator(ks...), nil
62+
}
63+
3664
type cgAuth struct {
3765
id, iss, aud string
3866

@@ -73,3 +101,50 @@ func (a *cgAuth) AddAuth(ctx context.Context, req *http.Request) error {
73101
req.SetBasicAuth("user", a.cgtok)
74102
return nil
75103
}
104+
105+
func (a *cgAuth) Token(ctx context.Context, req *http.Request) (string, error) {
106+
if a.id == "" {
107+
return "", nil
108+
}
109+
if req.Host != strings.TrimPrefix(a.aud, "https://") {
110+
return "", nil
111+
}
112+
113+
a.cgerr = nil
114+
ts, err := idtoken.NewTokenSource(ctx, a.iss)
115+
if err != nil {
116+
a.cgerr = fmt.Errorf("creating token source: %w", err)
117+
return "", err
118+
}
119+
tok, err := ts.Token()
120+
if err != nil {
121+
a.cgerr = fmt.Errorf("getting token: %w", err)
122+
return "", err
123+
}
124+
ctok, err := sts.Exchange(ctx, a.iss, a.aud, tok.AccessToken, sts.WithIdentity(a.id))
125+
if err != nil {
126+
a.cgerr = fmt.Errorf("exchanging token: %w", err)
127+
}
128+
return ctok, a.cgerr
129+
}
130+
131+
type multiAuthenticator struct {
132+
auths []Authenticator
133+
}
134+
135+
func NewMultiAuthenticator(auth ...Authenticator) Authenticator {
136+
return &multiAuthenticator{auths: auth}
137+
}
138+
139+
func (a *multiAuthenticator) AddAuth(ctx context.Context, req *http.Request) error {
140+
for _, auth := range a.auths {
141+
if err := auth.AddAuth(ctx, req); err != nil {
142+
return err
143+
}
144+
if req.Header.Get("Authorization") != "" {
145+
// Auth was set, we're done.
146+
return nil
147+
}
148+
}
149+
return nil
150+
}

internal/apk/auth_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package apk
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
)
10+
11+
type mockAuth struct {
12+
token string
13+
}
14+
15+
func (m *mockAuth) AddAuth(ctx context.Context, req *http.Request) error {
16+
if m.token != "" {
17+
req.SetBasicAuth("user", m.token)
18+
}
19+
return nil
20+
}
21+
22+
func TestNewMultiAuthenticator(t *testing.T) {
23+
auth := NewMultiAuthenticator(&mockAuth{}, &mockAuth{token: "foo"})
24+
req := httptest.NewRequest("GET", "/", nil)
25+
if err := auth.AddAuth(context.Background(), req); err != nil {
26+
t.Fatalf("NewMultiAuthenticator() error = %v", err)
27+
}
28+
want := "Basic " + base64.StdEncoding.EncodeToString([]byte("user:foo"))
29+
got := req.Header.Get("Authorization")
30+
if got != want {
31+
t.Errorf("Authorization = %v, want %v", string(got), want)
32+
}
33+
}
34+
35+
func TestNewChainguardMultiKeychain(t *testing.T) {
36+
_, err := NewChainguardMultiKeychain("uidp,chainguard://uidp@apk.cgr.dev?iss=issuer.enforce.dev", "foo", "bar")
37+
if err != nil {
38+
t.Fatal(err)
39+
}
40+
}
41+
42+
func TestNewChainguardIdentityAuthFromURL(t *testing.T) {
43+
auth, err := NewChainguardIdentityAuthFromURL("chainguard://uidp@apk.cgr.dev?iss=issuer.enforce.dev")
44+
if err != nil {
45+
t.Fatal(err)
46+
}
47+
cgauth, ok := auth.(*cgAuth)
48+
if !ok {
49+
t.Fatalf("NewChainguardIdentityAuthFromURL() = %T, want *cgAuth", auth)
50+
}
51+
52+
if cgauth.id != "uidp" {
53+
t.Errorf("id = %v, want uidp", cgauth.id)
54+
}
55+
if cgauth.iss != "https://issuer.enforce.dev" {
56+
t.Errorf("iss = %v, want https://issuer.enforce.dev", cgauth.iss)
57+
}
58+
if cgauth.aud != "apk.cgr.dev" {
59+
t.Errorf("aud = %v, want apk.cgr.dev", cgauth.aud)
60+
}
61+
}

internal/chainguard/auth.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package chainguard
2+
3+
import (
4+
"fmt"
5+
"net/url"
6+
"strings"
7+
)
8+
9+
type Identity struct {
10+
ID, Issuer, Audience string
11+
}
12+
13+
func ParseIdentity(raw string) (*Identity, error) {
14+
u, err := url.Parse(raw)
15+
if err != nil {
16+
return nil, fmt.Errorf("parsing URL: %w", err)
17+
}
18+
19+
if u.Scheme != "chainguard" {
20+
return nil, fmt.Errorf("invalid scheme %q", u.Scheme)
21+
}
22+
23+
iss := u.Query().Get("iss")
24+
if iss == "" {
25+
return nil, fmt.Errorf("missing issuer query parameter")
26+
}
27+
if !strings.HasPrefix(iss, "https://") {
28+
iss = "https://" + iss
29+
}
30+
return &Identity{
31+
ID: u.User.Username(),
32+
Issuer: iss,
33+
Audience: u.Hostname(),
34+
}, nil
35+
}

internal/chainguard/auth_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package chainguard
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestNewChainguardIdentityAuthFromURL(t *testing.T) {
8+
cases := []struct {
9+
rawURL string
10+
wantErr bool
11+
wantID string
12+
wantIss string
13+
wantAud string
14+
}{
15+
{
16+
rawURL: "chainguard://uidp@cgr.dev?iss=issuer.enforce.dev",
17+
wantErr: false,
18+
wantID: "uidp",
19+
wantIss: "https://issuer.enforce.dev",
20+
wantAud: "cgr.dev",
21+
},
22+
{
23+
rawURL: "invalid-url",
24+
wantErr: true,
25+
},
26+
}
27+
28+
for _, tc := range cases {
29+
t.Run(tc.rawURL, func(t *testing.T) {
30+
got, err := ParseIdentity(tc.rawURL)
31+
if (err != nil) != tc.wantErr {
32+
t.Fatalf("NewChainguardIdentityAuthFromURL() error = %v, wantErr %v", err, tc.wantErr)
33+
}
34+
if err == nil {
35+
if got.ID != tc.wantID {
36+
t.Errorf("id = %v, want %v", got.ID, tc.wantID)
37+
}
38+
if got.Issuer != tc.wantIss {
39+
t.Errorf("iss = %v, want %v", got.Issuer, tc.wantIss)
40+
}
41+
if got.Audience != tc.wantAud {
42+
t.Errorf("aud = %v, want %v", got.Audience, tc.wantAud)
43+
}
44+
}
45+
})
46+
}
47+
}

internal/explore/auth.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import (
44
"context"
55
"fmt"
66
"log"
7+
"strings"
78
"time"
89

910
"chainguard.dev/sdk/sts"
1011
"github.com/google/go-containerregistry/pkg/authn"
12+
"github.com/jonjohnsonjr/dagdotdev/internal/chainguard"
1113
"golang.org/x/time/rate"
1214
"google.golang.org/api/idtoken"
1315
)
@@ -22,6 +24,32 @@ func NewChainguardIdentityAuth(identity, issuer, audience string) authn.Keychain
2224
}
2325
}
2426

27+
// NewChainguardIdentityAuthFromURL parses a URL of the form uidp@cgr.dev?iss=issuer.enforce.dev
28+
func NewChainguardIdentityAuthFromURL(raw string) (authn.Keychain, error) {
29+
id, err := chainguard.ParseIdentity(raw)
30+
if err != nil {
31+
return nil, err
32+
}
33+
return NewChainguardIdentityAuth(id.ID, id.Issuer, id.Audience), nil
34+
}
35+
36+
func NewChainguardMultiKeychain(raw string, defaultIssuer string, defaultAudience string) (authn.Keychain, error) {
37+
var ks []authn.Keychain
38+
for _, s := range strings.Split(raw, ",") {
39+
if strings.HasPrefix(s, "chainguard://") {
40+
k, err := NewChainguardIdentityAuthFromURL(s)
41+
if err != nil {
42+
return nil, fmt.Errorf("parsing %q: %w", s, err)
43+
}
44+
ks = append(ks, k)
45+
} else {
46+
// Not URL format, fallback to basic identity format.
47+
ks = append(ks, NewChainguardIdentityAuth(s, defaultIssuer, defaultAudience))
48+
}
49+
}
50+
return authn.NewMultiKeychain(ks...), nil
51+
}
52+
2553
type keychain struct {
2654
id, iss, aud string
2755

@@ -42,8 +70,8 @@ func (k *keychain) ResolveContext(ctx context.Context, res authn.Resource) (auth
4270
return authn.Anonymous, nil
4371
}
4472

45-
if res.RegistryStr() != "cgr.dev" {
46-
log.Printf("%q != %q", res.RegistryStr(), "cgr.dev")
73+
if res.RegistryStr() != k.aud {
74+
log.Printf("%q != %q", res.RegistryStr(), k.aud)
4775
return authn.Anonymous, nil
4876
}
4977

internal/explore/auth_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package explore
2+
3+
import "testing"
4+
5+
func TestNewChainguardIdentityAuthFromURL(t *testing.T) {
6+
auth, err := NewChainguardIdentityAuthFromURL("chainguard://uidp@apk.cgr.dev?iss=issuer.enforce.dev")
7+
if err != nil {
8+
t.Fatal(err)
9+
}
10+
cgauth, ok := auth.(*keychain)
11+
if !ok {
12+
t.Fatalf("NewChainguardIdentityAuthFromURL() = %T, want *cgAuth", auth)
13+
}
14+
15+
if cgauth.id != "uidp" {
16+
t.Errorf("id = %v, want uidp", cgauth.id)
17+
}
18+
if cgauth.iss != "https://issuer.enforce.dev" {
19+
t.Errorf("iss = %v, want https://issuer.enforce.dev", cgauth.iss)
20+
}
21+
if cgauth.aud != "apk.cgr.dev" {
22+
t.Errorf("aud = %v, want apk.cgr.dev", cgauth.aud)
23+
}
24+
}
25+
26+
func TestNewChainguardMultiKeychain(t *testing.T) {
27+
_, err := NewChainguardMultiKeychain("uidp,chainguard://uidp@cgr.dev?iss=issuer.enforce.dev", "foo", "bar")
28+
if err != nil {
29+
t.Fatalf("NewChainguardMultiKeychain() error = %v", err)
30+
}
31+
}

main.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ func run(args []string) error {
4949
opt = append(opt, apk.WithKeychain(gcrane.Keychain))
5050
}
5151
if cgid := os.Getenv("CHAINGUARD_IDENTITY"); cgid != "" {
52-
cgauth := apk.NewChainguardIdentityAuth(cgid, "https://issuer.enforce.dev", "apk.cgr.dev")
52+
cgauth, err := apk.NewChainguardMultiKeychain(cgid, "https://issuer.enforce.dev", "apk.cgr.dev")
53+
if err != nil {
54+
return fmt.Errorf("error creating apk auth keychain: %w", err)
55+
}
5356
opt = append(opt, apk.WithAuth(cgauth))
5457
}
5558
if eg := os.Getenv("EXAMPLES"); eg != "" {
@@ -68,7 +71,10 @@ func run(args []string) error {
6871
kcs := []authn.Keychain{}
6972
if cgid := os.Getenv("CHAINGUARD_IDENTITY"); cgid != "" {
7073
log.Printf("saw CHAINGUARD_IDENTITY=%q", cgid)
71-
cgauth := explore.NewChainguardIdentityAuth(cgid, "https://issuer.enforce.dev", "cgr.dev")
74+
cgauth, err := explore.NewChainguardMultiKeychain(cgid, "https://issuer.enforce.dev", "cgr.dev")
75+
if err != nil {
76+
return fmt.Errorf("error creating OCI auth keychain: %w", err)
77+
}
7278
kcs = append(kcs, cgauth)
7379
}
7480
if *auth || os.Getenv("AUTH") == "keychain" {

0 commit comments

Comments
 (0)