Skip to content

Commit 1ce3bf1

Browse files
authored
oidc-fix (#741)
1 parent 615e1c4 commit 1ce3bf1

File tree

12 files changed

+137
-73
lines changed

12 files changed

+137
-73
lines changed

CHANGELOG.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,18 @@ All notable changes to this project will be documented in this file. For commit
44

55
## v0.7.8-beta
66

7+
Note: if using oidc, please update from 0.7.7 to resolve invalid_grant issue. Also - oidc no longer creates users automatically by default -- must be enabled.
78

89
**New Features**:
9-
-
10-
11-
**Notes**:
12-
-
10+
- More oidc user creation options https://github.com/gtsteffaniak/filebrowser/issues/685
11+
- `auth.methods.oidc.createUser` must be true to automatically create user, defaults to false.
12+
- `auth.methods.oidc.adminGroup` allows using oidc provider group name to enable admin user creation.
1313

1414
**BugFixes**:
1515
- fix save editor info sometimes saves wrong file. https://github.com/gtsteffaniak/filebrowser/issues/701
1616
- make ctrl select work on mac or windows. https://github.com/gtsteffaniak/filebrowser/issues/739
17+
- oidc login failures introduced in 0.7.6 https://github.com/gtsteffaniak/filebrowser/issues/731
18+
- oidc respects non-default baseURL
1719

1820
## v0.7.7-beta
1921

backend/common/settings/auth.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ type OidcConfig struct {
6060
UserIdentifier string `json:"userIdentifier"` // the user identifier to use for authentication. Default is "username", can be "email" or "username", or "phone"
6161
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.
6262
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
63+
CreateUser bool `json:"createUser"` // create user if not exists
64+
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges.
6365
Provider *oidc.Provider `json:"-"` // OIDC provider
6466
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
6567
}

backend/http/oidc.go

Lines changed: 85 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"crypto/tls"
55
"fmt"
66
"net/http"
7+
"net/url"
8+
"slices"
79
"strings"
810
"time"
911

@@ -18,11 +20,42 @@ import (
1820

1921
// userInfo struct to hold user claims from either UserInfo or ID token
2022
type userInfo struct {
21-
Name string `json:"name"`
22-
PreferredUsername string `json:"preferred_username"`
23-
Email string `json:"email"`
24-
Sub string `json:"sub"`
25-
Phone string `json:"phone_number"`
23+
Name string `json:"name"`
24+
PreferredUsername string `json:"preferred_username"`
25+
Email string `json:"email"`
26+
Sub string `json:"sub"`
27+
Phone string `json:"phone_number"`
28+
Groups []string `json:"groups"`
29+
}
30+
31+
// oidcLoginHandler redirects the user to the OIDC provider's authorization endpoint.
32+
// This function remains largely the same, but includes the 'fb_redirect' parameter
33+
// to redirect the user back to the original page after successful login.
34+
func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
35+
oidcCfg := settings.Config.Auth.Methods.OidcAuth
36+
if !oidcCfg.Enabled {
37+
return http.StatusForbidden, fmt.Errorf("oidc authentication is not enabled")
38+
}
39+
40+
origin := r.Header.Get("Origin")
41+
if origin == "" {
42+
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
43+
}
44+
oauth2Config := &oauth2.Config{
45+
ClientID: oidcCfg.ClientID,
46+
ClientSecret: oidcCfg.ClientSecret,
47+
Endpoint: oidcCfg.Provider.Endpoint(),
48+
RedirectURL: fmt.Sprintf("%s%sapi/auth/oidc/callback", origin, config.Server.BaseURL),
49+
Scopes: strings.Split(oidcCfg.Scopes, " "),
50+
}
51+
52+
nonce := utils.InsecureRandomIdentifier(16)
53+
fbRedirect := r.URL.Query().Get("redirect")
54+
state := fmt.Sprintf("%s:%s", nonce, fbRedirect)
55+
56+
authURL := oauth2Config.AuthCodeURL(state)
57+
http.Redirect(w, r, authURL, http.StatusFound)
58+
return 0, nil
2659
}
2760

2861
// oidcCallbackHandler handles the OIDC callback after the user authenticates with the provider.
@@ -49,14 +82,14 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
4982
}
5083
ctx = oidc.ClientContext(ctx, customClient)
5184
}
52-
code := r.Header.Get("X-Secret")
85+
code := r.URL.Query().Get("code")
5386
// state := r.URL.Query().Get("state") // You might want to validate the state parameter for CSRF protection
5487

5588
// The redirect URI MUST match the one registered with the OIDC provider
5689
// and used in the initial /api/auth/oidc/login handler.
5790
// Using r.Host here might be tricky if running behind a proxy.
5891
// Consider using a fixed redirect URL from settings if possible.
59-
redirectURL := fmt.Sprintf("%s://%s/api/auth/oidc/callback", getScheme(r), r.Host)
92+
redirectURL := fmt.Sprintf("%s://%s%sapi/auth/oidc/callback", getScheme(r), r.Host, config.Server.BaseURL)
6093

6194
oauth2Config := &oauth2.Config{
6295
ClientID: oidcCfg.ClientID,
@@ -88,7 +121,8 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
88121
// This uses the verifier initialized with the provider's JWKS endpoint and client ID
89122
idToken, err := oidcCfg.Verifier.Verify(ctx, rawIDToken)
90123
if err != nil {
91-
logger.Warningf("failed to verify ID token: %v. Falling back to UserInfo endpoint.", err)
124+
// this might not be necessary for certain providers like authentik
125+
logger.Debugf("failed to verify ID token: %v. This might be expected, falling back to UserInfo endpoint.", err)
92126
// Verification failed, claimsFromIDToken remains false
93127
} else {
94128
var claims map[string]interface{}
@@ -173,64 +207,43 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
173207
logger.Error("No valid username found in ID token or UserInfo response.")
174208
return http.StatusInternalServerError, fmt.Errorf("no valid username found in ID token or UserInfo response from claims")
175209
}
210+
isAdmin := false // Default to non-admin user
211+
if config.Auth.Methods.OidcAuth.AdminGroup != "" {
212+
if slices.Contains(userdata.Groups, config.Auth.Methods.OidcAuth.AdminGroup) {
213+
isAdmin = true // User is in the admin group, grant admin privileges
214+
logger.Debugf("User %s is in admin group %s, granting admin privileges.", loginUsername, config.Auth.Methods.OidcAuth.AdminGroup)
215+
}
216+
}
176217
// Proceed to log the user in with the OIDC data
177218
// userdata struct now contains info from either verified ID token or UserInfo endpoint
178-
return loginWithOidcUser(w, r, loginUsername)
179-
}
180-
181-
// oidcLoginHandler redirects the user to the OIDC provider's authorization endpoint.
182-
// This function remains largely the same, but includes the 'fb_redirect' parameter
183-
// to redirect the user back to the original page after successful login.
184-
func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
185-
oidcCfg := settings.Config.Auth.Methods.OidcAuth
186-
if !oidcCfg.Enabled {
187-
return http.StatusForbidden, fmt.Errorf("oidc authentication is not enabled")
188-
}
189-
190-
origin := r.Header.Get("Origin")
191-
if origin == "" {
192-
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
193-
}
194-
195-
oauth2Config := &oauth2.Config{
196-
ClientID: oidcCfg.ClientID,
197-
ClientSecret: oidcCfg.ClientSecret,
198-
Endpoint: oidcCfg.Provider.Endpoint(),
199-
RedirectURL: fmt.Sprintf("%s/api/auth/oidc/callback", origin),
200-
Scopes: strings.Split(oidcCfg.Scopes, " "),
201-
}
202-
203-
nonce := utils.InsecureRandomIdentifier(16)
204-
fbRedirect := r.URL.Query().Get("fb_redirect")
205-
state := fmt.Sprintf("%s:%s", nonce, fbRedirect)
206-
207-
authURL := oauth2Config.AuthCodeURL(state)
208-
http.Redirect(w, r, authURL, http.StatusFound)
209-
return 0, nil
219+
return loginWithOidcUser(w, r, loginUsername, isAdmin)
210220
}
211221

212222
// loginWithOidcUser extracts the username from the user claims (userInfo)
213223
// based on the configured UserIdentifier and logs the user into the application.
214224
// It creates a new user if one doesn't exist.
215-
func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string) (int, error) {
216-
logger.Debugf("Successfully authenticated OIDC username: %s", username)
225+
func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string, isAdmin bool) (int, error) {
226+
logger.Debugf("Successfully authenticated OIDC username: %s isAdmin: %v", username, isAdmin)
217227
// Retrieve the user from the store and store it in the context
218228
user, err := store.Users.Get(username)
219229
if err != nil {
220230
if err.Error() != "the resource does not exist" {
221231
return http.StatusInternalServerError, err
222232
}
223-
224-
err = storage.CreateUser(users.User{
225-
LoginMethod: users.LoginMethodOidc,
226-
Username: username,
227-
}, false)
228-
if err != nil {
229-
return http.StatusInternalServerError, err
230-
}
231-
user, err = store.Users.Get(username)
232-
if err != nil {
233-
return http.StatusInternalServerError, err
233+
if config.Auth.Methods.OidcAuth.CreateUser {
234+
err = storage.CreateUser(users.User{
235+
LoginMethod: users.LoginMethodOidc,
236+
Username: username,
237+
}, isAdmin)
238+
if err != nil {
239+
return http.StatusInternalServerError, err
240+
}
241+
user, err = store.Users.Get(username)
242+
if err != nil {
243+
return http.StatusInternalServerError, err
244+
}
245+
} else {
246+
return http.StatusForbidden, fmt.Errorf("user %s does not exist and createUser is disabled. Your admin needs to create your user before you can access this application", username)
234247
}
235248
}
236249
if user.LoginMethod != users.LoginMethodOidc {
@@ -271,20 +284,31 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string)
271284
// or to the root ("/") if no specific redirect was requested.
272285
// The 'fb_redirect' parameter is extracted from the 'state' parameter for security.
273286
state := r.URL.Query().Get("state")
274-
fbRedirect := "/" // Default redirect path
287+
288+
fbRedirect := config.Server.BaseURL // Default redirect to the base URL
275289
if state != "" {
276-
// Assuming state is in the format "nonce:fb_redirect"
277290
parts := strings.SplitN(state, ":", 2)
278-
if len(parts) == 2 {
279-
// TODO: Validate the nonce part against the stored nonce for CSRF protection
280-
// For this example, we'll just extract the redirect part
281-
extractedRedirect := parts[1]
282-
if extractedRedirect != "" {
283-
fbRedirect = extractedRedirect
291+
292+
// 2. Validate the nonce
293+
// receivedNonce := parts[0]
294+
// if receivedNonce != nonceCookie.Value {
295+
// // Handle error: nonce mismatch (possible CSRF attack)
296+
// return http.StatusBadRequest, fmt.Errorf("invalid state nonce")
297+
// }
298+
299+
if len(parts) == 2 && parts[1] != "" {
300+
// 3. Prevent Open Redirect vulnerability
301+
// Ensure the redirect is to a local path.
302+
potentialRedirect, err := url.QueryUnescape(parts[1])
303+
if err == nil && strings.HasPrefix(potentialRedirect, "/") {
304+
fbRedirect = potentialRedirect
305+
} else {
306+
logger.Warningf("Blocked potentially malicious redirect to: %s", parts[1])
284307
}
285308
}
286309
}
287310

311+
// Clean up
288312
http.Redirect(w, r, fbRedirect, http.StatusFound)
289313

290314
// Return 0 to indicate that the response has been handled by the redirect

backend/swagger/docs/docs.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1910,6 +1910,10 @@ const docTemplate = `{
19101910
"settings.OidcConfig": {
19111911
"type": "object",
19121912
"properties": {
1913+
"adminGroup": {
1914+
"description": "if set, users in this group will be granted admin privileges.",
1915+
"type": "string"
1916+
},
19131917
"clientId": {
19141918
"description": "client id of the OIDC application",
19151919
"type": "string"
@@ -1918,6 +1922,10 @@ const docTemplate = `{
19181922
"description": "client secret of the OIDC application",
19191923
"type": "string"
19201924
},
1925+
"createUser": {
1926+
"description": "create user if not exists",
1927+
"type": "boolean"
1928+
},
19211929
"disableVerifyTLS": {
19221930
"description": "disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.",
19231931
"type": "boolean"

backend/swagger/docs/swagger.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1899,6 +1899,10 @@
18991899
"settings.OidcConfig": {
19001900
"type": "object",
19011901
"properties": {
1902+
"adminGroup": {
1903+
"description": "if set, users in this group will be granted admin privileges.",
1904+
"type": "string"
1905+
},
19021906
"clientId": {
19031907
"description": "client id of the OIDC application",
19041908
"type": "string"
@@ -1907,6 +1911,10 @@
19071911
"description": "client secret of the OIDC application",
19081912
"type": "string"
19091913
},
1914+
"createUser": {
1915+
"description": "create user if not exists",
1916+
"type": "boolean"
1917+
},
19101918
"disableVerifyTLS": {
19111919
"description": "disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.",
19121920
"type": "boolean"

backend/swagger/docs/swagger.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,12 +209,18 @@ definitions:
209209
type: object
210210
settings.OidcConfig:
211211
properties:
212+
adminGroup:
213+
description: if set, users in this group will be granted admin privileges.
214+
type: string
212215
clientId:
213216
description: client id of the OIDC application
214217
type: string
215218
clientSecret:
216219
description: client secret of the OIDC application
217220
type: string
221+
createUser:
222+
description: create user if not exists
223+
type: boolean
218224
disableVerifyTLS:
219225
description: disable TLS verification for the OIDC provider. This is insecure
220226
and should only be used for testing.

frontend/.eslintrc.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"@intlify/vue-i18n/no-missing-keys": "error",
3131
// Optional: Warn about unused keys in your 'en.json'
3232
// This requires configuring the `localeDir` setting below.
33-
"@intlify/vue-i18n/no-unused-keys": ["warn", {
33+
"@intlify/vue-i18n/no-unused-keys": ["error", {
3434
"src": "./src", // Path to your source files
3535
"extensions": [".js", ".vue"]
3636
// Important: This tells the rule to check unused keys specifically in en.json
@@ -40,7 +40,7 @@
4040
}],
4141

4242
"@intlify/vue-i18n/no-raw-text": [
43-
"warn",
43+
"error",
4444
{
4545
"ignoreNodes": ["i", "v-icon"]
4646
}

frontend/public/config.generated.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ auth:
6969
userIdentifier: "" # the user identifier to use for authentication. Default is "username", can be "email" or "username", or "phone"
7070
disableVerifyTLS: false # disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.
7171
logoutRedirectUrl: "" # if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
72+
createUser: false # create user if not exists
73+
adminGroup: "" # if set, users in this group will be granted admin privileges.
7274
key: "" # the key used to sign the JWT tokens. If not set, a random key will be generated.
7375
adminUsername: admin # the username of the admin user. If not set, the default is "admin".
7476
adminPassword: admin # the password of the admin user. If not set, the default is "admin".

frontend/src/views/Login.vue

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
</div>
4444
<div v-if="oidcAvailable" class="password-entry">
4545
<div v-if="passwordAvailable" class="or">{{ $t("login.or") }}</div>
46-
<a href="/api/auth/oidc/login" class="button button--block direct-login">
46+
<a :href="loginURL" class="button button--block direct-login">
4747
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
4848
OpenID Connect
4949
</a>
@@ -60,6 +60,7 @@ import Prompts from "@/components/prompts/Prompts.vue";
6060
import Icon from "@/components/files/Icon.vue";
6161
import { usersApi } from "@/api";
6262
import { initAuth } from "@/utils/auth";
63+
import { removeLeadingSlash } from "@/utils/url";
6364
import {
6465
name,
6566
logoURL,
@@ -69,6 +70,7 @@ import {
6970
darkMode,
7071
oidcAvailable,
7172
passwordAvailable,
73+
baseURL,
7274
} from "@/utils/constants";
7375
7476
export default {
@@ -98,9 +100,19 @@ export default {
98100
password: "",
99101
recaptcha: recaptcha,
100102
passwordConfirm: "",
103+
loginURL: baseURL + "api/auth/oidc/login",
101104
};
102105
},
103106
mounted() {
107+
let redirect = state.route.query.redirect;
108+
if (redirect === "" || redirect === undefined || redirect === null) {
109+
redirect = baseURL + "files/";
110+
} else {
111+
redirect = removeLeadingSlash(redirect);
112+
redirect = baseURL + redirect;
113+
}
114+
this.loginURL += `?redirect=${encodeURIComponent(redirect)}`;
115+
104116
if (!recaptcha) return;
105117
window.grecaptcha.ready(function () {
106118
window.grecaptcha.render("recaptcha", {
@@ -249,4 +261,4 @@ export default {
249261
.or::after {
250262
right: 0;
251263
}
252-
</style>
264+
</style>

frontend/src/views/files/DocViewer.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<template>
22
<div class="viewer-background">
3-
<div v-if="loading" class="status-text">Loading document...</div>
3+
<div v-if="loading" class="status-text">{{ $t('files.loading') }}</div>
44
<div v-else-if="error" class="status-text error">{{ error }}</div>
55
<div v-else class="docx-page" v-html="docxHtml"></div>
66
</div>

0 commit comments

Comments
 (0)