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

View File

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

View File

@ -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]

View File

@ -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. 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 == Define a user
include::partial$config-start.adoc[] include::partial$config-start.adoc[]

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 { 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) match, err := checkUserPassword(api.cfg, req.Msg.Username, req.Msg.Password)
if err != nil { if err != nil {
if errors.Is(err, ErrArgon2Busy) { if errors.Is(err, ErrArgon2Busy) {

View File

@ -61,6 +61,15 @@ func comparePasswordAndHash(password, hash string) (bool, error) {
return match, nil 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) { func checkUserPassword(cfg *config.Config, username, password string) (bool, error) {
user := cfg.FindUserByUsername(username) user := cfg.FindUserByUsername(username)
if user == nil { if user == nil {

View File

@ -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())
}

View File

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

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",
}
}

View File

@ -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))
}

View File

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

View File

@ -1,6 +1,7 @@
package config package config
import ( import (
"fmt"
"strings" "strings"
"text/template" "text/template"
@ -15,7 +16,7 @@ func (cfg *Config) Sanitize() {
cfg.sanitizeLogLevel() cfg.sanitizeLogLevel()
cfg.sanitizeAuthRequireGuestsToLogin() cfg.sanitizeAuthRequireGuestsToLogin()
cfg.sanitizeLogHistoryPageSize() cfg.sanitizeLogHistoryPageSize()
cfg.sanitizeLocalUserPasswords() cfg.sanitizeLocalUsers()
cfg.sanitizeSecurityHeaders() cfg.sanitizeSecurityHeaders()
// log.Infof("cfg %p", cfg) // 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 { for _, user := range cfg.AuthLocalUsers.Users {
if user.Password != "" { expandLocalUserEnvTemplates(user)
user.Password = parsePasswordTemplate(user.Password) }
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() { func (cfg *Config) sanitizeSecurityHeaders() {
@ -204,16 +248,16 @@ func (cfg *Config) sanitizeSecurityHeadersXFrameOptions() {
cfg.Security.XFrameOptions = "DENY" cfg.Security.XFrameOptions = "DENY"
} }
// parsePasswordTemplate expands {{ .Env.VAR }} in local user password fields using the process environment. // expandEnvTemplate expands {{ .Env.VAR }} in config strings using the process environment.
func parsePasswordTemplate(source string) string { func expandEnvTemplate(source string) string {
t, err := template.New("password").Option("missingkey=error").Parse(source) t, err := template.New("envTemplate").Option("missingkey=error").Parse(source)
if err != nil { 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 return source
} }
var b strings.Builder var b strings.Builder
if err := t.Execute(&b, map[string]interface{}{"Env": env.BuildEnvMap()}); err != nil { 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 source
} }
return b.String() return b.String()

View File

@ -1,8 +1,10 @@
package config package config
import ( import (
"github.com/stretchr/testify/assert"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestSanitizeConfig(t *testing.T) { func TestSanitizeConfig(t *testing.T) {
@ -42,9 +44,9 @@ func TestSanitizePopupOnStartHistory(t *testing.T) {
c.DefaultPopupOnStart = "nothing" c.DefaultPopupOnStart = "nothing"
c.Actions = append(c.Actions, &Action{ c.Actions = append(c.Actions, &Action{
Title: "With history", Title: "With history",
PopupOnStart: "history", PopupOnStart: "history",
Shell: "true", Shell: "true",
}) })
c.Sanitize() c.Sanitize()
@ -89,3 +91,19 @@ func TestSanitizeConfigInlineDashboardActions(t *testing.T) {
assert.NotEmpty(t, found.ID, "Inline action should have a generated ID") 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)
}

View File

@ -13,6 +13,7 @@ import (
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"path" "path"
"strings"
"github.com/OliveTin/OliveTin/internal/api" "github.com/OliveTin/OliveTin/internal/api"
"github.com/OliveTin/OliveTin/internal/auth" "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) { func logDebugRequest(cfg *config.Config, source string, r *http.Request) {
if cfg.LogDebugOptions.SingleFrontendRequests { if cfg.LogDebugOptions.SingleFrontendRequests {
log.Debugf("SingleFrontend HTTP Req URL %v: %q", source, r.URL) log.Debugf("SingleFrontend HTTP Req URL %v: %q", source, r.URL)
if cfg.LogDebugOptions.SingleFrontendRequestHeaders { if cfg.LogDebugOptions.SingleFrontendRequestHeaders {
for name, values := range r.Header { 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))
} }
} }
} }

View File

@ -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"}))
}