From 58b2f7c36f76a173bfe28563beccf1c5db6ee435 Mon Sep 17 00:00:00 2001 From: Neil Trini Lasrado Date: Wed, 16 Sep 2026 23:53:17 +0530 Subject: [PATCH] Add lib/auth/oidcgoogle: a Google-only OIDCService implementation Implements auth.OIDCService so a self-hosted cluster can authenticate users against Google Workspace, restricted to a single hosted domain. Nothing registers the service yet; that is a later slice. Authorization rests on the hd claim: it must be present and exactly equal to the configured domain, compared as ASCII-lowercase. A personal Gmail account carries no hd at all, so absence is an explicit rejection rather than a default. The domain is never derived from the email claim, which a suffix match would make forgeable via example.com.evil.net. The ID token is fully verified before any claim is trusted, with signing algorithms pinned to RS256 and the nonce bound to the state token as hex(SHA-256(state)). State is single-use: deleted on read, failing closed if the delete errors. Two guards protect existing accounts. A Google identity cannot take over a user created by another connector, and cannot inherit a user whose stored OIDC sub differs -- Google never reuses sub, but an email address can be reissued to a different person. checkConnector refuses five settings this implementation does not enforce rather than ignoring them: non-Google issuer, allow_unverified_email, max_age, Workspace directory sync, and mfa.enabled. Co-Authored-By: Claude Opus 5 --- lib/auth/oidcgoogle/claims.go | 126 +++ lib/auth/oidcgoogle/oidcgoogle.go | 1081 +++++++++++++++++++++++ lib/auth/oidcgoogle/oidcgoogle_test.go | 953 ++++++++++++++++++++ lib/auth/oidcgoogle/testhelpers_test.go | 454 ++++++++++ lib/oidc/fakeissuer/oidc.go | 27 + 5 files changed, 2641 insertions(+) create mode 100644 lib/auth/oidcgoogle/claims.go create mode 100644 lib/auth/oidcgoogle/oidcgoogle.go create mode 100644 lib/auth/oidcgoogle/oidcgoogle_test.go create mode 100644 lib/auth/oidcgoogle/testhelpers_test.go 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)