blob: 0e3b4b588205766418c79d27dc6aef36ed05743c [file] [log] [blame]
// Copyright 2016 The LUCI 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 auth
import (
"bytes"
"context"
"io/ioutil"
"net/http"
"strings"
"testing"
"time"
"golang.org/x/oauth2"
"go.chromium.org/luci/auth"
"go.chromium.org/luci/auth/identity"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/server/auth/signing"
"go.chromium.org/luci/server/auth/signing/signingtest"
. "github.com/smartystreets/goconvey/convey"
)
func TestGetRPCTransport(t *testing.T) {
t.Parallel()
const ownServiceAccountName = "service-own-sa@example.com"
Convey("GetRPCTransport works", t, func() {
ctx := context.Background()
mock := &clientRPCTransportMock{}
ctx = ModifyConfig(ctx, func(cfg Config) Config {
cfg.AccessTokenProvider = mock.getAccessToken
cfg.AnonymousTransport = mock.getTransport
cfg.Signer = signingtest.NewSigner(&signing.ServiceInfo{
ServiceAccountName: ownServiceAccountName,
})
return cfg
})
Convey("in NoAuth mode", func(c C) {
t, err := GetRPCTransport(ctx, NoAuth)
So(err, ShouldBeNil)
_, err = t.RoundTrip(makeReq("https://example.com"))
So(err, ShouldBeNil)
So(len(mock.calls), ShouldEqual, 0)
So(len(mock.reqs[0].Header), ShouldEqual, 0)
})
Convey("in AsSelf mode", func(c C) {
t, err := GetRPCTransport(ctx, AsSelf, WithScopes("A", "B"))
So(err, ShouldBeNil)
_, err = t.RoundTrip(makeReq("https://example.com"))
So(err, ShouldBeNil)
So(mock.calls[0], ShouldResemble, []string{"A", "B"})
So(mock.reqs[0].Header, ShouldResemble, http.Header{
"Authorization": {"Bearer as-self-token:A,B"},
})
})
Convey("in AsSelf mode with default scopes", func(c C) {
t, err := GetRPCTransport(ctx, AsSelf)
So(err, ShouldBeNil)
_, err = t.RoundTrip(makeReq("https://example.com"))
So(err, ShouldBeNil)
So(mock.calls[0], ShouldResemble, []string{"https://www.googleapis.com/auth/userinfo.email"})
So(mock.reqs[0].Header, ShouldResemble, http.Header{
"Authorization": {"Bearer as-self-token:https://www.googleapis.com/auth/userinfo.email"},
})
})
Convey("in AsSelf mode with ID token, static aud", func(c C) {
mocks := &rpcMocks{
MintIDTokenForServiceAccount: func(ic context.Context, p MintIDTokenParams) (*Token, error) {
So(p, ShouldResemble, MintIDTokenParams{
ServiceAccount: ownServiceAccountName,
Audience: "https://example.com/aud",
MinTTL: 2 * time.Minute,
})
return &Token{
Token: "id-token",
Expiry: clock.Now(ic).Add(time.Hour),
}, nil
},
}
t, err := GetRPCTransport(ctx, AsSelf, WithIDTokenAudience("https://example.com/aud"), mocks)
So(err, ShouldBeNil)
_, err = t.RoundTrip(makeReq("https://another.example.com"))
So(err, ShouldBeNil)
So(mock.reqs[0].Header, ShouldResemble, http.Header{
"Authorization": {"Bearer id-token"},
})
})
Convey("in AsSelf mode with ID token, pattern aud", func(c C) {
mocks := &rpcMocks{
MintIDTokenForServiceAccount: func(ic context.Context, p MintIDTokenParams) (*Token, error) {
So(p, ShouldResemble, MintIDTokenParams{
ServiceAccount: ownServiceAccountName,
Audience: "https://another.example.com:443/aud",
MinTTL: 2 * time.Minute,
})
return &Token{
Token: "id-token",
Expiry: clock.Now(ic).Add(time.Hour),
}, nil
},
}
t, err := GetRPCTransport(ctx, AsSelf, WithIDTokenAudience("https://${host}/aud"), mocks)
So(err, ShouldBeNil)
_, err = t.RoundTrip(makeReq("https://another.example.com:443"))
So(err, ShouldBeNil)
So(mock.reqs[0].Header, ShouldResemble, http.Header{
"Authorization": {"Bearer id-token"},
})
})
Convey("in AsUser mode, authenticated", func(c C) {
ctx := WithState(ctx, &state{
user: &User{Identity: "user:abc@example.com"},
})
t, err := GetRPCTransport(ctx, AsUser, WithDelegationTags("a:b", "c:d"), &rpcMocks{
MintDelegationToken: func(ic context.Context, p DelegationTokenParams) (*Token, error) {
c.So(p, ShouldResemble, DelegationTokenParams{
TargetHost: "example.com",
Tags: []string{"a:b", "c:d"},
MinTTL: 10 * time.Minute,
})
return &Token{Token: "deleg_tok"}, nil
},
})
So(err, ShouldBeNil)
_, err = t.RoundTrip(makeReq("https://example.com/some-path/sd"))
So(err, ShouldBeNil)
So(mock.calls[0], ShouldResemble, []string{"https://www.googleapis.com/auth/userinfo.email"})
So(mock.reqs[0].Header, ShouldResemble, http.Header{
"Authorization": {"Bearer as-self-token:https://www.googleapis.com/auth/userinfo.email"},
"X-Delegation-Token-V1": {"deleg_tok"},
})
})
Convey("in AsProject mode", func(c C) {
callExampleCom := func(ctx context.Context) {
t, err := GetRPCTransport(ctx, AsProject, WithProject("infra"), &rpcMocks{
MintProjectToken: func(ic context.Context, p ProjectTokenParams) (*Token, error) {
c.So(p, ShouldResemble, ProjectTokenParams{
MinTTL: 2 * time.Minute,
LuciProject: "infra",
OAuthScopes: defaultOAuthScopes,
})
return &Token{
Token: "scoped tok",
Expiry: clock.Now(ctx).Add(time.Hour),
}, nil
},
})
So(err, ShouldBeNil)
_, err = t.RoundTrip(makeReq("https://example.com/some-path/sd"))
So(err, ShouldBeNil)
}
Convey("external service", func() {
callExampleCom(WithState(ctx, &state{
db: &fakeDB{internalService: "not-example.com"},
}))
So(mock.reqs[0].Header, ShouldResemble, http.Header{
"Authorization": {"Bearer scoped tok"},
})
})
Convey("internal service", func() {
callExampleCom(WithState(ctx, &state{
db: &fakeDB{internalService: "example.com"},
}))
So(mock.reqs[0].Header, ShouldResemble, http.Header{
"Authorization": {"Bearer as-self-token:https://www.googleapis.com/auth/userinfo.email"},
"X-Luci-Project": {"infra"},
})
})
})
Convey("in AsUser mode, anonymous", func(c C) {
ctx := WithState(ctx, &state{
user: &User{Identity: identity.AnonymousIdentity},
})
t, err := GetRPCTransport(ctx, AsUser, &rpcMocks{
MintDelegationToken: func(ic context.Context, p DelegationTokenParams) (*Token, error) {
panic("must not be called")
},
})
So(err, ShouldBeNil)
_, err = t.RoundTrip(makeReq("https://example.com"))
So(err, ShouldBeNil)
So(mock.reqs[0].Header, ShouldResemble, http.Header{})
})
Convey("in AsUser mode, with existing token", func(c C) {
ctx := WithState(ctx, &state{
user: &User{Identity: identity.AnonymousIdentity},
})
t, err := GetRPCTransport(ctx, AsUser, WithDelegationToken("deleg_tok"), &rpcMocks{
MintDelegationToken: func(ic context.Context, p DelegationTokenParams) (*Token, error) {
panic("must not be called")
},
})
So(err, ShouldBeNil)
_, err = t.RoundTrip(makeReq("https://example.com"))
So(err, ShouldBeNil)
So(mock.calls[0], ShouldResemble, []string{"https://www.googleapis.com/auth/userinfo.email"})
So(mock.reqs[0].Header, ShouldResemble, http.Header{
"Authorization": {"Bearer as-self-token:https://www.googleapis.com/auth/userinfo.email"},
"X-Delegation-Token-V1": {"deleg_tok"},
})
})
Convey("in AsUser mode with both delegation tags and token", func(c C) {
_, err := GetRPCTransport(
ctx, AsUser, WithDelegationToken("deleg_tok"), WithDelegationTags("a:b"))
So(err, ShouldNotBeNil)
})
Convey("in NoAuth mode with delegation tags, should error", func(c C) {
_, err := GetRPCTransport(ctx, NoAuth, WithDelegationTags("a:b"))
So(err, ShouldNotBeNil)
})
Convey("in NoAuth mode with scopes, should error", func(c C) {
_, err := GetRPCTransport(ctx, NoAuth, WithScopes("A"))
So(err, ShouldNotBeNil)
})
Convey("in NoAuth mode with ID token, should error", func(c C) {
_, err := GetRPCTransport(ctx, NoAuth, WithIDTokenAudience("aud"))
So(err, ShouldNotBeNil)
})
Convey("in AsSelf mode with ID token and scopes, should error", func(c C) {
_, err := GetRPCTransport(ctx, AsSelf, WithScopes("A"), WithIDTokenAudience("aud"))
So(err, ShouldNotBeNil)
})
Convey("in AsSelf mode with bad aud pattern, should error", func(c C) {
_, err := GetRPCTransport(ctx, AsSelf, WithIDTokenAudience("${huh}"))
So(err, ShouldNotBeNil)
})
Convey("in AsCredentialsForwarder mode, anonymous", func(c C) {
ctx := WithState(ctx, &state{
user: &User{Identity: identity.AnonymousIdentity},
endUserErr: ErrNoForwardableCreds,
})
t, err := GetRPCTransport(ctx, AsCredentialsForwarder)
So(err, ShouldBeNil)
_, err = t.RoundTrip(makeReq("https://example.com"))
So(err, ShouldBeNil)
// No credentials passed.
So(mock.reqs[0].Header, ShouldHaveLength, 0)
})
Convey("in AsCredentialsForwarder mode, non-anonymous", func(c C) {
ctx := WithState(ctx, &state{
user: &User{Identity: "user:a@example.com"},
endUserTok: &oauth2.Token{
TokenType: "Bearer",
AccessToken: "abc.def",
},
endUserExtraHeaders: map[string]string{"X-Extra": "val"},
})
t, err := GetRPCTransport(ctx, AsCredentialsForwarder)
So(err, ShouldBeNil)
_, err = t.RoundTrip(makeReq("https://example.com"))
So(err, ShouldBeNil)
// Passed the token and the extra header.
So(mock.reqs[0].Header, ShouldResemble, http.Header{
"Authorization": {"Bearer abc.def"},
"X-Extra": {"val"},
})
})
Convey("in AsCredentialsForwarder mode, non-forwardable", func(c C) {
ctx := WithState(ctx, &state{
user: &User{Identity: "user:a@example.com"},
endUserErr: ErrNoForwardableCreds,
})
_, err := GetRPCTransport(ctx, AsCredentialsForwarder)
So(err, ShouldEqual, ErrNoForwardableCreds)
})
Convey("in AsActor mode with account", func(c C) {
mocks := &rpcMocks{
MintAccessTokenForServiceAccount: func(ic context.Context, p MintAccessTokenParams) (*Token, error) {
So(p, ShouldResemble, MintAccessTokenParams{
ServiceAccount: "abc@example.com",
Scopes: []string{auth.OAuthScopeEmail},
MinTTL: 2 * time.Minute,
})
return &Token{
Token: "blah-blah",
Expiry: clock.Now(ic).Add(time.Hour),
}, nil
},
}
t, err := GetRPCTransport(ctx, AsActor, WithServiceAccount("abc@example.com"), mocks)
So(err, ShouldBeNil)
_, err = t.RoundTrip(makeReq("https://example.com"))
So(err, ShouldBeNil)
So(mock.reqs[0].Header, ShouldResemble, http.Header{
"Authorization": {"Bearer blah-blah"},
})
})
Convey("in AsActor mode without account, error", func(c C) {
_, err := GetRPCTransport(ctx, AsActor)
So(err, ShouldNotBeNil)
})
Convey("in AsProject mode without project, error", func(c C) {
_, err := GetRPCTransport(ctx, AsProject)
So(err, ShouldNotBeNil)
})
Convey("in AsSessionUser mode without session", func(c C) {
_, err := GetRPCTransport(ctx, AsSessionUser)
So(err, ShouldEqual, ErrNotConfigured)
})
Convey("in AsSessionUser mode", func(c C) {
ctx := WithState(ctx, &state{
user: &User{Identity: "user:abc@example.com"},
session: &fakeSession{
accessToken: &oauth2.Token{
TokenType: "Bearer",
AccessToken: "access_token",
},
idToken: &oauth2.Token{
TokenType: "Bearer",
AccessToken: "id_token",
},
},
})
Convey("OAuth2 token", func() {
t, err := GetRPCTransport(ctx, AsSessionUser)
So(err, ShouldBeNil)
_, err = t.RoundTrip(makeReq("https://example.com"))
So(err, ShouldBeNil)
So(mock.reqs[0].Header, ShouldResemble, http.Header{
"Authorization": {"Bearer access_token"},
})
})
Convey("ID token", func() {
t, err := GetRPCTransport(ctx, AsSessionUser, WithIDToken())
So(err, ShouldBeNil)
_, err = t.RoundTrip(makeReq("https://example.com"))
So(err, ShouldBeNil)
So(mock.reqs[0].Header, ShouldResemble, http.Header{
"Authorization": {"Bearer id_token"},
})
})
Convey("Trying to override scopes", func() {
_, err := GetRPCTransport(ctx, AsSessionUser, WithScopes("a"))
So(err, ShouldNotBeNil)
})
Convey("Trying to override aud", func() {
_, err := GetRPCTransport(ctx, AsSessionUser, WithIDTokenAudience("aud"))
So(err, ShouldNotBeNil)
})
})
Convey("when headers are needed, Request context is used", func() {
root := ctx
// Contexts with different auth state.
ctx1 := WithState(root, &state{user: &User{Identity: "user:abc@example.com"}})
ctx2 := WithState(root, &state{user: &User{Identity: "user:abc@example.com"}})
// Use a mode which actually uses transport context to compute headers.
run := func(c C, reqCtx, transCtx context.Context) (usedCtx context.Context) {
mocks := &rpcMocks{
MintAccessTokenForServiceAccount: func(ic context.Context, _ MintAccessTokenParams) (*Token, error) {
usedCtx = ic
return &Token{
Token: "blah",
Expiry: clock.Now(ic).Add(time.Hour),
}, nil
},
}
t, err := GetRPCTransport(transCtx, AsActor, WithServiceAccount("abc@example.com"), mocks)
c.So(err, ShouldBeNil)
req := makeReq("https://example.com")
if reqCtx != nil {
req = req.WithContext(reqCtx)
}
_, err = t.RoundTrip(req)
c.So(err, ShouldBeNil)
return
}
Convey("no request context", func(c C) {
So(run(c, nil, ctx1), ShouldEqual, ctx1)
})
Convey("same context", func(c C) {
So(run(c, ctx1, ctx1), ShouldEqual, ctx1)
})
Convey("uses request context", func(c C) {
reqCtx, cancel := context.WithTimeout(ctx1, time.Minute)
defer cancel()
transCtx, cancel := context.WithTimeout(ctx1, time.Hour)
defer cancel()
So(run(c, reqCtx, transCtx), ShouldEqual, reqCtx)
})
Convey("OK on two background contexts", func(c C) {
reqCtx, cancel := context.WithTimeout(root, time.Minute)
defer cancel()
transCtx, cancel := context.WithTimeout(root, time.Hour)
defer cancel()
So(run(c, reqCtx, transCtx), ShouldEqual, reqCtx)
})
Convey("request ctx is user, transport is background", func(c C) {
reqCtx, cancel := context.WithTimeout(ctx1, time.Minute)
reqCtxDeadline, _ := reqCtx.Deadline()
defer cancel()
transCtx, cancel := context.WithTimeout(root, time.Hour)
defer cancel()
// Used `reqCtx` for the deadline, but have background auth state.
usedCtx := run(c, reqCtx, transCtx)
usedDeadline, _ := usedCtx.Deadline()
So(usedDeadline.Equal(reqCtxDeadline), ShouldBeTrue)
So(GetState(usedCtx), ShouldResemble, GetState(transCtx))
})
Convey("request ctx is background, transport is user", func(c C) {
So(func() {
reqCtx, cancel := context.WithTimeout(root, time.Minute)
defer cancel()
transCtx, cancel := context.WithTimeout(ctx1, time.Hour)
defer cancel()
run(c, reqCtx, transCtx)
}, ShouldPanic)
})
Convey("panics on contexts with different auth state", func(c C) {
So(func() {
reqCtx, cancel := context.WithTimeout(ctx1, time.Minute)
defer cancel()
transCtx, cancel := context.WithTimeout(ctx2, time.Hour)
defer cancel()
run(c, reqCtx, transCtx)
}, ShouldPanic)
})
})
})
}
func TestTokenSource(t *testing.T) {
t.Parallel()
Convey("GetTokenSource works", t, func() {
ctx := context.Background()
mock := &clientRPCTransportMock{}
ctx = ModifyConfig(ctx, func(cfg Config) Config {
cfg.AccessTokenProvider = mock.getAccessToken
cfg.AnonymousTransport = mock.getTransport
return cfg
})
Convey("With no scopes", func() {
ts, err := GetTokenSource(ctx, AsSelf)
So(err, ShouldBeNil)
tok, err := ts.Token()
So(err, ShouldBeNil)
So(tok, ShouldResemble, &oauth2.Token{
AccessToken: "as-self-token:https://www.googleapis.com/auth/userinfo.email",
TokenType: "Bearer",
})
})
Convey("With a specific list of scopes", func() {
ts, err := GetTokenSource(ctx, AsSelf, WithScopes("foo", "bar", "baz"))
So(err, ShouldBeNil)
tok, err := ts.Token()
So(err, ShouldBeNil)
So(tok, ShouldResemble, &oauth2.Token{
AccessToken: "as-self-token:foo,bar,baz",
TokenType: "Bearer",
})
})
Convey("With ID token, static aud", func() {
_, err := GetTokenSource(ctx, AsSelf, WithIDTokenAudience("https://host.example.com"))
So(err, ShouldBeNil)
})
Convey("With ID token, pattern aud", func() {
_, err := GetTokenSource(ctx, AsSelf, WithIDTokenAudience("https://${host}"))
So(err, ShouldNotBeNil)
})
Convey("NoAuth is not allowed", func() {
ts, err := GetTokenSource(ctx, NoAuth)
So(ts, ShouldBeNil)
So(err, ShouldNotBeNil)
})
Convey("AsUser is not allowed", func() {
ts, err := GetTokenSource(ctx, AsUser)
So(ts, ShouldBeNil)
So(err, ShouldNotBeNil)
})
})
}
func TestParseAudPattern(t *testing.T) {
t.Parallel()
Convey("Works", t, func() {
cb, err := parseAudPattern("https://${host}/zzz")
So(err, ShouldBeNil)
s, err := cb(&http.Request{
Host: "something.example.com:443",
})
So(err, ShouldBeNil)
So(s, ShouldEqual, "https://something.example.com:443/zzz")
})
Convey("Static", t, func() {
cb, err := parseAudPattern("no-vars-here")
So(cb, ShouldBeNil)
So(err, ShouldBeNil)
})
Convey("Malformed", t, func() {
cb, err := parseAudPattern("aaa-${host)-bbb")
So(cb, ShouldBeNil)
So(err, ShouldNotBeNil)
})
Convey("Unknown var", t, func() {
cb, err := parseAudPattern("aaa-${unknown}-bbb")
So(cb, ShouldBeNil)
So(err, ShouldNotBeNil)
})
}
func makeReq(url string) *http.Request {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
panic(err)
}
return req
}
type fakeSession struct {
accessToken *oauth2.Token
idToken *oauth2.Token
}
func (s *fakeSession) AccessToken(ctx context.Context) (*oauth2.Token, error) {
return s.accessToken, nil
}
func (s *fakeSession) IDToken(ctx context.Context) (*oauth2.Token, error) {
return s.idToken, nil
}
type clientRPCTransportMock struct {
calls [][]string
reqs []*http.Request
cb func(req *http.Request, body string) string
}
func (m *clientRPCTransportMock) getAccessToken(ctx context.Context, scopes []string) (*oauth2.Token, error) {
m.calls = append(m.calls, scopes)
return &oauth2.Token{
AccessToken: "as-self-token:" + strings.Join(scopes, ","),
TokenType: "Bearer",
}, nil
}
func (m *clientRPCTransportMock) getTransport(ctx context.Context) http.RoundTripper {
return m
}
func (m *clientRPCTransportMock) RoundTrip(req *http.Request) (*http.Response, error) {
m.reqs = append(m.reqs, req)
code := 500
resp := "internal error"
if req.Body != nil {
body, err := ioutil.ReadAll(req.Body)
req.Body.Close()
if err != nil {
return nil, err
}
if m.cb != nil {
code = 200
resp = m.cb(req, string(body))
}
}
return &http.Response{
StatusCode: code,
Body: ioutil.NopCloser(bytes.NewReader([]byte(resp))),
}, nil
}