Compare commits

...

2 Commits

Author SHA1 Message Date
joshhunt 5168aa63dc Add fs devenv oauth setup using authelia 2026-01-14 23:51:06 +00:00
joshhunt 1aea178e23 FS: Fix oauth login error not displaying 2026-01-14 15:45:34 +00:00
8 changed files with 359 additions and 2 deletions
+3
View File
@@ -71,6 +71,7 @@ dc_resource("tempo", labels=["observability"])
dc_resource("postgres", labels=["misc"])
dc_resource("tempo-init", labels=["misc"])
dc_resource("authelia", labels=["auth"])
# paths in tilt files are confusing....
# - if tilt is dealing with the path, it is relative to the Tiltfile
@@ -86,6 +87,7 @@ docker_build('grafana-fs-dev',
'devenv/frontend-service/provisioning',
'devenv/frontend-service/configs/grafana-api.local.ini',
'devenv/frontend-service/configs/frontend-service.local.ini',
'devenv/frontend-service/configs/authelia',
'conf/defaults.ini',
'public/emails',
'public/views',
@@ -107,6 +109,7 @@ docker_build('grafana-fs-dev',
sync('../../public/dashboards', '/grafana/public/dashboards'),
sync('../../public/app/plugins', '/grafana/public/app/plugins'),
sync('../../public/build/assets-manifest.json', '/grafana/public/build/assets-manifest.json'),
sync('./configs/authelia', '/grafana/devenv/frontend-service/configs/authelia'),
sync('./provisioning', '/ignore/provisioning'), # Just to trigger a restart instead of rebuild
sync('./configs/grafana-api.local.ini', '/ignore/grafana-api.local.ini'), # Just to trigger a restart instead of rebuild
sync('./configs/frontend-service.local.ini', '/ignore/frontend-service.local.ini'), # Just to trigger a restart instead of rebuild
@@ -0,0 +1,121 @@
---
theme: light
server:
address: 'tcp://:9091/'
log:
level: debug
format: text
webauthn:
disable: true
duo_api:
disable: true
authentication_backend:
file:
path: /config/users_database.yml
password:
algorithm: 'argon2'
password_change:
disable: true
password_reset:
disable: true
session:
secret: insecure_session_secret
cookies:
- name: authelia_session
domain: oauth.localhost
authelia_url: https://oauth.localhost:9091
expiration: '1 hour'
inactivity: '5 minutes'
remember_me: '1 month'
access_control:
default_policy: one_factor
storage:
encryption_key: you_must_generate_a_secret_of_more_than_twenty_chars_and_configure_this
local:
path: /config/db.sqlite3
notifier:
filesystem:
filename: /config/notification.txt
identity_validation:
reset_password:
jwt_secret: a_very_important_secret
identity_providers:
oidc:
hmac_secret: this_is_a_secret_abc123abc123abc123
jwks:
- algorithm: RS256
key: |
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCbKAl8ITAb+1I8
61R1bp2ZbZuSuKxUYXuCGWdGa+lhn45sVJz2HkqtYe7m/BG87WbhiJv5LYvMApFW
ajm95LLqp8z3zPzD0ULy3Nks2yNIuMSVyT7/AltvO5wg11gRJZjSkre7knRF/KP6
ARbCwF/d1iLwXIWeGj+hCzaz3CPfMm7kSVzXUkzC1XsU38fb9TbDVHm1gQMluiKo
Yrmsz2DX6nyTSqJXfj6iblcjePs8Y9jzLNbyda7NyxA6k8WhKMwIHdCQxajsJwy6
AnkYLTLvNlR+kD6xa4hSCUs/eDo8zqFWybEchvwwbp/Xys443GgDUf/iGbZbBl09
tYWajIerAgMBAAECggEABzAHoHR5IhK2cJQGSZpiOqVVO6rKcO5DJne+zQhau2cE
1gflbZFhrD6JLrImsDXfOjt13kk53K2RxgsNubpKf4xedmxMmVWsYEvS070jGU9V
7ApynKWjTrfYN71CGyk+tyKM2Gekc0mM3xwFzefTiRdObwwaNITKxc8bLaHZF5HP
/FMG2zqf84TU1six4JamVxMK3EQSzBc1PnYoSa5W8hdQCQkZTx+T4YoGSi7UIZG1
BAxQN8u9Dj2la8fwd8ttT+ErGScyQqbfgCbs9/5guZSHXq/qBrMJ4LP/Ir8+VVfo
lrMtxotHWKvOdZqSy0D9GlUDo7i/tgShrUGojCnQwQKBgQDbZ493k6DvcIlu8N5d
kMMScX+zi6jTQtBfJXhDRybj4i+nUTWlRbnhNAZhD7gl+z+ngnnTWqmULTAPm2Np
jZudARUNiR+iRJonLrVMWeHF2nrE5/wrF3NDe7gyUxE2lAUy/wFFbAIxCp9pD50s
bafU+OYTGe9tTzjo+40PbgMocwKBgQC1CR7QKZjP0WNKO9LQYhY8y8/jkM58GWJm
R5qhlVTJ0yLgBZy3kdxlbpUsBoE6aByOC70cNKOO0vh1iimXzGSdtznBJc+PxiXX
zDFsPoS0QHlfbCQJmYrwiZhHPNAoCWYbJo8KvkIwVnM1UFrLdpCfk9sQIoZph46+
o7CSsI2t6QKBgBjA7FzPWR7qkXbk0hG4XWndSE0XeqqrJRs2/QSKKIcZY8r6zJSi
8z/HQNj+jwYp/JqHi/sehXdkScHZBDRKd74U+y0VxW3nU4UMLgQ5N9G8vpEsozSx
Zp28faGf4ZdIx/Vi89/DOdQSoL2Xt2Hl1UOf+UU6bdrlT0Rp7RZKkSylAoGAfBWc
QSHB0++5FpCalqokg9dOzrPaU8UyZNh/bHFmhE9rgBFYsZoQbpW1OU/cE6R4rgPt
wv9xe9uu4SGqEJnP/SoxM+ousmUmWxtiZMcVPldS2czNhqbvTJ+C+JD+O/L1QXbU
ZJCz3V3j6Y8CLKM/zaESbaS8bGi1toWL6X+KHMECgYEAloauyKHsgtKiNF3D8OmI
unN2E9NnLGFg7R59C0j+T9+qzi5Mkfeqef5euOa7KwkbKmhr1SmGW4sjtJ2m4sdD
pyTE9OuMKK7O8xXNSRLEoBk8gjodbeobYPFPjOkAJRZZFnP2JuLhyEklP1P4J0WZ
pPBw/R+JfWyZAqJcwrNdmgU=
-----END PRIVATE KEY-----
lifespans:
access_token: '1 hour'
authorize_code: '1 minute'
id_token: '1 hour'
refresh_token: '90 minutes'
enable_client_debug_messages: true
authorization_policies:
grafana_group_policy:
default_policy: 'deny'
rules:
- policy: 'one_factor'
subject: 'group:grafana'
clients:
- client_id: 'grafana'
consent_mode: implicit
client_name: 'Grafana'
client_secret: '$pbkdf2-sha512$310000$c8p78n7pUMln0jzvd4aK4Q$JNRBzwAo0ek5qKn50cFzzvE9RXV88h1wJn5KGiHrD0YKtZaR/nCb2CJPOsKaPK0hjf.9yHxzQGZziziccp6Yng' # The digest of 'insecure_secret'.
public: false
authorization_policy: 'grafana_group_policy'
require_pkce: true
pkce_challenge_method: 'S256'
redirect_uris:
- http://localhost:3000/login/generic_oauth
scopes:
- 'openid'
- 'profile'
- 'groups'
- 'email'
response_types:
- 'code'
grant_types:
- 'authorization_code'
access_token_signed_response_alg: 'RS256'
userinfo_signed_response_alg: 'none'
token_endpoint_auth_method: 'client_secret_basic'
@@ -0,0 +1,45 @@
# This file defines test users for local development.
# Password: all users have password "password"
# Password hash generated with: docker run --rm authelia/authelia:latest authelia crypto hash generate argon2 --password 'password'
#
# Users must in the 'grafana' group to be able to log in to Grafana.
users:
# Admin user with GrafanaAdmin role
oauth-admin:
disabled: false
displayname: 'Admin User'
password: '$argon2id$v=19$m=65536,t=3,p=4$an3cCNeaYsC2sFIc1LrCLQ$YxvcYkj2NTUsV5o5wxeIc0KNhqc8/mPIKKC6e1AnC4A'
email: test1@example.com
groups:
- grafana
- grafanaadmin
# Editor user
oauth-editor:
disabled: false
displayname: 'Editor User'
password: '$argon2id$v=19$m=65536,t=3,p=4$an3cCNeaYsC2sFIc1LrCLQ$YxvcYkj2NTUsV5o5wxeIc0KNhqc8/mPIKKC6e1AnC4A'
email: test2@example.com
groups:
- grafana
- editor
# Viewer user
oauth-viewer:
disabled: false
displayname: 'Viewer User'
password: '$argon2id$v=19$m=65536,t=3,p=4$an3cCNeaYsC2sFIc1LrCLQ$YxvcYkj2NTUsV5o5wxeIc0KNhqc8/mPIKKC6e1AnC4A'
email: test3@example.com
groups:
- grafana
- viewer
# Denied access user (not in grafana group)
norole:
disabled: false
displayname: 'No Role User'
password: '$argon2id$v=19$m=65536,t=3,p=4$an3cCNeaYsC2sFIc1LrCLQ$YxvcYkj2NTUsV5o5wxeIc0KNhqc8/mPIKKC6e1AnC4A'
email: norole@example.com
groups:
- users
+1 -1
View File
@@ -73,7 +73,7 @@ server {
}
# API calls go to the backend
location ~ ^/(api|apis|avatar|bootdata|render|logout|public/plugins|public/openapi3|public/api-merged|goto|swagger|openapi/v3) {
location ~ ^/(api|apis|avatar|bootdata|render|logout|public/plugins|public/openapi3|public/api-merged|goto|swagger|openapi/v3|login/generic_oauth) {
# Add debug headers to the response
add_header Nginx-Trace-Id $otel_trace_id always;
add_header Nginx-Route "backend" always;
+57 -1
View File
@@ -41,7 +41,7 @@ services:
GF_FEATURE_TOGGLES_ENABLE: enableNativeHTTPHistogram
GF_DATABASE_URL: postgres://grafana:grafana@postgres:5432/grafana
GF_SERVER_ROUTER_LOGGING: true
GF_LOG_LEVEL: info
GF_LOG_LEVEL: debug
GF_AUTH_LOGIN_COOKIE_NAME: grafana_fs_dev_login # set a custom cookie name to not conflict with other instances running on localhost
OTEL_SERVICE_NAME: grafana-api
GF_TRACING_OPENTELEMETRY_OTLP_ADDRESS: 'alloy:4317'
@@ -150,6 +150,62 @@ services:
tempo-init:
condition: service_completed_successfully
<<<<<<< ours
authelia:
image: authelia/authelia:latest
container_name: authelia
volumes:
- ./configs/authelia/configuration.yml:/config/configuration.yml
- ./configs/authelia/users_database.yml:/config/users_database.yml
ports:
- '9091:9091'
environment:
- TZ=UTC
labels:
- 'alloy.logs=true'
authelia:
image: authelia/authelia:latest
container_name: authelia
volumes:
- ./configs/authelia/configuration.yml:/config/configuration.yml
- ./configs/authelia/users_database.yml:/config/users_database.yml
ports:
- '9091:9091'
environment:
- TZ=UTC
labels:
- 'alloy.logs=true'
authelia:
image: authelia/authelia:latest
container_name: authelia
volumes:
- ./configs/authelia/configuration.yml:/config/configuration.yml
- ./configs/authelia/users_database.yml:/config/users_database.yml
ports:
- '9091:9091'
environment:
- TZ=UTC
labels:
- 'alloy.logs=true'
||||||| ancestor
=======
authelia:
image: authelia/authelia:latest
container_name: authelia
volumes:
- ./configs/authelia/configuration.yml:/config/configuration.yml
- ./configs/authelia/users_database.yml:/config/users_database.yml
ports:
- '9091:9091'
environment:
- TZ=UTC
labels:
- 'alloy.logs=true'
>>>>>>> theirs
volumes:
backend-data:
postgres-data:
@@ -187,6 +187,112 @@ func TestFrontendService_Middleware(t *testing.T) {
})
}
func TestFrontendService_LoginErrorCookie(t *testing.T) {
publicDir := setupTestWebAssets(t)
cfg := &setting.Cfg{
HTTPPort: "3000",
StaticRootPath: publicDir,
BuildVersion: "10.3.0",
OAuthLoginErrorMessage: "oauth.login.error",
CookieSecure: false,
CookieSameSiteDisabled: false,
CookieSameSiteMode: http.SameSiteLaxMode,
}
t.Run("should detect login_error cookie and set generic error message", func(t *testing.T) {
service := createTestService(t, cfg)
mux := web.New()
service.addMiddlewares(mux)
service.registerRoutes(mux)
req := httptest.NewRequest("GET", "/", nil)
// Set the login_error cookie (with some encrypted-looking value)
req.AddCookie(&http.Cookie{
Name: "login_error",
Value: "abc123encryptedvalue",
})
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
body := recorder.Body.String()
// Check that the generic error message is in the response
assert.Contains(t, body, "loginError", "Should contain loginError when cookie is present")
assert.Contains(t, body, "oauth.login.error", "Should contain the generic OAuth error message")
// Check that the cookie was deleted (MaxAge=-1)
cookies := recorder.Result().Cookies()
var foundDeletedCookie bool
for _, cookie := range cookies {
if cookie.Name == "login_error" {
assert.Equal(t, -1, cookie.MaxAge, "Cookie should be deleted (MaxAge=-1)")
assert.Equal(t, "", cookie.Value, "Cookie value should be empty")
foundDeletedCookie = true
break
}
}
assert.True(t, foundDeletedCookie, "Should have set a cookie deletion header")
})
t.Run("should not set error when login_error cookie is absent", func(t *testing.T) {
service := createTestService(t, cfg)
mux := web.New()
service.addMiddlewares(mux)
service.registerRoutes(mux)
req := httptest.NewRequest("GET", "/", nil)
// No login_error cookie
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
body := recorder.Body.String()
// The page should render but without the login error
assert.Contains(t, body, "window.grafanaBootData")
// Check that loginError is not set (or is empty/omitted in JSON)
// Since it's omitempty, it shouldn't appear at all
assert.NotContains(t, body, "loginError", "Should not contain loginError when cookie is absent")
})
t.Run("should handle custom OAuth error message from config", func(t *testing.T) {
customCfg := &setting.Cfg{
HTTPPort: "3000",
StaticRootPath: publicDir,
BuildVersion: "10.3.0",
OAuthLoginErrorMessage: "Oh no a boo-boo happened!",
CookieSecure: false,
CookieSameSiteDisabled: false,
CookieSameSiteMode: http.SameSiteLaxMode,
}
service := createTestService(t, customCfg)
mux := web.New()
service.addMiddlewares(mux)
service.registerRoutes(mux)
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{
Name: "login_error",
Value: "abc123encryptedvalue",
})
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
body := recorder.Body.String()
// Check that the custom error message is used
assert.Contains(t, body, "Oh no a boo-boo happened!", "Should use custom OAuth error message from config")
})
}
func TestFrontendService_IndexHooks(t *testing.T) {
publicDir := setupTestWebAssets(t)
cfg := &setting.Cfg{
@@ -47,4 +47,6 @@ type FSFrontendSettings struct {
CSPReportOnlyEnabled bool `json:"cspReportOnlyEnabled,omitempty"`
Http2Enabled bool `json:"http2Enabled,omitempty"`
ReportingStaticContext map[string]string `json:"reportingStaticContext,omitempty"`
LoginError string `json:"loginError,omitempty"`
}
+24
View File
@@ -148,6 +148,30 @@ func (p *IndexProvider) HandleRequest(writer http.ResponseWriter, request *http.
data.Nonce = nonce
data.PublicDashboardAccessToken = reqCtx.PublicDashboardAccessToken
// TODO -- reevaluate with mt authnz
// Check for login_error cookie and set a generic error message.
// The backend sets an encrypted cookie on oauth login failures that we can't read
// so we just show a generic error if the cookie is present.
if cookie, err := request.Cookie("login_error"); err == nil && cookie.Value != "" {
p.log.Info("request has login_error cookie")
// Defaults to a translation key that the frontend will resolve to a localized message
data.Settings.LoginError = p.data.Config.OAuthLoginErrorMessage
cookiePath := "/"
if p.data.AppSubUrl != "" {
cookiePath = p.data.AppSubUrl
}
http.SetCookie(writer, &http.Cookie{
Name: "login_error",
Value: "",
Path: cookiePath,
MaxAge: -1,
HttpOnly: true,
Secure: p.data.Config.CookieSecure,
SameSite: p.data.Config.CookieSameSiteMode,
})
}
if data.CSPEnabled {
data.CSPContent = middleware.ReplacePolicyVariables(p.data.CSPContent, p.data.AppSubUrl, data.Nonce)
writer.Header().Set("Content-Security-Policy", data.CSPContent)