Compare commits

...

2 Commits

Author SHA1 Message Date
joshhunt
cf233d68eb Add Oauth configuration to frontend service devenv 2026-01-14 15:45:34 +00:00
joshhunt
1aea178e23 FS: Fix oauth login error not displaying 2026-01-14 15:45:34 +00:00
8 changed files with 5659 additions and 1 deletions

View File

@@ -71,6 +71,11 @@ dc_resource("tempo", labels=["observability"])
dc_resource("postgres", labels=["misc"])
dc_resource("tempo-init", labels=["misc"])
dc_resource("oauthkeycloakdb", labels=["auth"])
dc_resource("oauthkeycloak",
resource_deps=["oauthkeycloakdb"],
labels=["auth"]
)
# paths in tilt files are confusing....
# - if tilt is dealing with the path, it is relative to the Tiltfile
@@ -86,6 +91,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/oauth',
'conf/defaults.ini',
'public/emails',
'public/views',
@@ -107,6 +113,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('./oauth', '/grafana/devenv/frontend-service/oauth'),
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

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;

View File

@@ -150,6 +150,36 @@ services:
tempo-init:
condition: service_completed_successfully
oauthkeycloakdb:
image: docker.io/library/postgres:16-alpine
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: password
volumes:
- ./oauth/cloak.sql:/docker-entrypoint-initdb.d/cloak.sql
- oauth-keycloak-data:/var/lib/postgresql/data
labels:
- 'alloy.logs=true'
oauthkeycloak:
image: quay.io/keycloak/keycloak:23.0
command: start-dev
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://oauthkeycloakdb/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: password
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
PROXY_ADDRESS_FORWARDING: "true"
ports:
- '8087:8080'
depends_on:
- oauthkeycloakdb
labels:
- 'alloy.logs=true'
volumes:
backend-data:
postgres-data:
@@ -157,3 +187,4 @@ volumes:
loki-data:
tempo-data:
prometheus-data:
oauth-keycloak-data:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"keys": [
{
"kid": "On2FQuJ8Y-909uJGWQEDkbzG-GRNmMc43HslEgVv_VQ",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "qDmQHfTcOQOzmNJbVvtvuS8p_EgmiscP7vA_PZNyKx9O7utyGuoAmJH8e2w8gLIDDWHl5_x8aAIl_-TTPTSiyX8I68ryIdR28ZSe5u4pRdpXCVvJpOefKNIxQCTH7rs4KuRj0HZ2u1mu1Vz5_CeCCoKwKSmheD3u1xTJ8-VxQmdqfGxhuKtnkof7977HWOWy4GLDFqxyYHgihP_MmSeTmXUhVeZI6IOCqHMpF8eFWVGKM6V8rIKf8QO2K_vDJBM_3C933vMY8mqSQXbI3G54x-0myAaQXr4JkxjvUGKg5YC3ZXw7AjfZv_W_fQOG0GYp2hQ0akR4KNKT3XPNmpMVlQ",
"e": "AQAB",
"x5c": [
"MIICnTCCAYUCBgF+u1ir8jANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAdncmFmYW5hMB4XDTIyMDIwMjE2NDkxN1oXDTMyMDIwMjE2NTA1N1owEjEQMA4GA1UEAwwHZ3JhZmFuYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKg5kB303DkDs5jSW1b7b7kvKfxIJorHD+7wPz2TcisfTu7rchrqAJiR/HtsPICyAw1h5ef8fGgCJf/k0z00osl/COvK8iHUdvGUnubuKUXaVwlbyaTnnyjSMUAkx+67OCrkY9B2drtZrtVc+fwnggqCsCkpoXg97tcUyfPlcUJnanxsYbirZ5KH+/e+x1jlsuBiwxascmB4IoT/zJknk5l1IVXmSOiDgqhzKRfHhVlRijOlfKyCn/EDtiv7wyQTP9wvd97zGPJqkkF2yNxueMftJsgGkF6+CZMY71BioOWAt2V8OwI32b/1v30DhtBmKdoUNGpEeCjSk91zzZqTFZUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEABlW64QxuREB81VMGsyhj4Q5RykFaVuD5O8YlwUpmVfAVLzb0Drf54Kn4bnpnckKyYV+T+HsN4QXt81UE41xH0Aai2H3vrGH+PJf6aLPCDE+jpMqtN3n6IgImJXJPL8upMfhhWDv4nkM4uynEwWupzmrKi4oJuTETSMktJby4o6//XWnCzCVMoAGFJU4gtjBUzOMLW26zD+yc+BuUtfR3HzItVHSZKQSNSFO0kVS68RgrER8qJw07z3BOJ2bPpPM0PYyEngGMaowz/T6lI32ymGMWYMAnslthS1KAW9xcTBwnrW1nMhe5a0LPxIktys/wJtxIHZLc5sOddGT4xYklLg=="
],
"x5t": "prs-h1NBqOSJMH-tQWLTqguWets",
"x5t#S256": "YjK3HobZW8xbNL1IPDgFhCM41UC5c0hG2cxaF6v961Q"
}
]
}

View File

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

View File

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

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)