Files
pocket-id/backend/internal/oidc/authorization_service.go
T
97bd466f38 feat: prompt admin with PKCE client support hint (#1499)
Co-authored-by: james <james@goldfish.net>
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2026-06-28 11:10:30 -07:00

730 lines
26 KiB
Go

package oidc
import (
"context"
"errors"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/common"
"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/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func newAuthorizationService(db *gorm.DB, interactionSessionService *interactionSessionService, claimsService *ClaimsService, reauth ReauthenticationTokenConsumer, auditLog AuditLogger) *authorizationService {
return &authorizationService{
db: db,
interactionSessionService: interactionSessionService,
claimsService: claimsService,
reauth: reauth,
auditLog: auditLog,
}
}
type authorizationService struct {
db *gorm.DB
interactionSessionService *interactionSessionService
claimsService *ClaimsService
reauth ReauthenticationTokenConsumer
auditLog AuditLogger
}
type requestMeta struct {
IPAddress string
UserAgent string
}
type authorizationResult struct {
RequiresInteraction bool
InteractionID string
Session *Session
}
type promptValues []string
func newPromptValues(prompt string) promptValues {
return strings.Fields(prompt)
}
func (p promptValues) has(value string) bool {
return slices.Contains(p, value)
}
// consentRequired reports whether the user has to be shown the consent screen
// A client can be configured to skip consent so trusted first-party apps are not prompted on every new authorization, but an explicit prompt=consent always forces the screen regardless of that setting
func consentRequired(hasAlreadyAuthorizedClient, clientSkipsConsent bool, prompt promptValues) bool {
if prompt.has("consent") {
return true
}
return !hasAlreadyAuthorizedClient && !clientSkipsConsent
}
// authorizeInput is the authorization request as provided by the handler.
type authorizeInput struct {
userID string
authenticationMethod string
authenticationTime time.Time
requester fosite.AuthorizeRequester
hasPushedAuthorizationRequest bool
reauthenticationToken string
interactionID string
requestParams map[string]string
meta requestMeta
}
// authorizeRequest is the input enriched with everything the service derives from it.
type authorizeRequest struct {
authorizeInput
client Client
prompt promptValues
interactionSession *InteractionSession
now time.Time
}
func (s *authorizationService) authorize(ctx context.Context, input authorizeInput) (authorizationResult, error) {
client := input.requester.GetClient().(Client)
prompt := newPromptValues(input.requester.GetRequestForm().Get("prompt"))
if err := validateClientPKCERequirement(client, input.requester); err != nil {
return authorizationResult{}, err
}
interactionSession, err := s.boundInteractionSession(ctx, input.interactionID, input.userID, client, input.requester)
if err != nil {
return authorizationResult{}, err
}
// Reject authorization requests that require PAR when the request is not a resumed interaction and doesn't have a valid PAR.
if client.RequiresPushedAuthorizationRequests && !input.hasPushedAuthorizationRequest && interactionSession == nil {
return authorizationResult{}, &common.OidcPARRequiredError{}
}
if input.userID == "" {
if prompt.has("none") {
return authorizationResult{}, fosite.ErrLoginRequired
}
interactionSession, err := s.createInteractionSession(ctx, input.requester, input.requestParams, "", interactionRequirements{
AuthenticationRequired: true,
ReauthenticationRequired: prompt.has("login") || client.RequiresReauthentication,
AccountSelectionRequired: prompt.has("select_account"),
ConsentRequired: prompt.has("consent"),
})
if err != nil {
return authorizationResult{}, err
}
return authorizationResult{RequiresInteraction: true, InteractionID: interactionSession.ID}, nil
}
req := authorizeRequest{
authorizeInput: input,
client: client,
prompt: prompt,
interactionSession: interactionSession,
now: time.Now().UTC(),
}
codeChallenge := input.requester.GetRequestForm().Get("code_challenge")
var result authorizationResult
err = withTx(ctx, s.db, func(ctx context.Context) error {
var err error
result, err = s.authorizeAuthenticated(ctx, req)
if err != nil {
return err
}
if codeChallenge != "" &&
!client.PkceEnabled &&
!client.PkceSupported {
tx := dbFromContext(ctx, s.db)
_ = flagPkceSupportedClient(ctx, client.GetID(), tx)
}
return nil
})
if err != nil {
return authorizationResult{}, err
}
if result.Session == nil {
return result, nil
}
if err := s.claimsService.applyIDTokenClaims(ctx, result.Session, input.requester.GetGrantedScopes()); err != nil {
return authorizationResult{}, err
}
return result, nil
}
// authorizeAuthenticated either reports the interaction the user still has to complete or grants the request.
func (s *authorizationService) authorizeAuthenticated(ctx context.Context, req authorizeRequest) (authorizationResult, error) {
var user model.User
err := dbFromContext(ctx, s.db).
Preload("UserGroups").
First(&user, "id = ?", req.userID).
Error
if err != nil {
return authorizationResult{}, err
}
if !IsUserGroupAllowedToAuthorize(user, req.client.OidcClient) {
return authorizationResult{}, fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.")
}
interactionSession := req.interactionSession
if interactionSession != nil && interactionSession.UserID != nil && *interactionSession.UserID != req.userID {
if err := s.switchInteractionSessionUser(ctx, interactionSession, req.userID, req.authenticationTime); err != nil {
return authorizationResult{}, err
}
if err := s.interactionSessionService.update(ctx, *interactionSession); err != nil {
return authorizationResult{}, err
}
}
requirements, authenticationTime, err := s.resolveRequirements(ctx, req, interactionSession)
if err != nil {
return authorizationResult{}, err
}
if requirements.any() {
if interactionSession != nil {
return authorizationResult{RequiresInteraction: true, InteractionID: interactionSession.ID}, nil
}
created, err := s.createInteractionSession(ctx, req.requester, req.requestParams, req.userID, requirements)
if err != nil {
return authorizationResult{}, err
}
return authorizationResult{RequiresInteraction: true, InteractionID: created.ID}, nil
}
if interactionSession != nil {
err := s.interactionSessionService.delete(ctx, interactionSession.ID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return authorizationResult{}, fosite.ErrInvalidRequest.WithHint("The interaction session has already been used.")
}
if err != nil {
return authorizationResult{}, err
}
}
hasAlreadyAuthorizedClient, err := s.consent(ctx, req.userID, req.client.GetID(), req.requester.GetRequestedScopes())
if err != nil {
return authorizationResult{}, err
}
session := s.buildAuthorizedSession(req, interactionSession, authenticationTime)
for _, scope := range req.requester.GetRequestedScopes() {
req.requester.GrantScope(scope)
}
authorizationEvent := model.AuditLogEventClientAuthorization
if !hasAlreadyAuthorizedClient {
authorizationEvent = model.AuditLogEventNewClientAuthorization
}
if s.auditLog != nil {
s.auditLog.Create(ctx, authorizationEvent, req.meta.IPAddress, req.meta.UserAgent, req.userID, model.AuditLogData{"clientName": req.client.Name}, dbFromContext(ctx, s.db))
}
return authorizationResult{Session: session}, nil
}
func flagPkceSupportedClient(ctx context.Context, clientID string, tx *gorm.DB) error {
err := tx.
WithContext(ctx).
Model(&model.OidcClient{}).
Where("id = ?", clientID).
Update("pkce_supported", true).
Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return nil
}
// resolveRequirements determines the interaction steps still required; the requirements
// of a resumed interaction session win over the ones derived from the request.
func (s *authorizationService) resolveRequirements(ctx context.Context, req authorizeRequest, interactionSession *InteractionSession) (interactionRequirements, time.Time, error) {
authenticationTime := req.authenticationTime
hasAlreadyAuthorizedClient, err := s.hasAuthorizedClient(ctx, req.client.GetID(), req.userID, req.requester.GetRequestedScopes())
if err != nil {
return interactionRequirements{}, authenticationTime, err
}
maxAgeReauthenticationRequired, err := requiresReauthenticationForMaxAge(req.requester.GetRequestForm().Get("max_age"), authenticationTime, req.now)
if err != nil {
return interactionRequirements{}, authenticationTime, err
}
requirements := interactionRequirements{
ConsentRequired: consentRequired(hasAlreadyAuthorizedClient, req.client.SkipConsent, req.prompt),
ReauthenticationRequired: req.prompt.has("login") || req.client.RequiresReauthentication || maxAgeReauthenticationRequired,
AccountSelectionRequired: req.prompt.has("select_account"),
AuthenticationRequired: false,
}
if interactionSession != nil {
requirements = interactionRequirements{
ConsentRequired: interactionSession.ConsentRequired,
ReauthenticationRequired: interactionSession.ReauthenticationRequired,
AccountSelectionRequired: interactionSession.AccountSelectionRequired,
AuthenticationRequired: interactionSession.AuthenticationRequired,
}
if interactionSession.ReauthenticatedAt != nil {
authenticationTime = interactionSession.ReauthenticatedAt.UTC()
}
}
if req.prompt.has("none") && requirements.ConsentRequired {
return interactionRequirements{}, authenticationTime, fosite.ErrConsentRequired
}
if req.prompt.has("none") && requirements.ReauthenticationRequired {
return interactionRequirements{}, authenticationTime, fosite.ErrLoginRequired
}
if requirements.ReauthenticationRequired && req.reauthenticationToken != "" && s.reauth != nil {
reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), req.reauthenticationToken, req.userID)
if err == nil {
requirements.ReauthenticationRequired = false
authenticationTime = reauthenticatedAt
}
}
return requirements, authenticationTime, nil
}
func (s *authorizationService) buildAuthorizedSession(req authorizeRequest, interactionSession *InteractionSession, authenticationTime time.Time) *Session {
if authenticationTime.IsZero() {
authenticationTime = req.now
}
requestedAt := req.requester.GetRequestedAt()
if interactionSession != nil && !interactionSession.RequestedAt.ToTime().IsZero() {
requestedAt = interactionSession.RequestedAt.UTC()
}
if requestedAt.IsZero() {
requestedAt = req.now
}
return NewAuthenticatedSession(req.userID, req.authenticationMethod, authenticationTime, requestedAt)
}
// interactionRequestQuery returns the authorize parameters stored for the interaction
// session, so the handler can rebuild the re-entry request server-side.
func (s *authorizationService) interactionRequestQuery(ctx context.Context, interactionID string) (query url.Values, err error) {
interactionSession, err := s.interactionSessionService.get(ctx, interactionID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fosite.ErrInvalidRequest.WithHint("The interaction session is invalid or has expired.")
}
if err != nil {
return nil, err
}
query = url.Values{}
for key, value := range interactionSession.Parameters {
if value != "" {
query.Set(key, value)
}
}
query.Set("interaction", interactionSession.ID)
return query, nil
}
// boundInteractionSession loads the referenced interaction session and validates the request against it.
func (s *authorizationService) boundInteractionSession(ctx context.Context, interactionID string, userID string, client Client, requester fosite.AuthorizeRequester) (*InteractionSession, error) {
if interactionID == "" {
return nil, nil
}
session, err := s.interactionSessionService.get(ctx, interactionID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fosite.ErrInvalidRequest.WithHint("The interaction session is invalid or has expired.")
}
if err != nil {
return nil, err
}
if err := validateInteractionSessionBinding(session, userID, client, requester); err != nil {
return nil, err
}
return &session, nil
}
// validateInteractionSessionBinding ensures a request matches the interaction session
// it resumes: the requirement flags and the PAR exemption come from the session, so a
// tampered request (extra scopes, another client's interaction) must not inherit them.
func validateInteractionSessionBinding(interactionSession InteractionSession, userID string, client Client, requester fosite.AuthorizeRequester) error {
if interactionSession.ClientID != client.GetID() {
return fosite.ErrInvalidRequest.WithHint("The interaction session does not belong to this client.")
}
if interactionSession.UserID != nil {
if userID == "" {
return fosite.ErrLoginRequired
}
}
for _, scope := range requester.GetRequestedScopes() {
if !slices.Contains(interactionSession.Scopes, scope) {
return fosite.ErrInvalidRequest.WithHint("The requested scopes exceed the scopes of the interaction session.")
}
}
return nil
}
type interactionRequirements struct {
ConsentRequired bool
ReauthenticationRequired bool
AuthenticationRequired bool
AccountSelectionRequired bool
}
func (r interactionRequirements) any() bool {
return r.ConsentRequired || r.ReauthenticationRequired || r.AuthenticationRequired || r.AccountSelectionRequired
}
func (s *authorizationService) createInteractionSession(ctx context.Context, requester fosite.AuthorizeRequester, requestParams map[string]string, userID string, requirements interactionRequirements) (InteractionSession, error) {
parameters := make(map[string]string, len(requestParams))
for key, value := range requestParams {
parameters[key] = value
}
return s.interactionSessionService.create(ctx, InteractionSession{
Base: model.Base{
ID: requester.GetID(),
},
Scopes: datatype.StringList(requester.GetRequestedScopes()),
ClientID: requester.GetClient().GetID(),
UserID: utils.PtrOrNil(userID),
ConsentRequired: requirements.ConsentRequired,
ReauthenticationRequired: requirements.ReauthenticationRequired,
AuthenticationRequired: requirements.AuthenticationRequired,
AccountSelectionRequired: requirements.AccountSelectionRequired,
RequestedAt: datatype.DateTime(requester.GetRequestedAt()),
Parameters: parameters,
})
}
func bindInteractionSessionUser(interactionSession *InteractionSession, userID string) error {
if userID == "" {
return fosite.ErrLoginRequired
}
if interactionSession.UserID != nil && *interactionSession.UserID != userID {
return fosite.ErrInvalidRequest.WithHint("The interaction session belongs to another user.")
}
if interactionSession.UserID == nil {
interactionSession.UserID = &userID
}
return nil
}
func (s *authorizationService) switchInteractionSessionUser(ctx context.Context, interactionSession *InteractionSession, userID string, authenticationTime time.Time) error {
if userID == "" {
return fosite.ErrLoginRequired
}
interactionSession.UserID = &userID
requirements, err := s.interactionRequirementsForUser(ctx, userID, interactionSession, authenticationTime)
if err != nil {
return err
}
interactionSession.AuthenticationRequired = false
interactionSession.ConsentRequired = requirements.ConsentRequired
interactionSession.ReauthenticationRequired = requirements.ReauthenticationRequired
interactionSession.ReauthenticatedAt = nil
return nil
}
func (s *authorizationService) getInteractionSession(ctx context.Context, interactionSessionID string) (interactionSessionForUser, error) {
interactionSession, err := s.interactionSessionService.get(ctx, interactionSessionID)
if err != nil {
return interactionSessionForUser{}, err
}
return newInteractionSessionForUser(interactionSession)
}
func (s *authorizationService) completeInteractionStep(ctx context.Context, interactionSessionID, userID string, step interactionStep, reauthenticationToken string, authenticationTime time.Time, meta requestMeta) (completeInteractionResponse, error) {
var interactionSession InteractionSession
var response completeInteractionResponse
err := withTx(ctx, s.db, func(ctx context.Context) error {
var err error
interactionSession, err = s.interactionSessionService.get(ctx, interactionSessionID)
if err != nil {
return err
}
if interactionSession.UserID != nil && *interactionSession.UserID != userID {
if err := s.switchInteractionSessionUser(ctx, &interactionSession, userID, authenticationTime); err != nil {
return err
}
}
if userID == "" {
return fosite.ErrLoginRequired
}
requiredSteps := requiredInteractionSteps(interactionSession)
if len(requiredSteps) == 0 {
response = completeInteractionResponse{RedirectURL: authorizeRedirectURL(interactionSession.ID)}
return nil
}
if requiredSteps[0] != step {
return &common.ValidationError{Message: "expected interaction step " + string(requiredSteps[0]) + " but got " + string(step)}
}
if err := s.applyInteractionStep(ctx, &interactionSession, userID, step, reauthenticationToken, authenticationTime, meta); err != nil {
return err
}
return s.interactionSessionService.update(ctx, interactionSession)
})
if err != nil {
return completeInteractionResponse{}, err
}
if response.RedirectURL != "" {
return response, nil
}
if !hasRemainingInteractionSteps(interactionSession) {
return completeInteractionResponse{RedirectURL: authorizeRedirectURL(interactionSession.ID)}, nil
}
interaction, err := newInteractionSessionForUser(interactionSession)
if err != nil {
return completeInteractionResponse{}, err
}
return completeInteractionResponse{Interaction: &interaction}, nil
}
func (s *authorizationService) applyInteractionStep(ctx context.Context, interactionSession *InteractionSession, userID string, step interactionStep, reauthenticationToken string, authenticationTime time.Time, meta requestMeta) error {
switch step {
case interactionStepAuthenticate:
if err := bindInteractionSessionUser(interactionSession, userID); err != nil {
return err
}
interactionSession.AuthenticationRequired = false
return s.populatePostAuthenticationRequirements(ctx, userID, interactionSession, authenticationTime)
case interactionStepSelectAccount:
if err := s.switchInteractionSessionUser(ctx, interactionSession, userID, authenticationTime); err != nil {
return err
}
interactionSession.AccountSelectionRequired = false
return nil
case interactionStepReauthenticate:
return s.completeReauthenticationStep(ctx, interactionSession, userID, reauthenticationToken)
case interactionStepConsent:
return s.completeConsentStep(ctx, interactionSession, userID, meta)
default:
return &common.ValidationError{Message: "unknown interaction step " + string(step)}
}
}
func (s *authorizationService) completeReauthenticationStep(ctx context.Context, interactionSession *InteractionSession, userID, reauthenticationToken string) error {
if err := bindInteractionSessionUser(interactionSession, userID); err != nil {
return err
}
if reauthenticationToken == "" {
return &common.ValidationError{Message: "reauthentication token is required"}
}
reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), reauthenticationToken, userID)
if err != nil {
return err
}
interactionSession.ReauthenticationRequired = false
interactionSession.ReauthenticatedAt = new(datatype.DateTime(reauthenticatedAt))
return nil
}
func (s *authorizationService) completeConsentStep(ctx context.Context, interactionSession *InteractionSession, userID string, meta requestMeta) error {
if err := bindInteractionSessionUser(interactionSession, userID); err != nil {
return err
}
hasAlreadyAuthorizedClient, err := s.consent(ctx, userID, interactionSession.ClientID, interactionSession.Scopes)
if err != nil {
return err
}
if !hasAlreadyAuthorizedClient && s.auditLog != nil {
s.auditLog.Create(ctx, model.AuditLogEventNewClientAuthorization, meta.IPAddress, meta.UserAgent, userID, model.AuditLogData{"clientName": interactionSession.Client.Name}, dbFromContext(ctx, s.db))
}
interactionSession.ConsentRequired = false
return nil
}
func (s *authorizationService) populatePostAuthenticationRequirements(ctx context.Context, userID string, interactionSession *InteractionSession, authenticationTime time.Time) error {
if userID == "" {
return errors.New("user is required to complete authentication")
}
requirements, err := s.interactionRequirementsForUser(ctx, userID, interactionSession, authenticationTime)
if err != nil {
return err
}
interactionSession.ConsentRequired = interactionSession.ConsentRequired || requirements.ConsentRequired
interactionSession.AccountSelectionRequired = interactionSession.AccountSelectionRequired || requirements.AccountSelectionRequired
interactionSession.ReauthenticationRequired = interactionSession.ReauthenticationRequired || requirements.ReauthenticationRequired
return nil
}
func (s *authorizationService) interactionRequirementsForUser(ctx context.Context, userID string, interactionSession *InteractionSession, authenticationTime time.Time) (interactionRequirements, error) {
prompt := newPromptValues(interactionSession.Parameters["prompt"])
hasAlreadyAuthorizedClient, err := s.hasAuthorizedClient(ctx, interactionSession.ClientID, userID, interactionSession.Scopes)
if err != nil {
return interactionRequirements{}, err
}
maxAgeReauthenticationRequired, err := requiresReauthenticationForMaxAge(interactionSession.Parameters["max_age"], authenticationTime, time.Now().UTC())
if err != nil {
return interactionRequirements{}, err
}
return interactionRequirements{
ConsentRequired: consentRequired(hasAlreadyAuthorizedClient, interactionSession.Client.SkipConsent, prompt),
ReauthenticationRequired: prompt.has("login") || interactionSession.Client.RequiresReauthentication || maxAgeReauthenticationRequired,
AccountSelectionRequired: prompt.has("select_account"),
AuthenticationRequired: false,
}, nil
}
// authorizeRedirectURL only references the interaction session; the authorize endpoint
// restores the request parameters from it server-side.
func authorizeRedirectURL(interactionSessionID string) string {
return "/authorize?interaction=" + url.QueryEscape(interactionSessionID)
}
// validateClientPKCERequirement enforces the per-client PkceEnabled flag. fosite only
// enforces PKCE for public clients (EnforcePKCEForPublicClients).
func validateClientPKCERequirement(client Client, requester fosite.AuthorizeRequester) error {
if !client.PkceEnabled {
return nil
}
if requester.GetRequestForm().Get("code_challenge") == "" {
return fosite.ErrInvalidRequest.WithHint("This client requires PKCE, but the 'code_challenge' parameter is missing.")
}
return nil
}
func requiresReauthenticationForMaxAge(maxAgeRaw string, authenticationTime time.Time, now time.Time) (bool, error) {
if maxAgeRaw == "" {
return false, nil
}
maxAge, err := strconv.ParseInt(maxAgeRaw, 10, 64)
if err != nil || maxAge < 0 {
return false, fosite.ErrInvalidRequest.WithHint("Parameter 'max_age' must be a non-negative integer.")
}
if authenticationTime.IsZero() {
return true, nil
}
return !now.Before(authenticationTime.UTC().Add(time.Duration(maxAge) * time.Second)), nil
}
func (s *authorizationService) consent(ctx context.Context, userID string, clientID string, scope []string) (hasAlreadyAuthorizedClient bool, err error) {
db := dbFromContext(ctx, s.db)
hasAlreadyAuthorizedClient, err = s.hasAuthorizedClient(ctx, clientID, userID, scope)
if err != nil {
return false, err
}
if hasAlreadyAuthorizedClient {
err = db.
Model(&model.UserAuthorizedOidcClient{}).
Where("user_id = ? AND client_id = ?", userID, clientID).
Update("last_used_at", datatype.DateTime(time.Now())).
Error
if err != nil {
return hasAlreadyAuthorizedClient, err
}
return hasAlreadyAuthorizedClient, nil
}
userAuthorizedClient := model.UserAuthorizedOidcClient{
UserID: userID,
ClientID: clientID,
Scope: scope,
LastUsedAt: datatype.DateTime(time.Now()),
}
err = db.
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}},
DoUpdates: clause.AssignmentColumns([]string{"scope"}),
}).
Create(&userAuthorizedClient).
Error
return hasAlreadyAuthorizedClient, err
}
func (s *authorizationService) hasAuthorizedClient(ctx context.Context, clientID, userID string, scope []string) (bool, error) {
var userAuthorizedOidcClient model.UserAuthorizedOidcClient
err := dbFromContext(ctx, s.db).
First(&userAuthorizedOidcClient, "client_id = ? AND user_id = ?", clientID, userID).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
authorizedScopes := userAuthorizedOidcClient.Scope
for _, requestedScope := range scope {
if !slices.Contains(authorizedScopes, requestedScope) {
return false, nil
}
}
return true, nil
}
// IsUserGroupAllowedToAuthorize reports whether the user may use the group-restricted client.
func IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool {
if !client.IsGroupRestricted {
return true
}
for _, userGroup := range client.AllowedUserGroups {
for _, userGroupUser := range user.UserGroups {
if userGroup.ID == userGroupUser.ID {
return true
}
}
}
return false
}