mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-24 21:17:31 +00:00
847 lines
22 KiB
Go
847 lines
22 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
|
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
|
"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"
|
|
)
|
|
|
|
const (
|
|
GrantTypeAuthorizationCode = "authorization_code"
|
|
GrantTypeRefreshToken = "refresh_token"
|
|
GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
|
|
GrantTypeClientCredentials = "client_credentials"
|
|
|
|
AccessTokenDuration = time.Hour
|
|
RefreshTokenDuration = 30 * 24 * time.Hour // 30 days
|
|
)
|
|
|
|
type OidcService struct {
|
|
db *gorm.DB
|
|
jwtService *JwtService
|
|
previewBuilder oidcClientPreviewBuilder
|
|
scimService *ScimService
|
|
|
|
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)
|
|
}
|
|
|
|
func NewOidcService(
|
|
db *gorm.DB,
|
|
jwtService *JwtService,
|
|
previewBuilder oidcClientPreviewBuilder,
|
|
scimService *ScimService,
|
|
httpClient *http.Client,
|
|
fileStorage storage.FileStorage,
|
|
) (s *OidcService, err error) {
|
|
s = &OidcService{
|
|
db: db,
|
|
jwtService: jwtService,
|
|
previewBuilder: previewBuilder,
|
|
scimService: scimService,
|
|
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)
|
|
}
|
|
|
|
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 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").
|
|
Model(&model.OidcClient{})
|
|
|
|
if name != "" {
|
|
query = query.Where("name LIKE ?", "%"+name+"%")
|
|
}
|
|
|
|
// As allowedUserGroupsCount is not a column, we need to manually sort it
|
|
if listRequestOptions.SortColumn == "allowedUserGroupsCount" && utils.IsValidSortDirection(listRequestOptions.SortDirection) {
|
|
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.SortDirection)
|
|
|
|
response, err := utils.Paginate(listRequestOptions.Page, listRequestOptions.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) (model.OidcClient, error) {
|
|
client := model.OidcClient{
|
|
Base: model.Base{
|
|
ID: input.ID,
|
|
},
|
|
CreatedByID: new(userID),
|
|
}
|
|
updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto)
|
|
|
|
err := s.db.
|
|
WithContext(ctx).
|
|
Create(&client).
|
|
Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
|
return model.OidcClient{}, &common.ClientIdAlreadyExistsError{}
|
|
}
|
|
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, nil
|
|
}
|
|
|
|
func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input dto.OidcClientUpdateDto) (model.OidcClient, error) {
|
|
tx := s.db.Begin()
|
|
defer func() {
|
|
tx.Rollback()
|
|
}()
|
|
|
|
var client model.OidcClient
|
|
err := tx.WithContext(ctx).
|
|
Preload("CreatedBy").
|
|
First(&client, "id = ?", clientID).Error
|
|
if err != nil {
|
|
return model.OidcClient{}, err
|
|
}
|
|
|
|
updateOIDCClientModelFromDto(&client, &input)
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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) {
|
|
dto.Normalize(input)
|
|
|
|
// Base fields
|
|
client.Name = input.Name
|
|
client.Description = input.Description
|
|
client.CallbackURLs = input.CallbackURLs
|
|
client.LogoutCallbackURLs = input.LogoutCallbackURLs
|
|
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
|
|
}
|
|
client.RequiresReauthentication = input.RequiresReauthentication
|
|
client.RequiresPushedAuthorizationRequests = input.RequiresPushedAuthorizationRequests
|
|
client.SkipConsent = input.SkipConsent
|
|
client.LaunchURL = input.LaunchURL
|
|
client.IsGroupRestricted = input.IsGroupRestricted
|
|
|
|
// Credentials
|
|
client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities))
|
|
for i, fi := range input.Credentials.FederatedIdentities {
|
|
client.Credentials.FederatedIdentities[i] = model.OidcClientFederatedIdentity{
|
|
Issuer: fi.Issuer,
|
|
Audience: fi.Audience,
|
|
Subject: fi.Subject,
|
|
JWKS: fi.JWKS,
|
|
ReplayProtection: fi.ReplayProtection,
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error {
|
|
var client model.OidcClient
|
|
err := s.db.
|
|
WithContext(ctx).
|
|
Where("id = ?", clientID).
|
|
Clauses(clause.Returning{}).
|
|
Delete(&client).
|
|
Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Delete images if present
|
|
// Note that storage operations must be done outside of a transaction
|
|
if client.ImageType != nil && *client.ImageType != "" {
|
|
old := path.Join("oidc-client-images", client.ID+"."+*client.ImageType)
|
|
_ = s.fileStorage.Delete(ctx, old)
|
|
}
|
|
if client.DarkImageType != nil && *client.DarkImageType != "" {
|
|
old := path.Join("oidc-client-images", client.ID+"-dark."+*client.DarkImageType)
|
|
_ = s.fileStorage.Delete(ctx, old)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string, input dto.OidcClientSecretDto) (string, error) {
|
|
tx := s.db.Begin()
|
|
defer func() {
|
|
tx.Rollback()
|
|
}()
|
|
|
|
var client model.OidcClient
|
|
err := tx.
|
|
WithContext(ctx).
|
|
First(&client, "id = ?", clientID).
|
|
Error
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
clientSecret := input.Secret
|
|
if clientSecret == "" {
|
|
clientSecret, err = utils.GenerateRandomAlphanumericString(32)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(clientSecret), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
client.Secret = string(hashedSecret)
|
|
err = tx.
|
|
WithContext(ctx).
|
|
Save(&client).
|
|
Error
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
err = tx.Commit().Error
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return clientSecret, nil
|
|
}
|
|
|
|
func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light bool) (io.ReadCloser, int64, string, error) {
|
|
var client model.OidcClient
|
|
err := s.db.
|
|
WithContext(ctx).
|
|
First(&client, "id = ?", clientID).
|
|
Error
|
|
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, "", errors.New("image not found")
|
|
}
|
|
|
|
mimeType := utils.GetImageMimeType(ext)
|
|
if mimeType == "" {
|
|
return nil, 0, "", fmt.Errorf("unsupported image type '%s'", ext)
|
|
}
|
|
key := path.Join("oidc-client-images", client.ID+suffix+"."+ext)
|
|
reader, size, err := s.fileStorage.Open(ctx, key)
|
|
if err != nil {
|
|
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 &common.FileTypeNotSupportedError{}
|
|
}
|
|
|
|
var darkSuffix string
|
|
if !light {
|
|
darkSuffix = "-dark"
|
|
}
|
|
|
|
imagePath := path.Join("oidc-client-images", clientID+darkSuffix+"."+fileType)
|
|
reader, err := file.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer reader.Close()
|
|
strippedReader, err := imageutil.StripMetadata(reader, fileType)
|
|
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 "", errors.New("image not found")
|
|
}
|
|
|
|
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 "", errors.New("image not found")
|
|
}
|
|
|
|
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()
|
|
}()
|
|
|
|
var client model.OidcClient
|
|
err := tx.
|
|
WithContext(ctx).
|
|
First(&client, "id = ?", clientID).
|
|
Error
|
|
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 := path.Join("oidc-client-images", 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
|
|
}
|
|
|
|
s.scimService.ScheduleSync()
|
|
return client, nil
|
|
}
|
|
|
|
func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id string) (int64, error) {
|
|
// We only perform select queries here, so we can rollback in all cases
|
|
tx := s.db.Begin()
|
|
defer func() {
|
|
tx.Rollback()
|
|
}()
|
|
|
|
var client model.OidcClient
|
|
err := tx.WithContext(ctx).Where("id = ?", id).First(&client).Error
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
count := tx.WithContext(ctx).Model(&client).Association("AllowedUserGroups").Count()
|
|
return count, nil
|
|
}
|
|
|
|
func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) {
|
|
|
|
query := s.db.
|
|
WithContext(ctx).
|
|
Model(&model.UserAuthorizedOidcClient{}).
|
|
Preload("Client").
|
|
Where("user_id = ?", userID)
|
|
|
|
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 err != nil {
|
|
return 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
|
|
}
|
|
|
|
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 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)
|
|
|
|
var clients []model.OidcClient
|
|
|
|
// Handle custom sorting for lastUsedAt column
|
|
var response utils.PaginationResponse
|
|
if listRequestOptions.SortColumn == "lastUsedAt" && utils.IsValidSortDirection(listRequestOptions.SortDirection) {
|
|
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.SortDirection + " 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(),
|
|
},
|
|
LastUsedAt: lastUsedAt,
|
|
}
|
|
}
|
|
|
|
return dtos, response, err
|
|
}
|
|
|
|
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 err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !oidc.IsUserGroupAllowedToAuthorize(user, client) {
|
|
return nil, &common.OidcAccessDeniedError{}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var errLogoTooLarge = errors.New("logo is too large")
|
|
|
|
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 err
|
|
}
|
|
|
|
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 err
|
|
} else if ok {
|
|
return 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 errors.New("stopped after 10 redirects")
|
|
}
|
|
|
|
ok, err := utils.IsURLPrivate(r.Context(), r.URL)
|
|
if err != nil {
|
|
return err
|
|
} else if ok {
|
|
return errors.New("private IP addresses are not allowed")
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("User-Agent", "pocket-id/oidc-logo-fetcher")
|
|
req.Header.Set("Accept", "image/*")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("failed to fetch logo: %s", resp.Status)
|
|
}
|
|
|
|
const maxLogoSize int64 = 2 * 1024 * 1024 // 2MB
|
|
if resp.ContentLength > maxLogoSize {
|
|
return errLogoTooLarge
|
|
}
|
|
|
|
// 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 &common.FileTypeNotSupportedError{}
|
|
}
|
|
|
|
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 errLogoTooLarge
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
|
|
imagePath := path.Join("oidc-client-images", clientID+darkSuffix+"."+ext)
|
|
err = s.fileStorage.Save(ctx, imagePath, strippedReader)
|
|
if errors.Is(err, utils.ErrSizeExceeded) {
|
|
return errLogoTooLarge
|
|
} else if err != nil {
|
|
return 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 {
|
|
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 := path.Join("oidc-client-images", client.ID+darkSuffix+"."+*currentType)
|
|
_ = s.fileStorage.Delete(ctx, old)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *OidcService) GetClientScimServiceProvider(ctx context.Context, clientID string) (model.ScimServiceProvider, error) {
|
|
var provider model.ScimServiceProvider
|
|
err := s.db.
|
|
WithContext(ctx).
|
|
First(&provider, "oidc_client_id = ?", clientID).
|
|
Error
|
|
if err != nil {
|
|
return model.ScimServiceProvider{}, err
|
|
}
|
|
|
|
return provider, nil
|
|
}
|