diff --git a/pkg/auth/auth_simple.go b/pkg/auth/auth_simple.go index 72ea242..69717f2 100644 --- a/pkg/auth/auth_simple.go +++ b/pkg/auth/auth_simple.go @@ -535,6 +535,26 @@ func (s *simpleService) handleCallback(w http.ResponseWriter, r *http.Request) { redirectParams.Set("orgs", strings.Join(githubUser.Organizations, ",")) } + // Resolve success page display customization from config rules. + if s.cfg.SuccessPage != nil { + display := s.cfg.SuccessPage.Resolve(githubUser.Login, githubUser.Organizations) + if display.Tagline != "" { + redirectParams.Set("sp_tagline", display.Tagline) + } + + if display.Media != nil { + redirectParams.Set("sp_media_type", display.Media.Type) + + if display.Media.URL != "" { + redirectParams.Set("sp_media_url", display.Media.URL) + } + + if display.Media.ASCIIArtBase64 != "" { + redirectParams.Set("sp_media_ascii", display.Media.ASCIIArtBase64) + } + } + } + redirectURL := fmt.Sprintf("%s?%s", pending.RedirectURI, redirectParams.Encode()) http.Redirect(w, r, redirectURL, http.StatusFound) } diff --git a/pkg/auth/client/client.go b/pkg/auth/client/client.go index 2f6857c..d33ad5a 100644 --- a/pkg/auth/client/client.go +++ b/pkg/auth/client/client.go @@ -306,8 +306,12 @@ func (c *client) startCallbackServer(_ context.Context, expectedState string, co } user := callbackUser{ - Login: r.URL.Query().Get("login"), - AvatarURL: r.URL.Query().Get("avatar_url"), + Login: r.URL.Query().Get("login"), + AvatarURL: r.URL.Query().Get("avatar_url"), + Tagline: r.URL.Query().Get("sp_tagline"), + MediaType: r.URL.Query().Get("sp_media_type"), + MediaURL: r.URL.Query().Get("sp_media_url"), + MediaASCIIB64: r.URL.Query().Get("sp_media_ascii"), } if orgsParam := r.URL.Query().Get("orgs"); orgsParam != "" { diff --git a/pkg/auth/client/success_page.go b/pkg/auth/client/success_page.go index 9145b04..b797725 100644 --- a/pkg/auth/client/success_page.go +++ b/pkg/auth/client/success_page.go @@ -12,37 +12,17 @@ type callbackUser struct { Login string AvatarURL string Orgs []string -} -// specialUsers get ASCII art instead of the GIF. -var specialUsers = map[string]bool{ - "samcm": true, - "mattevans": true, - "savid": true, -} - -// pandaASCIIBase64 is the base64-encoded ASCII art shown to special users. -// To update: write your raw multiline ASCII art to a file, then: -// -// base64 < art.txt | tr -d '\n' -// -// Paste the output here. -var pandaASCIIBase64 = "KiBnIG8gYSB0IHMgZSB4ICogZyBvIGEgdCBzIGUgeCAqIGcgbyBhIHQgcyBlIHggKgpnICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBnICAKbyAvICAgICBcICAgICAgICAgICAgIFwgICAgICAgICAgICAvICAgIFwgICAgICAgbwphfCAgICAgICB8ICAgICAgICAgICAgIFwgICAgICAgICAgfCAgICAgIHwgICAgICBhCnR8ICAgICAgIGAuICAgICAgICAgICAgIHwgICAgICAgICB8ICAgICAgIDogICAgIHQKc2AgICAgICAgIHwgICAgICAgICAgICAgfCAgICAgICAgXHwgICAgICAgfCAgICAgcwplIFwgICAgICAgfCAvICAgICAgIC8gIFxcXCAgIC0tX18gXFwgICAgICAgOiAgICBlCnggIFwgICAgICBcLyAgIF8tLX5+ICAgICAgICAgIH4tLV9ffCBcICAgICB8ICAgIHggIAoqICAgXCAgICAgIFxfLX4gICAgICAgICAgICAgICAgICAgIH4tX1wgICAgfCAgICAqCmcgICAgXF8gICAgIFwgICAgICAgIF8uLS0tLS0tLS0uX19fX19fXHwgICB8ICAgIGcKbyAgICAgIFwgICAgIFxfX19fX18vLyBfIF9fXyBfIChfKF9fPiAgXCAgIHwgICAgbwphICAgICAgIFwgICAuICBDIF9fXykgIF9fX19fXyAoXyhfX19fPiAgfCAgLyAgICBhCnQgICAgICAgL1wgfCAgIEMgX19fXykvICAgICAgXCAoX19fX18+ICB8Xy8gICAgIHQKcyAgICAgIC8gL1x8ICAgQ19fX19fKSAgICAgICB8ICAoX19fPiAgIC8gIFwgICAgcwplICAgICB8ICAgKCAgIF9DX19fX18pXF9fX19fXy8gIC8vIF8vIC8gICAgIFwgICBlCnggICAgIHwgICAgXCAgfF9fICAgXFxfX19fX19fX18vLyAoX18vICAgICAgIHwgIHgKKiAgICB8IFwgICAgXF9fX18pICAgYC0tLS0gICAtLScgICAgICAgICAgICAgfCAgKgpnICAgIHwgIFxfICAgICAgICAgIF9fX1wgICAgICAgL18gICAgICAgICAgXy8gfCBnCm8gICB8ICAgICAgICAgICAgICAvICAgIHwgICAgIHwgIFwgICAgICAgICAgICB8IG8KYSAgIHwgICAgICAgICAgICAgfCAgICAvICAgICAgIFwgIFwgICAgICAgICAgIHwgYQp0ICAgfCAgICAgICAgICAvIC8gICAgfCAgICAgICAgIHwgIFwgICAgICAgICAgIHx0CnMgICB8ICAgICAgICAgLyAvICAgICAgXF9fL1xfX18vICAgIHwgICAgICAgICAgfHMKZSAgfCAgICAgICAgICAgLyAgICAgICAgfCAgICB8ICAgICAgIHwgICAgICAgICB8ZQp4ICB8ICAgICAgICAgIHwgICAgICAgICB8ICAgIHwgICAgICAgfCAgICAgICAgIHx4CiogZyBvIGEgdCBzIGUgeCAqIGcgbyBhIHQgcyBlIHggKiBnIG8gYSB0IHMgZSB4ICoK" //nolint:lll // base64 blob - -// decodePandaASCII decodes the base64-encoded ASCII art. -func decodePandaASCII() string { - b, err := base64.StdEncoding.DecodeString(pandaASCIIBase64) - if err != nil { - return "(panda art failed to decode)" - } - - return string(b) + // Display fields resolved by proxy success page rules. + Tagline string + MediaType string // "gif" or "ascii" + MediaURL string + MediaASCIIB64 string } // buildSuccessPage generates a styled HTML success page based on user info. func buildSuccessPage(user callbackUser) string { //nolint:funlen // single HTML template - isEthPandaOps := hasOrg(user.Orgs, "ethpandaops") - isSpecialUser := specialUsers[strings.ToLower(user.Login)] + hasOrgs := len(user.Orgs) > 0 login := html.EscapeString(user.Login) if login == "" { @@ -59,32 +39,42 @@ func buildSuccessPage(user callbackUser) string { //nolint:funlen // single HTML avatarHTML = `
%s`, html.EscapeString(art)) - } else { - mediaHTML = `

%s`, html.EscapeString(string(art))) + } + } + case "gif": + if user.MediaURL != "" { + mediaHTML = fmt.Sprintf( + `
`)
}
-func TestBuildSuccessPage_EthPandaOps(t *testing.T) {
+func TestBuildSuccessPage_WithOrg(t *testing.T) {
t.Parallel()
page := buildSuccessPage(callbackUser{
@@ -46,55 +39,72 @@ func TestBuildSuccessPage_EthPandaOps(t *testing.T) {
})
assert.Contains(t, page, "pandafan")
- assert.Contains(t, page, "Enjoy debugging your devnet champ")
assert.Contains(t, page, "ethpandaops")
- assert.Contains(t, page, "casino-royale-bond.gif")
- assert.NotContains(t, page, "`)
+ assert.NotContains(t, page, "`)
}
-func TestBuildSuccessPage_SpecialUserWithoutOrg(t *testing.T) {
+func TestBuildSuccessPage_InvalidASCIIBase64(t *testing.T) {
t.Parallel()
page := buildSuccessPage(callbackUser{
- Login: "samcm",
- Orgs: []string{"some-other-org"},
+ Login: "someone",
+ MediaType: "ascii",
+ MediaASCIIB64: "not-valid-base64!!!",
})
- assert.Contains(t, page, "logged in to panda")
+ // Should gracefully skip the media block.
assert.NotContains(t, page, " 0 && !matchesAnyOrg(orgs, r.Match.Orgs) {
+ return false
+ }
+
+ if len(r.Match.Users) > 0 && !containsLower(r.Match.Users, lowerLogin) {
+ return false
+ }
+
+ return true
+}
+
+// matchesAnyOrg returns true if the user belongs to at least one of the target orgs.
+func matchesAnyOrg(userOrgs, targetOrgs []string) bool {
+ for _, target := range targetOrgs {
+ lower := strings.ToLower(target)
+
+ for _, org := range userOrgs {
+ if strings.ToLower(org) == lower {
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+// containsLower returns true if needle is in the haystack (case-insensitive).
+func containsLower(haystack []string, needle string) bool {
+ for _, s := range haystack {
+ if strings.ToLower(s) == needle {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/pkg/auth/success_page_test.go b/pkg/auth/success_page_test.go
new file mode 100644
index 0000000..ddaf1e7
--- /dev/null
+++ b/pkg/auth/success_page_test.go
@@ -0,0 +1,199 @@
+package auth
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSuccessPageConfig_Resolve(t *testing.T) {
+ t.Parallel()
+
+ cfg := &SuccessPageConfig{
+ Rules: []SuccessPageRule{
+ {
+ Match: SuccessPageMatch{
+ Orgs: []string{"ethpandaops"},
+ Users: []string{"samcm", "mattevans"},
+ },
+ SuccessPageDisplay: SuccessPageDisplay{
+ Tagline: "special user tagline",
+ Media: &SuccessPageMedia{
+ Type: "ascii",
+ ASCIIArtBase64: "dGVzdA==",
+ },
+ },
+ },
+ {
+ Match: SuccessPageMatch{
+ Orgs: []string{"ethpandaops"},
+ },
+ SuccessPageDisplay: SuccessPageDisplay{
+ Tagline: "org tagline",
+ Media: &SuccessPageMedia{
+ Type: "gif",
+ URL: "https://example.com/cool.gif",
+ },
+ },
+ },
+ },
+ Default: &SuccessPageDisplay{
+ Tagline: "default tagline",
+ },
+ }
+
+ tests := []struct {
+ name string
+ login string
+ orgs []string
+ wantTagline string
+ wantMediaType string
+ wantMediaURL string
+ wantMediaASCII string
+ }{
+ {
+ name: "special user matches first rule",
+ login: "samcm",
+ orgs: []string{"ethpandaops"},
+ wantTagline: "special user tagline",
+ wantMediaType: "ascii",
+ wantMediaASCII: "dGVzdA==",
+ },
+ {
+ name: "org member matches second rule",
+ login: "someone",
+ orgs: []string{"ethpandaops"},
+ wantTagline: "org tagline",
+ wantMediaType: "gif",
+ wantMediaURL: "https://example.com/cool.gif",
+ },
+ {
+ name: "no match returns default",
+ login: "outsider",
+ orgs: []string{"other-org"},
+ wantTagline: "default tagline",
+ },
+ {
+ name: "special user without matching org falls through",
+ login: "samcm",
+ orgs: []string{"other-org"},
+ wantTagline: "default tagline",
+ },
+ {
+ name: "empty user returns default",
+ login: "",
+ orgs: nil,
+ wantTagline: "default tagline",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ display := cfg.Resolve(tt.login, tt.orgs)
+
+ assert.Equal(t, tt.wantTagline, display.Tagline)
+
+ if tt.wantMediaType != "" {
+ assert.NotNil(t, display.Media)
+ assert.Equal(t, tt.wantMediaType, display.Media.Type)
+ assert.Equal(t, tt.wantMediaURL, display.Media.URL)
+ assert.Equal(t, tt.wantMediaASCII, display.Media.ASCIIArtBase64)
+ } else {
+ assert.Nil(t, display.Media)
+ }
+ })
+ }
+}
+
+func TestSuccessPageConfig_Resolve_CaseInsensitive(t *testing.T) {
+ t.Parallel()
+
+ cfg := &SuccessPageConfig{
+ Rules: []SuccessPageRule{
+ {
+ Match: SuccessPageMatch{
+ Orgs: []string{"EthPandaOps"},
+ Users: []string{"SamCM"},
+ },
+ SuccessPageDisplay: SuccessPageDisplay{
+ Tagline: "matched",
+ },
+ },
+ },
+ }
+
+ display := cfg.Resolve("samcm", []string{"ethpandaops"})
+ assert.Equal(t, "matched", display.Tagline)
+
+ display = cfg.Resolve("SAMCM", []string{"ETHPANDAOPS"})
+ assert.Equal(t, "matched", display.Tagline)
+}
+
+func TestSuccessPageConfig_Resolve_NilConfig(t *testing.T) {
+ t.Parallel()
+
+ var cfg *SuccessPageConfig
+ display := cfg.Resolve("samcm", []string{"ethpandaops"})
+ assert.Empty(t, display.Tagline)
+ assert.Nil(t, display.Media)
+}
+
+func TestSuccessPageConfig_Resolve_EmptyRulesNoDefault(t *testing.T) {
+ t.Parallel()
+
+ cfg := &SuccessPageConfig{}
+ display := cfg.Resolve("samcm", []string{"ethpandaops"})
+ assert.Empty(t, display.Tagline)
+ assert.Nil(t, display.Media)
+}
+
+func TestSuccessPageConfig_Resolve_UsersOnlyMatch(t *testing.T) {
+ t.Parallel()
+
+ cfg := &SuccessPageConfig{
+ Rules: []SuccessPageRule{
+ {
+ Match: SuccessPageMatch{
+ Users: []string{"admin"},
+ },
+ SuccessPageDisplay: SuccessPageDisplay{
+ Tagline: "admin tagline",
+ },
+ },
+ },
+ }
+
+ display := cfg.Resolve("admin", nil)
+ assert.Equal(t, "admin tagline", display.Tagline)
+
+ display = cfg.Resolve("other", nil)
+ assert.Empty(t, display.Tagline)
+}
+
+func TestSuccessPageConfig_Resolve_OrgsOnlyMatch(t *testing.T) {
+ t.Parallel()
+
+ cfg := &SuccessPageConfig{
+ Rules: []SuccessPageRule{
+ {
+ Match: SuccessPageMatch{
+ Orgs: []string{"myorg", "otherorg"},
+ },
+ SuccessPageDisplay: SuccessPageDisplay{
+ Tagline: "org member",
+ },
+ },
+ },
+ }
+
+ display := cfg.Resolve("anyone", []string{"myorg"})
+ assert.Equal(t, "org member", display.Tagline)
+
+ display = cfg.Resolve("anyone", []string{"otherorg"})
+ assert.Equal(t, "org member", display.Tagline)
+
+ display = cfg.Resolve("anyone", []string{"nope"})
+ assert.Empty(t, display.Tagline)
+}
diff --git a/pkg/proxy/server.go b/pkg/proxy/server.go
index 039d4df..973842b 100644
--- a/pkg/proxy/server.go
+++ b/pkg/proxy/server.go
@@ -97,6 +97,7 @@ func newServer(log logrus.FieldLogger, cfg ServerConfig, hostURL, port string) (
GitHub: cfg.Auth.GitHub,
AllowedOrgs: append([]string(nil), cfg.Auth.AllowedOrgs...),
Tokens: cfg.Auth.Tokens,
+ SuccessPage: cfg.Auth.SuccessPage,
}
authSvc, err := simpleauth.NewSimpleService(log, authCfg)
diff --git a/pkg/proxy/server_config.go b/pkg/proxy/server_config.go
index 60c94a6..ca6ae53 100644
--- a/pkg/proxy/server_config.go
+++ b/pkg/proxy/server_config.go
@@ -79,6 +79,9 @@ type AuthConfig struct {
// AccessTokenTTL is the lifetime of proxy-issued access tokens.
AccessTokenTTL time.Duration `yaml:"access_token_ttl,omitempty"`
+
+ // SuccessPage customizes the OAuth callback success page shown in the browser.
+ SuccessPage *simpleauth.SuccessPageConfig `yaml:"success_page,omitempty"`
}
// ClickHouseClusterConfig holds ClickHouse cluster configuration.
diff --git a/proxy-config.example.yaml b/proxy-config.example.yaml
index 3cb11a3..d2819d2 100644
--- a/proxy-config.example.yaml
+++ b/proxy-config.example.yaml
@@ -36,6 +36,26 @@ auth:
# tokens:
# secret_key: "${PROXY_TOKEN_SECRET}"
+ # Customize the OAuth callback success page shown in the browser.
+ # Rules are evaluated in order; the first match wins.
+ # success_page:
+ # rules:
+ # - match:
+ # orgs: ["ethpandaops"]
+ # users: ["samcm", "mattevans"]
+ # media:
+ # type: ascii
+ # ascii_art_base64: "base64-encoded-art-here"
+ # tagline: "Enjoy debugging your devnet champ"
+ # - match:
+ # orgs: ["ethpandaops"]
+ # media:
+ # type: gif
+ # url: "https://example.com/cool.gif"
+ # tagline: "Enjoy debugging your devnet champ"
+ # default:
+ # tagline: "You can close this window and return to your terminal."
+
# ClickHouse clusters
clickhouse:
- name: xatu