James Read há 1 mês atrás
pai
commit
8bf52fbea3

+ 1 - 0
docs/modules/ROOT/nav.adoc

@@ -96,6 +96,7 @@
 * xref:security/concepts.adoc[Security]
 ** xref:security/acl.adoc[Access Control Lists]
 ** xref:security/local.adoc[Local Users Authorization]
+** xref:security/api_keys.adoc[API Keys]
 ** xref:security/trusted_header.adoc[Trusted Header Authorization]
 ** xref:security/jwt.adoc[JWT Authorization]
 *** xref:security/jwt_keys.adoc[JWT with Keys]

+ 66 - 0
docs/modules/ROOT/pages/security/api_keys.adoc

@@ -0,0 +1,66 @@
+[#api-keys]
+= API Keys
+
+This page is for **developers** who want to call OliveTin's HTTP API (Connect RPC under `/api/`) using a **Bearer token**, without using the interactive web login.
+
+API keys are configured on xref:security/local.adoc[local users] as an optional `apiKey` field. When present, clients can authenticate by sending:
+
+----
+Authorization: Bearer <your-api-key>
+----
+
+The prefix `Bearer ` (including the trailing space after `Bearer`) must match exactly.
+
+== Configuration
+
+include::partial$config-start.adoc[]
+----
+authLocalUsers:
+  enabled: true
+  users:
+    - username: automation
+      usergroup: bots
+      apiKey: "{{ .Env.OLIVETIN_AUTOMATION_KEY }}"
+
+    - username: alice
+      usergroup: admins
+      password: $argon2id$v=19$m=65536,t=4,p=6$...
+      apiKey: "{{ .Env.OLIVETIN_ALICE_API_KEY }}"
+----
+
+* Use a **long, random** API key (similar to any other bearer secret).
+* Prefer loading the key from the environment with `{{ .Env.VAR }}` instead of committing the raw value to disk.
+* **TLS**: send bearer tokens only over HTTPS in real deployments.
+* **Interactive login**: if a user has **no** `password` configured, they **cannot** use the `/login` page; they can only authenticate with an API key (or another auth mechanism you configure separately).
+
+Two local users **must not** share the same `apiKey` value. OliveTin will refuse to start if duplicate keys are detected.
+
+== Authorization (permissions)
+
+API key authentication uses the same **username** and **usergroup** as the matching local user. xref:security/acl.adoc[Access Control Lists] and `defaultPermissions` apply in the same way as for users who sign in via the web UI.
+
+== Example: curl and Init
+
+The OliveTin API is **Connect RPC**. Unary calls accept JSON bodies. The following example calls `Init` with an empty request object:
+
+[source,bash]
+----
+curl -sS -X POST \
+  -H "Authorization: Bearer YOUR_API_KEY_HERE" \
+  -H "Content-Type: application/json" \
+  "https://olivetin.example.com:1337/api/olivetin.api.v1.OliveTinApiService/Init" \
+  --data '{}'
+----
+
+Replace the host, port, and path prefix if your installation differs. Other RPCs use the same URL pattern with a different final segment (method name).
+
+== Operational security notes
+
+* **Reverse proxies**: if you use xref:security/trusted_header.adoc[Trusted Header Authorization], remember it is evaluated **before** bearer API keys. Do not expose OliveTin in a way that allows clients to spoof trusted identity headers.
+* **Debug logging**: avoid enabling `logDebugOptions.singleFrontendRequestHeaders` in production. OliveTin redacts common sensitive headers (including `Authorization`) in debug output, but minimizing debug surface area is still recommended.
+* **Brute force**: OliveTin does not ship per-IP rate limiting for failed bearer attempts. Consider rate limiting or WAF rules on `/api/` at your reverse proxy.
+
+== See also
+
+* xref:security/local.adoc[Local Users Authorization] (password hashing and local user basics)
+* xref:security/acl.adoc[Access Control Lists]

+ 2 - 0
docs/modules/ROOT/pages/security/local.adoc

@@ -3,6 +3,8 @@
 
 OliveTin supports just basic users defined with a username and password in the config.yaml file. This can be used when you do not want to use a full authentication system like LDAP, OAuth2 or a Reverse Proxy.
 
+For programmatic access (scripts, integrations) using per-user bearer API keys, see xref:security/api_keys.adoc[API Keys].
+
 == Define a user
 
 include::partial$config-start.adoc[]

+ 47 - 32
frontend/resources/vue/views/EntitiesView.vue

@@ -1,51 +1,66 @@
 <template>
-	<Section class = "with-header-and-content" v-if="entityDefinitions.length === 0" title="Loading entity definitions...">
-		<div class = "section-header">
-			<h2 class="loading-message">
-				Loading entity definitions...
-			</h2>
-		</div>
+	<Section v-if="!definitionsLoaded" title="Loading entity definitions..." />
+	<Section
+		v-else-if="totalInstances === 0"
+		title="There are no entities to show yet."
+	>
+		<p>
+			When OliveTin has registered entity instances (for example from entity files or your setup), they will be listed here.
+		</p>
 	</Section>
 	<template v-else>
 		<Section v-for="def in entityDefinitions" :key="def.title" :title="'Entity: ' + def.title ">
-			<div class = "section-content">
-				<p>{{ def.instances.length }} instances.</p>
+			<p>{{ def.instances.length }} instances.</p>
 
-				<ul>
-					<li v-for="inst in def.instances" :key="inst.uniqueKey">
-						<router-link :to="{ name: 'EntityDetails', params: { entityType: inst.type, entityKey: inst.uniqueKey } }">
-							{{ inst.title }}
-						</router-link>
-					</li>
-				</ul>
+			<ul>
+				<li v-for="inst in def.instances" :key="inst.uniqueKey">
+					<router-link :to="{ name: 'EntityDetails', params: { entityType: inst.type, entityKey: inst.uniqueKey } }">
+						{{ inst.title }}
+					</router-link>
+				</li>
+			</ul>
 
-				<h3>Used on Dashboards:</h3>
-				<ul>
-					<li v-for="dash in filteredDashboards(def.usedOnDashboards)" :key="dash">
-						<template v-if="isEntityDirectory(dash)">
-							{{ getDashboardTitle(dash) }} <span class="entity-directory-label">[Entity Directory]</span>
-						</template>
-						<router-link v-else-if="!dash.includes('entity:')" :to="{ name: 'Dashboard', params: { title: getDashboardTitle(dash) } }">
-							{{ getDashboardTitle(dash) }}
-						</router-link>
-						<span v-else>{{ dash }}</span>
-					</li>
-				</ul>
-			</div>
+			<h3>Used on Dashboards:</h3>
+			<ul>
+				<li v-for="dash in filteredDashboards(def.usedOnDashboards)" :key="dash">
+					<template v-if="isEntityDirectory(dash)">
+						{{ getDashboardTitle(dash) }} <span class="entity-directory-label">[Entity Directory]</span>
+					</template>
+					<router-link v-else-if="!dash.includes('entity:')" :to="{ name: 'Dashboard', params: { title: getDashboardTitle(dash) } }">
+						{{ getDashboardTitle(dash) }}
+					</router-link>
+					<span v-else>{{ dash }}</span>
+				</li>
+			</ul>
 		</Section>
 	</template>
 </template>
 
 <script setup>
-	import { ref, onMounted } from 'vue'
+	import { ref, computed, onMounted } from 'vue'
 	import Section from 'picocrank/vue/components/Section.vue'
 
+	const definitionsLoaded = ref(false)
 	const entityDefinitions = ref([])
 
-	async function fetchEntities() {
-	    const ret = await window.client.getEntities()
+	const totalInstances = computed(() =>
+		entityDefinitions.value.reduce(
+			(sum, def) => sum + (def.instances?.length ?? 0),
+			0,
+		),
+	)
 
-        entityDefinitions.value = ret.entityDefinitions
+	async function fetchEntities() {
+		try {
+			const ret = await window.client.getEntities()
+			entityDefinitions.value = ret.entityDefinitions ?? []
+		} catch (err) {
+			console.error('Failed to fetch entities:', err)
+			window.showBigError('fetch-entities', 'getting entities', err, false)
+			entityDefinitions.value = []
+		} finally {
+			definitionsLoaded.value = true
+		}
 	}
 
 	function filteredDashboards(dashboards) {

+ 51 - 3
service/internal/api/api.go

@@ -204,10 +204,24 @@ func (api *oliveTinAPI) applyLocalLoginResult(req *apiv1.LocalUserLoginRequest,
 	}
 }
 
-func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[apiv1.LocalUserLoginRequest]) (*connect.Response[apiv1.LocalUserLoginResponse], error) {
+func (api *oliveTinAPI) localUserLoginEarlyReject(req *connect.Request[apiv1.LocalUserLoginRequest]) *connect.Response[apiv1.LocalUserLoginResponse] {
 	if !api.cfg.AuthLocalUsers.Enabled {
-		return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false}), nil
+		return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false})
+	}
+
+	if isLocalInteractiveLoginDisabledForUser(api.cfg, req.Msg.Username) {
+		log.WithFields(log.Fields{"username": req.Msg.Username}).Debug("LocalUserLogin: interactive login disabled (no password configured)")
+		return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false})
+	}
+
+	return nil
+}
+
+func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[apiv1.LocalUserLoginRequest]) (*connect.Response[apiv1.LocalUserLoginResponse], error) {
+	if early := api.localUserLoginEarlyReject(req); early != nil {
+		return early, nil
 	}
+
 	match, err := checkUserPassword(api.cfg, req.Msg.Username, req.Msg.Password)
 	if err != nil {
 		if errors.Is(err, ErrArgon2Busy) {
@@ -724,12 +738,46 @@ func (api *oliveTinAPI) argumentNotFoundForValidation(msg *apiv1.ValidateArgumen
 	return arg == nil
 }
 
+func (api *oliveTinAPI) validateArgumentTypeBindingAccess(user *authpublic.AuthenticatedUser, msg *apiv1.ValidateArgumentTypeRequest) error {
+	if msg == nil || msg.BindingId == "" {
+		return nil
+	}
+
+	return api.errUnlessUserMayValidateArgumentTypeForBinding(user, msg.BindingId)
+}
+
+func (api *oliveTinAPI) errUnlessUserMayValidateArgumentTypeForBinding(user *authpublic.AuthenticatedUser, bindingID string) error {
+	binding := api.executor.FindBindingByID(bindingID)
+	if binding == nil || binding.Action == nil {
+		return connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", bindingID))
+	}
+
+	if !api.userCanViewAction(user, binding.Action) {
+		return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
+	}
+
+	return nil
+}
+
 func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Request[apiv1.ValidateArgumentTypeRequest]) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
+	if err := api.checkDashboardAccess(user); err != nil {
+		return nil, err
+	}
+
+	if err := api.validateArgumentTypeBindingAccess(user, req.Msg); err != nil {
+		return nil, err
+	}
+
 	if api.argumentNotFoundForValidation(req.Msg) {
 		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", req.Msg.BindingId))
 	}
 
-	err := api.validateArgumentTypeInternal(req.Msg)
+	return api.validateArgumentTypeConnectResponse(req.Msg)
+}
+
+func (api *oliveTinAPI) validateArgumentTypeConnectResponse(msg *apiv1.ValidateArgumentTypeRequest) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
+	err := api.validateArgumentTypeInternal(msg)
 	desc := ""
 	if err != nil {
 		desc = err.Error()

+ 90 - 0
service/internal/api/api_test.go

@@ -408,6 +408,96 @@ func TestGetActionBindingDeniedWhenNoViewPermission(t *testing.T) {
 		"user with view:false must get permission denied from GetActionBinding")
 }
 
+// TestValidateArgumentTypeDeniesGuestsWhenLoginRequired (GHSA-f637-w7p2-m7fx) asserts that when
+// guests must log in, ValidateArgumentType does not bypass dashboard access controls.
+func TestValidateArgumentTypeDeniesGuestsWhenLoginRequired(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.AuthRequireGuestsToLogin = true
+	cfg.Actions = append(cfg.Actions, &config.Action{
+		ID:    "a1",
+		Title: "Probe",
+		Shell: "echo",
+		Arguments: []config.ActionArgument{
+			{Name: "x", Type: "ascii"},
+		},
+	})
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClient(cfg)
+	defer ts.Close()
+
+	_, err := client.ValidateArgumentType(context.Background(), connect.NewRequest(&apiv1.ValidateArgumentTypeRequest{
+		BindingId:    "a1",
+		ArgumentName: "x",
+		Value:        "v",
+		Type:         "ascii",
+	}))
+	require.Error(t, err)
+	assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err),
+		"guest must not call ValidateArgumentType when AuthRequireGuestsToLogin is true")
+}
+
+// TestValidateArgumentTypeDeniedWithoutViewPermission (GHSA-f637-w7p2-m7fx) asserts ValidateArgumentType
+// respects the same view ACL as GetActionBinding so the RPC cannot enumerate restricted actions.
+func TestValidateArgumentTypeDeniedWithoutViewPermission(t *testing.T) {
+	cfg, _, _ := buildViewPermissionTestConfig(t)
+	cfg.AuthHttpHeaderUsername = "X-Ot-User"
+	for i := range cfg.Actions {
+		if cfg.Actions[i].ID == "secret_action" {
+			cfg.Actions[i].Arguments = []config.ActionArgument{{Name: "target", Type: "ascii"}}
+			break
+		}
+	}
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClient(cfg)
+	defer ts.Close()
+
+	req := connect.NewRequest(&apiv1.ValidateArgumentTypeRequest{
+		BindingId:    "secret_action",
+		ArgumentName: "target",
+		Value:        "ok",
+		Type:         "ascii",
+	})
+	req.Header().Set("X-Ot-User", "low")
+
+	_, err := client.ValidateArgumentType(context.Background(), req)
+	require.Error(t, err)
+	assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err),
+		"user with view:false must get permission denied from ValidateArgumentType")
+}
+
+// TestValidateArgumentTypeAllowedWithViewPermission (GHSA-f637-w7p2-m7fx) asserts authenticated users
+// with view access can still use ValidateArgumentType for argument validation.
+func TestValidateArgumentTypeAllowedWithViewPermission(t *testing.T) {
+	cfg, _, _ := buildViewPermissionTestConfig(t)
+	cfg.AuthHttpHeaderUsername = "X-Ot-User"
+	for i := range cfg.Actions {
+		if cfg.Actions[i].ID == "secret_action" {
+			cfg.Actions[i].Arguments = []config.ActionArgument{{Name: "target", Type: "ascii"}}
+			break
+		}
+	}
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClient(cfg)
+	defer ts.Close()
+
+	req := connect.NewRequest(&apiv1.ValidateArgumentTypeRequest{
+		BindingId:    "secret_action",
+		ArgumentName: "target",
+		Value:        "ok",
+		Type:         "ascii",
+	})
+	req.Header().Set("X-Ot-User", "admin")
+
+	resp, err := client.ValidateArgumentType(context.Background(), req)
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	require.NotNil(t, resp.Msg)
+	assert.True(t, resp.Msg.Valid, "admin with view:true should get successful validation for a valid ascii value")
+}
+
 // TestViewPermissionAllowedSeesAction (GHSA: view permission) asserts that a user with view: true
 // still sees the action in the dashboard and can fetch it via GetActionBinding.
 func TestViewPermissionAllowedSeesAction(t *testing.T) {

+ 9 - 0
service/internal/api/local_user_login.go

@@ -61,6 +61,15 @@ func comparePasswordAndHash(password, hash string) (bool, error) {
 	return match, nil
 }
 
+func isLocalInteractiveLoginDisabledForUser(cfg *config.Config, username string) bool {
+	user := cfg.FindUserByUsername(username)
+	if user == nil {
+		return false
+	}
+
+	return user.Password == ""
+}
+
 func checkUserPassword(cfg *config.Config, username, password string) (bool, error) {
 	user := cfg.FindUserByUsername(username)
 	if user == nil {

+ 35 - 0
service/internal/api/local_user_login_test.go

@@ -0,0 +1,35 @@
+package api
+
+import (
+	"context"
+	"testing"
+
+	"connectrpc.com/connect"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	config "github.com/OliveTin/OliveTin/internal/config"
+)
+
+func TestLocalUserLoginRejectsUserWithNoPassword(t *testing.T) {
+	t.Parallel()
+
+	cfg := config.DefaultConfig()
+	cfg.AuthLocalUsers.Enabled = true
+	cfg.AuthLocalUsers.Users = []*config.LocalUser{{
+		Username: "onlykey",
+		ApiKey:   "k",
+		Password: "",
+	}}
+
+	ts, client := getNewTestServerAndClient(cfg)
+	defer ts.Close()
+
+	resp, err := client.LocalUserLogin(context.Background(), connect.NewRequest(&apiv1.LocalUserLoginRequest{
+		Username: "onlykey",
+		Password: "anything",
+	}))
+	require.NoError(t, err)
+	assert.False(t, resp.Msg.GetSuccess())
+}

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

@@ -14,6 +14,7 @@ import (
 var authChain = []func(*types.AuthCheckingContext) *types.AuthenticatedUser{
 	checkUserFromHeaders,
 	checkUserFromLocalSession,
+	checkUserFromLocalBearerApiKey,
 	otjwt.CheckUserFromJwtHeader,
 	otjwt.CheckUserFromJwtCookie,
 }

+ 109 - 0
service/internal/auth/local_bearer.go

@@ -0,0 +1,109 @@
+package auth
+
+import (
+	"crypto/subtle"
+	"strings"
+
+	types "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	"github.com/OliveTin/OliveTin/internal/config"
+	log "github.com/sirupsen/logrus"
+)
+
+const localBearerScheme = "Bearer"
+
+func constantTimeEqualString(a, b string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+
+	return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
+}
+
+func bearerTokenFromAuthorizationHeader(authz string) (string, bool) {
+	idx := strings.IndexByte(authz, ' ')
+	if idx <= 0 {
+		return "", false
+	}
+
+	if !strings.EqualFold(authz[:idx], localBearerScheme) {
+		return "", false
+	}
+
+	token := strings.TrimSpace(authz[idx+1:])
+	if token == "" {
+		return "", false
+	}
+
+	return token, true
+}
+
+func localUserHasAPIKey(user *config.LocalUser) bool {
+	return user != nil && user.ApiKey != ""
+}
+
+func findLocalUserByAPIKey(cfg *config.Config, token string) *config.LocalUser {
+	for _, user := range cfg.AuthLocalUsers.Users {
+		if !localUserHasAPIKey(user) {
+			continue
+		}
+
+		if constantTimeEqualString(token, user.ApiKey) {
+			return user
+		}
+	}
+
+	return nil
+}
+
+func localBearerAuthorizationHasEmptyCredential(authz string) bool {
+	idx := strings.IndexByte(authz, ' ')
+	return idx > 0 &&
+		strings.EqualFold(authz[:idx], localBearerScheme) &&
+		strings.TrimSpace(authz[idx+1:]) == ""
+}
+
+func logLocalBearerAPIKeyParseFailure(authz string) {
+	if strings.TrimSpace(authz) == "" {
+		return
+	}
+
+	if localBearerAuthorizationHasEmptyCredential(authz) {
+		log.Debugf("Local bearer API key: rejected (empty credential after Bearer prefix)")
+		return
+	}
+
+	log.Tracef("Local bearer API key: skipped (Authorization is not a Bearer token)")
+}
+
+func checkUserFromLocalBearerApiKey(context *types.AuthCheckingContext) *types.AuthenticatedUser {
+	if !context.Config.AuthLocalUsers.Enabled {
+		log.Tracef("Local bearer API key: skipped (authLocalUsers disabled)")
+		return nil
+	}
+
+	authz := context.Request.Header.Get("Authorization")
+	token, ok := bearerTokenFromAuthorizationHeader(authz)
+	if !ok {
+		logLocalBearerAPIKeyParseFailure(authz)
+		return nil
+	}
+
+	log.Debugf("Local bearer API key: checking configured local user API keys")
+
+	user := findLocalUserByAPIKey(context.Config, token)
+	if user == nil {
+		log.Debugf("Local bearer API key: rejected (no matching local user)")
+		return nil
+	}
+
+	log.WithFields(log.Fields{
+		"username":  user.Username,
+		"usergroup": user.Usergroup,
+	}).Debugf("Local bearer API key: authenticated")
+
+	return &types.AuthenticatedUser{
+		Username:      user.Username,
+		UsergroupLine: user.Usergroup,
+		Provider:      "local",
+	}
+}

+ 106 - 0
service/internal/auth/local_bearer_test.go

@@ -0,0 +1,106 @@
+package auth
+
+import (
+	"net/http/httptest"
+	"testing"
+
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestCheckUserFromLocalBearerApiKey_Match_LowercaseBearerScheme(t *testing.T) {
+	t.Parallel()
+
+	cfg := config.DefaultConfig()
+	cfg.AuthLocalUsers.Enabled = true
+	cfg.AuthLocalUsers.Users = []*config.LocalUser{{
+		Username:  "bot",
+		Usergroup: "bots",
+		ApiKey:    "secret-api-key",
+	}}
+
+	req := httptest.NewRequest("POST", "/", nil)
+	req.Header.Set("Authorization", "bearer secret-api-key")
+
+	ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
+	user := checkUserFromLocalBearerApiKey(ctx)
+	require.NotNil(t, user)
+	assert.Equal(t, "bot", user.Username)
+	assert.Equal(t, "bots", user.UsergroupLine)
+	assert.Equal(t, "local", user.Provider)
+}
+
+func TestCheckUserFromLocalBearerApiKey_Match(t *testing.T) {
+	t.Parallel()
+
+	cfg := config.DefaultConfig()
+	cfg.AuthLocalUsers.Enabled = true
+	cfg.AuthLocalUsers.Users = []*config.LocalUser{{
+		Username:  "bot",
+		Usergroup: "bots",
+		ApiKey:    "secret-api-key",
+	}}
+
+	req := httptest.NewRequest("POST", "/", nil)
+	req.Header.Set("Authorization", "Bearer secret-api-key")
+
+	ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
+	user := checkUserFromLocalBearerApiKey(ctx)
+	require.NotNil(t, user)
+	assert.Equal(t, "bot", user.Username)
+	assert.Equal(t, "bots", user.UsergroupLine)
+	assert.Equal(t, "local", user.Provider)
+}
+
+func TestCheckUserFromLocalBearerApiKey_WrongKey(t *testing.T) {
+	t.Parallel()
+
+	cfg := config.DefaultConfig()
+	cfg.AuthLocalUsers.Enabled = true
+	cfg.AuthLocalUsers.Users = []*config.LocalUser{{
+		Username: "bot",
+		ApiKey:   "secret-api-key",
+	}}
+
+	req := httptest.NewRequest("POST", "/", nil)
+	req.Header.Set("Authorization", "Bearer wrong")
+
+	ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
+	assert.Nil(t, checkUserFromLocalBearerApiKey(ctx))
+}
+
+func TestCheckUserFromLocalBearerApiKey_DisabledLocalUsers(t *testing.T) {
+	t.Parallel()
+
+	cfg := config.DefaultConfig()
+	cfg.AuthLocalUsers.Enabled = false
+	cfg.AuthLocalUsers.Users = []*config.LocalUser{{
+		Username: "bot",
+		ApiKey:   "secret-api-key",
+	}}
+
+	req := httptest.NewRequest("POST", "/", nil)
+	req.Header.Set("Authorization", "Bearer secret-api-key")
+
+	ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
+	assert.Nil(t, checkUserFromLocalBearerApiKey(ctx))
+}
+
+func TestCheckUserFromLocalBearerApiKey_NoBearerPrefix(t *testing.T) {
+	t.Parallel()
+
+	cfg := config.DefaultConfig()
+	cfg.AuthLocalUsers.Enabled = true
+	cfg.AuthLocalUsers.Users = []*config.LocalUser{{
+		Username: "bot",
+		ApiKey:   "secret-api-key",
+	}}
+
+	req := httptest.NewRequest("POST", "/", nil)
+	req.Header.Set("Authorization", "secret-api-key")
+
+	ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
+	assert.Nil(t, checkUserFromLocalBearerApiKey(ctx))
+}

+ 1 - 0
service/internal/config/config.go

@@ -195,6 +195,7 @@ type LocalUser struct {
 	Username  string `koanf:"username"`
 	Usergroup string `koanf:"usergroup"`
 	Password  string `koanf:"password"`
+	ApiKey    string `koanf:"apiKey"`
 }
 
 type OAuth2Provider struct {

+ 53 - 9
service/internal/config/sanitize.go

@@ -1,6 +1,7 @@
 package config
 
 import (
+	"fmt"
 	"strings"
 	"text/template"
 
@@ -15,7 +16,7 @@ func (cfg *Config) Sanitize() {
 	cfg.sanitizeLogLevel()
 	cfg.sanitizeAuthRequireGuestsToLogin()
 	cfg.sanitizeLogHistoryPageSize()
-	cfg.sanitizeLocalUserPasswords()
+	cfg.sanitizeLocalUsers()
 	cfg.sanitizeSecurityHeaders()
 
 	// log.Infof("cfg %p", cfg)
@@ -177,12 +178,55 @@ func (cfg *Config) sanitizeLogHistoryPageSize() {
 	}
 }
 
-func (cfg *Config) sanitizeLocalUserPasswords() {
+func (cfg *Config) sanitizeLocalUsers() {
 	for _, user := range cfg.AuthLocalUsers.Users {
-		if user.Password != "" {
-			user.Password = parsePasswordTemplate(user.Password)
+		expandLocalUserEnvTemplates(user)
+	}
+
+	if err := validateUniqueLocalUserAPIKeys(cfg.AuthLocalUsers.Users); err != nil {
+		log.Fatalf("%v", err)
+	}
+}
+
+func expandLocalUserEnvTemplates(user *LocalUser) {
+	if user == nil {
+		return
+	}
+
+	if user.Password != "" {
+		user.Password = expandEnvTemplate(user.Password)
+	}
+
+	if user.ApiKey != "" {
+		user.ApiKey = expandEnvTemplate(user.ApiKey)
+	}
+}
+
+// validateUniqueLocalUserAPIKeys returns an error when two local users share the same non-empty apiKey.
+func validateUniqueLocalUserAPIKeys(users []*LocalUser) error {
+	seen := make(map[string]string)
+
+	for _, user := range users {
+		if err := recordUniqueLocalUserAPIKey(seen, user); err != nil {
+			return err
 		}
 	}
+
+	return nil
+}
+
+func recordUniqueLocalUserAPIKey(seen map[string]string, user *LocalUser) error {
+	if user == nil || user.ApiKey == "" {
+		return nil
+	}
+
+	if prior, ok := seen[user.ApiKey]; ok {
+		return fmt.Errorf("duplicate authLocalUsers apiKey for users %q and %q", prior, user.Username)
+	}
+
+	seen[user.ApiKey] = user.Username
+
+	return nil
 }
 
 func (cfg *Config) sanitizeSecurityHeaders() {
@@ -204,16 +248,16 @@ func (cfg *Config) sanitizeSecurityHeadersXFrameOptions() {
 	cfg.Security.XFrameOptions = "DENY"
 }
 
-// parsePasswordTemplate expands {{ .Env.VAR }} in local user password fields using the process environment.
-func parsePasswordTemplate(source string) string {
-	t, err := template.New("password").Option("missingkey=error").Parse(source)
+// expandEnvTemplate expands {{ .Env.VAR }} in config strings using the process environment.
+func expandEnvTemplate(source string) string {
+	t, err := template.New("envTemplate").Option("missingkey=error").Parse(source)
 	if err != nil {
-		log.WithFields(log.Fields{"error": err}).Debug("Password template parse failed, using literal")
+		log.WithFields(log.Fields{"error": err}).Debug("Env template parse failed, using literal")
 		return source
 	}
 	var b strings.Builder
 	if err := t.Execute(&b, map[string]interface{}{"Env": env.BuildEnvMap()}); err != nil {
-		log.WithFields(log.Fields{"error": err}).Debug("Password template execute failed, using literal")
+		log.WithFields(log.Fields{"error": err}).Debug("Env template execute failed, using literal")
 		return source
 	}
 	return b.String()

+ 22 - 4
service/internal/config/sanitize_test.go

@@ -1,8 +1,10 @@
 package config
 
 import (
-	"github.com/stretchr/testify/assert"
 	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestSanitizeConfig(t *testing.T) {
@@ -42,9 +44,9 @@ func TestSanitizePopupOnStartHistory(t *testing.T) {
 	c.DefaultPopupOnStart = "nothing"
 
 	c.Actions = append(c.Actions, &Action{
-		Title:         "With history",
-		PopupOnStart:  "history",
-		Shell:         "true",
+		Title:        "With history",
+		PopupOnStart: "history",
+		Shell:        "true",
 	})
 	c.Sanitize()
 
@@ -89,3 +91,19 @@ func TestSanitizeConfigInlineDashboardActions(t *testing.T) {
 		assert.NotEmpty(t, found.ID, "Inline action should have a generated ID")
 	}
 }
+
+func TestValidateUniqueLocalUserAPIKeys(t *testing.T) {
+	t.Parallel()
+
+	err := validateUniqueLocalUserAPIKeys([]*LocalUser{
+		{Username: "a", ApiKey: "same"},
+		{Username: "b", ApiKey: "same"},
+	})
+	require.Error(t, err)
+
+	err = validateUniqueLocalUserAPIKeys([]*LocalUser{
+		{Username: "a", ApiKey: "one"},
+		{Username: "b", ApiKey: "two"},
+	})
+	require.NoError(t, err)
+}

+ 24 - 1
service/internal/httpservers/frontend.go

@@ -13,6 +13,7 @@ import (
 	"net/http/httputil"
 	"net/url"
 	"path"
+	"strings"
 
 	"github.com/OliveTin/OliveTin/internal/api"
 	"github.com/OliveTin/OliveTin/internal/auth"
@@ -57,13 +58,35 @@ func securityHeadersMiddleware(cfg *config.Config, next http.Handler) http.Handl
 	})
 }
 
+func isSensitiveLogHeaderName(name string) bool {
+	switch strings.ToLower(name) {
+	case "authorization", "cookie", "x-forwarded-access-token":
+		return true
+	default:
+		return false
+	}
+}
+
+func redactHeaderValuesForLog(name string, values []string) []string {
+	if !isSensitiveLogHeaderName(name) {
+		return values
+	}
+
+	out := make([]string, len(values))
+	for i := range values {
+		out[i] = "[redacted]"
+	}
+
+	return out
+}
+
 func logDebugRequest(cfg *config.Config, source string, r *http.Request) {
 	if cfg.LogDebugOptions.SingleFrontendRequests {
 		log.Debugf("SingleFrontend HTTP Req URL %v: %q", source, r.URL)
 
 		if cfg.LogDebugOptions.SingleFrontendRequestHeaders {
 			for name, values := range r.Header {
-				log.Debugf("SingleFrontend HTTP Req Hdr: %v = %v", name, values)
+				log.Debugf("SingleFrontend HTTP Req Hdr: %v = %v", name, redactHeaderValuesForLog(name, values))
 			}
 		}
 	}

+ 17 - 0
service/internal/httpservers/frontend_test.go

@@ -0,0 +1,17 @@
+package httpservers
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRedactHeaderValuesForLog(t *testing.T) {
+	t.Parallel()
+
+	assert.Equal(t, []string{"[redacted]"}, redactHeaderValuesForLog("Authorization", []string{"Bearer secret"}))
+	assert.Equal(t, []string{"[redacted]", "[redacted]"}, redactHeaderValuesForLog("Cookie", []string{"a=1", "b=2"}))
+	assert.Equal(t, []string{"[redacted]"}, redactHeaderValuesForLog("authorization", []string{"x"}))
+	assert.Equal(t, []string{"[redacted]"}, redactHeaderValuesForLog("X-Forwarded-Access-Token", []string{"jwt"}))
+	assert.Equal(t, []string{"https"}, redactHeaderValuesForLog("X-Forwarded-Proto", []string{"https"}))
+}

+ 8 - 1
service/internal/tpl/templates.go

@@ -24,6 +24,8 @@ func jsonFunc(v any) (string, error) {
 	return string(data), nil
 }
 
+// Root template (funcs/options). parseTemplate clones before Parse — text/template
+// must not receive concurrent Parse calls on the same instance.
 var tpl = template.New("tpl").
 	Option("missingkey=error").
 	Funcs(template.FuncMap{"Json": jsonFunc})
@@ -179,7 +181,12 @@ func checkMissingArgumentError(err error) (bool, string) {
 }
 
 func parseTemplate(source string, data any) (string, error) {
-	t, err := tpl.Parse(source)
+	clone, err := tpl.Clone()
+	if err != nil {
+		return "", err
+	}
+
+	t, err := clone.Parse(source)
 
 	if err != nil {
 		return "", err