diff --git a/lib/auth/oidcgoogle/claims.go b/lib/auth/oidcgoogle/claims.go new file mode 100644 index 000000000000..efbfb827f5ca --- /dev/null +++ b/lib/auth/oidcgoogle/claims.go @@ -0,0 +1,126 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package oidcgoogle + +import ( + "github.com/gravitational/trace" + zoidc "github.com/zitadel/oidc/v3/pkg/oidc" + + "github.com/gravitational/teleport/api/constants" + "github.com/gravitational/teleport/api/types" +) + +// googleClaims are the ID token claims this service understands. +// +// It embeds [zoidc.TokenClaims] (iss/sub/aud/exp/nonce/...) and adds the Google +// specific claims. TokenClaims is used rather than zoidc.IDTokenClaims because +// IDTokenClaims defines its own UnmarshalJSON; embedding it would promote that +// method onto this struct and the fields declared below would silently never be +// populated. A silently empty "hd" would mean every login is rejected -- safe, +// but broken -- so the choice matters. +// +// The claim set is deliberately a fixed whitelist rather than a free-form map: +// only these claims can ever influence username derivation or role mapping. +type googleClaims struct { + zoidc.TokenClaims + + // Email is the account's primary email address. + Email string `json:"email"` + // EmailVerified is Google's assertion that the address is verified. + // zoidc.Bool tolerates providers that send this as the string "true"; + // Google sends a real JSON boolean. An absent claim decodes to false, + // which is a reject. + EmailVerified zoidc.Bool `json:"email_verified"` + // HostedDomain is the "hd" claim: the Google Workspace domain that owns + // this account. Personal Gmail accounts do NOT have this claim. + HostedDomain string `json:"hd"` + // Name, GivenName and FamilyName come from the "profile" scope and are + // exposed as traits only. + Name string `json:"name"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` +} + +// username derives the Teleport username from the verified claims. +// +// The default claim is "email", which matches Teleport's own default for OIDC +// connectors. "sub" is offered because it is Google's stable, never-reused +// identifier; an email address can be deleted and later reassigned to a +// different person within the same Workspace. +// +// Arbitrary claims are NOT supported: allowing one would mean trusting a claim +// we have not reasoned about (for example "name", which a user can usually edit +// themselves, and which would let a user choose their own Teleport username). +func (c *googleClaims) username(usernameClaim string) (string, error) { + switch usernameClaim { + case "", "email": + if c.Email == "" { + return "", trace.AccessDenied("Google ID token has no email claim") + } + return c.Email, nil + case "sub": + if c.Subject == "" { + return "", trace.AccessDenied("Google ID token has no subject claim") + } + return c.Subject, nil + default: + return "", trace.BadParameter("username_claim %q is not supported; use \"email\" or \"sub\"", usernameClaim) + } +} + +// traits converts the verified claims into Teleport traits for role mapping and +// role templating. +// +// hostedDomain is the configured (lower case) domain. The "hd" trait is set to +// that canonical value rather than to the raw claim. By the time this is +// called, checkClaims has already established that the two are equal ignoring +// ASCII case, so nothing is being asserted that the token did not say; using +// the canonical form just means a connector's claims_to_roles rule does not +// silently stop matching if Google ever changes the casing it emits. +func (c *googleClaims) traits(username, hostedDomain string) map[string][]string { + traits := map[string][]string{ + constants.TraitLogins: {username}, + "sub": {c.Subject}, + "email": {c.Email}, + "hd": {hostedDomain}, + } + if c.Name != "" { + traits["name"] = []string{c.Name} + } + if c.GivenName != "" { + traits["given_name"] = []string{c.GivenName} + } + if c.FamilyName != "" { + traits["family_name"] = []string{c.FamilyName} + } + return traits +} + +// asOIDCClaims renders the claims for the SSO diagnostic record and the audit +// event's identity attributes. +func (c *googleClaims) asOIDCClaims() types.OIDCClaims { + return types.OIDCClaims{ + "iss": c.Issuer, + "sub": c.Subject, + "email": c.Email, + "email_verified": bool(c.EmailVerified), + "hd": c.HostedDomain, + "name": c.Name, + } +} diff --git a/lib/auth/oidcgoogle/oidcgoogle.go b/lib/auth/oidcgoogle/oidcgoogle.go new file mode 100644 index 000000000000..8841c6b86f12 --- /dev/null +++ b/lib/auth/oidcgoogle/oidcgoogle.go @@ -0,0 +1,1081 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// Package oidcgoogle implements [auth.OIDCService] for exactly one identity +// provider: Google, restricted to a single Google Workspace hosted domain. +// +// It is deliberately narrower than a general purpose OIDC connector. The +// narrowness is the security property: every authenticated user must present a +// Google-signed ID token whose "hd" (hosted domain) claim is exactly the domain +// this process was configured with. Anything else -- another issuer, a personal +// Gmail account (which carries no "hd" claim at all), an unverified address -- +// is rejected before any Teleport user is looked up or created. +// +// Threat model notes for reviewers: +// +// - The ID token is verified (signature, iss, aud, exp, nonce) BEFORE any +// claim in it is read for an authorization decision. See +// [Service.ValidateOIDCAuthCallback]. +// - The hosted domain is never derived from the "email" claim. Suffix +// matching an email is the classic break: "attacker@example.com.evil.net" +// passes a naive strings.HasSuffix check. Only "hd" is trusted, and only +// after verification. +// - The state token is single use: it is deleted from the backend as soon as +// it is read, before the authorization code is exchanged. +// - The client redirect URL is validated with [sso.ValidateClientRedirect] on +// the request side. Skipping that check is a certificate theft +// vulnerability: an attacker sends a victim a login link carrying the +// attacker's redirect_url, the victim completes a genuine Google login, and +// the attacker's listener receives the victim's certificates. +package oidcgoogle + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "github.com/zitadel/oidc/v3/pkg/client" + "github.com/zitadel/oidc/v3/pkg/client/rp" + zoidc "github.com/zitadel/oidc/v3/pkg/oidc" + "golang.org/x/oauth2" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/constants" + apidefaults "github.com/gravitational/teleport/api/defaults" + "github.com/gravitational/teleport/api/types" + apievents "github.com/gravitational/teleport/api/types/events" + "github.com/gravitational/teleport/api/utils/keys/hardwarekey" + "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/authz" + "github.com/gravitational/teleport/lib/client/sso" + "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/events" + "github.com/gravitational/teleport/lib/loginrule" + teleoidc "github.com/gravitational/teleport/lib/oidc" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/utils" +) + +// GoogleIssuerURL is the only issuer URL an OIDC connector may declare for this +// service. Google publishes its discovery document at +// https://accounts.google.com/.well-known/openid-configuration and its ID +// tokens carry this exact "iss" value. +// +// Note that Google historically also issued tokens with an "accounts.google.com" +// (no scheme) issuer. We do not accept that form: the connector's issuer_url, +// the discovery document's issuer and the token's "iss" must all agree on the +// https form, which is what Google emits today. +const GoogleIssuerURL = "https://accounts.google.com" + +// ErrUnauthorizedIdentity marks a rejection where the user successfully +// authenticated to Google -- the ID token verified -- but is not permitted to +// log in to this cluster: wrong (or absent) hosted domain, unverified email, or +// no roles mapped. +// +// It is the OIDC analogue of [auth.ErrGithubNoTeams], but note how far it +// actually travels. ValidateOIDCAuthCallback runs on Auth and the proxy calls it +// over gRPC, where a trace error is flattened to a status code plus a message +// string and rebuilt client-side as a fresh error. A sentinel made with +// errors.New exists only in the Auth process, so errors.Is against it CANNOT +// succeed on the proxy. [auth.ErrGithubNoTeams] survives that boundary only +// because it is a trace.BadParameter, whose Is compares message strings. +// +// The consequence today is that an unauthorized identity reaches the generic +// failure page rather than LoginFailedUnauthorizedRedirectURL. That is a UX +// shortfall, not a security one: the rejection still happens on Auth and the +// UserLogin failure event still records the real reason. Distinguishing the two +// cases in the browser needs a different mechanism and is left as a follow-up. +// +// Within the Auth process the sentinel behaves as documented, which is what the +// multi-error Unwrap below is for. +// +// Do NOT use it for token verification failures. A bad signature, a wrong +// audience or a replayed state is not an unauthorized user, it is an attack or +// a broken client, and it should not be reported to the browser as "your +// account is not allowed". +var ErrUnauthorizedIdentity = errors.New("this Google identity is not authorized to log in to this cluster") + +// unauthorizedIdentityError is an access-denied error that also matches +// [ErrUnauthorizedIdentity] under errors.Is. +// +// The multi-error Unwrap is what makes both work at once: +// - trace.IsAccessDenied(err) finds the embedded *trace.AccessDeniedError, so +// the error still maps to 403 everywhere trace errors are converted; +// - errors.Is(err, ErrUnauthorizedIdentity) finds the sentinel. +// +// A plain trace.AccessDenied sentinel cannot be used here because +// trace.AccessDeniedError.Is compares message strings, and every rejection +// carries a different (deliberately specific) message. +type unauthorizedIdentityError struct { + denied *trace.AccessDeniedError +} + +func (e *unauthorizedIdentityError) Error() string { return e.denied.Error() } + +func (e *unauthorizedIdentityError) Unwrap() []error { + return []error{e.denied, ErrUnauthorizedIdentity} +} + +// unauthorizedf builds an [unauthorizedIdentityError]. The message is the +// reason recorded in the UserLogin audit event, so it should say precisely why +// the identity was refused. +func unauthorizedf(format string, args ...any) error { + return trace.Wrap(&unauthorizedIdentityError{ + denied: &trace.AccessDeniedError{Message: fmt.Sprintf(format, args...)}, + }) +} + +// googleScopes are the OAuth2 scopes requested. They are fixed rather than read +// from the connector so that a connector edit cannot broaden what this +// deployment asks Google for. "openid" and "email" give us sub/email/ +// email_verified/hd; "profile" gives us the display name used for traits. +var googleScopes = []string{"openid", "email", "profile"} + +// discoveryTTL is how long an OIDC discovery document is cached. Google rotates +// endpoints very rarely; caching keeps login off the critical path of an +// external HTTP call on every request. +const discoveryTTL = time.Hour + +// Identity is the subset of [services.Identity] used by this service. +// +// DeleteOIDCAuthRequest is added by Slice 1; it does not exist upstream. It is +// what makes state tokens single use rather than merely short lived. +type Identity interface { + CreateOIDCAuthRequest(ctx context.Context, req types.OIDCAuthRequest, ttl time.Duration) error + GetOIDCAuthRequest(ctx context.Context, stateToken string) (*types.OIDCAuthRequest, error) + DeleteOIDCAuthRequest(ctx context.Context, stateToken string) error + CreateSSODiagnosticInfo(ctx context.Context, authKind string, authRequestID string, entry types.SSODiagnosticInfo) error +} + +// AuthService is the subset of [*auth.Server] used by this service. +// +// It deliberately does NOT include auth.Server.CreateOIDCAuthRequest: that +// method delegates back into this service, so calling it here would recurse. +// Auth request persistence goes through [Identity] instead. +type AuthService interface { + GetOIDCConnector(ctx context.Context, name string, withSecrets bool) (types.OIDCConnector, error) + + GetUser(ctx context.Context, name string, withSecrets bool) (types.User, error) + CreateUser(ctx context.Context, user types.User) (types.User, error) + UpdateUser(ctx context.Context, user types.User) (types.User, error) + GetUserOrLoginState(ctx context.Context, username string) (services.UserState, error) + CallLoginHooks(ctx context.Context, user types.User) error + GetLoginRuleEvaluator() loginrule.Evaluator + GetRole(ctx context.Context, name string) (types.Role, error) + + CreateWebSessionFromReq(ctx context.Context, req auth.NewWebSessionRequest) (types.WebSession, error) + CreateSessionCerts(ctx context.Context, req *auth.SessionCertsRequest) ([]byte, []byte, error) + GetClusterName(ctx context.Context) (types.ClusterName, error) + GetCertAuthority(ctx context.Context, id types.CertAuthID, loadKeys bool) (types.CertAuthority, error) + ClientOptionsForLogin(userState services.UserState) (authclient.ClientOptions, error) + + EmitAuditEvent(ctx context.Context, e apievents.AuditEvent) error +} + +// Config configures a [Service]. +type Config struct { + // HostedDomain is the Google Workspace hosted domain that every + // authenticating user must belong to, e.g. "example.com". It is compared + // against the "hd" claim of the verified ID token. + // + // This is a process level setting rather than a connector field on purpose: + // a second connector created later must not be able to silently admit any + // Google account. + // + // It is required. See [New], which fails closed when it is empty. + HostedDomain string + // Identity persists and retrieves OIDC auth requests and SSO diagnostics. + Identity Identity + // Auth provides the auth server operations needed to resolve connectors, + // users, roles, sessions and certificates. + Auth AuthService + // Clock is used for user expiry and cache TTLs. Defaults to a real clock. + // + // Note: it does NOT control ID token expiry validation, which is performed + // by github.com/zitadel/oidc against the wall clock. + Clock clockwork.Clock + // Logger defaults to a package logger. + Logger *slog.Logger + // HTTPClient is used for OIDC discovery and the token exchange. Defaults to + // [http.DefaultClient]. + HTTPClient *http.Client +} + +// Service implements [auth.OIDCService] against Google, restricted to a single +// Google Workspace hosted domain. +type Service struct { + cfg Config + + // hostedDomain is cfg.HostedDomain, normalised to lower case ASCII. + hostedDomain string + + // allowedIssuer is the issuer URL a connector is required to declare. + // + // It is [GoogleIssuerURL] in production. There is deliberately no exported + // field or config option to change it: a mistyped or maliciously edited + // connector must not be able to point Teleport at an attacker controlled + // IdP. Tests in this package override it directly. + allowedIssuer string + + // signingAlgs is the set of JWS algorithms accepted for the ID token + // signature. It is pinned to RS256 (what Google actually uses) rather than + // left at the library default of RS256/ES256/PS256: a narrower set is one + // fewer moving part in signature verification. As with allowedIssuer there + // is no exported knob; tests in this package override it. + signingAlgs []string + + validator *teleoidc.CachingTokenValidator[*googleClaims] + discoveryCache *utils.FnCache +} + +// New returns a new Google OIDC service. +// +// It fails closed: without a hosted domain there is no way to tell a corporate +// account from a personal Gmail account, so construction is refused rather than +// defaulting to "allow any Google account". +func New(cfg Config) (*Service, error) { + if cfg.Identity == nil { + return nil, trace.BadParameter("identity service is required") + } + if cfg.Auth == nil { + return nil, trace.BadParameter("auth service is required") + } + if cfg.Clock == nil { + cfg.Clock = clockwork.NewRealClock() + } + if cfg.Logger == nil { + cfg.Logger = slog.With(teleport.ComponentKey, "oidc:google") + } + if cfg.HTTPClient == nil { + cfg.HTTPClient = http.DefaultClient + } + + hostedDomain, err := normalizeHostedDomain(cfg.HostedDomain) + if err != nil { + return nil, trace.Wrap(err) + } + + validator, err := teleoidc.NewCachingTokenValidator[*googleClaims](cfg.Clock) + if err != nil { + return nil, trace.Wrap(err) + } + + discoveryCache, err := utils.NewFnCache(utils.FnCacheConfig{ + Clock: cfg.Clock, + TTL: discoveryTTL, + ReloadOnErr: true, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + return &Service{ + cfg: cfg, + hostedDomain: hostedDomain, + allowedIssuer: GoogleIssuerURL, + signingAlgs: []string{"RS256"}, + validator: validator, + discoveryCache: discoveryCache, + }, nil +} + +// NonceForStateToken derives the OIDC "nonce" bound to an auth request from its +// state token. +// +// The binding rule, which Slice 1 records and every slice must agree on, is: +// +// nonce = hex(SHA-256(StateToken)) +// +// Deriving rather than storing avoids adding a field to the (generated, +// Apache-licensed) api/ protobufs. The security properties we need are that the +// nonce is unpredictable to anyone who has not seen the state token, and that it +// is reproducible at callback time from the stored request. A SHA-256 of a +// 256-bit cryptographically random state token gives both. +// +// Note the nonce is NOT a secret that must be hidden from the client: it travels +// through the user agent to Google in the authorization URL, exactly like the +// state token does. Its job is to bind the ID token to this specific request so +// a token minted for another request cannot be replayed here. +func NonceForStateToken(stateToken string) string { + sum := sha256.Sum256([]byte(stateToken)) + return hex.EncodeToString(sum[:]) +} + +// normalizeHostedDomain validates and lower-cases the configured hosted domain. +// +// Non-ASCII input is rejected. Unicode case folding is not a safe basis for a +// security comparison (distinct code points can fold together), and Google +// hosted domains are ASCII (IDNs appear in their punycode form). +func normalizeHostedDomain(domain string) (string, error) { + if domain == "" { + return "", trace.BadParameter("google hosted domain is required: refusing to start an OIDC service that would accept any Google account") + } + if !isASCII(domain) { + return "", trace.BadParameter("google hosted domain %q must be ASCII (use the punycode form of an internationalised domain)", domain) + } + lowered := strings.ToLower(domain) + if !strings.Contains(lowered, ".") || strings.ContainsAny(lowered, " \t/:@") { + return "", trace.BadParameter("google hosted domain %q is not a valid domain name", domain) + } + return lowered, nil +} + +func isASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] > 0x7f { + return false + } + } + return true +} + +// CreateOIDCAuthRequestForMFA is not implemented. +// +// Its only caller, lib/auth.(*Server).beginSSOMFAChallenge, is reachable only +// when the OIDC connector sets mfa.enabled: true, which this deployment does +// not do (and which checkConnector refuses outright). +// +// Operator note: Google authentication does NOT satisfy a Teleport second +// factor requirement. If second_factor is enforced, Google-authenticated users +// still need a local second factor (WebAuthn/OTP) registered in Teleport. +func (s *Service) CreateOIDCAuthRequestForMFA(ctx context.Context, req types.OIDCAuthRequest) (*types.OIDCAuthRequest, error) { + return nil, trace.NotImplemented("SSO MFA is not supported by the Google OIDC service") +} + +// CreateOIDCAuthRequest creates a new OIDC auth request and returns it with +// RedirectURL populated with the Google authorization URL the browser should be +// sent to. +func (s *Service) CreateOIDCAuthRequest(ctx context.Context, req types.OIDCAuthRequest) (*types.OIDCAuthRequest, error) { + connector, err := s.getConnector(ctx, req) + if err != nil { + return nil, trace.Wrap(err) + } + if err := s.checkConnector(connector); err != nil { + return nil, trace.Wrap(err) + } + + // Validate the client redirect URL. + // + // This is the check whose absence lets an attacker steal certificates: the + // callback hands the SSO result to ClientRedirectURL, so an unvalidated + // value is an open redirect for credentials. + // + // It is skipped for CreateWebSession requests, matching + // lib/auth.(*Server).CreateGithubAuthRequest. Those requests originate from + // the proxy, not from a client: the proxy sets the session cookie itself and + // redirects within its own web UI, so ClientRedirectURL there is a web UI + // path (not a "/callback" URL) and no certificate ever leaves the proxy for + // that URL. The proxy validates the web redirect separately. + if !req.CreateWebSession { + ceremonyType := sso.CeremonyTypeLogin + if req.SSOTestFlow { + ceremonyType = sso.CeremonyTypeTest + } + if err := sso.ValidateClientRedirect(req.ClientRedirectURL, ceremonyType, connector.GetClientRedirectSettings()); err != nil { + return nil, trace.Wrap(err, auth.InvalidClientRedirectErrorMessage) + } + } + + // A cryptographically random state token. Everything else (the nonce, the + // single-use property, the lookup at callback time) hangs off this value. + req.StateToken, err = utils.CryptoRandomHex(defaults.TokenLenBytes) + if err != nil { + return nil, trace.Wrap(err) + } + + // PKCE is always on. Google supports S256 and it costs nothing; we do not + // consult connector.IsPKCEEnabled() because there is no reason this + // deployment would ever want it off. + req.PkceVerifier = oauth2.GenerateVerifier() + + // The redirect_uri sent to Google. The identical value must be replayed at + // token exchange time or Google rejects the exchange. + redirectURL, err := services.GetRedirectURL(connector, req.ProxyAddress) + if err != nil { + return nil, trace.Wrap(err) + } + + oauthConfig, err := s.oauthConfig(ctx, connector, redirectURL) + if err != nil { + return nil, trace.Wrap(err) + } + + opts := []oauth2.AuthCodeOption{ + oauth2.S256ChallengeOption(req.PkceVerifier), + oauth2.SetAuthURLParam("nonce", NonceForStateToken(req.StateToken)), + // "hd" on the authorization request is a user experience hint only: it + // asks Google's account chooser to prefer accounts in this domain. It + // carries NO security weight and is trivially stripped by the user + // agent. The enforcing check is on the "hd" claim of the verified ID + // token in ValidateOIDCAuthCallback. + oauth2.SetAuthURLParam("hd", s.hostedDomain), + } + if prompt := connector.GetPrompt(); prompt != "" { + opts = append(opts, oauth2.SetAuthURLParam("prompt", prompt)) + } + + req.RedirectURL = oauthConfig.AuthCodeURL(req.StateToken, opts...) + + if err := s.cfg.Identity.CreateOIDCAuthRequest(ctx, req, defaults.OIDCAuthRequestTTL); err != nil { + return nil, trace.Wrap(err) + } + + s.cfg.Logger.DebugContext(ctx, "Created Google OIDC auth request", + "connector", connector.GetName(), + "create_web_session", req.CreateWebSession, + "test_flow", req.SSOTestFlow, + ) + + return &req, nil +} + +// getConnector resolves the connector for a request. +// +// A caller-supplied ConnectorSpec is honoured ONLY for SSOTestFlow requests +// (that is what "tctl sso test" is). Accepting a spec on a real login would let +// anyone who can reach the auth API define their own IdP, client ID and role +// mapping, so it is rejected outright. +func (s *Service) getConnector(ctx context.Context, req types.OIDCAuthRequest) (types.OIDCConnector, error) { + if req.SSOTestFlow { + if req.ConnectorSpec == nil { + return nil, trace.BadParameter("ConnectorSpec cannot be nil for SSOTestFlow") + } + if req.ConnectorID == "" { + return nil, trace.BadParameter("ConnectorID cannot be empty") + } + connector, err := types.NewOIDCConnector(req.ConnectorID, *req.ConnectorSpec) + return connector, trace.Wrap(err) + } + + if req.ConnectorSpec != nil { + return nil, trace.BadParameter("ConnectorSpec is only allowed when SSOTestFlow is true") + } + + connector, err := s.cfg.Auth.GetOIDCConnector(ctx, req.ConnectorID, true) + return connector, trace.Wrap(err) +} + +// checkConnector rejects connector configurations this service cannot enforce. +// +// Everything here fails closed. The alternative -- silently ignoring a setting +// an administrator deliberately turned on -- is how security controls quietly +// stop working. +func (s *Service) checkConnector(connector types.OIDCConnector) error { + if connector.GetIssuerURL() != s.allowedIssuer { + return trace.AccessDenied("connector %q has issuer_url %q; this cluster only accepts Google (%q)", + connector.GetName(), connector.GetIssuerURL(), s.allowedIssuer) + } + if connector.GetClientID() == "" { + return trace.BadParameter("connector %q has no client_id", connector.GetName()) + } + if connector.GetClientSecret() == "" { + return trace.BadParameter("connector %q has no client_secret", connector.GetName()) + } + // We unconditionally require email_verified == true, so a connector that + // asks to allow unverified email is asking for something we will not do. + if connector.GetAllowUnverifiedEmail() { + return trace.BadParameter("connector %q sets allow_unverified_email, which this service does not support", connector.GetName()) + } + // max_age implies verifying the "auth_time" claim, which we do not do. + if _, ok := connector.GetMaxAge(); ok { + return trace.BadParameter("connector %q sets max_age, which this service does not support", connector.GetName()) + } + // Google Workspace directory lookups (group membership via a service + // account) are not implemented; role mapping here is claim based only. + if connector.GetGoogleServiceAccount() != "" || connector.GetGoogleServiceAccountURI() != "" || connector.GetGoogleAdminEmail() != "" { + return trace.BadParameter("connector %q configures Google Workspace directory sync, which this service does not support", connector.GetName()) + } + if connector.IsMFAEnabled() { + return trace.BadParameter("connector %q enables SSO MFA, which this service does not support", connector.GetName()) + } + return nil +} + +// oauthConfig builds the OAuth2 config from the provider's discovery document. +// +// Endpoints come from discovery rather than being hardcoded so that we follow +// Google if it moves them, and so that this code is testable against a fake +// issuer without a build tag or an insecure flag. +func (s *Service) oauthConfig(ctx context.Context, connector types.OIDCConnector, redirectURL string) (*oauth2.Config, error) { + discovery, err := s.discover(ctx, connector.GetIssuerURL()) + if err != nil { + return nil, trace.Wrap(err) + } + if discovery.AuthorizationEndpoint == "" || discovery.TokenEndpoint == "" { + return nil, trace.BadParameter("issuer %q does not advertise an authorization or token endpoint", connector.GetIssuerURL()) + } + return &oauth2.Config{ + ClientID: connector.GetClientID(), + ClientSecret: connector.GetClientSecret(), + RedirectURL: redirectURL, + Scopes: googleScopes, + Endpoint: oauth2.Endpoint{ + AuthURL: discovery.AuthorizationEndpoint, + TokenURL: discovery.TokenEndpoint, + AuthStyle: oauth2.AuthStyleInParams, + }, + }, nil +} + +func (s *Service) discover(ctx context.Context, issuerURL string) (*zoidc.DiscoveryConfiguration, error) { + discovery, err := utils.FnCacheGet(ctx, s.discoveryCache, issuerURL, func(ctx context.Context) (*zoidc.DiscoveryConfiguration, error) { + ctx, cancel := context.WithTimeout(ctx, defaults.HTTPRequestTimeout) + defer cancel() + // client.Discover verifies that the document's "issuer" matches the + // URL we asked for, which is what stops a compromised well-known + // document from redirecting us to another issuer. + return client.Discover(ctx, issuerURL, s.cfg.HTTPClient) + }) + return discovery, trace.Wrap(err) +} + +// ValidateOIDCAuthCallback validates the redirect back from Google and, on +// success, returns the session and/or certificates for the authenticated user. +// +// It emits a UserLogin audit event for both success and failure. The RBAC layer +// above only emits for auth request creation failures, so callback auditing is +// this service's responsibility. +func (s *Service) ValidateOIDCAuthCallback(ctx context.Context, q url.Values) (*authclient.OIDCAuthResponse, error) { + diagCtx := auth.NewSSODiagContext(types.KindOIDC, s.cfg.Identity) + + event := &apievents.UserLogin{ + Metadata: apievents.Metadata{ + Type: events.UserLoginEvent, + }, + Method: events.LoginMethodOIDC, + ConnectionMetadata: authz.ConnectionMetadata(ctx), + } + + resp, err := s.validateCallback(ctx, diagCtx, q) + diagCtx.Info.Error = trace.UserMessage(err) + event.AppliedLoginRules = diagCtx.Info.AppliedLoginRules + + diagCtx.WriteToBackend(ctx) + + if attributes, encErr := apievents.EncodeMap(diagCtx.Info.OIDCClaims); encErr != nil { + s.cfg.Logger.DebugContext(ctx, "Failed to encode identity attributes", "error", encErr) + } else { + event.IdentityAttributes = attributes + } + + if err != nil { + event.Code = events.UserSSOLoginFailureCode + if diagCtx.Info.TestFlow { + event.Code = events.UserSSOTestFlowLoginFailureCode + } + event.Status.Success = false + // Status.Error carries the internal reason (why we rejected); + // Status.UserMessage carries what the user is told. + event.Status.Error = trace.Unwrap(err).Error() + event.Status.UserMessage = err.Error() + + if emitErr := s.cfg.Auth.EmitAuditEvent(ctx, event); emitErr != nil { + s.cfg.Logger.WarnContext(ctx, "Failed to emit Google OIDC login failure event", "error", emitErr) + } + return nil, trace.Wrap(err) + } + + event.Code = events.UserSSOLoginCode + if diagCtx.Info.TestFlow { + event.Code = events.UserSSOTestFlowLoginCode + } + event.Status.Success = true + event.User = resp.Username + + if emitErr := s.cfg.Auth.EmitAuditEvent(ctx, event); emitErr != nil { + s.cfg.Logger.WarnContext(ctx, "Failed to emit Google OIDC login event", "error", emitErr) + } + + return resp, nil +} + +func (s *Service) validateCallback(ctx context.Context, diagCtx *auth.SSODiagContext, q url.Values) (*authclient.OIDCAuthResponse, error) { + stateToken := q.Get("state") + if stateToken == "" { + return nil, trace.WithUserMessage( + trace.OAuth2("invalid_request", "missing state query param", q), + "Invalid parameters received from Google.") + } + diagCtx.RequestID = stateToken + + // Consume the auth request. The Get+Delete pair makes the state token + // single use: a replayed callback (whether by an attacker who observed the + // redirect, or by a browser retry) finds nothing and is rejected. Deleting + // BEFORE the code exchange means even a failed login burns the state. + // + // An expired request is already absent thanks to the backend TTL, so + // NotFound covers both "unknown" and "expired". + req, err := s.cfg.Identity.GetOIDCAuthRequest(ctx, stateToken) + if err != nil { + return nil, trace.Wrap(err, "Failed to get OIDC auth request.") + } + if err := s.cfg.Identity.DeleteOIDCAuthRequest(ctx, stateToken); err != nil { + // Fail closed: if we cannot guarantee the state is consumed, we must + // not proceed, or the state would remain replayable. + return nil, trace.Wrap(err, "Failed to consume OIDC auth request.") + } + diagCtx.Info.TestFlow = req.SSOTestFlow + + // Only now that the request is known do we surface a provider-side error, + // so that an unauthenticated caller cannot use this endpoint as an oracle. + if errParam := q.Get("error"); errParam != "" { + errDesc := q.Get("error_description") + return nil, trace.WithUserMessage( + trace.OAuth2("invalid_request", errParam, q), + "Google returned an error: %v [%v]", errDesc, errParam) + } + + code := q.Get("code") + if code == "" { + return nil, trace.WithUserMessage( + trace.OAuth2("invalid_request", "code query param must be set", q), + "Invalid parameters received from Google.") + } + + connector, err := s.getConnector(ctx, *req) + if err != nil { + return nil, trace.Wrap(err, "Failed to get OIDC connector.") + } + if err := s.checkConnector(connector); err != nil { + return nil, trace.Wrap(err) + } + diagCtx.Info.OIDCClaimsToRoles = connector.GetClaimsToRoles() + diagCtx.Info.OIDCConnectorTraitMapping = connector.GetTraitMappings() + + claims, err := s.exchangeAndVerify(ctx, connector, req, code) + if err != nil { + return nil, trace.Wrap(err) + } + + // Everything below this line reads verified claims. Nothing above it made + // an authorization decision based on claim content. + diagCtx.Info.OIDCClaims = claims.asOIDCClaims() + diagCtx.Info.OIDCIdentity = &types.OIDCIdentity{ + ID: claims.Subject, + Name: claims.Name, + Email: claims.Email, + ExpiresAt: claims.Expiration.AsTime(), + } + + if err := s.checkClaims(claims); err != nil { + return nil, trace.Wrap(err) + } + + params, err := s.calculateUser(ctx, diagCtx, connector, claims, req) + if err != nil { + return nil, trace.Wrap(err, "Failed to calculate user attributes.") + } + diagCtx.Info.CreateUserParams = &types.CreateUserParams{ + ConnectorName: params.ConnectorName, + Username: params.Username, + Roles: params.Roles, + Traits: params.Traits, + SessionTTL: types.Duration(params.SessionTTL), + } + + identity := types.ExternalIdentity{ + ConnectorID: params.ConnectorName, + Username: params.Username, + UserID: params.UserID, + } + + // SSOTestFlow must never write a user. createUser honours dryRun, and we + // return before creating any session or certificate. + user, err := s.createUser(ctx, params, req.SSOTestFlow) + if err != nil { + return nil, trace.Wrap(err, "Failed to create user from provided parameters.") + } + + if req.SSOTestFlow { + diagCtx.Info.Success = true + return &authclient.OIDCAuthResponse{ + Req: oidcAuthRequestFromProto(req), + Identity: identity, + Username: params.Username, + }, nil + } + + if err := s.cfg.Auth.CallLoginHooks(ctx, user); err != nil { + return nil, trace.Wrap(err) + } + + userState, err := s.cfg.Auth.GetUserOrLoginState(ctx, user.GetName()) + if err != nil { + return nil, trace.Wrap(err) + } + + diagCtx.Info.Success = true + return s.makeAuthResponse(ctx, req, userState, identity, params.SessionTTL) +} + +// exchangeAndVerify swaps the authorization code for tokens and verifies the +// resulting ID token. +// +// Verification is delegated to lib/oidc's caching validator, which fetches the +// provider's JWKS (handling key rotation) and checks signature, issuer, +// audience and expiry. The nonce is bound here. +func (s *Service) exchangeAndVerify(ctx context.Context, connector types.OIDCConnector, req *types.OIDCAuthRequest, code string) (*googleClaims, error) { + redirectURL, err := services.GetRedirectURL(connector, req.ProxyAddress) + if err != nil { + return nil, trace.Wrap(err) + } + oauthConfig, err := s.oauthConfig(ctx, connector, redirectURL) + if err != nil { + return nil, trace.Wrap(err) + } + + exchangeCtx := context.WithValue(ctx, oauth2.HTTPClient, s.cfg.HTTPClient) + token, err := oauthConfig.Exchange(exchangeCtx, code, oauth2.VerifierOption(req.PkceVerifier)) + if err != nil { + return nil, trace.Wrap(err, "Requesting Google OAuth2 token failed.") + } + + rawIDToken, ok := token.Extra("id_token").(string) + if !ok || rawIDToken == "" { + return nil, trace.AccessDenied("Google token response did not contain an ID token.") + } + + validator, err := s.validator.GetValidator(ctx, connector.GetIssuerURL(), connector.GetClientID()) + if err != nil { + return nil, trace.Wrap(err) + } + + // The nonce is bound to the state token that this callback presented. Note + // that the underlying verifier checks the nonce unconditionally (its default + // expectation is the empty string), so this option is required, not + // optional: omitting it would reject every real Google token rather than + // silently skip the check. + expectedNonce := NonceForStateToken(req.StateToken) + claims, err := validator.ValidateToken(ctx, rawIDToken, + rp.WithNonce(func(context.Context) string { + return expectedNonce + }), + rp.WithSupportedSigningAlgorithms(s.signingAlgs...), + ) + if err != nil { + return nil, trace.AccessDenied("Google ID token verification failed: %v", err) + } + + return claims, nil +} + +// checkClaims applies this deployment's authorization policy to a verified ID +// token. +func (s *Service) checkClaims(claims *googleClaims) error { + // The hosted domain check. + // + // A personal Gmail account carries NO "hd" claim at all, so an absent claim + // must be an explicit reject. If this were written as + // "if hd != "" && hd != domain" then every personal Gmail account in the + // world would be admitted. + if claims.HostedDomain == "" { + return unauthorizedf("Google account %q has no hosted domain (hd) claim; only %s Workspace accounts may log in", + claims.Email, s.hostedDomain) + } + if !isASCII(claims.HostedDomain) || strings.ToLower(claims.HostedDomain) != s.hostedDomain { + return unauthorizedf("Google account %q belongs to hosted domain %q, not %q", + claims.Email, claims.HostedDomain, s.hostedDomain) + } + + // NOTE: the domain is never derived from the email claim. Do not "simplify" + // the check above into a suffix match on claims.Email: an attacker who + // controls example.com.evil.net can then mint addresses that pass. + + if !bool(claims.EmailVerified) { + return unauthorizedf("Google account %q has an unverified email address", claims.Email) + } + if claims.Email == "" { + return trace.AccessDenied("Google ID token has no email claim") + } + // Defence in depth: the verifier already rejects a token with no "sub" + // (zoidc.CheckSubject), but the subject becomes the user's immutable + // identifier, so do not rely on a library detail for that. + if claims.Subject == "" { + return trace.AccessDenied("Google ID token has no subject claim") + } + return nil +} + +// createUserParams holds everything needed to create or update the Teleport +// user for an authenticated Google identity. +type createUserParams struct { + ConnectorName string + Username string + // UserID is the Google "sub" claim: a stable, immutable identifier for the + // account. Unlike the email address it is never reassigned. + UserID string + Roles []string + Traits map[string][]string + SessionTTL time.Duration +} + +func (s *Service) calculateUser(ctx context.Context, diagCtx *auth.SSODiagContext, connector types.OIDCConnector, claims *googleClaims, req *types.OIDCAuthRequest) (*createUserParams, error) { + username, err := claims.username(connector.GetUsernameClaim()) + if err != nil { + return nil, trace.Wrap(err) + } + + p := createUserParams{ + ConnectorName: connector.GetName(), + Username: username, + UserID: claims.Subject, + Traits: claims.traits(username, s.hostedDomain), + } + diagCtx.Info.OIDCTraitsFromClaims = p.Traits + + warnings, roles := services.TraitsToRoles(connector.GetTraitMappings(), p.Traits) + if len(warnings) > 0 { + diagCtx.Info.OIDCClaimsToRolesWarnings = &types.SSOWarnings{ + Message: "Some claims-to-roles rules did not match", + Warnings: warnings, + } + s.cfg.Logger.WarnContext(ctx, "Unmatched claims-to-roles rules", "warnings", warnings) + } + if len(roles) == 0 { + return nil, unauthorizedf("no roles mapped for user %q; check the connector's claims_to_roles", username) + } + p.Roles = roles + + evaluationOutput, err := s.cfg.Auth.GetLoginRuleEvaluator().Evaluate(ctx, &loginrule.EvaluationInput{ + Traits: p.Traits, + }) + if err != nil { + return nil, trace.Wrap(err) + } + p.Traits = evaluationOutput.Traits + diagCtx.Info.AppliedLoginRules = evaluationOutput.AppliedRules + + // Pick the smaller of the role-derived TTL and the requested TTL. + roleSet, err := services.FetchRolesWithContext(p.Roles, s.cfg.Auth, services.RoleTemplateContext{ + Username: p.Username, + Traits: p.Traits, + }) + if err != nil { + return nil, trace.Wrap(err) + } + roleTTL := roleSet.AdjustSessionTTL(apidefaults.MaxCertDuration) + p.SessionTTL = utils.MinTTL(roleTTL, req.CertTTL) + + return &p, nil +} + +// createUser creates or updates the Teleport user for a Google identity. +// +// This reimplements the collision guard from lib/auth.(*Server).createGithubUser +// (that method is unexported, so it cannot be reused). The guard is what stops a +// Google identity whose derived username happens to match an existing local +// account -- say a local "admin" -- from taking that account over: if the +// existing user was not created by this same connector, we refuse. +// +// dryRun is set for SSOTestFlow. In that case nothing is written to the backend. +func (s *Service) createUser(ctx context.Context, p *createUserParams, dryRun bool) (types.User, error) { + expires := s.cfg.Clock.Now().UTC().Add(p.SessionTTL) + + user := &types.UserV2{ + Kind: types.KindUser, + Version: types.V2, + Metadata: types.Metadata{ + Name: p.Username, + Namespace: apidefaults.Namespace, + Expires: &expires, + }, + Spec: types.UserSpecV2{ + Roles: p.Roles, + Traits: p.Traits, + OIDCIdentities: []types.ExternalIdentity{{ + ConnectorID: p.ConnectorName, + Username: p.Username, + UserID: p.UserID, + }}, + CreatedBy: types.CreatedBy{ + User: types.UserRef{Name: teleport.UserSystem}, + Time: s.cfg.Clock.Now().UTC(), + Connector: &types.ConnectorRef{ + Type: constants.OIDC, + ID: p.ConnectorName, + Identity: p.Username, + }, + }, + }, + } + + // Run Teleport's own resource validation before writing. The username comes + // from a claim, and although it is a Google-issued email address rather + // than free text, a name that Teleport considers malformed or reserved must + // not reach the backend. + if err := user.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err, "invalid user derived from Google identity") + } + + if dryRun { + return user, nil + } + + existingUser, err := s.cfg.Auth.GetUser(ctx, p.Username, false) + if err != nil && !trace.IsNotFound(err) { + return nil, trace.Wrap(err) + } + + if existingUser != nil { + ref := user.GetCreatedBy().Connector + if !ref.IsSameProvider(existingUser.GetCreatedBy().Connector) { + return nil, trace.AlreadyExists("user %q already exists and was not created by the %q OIDC connector", + existingUser.GetName(), p.ConnectorName) + } + + // Second guard, beyond the one createGithubUser has: the existing user + // must be the same Google account, not merely an account with the same + // address. + // + // Google's "sub" claim is immutable and never reused; an email address + // is not. If alice@ leaves and a Workspace administrator later creates + // a new alice@ for a different person, that person would otherwise + // silently inherit the previous alice's Teleport user, roles and + // traits. Refuse instead, and let an administrator delete the stale + // user deliberately. + for _, existing := range existingUser.GetOIDCIdentities() { + if existing.ConnectorID == p.ConnectorName && existing.UserID != "" && existing.UserID != p.UserID { + return nil, trace.AlreadyExists( + "user %q already exists for a different Google account (subject %q, not %q); remove the stale user before this account can log in", + existingUser.GetName(), existing.UserID, p.UserID) + } + } + + user.SetRevision(existingUser.GetRevision()) + if _, err := s.cfg.Auth.UpdateUser(ctx, user); err != nil { + return nil, trace.Wrap(err) + } + return user, nil + } + + if _, err := s.cfg.Auth.CreateUser(ctx, user); err != nil { + return nil, trace.Wrap(err) + } + return user, nil +} + +// makeAuthResponse mirrors lib/auth.(*Server).makeGithubAuthResponse. +func (s *Service) makeAuthResponse( + ctx context.Context, + req *types.OIDCAuthRequest, + userState services.UserState, + identity types.ExternalIdentity, + sessionTTL time.Duration, +) (*authclient.OIDCAuthResponse, error) { + resp := authclient.OIDCAuthResponse{ + Req: oidcAuthRequestFromProto(req), + Identity: identity, + Username: userState.GetName(), + } + + // Browser flow: create a web session. + if req.CreateWebSession { + session, err := s.cfg.Auth.CreateWebSessionFromReq(ctx, auth.NewWebSessionRequest{ + User: userState.GetName(), + Roles: userState.GetRoles(), + Traits: userState.GetTraits(), + SessionTTL: sessionTTL, + LoginTime: s.cfg.Clock.Now().UTC(), + LoginIP: req.ClientLoginIP, + LoginUserAgent: req.ClientUserAgent, + AttestWebSession: true, + CreateDeviceWebToken: true, + Scope: req.Scope, + }) + if err != nil { + return nil, trace.Wrap(err, "Failed to create web session.") + } + resp.Session = session + } + + // Console flow: sign the client's public keys. + if len(req.SshPublicKey) != 0 || len(req.TlsPublicKey) != 0 { + sshCert, tlsCert, err := s.cfg.Auth.CreateSessionCerts(ctx, &auth.SessionCertsRequest{ + UserState: userState, + SessionTTL: sessionTTL, + SSHPubKey: req.SshPublicKey, + TLSPubKey: req.TlsPublicKey, + SSHAttestationStatement: hardwarekey.AttestationStatementFromProto(req.SshAttestationStatement), + TLSAttestationStatement: hardwarekey.AttestationStatementFromProto(req.TlsAttestationStatement), + Compatibility: req.Compatibility, + RouteToCluster: req.RouteToCluster, + KubernetesCluster: req.KubernetesCluster, + LoginIP: req.ClientLoginIP, + Scope: req.Scope, + }) + if err != nil { + return nil, trace.Wrap(err, "Failed to create session certificate.") + } + + clusterName, err := s.cfg.Auth.GetClusterName(ctx) + if err != nil { + return nil, trace.Wrap(err, "Failed to obtain cluster name.") + } + + resp.Cert = sshCert + resp.TLSCert = tlsCert + + // Return the host CA for this cluster only. + authority, err := s.cfg.Auth.GetCertAuthority(ctx, types.CertAuthID{ + Type: types.HostCA, + DomainName: clusterName.GetClusterName(), + }, false) + if err != nil { + return nil, trace.Wrap(err, "Failed to obtain cluster's host CA.") + } + resp.HostSigners = append(resp.HostSigners, authority) + } + + if o, err := s.cfg.Auth.ClientOptionsForLogin(userState); err == nil { + resp.ClientOptions = o + } else { + s.cfg.Logger.WarnContext(ctx, "Failed to calculate client options for Google OIDC login", + "username", userState.GetName(), "error", err) + } + + return &resp, nil +} + +func oidcAuthRequestFromProto(req *types.OIDCAuthRequest) authclient.OIDCAuthRequest { + return authclient.OIDCAuthRequest{ + ConnectorID: req.ConnectorID, + CSRFToken: req.CSRFToken, + SSHPubKey: req.SshPublicKey, + TLSPubKey: req.TlsPublicKey, + CreateWebSession: req.CreateWebSession, + ClientRedirectURL: req.ClientRedirectURL, + } +} + +// Compile-time assertions. [Identity] is satisfied by *auth.Services thanks to +// Slice 1's DeleteOIDCAuthRequest; Slice 4 wires the two together. +var ( + _ auth.OIDCService = (*Service)(nil) + _ AuthService = (*auth.Server)(nil) + _ Identity = (*auth.Services)(nil) +) diff --git a/lib/auth/oidcgoogle/oidcgoogle_test.go b/lib/auth/oidcgoogle/oidcgoogle_test.go new file mode 100644 index 000000000000..44c36759908e --- /dev/null +++ b/lib/auth/oidcgoogle/oidcgoogle_test.go @@ -0,0 +1,953 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package oidcgoogle + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/url" + "strings" + "testing" + "time" + + "github.com/gravitational/trace" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/types" + apievents "github.com/gravitational/teleport/api/types/events" + "github.com/gravitational/teleport/lib/events" +) + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- + +func TestNewFailsClosedWithoutHostedDomain(t *testing.T) { + t.Parallel() + + identity := newFakeIdentity() + + for _, tc := range []struct { + name string + hostedDomain string + assertErr require.ErrorAssertionFunc + }{ + { + // The whole point: with no hosted domain there is no way to tell a + // corporate account from a personal Gmail account. + name: "empty is refused", + hostedDomain: "", + assertErr: require.Error, + }, + { + name: "non-ascii is refused", + hostedDomain: "exàmple.com", + assertErr: require.Error, + }, + { + name: "not a domain is refused", + hostedDomain: "example", + assertErr: require.Error, + }, + { + name: "url is refused", + hostedDomain: "https://example.com", + assertErr: require.Error, + }, + { + name: "valid", + hostedDomain: "example.com", + assertErr: require.NoError, + }, + { + name: "valid, normalised to lower case", + hostedDomain: "Example.COM", + assertErr: require.NoError, + }, + } { + t.Run(tc.name, func(t *testing.T) { + svc, err := New(Config{ + HostedDomain: tc.hostedDomain, + Identity: identity, + Auth: nopAuthService{}, + }) + tc.assertErr(t, err) + if err == nil { + require.Equal(t, strings.ToLower(tc.hostedDomain), svc.hostedDomain) + } + }) + } +} + +func TestNewRequiresDependencies(t *testing.T) { + t.Parallel() + + _, err := New(Config{HostedDomain: testHostedDomain, Auth: nopAuthService{}}) + require.Error(t, err) + + _, err = New(Config{HostedDomain: testHostedDomain, Identity: newFakeIdentity()}) + require.Error(t, err) +} + +// --------------------------------------------------------------------------- +// Nonce derivation +// --------------------------------------------------------------------------- + +func TestNonceForStateToken(t *testing.T) { + t.Parallel() + + // The binding rule every slice must agree on: nonce = hex(SHA-256(state)). + const state = "0123456789abcdef" + sum := sha256.Sum256([]byte(state)) + require.Equal(t, hex.EncodeToString(sum[:]), NonceForStateToken(state)) + require.Len(t, NonceForStateToken(state), 64) + require.NotEqual(t, NonceForStateToken(state), NonceForStateToken(state+"0")) +} + +// --------------------------------------------------------------------------- +// Request side +// --------------------------------------------------------------------------- + +func TestCreateOIDCAuthRequest(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + + req := env.startLogin(t, types.OIDCAuthRequest{ + ConnectorID: testConnectorID, + ClientRedirectURL: "http://127.0.0.1:12345/callback?secret_key=abc", + CertTTL: time.Hour, + }) + + require.NotEmpty(t, req.StateToken) + require.NotEmpty(t, req.PkceVerifier) + // The request must be persisted, or the callback has nothing to look up. + require.Equal(t, 1, env.identity.count()) + + authURL, err := url.Parse(req.RedirectURL) + require.NoError(t, err) + q := authURL.Query() + + require.Equal(t, req.StateToken, q.Get("state")) + require.Equal(t, NonceForStateToken(req.StateToken), q.Get("nonce")) + require.Equal(t, "code", q.Get("response_type")) + require.Equal(t, testClientID, q.Get("client_id")) + require.Equal(t, testRedirectURL, q.Get("redirect_uri")) + require.Equal(t, "openid email profile", q.Get("scope")) + // PKCE is always on. + require.Equal(t, "S256", q.Get("code_challenge_method")) + require.NotEmpty(t, q.Get("code_challenge")) + require.NotEqual(t, req.PkceVerifier, q.Get("code_challenge")) + // "hd" on the authorization URL is a UI hint; it carries no security weight + // but it should still be there so Google's account chooser behaves. + require.Equal(t, testHostedDomain, q.Get("hd")) + + // The endpoint must have come from discovery, not a hardcoded google.com. + require.True(t, strings.HasPrefix(req.RedirectURL, env.google.issuer()+"/authorize"), + "authorization endpoint should come from OIDC discovery, got %q", req.RedirectURL) +} + +func TestCreateOIDCAuthRequestStateIsUnpredictable(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + + seen := make(map[string]struct{}) + for range 20 { + req := env.startLogin(t, types.OIDCAuthRequest{ + ClientRedirectURL: "http://127.0.0.1:12345/callback?secret_key=abc", + }) + require.NotContains(t, seen, req.StateToken) + seen[req.StateToken] = struct{}{} + } +} + +// TestCreateOIDCAuthRequestValidatesClientRedirect is the certificate-theft +// test. Without sso.ValidateClientRedirect, an attacker sends a victim a login +// link carrying the attacker's redirect_url; the victim completes a genuine +// Google login and the attacker's listener receives the victim's certificates. +func TestCreateOIDCAuthRequestValidatesClientRedirect(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + ctx := context.Background() + + for _, tc := range []struct { + name string + clientRedirect string + createWebSession bool + ssoTestFlow bool + assertErr require.ErrorAssertionFunc + }{ + { + name: "loopback callback accepted", + clientRedirect: "http://127.0.0.1:41234/callback?secret_key=abc", + assertErr: require.NoError, + }, + { + name: "localhost callback accepted", + clientRedirect: "http://localhost:41234/callback?secret_key=abc", + assertErr: require.NoError, + }, + { + name: "evil host rejected", + clientRedirect: "https://evil.example.net/callback?secret_key=abc", + assertErr: require.Error, + }, + { + name: "evil host over http rejected", + clientRedirect: "http://evil.example.net/callback?secret_key=abc", + assertErr: require.Error, + }, + { + name: "non callback path rejected", + clientRedirect: "http://127.0.0.1:41234/steal?secret_key=abc", + assertErr: require.Error, + }, + { + name: "extra query params rejected", + clientRedirect: "http://127.0.0.1:41234/callback?secret_key=abc&x=1", + assertErr: require.Error, + }, + { + // tctl sso test must not be a way around the rule. + name: "custom host rejected in test flow", + clientRedirect: "https://evil.example.net/callback?secret_key=abc", + ssoTestFlow: true, + assertErr: require.Error, + }, + } { + t.Run(tc.name, func(t *testing.T) { + req := types.OIDCAuthRequest{ + ConnectorID: testConnectorID, + ClientRedirectURL: tc.clientRedirect, + CreateWebSession: tc.createWebSession, + SSOTestFlow: tc.ssoTestFlow, + } + if tc.ssoTestFlow { + spec := env.connector.(*types.OIDCConnectorV3).Spec + req.ConnectorSpec = &spec + } + _, err := env.svc.CreateOIDCAuthRequest(ctx, req) + tc.assertErr(t, err) + }) + } +} + +// The web flow skips client redirect validation, exactly as GitHub's does: the +// request comes from the proxy, which sets the session cookie itself and +// redirects within its own UI. No certificate is handed to that URL. +func TestCreateOIDCAuthRequestWebSessionSkipsClientRedirectValidation(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + + _, err := env.svc.CreateOIDCAuthRequest(context.Background(), types.OIDCAuthRequest{ + ConnectorID: testConnectorID, + CreateWebSession: true, + ClientRedirectURL: "/web/cluster/me.localhost/nodes", + }) + require.NoError(t, err) +} + +// A caller-supplied connector spec on a real login would let anyone who can +// reach the auth API define their own IdP, client ID and role mapping. +func TestCreateOIDCAuthRequestRejectsConnectorSpecOutsideTestFlow(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + + spec := env.connector.(*types.OIDCConnectorV3).Spec + _, err := env.svc.CreateOIDCAuthRequest(context.Background(), types.OIDCAuthRequest{ + ConnectorID: testConnectorID, + ConnectorSpec: &spec, + }) + require.Error(t, err) + require.True(t, trace.IsBadParameter(err), "expected BadParameter, got %v", err) +} + +func TestCreateOIDCAuthRequestForMFANotImplemented(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + + _, err := env.svc.CreateOIDCAuthRequestForMFA(context.Background(), types.OIDCAuthRequest{ + ConnectorID: testConnectorID, + }) + require.True(t, trace.IsNotImplemented(err), "expected NotImplemented, got %v", err) +} + +// --------------------------------------------------------------------------- +// Connector policy +// --------------------------------------------------------------------------- + +// Every case here is a setting we cannot enforce. Silently ignoring a control an +// administrator deliberately turned on is how security controls quietly stop +// working, so each one is a hard refusal. +func TestCheckConnectorFailsClosed(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + + for _, tc := range []struct { + name string + mutate func(spec *types.OIDCConnectorSpecV3) + wantErr bool + }{ + {name: "unmodified", mutate: func(*types.OIDCConnectorSpecV3) {}}, + { + name: "issuer is not google", + mutate: func(s *types.OIDCConnectorSpecV3) { s.IssuerURL = "https://login.microsoftonline.com/common/v2.0" }, + wantErr: true, + }, + { + name: "allow_unverified_email", + mutate: func(s *types.OIDCConnectorSpecV3) { s.AllowUnverifiedEmail = true }, + wantErr: true, + }, + { + name: "max_age", + mutate: func(s *types.OIDCConnectorSpecV3) { d := types.Duration(time.Hour); s.MaxAge = &types.MaxAge{Value: d} }, + wantErr: true, + }, + { + name: "google directory sync", + mutate: func(s *types.OIDCConnectorSpecV3) { s.GoogleAdminEmail = "admin@example.com" }, + wantErr: true, + }, + { + name: "sso mfa", + mutate: func(s *types.OIDCConnectorSpecV3) { + s.MFASettings = &types.OIDCConnectorMFASettings{Enabled: true, ClientId: "x", ClientSecret: "y"} + }, + wantErr: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + spec := env.connector.(*types.OIDCConnectorV3).Spec + tc.mutate(&spec) + connector, err := types.NewOIDCConnector(testConnectorID, spec) + require.NoError(t, err) + + err = env.svc.checkConnector(connector) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Callback side: the token table +// --------------------------------------------------------------------------- + +func TestValidateOIDCAuthCallback(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + // opts describes the ID token the fake Google returns. nonce is filled + // in from the real state token unless overrideNonce is set. + opts tokenOpts + overrideNonce string + wantErr bool + // wantUnauthorized asserts the error matches ErrUnauthorizedIdentity, + // i.e. "you authenticated but you may not log in", as opposed to + // "authentication itself failed". + wantUnauthorized bool + errContains string + }{ + { + name: "valid", + }, + { + name: "wrong audience", + opts: tokenOpts{audience: "someone-elses-client-id"}, + wantErr: true, + errContains: "verification failed", + }, + { + name: "wrong issuer", + opts: tokenOpts{issuer: "https://accounts.evil.example.net"}, + wantErr: true, + errContains: "verification failed", + }, + { + name: "expired", + opts: tokenOpts{expires: time.Now().Add(-time.Minute)}, + wantErr: true, + errContains: "verification failed", + }, + { + name: "bad signature", + opts: tokenOpts{forgeSignature: true}, + wantErr: true, + errContains: "verification failed", + }, + { + name: "nonce mismatch", + overrideNonce: "not-the-nonce-for-this-state", + wantErr: true, + errContains: "verification failed", + }, + { + name: "empty nonce", + overrideNonce: " ", + wantErr: true, + errContains: "verification failed", + }, + { + // The single most important negative case. A personal Gmail + // account carries no "hd" claim at all. If this were written as + // "reject only when hd is present and wrong", every Gmail account + // in the world would be admitted. + name: "no hosted domain (personal gmail)", + opts: tokenOpts{omitHostedDomain: true, email: "someone@gmail.com"}, + wantErr: true, + wantUnauthorized: true, + errContains: "no hosted domain", + }, + { + name: "wrong hosted domain", + opts: tokenOpts{hostedDomain: "other.example.net"}, + wantErr: true, + wantUnauthorized: true, + errContains: "other.example.net", + }, + { + // The classic break: a naive suffix check on the email claim would + // let this through. The email is not consulted for the domain. + name: "email suffix lookalike does not substitute for hd", + opts: tokenOpts{ + omitHostedDomain: true, + email: "attacker@" + testHostedDomain + ".evil.net", + }, + wantErr: true, + wantUnauthorized: true, + errContains: "no hosted domain", + }, + { + name: "hosted domain lookalike in email with correct hd is fine", + opts: tokenOpts{ + hostedDomain: testHostedDomain, + email: "bob@" + testHostedDomain, + }, + }, + { + name: "email not verified", + opts: tokenOpts{emailVerified: false}, + wantErr: true, + wantUnauthorized: true, + errContains: "unverified", + }, + { + name: "email_verified absent is treated as false", + opts: tokenOpts{emailVerified: ""}, + wantErr: true, + wantUnauthorized: true, + errContains: "unverified", + }, + { + name: "no subject", + opts: tokenOpts{omitSubject: true}, + wantErr: true, + errContains: "verification failed", + }, + { + name: "hosted domain case is normalised", + opts: tokenOpts{hostedDomain: "EXAMPLE.com"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + ctx := context.Background() + + req := env.startLogin(t, types.OIDCAuthRequest{ + ClientRedirectURL: "http://127.0.0.1:41234/callback?secret_key=abc", + CertTTL: time.Hour, + }) + + opts := tc.opts + opts.nonce = NonceForStateToken(req.StateToken) + if tc.overrideNonce != "" { + opts.nonce = strings.TrimSpace(tc.overrideNonce) + } + env.google.setIDToken(env.google.issueToken(t, opts)) + + resp, err := env.svc.ValidateOIDCAuthCallback(ctx, url.Values{ + "code": []string{"auth-code"}, + "state": []string{req.StateToken}, + }) + + if !tc.wantErr { + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, testConnectorID, resp.Identity.ConnectorID) + require.Equal(t, resp.Username, resp.Identity.Username) + // Req must be the JSON-friendly authclient shape. + require.Equal(t, testConnectorID, resp.Req.ConnectorID) + requireLoginEvent(t, env, events.UserSSOLoginCode, "") + return + } + + require.Error(t, err) + require.Nil(t, resp) + if tc.errContains != "" { + require.Contains(t, strings.ToLower(err.Error()), strings.ToLower(tc.errContains)) + } + require.Equal(t, tc.wantUnauthorized, errors.Is(err, ErrUnauthorizedIdentity), + "ErrUnauthorizedIdentity match mismatch for %v", err) + + // Every rejection must leave a UserLogin failure event carrying the + // reason. + requireLoginEvent(t, env, events.UserSSOLoginFailureCode, tc.errContains) + }) + } +} + +// requireLoginEvent asserts that the last emitted audit event is a UserLogin +// with the given code, and (for failures) that it records a reason. +func requireLoginEvent(t *testing.T, env *testEnv, code, reasonContains string) { + t.Helper() + + last := env.emitter.LastEvent() + require.NotNil(t, last, "expected a UserLogin audit event to be emitted") + require.Equal(t, events.UserLoginEvent, last.GetType()) + require.Equal(t, code, last.GetCode()) + + login, ok := last.(*apievents.UserLogin) + require.True(t, ok, "expected *apievents.UserLogin, got %T", last) + require.Equal(t, events.LoginMethodOIDC, login.Method) + + if code == events.UserSSOLoginCode || code == events.UserSSOTestFlowLoginCode { + require.True(t, login.Status.Success) + require.NotEmpty(t, login.User) + return + } + + require.False(t, login.Status.Success) + require.NotEmpty(t, login.Status.Error, "failure event must record why the login was rejected") + if reasonContains != "" { + require.Contains(t, + strings.ToLower(login.Status.Error+" "+login.Status.UserMessage), + strings.ToLower(reasonContains)) + } +} + +// --------------------------------------------------------------------------- +// State token handling +// --------------------------------------------------------------------------- + +func TestValidateOIDCAuthCallbackStateIsSingleUse(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + ctx := context.Background() + + req := env.startLogin(t, types.OIDCAuthRequest{ + ClientRedirectURL: "http://127.0.0.1:41234/callback?secret_key=abc", + CertTTL: time.Hour, + }) + env.google.setIDToken(env.google.issueToken(t, tokenOpts{nonce: NonceForStateToken(req.StateToken)})) + + q := url.Values{"code": []string{"auth-code"}, "state": []string{req.StateToken}} + + _, err := env.svc.ValidateOIDCAuthCallback(ctx, q) + require.NoError(t, err) + require.Equal(t, 0, env.identity.count(), "the auth request must be deleted on use") + + // Replaying the same callback must fail. + _, err = env.svc.ValidateOIDCAuthCallback(ctx, q) + require.Error(t, err) + require.True(t, trace.IsNotFound(err), "expected NotFound on replay, got %v", err) + requireLoginEvent(t, env, events.UserSSOLoginFailureCode, "") +} + +func TestValidateOIDCAuthCallbackUnknownState(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + + _, err := env.svc.ValidateOIDCAuthCallback(context.Background(), url.Values{ + "code": []string{"auth-code"}, + "state": []string{"never-issued"}, + }) + require.Error(t, err) + require.True(t, trace.IsNotFound(err), "expected NotFound, got %v", err) +} + +func TestValidateOIDCAuthCallbackMissingState(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + + _, err := env.svc.ValidateOIDCAuthCallback(context.Background(), url.Values{ + "code": []string{"auth-code"}, + }) + require.Error(t, err) + requireLoginEvent(t, env, events.UserSSOLoginFailureCode, "") +} + +// If the state token cannot be consumed we must not proceed: leaving it in the +// backend would leave it replayable. +func TestValidateOIDCAuthCallbackFailsClosedIfStateCannotBeDeleted(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + + req := env.startLogin(t, types.OIDCAuthRequest{ + ClientRedirectURL: "http://127.0.0.1:41234/callback?secret_key=abc", + CertTTL: time.Hour, + }) + env.google.setIDToken(env.google.issueToken(t, tokenOpts{nonce: NonceForStateToken(req.StateToken)})) + env.identity.deleteErr = trace.ConnectionProblem(nil, "backend is down") + + _, err := env.svc.ValidateOIDCAuthCallback(context.Background(), url.Values{ + "code": []string{"auth-code"}, + "state": []string{req.StateToken}, + }) + require.Error(t, err) +} + +// The code exchange must carry the PKCE verifier that was generated for this +// request, and the same redirect_uri that was sent on the authorization URL. +func TestValidateOIDCAuthCallbackSendsPKCEVerifier(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + + req := env.startLogin(t, types.OIDCAuthRequest{ + ClientRedirectURL: "http://127.0.0.1:41234/callback?secret_key=abc", + CertTTL: time.Hour, + }) + env.google.setIDToken(env.google.issueToken(t, tokenOpts{nonce: NonceForStateToken(req.StateToken)})) + + _, err := env.svc.ValidateOIDCAuthCallback(context.Background(), url.Values{ + "code": []string{"auth-code"}, + "state": []string{req.StateToken}, + }) + require.NoError(t, err) + + form := env.google.tokenRequestForm() + require.Equal(t, "authorization_code", form.Get("grant_type")) + require.Equal(t, "auth-code", form.Get("code")) + require.Equal(t, req.PkceVerifier, form.Get("code_verifier")) + require.Equal(t, testRedirectURL, form.Get("redirect_uri")) +} + +// --------------------------------------------------------------------------- +// User handling +// --------------------------------------------------------------------------- + +// The collision guard. Without it a Google identity whose derived username +// matches an existing local account -- say a local "admin" -- takes that +// account over. +func TestValidateOIDCAuthCallbackLocalUserCollision(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + ctx := context.Background() + + const username = "alice@" + testHostedDomain + + local, err := types.NewUser(username) + require.NoError(t, err) + local.SetRoles([]string{testRole}) + // A user created by an administrator, not by this connector. + local.SetCreatedBy(types.CreatedBy{ + User: types.UserRef{Name: "admin"}, + Time: time.Now().UTC(), + }) + _, err = env.authServer.CreateUser(ctx, local) + require.NoError(t, err) + + req := env.startLogin(t, types.OIDCAuthRequest{ + ClientRedirectURL: "http://127.0.0.1:41234/callback?secret_key=abc", + CertTTL: time.Hour, + }) + env.google.setIDToken(env.google.issueToken(t, tokenOpts{ + nonce: NonceForStateToken(req.StateToken), + email: username, + })) + + _, err = env.svc.ValidateOIDCAuthCallback(ctx, url.Values{ + "code": []string{"auth-code"}, + "state": []string{req.StateToken}, + }) + require.Error(t, err) + require.True(t, trace.IsAlreadyExists(err), "expected AlreadyExists, got %v", err) + requireLoginEvent(t, env, events.UserSSOLoginFailureCode, "already exists") + + // The local user must be untouched: still no OIDC identity attached. + after, err := env.authServer.GetUser(ctx, username, false) + require.NoError(t, err) + require.Empty(t, after.GetOIDCIdentities()) + require.Nil(t, after.GetCreatedBy().Connector) +} + +func TestValidateOIDCAuthCallbackCreatesAndUpdatesUser(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + ctx := context.Background() + + const username = "alice@" + testHostedDomain + + login := func() { + req := env.startLogin(t, types.OIDCAuthRequest{ + ClientRedirectURL: "http://127.0.0.1:41234/callback?secret_key=abc", + CertTTL: time.Hour, + }) + env.google.setIDToken(env.google.issueToken(t, tokenOpts{ + nonce: NonceForStateToken(req.StateToken), + email: username, + })) + _, err := env.svc.ValidateOIDCAuthCallback(ctx, url.Values{ + "code": []string{"auth-code"}, + "state": []string{req.StateToken}, + }) + require.NoError(t, err) + } + + login() + + user, err := env.authServer.GetUser(ctx, username, false) + require.NoError(t, err) + require.Equal(t, []string{testRole}, user.GetRoles()) + require.Equal(t, []types.ExternalIdentity{{ + ConnectorID: testConnectorID, + Username: username, + UserID: "1234567890", + }}, user.GetOIDCIdentities()) + require.Equal(t, types.KindOIDC, user.GetCreatedBy().Connector.Type) + require.Equal(t, testConnectorID, user.GetCreatedBy().Connector.ID) + // The email address is a trait so roles can template on it. + require.Equal(t, []string{username}, user.GetTraits()["email"]) + require.Equal(t, []string{testHostedDomain}, user.GetTraits()["hd"]) + + // A second login for the same connector updates rather than colliding. + login() + + user, err = env.authServer.GetUser(ctx, username, false) + require.NoError(t, err) + require.Equal(t, []string{testRole}, user.GetRoles()) +} + +// tctl sso test must never write a user. +func TestValidateOIDCAuthCallbackSSOTestFlowWritesNothing(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + ctx := context.Background() + + spec := env.connector.(*types.OIDCConnectorV3).Spec + req, err := env.svc.CreateOIDCAuthRequest(ctx, types.OIDCAuthRequest{ + ConnectorID: testConnectorID, + ConnectorSpec: &spec, + SSOTestFlow: true, + ClientRedirectURL: "http://127.0.0.1:41234/callback?secret_key=abc", + CertTTL: time.Hour, + }) + require.NoError(t, err) + + env.google.setIDToken(env.google.issueToken(t, tokenOpts{nonce: NonceForStateToken(req.StateToken)})) + + resp, err := env.svc.ValidateOIDCAuthCallback(ctx, url.Values{ + "code": []string{"auth-code"}, + "state": []string{req.StateToken}, + }) + require.NoError(t, err) + require.Equal(t, "alice@"+testHostedDomain, resp.Username) + // No session, no certificates. + require.Nil(t, resp.Session) + require.Empty(t, resp.Cert) + require.Empty(t, resp.TLSCert) + + // And crucially: no user. + _, err = env.authServer.GetUser(ctx, "alice@"+testHostedDomain, false) + require.True(t, trace.IsNotFound(err), "SSOTestFlow must not create a user, got %v", err) + + requireLoginEvent(t, env, events.UserSSOTestFlowLoginCode, "") + + // The diagnostic record must have been written so tctl sso test can show it. + require.Contains(t, env.identity.diagInfos, req.StateToken) + require.True(t, env.identity.diagInfos[req.StateToken].Success) +} + +func TestValidateOIDCAuthCallbackSSOTestFlowRecordsFailureDiagnostics(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + ctx := context.Background() + + spec := env.connector.(*types.OIDCConnectorV3).Spec + req, err := env.svc.CreateOIDCAuthRequest(ctx, types.OIDCAuthRequest{ + ConnectorID: testConnectorID, + ConnectorSpec: &spec, + SSOTestFlow: true, + ClientRedirectURL: "http://127.0.0.1:41234/callback?secret_key=abc", + CertTTL: time.Hour, + }) + require.NoError(t, err) + + env.google.setIDToken(env.google.issueToken(t, tokenOpts{ + nonce: NonceForStateToken(req.StateToken), + omitHostedDomain: true, + })) + + _, err = env.svc.ValidateOIDCAuthCallback(ctx, url.Values{ + "code": []string{"auth-code"}, + "state": []string{req.StateToken}, + }) + require.Error(t, err) + requireLoginEvent(t, env, events.UserSSOTestFlowLoginFailureCode, "no hosted domain") + + info, ok := env.identity.diagInfos[req.StateToken] + require.True(t, ok) + require.False(t, info.Success) + require.NotEmpty(t, info.Error) +} + +// --------------------------------------------------------------------------- +// Web and console flows +// --------------------------------------------------------------------------- + +func TestValidateOIDCAuthCallbackWebSession(t *testing.T) { + t.Parallel() + env := setupTestEnvWithCAs(t) + ctx := context.Background() + + req := env.startLogin(t, types.OIDCAuthRequest{ + CreateWebSession: true, + ClientLoginIP: "10.0.0.1", + ClientUserAgent: "test-agent", + }) + env.google.setIDToken(env.google.issueToken(t, tokenOpts{nonce: NonceForStateToken(req.StateToken)})) + + resp, err := env.svc.ValidateOIDCAuthCallback(ctx, url.Values{ + "code": []string{"auth-code"}, + "state": []string{req.StateToken}, + }) + require.NoError(t, err) + require.NotNil(t, resp.Session) + require.Equal(t, "alice@"+testHostedDomain, resp.Session.GetUser()) + require.Empty(t, resp.Cert, "web flow should not issue certificates") +} + +func TestValidateOIDCAuthCallbackConsoleCerts(t *testing.T) { + t.Parallel() + env := setupTestEnvWithCAs(t) + ctx := context.Background() + + sshPub, tlsPub := testPublicKeys(t) + + req := env.startLogin(t, types.OIDCAuthRequest{ + ClientRedirectURL: "http://127.0.0.1:41234/callback?secret_key=abc", + SshPublicKey: sshPub, + TlsPublicKey: tlsPub, + CertTTL: time.Hour, + RouteToCluster: "me.localhost", + }) + env.google.setIDToken(env.google.issueToken(t, tokenOpts{nonce: NonceForStateToken(req.StateToken)})) + + resp, err := env.svc.ValidateOIDCAuthCallback(ctx, url.Values{ + "code": []string{"auth-code"}, + "state": []string{req.StateToken}, + }) + require.NoError(t, err) + require.NotEmpty(t, resp.Cert) + require.NotEmpty(t, resp.TLSCert) + // The console client needs the host CA to trust the cluster it is about to + // connect to. + require.Len(t, resp.HostSigners, 1) + require.Equal(t, types.HostCA, resp.HostSigners[0].GetType()) + require.Nil(t, resp.Session, "console flow should not create a web session") +} + +// Google's "sub" is immutable and never reused; an email address is not. A +// recycled address must not inherit the previous holder's Teleport user. +func TestValidateOIDCAuthCallbackRejectsRecycledEmailAddress(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + ctx := context.Background() + + login := func(subject string) error { + req := env.startLogin(t, types.OIDCAuthRequest{ + ClientRedirectURL: "http://127.0.0.1:41234/callback?secret_key=abc", + CertTTL: time.Hour, + }) + env.google.setIDToken(env.google.issueToken(t, tokenOpts{ + nonce: NonceForStateToken(req.StateToken), + subject: subject, + })) + _, err := env.svc.ValidateOIDCAuthCallback(ctx, url.Values{ + "code": []string{"auth-code"}, + "state": []string{req.StateToken}, + }) + return err + } + + require.NoError(t, login(testGoogleSubject)) + // Same address, different Google account. + err := login("9999999999") + require.Error(t, err) + require.True(t, trace.IsAlreadyExists(err), "expected AlreadyExists, got %v", err) + requireLoginEvent(t, env, events.UserSSOLoginFailureCode, "different Google account") + + // The original user is untouched. + user, err := env.authServer.GetUser(ctx, "alice@"+testHostedDomain, false) + require.NoError(t, err) + require.Equal(t, testGoogleSubject, user.GetOIDCIdentities()[0].UserID) +} + +// ErrUnauthorizedIdentity must satisfy two contracts at once: errors.Is for the +// proxy's "unauthorized" redirect, and trace.IsAccessDenied so the error still +// maps to 403 everywhere trace errors are converted. +func TestUnauthorizedIdentityErrorContract(t *testing.T) { + t.Parallel() + + err := unauthorizedf("account %q is not in %q", "bob@gmail.com", "example.com") + require.True(t, errors.Is(err, ErrUnauthorizedIdentity)) + require.True(t, trace.IsAccessDenied(err)) + require.Contains(t, err.Error(), "bob@gmail.com") + + // An unrelated access-denied error must NOT match the sentinel, or the + // proxy would show "your account is not allowed" for, say, a forged token. + require.False(t, errors.Is(trace.AccessDenied("nope"), ErrUnauthorizedIdentity)) +} + +func TestClaimsUsername(t *testing.T) { + t.Parallel() + + claims := &googleClaims{Email: "alice@example.com"} + claims.Subject = "sub-123" + + for _, tc := range []struct { + claimName string + want string + wantErr bool + }{ + {claimName: "", want: "alice@example.com"}, + {claimName: "email", want: "alice@example.com"}, + {claimName: "sub", want: "sub-123"}, + // A user-editable claim must not be usable as a username, or a user + // could choose their own Teleport identity. + {claimName: "name", wantErr: true}, + {claimName: "hd", wantErr: true}, + } { + t.Run("claim="+tc.claimName, func(t *testing.T) { + got, err := claims.username(tc.claimName) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/lib/auth/oidcgoogle/testhelpers_test.go b/lib/auth/oidcgoogle/testhelpers_test.go new file mode 100644 index 000000000000..024cf20d5089 --- /dev/null +++ b/lib/auth/oidcgoogle/testhelpers_test.go @@ -0,0 +1,454 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package oidcgoogle + +import ( + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/auth/authtest" + authority "github.com/gravitational/teleport/lib/auth/testauthority" + "github.com/gravitational/teleport/lib/backend/memory" + "github.com/gravitational/teleport/lib/cryptosuites" + "github.com/gravitational/teleport/lib/events/eventstest" + "github.com/gravitational/teleport/lib/modules" + "github.com/gravitational/teleport/lib/oidc/fakeissuer" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/utils/log/logtest" +) + +const ( + testHostedDomain = "example.com" + testClientID = "test-client-id.apps.googleusercontent.com" + testClientSecret = "test-client-secret" + testRedirectURL = "https://proxy.example.com:3080/v1/webapi/oidc/callback" + testConnectorID = "google" + // testGoogleSubject is the "sub" claim: Google's immutable account id. + testGoogleSubject = "1234567890" + testRole = "access" +) + +// fakeIdentity is an in-memory stand-in for the parts of services.Identity this +// service uses. +// +// It is a fake rather than the real local.IdentityService because +// DeleteOIDCAuthRequest is added by Slice 1 and does not exist in this tree +// yet. The behaviour it models -- get returns what was stored, delete makes the +// next get fail with NotFound -- is exactly what Slice 1's merge criteria +// require, so these tests will keep passing once the real implementation lands. +type fakeIdentity struct { + mu sync.Mutex + requests map[string]*types.OIDCAuthRequest + diagInfos map[string]types.SSODiagnosticInfo + + // deleteErr, when set, makes DeleteOIDCAuthRequest fail. Used to check that + // we fail closed when the state token cannot be consumed. + deleteErr error +} + +func newFakeIdentity() *fakeIdentity { + return &fakeIdentity{ + requests: make(map[string]*types.OIDCAuthRequest), + diagInfos: make(map[string]types.SSODiagnosticInfo), + } +} + +func (f *fakeIdentity) CreateOIDCAuthRequest(ctx context.Context, req types.OIDCAuthRequest, ttl time.Duration) error { + if err := req.Check(); err != nil { + return trace.Wrap(err) + } + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.requests[req.StateToken]; ok { + return trace.AlreadyExists("auth request %q already exists", req.StateToken) + } + cloned := req + f.requests[req.StateToken] = &cloned + return nil +} + +func (f *fakeIdentity) GetOIDCAuthRequest(ctx context.Context, stateToken string) (*types.OIDCAuthRequest, error) { + f.mu.Lock() + defer f.mu.Unlock() + req, ok := f.requests[stateToken] + if !ok { + return nil, trace.NotFound("auth request %q not found", stateToken) + } + cloned := *req + return &cloned, nil +} + +func (f *fakeIdentity) DeleteOIDCAuthRequest(ctx context.Context, stateToken string) error { + if f.deleteErr != nil { + return f.deleteErr + } + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.requests[stateToken]; !ok { + return trace.NotFound("auth request %q not found", stateToken) + } + delete(f.requests, stateToken) + return nil +} + +func (f *fakeIdentity) CreateSSODiagnosticInfo(ctx context.Context, authKind, authRequestID string, entry types.SSODiagnosticInfo) error { + f.mu.Lock() + defer f.mu.Unlock() + f.diagInfos[authRequestID] = entry + return nil +} + +func (f *fakeIdentity) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.requests) +} + +// fakeGoogle is a stand-in for Google's authorization server. Discovery and +// JWKS are served by lib/oidc/fakeissuer (the same fake the rest of the tree +// verifies tokens against); only the token endpoint, which fakeissuer does not +// implement, is hosted here. +type fakeGoogle struct { + idp *fakeissuer.IDP + otherIDP *fakeissuer.IDP // different key, used to forge bad signatures + tokenSrv *httptest.Server + + mu sync.Mutex + idToken string + lastForm url.Values +} + +func newFakeGoogle(t *testing.T) *fakeGoogle { + t.Helper() + logger := logtest.NewLogger() + + idp, err := fakeissuer.NewIDP(logger) + require.NoError(t, err) + t.Cleanup(idp.Close) + + otherIDP, err := fakeissuer.NewIDP(logger) + require.NoError(t, err) + t.Cleanup(otherIDP.Close) + + g := &fakeGoogle{idp: idp, otherIDP: otherIDP} + + mux := http.NewServeMux() + mux.HandleFunc("/token", g.handleToken) + g.tokenSrv = httptest.NewServer(mux) + t.Cleanup(g.tokenSrv.Close) + + idp.SetEndpoints(idp.IssuerURL()+"/authorize", g.tokenSrv.URL+"/token") + return g +} + +func (g *fakeGoogle) issuer() string { return g.idp.IssuerURL() } + +func (g *fakeGoogle) handleToken(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + g.mu.Lock() + g.lastForm = r.PostForm + idToken := g.idToken + g.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "fake-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "id_token": idToken, + }) +} + +// setIDToken sets the ID token the fake token endpoint will return. +func (g *fakeGoogle) setIDToken(token string) { + g.mu.Lock() + defer g.mu.Unlock() + g.idToken = token +} + +func (g *fakeGoogle) tokenRequestForm() url.Values { + g.mu.Lock() + defer g.mu.Unlock() + return g.lastForm +} + +// tokenOpts describes the ID token the fake Google should mint. The zero value +// is filled in with a valid, in-domain, verified identity. +type tokenOpts struct { + issuer string + audience any + subject string + // omitSubject drops the "sub" claim entirely. + omitSubject bool + email string + emailVerified any + // hostedDomain is the "hd" claim. omitHostedDomain distinguishes "absent" + // (a personal Gmail account) from "present but empty". + hostedDomain string + omitHostedDomain bool + nonce string + expires time.Time + // forgeSignature signs with a key the IDP does not publish. + forgeSignature bool +} + +func (g *fakeGoogle) issueToken(t *testing.T, opts tokenOpts) string { + t.Helper() + now := time.Now() + + claims := map[string]any{ + "iss": cmpOr(opts.issuer, g.issuer()), + "aud": valueOr[any](opts.audience, testClientID), + "iat": now.Add(-time.Minute).Unix(), + "nbf": now.Add(-time.Minute).Unix(), + "exp": valueOrTime(opts.expires, now.Add(10*time.Minute)).Unix(), + "email": cmpOr(opts.email, "alice@"+testHostedDomain), + "name": "Alice Example", + "nonce": opts.nonce, + } + if !opts.omitSubject { + claims["sub"] = cmpOr(opts.subject, testGoogleSubject) + } + claims["email_verified"] = valueOr[any](opts.emailVerified, true) + if !opts.omitHostedDomain { + claims["hd"] = cmpOr(opts.hostedDomain, testHostedDomain) + } + + idp := g.idp + if opts.forgeSignature { + idp = g.otherIDP + } + token, err := idp.SignClaims(claims) + require.NoError(t, err) + return token +} + +func cmpOr(v, fallback string) string { + if v == "" { + return fallback + } + return v +} + +func valueOr[T any](v any, fallback T) any { + if v == nil { + return fallback + } + return v +} + +func valueOrTime(v, fallback time.Time) time.Time { + if v.IsZero() { + return fallback + } + return v +} + +// testEnv bundles a real auth server, a fake identity store and a fake Google. +type testEnv struct { + authServer *auth.Server + emitter *eventstest.MockRecorderEmitter + identity *fakeIdentity + google *fakeGoogle + svc *Service + connector types.OIDCConnector +} + +// setupTestEnv builds a lightweight auth server: enough for connector, user and +// audit behaviour, but with no certificate authorities, so it cannot issue web +// sessions or certificates. Use setupTestEnvWithCAs for those. +func setupTestEnv(t *testing.T) *testEnv { + t.Helper() + ctx := context.Background() + + clock := clockwork.NewRealClock() + + bk, err := memory.New(memory.Config{Context: ctx, Clock: clock}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, bk.Close()) }) + + clusterName, err := services.NewClusterNameWithRandomID(types.ClusterNameSpecV2{ + ClusterName: "me.localhost", + }) + require.NoError(t, err) + + keygen, err := authority.NewKeygen(modules.BuildOSS, clock.Now) + require.NoError(t, err) + + authServer, err := auth.NewServer(&auth.InitConfig{ + ClusterName: clusterName, + Backend: bk, + VersionStorage: authtest.NewFakeTeleportVersion(), + Authority: keygen, + SkipPeriodicOperations: true, + HostUUID: uuid.NewString(), + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, authServer.Close()) }) + + emitter := &eventstest.MockRecorderEmitter{} + authServer.SetEmitter(emitter) + + role, err := types.NewRole(testRole, types.RoleSpecV6{}) + require.NoError(t, err) + _, err = authServer.UpsertRole(ctx, role) + require.NoError(t, err) + + return finishTestEnv(t, authServer, emitter) +} + +// setupTestEnvWithCAs builds a fully initialised auth server (cluster name, +// certificate authorities, auth preference) so that web sessions and user +// certificates can actually be issued. It is slower, so only the tests that +// need it use it. +func setupTestEnvWithCAs(t *testing.T) *testEnv { + t.Helper() + ctx := context.Background() + + testAuth, err := authtest.NewAuthServer(authtest.AuthServerConfig{ + Dir: t.TempDir(), + ClusterName: "me.localhost", + Clock: clockwork.NewRealClock(), + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, testAuth.Close()) }) + + role, err := types.NewRole(testRole, types.RoleSpecV6{}) + require.NoError(t, err) + _, err = testAuth.AuthServer.UpsertRole(ctx, role) + require.NoError(t, err) + + emitter := &eventstest.MockRecorderEmitter{} + testAuth.AuthServer.SetEmitter(emitter) + + return finishTestEnv(t, testAuth.AuthServer, emitter) +} + +func finishTestEnv(t *testing.T, authServer *auth.Server, emitter *eventstest.MockRecorderEmitter) *testEnv { + t.Helper() + ctx := context.Background() + + google := newFakeGoogle(t) + connector := testConnector(t, google.issuer()) + _, err := authServer.Services.CreateOIDCConnector(ctx, connector) + require.NoError(t, err) + + identity := newFakeIdentity() + svc := newTestService(t, google, authServer, identity) + + emitter.Reset() + + return &testEnv{ + authServer: authServer, + emitter: emitter, + identity: identity, + google: google, + svc: svc, + connector: connector, + } +} + +func newTestService(t *testing.T, google *fakeGoogle, authServer AuthService, identity Identity) *Service { + t.Helper() + svc, err := New(Config{ + HostedDomain: testHostedDomain, + Identity: identity, + Auth: authServer, + Logger: logtest.NewLogger(), + }) + require.NoError(t, err) + + // The fake issuer is not Google, and it signs with ES256 rather than + // RS256. Both restrictions are unexported precisely so that nothing but a + // test in this package can relax them. + svc.allowedIssuer = google.issuer() + svc.signingAlgs = []string{"ES256"} + return svc +} + +func testConnector(t *testing.T, issuerURL string) types.OIDCConnector { + t.Helper() + connector, err := types.NewOIDCConnector(testConnectorID, types.OIDCConnectorSpecV3{ + IssuerURL: issuerURL, + ClientID: testClientID, + ClientSecret: testClientSecret, + RedirectURLs: []string{testRedirectURL}, + ClaimsToRoles: []types.ClaimMapping{{ + Claim: "hd", + Value: testHostedDomain, + Roles: []string{testRole}, + }}, + }) + require.NoError(t, err) + return connector +} + +// startLogin performs the request side and returns the stored auth request. +func (e *testEnv) startLogin(t *testing.T, req types.OIDCAuthRequest) *types.OIDCAuthRequest { + t.Helper() + if req.ConnectorID == "" { + req.ConnectorID = testConnectorID + } + out, err := e.svc.CreateOIDCAuthRequest(context.Background(), req) + require.NoError(t, err) + return out +} + +// nopAuthService satisfies [AuthService] for constructor tests that never reach +// any of its methods. Embedding the nil interface means an unexpected call +// panics loudly rather than silently returning a zero value. +type nopAuthService struct{ AuthService } + +// testPublicKeys returns an SSH authorized_keys blob and a PEM public key that +// the auth server will accept for certificate issuance. +func testPublicKeys(t *testing.T) (sshPub, tlsPub []byte) { + t.Helper() + + key, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.ECDSAP256) + require.NoError(t, err) + + sshSigner, err := ssh.NewPublicKey(key.Public()) + require.NoError(t, err) + sshPub = ssh.MarshalAuthorizedKey(sshSigner) + + der, err := x509.MarshalPKIXPublicKey(key.Public()) + require.NoError(t, err) + tlsPub = pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}) + + return sshPub, tlsPub +} diff --git a/lib/oidc/fakeissuer/oidc.go b/lib/oidc/fakeissuer/oidc.go index ffccd4003e76..4ef3f2c39a67 100644 --- a/lib/oidc/fakeissuer/oidc.go +++ b/lib/oidc/fakeissuer/oidc.go @@ -38,6 +38,27 @@ type IDP struct { log *slog.Logger signer *KubernetesSigner server *httptest.Server + + // authorizationEndpoint and tokenEndpoint, when non-empty, are advertised + // in the discovery document. The fake IDP does not implement either + // endpoint itself; a test that needs them points these at its own handlers. + // They are omitted from the document when empty, so existing callers are + // unaffected. + authorizationEndpoint string + tokenEndpoint string +} + +// SetEndpoints sets the authorization and token endpoints advertised by the +// discovery document. Either may be empty, in which case it is omitted. +func (f *IDP) SetEndpoints(authorizationEndpoint, tokenEndpoint string) { + f.authorizationEndpoint = authorizationEndpoint + f.tokenEndpoint = tokenEndpoint +} + +// SignClaims signs an arbitrary claims value with the IDP's key, so that tests +// can mint tokens whose claim set is not the Kubernetes service account shape. +func (f *IDP) SignClaims(claims any) (string, error) { + return f.signer.signWithClaims(claims) } // NewIDP creates a IDP and starts its HTTP server. @@ -97,6 +118,12 @@ func (f *IDP) handleOpenIDConfig(w http.ResponseWriter, r *http.Request) { "scopes_supported": []string{"openid"}, "subject_types_supported": []string{"public"}, } + if f.authorizationEndpoint != "" { + response["authorization_endpoint"] = f.authorizationEndpoint + } + if f.tokenEndpoint != "" { + response["token_endpoint"] = f.tokenEndpoint + } responseBytes, err := json.Marshal(response) if err != nil { f.log.ErrorContext(r.Context(), "failed to marshal openid-configuration", "error", err)