Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ OAUTH_GOOGLE_SECRET=
OAUTH_GITHUB_CLIENTID=
OAUTH_GITHUB_SECRET=

OAUTH_ALLOWED_ROLES=

EMAIL_NOREPLY=noreply@yourdomain.com

#EMAIL_MAILGUN_API=
Expand Down
5 changes: 5 additions & 0 deletions app/actions/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type CreateEditOAuthConfig struct {
JSONUserIDPath string `json:"jsonUserIDPath"`
JSONUserNamePath string `json:"jsonUserNamePath"`
JSONUserEmailPath string `json:"jsonUserEmailPath"`
JSONUserRolesPath string `json:"jsonUserRolesPath"`
}

func NewCreateEditOAuthConfig() *CreateEditOAuthConfig {
Expand Down Expand Up @@ -184,5 +185,9 @@ func (action *CreateEditOAuthConfig) Validate(ctx context.Context, user *entity.
result.AddFieldFailure("jsonUserEmailPath", "JSON User Email Path must have less than 100 characters.")
}

if len(action.JSONUserRolesPath) > 100 {
result.AddFieldFailure("jsonUserRolesPath", "JSON User Roles Path must have less than 100 characters.")
}

return result
}
1 change: 1 addition & 0 deletions app/cmd/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ func routes(r *web.Engine) *web.Engine {
r.Get("/signin/complete", handlers.CompleteSignInProfilePage())
r.Get("/loginemailsent", handlers.LoginEmailSentPage())
r.Get("/not-invited", handlers.NotInvitedPage())
r.Get("/access-denied", handlers.AccessDeniedPage())
r.Get("/signin/verify", handlers.VerifySignInKey(enum.EmailVerificationKindSignIn))
r.Get("/invite/verify", handlers.VerifySignInKey(enum.EmailVerificationKindUserInvitation))
r.Post("/_api/signin/complete", handlers.CompleteSignInProfile())
Expand Down
1 change: 1 addition & 0 deletions app/handlers/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ func SaveOAuthConfig() web.HandlerFunc {
JSONUserIDPath: action.JSONUserIDPath,
JSONUserNamePath: action.JSONUserNamePath,
JSONUserEmailPath: action.JSONUserEmailPath,
JSONUserRolesPath: action.JSONUserRolesPath,
},
); err != nil {
return c.Failure(err)
Expand Down
51 changes: 51 additions & 0 deletions app/handlers/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/getfider/fider/app/pkg/bus"

"github.com/getfider/fider/app"
"github.com/getfider/fider/app/pkg/env"
"github.com/getfider/fider/app/pkg/errors"
"github.com/getfider/fider/app/pkg/jwt"
"github.com/getfider/fider/app/pkg/log"
Expand Down Expand Up @@ -91,6 +92,17 @@ func OAuthToken() web.HandlerFunc {
return c.Failure(err)
}

// Check if user has required roles (if OAUTH_ALLOWED_ROLES is configured)
if !hasAllowedRole(oauthUser.Result.Roles) {
log.Warnf(c, "User @{UserID} attempted OAuth login but does not have required role. User roles: @{UserRoles}, Allowed roles: @{AllowedRoles}",
dto.Props{
"UserID": oauthUser.Result.ID,
"UserRoles": oauthUser.Result.Roles,
"AllowedRoles": env.Config.OAuth.AllowedRoles,
})
return c.Redirect("/access-denied")
}

var user *entity.User

userByProvider := &query.GetUserByProvider{Provider: provider, UID: oauthUser.Result.ID}
Expand Down Expand Up @@ -264,3 +276,42 @@ func SignInByOAuth() web.HandlerFunc {
return c.Redirect(authURL.Result)
}
}

// hasAllowedRole checks if the user has any of the allowed roles configured in OAUTH_ALLOWED_ROLES
// If OAUTH_ALLOWED_ROLES is not set or empty, all users are allowed (returns true)
// If set, user must have at least one of the specified roles
func hasAllowedRole(userRoles []string) bool {
allowedRolesConfig := strings.TrimSpace(env.Config.OAuth.AllowedRoles)

// If no roles restriction is configured, allow all users
if allowedRolesConfig == "" {
return true
}

// Parse allowed roles from config (semicolon-separated)
allowedRoles := strings.Split(allowedRolesConfig, ";")
allowedRolesMap := make(map[string]bool)
for _, role := range allowedRoles {
role = strings.TrimSpace(role)
if role != "" {
allowedRolesMap[role] = true
}
}

// If no valid roles in config, allow all
if len(allowedRolesMap) == 0 {
return true
}

// Check if user has any of the allowed roles
for _, userRole := range userRoles {
userRole = strings.TrimSpace(userRole)
if allowedRolesMap[userRole] {
return true
}
}

// User doesn't have any of the required roles
return false
}

11 changes: 11 additions & 0 deletions app/handlers/signin.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ func NotInvitedPage() web.HandlerFunc {
}
}

// AccessDeniedPage renders the access denied page for OAuth role mismatches
func AccessDeniedPage() web.HandlerFunc {
return func(c *web.Context) error {
return c.Page(http.StatusForbidden, web.Props{
Page: "Error/AccessDenied.page",
Title: "Access Denied",
Description: "You do not have the required permissions to access this site.",
})
}
}

// SignInByEmail checks if user exists and sends code only for existing users
func SignInByEmail() web.HandlerFunc {
return func(c *web.Context) error {
Expand Down
1 change: 1 addition & 0 deletions app/models/cmd/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type SaveCustomOAuthConfig struct {
JSONUserIDPath string
JSONUserNamePath string
JSONUserEmailPath string
JSONUserRolesPath string
}

type ParseOAuthRawProfile struct {
Expand Down
7 changes: 4 additions & 3 deletions app/models/dto/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ package dto

//OAuthUserProfile represents an OAuth user profile
type OAuthUserProfile struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Roles []string `json:"roles"`
}

//OAuthProviderOption represents an OAuth provider that can be used to authenticate
Expand Down
2 changes: 2 additions & 0 deletions app/models/entity/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type OAuthConfig struct {
JSONUserIDPath string
JSONUserNamePath string
JSONUserEmailPath string
JSONUserRolesPath string
}

// MarshalJSON returns the JSON encoding of OAuthConfig
Expand All @@ -51,5 +52,6 @@ func (o OAuthConfig) MarshalJSON() ([]byte, error) {
"jsonUserIDPath": o.JSONUserIDPath,
"jsonUserNamePath": o.JSONUserNamePath,
"jsonUserEmailPath": o.JSONUserEmailPath,
"jsonUserRolesPath": o.JSONUserRolesPath,
})
}
1 change: 1 addition & 0 deletions app/pkg/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ type config struct {
ClientID string `env:"OAUTH_GITHUB_CLIENTID"`
Secret string `env:"OAUTH_GITHUB_SECRET"`
}
AllowedRoles string `env:"OAUTH_ALLOWED_ROLES"`
}
Email struct {
Type string `env:"EMAIL"` // possible values: smtp, mailgun, awsses
Expand Down
132 changes: 132 additions & 0 deletions app/services/oauth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package oauth
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"strings"
Expand Down Expand Up @@ -120,10 +121,17 @@ func parseOAuthRawProfile(ctx context.Context, c *cmd.ParseOAuthRawProfile) erro
// Extract and combine name parts
name := extractCompositeName(query, config.JSONUserNamePath)

// Extract roles if path is configured
var roles []string
if config.JSONUserRolesPath != "" {
roles = extractRolesFromJSON(c.Body, config.JSONUserRolesPath)
}

profile := &dto.OAuthUserProfile{
ID: strings.TrimSpace(query.String(config.JSONUserIDPath)),
Name: name,
Email: strings.ToLower(strings.TrimSpace(query.String(config.JSONUserEmailPath))),
Roles: roles,
}

if profile.ID == "" {
Expand Down Expand Up @@ -193,6 +201,130 @@ func extractCompositeName(query *jsonq.Query, namePath string) string {
return ""
}

// Supports formats:
// - "roles" for array of strings: ["ROLE_ADMIN", "ROLE_USER"]
// - "roles[].id" for array of objects: [{"id": "ROLE_ADMIN"}, {"id": "ROLE_USER"}]
// - "user.roles[].name" for nested array of objects
func extractRolesFromJSON(jsonBody string, rolesPath string) []string {
rolesPath = strings.TrimSpace(rolesPath)
if rolesPath == "" {
return nil
}

// Parse the JSON body
var data map[string]interface{}
if err := json.Unmarshal([]byte(jsonBody), &data); err != nil {
return nil
}

// Check if we need to extract a field from array of objects (e.g., "roles[].id")
var fieldToExtract string
var actualPath string

if strings.Contains(rolesPath, "[].") {
parts := strings.Split(rolesPath, "[].")
if len(parts) == 2 {
actualPath = parts[0]
fieldToExtract = parts[1]
}
} else {
actualPath = rolesPath
}

// Navigate to the value using the path
value := navigateJSONPath(data, actualPath)
if value == nil {
return nil
}

// If it's an array
if arr, ok := value.([]interface{}); ok {
roles := make([]string, 0)

// If we need to extract a field from objects
if fieldToExtract != "" {
for _, item := range arr {
if obj, ok := item.(map[string]interface{}); ok {
if fieldValue, exists := obj[fieldToExtract]; exists {
if roleStr, ok := fieldValue.(string); ok && roleStr != "" {
roles = append(roles, strings.TrimSpace(roleStr))
}
}
}
}
} else {
// Array of strings
for _, item := range arr {
if roleStr, ok := item.(string); ok && roleStr != "" {
roles = append(roles, strings.TrimSpace(roleStr))
}
}
}

if len(roles) > 0 {
return roles
}
}

// If it's a string, try splitting
if str, ok := value.(string); ok {
str = strings.TrimSpace(str)
if str != "" {
// Try splitting by semicolon first, then comma
var roles []string
if strings.Contains(str, ";") {
roles = strings.Split(str, ";")
} else if strings.Contains(str, ",") {
roles = strings.Split(str, ",")
} else {
roles = []string{str}
}

// Trim whitespace from each role
cleanRoles := make([]string, 0)
for _, role := range roles {
role = strings.TrimSpace(role)
if role != "" {
cleanRoles = append(cleanRoles, role)
}
}
return cleanRoles
}
}

return nil
}

// navigateJSONPath navigates through nested JSON structure using dot notation
// e.g., "user.profile.roles" will navigate data["user"]["profile"]["roles"]
func navigateJSONPath(data map[string]interface{}, path string) interface{} {
if path == "" {
return nil
}

parts := strings.Split(path, ".")
var current interface{} = data

for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}

if m, ok := current.(map[string]interface{}); ok {
if value, exists := m[part]; exists {
current = value
} else {
return nil
}
} else {
return nil
}
}

return current
}

func getOAuthAuthorizationURL(ctx context.Context, q *query.GetOAuthAuthorizationURL) error {
config, err := getConfig(ctx, q.Provider)
if err != nil {
Expand Down
Loading
Loading