OIDC multiple redirect URLs (#12054)

This commit is contained in:
Brian Joerger 2022-05-31 10:52:04 -07:00 committed by GitHub
parent 8302d467d1
commit 26bad238fa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1730 additions and 1326 deletions

View file

@ -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)
}

View file

@ -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])
}

View file

@ -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

View file

@ -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.

View file

@ -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
View 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

@ -1 +1 @@
Subproject commit e407f7ad7d79d6db068487fc145d31f3d44c6637
Subproject commit 39e02ffd6a3b7aed87f250c3d2309e39df36c866

View file

@ -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 {

View file

@ -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)

View file

@ -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

View file

@ -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)
}

View file

@ -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 {

View file

@ -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"

View file

@ -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

View file

@ -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)
}

View file

@ -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

View file

@ -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)
}

View file

@ -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

View file

@ -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)
}

View file

@ -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)
})
}
}

View file

@ -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()

View file

@ -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.")

View file

@ -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",

View file

@ -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",