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)