mirror of
https://github.com/gravitational/teleport
synced 2024-10-20 17:23:22 +00:00
OIDC multiple redirect URLs (#12054)
This commit is contained in:
parent
8302d467d1
commit
26bad238fa
|
@ -1423,6 +1423,9 @@ func (c *Client) GetOIDCConnector(ctx context.Context, name string, withSecrets
|
|||
if err != nil {
|
||||
return nil, trail.FromGRPC(err)
|
||||
}
|
||||
// An old server would send RedirectURL instead of RedirectURLs
|
||||
// DELETE IN 11.0.0
|
||||
resp.CheckSetRedirectURL()
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
|
@ -1435,6 +1438,9 @@ func (c *Client) GetOIDCConnectors(ctx context.Context, withSecrets bool) ([]typ
|
|||
}
|
||||
oidcConnectors := make([]types.OIDCConnector, len(resp.OIDCConnectors))
|
||||
for i, oidcConnector := range resp.OIDCConnectors {
|
||||
// An old server would send RedirectURL instead of RedirectURLs
|
||||
// DELETE IN 11.0.0
|
||||
oidcConnector.CheckSetRedirectURL()
|
||||
oidcConnectors[i] = oidcConnector
|
||||
}
|
||||
return oidcConnectors, nil
|
||||
|
@ -1446,6 +1452,9 @@ func (c *Client) UpsertOIDCConnector(ctx context.Context, oidcConnector types.OI
|
|||
if !ok {
|
||||
return trace.BadParameter("invalid type %T", oidcConnector)
|
||||
}
|
||||
// An old server would expect RedirectURL instead of RedirectURLs
|
||||
// DELETE IN 11.0.0
|
||||
connector.CheckSetRedirectURL()
|
||||
_, err := c.grpc.UpsertOIDCConnector(ctx, connector, c.callOpts...)
|
||||
return trail.FromGRPC(err)
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/protobuf/ptypes/empty"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/gravitational/teleport/api/client/proto"
|
||||
"github.com/gravitational/teleport/api/defaults"
|
||||
|
@ -45,8 +46,8 @@ type mockServer struct {
|
|||
|
||||
func newMockServer() *mockServer {
|
||||
m := &mockServer{
|
||||
grpc.NewServer(),
|
||||
&proto.UnimplementedAuthServiceServer{},
|
||||
grpc: grpc.NewServer(),
|
||||
UnimplementedAuthServiceServer: &proto.UnimplementedAuthServiceServer{},
|
||||
}
|
||||
proto.RegisterAuthServiceServer(m.grpc, m)
|
||||
return m
|
||||
|
@ -615,3 +616,101 @@ func TestGetResources(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockOIDCConnectorServer struct {
|
||||
*mockServer
|
||||
connectors map[string]*types.OIDCConnectorV3
|
||||
}
|
||||
|
||||
func newMockOIDCConnectorServer() *mockOIDCConnectorServer {
|
||||
m := &mockOIDCConnectorServer{
|
||||
&mockServer{
|
||||
grpc: grpc.NewServer(),
|
||||
UnimplementedAuthServiceServer: &proto.UnimplementedAuthServiceServer{},
|
||||
},
|
||||
make(map[string]*types.OIDCConnectorV3),
|
||||
}
|
||||
proto.RegisterAuthServiceServer(m.grpc, m)
|
||||
return m
|
||||
}
|
||||
|
||||
func startMockOIDCConnectorServer(t *testing.T) string {
|
||||
l, err := net.Listen("tcp", "")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, l.Close()) })
|
||||
go newMockOIDCConnectorServer().grpc.Serve(l)
|
||||
return l.Addr().String()
|
||||
}
|
||||
|
||||
func (m *mockOIDCConnectorServer) GetOIDCConnector(ctx context.Context, req *types.ResourceWithSecretsRequest) (*types.OIDCConnectorV3, error) {
|
||||
conn, ok := m.connectors[req.Name]
|
||||
if !ok {
|
||||
return nil, trace.NotFound("not found")
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (m *mockOIDCConnectorServer) GetOIDCConnectors(ctx context.Context, req *types.ResourcesWithSecretsRequest) (*types.OIDCConnectorV3List, error) {
|
||||
var connectors []*types.OIDCConnectorV3
|
||||
for _, conn := range m.connectors {
|
||||
connectors = append(connectors, conn)
|
||||
}
|
||||
return &types.OIDCConnectorV3List{
|
||||
OIDCConnectors: connectors,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockOIDCConnectorServer) UpsertOIDCConnector(ctx context.Context, oidcConnector *types.OIDCConnectorV3) (*empty.Empty, error) {
|
||||
m.connectors[oidcConnector.Metadata.Name] = oidcConnector
|
||||
return &empty.Empty{}, nil
|
||||
}
|
||||
|
||||
// Test that client will perform properly with an old server
|
||||
// DELETE IN 11.0.0
|
||||
func TestSetOIDCRedirectURLBackwardsCompatibility(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
addr := startMockOIDCConnectorServer(t)
|
||||
|
||||
// Create client
|
||||
clt, err := New(ctx, Config{
|
||||
Addrs: []string{addr},
|
||||
Credentials: []Credentials{
|
||||
&mockInsecureTLSCredentials{}, // TODO(Joerger) replace insecure credentials
|
||||
},
|
||||
DialOpts: []grpc.DialOption{
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()), // TODO(Joerger) remove insecure dial option
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
conn := &types.OIDCConnectorV3{
|
||||
Metadata: types.Metadata{
|
||||
Name: "one",
|
||||
},
|
||||
}
|
||||
|
||||
// Upsert should set "RedirectURL" on the provided connector if empty
|
||||
conn.Spec.RedirectURLs = []string{"one.example.com"}
|
||||
conn.Spec.RedirectURL = ""
|
||||
err = clt.UpsertOIDCConnector(ctx, conn)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(conn.GetRedirectURLs()))
|
||||
require.Equal(t, conn.GetRedirectURLs()[0], conn.Spec.RedirectURL)
|
||||
|
||||
// GetOIDCConnector should set "RedirectURLs" on the received connector if empty
|
||||
conn.Spec.RedirectURLs = []string{}
|
||||
conn.Spec.RedirectURL = "one.example.com"
|
||||
connResp, err := clt.GetOIDCConnector(ctx, conn.GetName(), false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(connResp.GetRedirectURLs()))
|
||||
require.Equal(t, connResp.GetRedirectURLs()[0], "one.example.com")
|
||||
|
||||
// GetOIDCConnectors should set "RedirectURLs" on the received connectors if empty
|
||||
conn.Spec.RedirectURLs = []string{}
|
||||
conn.Spec.RedirectURL = "one.example.com"
|
||||
connectorsResp, err := clt.GetOIDCConnectors(ctx, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(connectorsResp))
|
||||
require.Equal(t, 1, len(connectorsResp[0].GetRedirectURLs()))
|
||||
require.Equal(t, "one.example.com", connectorsResp[0].GetRedirectURLs()[0])
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
package types
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/gravitational/teleport/api/constants"
|
||||
|
@ -37,10 +38,8 @@ type OIDCConnector interface {
|
|||
// ClientSecret is used to authenticate our client and should not
|
||||
// be visible to end user
|
||||
GetClientSecret() string
|
||||
// RedirectURL - Identity provider will use this URL to redirect
|
||||
// client's browser back to it after successful authentication
|
||||
// Should match the URL on Provider's side
|
||||
GetRedirectURL() string
|
||||
// GetRedirectURLs returns list of redirect URLs.
|
||||
GetRedirectURLs() []string
|
||||
// GetACR returns the Authentication Context Class Reference (ACR) value.
|
||||
GetACR() string
|
||||
// GetProvider returns the identity provider.
|
||||
|
@ -62,8 +61,8 @@ type OIDCConnector interface {
|
|||
SetClientID(string)
|
||||
// SetIssuerURL sets the endpoint of the provider
|
||||
SetIssuerURL(string)
|
||||
// SetRedirectURL sets RedirectURL
|
||||
SetRedirectURL(string)
|
||||
// SetRedirectURLs sets the list of redirectURLs
|
||||
SetRedirectURLs([]string)
|
||||
// SetPrompt sets OIDC prompt value
|
||||
SetPrompt(string)
|
||||
// GetPrompt returns OIDC prompt value,
|
||||
|
@ -226,9 +225,9 @@ func (o *OIDCConnectorV3) SetIssuerURL(issuerURL string) {
|
|||
o.Spec.IssuerURL = issuerURL
|
||||
}
|
||||
|
||||
// SetRedirectURL sets client secret to some value
|
||||
func (o *OIDCConnectorV3) SetRedirectURL(redirectURL string) {
|
||||
o.Spec.RedirectURL = redirectURL
|
||||
// SetRedirectURLs sets the list of redirectURLs
|
||||
func (o *OIDCConnectorV3) SetRedirectURLs(redirectURLs []string) {
|
||||
o.Spec.RedirectURLs = redirectURLs
|
||||
}
|
||||
|
||||
// SetACR sets the Authentication Context Class Reference (ACR) value.
|
||||
|
@ -277,11 +276,9 @@ func (o *OIDCConnectorV3) GetClientSecret() string {
|
|||
return o.Spec.ClientSecret
|
||||
}
|
||||
|
||||
// GetRedirectURL - Identity provider will use this URL to redirect
|
||||
// client's browser back to it after successful authentication
|
||||
// Should match the URL on Provider's side
|
||||
func (o *OIDCConnectorV3) GetRedirectURL() string {
|
||||
return o.Spec.RedirectURL
|
||||
// GetRedirectURLs returns a list of the connector's redirect URLs.
|
||||
func (o *OIDCConnectorV3) GetRedirectURLs() []string {
|
||||
return o.Spec.RedirectURLs
|
||||
}
|
||||
|
||||
// GetACR returns the Authentication Context Class Reference (ACR) value.
|
||||
|
@ -359,16 +356,68 @@ func (o *OIDCConnectorV3) CheckAndSetDefaults() error {
|
|||
if name := o.Metadata.Name; utils.SliceContainsStr(constants.SystemConnectors, name) {
|
||||
return trace.BadParameter("ID: invalid connector name, %v is a reserved name", name)
|
||||
}
|
||||
|
||||
if o.Spec.ClientID == "" {
|
||||
return trace.BadParameter("ClientID: missing client id")
|
||||
}
|
||||
|
||||
// make sure claim mappings have either roles or a role template
|
||||
if len(o.GetClaimsToRoles()) == 0 {
|
||||
return trace.BadParameter("claims_to_roles is empty, authorization with connector would never assign any roles")
|
||||
}
|
||||
for _, v := range o.Spec.ClaimsToRoles {
|
||||
if len(v.Roles) == 0 {
|
||||
return trace.BadParameter("add roles in claims_to_roles")
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := url.Parse(o.GetIssuerURL()); err != nil {
|
||||
return trace.BadParameter("bad IssuerURL '%v', err: %v", o.GetIssuerURL(), err)
|
||||
}
|
||||
|
||||
// DELETE IN 11.0.0
|
||||
o.CheckSetRedirectURL()
|
||||
|
||||
if len(o.GetRedirectURLs()) == 0 {
|
||||
return trace.BadParameter("RedirectURL: missing redirect_url")
|
||||
}
|
||||
for _, redirectURL := range o.GetRedirectURLs() {
|
||||
if _, err := url.Parse(redirectURL); err != nil {
|
||||
return trace.BadParameter("bad RedirectURL '%v', err: %v", redirectURL, err)
|
||||
}
|
||||
}
|
||||
|
||||
if o.GetGoogleServiceAccountURI() != "" && o.GetGoogleServiceAccount() != "" {
|
||||
return trace.BadParameter("one of either google_service_account_uri or google_service_account is supported, not both")
|
||||
}
|
||||
|
||||
if o.GetGoogleServiceAccountURI() != "" {
|
||||
uri, err := utils.ParseSessionsURI(o.GetGoogleServiceAccountURI())
|
||||
if err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
if uri.Scheme != "file" {
|
||||
return trace.BadParameter("only file:// scheme is supported for google_service_account_uri")
|
||||
}
|
||||
if o.GetGoogleAdminEmail() == "" {
|
||||
return trace.BadParameter("whenever google_service_account_uri is specified, google_admin_email should be set as well, read https://developers.google.com/identity/protools/OAuth2ServiceAccount#delegatingauthority for more details")
|
||||
}
|
||||
}
|
||||
|
||||
if o.GetGoogleServiceAccount() != "" {
|
||||
if o.GetGoogleAdminEmail() == "" {
|
||||
return trace.BadParameter("whenever google_service_account is specified, google_admin_email should be set as well, read https://developers.google.com/identity/protocols/OAuth2ServiceAccount#delegatingauthority for more details")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RedirectURL must be checked/set when communicating with an old server or client.
|
||||
// DELETE IN 11.0.0
|
||||
func (o *OIDCConnectorV3) CheckSetRedirectURL() {
|
||||
if o.Spec.RedirectURL == "" && len(o.Spec.RedirectURLs) != 0 {
|
||||
o.Spec.RedirectURL = o.Spec.RedirectURLs[0]
|
||||
} else if len(o.Spec.RedirectURLs) == 0 && o.Spec.RedirectURL != "" {
|
||||
o.Spec.RedirectURLs = []string{o.Spec.RedirectURL}
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2556,7 +2556,9 @@ message OIDCConnectorSpecV3 {
|
|||
// RedirectURL is a URL that will redirect the client's browser
|
||||
// back to the identity provider after successful authentication.
|
||||
// This should match the URL on the Provider's side.
|
||||
string RedirectURL = 4 [ (gogoproto.jsontag) = "redirect_url" ];
|
||||
//
|
||||
// DELETE IN 11.0.0 in favor of RedirectURLs
|
||||
string RedirectURL = 4 [ (gogoproto.jsontag) = "-" ];
|
||||
// ACR is an Authentication Context Class Reference value. The meaning of the ACR
|
||||
// value is context-specific and varies for identity providers.
|
||||
string ACR = 5 [ (gogoproto.jsontag) = "acr_values,omitempty" ];
|
||||
|
@ -2579,6 +2581,16 @@ message OIDCConnectorSpecV3 {
|
|||
string GoogleServiceAccount = 12 [ (gogoproto.jsontag) = "google_service_account,omitempty" ];
|
||||
// GoogleAdminEmail is the email of a google admin to impersonate.
|
||||
string GoogleAdminEmail = 13 [ (gogoproto.jsontag) = "google_admin_email,omitempty" ];
|
||||
// RedirectURLs is a list of callback URLs which the identity provider can use
|
||||
// to redirect the client back to the Teleport Proxy to complete authentication.
|
||||
// This list should match the URLs on the provider's side. The URL used for a
|
||||
// given auth request will be chosen to match the requesting Proxy's public
|
||||
// address. If there is no match, the first url in the list will be used.
|
||||
wrappers.StringValues RedirectURLs = 14 [
|
||||
(gogoproto.nullable) = false,
|
||||
(gogoproto.jsontag) = "redirect_url",
|
||||
(gogoproto.customtype) = "github.com/gravitational/teleport/api/types/wrappers.Strings"
|
||||
];
|
||||
}
|
||||
|
||||
// SAMLConnectorV2 represents a SAML connector.
|
||||
|
|
|
@ -19,8 +19,6 @@ package utils
|
|||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/gravitational/teleport"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
)
|
||||
|
||||
|
@ -35,7 +33,7 @@ func ParseSessionsURI(in string) (*url.URL, error) {
|
|||
return nil, trace.BadParameter("failed to parse URI %q: %v", in, err)
|
||||
}
|
||||
if u.Scheme == "" {
|
||||
u.Scheme = teleport.SchemeFile
|
||||
u.Scheme = "file"
|
||||
}
|
||||
return u, nil
|
||||
}
|
45
api/utils/uri_test.go
Normal file
45
api/utils/uri_test.go
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
Copyright 2022 Gravitational, Inc.
|
||||
|
||||
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 utils
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestParseSessionsURI parses sessions URI
|
||||
func TestParseSessionsURI(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
info string
|
||||
in string
|
||||
url *url.URL
|
||||
}{
|
||||
{info: "local default file system URI", in: "/home/log", url: &url.URL{Scheme: "file", Path: "/home/log"}},
|
||||
{info: "explicit filesystem URI", in: "file:///home/log", url: &url.URL{Scheme: "file", Path: "/home/log"}},
|
||||
{info: "other scheme", in: "other://my-bucket", url: &url.URL{Scheme: "other", Host: "my-bucket"}},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.info, func(t *testing.T) {
|
||||
out, err := ParseSessionsURI(testCase.in)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testCase.url, out)
|
||||
})
|
||||
}
|
||||
}
|
2
e
2
e
|
@ -1 +1 @@
|
|||
Subproject commit e407f7ad7d79d6db068487fc145d31f3d44c6637
|
||||
Subproject commit 39e02ffd6a3b7aed87f250c3d2309e39df36c866
|
|
@ -311,7 +311,8 @@ var (
|
|||
// - same for users and their sessions
|
||||
// - checks public keys to see if they're signed by it (can be trusted or not)
|
||||
type Server struct {
|
||||
lock sync.RWMutex
|
||||
lock sync.RWMutex
|
||||
// oidcClients is a map from authID & proxyAddr -> oidcClient
|
||||
oidcClients map[string]*oidcClient
|
||||
samlProviders map[string]*samlProvider
|
||||
githubClients map[string]*githubClient
|
||||
|
@ -3709,9 +3710,13 @@ const (
|
|||
|
||||
// oidcClient is internal structure that stores OIDC client and its config
|
||||
type oidcClient struct {
|
||||
client *oidc.Client
|
||||
config oidc.ClientConfig
|
||||
cancel context.CancelFunc
|
||||
client *oidc.Client
|
||||
connector types.OIDCConnector
|
||||
// syncCtx controls the provider sync goroutine.
|
||||
syncCtx context.Context
|
||||
syncCancel context.CancelFunc
|
||||
// firstSync will be closed once the first provider sync succeeds
|
||||
firstSync chan struct{}
|
||||
}
|
||||
|
||||
// samlProvider is internal structure that stores SAML client and its config
|
||||
|
@ -3726,28 +3731,6 @@ type githubClient struct {
|
|||
config oauth2.Config
|
||||
}
|
||||
|
||||
// oidcConfigsEqual returns true if the provided OIDC configs are equal
|
||||
func oidcConfigsEqual(a, b oidc.ClientConfig) bool {
|
||||
if a.RedirectURL != b.RedirectURL {
|
||||
return false
|
||||
}
|
||||
if a.Credentials.ID != b.Credentials.ID {
|
||||
return false
|
||||
}
|
||||
if a.Credentials.Secret != b.Credentials.Secret {
|
||||
return false
|
||||
}
|
||||
if len(a.Scope) != len(b.Scope) {
|
||||
return false
|
||||
}
|
||||
for i := range a.Scope {
|
||||
if a.Scope[i] != b.Scope[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// oauth2ConfigsEqual returns true if the provided OAuth2 configs are equal
|
||||
func oauth2ConfigsEqual(a, b oauth2.Config) bool {
|
||||
if a.Credentials.ID != b.Credentials.ID {
|
||||
|
|
|
@ -953,13 +953,17 @@ func TestOIDCConnectorCRUDEventsEmitted(t *testing.T) {
|
|||
|
||||
ctx := context.Background()
|
||||
// test oidc create event
|
||||
oidc, err := types.NewOIDCConnector("test", types.OIDCConnectorSpecV3{ClientID: "a", ClaimsToRoles: []types.ClaimMapping{
|
||||
{
|
||||
Claim: "dummy",
|
||||
Value: "dummy",
|
||||
Roles: []string{"dummy"},
|
||||
oidc, err := types.NewOIDCConnector("test", types.OIDCConnectorSpecV3{
|
||||
ClientID: "a",
|
||||
ClaimsToRoles: []types.ClaimMapping{
|
||||
{
|
||||
Claim: "dummy",
|
||||
Value: "dummy",
|
||||
Roles: []string{"dummy"},
|
||||
},
|
||||
},
|
||||
}})
|
||||
RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = s.a.UpsertOIDCConnector(ctx, oidc)
|
||||
require.NoError(t, err)
|
||||
|
|
|
@ -518,7 +518,7 @@ func TestOIDCAuthRequest(t *testing.T) {
|
|||
IssuerURL: "https://gitlab.com",
|
||||
ClientID: "example-client-id",
|
||||
ClientSecret: "example-client-secret",
|
||||
RedirectURL: "https://localhost:3080/v1/webapi/oidc/callback",
|
||||
RedirectURLs: []string{"https://localhost:3080/v1/webapi/oidc/callback"},
|
||||
Display: "sign in with example.com",
|
||||
Scope: []string{"foo", "bar"},
|
||||
ClaimsToRoles: []types.ClaimMapping{
|
||||
|
@ -535,14 +535,25 @@ func TestOIDCAuthRequest(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
|
||||
reqNormal := services.OIDCAuthRequest{ConnectorID: conn.GetName(), Type: constants.OIDC}
|
||||
reqTest := services.OIDCAuthRequest{ConnectorID: conn.GetName(), Type: constants.OIDC, SSOTestFlow: true, ConnectorSpec: &types.OIDCConnectorSpecV3{
|
||||
IssuerURL: "https://gitlab.com",
|
||||
ClientID: "example-client-id",
|
||||
ClientSecret: "example-client-secret",
|
||||
RedirectURL: "https://localhost:3080/v1/webapi/oidc/callback",
|
||||
Display: "sign in with example.com",
|
||||
Scope: []string{"foo", "bar"},
|
||||
}}
|
||||
reqTest := services.OIDCAuthRequest{
|
||||
ConnectorID: conn.GetName(),
|
||||
Type: constants.OIDC,
|
||||
SSOTestFlow: true,
|
||||
ConnectorSpec: &types.OIDCConnectorSpecV3{
|
||||
IssuerURL: "https://gitlab.com",
|
||||
ClientID: "example-client-id",
|
||||
ClientSecret: "example-client-secret",
|
||||
RedirectURLs: []string{"https://localhost:3080/v1/webapi/oidc/callback"},
|
||||
Display: "sign in with example.com",
|
||||
Scope: []string{"foo", "bar"},
|
||||
ClaimsToRoles: []types.ClaimMapping{
|
||||
{
|
||||
Claim: "groups",
|
||||
Value: "idp-admin",
|
||||
Roles: []string{"access"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
tests := []struct {
|
||||
desc string
|
||||
|
|
|
@ -2257,9 +2257,6 @@ func (g *GRPCServer) UpsertOIDCConnector(ctx context.Context, oidcConnector *typ
|
|||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
if err = services.ValidateOIDCConnector(oidcConnector); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
if err = auth.ServerWithRoles.UpsertOIDCConnector(ctx, oidcConnector); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
|
189
lib/auth/oidc.go
189
lib/auth/oidc.go
|
@ -25,6 +25,7 @@ import (
|
|||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/gravitational/teleport"
|
||||
"github.com/gravitational/teleport/api/constants"
|
||||
apidefaults "github.com/gravitational/teleport/api/defaults"
|
||||
|
@ -42,7 +43,10 @@ import (
|
|||
"github.com/gravitational/trace"
|
||||
)
|
||||
|
||||
// getOIDCConnectorAndClient returns the associated oidc connector
|
||||
// and client for the given oidc auth request.
|
||||
func (a *Server) getOIDCConnectorAndClient(ctx context.Context, request services.OIDCAuthRequest) (types.OIDCConnector, *oidc.Client, error) {
|
||||
// stateless test flow
|
||||
if request.SSOTestFlow {
|
||||
if request.ConnectorSpec == nil {
|
||||
return nil, nil, trace.BadParameter("ConnectorSpec cannot be nil when SSOTestFlow is true")
|
||||
|
@ -52,18 +56,32 @@ func (a *Server) getOIDCConnectorAndClient(ctx context.Context, request services
|
|||
return nil, nil, trace.BadParameter("ConnectorID cannot be empty")
|
||||
}
|
||||
|
||||
// stateless test flow
|
||||
connector, err := types.NewOIDCConnector(request.ConnectorID, *request.ConnectorSpec)
|
||||
if err != nil {
|
||||
return nil, nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// we don't want to cache the client. construct it directly.
|
||||
client, err := a.createOIDCClient(ctx, connector, false)
|
||||
client, err := newOIDCClient(ctx, connector, request.ProxyAddress)
|
||||
if err != nil {
|
||||
return nil, nil, trace.Wrap(err)
|
||||
}
|
||||
return connector, client, nil
|
||||
if err := client.waitFirstSync(defaults.WebHeadersTimeout); err != nil {
|
||||
return nil, nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// close this request-scoped oidc client after 10 minutes
|
||||
go func() {
|
||||
ticker := a.GetClock().NewTicker(defaults.OIDCAuthRequestTTL)
|
||||
defer ticker.Stop()
|
||||
select {
|
||||
case <-ticker.Chan():
|
||||
client.syncCancel()
|
||||
case <-client.syncCtx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
return connector, client.client, nil
|
||||
}
|
||||
|
||||
// regular execution flow
|
||||
|
@ -71,101 +89,71 @@ func (a *Server) getOIDCConnectorAndClient(ctx context.Context, request services
|
|||
if err != nil {
|
||||
return nil, nil, trace.Wrap(err)
|
||||
}
|
||||
client, err := a.getOrCreateOIDCClient(ctx, connector)
|
||||
|
||||
client, err := a.getCachedOIDCClient(ctx, connector, request.ProxyAddress)
|
||||
if err != nil {
|
||||
return nil, nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
return connector, client, nil
|
||||
// Wait for the client to successfully sync after getting it from the cache.
|
||||
// We do this after caching the client to prevent locking the server during
|
||||
// the initial sync period.
|
||||
if err := client.waitFirstSync(defaults.WebHeadersTimeout); err != nil {
|
||||
return nil, nil, trace.Wrap(err)
|
||||
}
|
||||
return connector, client.client, nil
|
||||
}
|
||||
|
||||
func (a *Server) getOrCreateOIDCClient(ctx context.Context, conn types.OIDCConnector) (*oidc.Client, error) {
|
||||
client, err := a.getOIDCClient(conn)
|
||||
if err == nil {
|
||||
return client, nil
|
||||
}
|
||||
if !trace.IsNotFound(err) {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
return a.createOIDCClient(ctx, conn, true)
|
||||
}
|
||||
|
||||
func (a *Server) getOIDCClient(conn types.OIDCConnector) (*oidc.Client, error) {
|
||||
// getCachedOIDCClient gets a cached oidc client for
|
||||
// the given OIDC connector and redirectURL preference.
|
||||
func (a *Server) getCachedOIDCClient(ctx context.Context, conn types.OIDCConnector, proxyAddr string) (*oidcClient, error) {
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
|
||||
clientPack, ok := a.oidcClients[conn.GetName()]
|
||||
if !ok {
|
||||
return nil, trace.NotFound("connector %v is not found", conn.GetName())
|
||||
// Each connector and proxy combination has a distinct client,
|
||||
// so we use a composite key to capture all combinations.
|
||||
clientMapKey := conn.GetName() + "_" + proxyAddr
|
||||
|
||||
cachedClient, ok := a.oidcClients[clientMapKey]
|
||||
if ok {
|
||||
if !cachedClient.needsRefresh(conn) && cachedClient.syncCtx.Err() == nil {
|
||||
return cachedClient, nil
|
||||
}
|
||||
// Cached client needs to be refreshed or is no longer syncing.
|
||||
cachedClient.syncCancel()
|
||||
delete(a.oidcClients, clientMapKey)
|
||||
}
|
||||
|
||||
config := oidcConfig(conn)
|
||||
if ok && oidcConfigsEqual(clientPack.config, config) {
|
||||
return clientPack.client, nil
|
||||
// Create a new oidc client and add it to the cache.
|
||||
client, err := newOIDCClient(ctx, conn, proxyAddr)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
clientPack.cancel()
|
||||
delete(a.oidcClients, conn.GetName())
|
||||
return nil, trace.NotFound("connector %v has updated the configuration and is invalidated", conn.GetName())
|
||||
|
||||
a.oidcClients[clientMapKey] = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (a *Server) createOIDCClient(ctx context.Context, conn types.OIDCConnector, rememberClient bool) (*oidc.Client, error) {
|
||||
config := oidcConfig(conn)
|
||||
func newOIDCClient(ctx context.Context, conn types.OIDCConnector, proxyAddr string) (*oidcClient, error) {
|
||||
redirectURL, err := services.GetRedirectURL(conn, proxyAddr)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
config := oidcConfig(conn, redirectURL)
|
||||
client, err := oidc.NewClient(config)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// SyncProviderConfig doesn't take a context for cancellation, instead it
|
||||
// returns a channel that has to be closed to stop the sync. To ensure that
|
||||
// the sync is eventually stopped we create a child context of the server context, which
|
||||
// is cancelled either on deletion of the connector or shutdown of the server.
|
||||
// This will cause syncCtx.Done() to unblock, at which point we can close the stop channel.
|
||||
firstSync := make(chan struct{})
|
||||
syncCtx, syncCancel := context.WithCancel(a.closeCtx)
|
||||
go func() {
|
||||
stop := client.SyncProviderConfig(conn.GetIssuerURL())
|
||||
close(firstSync)
|
||||
<-syncCtx.Done()
|
||||
close(stop)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-firstSync:
|
||||
case <-time.After(defaults.WebHeadersTimeout):
|
||||
syncCancel()
|
||||
return nil, trace.ConnectionProblem(nil,
|
||||
"timed out syncing oidc connector %v, ensure URL %q is valid and accessible and check configuration",
|
||||
conn.GetName(), conn.GetIssuerURL())
|
||||
case <-a.closeCtx.Done():
|
||||
syncCancel()
|
||||
return nil, trace.ConnectionProblem(nil, "auth server is shutting down")
|
||||
}
|
||||
|
||||
if rememberClient {
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
|
||||
a.oidcClients[conn.GetName()] = &oidcClient{client: client, config: config, cancel: syncCancel}
|
||||
} else {
|
||||
// either wait for the parent context to finish, or wait up to 10 minutes.
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(defaults.OIDCAuthRequestTTL):
|
||||
}
|
||||
log.Infof("Removing OIDC test client for connector %q, URL %q", conn.GetName(), conn.GetIssuerURL())
|
||||
syncCancel()
|
||||
}()
|
||||
}
|
||||
|
||||
return client, nil
|
||||
oidcClient := &oidcClient{client: client, connector: conn, firstSync: make(chan struct{})}
|
||||
oidcClient.startSync(ctx)
|
||||
return oidcClient, nil
|
||||
}
|
||||
|
||||
func oidcConfig(conn types.OIDCConnector) oidc.ClientConfig {
|
||||
func oidcConfig(conn types.OIDCConnector, redirectURL string) oidc.ClientConfig {
|
||||
return oidc.ClientConfig{
|
||||
RedirectURL: conn.GetRedirectURL(),
|
||||
RedirectURL: redirectURL,
|
||||
Credentials: oidc.ClientCredentials{
|
||||
ID: conn.GetClientID(),
|
||||
Secret: conn.GetClientSecret(),
|
||||
|
@ -175,6 +163,57 @@ func oidcConfig(conn types.OIDCConnector) oidc.ClientConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// needsRefresh returns whether the client's connector and the
|
||||
// given connector have the same values for fields relevant to
|
||||
// generating and syncing an oidc.Client.
|
||||
func (c *oidcClient) needsRefresh(conn types.OIDCConnector) bool {
|
||||
return !cmp.Equal(conn.GetRedirectURLs(), c.connector.GetRedirectURLs()) ||
|
||||
conn.GetClientID() != c.connector.GetClientID() ||
|
||||
conn.GetClientSecret() != c.connector.GetClientSecret() ||
|
||||
!cmp.Equal(conn.GetScope(), c.connector.GetScope()) ||
|
||||
conn.GetIssuerURL() != c.connector.GetIssuerURL()
|
||||
}
|
||||
|
||||
// startSync starts a goroutine to sync the client with its provider
|
||||
// config until the given ctx is closed or the sync is cancelled.
|
||||
func (c *oidcClient) startSync(ctx context.Context) {
|
||||
// SyncProviderConfig doesn't take a context for cancellation, instead it
|
||||
// returns a channel that has to be closed to stop the sync. To ensure that the
|
||||
// sync is eventually stopped, we "wrap" the stop channel with a cancel context.
|
||||
c.syncCtx, c.syncCancel = context.WithCancel(ctx)
|
||||
go func() {
|
||||
stop := c.client.SyncProviderConfig(c.connector.GetIssuerURL())
|
||||
close(c.firstSync)
|
||||
<-c.syncCtx.Done()
|
||||
close(stop)
|
||||
}()
|
||||
}
|
||||
|
||||
// waitFirstSync waits for the client to start syncing successfully, or
|
||||
// returns an error if syncing fails or fails to succeed within 10 seconds.
|
||||
// This prevents waiting on clients with faulty provider configurations.
|
||||
func (c *oidcClient) waitFirstSync(timeout time.Duration) error {
|
||||
timeoutTimer := time.NewTimer(timeout)
|
||||
|
||||
select {
|
||||
case <-c.firstSync:
|
||||
case <-c.syncCtx.Done():
|
||||
case <-timeoutTimer.C:
|
||||
// cancel sync so that it gets removed from the cache
|
||||
c.syncCancel()
|
||||
return trace.ConnectionProblem(nil, "timed out syncing oidc connector %v, ensure URL %q is valid and accessible and check configuration",
|
||||
c.connector.GetName(), c.connector.GetIssuerURL())
|
||||
}
|
||||
|
||||
// stop and flush timer
|
||||
if !timeoutTimer.Stop() {
|
||||
<-timeoutTimer.C
|
||||
}
|
||||
|
||||
// return the syncing error if there is one
|
||||
return trace.Wrap(c.syncCtx.Err())
|
||||
}
|
||||
|
||||
// UpsertOIDCConnector creates or updates an OIDC connector.
|
||||
func (a *Server) UpsertOIDCConnector(ctx context.Context, connector types.OIDCConnector) error {
|
||||
if err := a.Identity.UpsertOIDCConnector(ctx, connector); err != nil {
|
||||
|
|
|
@ -20,7 +20,7 @@ import (
|
|||
"os"
|
||||
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
"github.com/gravitational/teleport/lib/utils"
|
||||
"github.com/gravitational/teleport/api/utils"
|
||||
|
||||
"github.com/coreos/go-oidc/jose"
|
||||
"github.com/gravitational/trace"
|
||||
|
|
|
@ -41,6 +41,7 @@ import (
|
|||
"github.com/coreos/go-oidc/jose"
|
||||
"github.com/coreos/go-oidc/oauth2"
|
||||
"github.com/coreos/go-oidc/oidc"
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/jonboulle/clockwork"
|
||||
"github.com/stretchr/testify/require"
|
||||
directory "google.golang.org/api/admin/directory/v1"
|
||||
|
@ -84,7 +85,7 @@ func setUpSuite(t *testing.T) *OIDCSuite {
|
|||
|
||||
// createInsecureOIDCClient creates an insecure client for testing.
|
||||
func createInsecureOIDCClient(t *testing.T, connector types.OIDCConnector) *oidc.Client {
|
||||
conf := oidcConfig(connector)
|
||||
conf := oidcConfig(connector, "")
|
||||
conf.HTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
|
@ -141,22 +142,26 @@ func TestCreateOIDCUser(t *testing.T) {
|
|||
// all claim information is already within the token and additional claim
|
||||
// information does not need to be fetched.
|
||||
func TestUserInfoBlockHTTP(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := setUpSuite(t)
|
||||
// Create configurable IdP to use in tests.
|
||||
idp := newFakeIDP(t, false /* tls */)
|
||||
|
||||
// Create OIDC connector and client.
|
||||
connector, err := types.NewOIDCConnector("test-connector", types.OIDCConnectorSpecV3{
|
||||
IssuerURL: idp.s.URL,
|
||||
ClientID: "00000000000000000000000000000000",
|
||||
ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
IssuerURL: idp.s.URL,
|
||||
ClientID: "00000000000000000000000000000000",
|
||||
ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}},
|
||||
RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
oidcClient, err := s.a.getOrCreateOIDCClient(context.Background(), connector)
|
||||
|
||||
oidcClient, err := s.a.getCachedOIDCClient(ctx, connector, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify HTTP endpoints return trace.NotFound.
|
||||
_, err = claimsFromUserInfo(oidcClient, idp.s.URL, "")
|
||||
_, err = claimsFromUserInfo(oidcClient.client, idp.s.URL, "")
|
||||
fixtures.AssertNotFound(t, err)
|
||||
}
|
||||
|
||||
|
@ -168,9 +173,11 @@ func TestUserInfoBadStatus(t *testing.T) {
|
|||
|
||||
// Create OIDC connector and client.
|
||||
connector, err := types.NewOIDCConnector("test-connector", types.OIDCConnectorSpecV3{
|
||||
IssuerURL: idp.s.URL,
|
||||
ClientID: "00000000000000000000000000000000",
|
||||
ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
IssuerURL: idp.s.URL,
|
||||
ClientID: "00000000000000000000000000000000",
|
||||
ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}},
|
||||
RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
oidcClient := createInsecureOIDCClient(t, connector)
|
||||
|
@ -209,6 +216,7 @@ func TestSSODiagnostic(t *testing.T) {
|
|||
Roles: []string{"access"},
|
||||
},
|
||||
},
|
||||
RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"},
|
||||
}
|
||||
|
||||
oidcRequest := services.OIDCAuthRequest{
|
||||
|
@ -319,30 +327,198 @@ func TestSSODiagnostic(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestPingProvider confirms that the client_secret_post auth
|
||||
//method was set for a oauthclient.
|
||||
// method was set for a oauthclient.
|
||||
func TestPingProvider(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := setUpSuite(t)
|
||||
// Create configurable IdP to use in tests.
|
||||
idp := newFakeIDP(t, false /* tls */)
|
||||
|
||||
// Create and upsert oidc connector into identity
|
||||
connector, err := types.NewOIDCConnector("test-connector", types.OIDCConnectorSpecV3{
|
||||
IssuerURL: idp.s.URL,
|
||||
ClientID: "00000000000000000000000000000000",
|
||||
ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
Provider: teleport.Ping,
|
||||
ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}},
|
||||
RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = s.a.Identity.UpsertOIDCConnector(ctx, connector)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, req := range []services.OIDCAuthRequest{
|
||||
{
|
||||
ConnectorID: "test-connector",
|
||||
}, {
|
||||
SSOTestFlow: true,
|
||||
ConnectorID: "test-connector",
|
||||
ConnectorSpec: &types.OIDCConnectorSpecV3{
|
||||
IssuerURL: idp.s.URL,
|
||||
ClientID: "00000000000000000000000000000000",
|
||||
ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
Provider: teleport.Ping,
|
||||
ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}},
|
||||
RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("Test SSOFlow: %v", req.SSOTestFlow), func(t *testing.T) {
|
||||
oidcConnector, oidcClient, err := s.a.getOIDCConnectorAndClient(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
oac, err := getOAuthClient(oidcClient, oidcConnector)
|
||||
require.NoError(t, err)
|
||||
|
||||
// authMethod should be client secret post now
|
||||
require.Equal(t, oauth2.AuthMethodClientSecretPost, oac.GetAuthMethod())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCClientProviderSync(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
// Create configurable IdP to use in tests.
|
||||
idp := newFakeIDP(t, false /* tls */)
|
||||
|
||||
// Create OIDC connector and client.
|
||||
connector, err := types.NewOIDCConnector("test-connector", types.OIDCConnectorSpecV3{
|
||||
IssuerURL: idp.s.URL,
|
||||
ClientID: "00000000000000000000000000000000",
|
||||
ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
Provider: teleport.Ping,
|
||||
IssuerURL: idp.s.URL,
|
||||
ClientID: "00000000000000000000000000000000",
|
||||
ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
Provider: teleport.Ping,
|
||||
ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}},
|
||||
RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
oidcClient, err := s.a.getOrCreateOIDCClient(context.Background(), connector)
|
||||
|
||||
client, err := newOIDCClient(ctx, connector, "proxy.example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
oac, err := getOAuthClient(oidcClient, connector)
|
||||
// first sync should complete successfully
|
||||
require.NoError(t, client.waitFirstSync(100*time.Millisecond))
|
||||
require.NoError(t, client.syncCtx.Err())
|
||||
|
||||
// Create OIDC client with a canceled ctx
|
||||
canceledCtx, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
|
||||
client, err = newOIDCClient(canceledCtx, connector, "proxy.example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
// authMethod should be client secret post now
|
||||
require.Equal(t, oauth2.AuthMethodClientSecretPost, oac.GetAuthMethod())
|
||||
// provider sync goroutine should end and first sync should fail
|
||||
require.ErrorIs(t, client.syncCtx.Err(), context.Canceled)
|
||||
err = client.waitFirstSync(100 * time.Millisecond)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
|
||||
// Create OIDC connector and client without an issuer URL for provider syncing
|
||||
connectorNoIssuer, err := types.NewOIDCConnector("test-connector", types.OIDCConnectorSpecV3{
|
||||
ClientID: "00000000000000000000000000000000",
|
||||
ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
Provider: teleport.Ping,
|
||||
ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}},
|
||||
RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, time.Second)
|
||||
defer cancel()
|
||||
client, err = newOIDCClient(timeoutCtx, connectorNoIssuer, "proxy.example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
// first sync should fail after the given timeout and cancel the sync goroutine.
|
||||
err = client.waitFirstSync(100 * time.Millisecond)
|
||||
require.Error(t, err)
|
||||
require.True(t, trace.IsConnectionProblem(err))
|
||||
require.ErrorIs(t, client.syncCtx.Err(), context.Canceled)
|
||||
}
|
||||
|
||||
func TestOIDCClientCache(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := setUpSuite(t)
|
||||
// Create configurable IdP to use in tests.
|
||||
idp := newFakeIDP(t, false /* tls */)
|
||||
connectorSpec := types.OIDCConnectorSpecV3{
|
||||
IssuerURL: idp.s.URL,
|
||||
ClientID: "00000000000000000000000000000000",
|
||||
ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
Provider: teleport.Ping,
|
||||
ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}},
|
||||
RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"},
|
||||
}
|
||||
connector, err := types.NewOIDCConnector("test-connector", connectorSpec)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and cache a new oidc client
|
||||
client, err := s.a.getCachedOIDCClient(ctx, connector, "proxy.example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
// The next call should return the same client (compare memory address)
|
||||
cachedClient, err := s.a.getCachedOIDCClient(ctx, connector, "proxy.example.com")
|
||||
require.NoError(t, err)
|
||||
require.True(t, client == cachedClient)
|
||||
|
||||
// Canceling provider sync on a cached client should cause it to be replaced
|
||||
client.syncCancel()
|
||||
cachedClient, err = s.a.getCachedOIDCClient(ctx, connector, "proxy.example.com")
|
||||
require.NoError(t, err)
|
||||
require.False(t, client == cachedClient)
|
||||
|
||||
// Certain changes to the connector should cause the cached client to be refreshed
|
||||
originalClient := cachedClient
|
||||
for _, tc := range []struct {
|
||||
desc string
|
||||
mutateConnector func(types.OIDCConnector)
|
||||
expectNoRefresh bool
|
||||
}{
|
||||
{
|
||||
desc: "IssuerURL",
|
||||
mutateConnector: func(conn types.OIDCConnector) {
|
||||
conn.SetIssuerURL(newFakeIDP(t, false /* tls */).s.URL)
|
||||
},
|
||||
}, {
|
||||
desc: "ClientID",
|
||||
mutateConnector: func(conn types.OIDCConnector) {
|
||||
conn.SetClientID("11111111111111111111111111111111")
|
||||
},
|
||||
}, {
|
||||
desc: "ClientSecret",
|
||||
mutateConnector: func(conn types.OIDCConnector) {
|
||||
conn.SetClientSecret("1111111111111111111111111111111111111111111111111111111111111111")
|
||||
},
|
||||
}, {
|
||||
desc: "RedirectURLs",
|
||||
mutateConnector: func(conn types.OIDCConnector) {
|
||||
conn.SetRedirectURLs([]string{"https://other.example.com/v1/webapi/oidc/callback"})
|
||||
},
|
||||
}, {
|
||||
desc: "Scope",
|
||||
mutateConnector: func(conn types.OIDCConnector) {
|
||||
conn.SetScope([]string{"groups"})
|
||||
},
|
||||
}, {
|
||||
desc: "Prompt - no refresh",
|
||||
mutateConnector: func(conn types.OIDCConnector) {
|
||||
conn.SetPrompt("none")
|
||||
},
|
||||
expectNoRefresh: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
newConnector, err := types.NewOIDCConnector("test-connector", connectorSpec)
|
||||
require.NoError(t, err)
|
||||
tc.mutateConnector(newConnector)
|
||||
|
||||
client, err = s.a.getCachedOIDCClient(ctx, newConnector, "proxy.example.com")
|
||||
require.NoError(t, err)
|
||||
require.True(t, (client == originalClient) == tc.expectNoRefresh)
|
||||
|
||||
// reset cached client to the original client for remaining tests
|
||||
originalClient, err = s.a.getCachedOIDCClient(ctx, connector, "proxy.example.com")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakeIDP is a configurable OIDC IdP that can be used to mock responses in
|
||||
|
|
|
@ -849,7 +849,7 @@ func applyProxyConfig(fc *FileConfig, cfg *service.Config) error {
|
|||
}
|
||||
}
|
||||
if len(fc.Proxy.PublicAddr) != 0 {
|
||||
addrs, err := utils.AddrsFromStrings(fc.Proxy.PublicAddr, defaults.HTTPListenPort)
|
||||
addrs, err := utils.AddrsFromStrings(fc.Proxy.PublicAddr, cfg.Proxy.WebAddr.Port(defaults.HTTPListenPort))
|
||||
if err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
|
|
@ -657,10 +657,6 @@ type Auth struct {
|
|||
// Deprecated: Remove in Teleport 2.4.1.
|
||||
TrustedClusters []TrustedCluster `yaml:"trusted_clusters,omitempty"`
|
||||
|
||||
// OIDCConnectors is a list of trusted OpenID Connect Identity providers
|
||||
// Deprecated: Remove in Teleport 2.4.1.
|
||||
OIDCConnectors []OIDCConnector `yaml:"oidc_connectors,omitempty"`
|
||||
|
||||
// DynamicConfig determines when file configuration is pushed to the backend. Setting
|
||||
// it here overrides defaults.
|
||||
// Deprecated: Remove in Teleport 2.4.1.
|
||||
|
@ -1505,77 +1501,6 @@ type ClaimMapping struct {
|
|||
Roles []string `yaml:"roles,omitempty"`
|
||||
}
|
||||
|
||||
// OIDCConnector specifies configuration fo Open ID Connect compatible external
|
||||
// identity provider, e.g. google in some organisation
|
||||
type OIDCConnector struct {
|
||||
// ID is a provider id, 'e.g.' google, used internally
|
||||
ID string `yaml:"id"`
|
||||
// Issuer URL is the endpoint of the provider, e.g. https://accounts.google.com
|
||||
IssuerURL string `yaml:"issuer_url"`
|
||||
// ClientID is id for authentication client (in our case it's our Auth server)
|
||||
ClientID string `yaml:"client_id"`
|
||||
// ClientSecret is used to authenticate our client and should not
|
||||
// be visible to end user
|
||||
ClientSecret string `yaml:"client_secret"`
|
||||
// RedirectURL - Identity provider will use this URL to redirect
|
||||
// client's browser back to it after successful authentication
|
||||
// Should match the URL on Provider's side
|
||||
RedirectURL string `yaml:"redirect_url"`
|
||||
// ACR is the acr_values parameter to be sent with an authorization request.
|
||||
ACR string `yaml:"acr_values,omitempty"`
|
||||
// Provider is the identity provider we connect to. This field is
|
||||
// only required if using acr_values.
|
||||
Provider string `yaml:"provider,omitempty"`
|
||||
// Display controls how this connector is displayed
|
||||
Display string `yaml:"display"`
|
||||
// Scope is a list of additional scopes to request from OIDC
|
||||
// note that oidc and email scopes are always requested
|
||||
Scope []string `yaml:"scope"`
|
||||
// ClaimsToRoles is a list of mappings of claims to roles
|
||||
ClaimsToRoles []ClaimMapping `yaml:"claims_to_roles"`
|
||||
}
|
||||
|
||||
// Parse parses config struct into services connector and checks if it's valid
|
||||
func (o *OIDCConnector) Parse() (types.OIDCConnector, error) {
|
||||
if o.Display == "" {
|
||||
o.Display = o.ID
|
||||
}
|
||||
|
||||
var mappings []types.ClaimMapping
|
||||
for _, c := range o.ClaimsToRoles {
|
||||
var roles []string
|
||||
if len(c.Roles) > 0 {
|
||||
roles = append(roles, c.Roles...)
|
||||
}
|
||||
|
||||
mappings = append(mappings, types.ClaimMapping{
|
||||
Claim: c.Claim,
|
||||
Value: c.Value,
|
||||
Roles: roles,
|
||||
})
|
||||
}
|
||||
|
||||
connector, err := types.NewOIDCConnector(o.ID, types.OIDCConnectorSpecV3{
|
||||
IssuerURL: o.IssuerURL,
|
||||
ClientID: o.ClientID,
|
||||
ClientSecret: o.ClientSecret,
|
||||
RedirectURL: o.RedirectURL,
|
||||
Display: o.Display,
|
||||
Scope: o.Scope,
|
||||
ClaimsToRoles: mappings,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
connector.SetACR(o.ACR)
|
||||
connector.SetProvider(o.Provider)
|
||||
if err := connector.CheckAndSetDefaults(); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
return connector, nil
|
||||
}
|
||||
|
||||
// Metrics is a `metrics_service` section of the config file:
|
||||
type Metrics struct {
|
||||
// Service is a generic service configuration section
|
||||
|
|
|
@ -1001,7 +1001,7 @@ func initUploadHandler(ctx context.Context, auditConfig types.ClusterAuditConfig
|
|||
}
|
||||
return handler, nil
|
||||
}
|
||||
uri, err := utils.ParseSessionsURI(auditConfig.AuditSessionsURI())
|
||||
uri, err := apiutils.ParseSessionsURI(auditConfig.AuditSessionsURI())
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
@ -1051,7 +1051,7 @@ func initExternalLog(ctx context.Context, auditConfig types.ClusterAuditConfig,
|
|||
var hasNonFileLog bool
|
||||
var loggers []events.IAuditLog
|
||||
for _, eventsURI := range auditConfig.AuditEventsURIs() {
|
||||
uri, err := utils.ParseSessionsURI(eventsURI)
|
||||
uri, err := apiutils.ParseSessionsURI(eventsURI)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
|
|
@ -395,7 +395,8 @@ type OIDCAuthRequest struct {
|
|||
// CSRFToken is associated with user web session token
|
||||
CSRFToken string `json:"csrf_token"`
|
||||
|
||||
// RedirectURL will be used by browser
|
||||
// RedirectURL will be used to route the user back to a
|
||||
// Teleport Proxy after the oidc login attempt in the brower.
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
|
||||
// PublicKey is an optional public key, users want these
|
||||
|
@ -428,6 +429,12 @@ type OIDCAuthRequest struct {
|
|||
|
||||
// ConnectorSpec is embedded connector spec for use in test flow.
|
||||
ConnectorSpec *types.OIDCConnectorSpecV3 `json:"connector_spec,omitempty"`
|
||||
|
||||
// ProxyAddress is an optional address which can be used to
|
||||
// find a redirect url from the OIDC connector which matches
|
||||
// the address. If there is no match, the default redirect
|
||||
// url will be used.
|
||||
ProxyAddress string `json:"proxy_address,omitempty"`
|
||||
}
|
||||
|
||||
// Check returns nil if all parameters are great, err otherwise
|
||||
|
|
|
@ -22,52 +22,10 @@ import (
|
|||
"github.com/coreos/go-oidc/jose"
|
||||
"github.com/gravitational/trace"
|
||||
|
||||
"github.com/gravitational/teleport"
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
"github.com/gravitational/teleport/lib/utils"
|
||||
)
|
||||
|
||||
// ValidateOIDCConnector validates the OIDC connector and sets default values
|
||||
func ValidateOIDCConnector(oc types.OIDCConnector) error {
|
||||
if err := oc.CheckAndSetDefaults(); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
if _, err := url.Parse(oc.GetIssuerURL()); err != nil {
|
||||
return trace.BadParameter("IssuerURL: bad url: '%v'", oc.GetIssuerURL())
|
||||
}
|
||||
if _, err := url.Parse(oc.GetRedirectURL()); err != nil {
|
||||
return trace.BadParameter("RedirectURL: bad url: '%v'", oc.GetRedirectURL())
|
||||
}
|
||||
|
||||
if oc.GetGoogleServiceAccountURI() != "" && oc.GetGoogleServiceAccount() != "" {
|
||||
return trace.BadParameter("one of either google_service_account_uri or google_service_account is supported, not both")
|
||||
}
|
||||
|
||||
if oc.GetGoogleServiceAccountURI() != "" {
|
||||
uri, err := utils.ParseSessionsURI(oc.GetGoogleServiceAccountURI())
|
||||
if err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
if uri.Scheme != teleport.SchemeFile {
|
||||
return trace.BadParameter("only %v:// scheme is supported for google_service_account_uri", teleport.SchemeFile)
|
||||
}
|
||||
if oc.GetGoogleAdminEmail() == "" {
|
||||
return trace.BadParameter("whenever google_service_account_uri is specified, google_admin_email should be set as well, read https://developers.google.com/identity/protocols/OAuth2ServiceAccount#delegatingauthority for more details")
|
||||
}
|
||||
}
|
||||
if oc.GetGoogleServiceAccount() != "" {
|
||||
if oc.GetGoogleAdminEmail() == "" {
|
||||
return trace.BadParameter("whenever google_service_account is specified, google_admin_email should be set as well, read https://developers.google.com/identity/protocols/OAuth2ServiceAccount#delegatingauthority for more details")
|
||||
}
|
||||
}
|
||||
|
||||
if len(oc.GetClaimsToRoles()) == 0 {
|
||||
return trace.BadParameter("claims_to_roles is empty, authorization with connector would never assign any roles")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClaimNames returns a list of claim names from the claim values
|
||||
func GetClaimNames(claims jose.Claims) []string {
|
||||
var out []string
|
||||
|
@ -95,6 +53,52 @@ func OIDCClaimsToTraits(claims jose.Claims) map[string][]string {
|
|||
return traits
|
||||
}
|
||||
|
||||
// GetRedirectURL gets a redirect URL for the given connector. If the connector
|
||||
// has a redirect URL which matches the host of the given Proxy address, then
|
||||
// that one will be returned. Otherwise, the first URL in the list will be returned.
|
||||
func GetRedirectURL(conn types.OIDCConnector, proxyAddr string) (string, error) {
|
||||
if len(conn.GetRedirectURLs()) == 0 {
|
||||
return "", trace.BadParameter("No redirect URLs provided")
|
||||
}
|
||||
|
||||
// If a specific proxyAddr wasn't provided in the oidc auth request,
|
||||
// or there is only one redirect URL, use the first redirect URL.
|
||||
if proxyAddr == "" || len(conn.GetRedirectURLs()) == 1 {
|
||||
return conn.GetRedirectURLs()[0], nil
|
||||
}
|
||||
|
||||
proxyNetAddr, err := utils.ParseAddr(proxyAddr)
|
||||
if err != nil {
|
||||
return "", trace.Wrap(err, "invalid proxy address %v", proxyAddr)
|
||||
}
|
||||
|
||||
var matchingHostname string
|
||||
for _, r := range conn.GetRedirectURLs() {
|
||||
redirectURL, err := url.ParseRequestURI(r)
|
||||
if err != nil {
|
||||
return "", trace.Wrap(err)
|
||||
}
|
||||
|
||||
// If we have a direct host:port match, return it.
|
||||
if proxyNetAddr.String() == redirectURL.Host {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// If we have a matching host, but not port,
|
||||
// save it as the best match for now.
|
||||
if matchingHostname == "" && proxyNetAddr.Host() == redirectURL.Hostname() {
|
||||
matchingHostname = r
|
||||
}
|
||||
}
|
||||
|
||||
if matchingHostname != "" {
|
||||
return matchingHostname, nil
|
||||
}
|
||||
|
||||
// No match, default to the first redirect URL.
|
||||
return conn.GetRedirectURLs()[0], nil
|
||||
}
|
||||
|
||||
// UnmarshalOIDCConnector unmarshals the OIDCConnector resource from JSON.
|
||||
func UnmarshalOIDCConnector(bytes []byte, opts ...MarshalOption) (types.OIDCConnector, error) {
|
||||
cfg, err := CollectOptions(opts)
|
||||
|
@ -113,7 +117,7 @@ func UnmarshalOIDCConnector(bytes []byte, opts ...MarshalOption) (types.OIDCConn
|
|||
if err := utils.FastUnmarshal(bytes, &c); err != nil {
|
||||
return nil, trace.BadParameter(err.Error())
|
||||
}
|
||||
if err := ValidateOIDCConnector(&c); err != nil {
|
||||
if err := c.CheckAndSetDefaults(); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
if cfg.ID != 0 {
|
||||
|
@ -130,7 +134,7 @@ func UnmarshalOIDCConnector(bytes []byte, opts ...MarshalOption) (types.OIDCConn
|
|||
|
||||
// MarshalOIDCConnector marshals the OIDCConnector resource to JSON.
|
||||
func MarshalOIDCConnector(oidcConnector types.OIDCConnector, opts ...MarshalOption) ([]byte, error) {
|
||||
if err := ValidateOIDCConnector(oidcConnector); err != nil {
|
||||
if err := oidcConnector.CheckAndSetDefaults(); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
|
|
|
@ -21,56 +21,24 @@ import (
|
|||
|
||||
"github.com/coreos/go-oidc/jose"
|
||||
|
||||
"github.com/gravitational/teleport"
|
||||
"github.com/gravitational/teleport/api/constants"
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Verify that an OIDC connector with no mappings produces no roles.
|
||||
func TestOIDCRoleMappingEmpty(t *testing.T) {
|
||||
// create a connector
|
||||
oidcConnector, err := types.NewOIDCConnector("example", types.OIDCConnectorSpecV3{
|
||||
IssuerURL: "https://www.exmaple.com",
|
||||
ClientID: "example-client-id",
|
||||
ClientSecret: "example-client-secret",
|
||||
RedirectURL: "https://localhost:3080/v1/webapi/oidc/callback",
|
||||
Display: "sign in with example.com",
|
||||
Scope: []string{"foo", "bar"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// create some claims
|
||||
var claims = make(jose.Claims)
|
||||
claims.Add("roles", "teleport-user")
|
||||
claims.Add("email", "foo@example.com")
|
||||
claims.Add("nickname", "foo")
|
||||
claims.Add("full_name", "foo bar")
|
||||
|
||||
traits := OIDCClaimsToTraits(claims)
|
||||
require.Len(t, traits, 4)
|
||||
|
||||
_, roles := TraitsToRoles(oidcConnector.GetTraitMappings(), traits)
|
||||
require.Len(t, roles, 0)
|
||||
}
|
||||
|
||||
// TestOIDCRoleMapping verifies basic mapping from OIDC claims to roles.
|
||||
func TestOIDCRoleMapping(t *testing.T) {
|
||||
// create a connector
|
||||
oidcConnector, err := types.NewOIDCConnector("example", types.OIDCConnectorSpecV3{
|
||||
IssuerURL: "https://www.exmaple.com",
|
||||
ClientID: "example-client-id",
|
||||
ClientSecret: "example-client-secret",
|
||||
RedirectURL: "https://localhost:3080/v1/webapi/oidc/callback",
|
||||
Display: "sign in with example.com",
|
||||
Scope: []string{"foo", "bar"},
|
||||
ClaimsToRoles: []types.ClaimMapping{
|
||||
{
|
||||
Claim: "roles",
|
||||
Value: "teleport-user",
|
||||
Roles: []string{"user"},
|
||||
},
|
||||
},
|
||||
IssuerURL: "https://www.exmaple.com",
|
||||
ClientID: "example-client-id",
|
||||
ClientSecret: "example-client-secret",
|
||||
Display: "sign in with example.com",
|
||||
Scope: []string{"foo", "bar"},
|
||||
ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"user"}}},
|
||||
RedirectURLs: []string{"https://localhost:3080/v1/webapi/oidc/callback"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
@ -89,150 +57,194 @@ func TestOIDCRoleMapping(t *testing.T) {
|
|||
require.Equal(t, "user", roles[0])
|
||||
}
|
||||
|
||||
// TestOIDCUnmarshal tests unmarshal of OIDC connector
|
||||
// TestOIDCUnmarshal tests UnmarshalOIDCConnector
|
||||
func TestOIDCUnmarshal(t *testing.T) {
|
||||
input := `
|
||||
{
|
||||
"kind": "oidc",
|
||||
"version": "v2",
|
||||
"metadata": {
|
||||
"name": "google"
|
||||
},
|
||||
"spec": {
|
||||
"issuer_url": "https://accounts.google.com",
|
||||
"client_id": "id-from-google.apps.googleusercontent.com",
|
||||
"client_secret": "secret-key-from-google",
|
||||
"redirect_url": "https://localhost:3080/v1/webapi/oidc/callback",
|
||||
"display": "whatever",
|
||||
"scope": ["roles"],
|
||||
"claims_to_roles": [{
|
||||
"claim": "roles",
|
||||
"value": "teleport-user",
|
||||
"roles": ["dictator"]
|
||||
}],
|
||||
"prompt": "consent login"
|
||||
}
|
||||
}
|
||||
`
|
||||
for _, tc := range []struct {
|
||||
desc string
|
||||
input string
|
||||
expectErr bool
|
||||
expectSpec types.OIDCConnectorSpecV3
|
||||
}{
|
||||
{
|
||||
desc: "basic connector",
|
||||
input: `{
|
||||
"version": "v3",
|
||||
"kind": "oidc",
|
||||
"metadata": {
|
||||
"name": "google"
|
||||
},
|
||||
"spec": {
|
||||
"client_id": "id-from-google.apps.googleusercontent.com",
|
||||
"client_secret": "secret-key-from-google",
|
||||
"display": "whatever",
|
||||
"scope": ["roles"],
|
||||
"prompt": "consent login",
|
||||
"claims_to_roles": [
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "teleport-user",
|
||||
"roles": ["dictator"]
|
||||
}
|
||||
],
|
||||
"redirect_url": "https://localhost:3080/v1/webapi/oidc/callback"
|
||||
}
|
||||
}`,
|
||||
expectSpec: types.OIDCConnectorSpecV3{
|
||||
ClientID: "id-from-google.apps.googleusercontent.com",
|
||||
ClientSecret: "secret-key-from-google",
|
||||
Display: "whatever",
|
||||
Scope: []string{"roles"},
|
||||
Prompt: "consent login",
|
||||
ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}},
|
||||
RedirectURLs: []string{"https://localhost:3080/v1/webapi/oidc/callback"},
|
||||
},
|
||||
}, {
|
||||
desc: "multiple redirect urls",
|
||||
input: `{
|
||||
"version": "v3",
|
||||
"kind": "oidc",
|
||||
"metadata": {
|
||||
"name": "google"
|
||||
},
|
||||
"spec": {
|
||||
"client_id": "id-from-google.apps.googleusercontent.com",
|
||||
"claims_to_roles": [
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "teleport-user",
|
||||
"roles": ["dictator"]
|
||||
}
|
||||
],
|
||||
"redirect_url": [
|
||||
"https://localhost:3080/v1/webapi/oidc/callback",
|
||||
"https://proxy.example.com/v1/webapi/oidc/callback",
|
||||
"https://other.proxy.example.com/v1/webapi/oidc/callback"
|
||||
]
|
||||
}
|
||||
}`,
|
||||
expectSpec: types.OIDCConnectorSpecV3{
|
||||
ClientID: "id-from-google.apps.googleusercontent.com",
|
||||
ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}},
|
||||
RedirectURLs: []string{
|
||||
"https://localhost:3080/v1/webapi/oidc/callback",
|
||||
"https://proxy.example.com/v1/webapi/oidc/callback",
|
||||
"https://other.proxy.example.com/v1/webapi/oidc/callback",
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
connector, err := UnmarshalOIDCConnector([]byte(tc.input))
|
||||
if tc.expectErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
oc, err := UnmarshalOIDCConnector([]byte(input))
|
||||
expectedConnector, err := types.NewOIDCConnector("google", tc.expectSpec)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedConnector, connector)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCCheckAndSetDefaults(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
desc string
|
||||
spec types.OIDCConnectorSpecV3
|
||||
expect func(*testing.T, types.OIDCConnector, error)
|
||||
}{
|
||||
{
|
||||
desc: "basic spec and defaults",
|
||||
spec: types.OIDCConnectorSpecV3{
|
||||
ClientID: "id-from-google.apps.googleusercontent.com",
|
||||
ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}},
|
||||
RedirectURLs: []string{"https://localhost:3080/v1/webapi/oidc/callback"},
|
||||
},
|
||||
expect: func(t *testing.T, c types.OIDCConnector, err error) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, types.V3, c.GetVersion())
|
||||
require.Equal(t, types.KindOIDCConnector, c.GetKind())
|
||||
require.Equal(t, "google", c.GetName())
|
||||
require.Equal(t, "id-from-google.apps.googleusercontent.com", c.GetClientID())
|
||||
require.Equal(t, []string{"https://localhost:3080/v1/webapi/oidc/callback"}, c.GetRedirectURLs())
|
||||
require.Equal(t, constants.OIDCPromptSelectAccount, c.GetPrompt())
|
||||
},
|
||||
}, {
|
||||
desc: "omit prompt",
|
||||
spec: types.OIDCConnectorSpecV3{
|
||||
ClientID: "id-from-google.apps.googleusercontent.com",
|
||||
ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}},
|
||||
RedirectURLs: []string{
|
||||
"https://localhost:3080/v1/webapi/oidc/callback",
|
||||
"https://proxy.example.com/v1/webapi/oidc/callback",
|
||||
"https://other.proxy.example.com/v1/webapi/oidc/callback",
|
||||
},
|
||||
Prompt: "none",
|
||||
},
|
||||
expect: func(t *testing.T, c types.OIDCConnector, err error) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", c.GetPrompt())
|
||||
},
|
||||
}, {
|
||||
desc: "invalid claims to roles",
|
||||
spec: types.OIDCConnectorSpecV3{
|
||||
ClientID: "id-from-google.apps.googleusercontent.com",
|
||||
ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user"}},
|
||||
RedirectURLs: []string{
|
||||
"https://localhost:3080/v1/webapi/oidc/callback",
|
||||
"https://proxy.example.com/v1/webapi/oidc/callback",
|
||||
"https://other.proxy.example.com/v1/webapi/oidc/callback",
|
||||
},
|
||||
Prompt: "none",
|
||||
},
|
||||
expect: func(t *testing.T, c types.OIDCConnector, err error) {
|
||||
require.Error(t, err)
|
||||
require.True(t, trace.IsBadParameter(err))
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
connector, err := types.NewOIDCConnector("google", tc.spec)
|
||||
tc.expect(t, connector, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCGetRedirectURL(t *testing.T) {
|
||||
conn, err := types.NewOIDCConnector("oidc", types.OIDCConnectorSpecV3{
|
||||
ClientID: "id-from-google.apps.googleusercontent.com",
|
||||
ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}},
|
||||
RedirectURLs: []string{
|
||||
"https://proxy.example.com/v1/webapi/oidc/callback",
|
||||
"https://other.example.com/v1/webapi/oidc/callback",
|
||||
"https://other.example.com:443/v1/webapi/oidc/callback",
|
||||
"https://other.example.com:3080/v1/webapi/oidc/callback",
|
||||
"https://eu.proxy.example.com/v1/webapi/oidc/callback",
|
||||
"https://us.proxy.example.com:443/v1/webapi/oidc/callback",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "google", oc.GetName())
|
||||
require.Equal(t, "https://accounts.google.com", oc.GetIssuerURL())
|
||||
require.Equal(t, "id-from-google.apps.googleusercontent.com", oc.GetClientID())
|
||||
require.Equal(t, "https://localhost:3080/v1/webapi/oidc/callback", oc.GetRedirectURL())
|
||||
require.Equal(t, "whatever", oc.GetDisplay())
|
||||
require.Equal(t, "consent login", oc.GetPrompt())
|
||||
}
|
||||
|
||||
// TestOIDCUnmarshalOmitPrompt makes sure that that setting
|
||||
// prompt value to none will omit the prompt value.
|
||||
func TestOIDCUnmarshalOmitPrompt(t *testing.T) {
|
||||
input := `
|
||||
{
|
||||
"kind": "oidc",
|
||||
"version": "v2",
|
||||
"metadata": {
|
||||
"name": "google"
|
||||
},
|
||||
"spec": {
|
||||
"issuer_url": "https://accounts.google.com",
|
||||
"client_id": "id-from-google.apps.googleusercontent.com",
|
||||
"client_secret": "secret-key-from-google",
|
||||
"redirect_url": "https://localhost:3080/v1/webapi/oidc/callback",
|
||||
"display": "whatever",
|
||||
"scope": ["roles"],
|
||||
"prompt": "none",
|
||||
"claims_to_roles": [
|
||||
{
|
||||
"claim": "email",
|
||||
"value": "*",
|
||||
"roles": [
|
||||
"access"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
oc, err := UnmarshalOIDCConnector([]byte(input))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "google", oc.GetName())
|
||||
require.Equal(t, "https://accounts.google.com", oc.GetIssuerURL())
|
||||
require.Equal(t, "id-from-google.apps.googleusercontent.com", oc.GetClientID())
|
||||
require.Equal(t, "https://localhost:3080/v1/webapi/oidc/callback", oc.GetRedirectURL())
|
||||
require.Equal(t, "whatever", oc.GetDisplay())
|
||||
require.Equal(t, "", oc.GetPrompt())
|
||||
}
|
||||
|
||||
// TestOIDCUnmarshalOmitPrompt makes sure that an
|
||||
// empty prompt value will default to select account.
|
||||
func TestOIDCUnmarshalPromptDefault(t *testing.T) {
|
||||
input := `
|
||||
{
|
||||
"kind": "oidc",
|
||||
"version": "v2",
|
||||
"metadata": {
|
||||
"name": "google"
|
||||
},
|
||||
"spec": {
|
||||
"issuer_url": "https://accounts.google.com",
|
||||
"client_id": "id-from-google.apps.googleusercontent.com",
|
||||
"client_secret": "secret-key-from-google",
|
||||
"redirect_url": "https://localhost:3080/v1/webapi/oidc/callback",
|
||||
"display": "whatever",
|
||||
"scope": ["roles"],
|
||||
"claims_to_roles": [
|
||||
{
|
||||
"claim": "email",
|
||||
"value": "*",
|
||||
"roles": [
|
||||
"access"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
oc, err := UnmarshalOIDCConnector([]byte(input))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "google", oc.GetName())
|
||||
require.Equal(t, "https://accounts.google.com", oc.GetIssuerURL())
|
||||
require.Equal(t, "id-from-google.apps.googleusercontent.com", oc.GetClientID())
|
||||
require.Equal(t, "https://localhost:3080/v1/webapi/oidc/callback", oc.GetRedirectURL())
|
||||
require.Equal(t, "whatever", oc.GetDisplay())
|
||||
require.Equal(t, teleport.OIDCPromptSelectAccount, oc.GetPrompt())
|
||||
}
|
||||
|
||||
// TestOIDCUnmarshalInvalid unmarshals and fails validation of the connector
|
||||
func TestOIDCUnmarshalInvalid(t *testing.T) {
|
||||
input := `
|
||||
{
|
||||
"kind": "oidc",
|
||||
"version": "v2",
|
||||
"metadata": {
|
||||
"name": "google"
|
||||
},
|
||||
"spec": {
|
||||
"issuer_url": "https://accounts.google.com",
|
||||
"client_id": "id-from-google.apps.googleusercontent.com",
|
||||
"client_secret": "secret-key-from-google",
|
||||
"redirect_url": "https://localhost:3080/v1/webapi/oidc/callback",
|
||||
"display": "whatever",
|
||||
"scope": ["roles"],
|
||||
"claims_to_roles": [{
|
||||
"claim": "roles",
|
||||
"value": "teleport-user",
|
||||
}]
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
_, err := UnmarshalOIDCConnector([]byte(input))
|
||||
require.Error(t, err)
|
||||
expectedMapping := map[string]string{
|
||||
"proxy.example.com": "https://proxy.example.com/v1/webapi/oidc/callback",
|
||||
"proxy.example.com:443": "https://proxy.example.com/v1/webapi/oidc/callback",
|
||||
"other.example.com": "https://other.example.com/v1/webapi/oidc/callback",
|
||||
"other.example.com:80": "https://other.example.com/v1/webapi/oidc/callback",
|
||||
"other.example.com:443": "https://other.example.com:443/v1/webapi/oidc/callback",
|
||||
"other.example.com:3080": "https://other.example.com:3080/v1/webapi/oidc/callback",
|
||||
"eu.proxy.example.com": "https://eu.proxy.example.com/v1/webapi/oidc/callback",
|
||||
"eu.proxy.example.com:443": "https://eu.proxy.example.com/v1/webapi/oidc/callback",
|
||||
"eu.proxy.example.com:3080": "https://eu.proxy.example.com/v1/webapi/oidc/callback",
|
||||
"us.proxy.example.com": "https://us.proxy.example.com:443/v1/webapi/oidc/callback",
|
||||
"notfound.example.com": "https://proxy.example.com/v1/webapi/oidc/callback",
|
||||
}
|
||||
|
||||
for proxyAddr, redirectURL := range expectedMapping {
|
||||
t.Run(proxyAddr, func(t *testing.T) {
|
||||
url, err := GetRedirectURL(conn, proxyAddr)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, redirectURL, url)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,7 +20,6 @@ import (
|
|||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
@ -28,7 +27,6 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gravitational/teleport"
|
||||
"github.com/gravitational/teleport/lib/fixtures"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
@ -198,27 +196,6 @@ func TestClickableURL(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestParseSessionsURI parses sessions URI
|
||||
func TestParseSessionsURI(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
info string
|
||||
in string
|
||||
url *url.URL
|
||||
}{
|
||||
{info: "local default file system URI", in: "/home/log", url: &url.URL{Scheme: teleport.SchemeFile, Path: "/home/log"}},
|
||||
{info: "explicit filesystem URI", in: "file:///home/log", url: &url.URL{Scheme: teleport.SchemeFile, Path: "/home/log"}},
|
||||
{info: "S3 URI", in: "s3://my-bucket", url: &url.URL{Scheme: teleport.SchemeS3, Host: "my-bucket"}},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.info, func(t *testing.T) {
|
||||
out, err := ParseSessionsURI(testCase.in)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testCase.url, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseAdvertiseAddr tests parsing of advertise address
|
||||
func TestParseAdvertiseAddr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
|
|
@ -1068,6 +1068,7 @@ func (h *Handler) oidcLoginWeb(w http.ResponseWriter, r *http.Request, p httprou
|
|||
CreateWebSession: true,
|
||||
ClientRedirectURL: req.clientRedirectURL,
|
||||
CheckUser: true,
|
||||
ProxyAddress: r.Host,
|
||||
})
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Error creating auth request.")
|
||||
|
@ -1229,6 +1230,7 @@ func (h *Handler) oidcLoginConsole(w http.ResponseWriter, r *http.Request, p htt
|
|||
Compatibility: req.Compatibility,
|
||||
RouteToCluster: req.RouteToCluster,
|
||||
KubernetesCluster: req.KubernetesCluster,
|
||||
ProxyAddress: r.Host,
|
||||
})
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to create OIDC auth request.")
|
||||
|
|
|
@ -1788,7 +1788,7 @@ func TestMultipleConnectors(t *testing.T) {
|
|||
|
||||
// create two oidc connectors, one named "foo" and another named "bar"
|
||||
oidcConnectorSpec := types.OIDCConnectorSpecV3{
|
||||
RedirectURL: "https://localhost:3080/v1/webapi/oidc/callback",
|
||||
RedirectURLs: []string{"https://localhost:3080/v1/webapi/oidc/callback"},
|
||||
ClientID: "000000000000-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com",
|
||||
ClientSecret: "AAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
IssuerURL: "https://oidc.example.com",
|
||||
|
|
|
@ -1448,9 +1448,9 @@ func mockConnector(t *testing.T) types.OIDCConnector {
|
|||
// Connector need not be functional since we are going to mock the actual
|
||||
// login operation.
|
||||
connector, err := types.NewOIDCConnector("auth.example.com", types.OIDCConnectorSpecV3{
|
||||
IssuerURL: "https://auth.example.com",
|
||||
RedirectURL: "https://cluster.example.com",
|
||||
ClientID: "fake-client",
|
||||
IssuerURL: "https://auth.example.com",
|
||||
RedirectURLs: []string{"https://cluster.example.com"},
|
||||
ClientID: "fake-client",
|
||||
ClaimsToRoles: []types.ClaimMapping{
|
||||
{
|
||||
Claim: "groups",
|
||||
|
|
Loading…
Reference in a new issue