package idpsync import ( "context" "net/http" "regexp" "strings" "github.com/golang-jwt/jwt/v4" "github.com/google/uuid" "golang.org/x/xerrors" "cdr.dev/slog" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/runtimeconfig" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/site" ) // IDPSync is an interface, so we can implement this as AGPL and as enterprise, // and just swap the underlying implementation. // IDPSync exists to contain all the logic for mapping a user's external IDP // claims to the internal representation of a user in Coder. // TODO: Move group + role sync into this interface. type IDPSync interface { OrganizationSyncEntitled() bool OrganizationSyncSettings(ctx context.Context, db database.Store) (*OrganizationSyncSettings, error) UpdateOrganizationSyncSettings(ctx context.Context, db database.Store, settings OrganizationSyncSettings) error // OrganizationSyncEnabled returns true if all OIDC users are assigned // to organizations via org sync settings. // This is used to know when to disable manual org membership assignment. OrganizationSyncEnabled(ctx context.Context, db database.Store) bool // ParseOrganizationClaims takes claims from an OIDC provider, and returns the // organization sync params for assigning users into organizations. ParseOrganizationClaims(ctx context.Context, mergedClaims jwt.MapClaims) (OrganizationParams, *HTTPError) // SyncOrganizations assigns and removed users from organizations based on the // provided params. SyncOrganizations(ctx context.Context, tx database.Store, user database.User, params OrganizationParams) error GroupSyncEntitled() bool // ParseGroupClaims takes claims from an OIDC provider, and returns the params // for group syncing. Most of the logic happens in SyncGroups. ParseGroupClaims(ctx context.Context, mergedClaims jwt.MapClaims) (GroupParams, *HTTPError) // SyncGroups assigns and removes users from groups based on the provided params. SyncGroups(ctx context.Context, db database.Store, user database.User, params GroupParams) error // GroupSyncSettings is exposed for the API to implement CRUD operations // on the settings used by IDPSync. This entry is thread safe and can be // accessed concurrently. The settings are stored in the database. GroupSyncSettings(ctx context.Context, orgID uuid.UUID, db database.Store) (*GroupSyncSettings, error) UpdateGroupSyncSettings(ctx context.Context, orgID uuid.UUID, db database.Store, settings GroupSyncSettings) error // RoleSyncEntitled returns true if the deployment is entitled to role syncing. RoleSyncEntitled() bool // OrganizationRoleSyncEnabled returns true if the organization has role sync // enabled. OrganizationRoleSyncEnabled(ctx context.Context, db database.Store, org uuid.UUID) (bool, error) // SiteRoleSyncEnabled returns true if the deployment has role sync enabled // at the site level. SiteRoleSyncEnabled() bool // RoleSyncSettings is similar to GroupSyncSettings. See GroupSyncSettings for // rational. RoleSyncSettings(ctx context.Context, orgID uuid.UUID, db database.Store) (*RoleSyncSettings, error) UpdateRoleSyncSettings(ctx context.Context, orgID uuid.UUID, db database.Store, settings RoleSyncSettings) error // ParseRoleClaims takes claims from an OIDC provider, and returns the params // for role syncing. Most of the logic happens in SyncRoles. ParseRoleClaims(ctx context.Context, mergedClaims jwt.MapClaims) (RoleParams, *HTTPError) // SyncRoles assigns and removes users from roles based on the provided params. // Site & org roles are handled in this method. SyncRoles(ctx context.Context, db database.Store, user database.User, params RoleParams) error } // AGPLIDPSync implements the IDPSync interface var _ IDPSync = AGPLIDPSync{} // AGPLIDPSync is the configuration for syncing user information from an external // IDP. All related code to syncing user information should be in this package. type AGPLIDPSync struct { Logger slog.Logger Manager *runtimeconfig.Manager SyncSettings } // DeploymentSyncSettings are static and are sourced from the deployment config. type DeploymentSyncSettings struct { // OrganizationField selects the claim field to be used as the created user's // organizations. If the field is the empty string, then no organization updates // will ever come from the OIDC provider. OrganizationField string // OrganizationMapping controls how organizations returned by the OIDC provider get mapped OrganizationMapping map[string][]uuid.UUID // OrganizationAssignDefault will ensure all users that authenticate will be // placed into the default organization. This is mostly a hack to support // legacy deployments. OrganizationAssignDefault bool // GroupField at the deployment level is used for deployment level group claim // settings. GroupField string // GroupAllowList (if set) will restrict authentication to only users who // have at least one group in this list. // A map representation is used for easier lookup. GroupAllowList map[string]struct{} // Legacy deployment settings that only apply to the default org. Legacy DefaultOrgLegacySettings // SiteRoleField selects the claim field to be used as the created user's // roles. If the field is the empty string, then no site role updates // will ever come from the OIDC provider. SiteRoleField string // SiteRoleMapping controls how groups returned by the OIDC provider get mapped // to site roles within Coder. // map[oidcRoleName][]coderRoleName SiteRoleMapping map[string][]string // SiteDefaultRoles is the default set of site roles to assign to a user if role sync // is enabled. SiteDefaultRoles []string } type DefaultOrgLegacySettings struct { GroupField string GroupMapping map[string]string GroupFilter *regexp.Regexp CreateMissingGroups bool } func FromDeploymentValues(dv *codersdk.DeploymentValues) DeploymentSyncSettings { if dv == nil { panic("Developer error: DeploymentValues should not be nil") } return DeploymentSyncSettings{ OrganizationField: dv.OIDC.OrganizationField.Value(), OrganizationMapping: dv.OIDC.OrganizationMapping.Value, OrganizationAssignDefault: dv.OIDC.OrganizationAssignDefault.Value(), SiteRoleField: dv.OIDC.UserRoleField.Value(), SiteRoleMapping: dv.OIDC.UserRoleMapping.Value, SiteDefaultRoles: dv.OIDC.UserRolesDefault.Value(), // TODO: Separate group field for allow list from default org. // Right now you cannot disable group sync from the default org and // configure an allow list. GroupField: dv.OIDC.GroupField.Value(), GroupAllowList: ConvertAllowList(dv.OIDC.GroupAllowList.Value()), Legacy: DefaultOrgLegacySettings{ GroupField: dv.OIDC.GroupField.Value(), GroupMapping: dv.OIDC.GroupMapping.Value, GroupFilter: dv.OIDC.GroupRegexFilter.Value(), CreateMissingGroups: dv.OIDC.GroupAutoCreate.Value(), }, } } type SyncSettings struct { DeploymentSyncSettings Group runtimeconfig.RuntimeEntry[*GroupSyncSettings] Role runtimeconfig.RuntimeEntry[*RoleSyncSettings] Organization runtimeconfig.RuntimeEntry[*OrganizationSyncSettings] } func NewAGPLSync(logger slog.Logger, manager *runtimeconfig.Manager, settings DeploymentSyncSettings) *AGPLIDPSync { return &AGPLIDPSync{ Logger: logger.Named("idp-sync"), Manager: manager, SyncSettings: SyncSettings{ DeploymentSyncSettings: settings, Group: runtimeconfig.MustNew[*GroupSyncSettings]("group-sync-settings"), Role: runtimeconfig.MustNew[*RoleSyncSettings]("role-sync-settings"), Organization: runtimeconfig.MustNew[*OrganizationSyncSettings]("organization-sync-settings"), }, } } // ParseStringSliceClaim parses the claim for groups and roles, expected []string. // // Some providers like ADFS return a single string instead of an array if there // is only 1 element. So this function handles the edge cases. func ParseStringSliceClaim(claim interface{}) ([]string, error) { groups := make([]string, 0) if claim == nil { return groups, nil } // The simple case is the type is exactly what we expected asStringArray, ok := claim.([]string) if ok { return asStringArray, nil } asArray, ok := claim.([]interface{}) if ok { for i, item := range asArray { asString, ok := item.(string) if !ok { return nil, xerrors.Errorf("invalid claim type. Element %d expected a string, got: %T", i, item) } groups = append(groups, asString) } return groups, nil } asString, ok := claim.(string) if ok { if asString == "" { // Empty string should be 0 groups. return []string{}, nil } // If it is a single string, first check if it is a csv. // If a user hits this, it is likely a misconfiguration and they need // to reconfigure their IDP to send an array instead. if strings.Contains(asString, ",") { return nil, xerrors.Errorf("invalid claim type. Got a csv string (%q), change this claim to return an array of strings instead.", asString) } return []string{asString}, nil } // Not sure what the user gave us. return nil, xerrors.Errorf("invalid claim type. Expected an array of strings, got: %T", claim) } // IsHTTPError handles us being inconsistent with returning errors as values or // pointers. func IsHTTPError(err error) *HTTPError { var httpErr HTTPError if xerrors.As(err, &httpErr) { return &httpErr } var httpErrPtr *HTTPError if xerrors.As(err, &httpErrPtr) { return httpErrPtr } return nil } // HTTPError is a helper struct for returning errors from the IDP sync process. // A regular error is not sufficient because many of these errors are surfaced // to a user logging in, and the errors should be descriptive. type HTTPError struct { Code int Msg string Detail string RenderStaticPage bool RenderDetailMarkdown bool } func (e HTTPError) Write(rw http.ResponseWriter, r *http.Request) { if e.RenderStaticPage { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: e.Code, HideStatus: true, Title: e.Msg, Description: e.Detail, RetryEnabled: false, DashboardURL: "/login", RenderDescriptionMarkdown: e.RenderDetailMarkdown, }) return } httpapi.Write(r.Context(), rw, e.Code, codersdk.Response{ Message: e.Msg, Detail: e.Detail, }) } func (e HTTPError) Error() string { if e.Detail != "" { return e.Detail } return e.Msg }