Compare commits
2 Commits
ash/react-
...
j-fs-dev-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf233d68eb | ||
|
|
1aea178e23 |
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
5471
devenv/frontend-service/oauth/cloak.sql
Normal file
5471
devenv/frontend-service/oauth/cloak.sql
Normal file
File diff suppressed because it is too large
Load Diff
17
devenv/frontend-service/oauth/jwks.json
Normal file
17
devenv/frontend-service/oauth/jwks.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user