From 5bfcec7dfd4ba51d38b41cea770ecc96e7e59d4d Mon Sep 17 00:00:00 2001 From: Greg Steuck Date: Wed, 28 Jul 2021 11:03:25 -0700 Subject: pkg/auth: move auth code into a new package for reuse in syz-hub --- dashboard/app/api.go | 7 +-- dashboard/app/app_test.go | 3 +- dashboard/app/auth.go | 126 ------------------------------------------- dashboard/app/auth_test.go | 99 ---------------------------------- dashboard/app/config.go | 3 +- pkg/auth/auth.go | 129 +++++++++++++++++++++++++++++++++++++++++++++ pkg/auth/auth_test.go | 99 ++++++++++++++++++++++++++++++++++ 7 files changed, 236 insertions(+), 230 deletions(-) delete mode 100644 dashboard/app/auth.go delete mode 100644 dashboard/app/auth_test.go create mode 100644 pkg/auth/auth.go create mode 100644 pkg/auth/auth_test.go diff --git a/dashboard/app/api.go b/dashboard/app/api.go index 748d7abb2..4107eb200 100644 --- a/dashboard/app/api.go +++ b/dashboard/app/api.go @@ -17,6 +17,7 @@ import ( "unicode/utf8" "github.com/google/syzkaller/dashboard/dashapi" + "github.com/google/syzkaller/pkg/auth" "github.com/google/syzkaller/pkg/email" "github.com/google/syzkaller/pkg/hash" "github.com/google/syzkaller/sys/targets" @@ -104,8 +105,8 @@ func handleAPI(c context.Context, r *http.Request) (reply interface{}, err error client := r.PostFormValue("client") method := r.PostFormValue("method") log.Infof(c, "api %q from %q", method, client) - auth := makeAuthEndpoint(googleTokenInfoEndpoint) - subj, err := auth.determineAuthSubj(timeNow(c), r.Header["Authorization"]) + auth := auth.MakeEndpoint(auth.GoogleTokenInfoEndpoint) + subj, err := auth.DetermineAuthSubj(timeNow(c), r.Header["Authorization"]) if err != nil { return nil, err } @@ -1351,7 +1352,7 @@ func GetEmails(r dashapi.Recipients, filter dashapi.RecipientType) []string { // corresponding namespace. func checkClient(conf *GlobalConfig, name0, secretPassword, oauthSubject string) (string, error) { checkAuth := func(ns, a string) (string, error) { - if strings.HasPrefix(a, oauthMagic) && a == oauthSubject { + if strings.HasPrefix(a, auth.OauthMagic) && a == oauthSubject { return ns, nil } if a != secretPassword { diff --git a/dashboard/app/app_test.go b/dashboard/app/app_test.go index 49df21d0d..777b73ce8 100644 --- a/dashboard/app/app_test.go +++ b/dashboard/app/app_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/google/syzkaller/dashboard/dashapi" + "github.com/google/syzkaller/pkg/auth" "github.com/google/syzkaller/sys/targets" "google.golang.org/appengine/user" ) @@ -57,7 +58,7 @@ var testConfig = &GlobalConfig{ FixBisectionAutoClose: true, Clients: map[string]string{ client1: password1, - "oauth": oauthMagic + "111111122222222", + "oauth": auth.OauthMagic + "111111122222222", }, Repos: []KernelRepo{ { diff --git a/dashboard/app/auth.go b/dashboard/app/auth.go deleted file mode 100644 index a6da8e24d..000000000 --- a/dashboard/app/auth.go +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright 2021 syzkaller project authors. All rights reserved. -// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file. - -// Relies on tokeninfo because it is properly documented: -// https://developers.google.com/identity/protocols/oauth2/openid-connect#validatinganidtoken - -// The client -// The VM that wants to invoke the API: -// 1) Gets a token from the metainfo server with this http request: -// META=http://metadata.google.internal/computeMetadata/v1 -// AUD=https://syzkaller.appspot.com/api -// curl -sH 'Metadata-Flavor: Google' \ -// "$META/instance/service-accounts/default/identity?audience=$AUD" -// 2) Invokes /api with header 'Authorization: Bearer ' -// -// The AppEngine api server: -// 1) Receive the token, invokes this http request: -// curl -s "https://oauth2.googleapis.com/tokeninfo?id_token=" -// 2) Checks the resulting JSON having the expected audience and expiration. -// 3) Looks up the permissions in the config using the value of sub. -// -// https://cloud.google.com/iap/docs/signed-headers-howto#retrieving_the_user_identity -// from the IAP docs agrees to trust sub. - -package main - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/google/syzkaller/dashboard/dashapi" -) - -const ( - // The official google oauth2 endpoint. - googleTokenInfoEndpoint = "https://oauth2.googleapis.com/tokeninfo" - // Used in the config map as a prefix to distinguish auth identifiers from secret passwords - // (which contain arbitrary strings, that can't have this prefix). - oauthMagic = "OauthSubject:" -) - -// Represent a verification backend. -type authEndpoint struct { - // URL supporting tokeninfo auth2 protocol. - url string - // TODO(blackgnezdo): cache tokens with a bit of care for concurrency. -} - -func makeAuthEndpoint(u string) authEndpoint { - return authEndpoint{url: u} -} - -// The JSON representation of JWT claims. -type jwtClaimsParse struct { - Subject string `json:"sub"` - Audience string `json:"aud"` - // The field in the JSON is a string but contains a UNIX time. - Expiration string `json:"exp"` -} - -// The typed representation of JWT claims. -type jwtClaims struct { - Subject string - Audience string - // The app uses the typed value. - Expiration time.Time -} - -func (auth *authEndpoint) queryTokenInfo(tokenValue string) (*jwtClaims, error) { - resp, err := http.PostForm(auth.url, url.Values{"id_token": {tokenValue}}) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, err - } - claims := new(jwtClaimsParse) - if err = json.Unmarshal(body, claims); err != nil { - return nil, err - } - expInt, err := strconv.ParseInt(claims.Expiration, 10, 64) - if err != nil { - return nil, err - } - r := jwtClaims{ - Subject: claims.Subject, - Audience: claims.Audience, - Expiration: time.Unix(expInt, 0), - } - return &r, nil -} - -// Returns the verified subject value based on the provided header -// value or "" if it can't be determined. A valid result starts with -// oauthMagic. The now parameter is the current time to compare the -// claims against. -func (auth *authEndpoint) determineAuthSubj(now time.Time, authHeader []string) (string, error) { - if len(authHeader) != 1 || !strings.HasPrefix(authHeader[0], "Bearer") { - // This is a normal case when the client uses a password. - return "", nil - } - // Values past this point are real authentication attempts. Whether - // or not they are valid is the question. - tokenValue := strings.TrimSpace(strings.TrimPrefix(authHeader[0], "Bearer")) - claims, err := auth.queryTokenInfo(tokenValue) - if err != nil { - return "", err - } - if claims.Audience != dashapi.DashboardAudience { - err := fmt.Errorf("unexpected audience %v %v", claims.Audience, claims) - return "", err - } - if claims.Expiration.Before(now) { - err := fmt.Errorf("token past expiration %v", claims.Expiration) - return "", err - } - return oauthMagic + claims.Subject, nil -} diff --git a/dashboard/app/auth_test.go b/dashboard/app/auth_test.go deleted file mode 100644 index c6d5fba23..000000000 --- a/dashboard/app/auth_test.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright 2017 syzkaller project authors. All rights reserved. -// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file. - -package main - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/google/syzkaller/dashboard/dashapi" -) - -func reponseFor(t *testing.T, claims jwtClaims) (*httptest.Server, authEndpoint) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - bytes, err := json.Marshal(jwtClaimsParse{ - Subject: claims.Subject, - Audience: claims.Audience, - Expiration: fmt.Sprint(claims.Expiration.Unix()), - }) - if err != nil { - t.Fatalf("Marshal %v", err) - } - w.Header()["Content-Type"] = []string{"application/json"} - w.Write(bytes) - })) - return ts, makeAuthEndpoint(ts.URL) -} - -func TestBearerValid(t *testing.T) { - tm := time.Now() - magic := "ValidSubj" - ts, dut := reponseFor(t, jwtClaims{ - Subject: magic, - Audience: dashapi.DashboardAudience, - Expiration: tm.AddDate(0, 0, 1), - }) - defer ts.Close() - - got, err := dut.determineAuthSubj(tm, []string{"Bearer x"}) - if err != nil { - t.Errorf("Unexpected error %v", err) - } - if !strings.HasSuffix(got, magic) { - t.Errorf("Wrong subj %v not suffix of %v", magic, got) - } -} - -func TestBearerWrongAudience(t *testing.T) { - tm := time.Now() - ts, dut := reponseFor(t, jwtClaims{ - Subject: "irrelevant", - Expiration: tm.AddDate(0, 0, 1), - Audience: "junk", - }) - defer ts.Close() - - _, err := dut.determineAuthSubj(tm, []string{"Bearer x"}) - if !strings.HasPrefix(err.Error(), "unexpected audience") { - t.Fatalf("Unexpected error %v", err) - } -} - -func TestBearerExpired(t *testing.T) { - tm := time.Now() - ts, dut := reponseFor(t, jwtClaims{ - Subject: "irrelevant", - Expiration: tm.AddDate(0, 0, -1), - Audience: dashapi.DashboardAudience, - }) - defer ts.Close() - - _, err := dut.determineAuthSubj(tm, []string{"Bearer x"}) - if !strings.HasPrefix(err.Error(), "token past expiration") { - t.Fatalf("Unexpected error %v", err) - } -} - -func TestMissingHeader(t *testing.T) { - ts, dut := reponseFor(t, jwtClaims{}) - defer ts.Close() - got, err := dut.determineAuthSubj(time.Now(), []string{}) - if err != nil || got != "" { - t.Errorf("Unexpected error %v %v", got, err) - } -} - -func TestBadHeader(t *testing.T) { - ts, dut := reponseFor(t, jwtClaims{}) - defer ts.Close() - got, err := dut.determineAuthSubj(time.Now(), []string{"bad"}) - if err != nil || got != "" { - t.Errorf("Unexpected error %v %v", got, err) - } -} diff --git a/dashboard/app/config.go b/dashboard/app/config.go index 08f8ce850..4aa79348f 100644 --- a/dashboard/app/config.go +++ b/dashboard/app/config.go @@ -12,6 +12,7 @@ import ( "time" "github.com/google/syzkaller/dashboard/dashapi" + "github.com/google/syzkaller/pkg/auth" "github.com/google/syzkaller/pkg/email" "github.com/google/syzkaller/pkg/vcs" ) @@ -202,7 +203,7 @@ type KcidbConfig struct { var ( namespaceNameRe = regexp.MustCompile("^[a-zA-Z0-9-_.]{4,32}$") clientNameRe = regexp.MustCompile("^[a-zA-Z0-9-_.]{4,100}$") - clientKeyRe = regexp.MustCompile("^([a-zA-Z0-9]{16,128})|(" + regexp.QuoteMeta(oauthMagic) + ".*)$") + clientKeyRe = regexp.MustCompile("^([a-zA-Z0-9]{16,128})|(" + regexp.QuoteMeta(auth.OauthMagic) + ".*)$") ) type ( diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go new file mode 100644 index 000000000..bd542bc32 --- /dev/null +++ b/pkg/auth/auth.go @@ -0,0 +1,129 @@ +// Copyright 2021 syzkaller project authors. All rights reserved. +// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file. + +// Relies on tokeninfo because it is properly documented: +// https://developers.google.com/identity/protocols/oauth2/openid-connect#validatinganidtoken + +// The client +// The VM that wants to invoke the API: +// 1) Gets a token from the metainfo server with this http request: +// META=http://metadata.google.internal/computeMetadata/v1 +// AUD=https://syzkaller.appspot.com/api +// curl -sH 'Metadata-Flavor: Google' \ +// "$META/instance/service-accounts/default/identity?audience=$AUD" +// 2) Invokes /api with header 'Authorization: Bearer ' +// +// The AppEngine api server: +// 1) Receive the token, invokes this http request: +// curl -s "https://oauth2.googleapis.com/tokeninfo?id_token=" +// 2) Checks the resulting JSON having the expected audience and expiration. +// 3) Looks up the permissions in the config using the value of sub. +// +// https://cloud.google.com/iap/docs/signed-headers-howto#retrieving_the_user_identity +// from the IAP docs agrees to trust sub. + +// Package auth contains authentication related code supporting secret +// passwords and oauth2 tokens on GCE. +package auth + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/google/syzkaller/dashboard/dashapi" +) + +const ( + // The official google oauth2 endpoint. + GoogleTokenInfoEndpoint = "https://oauth2.googleapis.com/tokeninfo" + // Used in the config map as a prefix to distinguish auth identifiers from secret passwords + // (which contain arbitrary strings, that can't have this prefix). + OauthMagic = "OauthSubject:" +) + +// Represent a verification backend. +type Endpoint struct { + // URL supporting tokeninfo auth2 protocol. + url string + // TODO(blackgnezdo): cache tokens with a bit of care for concurrency. +} + +func MakeEndpoint(u string) Endpoint { + return Endpoint{url: u} +} + +// The JSON representation of JWT claims. +type jwtClaimsParse struct { + Subject string `json:"sub"` + Audience string `json:"aud"` + // The field in the JSON is a string but contains a UNIX time. + Expiration string `json:"exp"` +} + +// The typed representation of JWT claims. +type jwtClaims struct { + Subject string + Audience string + // The app uses the typed value. + Expiration time.Time +} + +func (auth *Endpoint) queryTokenInfo(tokenValue string) (*jwtClaims, error) { + resp, err := http.PostForm(auth.url, url.Values{"id_token": {tokenValue}}) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + claims := new(jwtClaimsParse) + if err = json.Unmarshal(body, claims); err != nil { + return nil, err + } + expInt, err := strconv.ParseInt(claims.Expiration, 10, 64) + if err != nil { + return nil, err + } + r := jwtClaims{ + Subject: claims.Subject, + Audience: claims.Audience, + Expiration: time.Unix(expInt, 0), + } + return &r, nil +} + +// Returns the verified subject value based on the provided header +// value or "" if it can't be determined. A valid result starts with +// auth.OauthMagic. The now parameter is the current time to compare the +// claims against. The authHeader is styled as is typical for HTTP headers +// which carry the tokens prefixed by "Bearer " string. +func (auth *Endpoint) DetermineAuthSubj(now time.Time, authHeader []string) (string, error) { + if len(authHeader) != 1 || !strings.HasPrefix(authHeader[0], "Bearer") { + // This is a normal case when the client uses a password. + return "", nil + } + // Values past this point are real authentication attempts. Whether + // or not they are valid is the question. + tokenValue := strings.TrimSpace(strings.TrimPrefix(authHeader[0], "Bearer")) + claims, err := auth.queryTokenInfo(tokenValue) + if err != nil { + return "", err + } + if claims.Audience != dashapi.DashboardAudience { + err := fmt.Errorf("unexpected audience %v %v", claims.Audience, claims) + return "", err + } + if claims.Expiration.Before(now) { + err := fmt.Errorf("token past expiration %v", claims.Expiration) + return "", err + } + return OauthMagic + claims.Subject, nil +} diff --git a/pkg/auth/auth_test.go b/pkg/auth/auth_test.go new file mode 100644 index 000000000..13a9c5749 --- /dev/null +++ b/pkg/auth/auth_test.go @@ -0,0 +1,99 @@ +// Copyright 2021 syzkaller project authors. All rights reserved. +// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file. + +package auth + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/syzkaller/dashboard/dashapi" +) + +func reponseFor(t *testing.T, claims jwtClaims) (*httptest.Server, Endpoint) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + bytes, err := json.Marshal(jwtClaimsParse{ + Subject: claims.Subject, + Audience: claims.Audience, + Expiration: fmt.Sprint(claims.Expiration.Unix()), + }) + if err != nil { + t.Fatalf("Marshal %v", err) + } + w.Header()["Content-Type"] = []string{"application/json"} + w.Write(bytes) + })) + return ts, MakeEndpoint(ts.URL) +} + +func TestBearerValid(t *testing.T) { + tm := time.Now() + magic := "ValidSubj" + ts, dut := reponseFor(t, jwtClaims{ + Subject: magic, + Audience: dashapi.DashboardAudience, + Expiration: tm.AddDate(0, 0, 1), + }) + defer ts.Close() + + got, err := dut.DetermineAuthSubj(tm, []string{"Bearer x"}) + if err != nil { + t.Errorf("Unexpected error %v", err) + } + if !strings.HasSuffix(got, magic) { + t.Errorf("Wrong subj %v not suffix of %v", magic, got) + } +} + +func TestBearerWrongAudience(t *testing.T) { + tm := time.Now() + ts, dut := reponseFor(t, jwtClaims{ + Subject: "irrelevant", + Expiration: tm.AddDate(0, 0, 1), + Audience: "junk", + }) + defer ts.Close() + + _, err := dut.DetermineAuthSubj(tm, []string{"Bearer x"}) + if !strings.HasPrefix(err.Error(), "unexpected audience") { + t.Fatalf("Unexpected error %v", err) + } +} + +func TestBearerExpired(t *testing.T) { + tm := time.Now() + ts, dut := reponseFor(t, jwtClaims{ + Subject: "irrelevant", + Expiration: tm.AddDate(0, 0, -1), + Audience: dashapi.DashboardAudience, + }) + defer ts.Close() + + _, err := dut.DetermineAuthSubj(tm, []string{"Bearer x"}) + if !strings.HasPrefix(err.Error(), "token past expiration") { + t.Fatalf("Unexpected error %v", err) + } +} + +func TestMissingHeader(t *testing.T) { + ts, dut := reponseFor(t, jwtClaims{}) + defer ts.Close() + got, err := dut.DetermineAuthSubj(time.Now(), []string{}) + if err != nil || got != "" { + t.Errorf("Unexpected error %v %v", got, err) + } +} + +func TestBadHeader(t *testing.T) { + ts, dut := reponseFor(t, jwtClaims{}) + defer ts.Close() + got, err := dut.DetermineAuthSubj(time.Now(), []string{"bad"}) + if err != nil || got != "" { + t.Errorf("Unexpected error %v %v", got, err) + } +} -- cgit mrf-deployment