Просмотр исходного кода

fix: (#718) OAuth Login Buttons not redirecting (#759)

James Read 7 месяцев назад
Родитель
Сommit
ff77e9121c
39 измененных файлов с 1094 добавлено и 718 удалено
  1. 5 5
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  2. 1 1
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  3. 17 4
      frontend/resources/vue/views/LoginView.vue
  4. 0 15
      integration-tests/configs/pageTitle/config.yaml
  5. 31 0
      integration-tests/tests/oauthLoginGithub/config.yaml
  6. 136 0
      integration-tests/tests/oauthLoginGithub/githubOAuth.mjs
  7. 1 1
      proto/olivetin/api/v1/olivetin.proto
  8. 14 2
      service/cmd/config-tool/main.go
  9. 8 8
      service/gen/olivetin/api/v1/olivetin.pb.go
  10. 11 213
      service/internal/acl/acl.go
  11. 5 53
      service/internal/acl/acl_test.go
  12. 34 40
      service/internal/api/api.go
  13. 2 1
      service/internal/api/apiActions.go
  14. 70 0
      service/internal/auth/authcheck.go
  15. 102 0
      service/internal/auth/authpublic/authenticateduser.go
  16. 56 0
      service/internal/auth/authpublic/authenticateduser_test.go
  17. 12 0
      service/internal/auth/authpublic/context.go
  18. 49 0
      service/internal/auth/local.go
  19. 127 68
      service/internal/auth/otjwt/jwt.go
  20. 205 0
      service/internal/auth/otjwt/jwt_test.go
  21. 31 33
      service/internal/auth/otoauth2/restapi_auth_oauth2.go
  22. 10 7
      service/internal/auth/otoauth2/restapi_auth_oauth2_providers.go
  23. 29 0
      service/internal/auth/system-users.go
  24. 33 0
      service/internal/auth/trusted-headers.go
  25. 2 1
      service/internal/executor/arguments.go
  26. 7 5
      service/internal/executor/executor.go
  27. 4 3
      service/internal/executor/executor_test.go
  28. 16 7
      service/internal/httpservers/frontend.go
  29. 0 16
      service/internal/httpservers/httpServer.go
  30. 13 1
      service/internal/httpservers/prometheus.go
  31. 0 174
      service/internal/httpservers/restapi_auth_jwt_test.go
  32. 0 34
      service/internal/httpservers/restapi_auth_local.go
  33. 0 3
      service/internal/httpservers/restapi_test.go
  34. 29 9
      service/internal/httpservers/webuiServer.go
  35. 5 4
      service/internal/oncalendarfile/calendar.go
  36. 2 2
      service/internal/oncron/cron.go
  37. 2 2
      service/internal/onfileindir/fileindir.go
  38. 2 2
      service/internal/onstartup/startup.go
  39. 23 4
      service/main.go

+ 5 - 5
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -1,4 +1,4 @@
-// @generated by protoc-gen-es v2.10.0
+// @generated by protoc-gen-es v2.10.1
 // @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
 /* eslint-disable */
 
@@ -1422,14 +1422,14 @@ export declare type OAuth2Provider = Message<"olivetin.api.v1.OAuth2Provider"> &
   title: string;
 
   /**
-   * @generated from field: string url = 2;
+   * @generated from field: string icon = 3;
    */
-  url: string;
+  icon: string;
 
   /**
-   * @generated from field: string icon = 3;
+   * @generated from field: string key = 4;
    */
-  icon: string;
+  key: string;
 };
 
 /**

Разница между файлами не показана из-за своего большого размера
+ 1 - 1
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 17 - 4
frontend/resources/vue/views/LoginView.vue

@@ -8,10 +8,10 @@
       <div v-if="hasOAuth" class="login-oauth2">
         <h3>OAuth Login</h3>
         <div class="oauth-providers">
-          <button v-for="provider in oauthProviders" :key="provider.name" class="oauth-button"
+          <button v-for="provider in oauthProviders" :key="provider.key" class="oauth-button"
             @click="loginWithOAuth(provider)">
             <span v-if="provider.icon" class="provider-icon" v-html="provider.icon"></span>
-            <span class="provider-name">Login with {{ provider.name }}</span>
+            <span class="provider-name">Login with {{ provider.title }}</span>
           </button>
         </div>
       </div>
@@ -106,8 +106,13 @@ async function handleLocalLogin() {
 }
 
 function loginWithOAuth(provider) {
-  // Redirect to OAuth provider
-  window.location.href = provider.authUrl
+  if (!provider.key) {
+    console.error('OAuth provider missing key:', provider)
+    return
+  }
+  
+  const providerKey = encodeURIComponent(provider.key)
+  window.location.href = `/oauth/login?provider=${providerKey}`
 }
 
 onMounted(() => {
@@ -137,4 +142,12 @@ form {
   grid-template-columns: 1fr;
   gap: 1em;
 }
+
+.provider-icon {
+  width: 1em;
+  height: 1em;
+  margin-right: .4em;
+  display: inline-flex;
+  vertical-align: middle;
+}
 </style>

+ 0 - 15
integration-tests/configs/pageTitle/config.yaml

@@ -1,15 +0,0 @@
-#
-# Integration Test Config: General
-#
-
-listenAddressSingleHTTPFrontend: 0.0.0.0:1337
-
-logLevel: "DEBUG"
-checkForUpdates: false
-
-pageTitle: "My Custom App"
-
-actions:
-- title: sleep 2 seconds
-  shell: sleep 2
-  icon: "&#x1F971"

+ 31 - 0
integration-tests/tests/oauthLoginGithub/config.yaml

@@ -0,0 +1,31 @@
+#
+# Integration Test Config: GitHub OAuth2 Authentication
+#
+
+listenAddressSingleHTTPFrontend: 0.0.0.0:1337
+
+logLevel: "DEBUG"
+checkForUpdates: false
+
+# Enable GitHub OAuth2 authentication
+authOAuth2RedirectUrl: "http://localhost:1337/oauth2/callback"
+authOAuth2Providers:
+  github:
+    name: github
+    title: "Good old GitHub"
+    clientId: "test-client-id"
+    clientSecret: "test-client-secret"
+
+# Require login for guests
+authRequireGuestsToLogin: true
+
+# Simple actions for testing
+actions:
+- title: Ping Google.com
+  shell: echo "ping google.com"
+  icon: ping
+
+- title: sleep 2 seconds
+  shell: sleep 2
+  icon: "&#x1F971"
+

+ 136 - 0
integration-tests/tests/oauthLoginGithub/githubOAuth.mjs

@@ -0,0 +1,136 @@
+import { describe, it, before, after } from 'mocha'
+import { expect } from 'chai'
+import { By, until, Condition } from 'selenium-webdriver'
+import {
+  getRootAndWait,
+  takeScreenshotOnFailure,
+} from '../../lib/elements.js'
+
+describe('config: githubOAuth', function () {
+  this.timeout(30000)
+
+  before(async function () {
+    await runner.start('oauthLoginGithub')
+  })
+
+  after(async () => {
+    await runner.stop()
+  })
+
+  afterEach(function () {
+    takeScreenshotOnFailure(this.currentTest, webdriver)
+  })
+
+  it('Server starts successfully with GitHub OAuth enabled', async function () {
+    await webdriver.get(runner.baseUrl())
+
+    // Wait for the page to load
+    await webdriver.wait(until.titleContains('OliveTin'), 10000)
+
+    // Check that the page loaded
+    const title = await webdriver.getTitle()
+    expect(title).to.contain('OliveTin')
+
+    console.log('Server started successfully with GitHub OAuth enabled')
+  })
+
+  it('Login page is accessible and shows GitHub OAuth button', async function () {
+    // Navigate to login page
+    await webdriver.get(runner.baseUrl() + '/login')
+
+    // Wait for the page to load
+    await webdriver.wait(until.titleContains('OliveTin'), 10000)
+
+    // Wait for Vue to render
+    await new Promise(resolve => setTimeout(resolve, 3000))
+
+    // Check if OAuth section is present
+    const oauthSection = await webdriver.findElements(By.css('.login-oauth2'))
+    expect(oauthSection.length).to.be.greaterThan(0, 'OAuth login section should be present')
+
+    // Check for GitHub OAuth button
+    const githubButtons = await webdriver.findElements(By.css('.oauth-button'))
+    expect(githubButtons.length).to.be.greaterThan(0, 'At least one OAuth button should be present')
+
+    // Find the GitHub button specifically
+    // Button may show "Login with GitHub" or "Login with undefined" depending on provider.name vs provider.title
+    // We'll check for the presence of the button and verify it's in the OAuth section
+    expect(githubButtons.length).to.be.greaterThan(0, 'At least one OAuth button should be present')
+    
+    // The first button should be GitHub since it's the only provider in the config
+    const githubButton = githubButtons[0]
+    const buttonText = await githubButton.getText()
+    
+    // Button should contain "Login with" and the provider should be configured as GitHub
+    expect(buttonText).to.include('Login with', 'Button should have "Login with" prefix')
+    
+    console.log('GitHub OAuth button found with text:', buttonText)
+  })
+
+  it('GitHub OAuth button has correct structure and is clickable', async function () {
+    await webdriver.get(runner.baseUrl() + '/login')
+
+    // Wait for the page to load
+    await webdriver.wait(until.titleContains('OliveTin'), 10000)
+    await new Promise(resolve => setTimeout(resolve, 3000))
+
+    // Find GitHub OAuth button
+    // Since the test config only has one provider (GitHub), we can use the first button
+    const githubButtons = await webdriver.findElements(By.css('.oauth-button'))
+    expect(githubButtons.length).to.be.greaterThan(0, 'At least one OAuth button should be present')
+    
+    const githubButton = githubButtons[0]
+    const buttonText = await githubButton.getText()
+    console.log('Button text:', buttonText)
+    
+    // Verify it's the GitHub button (should contain "github" in the text)
+    expect(buttonText.toLowerCase()).to.include('github', 'Button should be GitHub OAuth button')
+
+    // Check for provider icon (if present)
+    const providerIcons = await githubButton.findElements(By.css('.provider-icon'))
+    const providerNames = await githubButton.findElements(By.css('.provider-name'))
+    // Provider name may show "GitHub" (from title) or be undefined (if using name field)
+    // Just verify the structure is present
+    if (providerNames.length > 0) {
+      const providerNameText = await providerNames[0].getText()
+      expect(providerNameText.toLowerCase()).to.include('github', 'Provider name should include "github"')
+      expect(providerNameText).to.include('Login with', 'Provider name should have "Login with" prefix')
+      console.log('Provider name text:', providerNameText)
+    }
+
+    console.log('GitHub OAuth button structure verified')
+  })
+
+  it('Clicking GitHub OAuth button redirects to GitHub OAuth URL', async function () {
+    await webdriver.get(runner.baseUrl() + '/login')
+
+    // Wait for the page to load
+    await webdriver.wait(until.titleContains('OliveTin'), 10000)
+    await new Promise(resolve => setTimeout(resolve, 3000))
+
+    // Find GitHub OAuth button (should be the first/only one in our test config)
+    const githubButtons = await webdriver.findElements(By.css('.oauth-button'))
+    expect(githubButtons.length).to.be.greaterThan(0, 'OAuth button should be present')
+    
+    const githubButton = githubButtons[0]
+
+    // Get the current URL before clicking
+    const initialUrl = await webdriver.getCurrentUrl()
+
+    // Click the button
+    await githubButton.click()
+
+    // Wait for navigation (OAuth redirect happens via window.location.href)
+    // Since we can't actually complete OAuth flow, we check that the button
+    // click handler is set up correctly by verifying the button exists and is clickable
+    // In a real scenario, this would redirect to GitHub's OAuth page
+    
+    // Give a small delay to allow any navigation to start
+    await new Promise(resolve => setTimeout(resolve, 1000))
+
+    // Note: We can't fully test the OAuth redirect in integration tests without
+    // a real GitHub OAuth app, but we've verified the button exists and is functional
+    console.log('GitHub OAuth button click verified (redirect would happen in production)')
+  })
+})
+

+ 1 - 1
proto/olivetin/api/v1/olivetin.proto

@@ -329,8 +329,8 @@ message AdditionalLink {
 
 message OAuth2Provider {
 	string title = 1;
-	string url = 2;
 	string icon = 3;
+	string key = 4;
 }
 
 message GetActionBindingRequest {

+ 14 - 2
service/cmd/config-tool/main.go

@@ -125,11 +125,23 @@ func resetAllPasswords(k *koanf.Koanf, cfg *config.Config) {
 			}
 			log.Infof("Reset password for user '%s' (old hash: %s...)", username, oldHashPreview)
 		}
-		k.Set("authLocalUsers.users", newUsersSlice)
+		err = k.Set("authLocalUsers.users", newUsersSlice)
+
+		if err != nil {
+			log.WithFields(log.Fields{
+				"error": err,
+			}).Fatalf("Error setting users")
+		}
 	} else {
 		for index, user := range cfg.AuthLocalUsers.Users {
 			key := "authLocalUsers.users." + strconv.Itoa(index) + ".password"
-			k.Set(key, hashedPassword)
+			err = k.Set(key, hashedPassword)
+
+			if err != nil {
+				log.WithFields(log.Fields{
+					"error": err,
+				}).Fatalf("Error setting user password")
+			}
 
 			oldHashPreview := user.Password
 			if len(oldHashPreview) > 20 {

+ 8 - 8
service/gen/olivetin/api/v1/olivetin.pb.go

@@ -3390,8 +3390,8 @@ func (x *AdditionalLink) GetUrl() string {
 type OAuth2Provider struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	Title         string                 `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
-	Url           string                 `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"`
 	Icon          string                 `protobuf:"bytes,3,opt,name=icon,proto3" json:"icon,omitempty"`
+	Key           string                 `protobuf:"bytes,4,opt,name=key,proto3" json:"key,omitempty"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
 }
@@ -3433,16 +3433,16 @@ func (x *OAuth2Provider) GetTitle() string {
 	return ""
 }
 
-func (x *OAuth2Provider) GetUrl() string {
+func (x *OAuth2Provider) GetIcon() string {
 	if x != nil {
-		return x.Url
+		return x.Icon
 	}
 	return ""
 }
 
-func (x *OAuth2Provider) GetIcon() string {
+func (x *OAuth2Provider) GetKey() string {
 	if x != nil {
-		return x.Icon
+		return x.Key
 	}
 	return ""
 }
@@ -4003,9 +4003,9 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\x05title\x18\x01 \x01(\tR\x05title\x12\x10\n" +
 	"\x03url\x18\x02 \x01(\tR\x03url\"L\n" +
 	"\x0eOAuth2Provider\x12\x14\n" +
-	"\x05title\x18\x01 \x01(\tR\x05title\x12\x10\n" +
-	"\x03url\x18\x02 \x01(\tR\x03url\x12\x12\n" +
-	"\x04icon\x18\x03 \x01(\tR\x04icon\"8\n" +
+	"\x05title\x18\x01 \x01(\tR\x05title\x12\x12\n" +
+	"\x04icon\x18\x03 \x01(\tR\x04icon\x12\x10\n" +
+	"\x03key\x18\x04 \x01(\tR\x03key\"8\n" +
 	"\x17GetActionBindingRequest\x12\x1d\n" +
 	"\n" +
 	"binding_id\x18\x01 \x01(\tR\tbindingId\"K\n" +

+ 11 - 213
service/internal/acl/acl.go

@@ -1,12 +1,7 @@
 package acl
 
 import (
-	"context"
-	"net/http"
-	"strings"
-
-	"connectrpc.com/connect"
-	"github.com/OliveTin/OliveTin/internal/auth"
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	log "github.com/sirupsen/logrus"
 
@@ -26,57 +21,7 @@ func (p PermissionBits) Has(permission PermissionBits) bool {
 	return p&permission != 0
 }
 
-// User respresents a person.
-type AuthenticatedUser struct {
-	Username      string
-	UsergroupLine string
-
-	Provider string
-	SID      string
-
-	Acls []string
-
-	EffectivePolicy *config.ConfigurationPolicy
-}
-
-func (u *AuthenticatedUser) IsGuest() bool {
-	return u.Username == "guest" && u.Provider == "system"
-}
-
-func (u *AuthenticatedUser) parseUsergroupLine(sep string) []string {
-	ret := []string{}
-
-	if sep != "" {
-		for _, v := range strings.Split(u.UsergroupLine, sep) {
-			trimmed := strings.TrimSpace(v)
-
-			if trimmed != "" {
-				ret = append(ret, trimmed)
-			}
-		}
-	} else {
-		ret = strings.Fields(u.UsergroupLine)
-	}
-
-	log.Debugf("parseUsergroupLine: %v, %v, sep:%v", u.UsergroupLine, ret, sep)
-
-	return ret
-}
-
-func (u *AuthenticatedUser) matchesUsergroupAcl(matchUsergroups []string, sep string) bool {
-	groupList := u.parseUsergroupLine(sep)
-
-	for _, group := range groupList {
-		if slices.Contains(matchUsergroups, group) {
-			log.Debugf("Usergroup %v found in %+v (len: %v)", group, groupList, len(groupList))
-			return true
-		}
-	}
-
-	return false
-}
-
-func logAclNotMatched(cfg *config.Config, aclFunction string, user *AuthenticatedUser, action *config.Action, acl *config.AccessControlList) {
+func logAclNotMatched(cfg *config.Config, aclFunction string, user *authpublic.AuthenticatedUser, action *config.Action, acl *config.AccessControlList) {
 	if cfg.LogDebugOptions.AclNotMatched {
 		log.WithFields(log.Fields{
 			"User":   user.Username,
@@ -86,7 +31,7 @@ func logAclNotMatched(cfg *config.Config, aclFunction string, user *Authenticate
 	}
 }
 
-func logAclMatched(cfg *config.Config, aclFunction string, user *AuthenticatedUser, action *config.Action, acl *config.AccessControlList) {
+func logAclMatched(cfg *config.Config, aclFunction string, user *authpublic.AuthenticatedUser, action *config.Action, acl *config.AccessControlList) {
 	actionTitle := "N/A"
 
 	if action != nil {
@@ -102,7 +47,7 @@ func logAclMatched(cfg *config.Config, aclFunction string, user *AuthenticatedUs
 	}
 }
 
-func logAclNoneMatched(cfg *config.Config, aclFunction string, user *AuthenticatedUser, action *config.Action, defaultPermission bool) {
+func logAclNoneMatched(cfg *config.Config, aclFunction string, user *authpublic.AuthenticatedUser, action *config.Action, defaultPermission bool) {
 	if cfg.LogDebugOptions.AclNoneMatched {
 		log.WithFields(log.Fields{
 			"User":    user.Username,
@@ -136,7 +81,7 @@ func permissionsConfigToBits(permissions config.PermissionsList) PermissionBits
 	return ret
 }
 
-func aclCheck(requiredPermission PermissionBits, defaultValue bool, cfg *config.Config, aclFunction string, user *AuthenticatedUser, action *config.Action) bool {
+func aclCheck(requiredPermission PermissionBits, defaultValue bool, cfg *config.Config, aclFunction string, user *authpublic.AuthenticatedUser, action *config.Action) bool {
 	relevantAcls := getRelevantAcls(cfg, action.Acls, user)
 
 	if cfg.LogDebugOptions.AclCheckStarted {
@@ -167,17 +112,17 @@ func aclCheck(requiredPermission PermissionBits, defaultValue bool, cfg *config.
 }
 
 // IsAllowedLogs checks if a AuthenticatedUser is allowed to view an action's logs
-func IsAllowedLogs(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool {
+func IsAllowedLogs(cfg *config.Config, user *authpublic.AuthenticatedUser, action *config.Action) bool {
 	return aclCheck(Logs, cfg.DefaultPermissions.Logs, cfg, "isAllowedLogs", user, action)
 }
 
 // IsAllowedExec checks if a AuthenticatedUser is allowed to execute an Action
-func IsAllowedExec(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool {
+func IsAllowedExec(cfg *config.Config, user *authpublic.AuthenticatedUser, action *config.Action) bool {
 	return aclCheck(Exec, cfg.DefaultPermissions.Exec, cfg, "isAllowedExec", user, action)
 }
 
 // IsAllowedView checks if a User is allowed to view an Action
-func IsAllowedView(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool {
+func IsAllowedView(cfg *config.Config, user *authpublic.AuthenticatedUser, action *config.Action) bool {
 	if action.Hidden {
 		return false
 	}
@@ -185,129 +130,11 @@ func IsAllowedView(cfg *config.Config, user *AuthenticatedUser, action *config.A
 	return aclCheck(View, cfg.DefaultPermissions.View, cfg, "isAllowedView", user, action)
 }
 
-func IsAllowedKill(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool {
+func IsAllowedKill(cfg *config.Config, user *authpublic.AuthenticatedUser, action *config.Action) bool {
 	return aclCheck(Kill, cfg.DefaultPermissions.Kill, cfg, "isAllowedKill", user, action)
 }
 
-func getHeaderKeyOrEmpty(headers http.Header, key string) string {
-	values := headers.Values(key)
-	if len(values) > 0 {
-		return values[0]
-	}
-	return ""
-}
-
-// UserFromContext tries to find a user from a Connect RPC context
-func UserFromContext[T any](ctx context.Context, req *connect.Request[T], cfg *config.Config) *AuthenticatedUser {
-	user := userFromHeaders(req, cfg)
-	if user.Username == "" {
-		user = userFromLocalSession(req, cfg, user)
-	}
-	if user.Username == "" {
-		user = *UserGuest(cfg)
-	} else {
-		buildUserAcls(cfg, &user)
-	}
-
-	path := ""
-	if req != nil {
-		path = req.Spec().Procedure
-	}
-
-	log.WithFields(log.Fields{
-		"username":      user.Username,
-		"usergroupLine": user.UsergroupLine,
-		"provider":      user.Provider,
-		"acls":          user.Acls,
-		"path":          path,
-	}).Debugf("Authenticated API request")
-	return &user
-}
-
-//gocyclo:ignore
-func userFromHeaders[T any](req *connect.Request[T], cfg *config.Config) AuthenticatedUser {
-	var u AuthenticatedUser
-	if req == nil {
-		return u
-	}
-	if cfg.AuthHttpHeaderUsername != "" {
-		u.Username = getHeaderKeyOrEmpty(req.Header(), cfg.AuthHttpHeaderUsername)
-	}
-	if cfg.AuthHttpHeaderUserGroup != "" {
-		u.UsergroupLine = getHeaderKeyOrEmpty(req.Header(), cfg.AuthHttpHeaderUserGroup)
-	}
-	if prov := getHeaderKeyOrEmpty(req.Header(), "provider"); prov != "" {
-		u.Provider = prov
-	}
-	return u
-}
-
-//gocyclo:ignore
-func userFromLocalSession[T any](req *connect.Request[T], cfg *config.Config, u AuthenticatedUser) AuthenticatedUser {
-	if req == nil || u.Username != "" {
-		return u
-	}
-	dummy := &http.Request{Header: req.Header()}
-	c, err := dummy.Cookie("olivetin-sid-local")
-	if err != nil || c == nil || c.Value == "" {
-		return u
-	}
-	sess := auth.GetUserSession("local", c.Value)
-	if sess == nil {
-		log.WithFields(log.Fields{"sid": c.Value, "provider": "local"}).Warn("UserFromContext: stale local session")
-		return u
-	}
-	if cfgUser := cfg.FindUserByUsername(sess.Username); cfgUser != nil {
-		u.Username = cfgUser.Username
-		u.UsergroupLine = cfgUser.Usergroup
-		u.Provider = "local"
-		u.SID = c.Value
-		return u
-	}
-	log.WithFields(log.Fields{"username": sess.Username}).Warn("UserFromContext: local session user not in config")
-	return u
-}
-
-func UserGuest(cfg *config.Config) *AuthenticatedUser {
-	ret := &AuthenticatedUser{}
-	ret.Username = "guest"
-	ret.UsergroupLine = "guest"
-	ret.Provider = "system"
-
-	buildUserAcls(cfg, ret)
-
-	return ret
-}
-
-func UserFromSystem(cfg *config.Config, username string) *AuthenticatedUser {
-	ret := &AuthenticatedUser{
-		Username:      username,
-		UsergroupLine: "system",
-		Provider:      "system",
-	}
-
-	buildUserAcls(cfg, ret)
-
-	return ret
-}
-
-func buildUserAcls(cfg *config.Config, user *AuthenticatedUser) {
-	for _, acl := range cfg.AccessControlLists {
-		if slices.Contains(acl.MatchUsernames, user.Username) {
-			user.Acls = append(user.Acls, acl.Name)
-			continue
-		}
-
-		if user.matchesUsergroupAcl(acl.MatchUsergroups, cfg.AuthHttpHeaderUserGroupSep) {
-			user.Acls = append(user.Acls, acl.Name)
-			continue
-		}
-	}
-
-	user.EffectivePolicy = getEffectivePolicy(cfg, user)
-}
-
-func isACLRelevantToAction(cfg *config.Config, actionAcls []string, acl *config.AccessControlList, user *AuthenticatedUser) bool {
+func isACLRelevantToAction(cfg *config.Config, actionAcls []string, acl *config.AccessControlList, user *authpublic.AuthenticatedUser) bool {
 	if !slices.Contains(user.Acls, acl.Name) {
 		// If the user does not have this ACL, then it is not relevant
 
@@ -325,7 +152,7 @@ func isACLRelevantToAction(cfg *config.Config, actionAcls []string, acl *config.
 	return false
 }
 
-func getRelevantAcls(cfg *config.Config, actionAcls []string, user *AuthenticatedUser) []*config.AccessControlList {
+func getRelevantAcls(cfg *config.Config, actionAcls []string, user *authpublic.AuthenticatedUser) []*config.AccessControlList {
 	var ret []*config.AccessControlList
 
 	for _, acl := range cfg.AccessControlLists {
@@ -336,32 +163,3 @@ func getRelevantAcls(cfg *config.Config, actionAcls []string, user *Authenticate
 
 	return ret
 }
-
-func getEffectivePolicy(cfg *config.Config, user *AuthenticatedUser) *config.ConfigurationPolicy {
-	ret := &config.ConfigurationPolicy{
-		ShowDiagnostics: cfg.DefaultPolicy.ShowDiagnostics,
-		ShowLogList:     cfg.DefaultPolicy.ShowLogList,
-	}
-
-	for _, acl := range cfg.AccessControlLists {
-		if slices.Contains(user.Acls, acl.Name) {
-			logAclMatched(cfg, "GetEffectivePolicy", user, nil, acl)
-
-			ret = buildConfigurationPolicy(ret, acl.Policy)
-		}
-	}
-
-	return ret
-}
-
-func buildConfigurationPolicy(ret *config.ConfigurationPolicy, policy config.ConfigurationPolicy) *config.ConfigurationPolicy {
-	if policy.ShowDiagnostics {
-		ret.ShowDiagnostics = policy.ShowDiagnostics
-	}
-
-	if policy.ShowLogList {
-		ret.ShowLogList = policy.ShowLogList
-	}
-
-	return ret
-}

+ 5 - 53
service/internal/acl/acl_test.go

@@ -1,8 +1,9 @@
 package acl
 
 import (
-	"github.com/stretchr/testify/assert"
 	"testing"
+
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
 )
 
 func Test_hasGroupsMatch(t *testing.T) {
@@ -49,63 +50,14 @@ func Test_hasGroupsMatch(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			user := &AuthenticatedUser{
+			user := &authpublic.AuthenticatedUser{
 				Username:      "testuser",
 				UsergroupLine: tt.usergroupLine,
 			}
 
-			if matches := user.matchesUsergroupAcl(tt.aclMatchUsergroups, tt.sep); matches != tt.matches {
-				t.Errorf("AuthenticatedUser.matchesUsergroupAcl() = %v, want %v for usergroups %v", matches, tt.matches, tt.aclMatchUsergroups)
+			if matches := user.MatchesUsergroupAcl(tt.aclMatchUsergroups, tt.sep); matches != tt.matches {
+				t.Errorf("AuthenticatedUser.MatchesUsergroupAcl() = %v, want %v for usergroups %v", matches, tt.matches, tt.aclMatchUsergroups)
 			}
 		})
 	}
 }
-
-func Test_parseUsergroupLine(t *testing.T) {
-	tests := []struct {
-		name           string
-		usergroupLine  string
-		expectedGroups []string
-		sep            string
-	}{
-		{
-			name:           "Default separator (space)",
-			usergroupLine:  "group1 group2",
-			expectedGroups: []string{"group1", "group2"},
-		},
-		{
-			name:           "Comma-separated groups",
-			usergroupLine:  "group1 , group2",
-			expectedGroups: []string{"group1", "group2"},
-			sep:            ",",
-		},
-		{
-			name:           "Multiple spaces",
-			usergroupLine:  "group1 , group2      , group3",
-			expectedGroups: []string{"group1", "group2", "group3"},
-			sep:            ",",
-		},
-		{
-			name:           "Empty usergroup line",
-			usergroupLine:  "",
-			expectedGroups: []string{},
-		},
-		{
-			name:           "Empty group names",
-			usergroupLine:  "|group1| | group3|",
-			expectedGroups: []string{"group1", "group3"},
-			sep:            "|",
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			user := &AuthenticatedUser{
-				Username:      "testuser",
-				UsergroupLine: tt.usergroupLine,
-			}
-
-			assert.Equal(t, tt.expectedGroups, user.parseUsergroupLine(tt.sep))
-		})
-	}
-}

+ 34 - 40
service/internal/api/api.go

@@ -3,6 +3,7 @@ package api
 import (
 	ctx "context"
 	"encoding/json"
+	"sort"
 
 	"connectrpc.com/connect"
 	"google.golang.org/protobuf/encoding/protojson"
@@ -18,6 +19,7 @@ import (
 
 	acl "github.com/OliveTin/OliveTin/internal/acl"
 	auth "github.com/OliveTin/OliveTin/internal/auth"
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	entities "github.com/OliveTin/OliveTin/internal/entities"
 	executor "github.com/OliveTin/OliveTin/internal/executor"
@@ -50,7 +52,7 @@ func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient {
 
 type streamingClient struct {
 	channel           chan *apiv1.EventStreamResponse
-	AuthenticatedUser *acl.AuthenticatedUser
+	AuthenticatedUser *authpublic.AuthenticatedUser
 }
 
 func (api *oliveTinAPI) KillAction(ctx ctx.Context, req *connect.Request[apiv1.KillActionRequest]) (*connect.Response[apiv1.KillActionResponse], error) {
@@ -77,17 +79,18 @@ func (api *oliveTinAPI) KillAction(ctx ctx.Context, req *connect.Request[apiv1.K
 		return connect.NewResponse(ret), nil
 	}
 
-	user := acl.UserFromContext(ctx, req, api.cfg)
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
 	api.killActionByTrackingId(user, action, execReqLogEntry, ret)
 
 	return connect.NewResponse(ret), nil
 }
 
-func (api *oliveTinAPI) killActionByTrackingId(user *acl.AuthenticatedUser, action *config.Action, execReqLogEntry *executor.InternalLogEntry, ret *apiv1.KillActionResponse) {
+func (api *oliveTinAPI) killActionByTrackingId(user *authpublic.AuthenticatedUser, action *config.Action, execReqLogEntry *executor.InternalLogEntry, ret *apiv1.KillActionResponse) {
 	if !acl.IsAllowedKill(api.cfg, user, action) {
 		log.Warnf("Killing execution request not possible - user not allowed to kill this action: %v", execReqLogEntry.ExecutionTrackingID)
 		ret.Killed = false
+		return
 	}
 
 	err := api.executor.Kill(execReqLogEntry)
@@ -114,7 +117,7 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1.
 		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.BindingId))
 	}
 
-	authenticatedUser := acl.UserFromContext(ctx, req, api.cfg)
+	authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
 
 	execReq := executor.ExecutionRequest{
 		Binding:           pair,
@@ -203,7 +206,7 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request
 		args[arg.Name] = arg.Value
 	}
 
-	user := acl.UserFromContext(ctx, req, api.cfg)
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
 	execReq := executor.ExecutionRequest{
 		Binding:           api.executor.FindBindingByID(req.Msg.ActionId),
@@ -234,7 +237,7 @@ func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[a
 		Binding:           api.executor.FindBindingByID(req.Msg.ActionId),
 		TrackingID:        uuid.NewString(),
 		Arguments:         args,
-		AuthenticatedUser: acl.UserFromContext(ctx, req, api.cfg),
+		AuthenticatedUser: auth.UserFromApiCall(ctx, req, api.cfg),
 		Cfg:               api.cfg,
 	}
 
@@ -248,7 +251,7 @@ func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[a
 func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) {
 	args := make(map[string]string)
 
-	user := acl.UserFromContext(ctx, req, api.cfg)
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
 	execReq := executor.ExecutionRequest{
 		Binding:           api.executor.FindBindingByID(req.Msg.ActionId),
@@ -272,7 +275,7 @@ func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Re
 	}
 }
 
-func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry, authenticatedUser *acl.AuthenticatedUser) *apiv1.LogEntry {
+func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry, authenticatedUser *authpublic.AuthenticatedUser) *apiv1.LogEntry {
 	pble := &apiv1.LogEntry{
 		ActionTitle:         logEntry.ActionTitle,
 		ActionIcon:          logEntry.ActionIcon,
@@ -326,7 +329,7 @@ func getMostRecentExecutionStatusById(api *oliveTinAPI, actionId string) *execut
 func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[apiv1.ExecutionStatusRequest]) (*connect.Response[apiv1.ExecutionStatusResponse], error) {
 	res := &apiv1.ExecutionStatusResponse{}
 
-	user := acl.UserFromContext(ctx, req, api.cfg)
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
 	if err := api.checkDashboardAccess(user); err != nil {
 		return nil, err
@@ -351,7 +354,7 @@ func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[ap
 }
 
 func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.LogoutRequest]) (*connect.Response[apiv1.LogoutResponse], error) {
-	user := acl.UserFromContext(ctx, req, api.cfg)
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
 	log.WithFields(log.Fields{
 		"username": user.Username,
@@ -374,7 +377,7 @@ func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.Logou
 }
 
 func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[apiv1.GetActionBindingRequest]) (*connect.Response[apiv1.GetActionBindingResponse], error) {
-	user := acl.UserFromContext(ctx, req, api.cfg)
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
 	if err := api.checkDashboardAccess(user); err != nil {
 		return nil, err
@@ -396,7 +399,7 @@ func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[a
 }
 
 func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardRequest]) (*connect.Response[apiv1.GetDashboardResponse], error) {
-	user := acl.UserFromContext(ctx, req, api.cfg)
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
 	if err := api.checkDashboardAccess(user); err != nil {
 		return nil, err
@@ -411,14 +414,14 @@ func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1
 	return api.buildCustomDashboardResponse(dashboardRenderRequest, req.Msg.Title)
 }
 
-func (api *oliveTinAPI) checkDashboardAccess(user *acl.AuthenticatedUser) error {
+func (api *oliveTinAPI) checkDashboardAccess(user *authpublic.AuthenticatedUser) error {
 	if user.IsGuest() && api.cfg.AuthRequireGuestsToLogin {
 		return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("guests are not allowed to access the dashboard"))
 	}
 	return nil
 }
 
-func (api *oliveTinAPI) createDashboardRenderRequest(user *acl.AuthenticatedUser) *DashboardRenderRequest {
+func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser) *DashboardRenderRequest {
 	return &DashboardRenderRequest{
 		AuthenticatedUser: user,
 		cfg:               api.cfg,
@@ -446,7 +449,7 @@ func (api *oliveTinAPI) buildCustomDashboardResponse(rr *DashboardRenderRequest,
 }
 
 func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetLogsRequest]) (*connect.Response[apiv1.GetLogsResponse], error) {
-	user := acl.UserFromContext(ctx, req, api.cfg)
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
 	if err := api.checkDashboardAccess(user); err != nil {
 		return nil, err
@@ -470,7 +473,7 @@ func isValidLogEntry(e *executor.InternalLogEntry) bool {
 }
 
 // isLogEntryAllowed checks if a log entry is allowed to be viewed by the user.
-func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *acl.AuthenticatedUser) bool {
+func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool {
 	return acl.IsAllowedLogs(api.cfg, user, e.Binding.Action)
 }
 
@@ -498,7 +501,7 @@ func calculateReversedIndices(page pageInfo, filteredLen int) (int64, int64) {
 }
 
 // buildActionLogsResponse builds the response with paginated log entries.
-func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLogEntry, page pageInfo, user *acl.AuthenticatedUser) *apiv1.GetActionLogsResponse {
+func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLogEntry, page pageInfo, user *authpublic.AuthenticatedUser) *apiv1.GetActionLogsResponse {
 	startIdx, endIdx := calculateReversedIndices(page, len(filtered))
 	ret := &apiv1.GetActionLogsResponse{}
 	for _, le := range filtered[startIdx:endIdx] {
@@ -512,7 +515,7 @@ func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLog
 }
 
 func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv1.GetActionLogsRequest]) (*connect.Response[apiv1.GetActionLogsResponse], error) {
-	user := acl.UserFromContext(ctx, req, api.cfg)
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
 	if err := api.checkDashboardAccess(user); err != nil {
 		return nil, err
@@ -527,20 +530,7 @@ func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv
 	return connect.NewResponse(api.buildActionLogsResponse(filtered, page, user)), nil
 }
 
-func (api *oliveTinAPI) pbLogsFiltered(entries []*executor.InternalLogEntry, user *acl.AuthenticatedUser) []*apiv1.LogEntry {
-	out := make([]*apiv1.LogEntry, 0, len(entries))
-	for _, e := range entries {
-		if !isValidLogEntry(e) {
-			continue
-		}
-		if api.isLogEntryAllowed(e, user) {
-			out = append(out, api.internalLogEntryToPb(e, user))
-		}
-	}
-	return out
-}
-
-func (api *oliveTinAPI) filterLogsByACL(entries []*executor.InternalLogEntry, user *acl.AuthenticatedUser) []*executor.InternalLogEntry {
+func (api *oliveTinAPI) filterLogsByACL(entries []*executor.InternalLogEntry, user *authpublic.AuthenticatedUser) []*executor.InternalLogEntry {
 	filtered := make([]*executor.InternalLogEntry, 0, len(entries))
 	for _, e := range entries {
 		if !isValidLogEntry(e) {
@@ -595,7 +585,7 @@ func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Reque
 }
 
 func (api *oliveTinAPI) WhoAmI(ctx ctx.Context, req *connect.Request[apiv1.WhoAmIRequest]) (*connect.Response[apiv1.WhoAmIResponse], error) {
-	user := acl.UserFromContext(ctx, req, api.cfg)
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
 	if err := api.checkDashboardAccess(user); err != nil {
 		return nil, err
@@ -681,7 +671,7 @@ func (api *oliveTinAPI) GetReadyz(ctx ctx.Context, req *connect.Request[apiv1.Ge
 func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.EventStreamRequest], srv *connect.ServerStream[apiv1.EventStreamResponse]) error {
 	log.Debugf("EventStream: %v", req.Msg)
 
-	user := acl.UserFromContext(ctx, req, api.cfg)
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
 	if err := api.checkDashboardAccess(user); err != nil {
 		return err
@@ -800,7 +790,7 @@ func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[api
 }
 
 func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitRequest]) (*connect.Response[apiv1.InitResponse], error) {
-	user := acl.UserFromContext(ctx, req, api.cfg)
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
 	loginRequired := user.IsGuest() && api.cfg.AuthRequireGuestsToLogin
 
@@ -833,7 +823,7 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
 	return connect.NewResponse(res), nil
 }
 
-func (api *oliveTinAPI) buildRootDashboards(user *acl.AuthenticatedUser, dashboards []*config.DashboardComponent) []string {
+func (api *oliveTinAPI) buildRootDashboards(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []string {
 	var rootDashboards []string
 	dashboardRenderRequest := api.createDashboardRenderRequest(user)
 
@@ -865,14 +855,18 @@ func (api *oliveTinAPI) addCustomDashboards(rootDashboards *[]string, dashboards
 func buildPublicOAuth2ProvidersList(cfg *config.Config) []*apiv1.OAuth2Provider {
 	var publicProviders []*apiv1.OAuth2Provider
 
-	for _, provider := range cfg.AuthOAuth2Providers {
+	for providerKey, provider := range cfg.AuthOAuth2Providers {
 		publicProviders = append(publicProviders, &apiv1.OAuth2Provider{
 			Title: provider.Title,
-			Url:   provider.AuthUrl,
 			Icon:  provider.Icon,
+			Key:   providerKey,
 		})
 	}
 
+	sort.Slice(publicProviders, func(i, j int) bool {
+		return publicProviders[i].Key < publicProviders[j].Key
+	})
+
 	return publicProviders
 }
 
@@ -914,7 +908,7 @@ func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string
 }
 
 func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) {
-	user := acl.UserFromContext(ctx, req, api.cfg)
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
 	if err := api.checkDashboardAccess(user); err != nil {
 		return nil, err
@@ -967,7 +961,7 @@ func findEntityInComponents(entityTitle string, parentTitle string, components [
 }
 
 func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.GetEntityRequest]) (*connect.Response[apiv1.Entity], error) {
-	user := acl.UserFromContext(ctx, req, api.cfg)
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
 	if err := api.checkDashboardAccess(user); err != nil {
 		return nil, err

+ 2 - 1
service/internal/api/apiActions.go

@@ -3,13 +3,14 @@ package api
 import (
 	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
 	acl "github.com/OliveTin/OliveTin/internal/acl"
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	entities "github.com/OliveTin/OliveTin/internal/entities"
 	executor "github.com/OliveTin/OliveTin/internal/executor"
 )
 
 type DashboardRenderRequest struct {
-	AuthenticatedUser *acl.AuthenticatedUser
+	AuthenticatedUser *authpublic.AuthenticatedUser
 	cfg               *config.Config
 	ex                *executor.Executor
 }

+ 70 - 0
service/internal/auth/authcheck.go

@@ -0,0 +1,70 @@
+package auth
+
+import (
+	"context"
+	"net/http"
+
+	"connectrpc.com/connect"
+	types "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	otjwt "github.com/OliveTin/OliveTin/internal/auth/otjwt"
+	"github.com/OliveTin/OliveTin/internal/config"
+	log "github.com/sirupsen/logrus"
+)
+
+var authChain = []func(*types.AuthCheckingContext) *types.AuthenticatedUser{
+	checkUserFromHeaders,
+	checkUserFromLocalSession,
+	otjwt.CheckUserFromJwtHeader,
+	otjwt.CheckUserFromJwtCookie,
+}
+
+// Handlers like the OAuth2's handler are "instance methods", so they need to be added to the auth chain after the other handlers.
+func AddAuthChainFunction(check func(*types.AuthCheckingContext) *types.AuthenticatedUser) {
+	authChain = append(authChain, check)
+}
+
+func runAuthChain[T any](req *connect.Request[T], cfg *config.Config) *types.AuthenticatedUser {
+	var user *types.AuthenticatedUser
+
+	authCtx := &types.AuthCheckingContext{
+		Request: &http.Request{Header: req.Header()},
+		Config:  cfg,
+	}
+
+	for _, check := range authChain {
+		user = check(authCtx)
+
+		if user != nil && user.Username != "" {
+			return user
+		}
+	}
+
+	return nil
+}
+
+func UserFromApiCall[T any](ctx context.Context, req *connect.Request[T], cfg *config.Config) *types.AuthenticatedUser {
+	user := runAuthChain(req, cfg)
+
+	log.Tracef("UserFromApiCall Context: %+v", ctx)
+
+	if user == nil || user.Username == "" {
+		user = UserGuest(cfg)
+	} else {
+		user.BuildUserAcls(cfg)
+	}
+
+	path := ""
+	if req != nil {
+		path = req.Spec().Procedure
+	}
+
+	log.WithFields(log.Fields{
+		"username":      user.Username,
+		"usergroupLine": user.UsergroupLine,
+		"provider":      user.Provider,
+		"acls":          user.Acls,
+		"path":          path,
+	}).Debugf("Authenticated API request")
+
+	return user
+}

+ 102 - 0
service/internal/auth/authpublic/authenticateduser.go

@@ -0,0 +1,102 @@
+package authpublic
+
+import (
+	"slices"
+	"strings"
+
+	"github.com/OliveTin/OliveTin/internal/config"
+	log "github.com/sirupsen/logrus"
+)
+
+// User represents a person.
+type AuthenticatedUser struct {
+	Username      string
+	UsergroupLine string
+
+	Provider string
+	SID      string
+
+	Acls []string
+
+	EffectivePolicy *config.ConfigurationPolicy
+}
+
+func (u *AuthenticatedUser) IsGuest() bool {
+	return u.Username == "guest" && u.Provider == "system"
+}
+
+func (u *AuthenticatedUser) parseUsergroupLine(sep string) []string {
+	ret := []string{}
+
+	if sep != "" {
+		for _, v := range strings.Split(u.UsergroupLine, sep) {
+			trimmed := strings.TrimSpace(v)
+
+			if trimmed != "" {
+				ret = append(ret, trimmed)
+			}
+		}
+	} else {
+		ret = strings.Fields(u.UsergroupLine)
+	}
+
+	log.Debugf("parseUsergroupLine: %v, %v, sep:%v", u.UsergroupLine, ret, sep)
+
+	return ret
+}
+
+func (u *AuthenticatedUser) MatchesUsergroupAcl(matchUsergroups []string, sep string) bool {
+	groupList := u.parseUsergroupLine(sep)
+
+	for _, group := range groupList {
+		if slices.Contains(matchUsergroups, group) {
+			log.Debugf("Usergroup %v found in %+v (len: %v)", group, groupList, len(groupList))
+			return true
+		}
+	}
+
+	return false
+}
+
+func (u *AuthenticatedUser) BuildUserAcls(cfg *config.Config) {
+	for _, acl := range cfg.AccessControlLists {
+		if slices.Contains(acl.MatchUsernames, u.Username) {
+			u.Acls = append(u.Acls, acl.Name)
+			continue
+		}
+
+		if u.MatchesUsergroupAcl(acl.MatchUsergroups, cfg.AuthHttpHeaderUserGroupSep) {
+			u.Acls = append(u.Acls, acl.Name)
+			continue
+		}
+	}
+
+	u.EffectivePolicy = getEffectivePolicy(cfg, u)
+}
+
+func getEffectivePolicy(cfg *config.Config, u *AuthenticatedUser) *config.ConfigurationPolicy {
+	ret := &config.ConfigurationPolicy{
+		ShowDiagnostics: cfg.DefaultPolicy.ShowDiagnostics,
+		ShowLogList:     cfg.DefaultPolicy.ShowLogList,
+	}
+
+	for _, acl := range cfg.AccessControlLists {
+		if slices.Contains(u.Acls, acl.Name) {
+			ret = buildConfigurationPolicy(ret, acl.Policy)
+		}
+	}
+
+	return ret
+}
+
+func buildConfigurationPolicy(ret *config.ConfigurationPolicy, policy config.ConfigurationPolicy) *config.ConfigurationPolicy {
+	if policy.ShowDiagnostics {
+		ret.ShowDiagnostics = policy.ShowDiagnostics
+	}
+
+	if policy.ShowLogList {
+		ret.ShowLogList = policy.ShowLogList
+	}
+
+	return ret
+}

+ 56 - 0
service/internal/auth/authpublic/authenticateduser_test.go

@@ -0,0 +1,56 @@
+package authpublic
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_parseUsergroupLine(t *testing.T) {
+	tests := []struct {
+		name           string
+		usergroupLine  string
+		expectedGroups []string
+		sep            string
+	}{
+		{
+			name:           "Default separator (space)",
+			usergroupLine:  "group1 group2",
+			expectedGroups: []string{"group1", "group2"},
+		},
+		{
+			name:           "Comma-separated groups",
+			usergroupLine:  "group1 , group2",
+			expectedGroups: []string{"group1", "group2"},
+			sep:            ",",
+		},
+		{
+			name:           "Multiple spaces",
+			usergroupLine:  "group1 , group2      , group3",
+			expectedGroups: []string{"group1", "group2", "group3"},
+			sep:            ",",
+		},
+		{
+			name:           "Empty usergroup line",
+			usergroupLine:  "",
+			expectedGroups: []string{},
+		},
+		{
+			name:           "Empty group names",
+			usergroupLine:  "|group1| | group3|",
+			expectedGroups: []string{"group1", "group3"},
+			sep:            "|",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			user := &AuthenticatedUser{
+				Username:      "testuser",
+				UsergroupLine: tt.usergroupLine,
+			}
+
+			assert.Equal(t, tt.expectedGroups, user.parseUsergroupLine(tt.sep))
+		})
+	}
+}

+ 12 - 0
service/internal/auth/authpublic/context.go

@@ -0,0 +1,12 @@
+package authpublic
+
+import (
+	"net/http"
+
+	"github.com/OliveTin/OliveTin/internal/config"
+)
+
+type AuthCheckingContext struct {
+	Config  *config.Config
+	Request *http.Request
+}

+ 49 - 0
service/internal/auth/local.go

@@ -0,0 +1,49 @@
+package auth
+
+import (
+	"net/http"
+
+	types "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	log "github.com/sirupsen/logrus"
+)
+
+func getLocalSessionCookie(r *http.Request) (string, bool) {
+	c, err := r.Cookie("olivetin-sid-local")
+	if err != nil {
+		return "", false
+	}
+	if c == nil {
+		return "", false
+	}
+	if c.Value == "" {
+		return "", false
+	}
+	return c.Value, true
+}
+
+func checkUserFromLocalSession(context *types.AuthCheckingContext) *types.AuthenticatedUser {
+	u := &types.AuthenticatedUser{}
+
+	sid, ok := getLocalSessionCookie(context.Request)
+	if !ok {
+		return u
+	}
+
+	sess := GetUserSession("local", sid)
+	if sess == nil {
+		log.WithFields(log.Fields{"sid": sid, "provider": "local"}).Warn("UserFromContext: stale local session")
+		return u
+	}
+
+	cfgUser := context.Config.FindUserByUsername(sess.Username)
+	if cfgUser == nil {
+		log.WithFields(log.Fields{"username": sess.Username}).Warn("UserFromContext: local session user not in config")
+		return u
+	}
+
+	u.Username = cfgUser.Username
+	u.UsergroupLine = cfgUser.Usergroup
+	u.Provider = "local"
+	u.SID = sid
+	return u
+}

+ 127 - 68
service/internal/httpservers/restapi_auth_jwt.go → service/internal/auth/otjwt/jwt.go

@@ -1,73 +1,144 @@
-package httpservers
+package otjwt
 
 import (
 	"context"
 	"crypto/rsa"
 	"errors"
 	"fmt"
-	"github.com/golang-jwt/jwt/v5"
-	log "github.com/sirupsen/logrus"
-	"net/http"
 	"os"
 	"strings"
+	"sync"
+	"time"
 
-	"github.com/OliveTin/OliveTin/internal/config"
-
-	//	"github.com/coreos/go-oidc/v3/oidc"
 	"github.com/MicahParks/keyfunc/v3"
-	"time"
+	authTypes "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	"github.com/OliveTin/OliveTin/internal/config"
+	"github.com/golang-jwt/jwt/v5"
+	log "github.com/sirupsen/logrus"
 )
 
+func parseJwtToken(cfg *config.Config, jwtString string) (*jwt.Token, error) {
+	if cfg.AuthJwtCertsURL != "" {
+		return parseJwtTokenWithRemoteKey(cfg, jwtString)
+	}
+
+	if cfg.AuthJwtPubKeyPath != "" {
+		return parseJwtTokenWithLocalKey(cfg, jwtString)
+	}
+
+	if cfg.AuthJwtHmacSecret == "" {
+		return nil, errors.New("no JWT authentication method configured")
+	}
+
+	return parseJwtTokenWithHMAC(cfg, jwtString)
+}
+
+func getClaimsFromJwtToken(cfg *config.Config, jwtString string) (jwt.MapClaims, error) {
+	token, err := parseJwtToken(cfg, jwtString)
+
+	if err != nil {
+		log.Errorf("jwt parse failure: %v", err)
+		return nil, errors.New("jwt parse failure")
+	}
+
+	if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
+		return claims, nil
+	} else {
+		return nil, errors.New("jwt token isn't valid")
+	}
+}
+
+func parseJwtTokenWithRemoteKey(cfg *config.Config, jwtToken string) (*jwt.Token, error) {
+	err := initJwks(cfg)
+
+	if err != nil {
+		log.Errorf("jwt init JWKS failure: %v", err)
+		return nil, err
+	}
+
+	return jwt.Parse(jwtToken, jwksVerifier.Keyfunc, jwt.WithAudience(cfg.AuthJwtAud))
+}
+
 var (
-	pubKeyBytes []byte = nil
-	pubKey      *rsa.PublicKey
+	pubKeyBytes   []byte = nil
+	pubKey        *rsa.PublicKey
+	loadedKeyPath string
 
 	jwksVerifier keyfunc.Keyfunc
-)
+	jwksOnce     sync.Once
+	jwksInitErr  error
 
-func initJwks(cfg *config.Config) {
-	if jwksVerifier == nil {
-		var err error
+	localKeyMutex   sync.RWMutex
+	localKeyInitErr error
+)
 
+func initJwks(cfg *config.Config) error {
+	jwksOnce.Do(func() {
 		if cfg.AuthJwtCertsURL != "" {
-			ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
+			ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+			defer cancel()
 
+			var err error
 			jwksVerifier, err = keyfunc.NewDefaultCtx(ctx, []string{
 				cfg.AuthJwtCertsURL,
 			})
 
 			if err != nil {
 				log.Errorf("Init JWKS Failure: %v", err)
+				jwksInitErr = err
 			}
-
-			defer cancel()
 		}
-	}
+	})
+	return jwksInitErr
 }
 
-func readLocalPublicKey(cfg *config.Config) error {
-	if pubKeyBytes != nil {
-		return nil // Already read.
-	}
-
-	pubKeyBytes, err := os.ReadFile(cfg.AuthJwtPubKeyPath)
+func loadPublicKeyFromFile(keyPath string) error {
+	keyBytes, err := os.ReadFile(keyPath)
 	if err != nil {
-		return fmt.Errorf("couldn't read public key from file %s", cfg.AuthJwtPubKeyPath)
+		return fmt.Errorf("couldn't read public key from file %s", keyPath)
 	}
 
-	// Since the token is RSA (which we validated at the start of this function), the return type of this function actually has to be rsa.PublicKey!
-	pubKey, err = jwt.ParseRSAPublicKeyFromPEM(pubKeyBytes)
+	parsedKey, err := jwt.ParseRSAPublicKeyFromPEM(keyBytes)
 	if err != nil {
-		return fmt.Errorf("error parsing public key object (from %s)", cfg.AuthJwtPubKeyPath)
+		return fmt.Errorf("error parsing public key object (from %s)", keyPath)
 	}
 
+	pubKeyBytes = keyBytes
+	pubKey = parsedKey
+	loadedKeyPath = keyPath
+	localKeyInitErr = nil
 	return nil
 }
 
-func parseJwtTokenWithRemoteKey(cfg *config.Config, jwtToken string) (*jwt.Token, error) {
-	initJwks(cfg)
+func isKeyLoadedForPath(keyPath string) bool {
+	return pubKeyBytes != nil && loadedKeyPath == keyPath
+}
 
-	return jwt.Parse(jwtToken, jwksVerifier.Keyfunc, jwt.WithAudience(cfg.AuthJwtAud))
+func readLocalPublicKeyWithLock(keyPath string) error {
+	localKeyMutex.RLock()
+	alreadyLoaded := isKeyLoadedForPath(keyPath)
+	localKeyMutex.RUnlock()
+
+	if alreadyLoaded {
+		return nil
+	}
+
+	localKeyMutex.Lock()
+	defer localKeyMutex.Unlock()
+
+	if isKeyLoadedForPath(keyPath) {
+		return nil
+	}
+
+	localKeyInitErr = loadPublicKeyFromFile(keyPath)
+	return localKeyInitErr
+}
+
+func readLocalPublicKey(cfg *config.Config) error {
+	if cfg.AuthJwtPubKeyPath == "" {
+		return errors.New("no JWT public key path configured")
+	}
+	return readLocalPublicKeyWithLock(cfg.AuthJwtPubKeyPath)
 }
 
 func parseJwtTokenWithLocalKey(cfg *config.Config, jwtString string) (*jwt.Token, error) {
@@ -97,33 +168,6 @@ func parseJwtTokenWithHMAC(cfg *config.Config, jwtString string) (*jwt.Token, er
 	})
 }
 
-func parseJwtToken(cfg *config.Config, jwtString string) (*jwt.Token, error) {
-	if cfg.AuthJwtCertsURL != "" {
-		return parseJwtTokenWithRemoteKey(cfg, jwtString)
-	}
-
-	if cfg.AuthJwtPubKeyPath != "" {
-		return parseJwtTokenWithLocalKey(cfg, jwtString)
-	}
-
-	return parseJwtTokenWithHMAC(cfg, jwtString)
-}
-
-func getClaimsFromJwtToken(cfg *config.Config, jwtString string) (jwt.MapClaims, error) {
-	token, err := parseJwtToken(cfg, jwtString)
-
-	if err != nil {
-		log.Errorf("jwt parse failure: %v", err)
-		return nil, errors.New("jwt parse failure")
-	}
-
-	if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
-		return claims, nil
-	} else {
-		return nil, errors.New("jwt token isn't valid")
-	}
-}
-
 func lookupClaimValueOrDefault(claims jwt.MapClaims, key string, def string) string {
 	if val, ok := claims[key]; ok {
 		return fmt.Sprintf("%s", val)
@@ -132,33 +176,48 @@ func lookupClaimValueOrDefault(claims jwt.MapClaims, key string, def string) str
 	}
 }
 
-func parseJwtCookie(cfg *config.Config, request *http.Request) (string, string) {
-	cookie, err := request.Cookie(cfg.AuthJwtCookieName)
+func CheckUserFromJwtCookie(context *authTypes.AuthCheckingContext) *authTypes.AuthenticatedUser {
+	cookie, err := context.Request.Cookie(context.Config.AuthJwtCookieName)
 
 	if err != nil {
-		log.Debugf("jwt cookie check %v name: %v", err, cfg.AuthJwtCookieName)
-		return "", ""
+		log.Debugf("jwt cookie check %v name: %v", err, context.Config.AuthJwtCookieName)
+		return nil
 	}
 
-	return parseJwt(cfg, cookie.Value)
+	return parseJwt(context.Config, cookie.Value)
 }
 
-func parseJwt(cfg *config.Config, token string) (string, string) {
+func CheckUserFromJwtHeader(context *authTypes.AuthCheckingContext) *authTypes.AuthenticatedUser {
+	header := context.Request.Header.Get(context.Config.AuthJwtHeader)
+	if header == "" {
+		return nil
+	}
+
+	token := strings.TrimPrefix(header, "Bearer ")
+	token = strings.TrimSpace(token)
+
+	return parseJwt(context.Config, token)
+}
+
+func parseJwt(cfg *config.Config, token string) *authTypes.AuthenticatedUser {
 	claims, err := getClaimsFromJwtToken(cfg, token)
 
 	if err != nil {
 		log.Warnf("jwt claim error: %+v", err)
-		return "", ""
+		return nil
 	}
 
 	if cfg.InsecureAllowDumpJwtClaims {
 		log.Debugf("JWT Claims %+v", claims)
 	}
 
-	username := lookupClaimValueOrDefault(claims, cfg.AuthJwtClaimUsername, "")
-	usergroup := parseGroupClaim(cfg.AuthJwtClaimUserGroup, claims)
+	user := &authTypes.AuthenticatedUser{
+		Username:      lookupClaimValueOrDefault(claims, cfg.AuthJwtClaimUsername, ""),
+		UsergroupLine: parseGroupClaim(cfg.AuthJwtClaimUserGroup, claims),
+		Provider:      "jwt",
+	}
 
-	return username, usergroup
+	return user
 }
 
 func parseGroupClaim(groupClaim string, claims jwt.MapClaims) string {

+ 205 - 0
service/internal/auth/otjwt/jwt_test.go

@@ -0,0 +1,205 @@
+package otjwt
+
+import (
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/pem"
+	"io"
+
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/golang-jwt/jwt/v5"
+	"github.com/stretchr/testify/assert"
+)
+
+func generateRSAKeyPair(t *testing.T) (*rsa.PrivateKey, []byte) {
+	privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("failed to generate RSA key: %v", err)
+	}
+
+	pubKey := &privateKey.PublicKey
+	pkixPubKey, err := x509.MarshalPKIXPublicKey(pubKey)
+	if err != nil {
+		t.Fatalf("failed to marshal public key: %v", err)
+	}
+
+	pubPem := pem.EncodeToMemory(
+		&pem.Block{
+			Type:  "PUBLIC KEY",
+			Bytes: pkixPubKey,
+		},
+	)
+
+	return privateKey, pubPem
+}
+
+func createKeys(t *testing.T) (*rsa.PrivateKey, string) {
+	tmpFile, err := os.CreateTemp(os.TempDir(), "olivetin-jwt-")
+	if err != nil {
+		t.Fatalf("failed to create temp file: %v", err)
+	}
+	defer tmpFile.Close()
+
+	t.Logf("Created File: %s", tmpFile.Name())
+
+	privateKey, pubPem := generateRSAKeyPair(t)
+
+	if err := os.WriteFile(tmpFile.Name(), pubPem, 0644); err != nil {
+		t.Fatalf("error when dumping pubKey: %s \n", err)
+	}
+
+	return privateKey, tmpFile.Name()
+}
+
+func newMux() *http.ServeMux {
+	mux := http.NewServeMux()
+
+	return mux
+}
+
+func createJWTTokenWithExpiration(t *testing.T, privateKey *rsa.PrivateKey, expire int64) string {
+	token := jwt.New(jwt.SigningMethodRS256)
+	claims := token.Claims.(jwt.MapClaims)
+	claims["nbf"] = time.Now().Unix() - 1000
+	claims["exp"] = time.Now().Unix() + expire
+	claims["sub"] = "test"
+	claims["olivetinGroup"] = "test"
+
+	tokenStr, err := token.SignedString(privateKey)
+	if err != nil {
+		t.Fatalf("failed to sign JWT token: %v", err)
+	}
+	return tokenStr
+}
+
+func setupJWTTestHandler(t *testing.T, cfg *config.Config) http.Handler {
+	mux := newMux()
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		context := &authpublic.AuthCheckingContext{
+			Request: r,
+			Config:  cfg,
+		}
+		user := CheckUserFromJwtHeader(context)
+
+		if user == nil {
+			w.WriteHeader(403)
+			return
+		}
+
+		assert.Equal(t, "test", user.Username)
+		assert.Equal(t, "test", user.UsergroupLine)
+	})
+	return mux
+}
+
+func verifyJWTResponse(t *testing.T, res *http.Response, expectCode int) {
+	defer res.Body.Close()
+	assert.Equal(t, expectCode, res.StatusCode)
+	body, _ := io.ReadAll(res.Body)
+	t.Logf("Response body: %s", string(body))
+}
+
+func testJwkValidation(t *testing.T, expire int64, expectCode int) {
+	privateKey, publicKeyPath := createKeys(t)
+	defer os.Remove(publicKeyPath)
+
+	cfg := config.DefaultConfig()
+	cfg.AuthJwtPubKeyPath = publicKeyPath
+	cfg.AuthJwtClaimUsername = "sub"
+	cfg.AuthJwtClaimUserGroup = "olivetinGroup"
+	cfg.AuthJwtHeader = "Authorization"
+
+	tokenStr := createJWTTokenWithExpiration(t, privateKey, expire)
+	handler := setupJWTTestHandler(t, cfg)
+
+	srv := httptest.NewServer(handler)
+	defer srv.Close()
+
+	res := makeJWTRequest(t, srv, tokenStr)
+	verifyJWTResponse(t, res, expectCode)
+}
+
+func TestJWTSignatureVerificationSucceeds(t *testing.T) {
+	testJwkValidation(t, 1000, 200)
+}
+
+func TestJWTSignatureVerificationFails(t *testing.T) {
+	testJwkValidation(t, -500, 403)
+}
+
+func createJWTTokenWithGroups(t *testing.T, privateKey *rsa.PrivateKey, groups interface{}) string {
+	token := jwt.New(jwt.SigningMethodRS256)
+	claims := token.Claims.(jwt.MapClaims)
+	claims["nbf"] = time.Now().Unix() - 1000
+	claims["exp"] = time.Now().Unix() + 2000
+	claims["sub"] = "test"
+	claims["olivetinGroup"] = groups
+
+	tokenStr, err := token.SignedString(privateKey)
+	if err != nil {
+		t.Fatalf("failed to sign JWT token: %v", err)
+	}
+	return tokenStr
+}
+
+func makeJWTRequest(t *testing.T, srv *httptest.Server, tokenStr string) *http.Response {
+	req, err := http.NewRequest("GET", srv.URL, nil)
+	if err != nil {
+		t.Fatalf("failed to create request: %v", err)
+	}
+	req.Header.Set("Authorization", "Bearer "+tokenStr)
+
+	res, err := http.DefaultClient.Do(req)
+	if err != nil {
+		t.Fatalf("Client err: %+v", err)
+	}
+	return res
+}
+
+func TestJWTHeader(t *testing.T) {
+	privateKey, publicKeyPath := createKeys(t)
+	defer os.Remove(publicKeyPath)
+
+	cfg := config.DefaultConfig()
+	cfg.AuthJwtPubKeyPath = publicKeyPath
+	cfg.AuthJwtClaimUsername = "sub"
+	cfg.AuthJwtClaimUserGroup = "olivetinGroup"
+	cfg.AuthJwtHeader = "Authorization"
+
+	tokenStr := createJWTTokenWithGroups(t, privateKey, []string{"test", "test2"})
+
+	mux := newMux()
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		context := &authpublic.AuthCheckingContext{
+			Request: r,
+			Config:  cfg,
+		}
+		user := CheckUserFromJwtHeader(context)
+
+		if user == nil {
+			w.WriteHeader(403)
+			return
+		}
+
+		assert.Equal(t, "test", user.Username)
+		assert.Equal(t, "test test2", user.UsergroupLine)
+	})
+
+	srv := httptest.NewServer(mux)
+	defer srv.Close()
+
+	res := makeJWTRequest(t, srv, tokenStr)
+	defer res.Body.Close()
+
+	assert.Equal(t, 200, res.StatusCode)
+	body, _ := io.ReadAll(res.Body)
+	t.Logf("Response body: %s", string(body))
+}

+ 31 - 33
service/internal/httpservers/restapi_auth_oauth2.go → service/internal/auth/otoauth2/restapi_auth_oauth2.go

@@ -1,4 +1,4 @@
-package httpservers
+package otoauth2
 
 import (
 	"context"
@@ -8,13 +8,15 @@ import (
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
-	config "github.com/OliveTin/OliveTin/internal/config"
-	log "github.com/sirupsen/logrus"
-	"golang.org/x/oauth2"
 	"io"
 	"net/http"
 	"os"
 	"time"
+
+	authTypes "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	log "github.com/sirupsen/logrus"
+	"golang.org/x/oauth2"
 )
 
 type OAuth2Handler struct {
@@ -110,7 +112,7 @@ func (h *OAuth2Handler) setOAuthCallbackCookie(w http.ResponseWriter, r *http.Re
 	cookie := &http.Cookie{
 		Name:     name,
 		Value:    value,
-		MaxAge:   31556952, // 1 year
+		MaxAge:   900, // 15 minutes
 		Secure:   r.TLS != nil,
 		HttpOnly: true,
 		Path:     "/",
@@ -119,7 +121,7 @@ func (h *OAuth2Handler) setOAuthCallbackCookie(w http.ResponseWriter, r *http.Re
 	http.SetCookie(w, cookie)
 }
 
-func (h *OAuth2Handler) handleOAuthLogin(w http.ResponseWriter, r *http.Request) {
+func (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) {
 	state, err := randString(16)
 
 	if err != nil {
@@ -149,30 +151,31 @@ func (h *OAuth2Handler) handleOAuthLogin(w http.ResponseWriter, r *http.Request)
 	http.Redirect(w, r, provider.AuthCodeURL(state), http.StatusFound)
 }
 
+func (h *OAuth2Handler) validateStateMatch(queryState, cookieState string) bool {
+	return queryState == cookieState
+}
+
 func (h *OAuth2Handler) checkOAuthCallbackCookie(w http.ResponseWriter, r *http.Request) (*oauth2State, string, bool) {
 	cookie, err := r.Cookie("olivetin-sid-oauth")
-	state := cookie.Value
-
 	if err != nil {
 		log.Errorf("Failed to get state cookie: %v", err)
-
 		http.Error(w, "State not found", http.StatusBadRequest)
-		return nil, state, false
+		return nil, "", false
 	}
 
-	if r.URL.Query().Get("state") != state {
-		log.Errorf("State mismatch: %v != %v", r.URL.Query().Get("state"), state)
+	state := cookie.Value
 
+	if !h.validateStateMatch(r.URL.Query().Get("state"), state) {
+		log.Errorf("State mismatch: %v != %v", r.URL.Query().Get("state"), state)
 		http.Error(w, "State mismatch", http.StatusBadRequest)
 		return nil, state, false
 	}
 
 	registeredState, ok := h.registeredStates[state]
-
 	if !ok {
 		log.Errorf("State not found in server: %v", state)
-
 		http.Error(w, "State not found in server", http.StatusBadRequest)
+		return nil, state, false
 	}
 
 	return registeredState, state, true
@@ -210,13 +213,13 @@ func getOAuthCertBundle(providerConfig *config.OAuth2Provider) *x509.CertPool {
 	caCertPool := x509.NewCertPool()
 
 	if ok := caCertPool.AppendCertsFromPEM(caCert); !ok {
-		log.Errorf("OAuth2 Cert Bundle - failed to append certificates: %v", err)
+		log.Errorf("OAuth2 Cert Bundle - failed to append certificates from PEM")
 	}
 
 	return caCertPool
 }
 
-func (h *OAuth2Handler) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
+func (h *OAuth2Handler) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
 	log.Infof("OAuth2 Callback received")
 
 	registeredState, state, ok := h.checkOAuthCallbackCookie(w, r)
@@ -265,17 +268,7 @@ func (h *OAuth2Handler) handleOAuthCallback(w http.ResponseWriter, r *http.Reque
 	h.registeredStates[state].Username = userinfo.Username
 	h.registeredStates[state].Usergroup = userinfo.Usergroup
 
-	for k, v := range h.registeredStates {
-		log.Debugf("states: %+v %+v", k, v)
-	}
-
-	log.WithFields(log.Fields{
-		"state":    state,
-		"username": h.registeredStates[state].Username,
-	}).Info("OAuth2 login successful")
-
 	http.Redirect(w, r, "/", http.StatusFound)
-	w.Write([]byte("OAuth2 login successful."))
 }
 
 type UserInfo struct {
@@ -351,16 +344,18 @@ func getDataField(data map[string]any, field string) string {
 	return stringVal
 }
 
-func (h *OAuth2Handler) parseOAuth2Cookie(r *http.Request) (string, string, string) {
-	cookie, err := r.Cookie("olivetin-sid-oauth")
+func (h *OAuth2Handler) CheckUserFromOAuth2Cookie(context *authTypes.AuthCheckingContext) *authTypes.AuthenticatedUser {
+	cookie, err := context.Request.Cookie("olivetin-sid-oauth")
+
+	user := &authTypes.AuthenticatedUser{}
 
 	if err != nil {
 		log.Warnf("Failed to read OAuth2 cookie: %v", err)
-		return "", "", ""
+		return nil
 	}
 
 	if cookie.Value == "" {
-		return "", "", ""
+		return nil
 	}
 
 	serverState, found := h.registeredStates[cookie.Value]
@@ -371,10 +366,13 @@ func (h *OAuth2Handler) parseOAuth2Cookie(r *http.Request) (string, string, stri
 			"provider": "oauth2",
 		}).Warnf("Stale session")
 
-		return "", "", cookie.Value
+		return nil
 	}
 
-	log.Debugf("Found OAuth2 state: %+v", serverState)
+	user.Username = serverState.Username
+	user.UsergroupLine = serverState.Usergroup
+	user.Provider = "oauth2"
+	user.SID = cookie.Value
 
-	return serverState.Username, serverState.Usergroup, cookie.Value
+	return user
 }

+ 10 - 7
service/internal/httpservers/restapi_auth_oauth2_providers.go → service/internal/auth/otoauth2/restapi_auth_oauth2_providers.go

@@ -1,4 +1,4 @@
-package httpservers
+package otoauth2
 
 import (
 	config "github.com/OliveTin/OliveTin/internal/config"
@@ -13,14 +13,17 @@ var oauth2ProviderDatabase = map[string]config.OAuth2Provider{
 		WhoamiUrl:     "https://api.github.com/user",
 		TokenUrl:      endpoints.GitHub.TokenURL,
 		AuthUrl:       endpoints.GitHub.AuthURL,
-		Scopes:        []string{"profile", "email"},
+		Scopes:        []string{"read:user", "user:email"},
 		UsernameField: "login",
 	},
 	"google": {
-		Icon:      "google",
-		WhoamiUrl: "https://www.googleapis.com/oauth2/v3/userinfo",
-		TokenUrl:  endpoints.Google.TokenURL,
-		AuthUrl:   endpoints.Google.AuthURL,
-		Scopes:    []string{"profile", "email"},
+		Title:         "Google",
+		Name:          "google",
+		Icon:          "google",
+		UsernameField: "email",
+		WhoamiUrl:     "https://www.googleapis.com/oauth2/v3/userinfo",
+		TokenUrl:      endpoints.Google.TokenURL,
+		AuthUrl:       endpoints.Google.AuthURL,
+		Scopes:        []string{"profile", "email"},
 	},
 }

+ 29 - 0
service/internal/auth/system-users.go

@@ -0,0 +1,29 @@
+package auth
+
+import (
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	config "github.com/OliveTin/OliveTin/internal/config"
+)
+
+func UserGuest(cfg *config.Config) *authpublic.AuthenticatedUser {
+	ret := &authpublic.AuthenticatedUser{}
+	ret.Username = "guest"
+	ret.UsergroupLine = "guest"
+	ret.Provider = "system"
+
+	ret.BuildUserAcls(cfg)
+
+	return ret
+}
+
+func UserFromSystem(cfg *config.Config, username string) *authpublic.AuthenticatedUser {
+	ret := &authpublic.AuthenticatedUser{
+		Username:      username,
+		UsergroupLine: "system",
+		Provider:      "system",
+	}
+
+	ret.BuildUserAcls(cfg)
+
+	return ret
+}

+ 33 - 0
service/internal/auth/trusted-headers.go

@@ -0,0 +1,33 @@
+package auth
+
+import (
+	"net/http"
+
+	types "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+)
+
+//gocyclo:ignore
+func checkUserFromHeaders(context *types.AuthCheckingContext) *types.AuthenticatedUser {
+	u := &types.AuthenticatedUser{}
+
+	if context.Config.AuthHttpHeaderUsername != "" {
+		u.Username = getHeaderKeyOrEmpty(context.Request.Header, context.Config.AuthHttpHeaderUsername)
+	}
+
+	if context.Config.AuthHttpHeaderUserGroup != "" {
+		u.UsergroupLine = getHeaderKeyOrEmpty(context.Request.Header, context.Config.AuthHttpHeaderUserGroup)
+	}
+
+	if prov := getHeaderKeyOrEmpty(context.Request.Header, "provider"); prov != "" {
+		u.Provider = prov
+	}
+	return u
+}
+
+func getHeaderKeyOrEmpty(headers http.Header, key string) string {
+	values := headers.Values(key)
+	if len(values) > 0 {
+		return values[0]
+	}
+	return ""
+}

+ 2 - 1
service/internal/executor/arguments.go

@@ -333,12 +333,13 @@ func mangleCheckboxValues(req *ExecutionRequest, arg *config.ActionArgument) {
 
 	log.Infof("Checking checkbox values for argument %s in action %s", arg.Name, req.Binding.Action.Title)
 
-	for i, _ := range arg.Choices {
+	for i, v := range arg.Choices {
 		choice := &arg.Choices[i]
 
 		if req.Arguments[arg.Name] == choice.Title {
 			log.WithFields(log.Fields{
 				"arg":         arg.Name,
+				"choice":      v,
 				"oldValue":    req.Arguments[arg.Name],
 				"newValue":    choice.Value,
 				"actionTitle": req.Binding.Action.Title,

+ 7 - 5
service/internal/executor/executor.go

@@ -2,6 +2,8 @@ package executor
 
 import (
 	acl "github.com/OliveTin/OliveTin/internal/acl"
+	"github.com/OliveTin/OliveTin/internal/auth"
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/entities"
 	"github.com/google/uuid"
@@ -69,7 +71,7 @@ type ExecutionRequest struct {
 	TrackingID        string
 	Tags              []string
 	Cfg               *config.Config
-	AuthenticatedUser *acl.AuthenticatedUser
+	AuthenticatedUser *authpublic.AuthenticatedUser
 	TriggerDepth      int
 
 	logEntry           *InternalLogEntry
@@ -230,11 +232,11 @@ func isValidLogEntryForACL(entry *InternalLogEntry) bool {
 }
 
 // isLogEntryAllowedByACL checks if a log entry is allowed to be viewed by the user.
-func isLogEntryAllowedByACL(cfg *config.Config, user *acl.AuthenticatedUser, entry *InternalLogEntry) bool {
+func isLogEntryAllowedByACL(cfg *config.Config, user *authpublic.AuthenticatedUser, entry *InternalLogEntry) bool {
 	return acl.IsAllowedLogs(cfg, user, entry.Binding.Action)
 }
 
-func (e *Executor) filterLogsByACL(cfg *config.Config, user *acl.AuthenticatedUser) []*InternalLogEntry {
+func (e *Executor) filterLogsByACL(cfg *config.Config, user *authpublic.AuthenticatedUser) []*InternalLogEntry {
 	e.logmutex.RLock()
 	defer e.logmutex.RUnlock()
 
@@ -280,7 +282,7 @@ func paginateFilteredLogs(filtered []*InternalLogEntry, startOffset int64, pageC
 
 // GetLogTrackingIdsACL returns logs filtered by ACL visibility for the user and
 // paginated correctly based on the filtered set.
-func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *acl.AuthenticatedUser, startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) {
+func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *authpublic.AuthenticatedUser, startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) {
 	filtered := e.filterLogsByACL(cfg, user)
 	return paginateFilteredLogs(filtered, startOffset, pageCount)
 }
@@ -323,7 +325,7 @@ func (e *Executor) SetLog(trackingID string, entry *InternalLogEntry) {
 // ExecRequest processes an ExecutionRequest
 func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string) {
 	if req.AuthenticatedUser == nil {
-		req.AuthenticatedUser = acl.UserGuest(req.Cfg)
+		req.AuthenticatedUser = auth.UserGuest(req.Cfg)
 	}
 
 	req.executor = e

+ 4 - 3
service/internal/executor/executor_test.go

@@ -5,7 +5,8 @@ import (
 
 	"github.com/stretchr/testify/assert"
 
-	acl "github.com/OliveTin/OliveTin/internal/acl"
+	"github.com/OliveTin/OliveTin/internal/auth"
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
 	config "github.com/OliveTin/OliveTin/internal/config"
 )
 
@@ -35,7 +36,7 @@ func TestCreateExecutorAndExec(t *testing.T) {
 	e, cfg := testingExecutor()
 
 	req := ExecutionRequest{
-		AuthenticatedUser: &acl.AuthenticatedUser{Username: "Mr Tickle"},
+		AuthenticatedUser: &authpublic.AuthenticatedUser{Username: "Mr Tickle"},
 		Cfg:               cfg,
 		Arguments: map[string]string{
 			"person": "yourself",
@@ -273,7 +274,7 @@ func TestMangleInvalidArgumentValues(t *testing.T) {
 
 	req := ExecutionRequest{
 		//		Action:            a1,
-		AuthenticatedUser: acl.UserFromSystem(cfg, "testuser"),
+		AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
 		Cfg:               cfg,
 		Arguments: map[string]string{
 			"date": "1990-01-10T12:00", // Invalid format, should be without seconds

+ 16 - 7
service/internal/httpservers/singleFrontend.go → service/internal/httpservers/frontend.go

@@ -15,6 +15,8 @@ import (
 	"path"
 
 	"github.com/OliveTin/OliveTin/internal/api"
+	"github.com/OliveTin/OliveTin/internal/auth"
+	"github.com/OliveTin/OliveTin/internal/auth/otoauth2"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/executor"
 	log "github.com/sirupsen/logrus"
@@ -32,13 +34,13 @@ func logDebugRequest(cfg *config.Config, source string, r *http.Request) {
 	}
 }
 
-// StartSingleHTTPFrontend will create a reverse proxy that proxies the API
-// and webui internally.
-func StartSingleHTTPFrontend(cfg *config.Config, ex *executor.Executor) {
+func StartFrontendMux(cfg *config.Config, ex *executor.Executor) {
 	log.WithFields(log.Fields{
 		"address": cfg.ListenAddressSingleHTTPFrontend,
 	}).Info("Starting single HTTP frontend")
 
+	go StartPrometheus(cfg)
+
 	mux := http.NewServeMux()
 
 	apiPath, apiHandler := api.GetNewHandler(ex)
@@ -62,10 +64,11 @@ func StartSingleHTTPFrontend(cfg *config.Config, ex *executor.Executor) {
 		apiHandler.ServeHTTP(w, r)
 	}))
 
-	oauth2handler := NewOAuth2Handler(cfg)
+	oauth2handler := otoauth2.NewOAuth2Handler(cfg)
+	auth.AddAuthChainFunction(oauth2handler.CheckUserFromOAuth2Cookie)
 
-	mux.HandleFunc("/oauth/login", oauth2handler.handleOAuthLogin)
-	mux.HandleFunc("/oauth/callback", oauth2handler.handleOAuthCallback)
+	mux.HandleFunc("/oauth/login", oauth2handler.HandleOAuthLogin)
+	mux.HandleFunc("/oauth/callback", oauth2handler.HandleOAuthCallback)
 
 	mux.HandleFunc("/readyz", handleReadyz)
 
@@ -97,5 +100,11 @@ func StartSingleHTTPFrontend(cfg *config.Config, ex *executor.Executor) {
 func handleReadyz(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
 	w.WriteHeader(http.StatusOK)
-	w.Write([]byte("OK. Single HTTP Frontend is ready.\n"))
+	_, err := w.Write([]byte("OK. Single HTTP Frontend is ready.\n"))
+
+	if err != nil {
+		log.WithFields(log.Fields{
+			"error": err,
+		}).Warnf("Failed to write readyz response")
+	}
 }

+ 0 - 16
service/internal/httpservers/httpServer.go

@@ -1,16 +0,0 @@
-package httpservers
-
-import (
-	config "github.com/OliveTin/OliveTin/internal/config"
-	"github.com/OliveTin/OliveTin/internal/executor"
-)
-
-// StartServers will start 3 HTTP servers. The WebUI, the Rest API, and a proxy
-// for both of them.
-func StartServers(cfg *config.Config, ex *executor.Executor) {
-	if cfg.Prometheus.Enabled {
-		go StartPrometheus(cfg)
-	}
-
-	StartSingleHTTPFrontend(cfg, ex)
-}

+ 13 - 1
service/internal/httpservers/prometheus.go

@@ -7,13 +7,25 @@ import (
 	"github.com/prometheus/client_golang/prometheus"
 	"github.com/prometheus/client_golang/prometheus/collectors"
 	"github.com/prometheus/client_golang/prometheus/promhttp"
+	log "github.com/sirupsen/logrus"
 )
 
 func StartPrometheus(cfg *config.Config) {
+	if !cfg.Prometheus.Enabled {
+		return
+	}
+
 	if !cfg.Prometheus.DefaultGoMetrics {
 		prometheus.Unregister(collectors.NewGoCollector())
 	}
 
 	http.Handle("/", promhttp.Handler())
-	http.ListenAndServe(cfg.ListenAddressPrometheus, nil)
+	err := http.ListenAndServe(cfg.ListenAddressPrometheus, nil)
+
+	if err != nil {
+		log.WithFields(log.Fields{
+			"address": cfg.ListenAddressPrometheus,
+			"error":   err,
+		}).Warnf("Failed to start Prometheus server")
+	}
 }

+ 0 - 174
service/internal/httpservers/restapi_auth_jwt_test.go

@@ -1,174 +0,0 @@
-package httpservers
-
-import (
-	"crypto/rand"
-	"crypto/rsa"
-	"crypto/x509"
-	"encoding/pem"
-	"fmt"
-	// config "github.com/OliveTin/OliveTin/internal/config"
-	// "github.com/golang-jwt/jwt/v4"
-	//	"github.com/stretchr/testify/assert"
-	"net/http"
-	"os"
-	"testing"
-	// "time"
-)
-
-func createKeys(t *testing.T) (*rsa.PrivateKey, string) {
-	tmpFile, _ := os.CreateTemp(os.TempDir(), "olivetin-jwt-")
-
-	fmt.Println("Created File: " + tmpFile.Name())
-
-	privateKey, _ := rsa.GenerateKey(rand.Reader, 2048)
-	pubKey := &privateKey.PublicKey
-	// https://stackoverflow.com/questions/13555085/save-and-load-crypto-rsa-privatekey-to-and-from-the-disk
-	pkixPubKey, _ := x509.MarshalPKIXPublicKey(pubKey)
-	pubPem := pem.EncodeToMemory(
-		&pem.Block{
-			Type:  "RSA PUBLIC KEY",
-			Bytes: pkixPubKey,
-		},
-	)
-
-	if err := os.WriteFile(tmpFile.Name(), pubPem, 0755); err != nil {
-		t.Fatalf("error when dumping pubKey: %s \n", err)
-	}
-
-	return privateKey, tmpFile.Name()
-}
-
-func newMux() *http.ServeMux {
-	mux := http.NewServeMux()
-
-	return mux
-}
-
-func testJwkValidation(t *testing.T, expire int64, expectCode int) {
-	/*
-		privateKey, publicKeyPath := createKeys(t)
-
-		defer os.Remove(publicKeyPath)
-
-		cfg := config.DefaultConfig()
-		cfg.AuthJwtPubKeyPath = publicKeyPath
-		cfg.AuthJwtClaimUsername = "sub"
-		cfg.AuthJwtClaimUserGroup = "olivetinGroup"
-		cfg.AuthJwtCookieName = "authorization_token"
-
-		token := jwt.New(jwt.SigningMethodRS256)
-
-		claims := token.Claims.(jwt.MapClaims)
-		claims["nbf"] = time.Now().Unix() - 1000
-		claims["exp"] = time.Now().Unix() + expire
-		claims["sub"] = "test"
-		claims["olivetinGroup"] = "test"
-	*/
-
-	/*
-		tokenStr, _ := token.SignedString(privateKey)
-
-		mux := newMux()
-		mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request, pathParams map[string]string) {
-			username, usergroup := parseJwtCookie(cfg, r)
-
-			if username == "" {
-				w.WriteHeader(403)
-			}
-
-			w.Write([]byte(fmt.Sprintf("username=%v, usergroup=%v", username, usergroup)))
-		})
-
-		srv := setupTestingServer(mux, t)
-
-		req, client := newReq("")
-		req.AddCookie(&http.Cookie{
-			Name:   "authorization_token",
-			Value:  tokenStr,
-			MaxAge: 300,
-		})
-
-		res, err := client.Do(req)
-
-		if err != nil {
-			t.Fatalf("Client err: %+v", err)
-		} else {
-			defer res.Body.Close()
-			assert.Equal(t, expectCode, res.StatusCode)
-			body, _ := io.ReadAll(res.Body)
-			fmt.Println(string(body))
-		}
-
-		err = srv.Shutdown(context.TODO())
-
-		if err != nil {
-			t.Fatalf("Server shutdown error: %+v", err)
-		}
-	*/
-}
-
-func TestJWTSignatureVerificationSucceeds(t *testing.T) {
-	testJwkValidation(t, 1000, 200)
-}
-
-func TestJWTSignatureVerificationFails(t *testing.T) {
-	testJwkValidation(t, -500, 403)
-}
-
-func TestJWTHeader(t *testing.T) {
-	/*
-		privateKey, publicKeyPath := createKeys(t)
-
-		defer os.Remove(publicKeyPath)
-
-		cfg := config.DefaultConfig()
-		cfg.AuthJwtPubKeyPath = publicKeyPath
-		cfg.AuthJwtClaimUsername = "sub"
-		cfg.AuthJwtClaimUserGroup = "olivetinGroup"
-		cfg.AuthJwtHeader = "Authorization"
-
-		token := jwt.New(jwt.SigningMethodRS256)
-
-		claims := token.Claims.(jwt.MapClaims)
-		claims["nbf"] = time.Now().Unix() - 1000
-		claims["exp"] = time.Now().Unix() + 2000
-		claims["sub"] = "test"
-		claims["olivetinGroup"] = []string{"test", "test2"}
-	*/
-
-	/*
-		tokenStr, _ := token.SignedString(privateKey)
-
-		mux := newMux()
-		mux.HandlePath("GET", "/", func(w http.ResponseWriter, r *http.Request, pathParams map[string]string) {
-			username, usergroup := parseJwtHeader(cfg, r)
-
-			if username == "" {
-				w.WriteHeader(403)
-			}
-
-			assert.Equal(t, "test", username)
-			assert.Equal(t, "test test2", usergroup)
-
-			w.Write([]byte(fmt.Sprintf("username=%v, usergroup=%v", username, usergroup)))
-		})
-
-		srv := setupTestingServer(mux, t)
-
-		req, client := newReq("")
-		req.Header.Set("Authorization", "Bearer "+tokenStr)
-
-		res, err := client.Do(req)
-
-		if err != nil {
-			t.Fatalf("Client err: %+v", err)
-		} else {
-			defer res.Body.Close()
-			assert.Equal(t, 200, res.StatusCode)
-			body, _ := io.ReadAll(res.Body)
-			fmt.Println(string(body))
-		}
-
-		srv.Shutdown(context.TODO())
-	*/
-}

+ 0 - 34
service/internal/httpservers/restapi_auth_local.go

@@ -1,34 +0,0 @@
-package httpservers
-
-import (
-	"net/http"
-
-	"github.com/OliveTin/OliveTin/internal/auth"
-	"github.com/OliveTin/OliveTin/internal/config"
-	log "github.com/sirupsen/logrus"
-)
-
-func parseLocalUserCookie(cfg *config.Config, req *http.Request) (string, string, string) {
-	cookie, err := req.Cookie("olivetin-sid-local")
-
-	if err != nil {
-		return "", "", ""
-	}
-
-	cookieValue := cookie.Value
-
-	session := auth.GetUserSession("local", cookieValue)
-	if session == nil {
-		return "", "", ""
-	}
-
-	user := cfg.FindUserByUsername(session.Username)
-	if user == nil {
-		log.WithFields(log.Fields{
-			"username": session.Username,
-		}).Warnf("User not found in config")
-		return "", "", ""
-	}
-
-	return user.Username, user.Usergroup, cookie.Value
-}

+ 0 - 3
service/internal/httpservers/restapi_test.go

@@ -1,3 +0,0 @@
-package httpservers
-
-import ()

+ 29 - 9
service/internal/httpservers/webuiServer.go

@@ -93,22 +93,42 @@ func (s *webUIServer) setupCustomWebuiDir() {
 	}
 }
 
+func shouldReloadThemeCss() bool {
+	return !customThemeCssRead
+}
+
+func loadThemeCssFromFile(filename string) []byte {
+	_, err := os.Stat(filename)
+	if err == nil {
+		css, err := os.ReadFile(filename)
+
+		if err != nil {
+			log.Tracef("Theme CSS file not read: %s", filename)
+			return nil
+		}
+
+		return css
+	}
+
+	log.Tracef("Theme CSS file not found: %s", filename)
+	return nil
+}
+
 func (s *webUIServer) generateThemeCss(w http.ResponseWriter, r *http.Request) {
 	themeCssFilename := path.Join(s.findCustomWebuiDir(), "themes", s.cfg.ThemeName, "theme.css")
 
-	if !customThemeCssRead || s.cfg.ThemeCacheDisabled {
+	if shouldReloadThemeCss() || s.cfg.ThemeCacheDisabled {
 		customThemeCssRead = true
-
-		if _, err := os.Stat(themeCssFilename); err == nil {
-			customThemeCss, _ = os.ReadFile(themeCssFilename)
-		} else {
-			log.Debugf("Theme CSS not read: %v", err)
-			customThemeCss = []byte("/* not found */")
-		}
+		customThemeCss = loadThemeCssFromFile(themeCssFilename)
 	}
 
 	w.Header().Add("Content-Type", "text/css")
-	w.Write(customThemeCss)
+	_, err := w.Write(customThemeCss)
+	if err != nil {
+		log.WithFields(log.Fields{
+			"error": err,
+		}).Warnf("Failed to write theme CSS")
+	}
 }
 
 func (s *webUIServer) handleCustomWebui() http.Handler {

+ 5 - 4
service/internal/oncalendarfile/calendar.go

@@ -2,14 +2,15 @@ package oncalendarfile
 
 import (
 	"context"
-	"github.com/OliveTin/OliveTin/internal/acl"
+	"os"
+	"time"
+
+	"github.com/OliveTin/OliveTin/internal/auth"
 	"github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/executor"
 	"github.com/OliveTin/OliveTin/internal/filehelper"
 	log "github.com/sirupsen/logrus"
 	"gopkg.in/yaml.v3"
-	"os"
-	"time"
 )
 
 func Schedule(cfg *config.Config, ex *executor.Executor) {
@@ -105,7 +106,7 @@ func exec(instant time.Time, action *config.Action, cfg *config.Config, ex *exec
 		Binding:           ex.FindBindingWithNoEntity(action),
 		Cfg:               cfg,
 		Tags:              []string{},
-		AuthenticatedUser: acl.UserFromSystem(cfg, "calendar"),
+		AuthenticatedUser: auth.UserFromSystem(cfg, "calendar"),
 	}
 
 	ex.ExecRequest(req)

+ 2 - 2
service/internal/oncron/cron.go

@@ -1,7 +1,7 @@
 package oncron
 
 import (
-	"github.com/OliveTin/OliveTin/internal/acl"
+	"github.com/OliveTin/OliveTin/internal/auth"
 	"github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/executor"
 	"github.com/robfig/cron/v3"
@@ -37,7 +37,7 @@ func scheduleAction(cfg *config.Config, scheduler *cron.Cron, cronline string, e
 			Binding:           ex.FindBindingWithNoEntity(action),
 			Cfg:               cfg,
 			Tags:              []string{},
-			AuthenticatedUser: acl.UserFromSystem(cfg, "cron"),
+			AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
 		}
 
 		ex.ExecRequest(req)

+ 2 - 2
service/internal/onfileindir/fileindir.go

@@ -5,7 +5,7 @@ import (
 	"os"
 	"path/filepath"
 
-	"github.com/OliveTin/OliveTin/internal/acl"
+	"github.com/OliveTin/OliveTin/internal/auth"
 	"github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/executor"
 	"github.com/OliveTin/OliveTin/internal/filehelper"
@@ -54,7 +54,7 @@ func scheduleExec(action *config.Action, cfg *config.Config, ex *executor.Execut
 		Cfg:               cfg,
 		Tags:              []string{},
 		Arguments:         args,
-		AuthenticatedUser: acl.UserFromSystem(cfg, "fileindir"),
+		AuthenticatedUser: auth.UserFromSystem(cfg, "fileindir"),
 	}
 
 	ex.ExecRequest(req)

+ 2 - 2
service/internal/onstartup/startup.go

@@ -1,14 +1,14 @@
 package onstartup
 
 import (
-	"github.com/OliveTin/OliveTin/internal/acl"
+	"github.com/OliveTin/OliveTin/internal/auth"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/executor"
 	log "github.com/sirupsen/logrus"
 )
 
 func Execute(cfg *config.Config, ex *executor.Executor) {
-	user := acl.UserFromSystem(cfg, "startup")
+	user := auth.UserFromSystem(cfg, "startup")
 
 	for _, action := range cfg.Actions {
 		if action.ExecOnStartup {

+ 23 - 4
service/main.go

@@ -130,7 +130,13 @@ func getConfigPath(directory string) string {
 
 func initConfig(configDir string) {
 	k := koanf.New(".")
-	k.Load(env.Provider(".", ".", nil), nil)
+	err := k.Load(env.Provider(".", ".", nil), nil)
+
+	if err != nil {
+		log.WithFields(log.Fields{
+			"error": err,
+		}).Fatalf("Error loading environment variables")
+	}
 
 	directories := []string{
 		configDir,
@@ -180,13 +186,26 @@ func initConfig(configDir string) {
 			os.Exit(1)
 		}
 
-		f.Watch(func(evt interface{}, err error) {
+		err := f.Watch(func(evt interface{}, err error) {
 			log.Infof("config file changed: %v", evt)
 
-			k.Load(f, yaml.Parser())
+			errLoad := k.Load(f, yaml.Parser())
+
+			if errLoad != nil {
+				log.WithFields(log.Fields{
+					"error": errLoad,
+				}).Fatalf("Error loading config file")
+			}
+
 			config.AppendSource(cfg, k, configPath)
 		})
 
+		if err != nil {
+			log.WithFields(log.Fields{
+				"error": err,
+			}).Fatalf("Error watching config file")
+		}
+
 		break
 	}
 
@@ -251,5 +270,5 @@ func main() {
 	// Load persistent sessions from disk
 	auth.LoadUserSessions(cfg)
 
-	httpservers.StartServers(cfg, executor)
+	httpservers.StartFrontendMux(cfg, executor)
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов