feat: API Key (bearer) auth

This commit is contained in:
jamesread
2026-05-21 22:59:12 +01:00
parent 61593a8aaf
commit 246e33d565
14 changed files with 428 additions and 16 deletions
+1
View File
@@ -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]
@@ -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]
@@ -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[]
+16 -2
View File
@@ -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) {
+9
View File
@@ -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 {
@@ -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
View File
@@ -14,6 +14,7 @@ import (
var authChain = []func(*types.AuthCheckingContext) *types.AuthenticatedUser{
checkUserFromHeaders,
checkUserFromLocalSession,
checkUserFromLocalBearerApiKey,
otjwt.CheckUserFromJwtHeader,
otjwt.CheckUserFromJwtCookie,
}
+97
View File
@@ -0,0 +1,97 @@
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 localBearerPrefix = "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) {
if !strings.HasPrefix(authz, localBearerPrefix) {
return "", false
}
token := strings.TrimSpace(strings.TrimPrefix(authz, localBearerPrefix))
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 logLocalBearerAPIKeyParseFailure(authz string) {
if strings.TrimSpace(authz) == "" {
return
}
if strings.HasPrefix(authz, localBearerPrefix) {
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",
}
}
@@ -0,0 +1,84 @@
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(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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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))
}
}
}
@@ -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"}))
}