package service import ( "cmp" "context" "errors" "fmt" "io" "log/slog" "mime/multipart" "net/http" "net/url" "path" "slices" "strings" "time" "uuid" "gorm.io/gorm" "gorm.io/gorm/clause" "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/backchannellogout" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/oidc" "github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/utils" imageutil "github.com/pocket-id/pocket-id/backend/internal/utils/image" jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk" ) const ( GrantTypeAuthorizationCode = "authorization_code" GrantTypeRefreshToken = "refresh_token" GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code" GrantTypeClientCredentials = "client_credentials" AccessTokenDuration = time.Duration(model.DefaultAccessTokenDurationMinutes) * time.Minute RefreshTokenDuration = time.Duration(model.DefaultRefreshTokenDurationMinutes) * time.Minute ) type OidcService struct { db *gorm.DB jwtService *JwtService previewBuilder oidcClientPreviewBuilder metadataRefresher metadataRefresher scimSyncScheduler ScimSyncScheduler backchannelLogout *backchannellogout.Service httpClient *http.Client fileStorage storage.FileStorage } type oidcClientPreviewBuilder interface { BuildClientPreview(ctx context.Context, client model.OidcClient, userID string, scopes []string, authenticationMethod string) (*oidc.ClientPreview, error) } type metadataRefresher interface { RefreshClientMetadata(ctx context.Context, clientID string) (model.OidcClient, error) } func NewOidcService( db *gorm.DB, jwtService *JwtService, previewBuilder oidcClientPreviewBuilder, metadataRefresher metadataRefresher, scimSyncScheduler ScimSyncScheduler, backchannelLogout *backchannellogout.Service, httpClient *http.Client, fileStorage storage.FileStorage, ) (s *OidcService, err error) { s = &OidcService{ db: db, jwtService: jwtService, previewBuilder: previewBuilder, metadataRefresher: metadataRefresher, scimSyncScheduler: scimSyncScheduler, backchannelLogout: backchannelLogout, httpClient: httpClient, fileStorage: fileStorage, } return s, nil } func (s *OidcService) GetClient(ctx context.Context, clientID string) (model.OidcClient, error) { return s.getClientInternal(ctx, clientID, s.db, false) } // RefreshClientMetadata forces a re-fetch of the OAuth Client ID Metadata Document // for a CIMD client, bypassing the cache TTL, and returns the refreshed client. func (s *OidcService) RefreshClientMetadata(ctx context.Context, clientID string) (model.OidcClient, error) { if s.metadataRefresher == nil { return model.OidcClient{}, apperror.ValidationMessage("Client ID metadata documents are not enabled") } client, err := s.metadataRefresher.RefreshClientMetadata(ctx, clientID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return model.OidcClient{}, err } return model.OidcClient{}, apperror.ValidationMessage(err.Error()) } return client, nil } func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx *gorm.DB, forUpdate bool) (model.OidcClient, error) { var client model.OidcClient q := tx. WithContext(ctx). Preload("CreatedBy"). Preload("AllowedUserGroups") if forUpdate { q = q.Clauses(clause.Locking{Strength: "UPDATE"}) } q = q.First(&client, "id = ?", clientID) if errors.Is(q.Error, gorm.ErrRecordNotFound) { return model.OidcClient{}, apperror.NotFound("OIDC client") } if q.Error != nil { return model.OidcClient{}, q.Error } return client, nil } func (s *OidcService) ListClients(ctx context.Context, name string, listRequestOptions utils.ListRequestOptions) ([]model.OidcClient, utils.PaginationResponse, error) { var clients []model.OidcClient query := s.db. WithContext(ctx). Preload("CreatedBy"). Preload("AllowedUserGroups"). Model(&model.OidcClient{}) if name != "" { query = query.Where("name LIKE ?", "%"+name+"%") } // Sort the allowed user groups relation by its row count because it is not an OIDC client column if listRequestOptions.Sort.Column == "allowedUserGroups" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) { query = query.Select("oidc_clients.*, COUNT(oidc_clients_allowed_user_groups.oidc_client_id)"). Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id"). Group("oidc_clients.id"). Order("COUNT(oidc_clients_allowed_user_groups.oidc_client_id) " + listRequestOptions.Sort.Direction) response, err := utils.Paginate(listRequestOptions.Pagination.Page, listRequestOptions.Pagination.Limit, query, &clients) return clients, response, err } response, err := utils.PaginateFilterAndSort(listRequestOptions, query, &clients) return clients, response, err } func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCreateDto, userID string, autoCreateSecret bool) (model.OidcClient, string, error) { client := model.OidcClient{ Base: model.Base{ ID: input.ID, }, CreatedByID: new(userID), } err := updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto) if err != nil { return model.OidcClient{}, "", err } // Generate the initial credential before saving so a failed generation cannot leave a client without its expected secret var createdSecret string if autoCreateSecret && !client.IsPublic { secret, value, err := newOIDCClientSecret(dto.OidcClientSecretCreateDto{}) if err != nil { return model.OidcClient{}, "", err } client.Credentials.Secrets = append(client.Credentials.Secrets, secret) createdSecret = value } err = s.db. WithContext(ctx). Create(&client). Error if err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return model.OidcClient{}, "", apperror.ClientIDAlreadyExists() } return model.OidcClient{}, "", err } // All storage operations must be executed outside of a transaction if input.LogoURL != nil { err = s.downloadAndSaveLogoFromURL(ctx, client.ID, *input.LogoURL, true) if err != nil { return model.OidcClient{}, "", fmt.Errorf("failed to download logo: %w", err) } } if input.DarkLogoURL != nil { err = s.downloadAndSaveLogoFromURL(ctx, client.ID, *input.DarkLogoURL, false) if err != nil { return model.OidcClient{}, "", fmt.Errorf("failed to download dark logo: %w", err) } } return client, createdSecret, nil } func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input dto.OidcClientUpdateDto) (model.OidcClient, error) { tx := s.db.Begin() defer func() { tx.Rollback() }() client, err := s.getClientInternal(ctx, clientID, tx, true) if err != nil { return model.OidcClient{}, err } wasGroupRestricted := client.IsGroupRestricted err = updateOIDCClientModelFromDto(&client, &input) if err != nil { return model.OidcClient{}, err } if !input.IsGroupRestricted { // Clear allowed user groups if the restriction is removed err = tx.Model(&client).Association("AllowedUserGroups").Clear() if err != nil { return model.OidcClient{}, err } } // Metadata refresh owns all other CIMD columns, so an admin update must never write back a stale metadata snapshot if client.IsMetadataDocument() { err = tx.WithContext(ctx). Model(&client). Select( "Description", "RequiresReauthentication", "RequiresPushedAuthorizationRequests", "SkipConsent", "LaunchURL", "IsGroupRestricted", "AccessTokenDurationMinutes", "RefreshTokenDurationMinutes", ). Updates(&client).Error } else { err = tx.WithContext(ctx).Save(&client).Error } if err != nil { return model.OidcClient{}, err } err = tx.Commit().Error if err != nil { return model.OidcClient{}, err } // Turning on the group restriction revokes access for every authorized user until groups are assigned, so tell their clients to end the sessions if s.backchannelLogout != nil && !wasGroupRestricted && client.IsGroupRestricted { s.backchannelLogout.NotifyLostGroupAccess(ctx, nil, client.ID) } // All storage operations must be executed outside of a transaction if input.LogoURL != nil { err = s.downloadAndSaveLogoFromURL(ctx, client.ID, *input.LogoURL, true) if err != nil { return model.OidcClient{}, fmt.Errorf("failed to download logo: %w", err) } } if input.DarkLogoURL != nil { err = s.downloadAndSaveLogoFromURL(ctx, client.ID, *input.DarkLogoURL, false) if err != nil { return model.OidcClient{}, fmt.Errorf("failed to download dark logo: %w", err) } } return client, nil } func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientUpdateDto) error { // Update fields that remain locally managed for every client type client.Description = input.Description client.RequiresReauthentication = input.RequiresReauthentication client.RequiresPushedAuthorizationRequests = input.RequiresPushedAuthorizationRequests client.SkipConsent = input.SkipConsent client.LaunchURL = input.LaunchURL client.IsGroupRestricted = input.IsGroupRestricted // Token lifetimes are optional, so a zero value falls back to the default client.AccessTokenDurationMinutes = cmp.Or(input.AccessTokenDurationMinutes, model.DefaultAccessTokenDurationMinutes) client.RefreshTokenDurationMinutes = cmp.Or(input.RefreshTokenDurationMinutes, model.DefaultRefreshTokenDurationMinutes) // Preserve fields that are sourced from the client metadata document if client.IsMetadataDocument() { return nil } // Update registration fields for manually configured clients client.Name = input.Name client.CallbackURLs = input.CallbackURLs client.LogoutCallbackURLs = input.LogoutCallbackURLs client.BackchannelLogoutURL = input.BackchannelLogoutURL client.IsPublic = input.IsPublic // PKCE is required for public clients client.PkceEnabled = input.IsPublic || input.PkceEnabled // Reset any PKCE support prompt if previously flagged if !input.PkceEnabled { client.PkceSupported = false } // Replace the federated credentials with the submitted configuration federatedIdentities := make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities)) for i, fi := range input.Credentials.FederatedIdentities { // Validate the public keys before storing them publicKeys, err := jwkutils.NormalizePublicKeys(fi.PublicKeys) if err != nil { return apperror.ValidationMessage(fmt.Sprintf("Federated client credential %d has an invalid public key: %v", i+1, err)) } if len(publicKeys) > 0 && fi.JWKS != "" { return apperror.ValidationMessage(fmt.Sprintf("Federated client credential %d must use either a JWKS URL or public keys, but not both", i+1)) } federatedIdentities[i] = model.OidcClientFederatedIdentity{ Issuer: fi.Issuer, Audience: fi.Audience, Subject: fi.Subject, JWKS: fi.JWKS, PublicKeys: publicKeys, ReplayProtection: fi.ReplayProtection, } } client.Credentials.FederatedIdentities = federatedIdentities return nil } func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error { tx := s.db.Begin() defer func() { tx.Rollback() }() // The authorizations cascade away with the client, so the users to notify must be resolved inside the transaction notifyLogout := func() {} if s.backchannelLogout != nil { var prepareErr error notifyLogout, prepareErr = s.backchannelLogout.PrepareClientNotifications(ctx, tx, clientID) if prepareErr != nil { // Notifications are best effort and must never block the deletion itself slog.ErrorContext(ctx, "Failed to prepare back-channel logout notifications for client", slog.String("clientId", clientID), slog.Any("error", prepareErr)) } } var client model.OidcClient result := tx. WithContext(ctx). Where("id = ?", clientID). Clauses(clause.Returning{}). Delete(&client) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return apperror.NotFound("OIDC client") } err := tx.Commit().Error if err != nil { return err } // The deleted client keeps serving its signed-in users, so tell it to end their sessions notifyLogout() // Delete images if present // Note that storage operations must be done outside of a transaction if client.ImageType != nil && *client.ImageType != "" { old := oidcClientImagePath(client.ID, "", *client.ImageType) _ = s.fileStorage.Delete(ctx, old) } if client.DarkImageType != nil && *client.DarkImageType != "" { old := oidcClientImagePath(client.ID, "-dark", *client.DarkImageType) _ = s.fileStorage.Delete(ctx, old) } return nil } // ListClientSecrets returns all secrets configured for a client, including the expired ones func (s *OidcService) ListClientSecrets(ctx context.Context, clientID string) ([]model.OidcClientSecret, error) { client, err := s.getClientInternal(ctx, clientID, s.db, false) if err != nil { return nil, err } return client.Credentials.Secrets, nil } // CreateClientSecret adds a new secret to a client and returns both the stored record and the secret's value, which is not recoverable afterwards func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string, input dto.OidcClientSecretCreateDto) (model.OidcClientSecret, string, error) { // An expiration date in the past would create a secret that can never be used if input.ExpiresAt != nil && !input.ExpiresAt.ToTime().After(time.Now()) { return model.OidcClientSecret{}, "", apperror.ValidationMessage("The expiration date of a client secret must be in the future") } tx := s.db.Begin() defer func() { tx.Rollback() }() client, err := s.getClientInternal(ctx, clientID, tx, true) if err != nil { return model.OidcClientSecret{}, "", fmt.Errorf("error retrieving OIDC client: %w", err) } if client.IsPublic { return model.OidcClientSecret{}, "", apperror.ValidationMessage("Cannot create a secret for a public client") } if len(client.Credentials.Secrets) >= model.MaxOidcClientSecrets { return model.OidcClientSecret{}, "", apperror.ValidationMessage(fmt.Sprintf("A client cannot have more than %d secrets", model.MaxOidcClientSecrets)) } secret, clientSecret, err := newOIDCClientSecret(input) if err != nil { return model.OidcClientSecret{}, "", err } client.Credentials.Secrets = append(client.Credentials.Secrets, secret) err = tx. WithContext(ctx). Model(&client). Select("Credentials"). Updates(&client). Error if err != nil { return model.OidcClientSecret{}, "", fmt.Errorf("failed to update OIDC client: %w", err) } err = tx.Commit().Error if err != nil { return model.OidcClientSecret{}, "", fmt.Errorf("failed to commit transaction: %w", err) } return secret, clientSecret, nil } // newOIDCClientSecret keeps the generated value transient while persisting only its hash and prefix func newOIDCClientSecret(input dto.OidcClientSecretCreateDto) (model.OidcClientSecret, string, error) { clientSecret := input.Secret if clientSecret == "" { var err error clientSecret, err = utils.GenerateRandomAlphanumericString(32) if err != nil { return model.OidcClientSecret{}, "", fmt.Errorf("failed to generate client secret: %w", err) } } secret := model.OidcClientSecret{ ID: uuid.NewV4().String(), Algorithm: model.OidcClientSecretHashSHA256, Hash: utils.CreateSha256Hash(clientSecret), Prefix: clientSecretPrefix(clientSecret), CreatedAt: datatype.DateTime(time.Now()), ExpiresAt: input.ExpiresAt, } return secret, clientSecret, nil } // DeleteClientSecret removes a single secret from a client, making it immediately unusable func (s *OidcService) DeleteClientSecret(ctx context.Context, clientID string, secretID string) error { tx := s.db.Begin() defer func() { tx.Rollback() }() client, err := s.getClientInternal(ctx, clientID, tx, true) if err != nil { return fmt.Errorf("error retrieving OIDC client: %w", err) } countBefore := len(client.Credentials.Secrets) client.Credentials.Secrets = slices.DeleteFunc(client.Credentials.Secrets, func(secret model.OidcClientSecret) bool { return secret.ID == secretID }) if len(client.Credentials.Secrets) == countBefore { return apperror.NotFound("Client secret") } err = tx. WithContext(ctx). Model(&client). Select("Credentials"). Updates(&client). Error if err != nil { return fmt.Errorf("failed to update OIDC client: %w", err) } err = tx.Commit().Error if err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } return nil } // clientSecretPrefix returns the leading characters of a secret that are stored in clear text to help admins tell secrets apart func clientSecretPrefix(clientSecret string) string { if len(clientSecret) <= model.OidcClientSecretPrefixLength { return "" } return clientSecret[:model.OidcClientSecretPrefixLength] } func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light bool) (io.ReadCloser, int64, string, error) { client, err := s.getClientInternal(ctx, clientID, s.db, false) if err != nil { return nil, 0, "", err } var suffix string var ext string switch { case !light && client.DarkImageType != nil: // Dark logo if requested and exists suffix = "-dark" ext = *client.DarkImageType case client.ImageType != nil: // Light logo if requested or no dark logo is available ext = *client.ImageType default: return nil, 0, "", apperror.ImageNotFound() } mimeType := utils.GetImageMimeType(ext) if mimeType == "" { return nil, 0, "", fmt.Errorf("unsupported image type '%s'", ext) } key := oidcClientImagePath(client.ID, suffix, ext) reader, size, err := s.fileStorage.Open(ctx, key) if err != nil { if storage.IsNotExist(err) { return nil, 0, "", apperror.ImageNotFound() } return nil, 0, "", err } return reader, size, mimeType, nil } func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, file *multipart.FileHeader, light bool) error { fileType := strings.ToLower(utils.GetFileExtension(file.Filename)) if mimeType := utils.GetImageMimeType(fileType); mimeType == "" { return apperror.UnsupportedFileType("") } var darkSuffix string if !light { darkSuffix = "-dark" } imagePath := oidcClientImagePath(clientID, darkSuffix, fileType) reader, err := file.Open() if err != nil { return err } defer reader.Close() strippedReader, err := imageutil.StripMetadata(reader, fileType) if errors.Is(err, imageutil.ErrInvalidImage) { return apperror.InvalidImage(err) } if err != nil { return err } err = s.fileStorage.Save(ctx, imagePath, strippedReader) if err != nil { return err } err = s.updateClientLogoType(ctx, clientID, fileType, light) if err != nil { return err } return nil } func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) error { return s.deleteClientLogoInternal(ctx, clientID, "", func(client *model.OidcClient) (string, error) { if client.ImageType == nil { return "", apperror.ImageNotFound() } oldImageType := *client.ImageType client.ImageType = nil return oldImageType, nil }) } func (s *OidcService) DeleteClientDarkLogo(ctx context.Context, clientID string) error { return s.deleteClientLogoInternal(ctx, clientID, "-dark", func(client *model.OidcClient) (string, error) { if client.DarkImageType == nil { return "", apperror.ImageNotFound() } oldImageType := *client.DarkImageType client.DarkImageType = nil return oldImageType, nil }) } func (s *OidcService) deleteClientLogoInternal(ctx context.Context, clientID string, imagePathSuffix string, setClientImage func(*model.OidcClient) (string, error)) error { tx := s.db.Begin() defer func() { tx.Rollback() }() client, err := s.getClientInternal(ctx, clientID, tx, true) if err != nil { return err } oldImageType, err := setClientImage(&client) if err != nil { return err } err = tx. WithContext(ctx). Save(&client). Error if err != nil { return err } err = tx.Commit().Error if err != nil { return err } // All storage operations must be performed outside of a database transaction imagePath := oidcClientImagePath(client.ID, imagePathSuffix, oldImageType) err = s.fileStorage.Delete(ctx, imagePath) if err != nil { return err } return nil } func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, input dto.OidcUpdateAllowedUserGroupsDto) (client model.OidcClient, err error) { tx := s.db.Begin() defer func() { tx.Rollback() }() client, err = s.getClientInternal(ctx, id, tx, true) if err != nil { return model.OidcClient{}, err } // Fetch the user groups based on UserGroupIDs in input var groups []model.UserGroup if len(input.UserGroupIDs) > 0 { err = tx. WithContext(ctx). Where("id IN (?)", input.UserGroupIDs). Find(&groups). Error if err != nil { return model.OidcClient{}, err } } // Replace the current user groups with the new set of user groups err = tx. WithContext(ctx). Model(&client). Association("AllowedUserGroups"). Replace(groups) if err != nil { return model.OidcClient{}, err } // Save the updated client err = tx. WithContext(ctx). Save(&client). Error if err != nil { return model.OidcClient{}, err } err = tx.Commit().Error if err != nil { return model.OidcClient{}, err } if s.scimSyncScheduler != nil { s.scimSyncScheduler.ScheduleSync(ctx) } // Notify users who authorized this client but are no longer in any allowed group if s.backchannelLogout != nil && client.IsGroupRestricted { s.backchannelLogout.NotifyLostGroupAccess(ctx, nil, client.ID) } return client, nil } func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) { tx := s.db.Begin() defer func() { tx.Rollback() }() var user model.User err := tx. WithContext(ctx). Select("id"). First(&user, "id = ?", userID). Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, utils.PaginationResponse{}, apperror.UserNotFound() } if err != nil { return nil, utils.PaginationResponse{}, err } query := tx. WithContext(ctx). Model(&model.UserAuthorizedOidcClient{}). Preload("Client"). Where("user_id = ?", userID) // Apply the launch URL filter before pagination so hidden authorizations have their own page count if hasLaunchURL, ok := getHasLaunchURLFilter(listRequestOptions); ok { query = query.Joins("JOIN oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id") if hasLaunchURL { query = query.Where("oidc_clients.launch_url IS NOT NULL AND oidc_clients.launch_url <> ''") } else { query = query.Where("oidc_clients.launch_url IS NULL OR oidc_clients.launch_url = ''") } } var authorizedClients []model.UserAuthorizedOidcClient response, err := utils.PaginateFilterAndSort(listRequestOptions, query, &authorizedClients) return authorizedClients, response, err } func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string, clientID string) error { tx := s.db.Begin() defer func() { tx.Rollback() }() var authorizedClient model.UserAuthorizedOidcClient err := tx. WithContext(ctx). Where("user_id = ? AND client_id = ?", userID, clientID). First(&authorizedClient).Error if errors.Is(err, gorm.ErrRecordNotFound) { return apperror.NotFound("Client authorization") } if err != nil { return err } // The authorization is gone after the delete, so the client to notify must be resolved inside the transaction notifyLogout := func() {} if s.backchannelLogout != nil { notifyLogout, err = s.backchannelLogout.PrepareAuthorizationNotification(ctx, tx, userID, clientID) if err != nil { // Notifications are best effort and must never block the revocation itself slog.ErrorContext(ctx, "Failed to prepare back-channel logout notification for authorization", slog.String("userId", userID), slog.String("clientId", clientID), slog.Any("error", err)) } } err = tx.WithContext(ctx).Delete(&authorizedClient).Error if err != nil { return err } if err = oidc.RevokeUserClientSessions(ctx, tx, userID, clientID); err != nil { return err } err = tx.Commit().Error if err != nil { return err } // Tell the client to end the user's session there as well notifyLogout() return nil } func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]dto.AccessibleOidcClientDto, utils.PaginationResponse, error) { tx := s.db.Begin() defer func() { tx.Rollback() }() var user model.User err := tx. WithContext(ctx). Preload("UserGroups"). First(&user, "id = ?", userID). Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, utils.PaginationResponse{}, apperror.UserNotFound() } if err != nil { return nil, utils.PaginationResponse{}, err } userGroupIDs := make([]string, len(user.UserGroups)) for i, group := range user.UserGroups { userGroupIDs[i] = group.ID } // Build the query for accessible clients query := tx. WithContext(ctx). Model(&model.OidcClient{}). Preload("UserAuthorizedOidcClients", "user_id = ?", userID). Where(`oidc_clients.is_group_restricted = ? OR EXISTS ( SELECT 1 FROM oidc_clients_allowed_user_groups WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id AND oidc_clients_allowed_user_groups.user_group_id IN (?))`, false, userGroupIDs) // Apply the launch URL filter before pagination so the app launcher never contains empty pages if hasLaunchURL, ok := getHasLaunchURLFilter(listRequestOptions); ok { if hasLaunchURL { query = query.Where("oidc_clients.launch_url IS NOT NULL AND oidc_clients.launch_url <> ''") } else { query = query.Where("oidc_clients.launch_url IS NULL OR oidc_clients.launch_url = ''") } } var clients []model.OidcClient // Handle custom sorting for lastUsedAt column var response utils.PaginationResponse if listRequestOptions.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) { query = query. Joins("LEFT JOIN user_authorized_oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id AND user_authorized_oidc_clients.user_id = ?", userID). Order("user_authorized_oidc_clients.last_used_at " + listRequestOptions.Sort.Direction + " NULLS LAST") } response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &clients) if err != nil { return nil, utils.PaginationResponse{}, err } dtos := make([]dto.AccessibleOidcClientDto, len(clients)) for i, client := range clients { var lastUsedAt *datatype.DateTime if len(client.UserAuthorizedOidcClients) > 0 { lastUsedAt = &client.UserAuthorizedOidcClients[0].LastUsedAt } dtos[i] = dto.AccessibleOidcClientDto{ OidcClientMetaDataDto: dto.OidcClientMetaDataDto{ ID: client.ID, Name: client.Name, Description: client.Description, LaunchURL: client.LaunchURL, HasLogo: client.HasLogo(), HasDarkLogo: client.HasDarkLogo(), ClientType: string(client.ClientType), }, LastUsedAt: lastUsedAt, } } return dtos, response, err } func getHasLaunchURLFilter(listRequestOptions utils.ListRequestOptions) (bool, bool) { values := listRequestOptions.Filters["hasLaunchURL"] if len(values) == 0 { return false, false } hasLaunchURL, ok := values[0].(bool) return hasLaunchURL, ok } func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes []string, authenticationMethod string) (*dto.OidcClientPreviewDto, error) { client, err := s.getClientInternal(ctx, clientID, s.db, false) if err != nil { return nil, err } var user model.User err = s.db. WithContext(ctx). Preload("UserGroups"). First(&user, "id = ?", userID). Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, apperror.UserNotFound() } if err != nil { return nil, err } if !oidc.IsUserGroupAllowedToAuthorize(user, client) { return nil, apperror.OidcAccessDenied() } preview, err := s.previewBuilder.BuildClientPreview(ctx, client, userID, scopes, authenticationMethod) if err != nil { return nil, err } return &dto.OidcClientPreviewDto{ IdToken: preview.IDToken, AccessToken: preview.AccessToken, UserInfo: preview.UserInfo, }, nil } func httpClientWithCheckRedirect(source *http.Client, checkRedirect func(req *http.Request, via []*http.Request) error) *http.Client { if source == nil { source = http.DefaultClient } // Create a new client that clones the transport client := &http.Client{ Transport: source.Transport, } // Assign the CheckRedirect function client.CheckRedirect = checkRedirect return client } func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clientID string, raw string, light bool) error { u, err := url.Parse(raw) if err != nil { return apperror.InvalidLogoURL(err) } if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return apperror.InvalidLogoURL(fmt.Errorf("URL must use HTTP or HTTPS and include a host")) } ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second) defer cancel() // Prevents SSRF by allowing only public IPs ok, err := utils.IsURLPrivate(ctx, u) if err != nil { return apperror.LogoDownloadFailed(err) } else if ok { return apperror.InvalidLogoURL(errors.New("private IP addresses are not allowed")) } // We need to check this on redirects too client := httpClientWithCheckRedirect(s.httpClient, func(r *http.Request, via []*http.Request) error { if len(via) >= 10 { return apperror.InvalidLogoURL(errors.New("stopped after 10 redirects")) } ok, err := utils.IsURLPrivate(r.Context(), r.URL) if err != nil { return err } else if ok { return apperror.InvalidLogoURL(errors.New("private IP addresses are not allowed")) } return nil }) req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil) if err != nil { return apperror.InvalidLogoURL(err) } req.Header.Set("User-Agent", "pocket-id/oidc-logo-fetcher") req.Header.Set("Accept", "image/*") resp, err := client.Do(req) if err != nil { if appErr, ok := errors.AsType[*apperror.Error](err); ok { return appErr } return apperror.LogoDownloadFailed(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return apperror.LogoDownloadFailed(fmt.Errorf("logo server returned %s", resp.Status)) } const maxLogoSize int64 = 2 * 1024 * 1024 // 2MB if resp.ContentLength > maxLogoSize { return apperror.LogoTooLarge("2 MB") } // Prefer extension in path if supported ext := utils.GetFileExtension(u.Path) if ext == "" || utils.GetImageMimeType(ext) == "" { // Otherwise, try to detect from content type ext = utils.GetImageExtensionFromMimeType(resp.Header.Get("Content-Type")) } if ext == "" { return apperror.LogoTypeNotSupported() } var darkSuffix string if !light { darkSuffix = "-dark" } limitReader := utils.NewLimitReader(resp.Body, maxLogoSize+1) strippedReader, err := imageutil.StripMetadata(limitReader, ext) if errors.Is(err, utils.ErrSizeExceeded) { return apperror.LogoTooLarge("2 MB") } else if err != nil { return apperror.LogoDownloadFailed(err) } imagePath := oidcClientImagePath(clientID, darkSuffix, ext) err = s.fileStorage.Save(ctx, imagePath, strippedReader) if errors.Is(err, utils.ErrSizeExceeded) { return apperror.LogoTooLarge("2 MB") } else if err != nil { return apperror.LogoDownloadFailed(err) } err = s.updateClientLogoType(ctx, clientID, ext, light) if err != nil { return err } return nil } func (s *OidcService) updateClientLogoType(ctx context.Context, clientID string, ext string, light bool) error { var darkSuffix string if !light { darkSuffix = "-dark" } tx := s.db.Begin() defer func() { tx.Rollback() }() // We need to acquire an update lock for the row to be locked, since we'll update it later var client model.OidcClient err := tx. WithContext(ctx). Clauses(clause.Locking{Strength: "UPDATE"}). First(&client, "id = ?", clientID). Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return apperror.NotFound("OIDC client") } return fmt.Errorf("failed to look up client: %w", err) } var currentType *string if light { currentType = client.ImageType client.ImageType = &ext } else { currentType = client.DarkImageType client.DarkImageType = &ext } err = tx. WithContext(ctx). Save(&client). Error if err != nil { return fmt.Errorf("failed to save updated client: %w", err) } err = tx.Commit().Error if err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } // Storage operations must be executed outside of a transaction if currentType != nil && *currentType != ext { old := oidcClientImagePath(client.ID, darkSuffix, *currentType) _ = s.fileStorage.Delete(ctx, old) } return nil } func oidcClientImagePath(clientID string, suffix string, extension string) string { storageID := clientID if !dto.ValidateClientID(clientID) { storageID = "cimd-" + utils.CreateSha256Hash(clientID) } return path.Join("oidc-client-images", storageID+suffix+"."+extension) }