From 6286c56517d2e3dd72e99fcdb8d8c155abe6eaac Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sun, 30 Mar 2025 03:09:53 +0200 Subject: [PATCH 1/5] add OIDC authentication --- go.mod | 12 +- go.sum | 25 ++-- internal/glance/auth.go | 231 ++++++++++++++++++++++++++++++++++++ internal/glance/config.go | 12 ++ internal/glance/glance.go | 66 +++++++++++ internal/glance/unixtime.go | 42 +++++++ 6 files changed, 378 insertions(+), 10 deletions(-) create mode 100644 internal/glance/auth.go create mode 100644 internal/glance/unixtime.go diff --git a/go.mod b/go.mod index 0ded337..d1fb9c5 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,14 @@ module github.com/glanceapp/glance go 1.23.6 require ( + github.com/coreos/go-oidc/v3 v3.13.0 github.com/fsnotify/fsnotify v1.8.0 + github.com/google/uuid v1.6.0 github.com/mmcdole/gofeed v1.3.0 github.com/shirou/gopsutil/v4 v4.25.1 github.com/tidwall/gjson v1.18.0 - golang.org/x/text v0.22.0 + golang.org/x/oauth2 v0.28.0 + golang.org/x/text v0.23.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -15,7 +18,9 @@ require ( github.com/PuerkitoBio/goquery v1.10.1 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/ebitengine/purego v0.8.2 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect github.com/mmcdole/goxpp v1.1.1 // indirect @@ -27,6 +32,7 @@ require ( github.com/tklauser/go-sysconf v0.3.14 // indirect github.com/tklauser/numcpus v0.9.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.30.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/sys v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index 97af31d..b0c2da6 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8W github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/coreos/go-oidc/v3 v3.13.0 h1:M66zd0pcc5VxvBNM4pB331Wrsanby+QomQYjN8HamW8= +github.com/coreos/go-oidc/v3 v3.13.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -9,12 +11,17 @@ github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= @@ -58,6 +65,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -72,8 +81,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -95,8 +106,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -115,8 +126,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/glance/auth.go b/internal/glance/auth.go new file mode 100644 index 0000000..13b8601 --- /dev/null +++ b/internal/glance/auth.go @@ -0,0 +1,231 @@ +package glance + +import ( + "context" + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +type GlanceAuth struct { + OidcProvider *oidc.Provider + OAuth2Config oauth2.Config + OidcVefifier *oidc.IDTokenVerifier + cookieName string +} + +type AuthenticatationResult struct { + IDToken string + RefreshToken string + IDTokenClaims *Claims +} + +type Claims struct { + Issuer string `json:"iss"` + Audience string `json:"aud"` + IssuedAt UnixTime `json:"iat"` + Expiration UnixTime `json:"exp"` +} + +func CreateGlanceAuth(ctx context.Context, config *config) (*GlanceAuth, error) { + if config.Server.BaseURL == "" { + return nil, errors.New("Option server.base-url required for OIDC authentication") + } + + provider, err := oidc.NewProvider(ctx, config.Authentication.Issuer) + if err != nil { + return nil, err + } + + verifier := provider.Verifier(&oidc.Config{ + ClientID: config.Authentication.ClientId, + }) + + scopes := []string{oidc.ScopeOpenID} + scopes = append(scopes, strings.Split(config.Authentication.Scopes, " ")...) + + return &GlanceAuth{ + OidcProvider: provider, + OAuth2Config: oauth2.Config{ + ClientID: config.Authentication.ClientId, + ClientSecret: config.Authentication.ClientSecret, + RedirectURL: config.Server.BaseURL + "/auth/resp", + + // Discovery returns the OAuth2 endpoints. + Endpoint: provider.Endpoint(), + + // "openid" is a required scope for OpenID Connect flows. + Scopes: scopes, + }, + OidcVefifier: verifier, + cookieName: config.Authentication.CookieName, + }, nil +} + +func (fw *GlanceAuth) VerifyToken(ctx context.Context, oauth2Token *oauth2.Token) (AuthenticatationResult, error) { + var result AuthenticatationResult + + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + return result, errors.New("No id_token field in oauth2 token") + } + + idToken, err := fw.OidcVefifier.Verify(ctx, rawIDToken) + if err != nil { + return result, err + } + + result = AuthenticatationResult{ + IDToken: rawIDToken, + RefreshToken: oauth2Token.RefreshToken, + IDTokenClaims: new(Claims), + } + if err := idToken.Claims(&result.IDTokenClaims); err != nil { + return result, err + } + + return result, nil +} + +func (fw *GlanceAuth) RefreshToken(ctx context.Context, refreshToken string) (*AuthenticatationResult, error) { + var result AuthenticatationResult + + tokenSource := fw.OAuth2Config.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken}) + oauth2Token, err := tokenSource.Token() + if err != nil { + return &result, err + } + + result, err = fw.VerifyToken(ctx, oauth2Token) + if err != nil { + return &result, err + } + + return &result, nil +} + +func getBaseCookie() *http.Cookie { + return &http.Cookie{ + Path: "/", + HttpOnly: true, + Secure: true, + } +} + +func (fw *GlanceAuth) MakeAuthCookie(authResult *AuthenticatationResult) *http.Cookie { + cookie := getBaseCookie() + cookie.Name = fw.cookieName + cookie.Value = authResult.IDToken + cookie.Expires = time.Now().Local().Add(time.Hour * 24) + + return cookie +} + +func (fw *GlanceAuth) GetAuthCookie(r *http.Request) (*http.Cookie, error) { + return r.Cookie(fw.cookieName) +} + +func (fw *GlanceAuth) ClearAuthCookie() *http.Cookie { + cookie := getBaseCookie() + cookie.Name = fw.cookieName + cookie.Expires = time.Now().Local().Add(time.Hour * -1) + + return cookie +} + +func (fw *GlanceAuth) MakeRefreshAuthCookie(authResult *AuthenticatationResult) *http.Cookie { + cookie := getBaseCookie() + cookie.Name = fw.cookieName + "-refresh" + cookie.Value = authResult.RefreshToken + cookie.Expires = time.Now().Local().Add(time.Hour * 24) + + return cookie +} + +func (fw *GlanceAuth) GetRefreshAuthCookie(r *http.Request) (*http.Cookie, error) { + return r.Cookie(fw.cookieName + "-refresh") +} + +func (fw *GlanceAuth) ClearRefreshAuthCookie() *http.Cookie { + cookie := getBaseCookie() + cookie.Name = fw.cookieName + "-refresh" + cookie.Expires = time.Now().Local().Add(time.Hour * -1) + + return cookie +} + +func (fw *GlanceAuth) HandleAuthentication(ctx context.Context, code string) (*AuthenticatationResult, error) { + var result AuthenticatationResult + + oauth2Token, err := fw.OAuth2Config.Exchange(ctx, code) + if err != nil { + slog.Error(err.Error()) + return &result, err + } + + result, err = fw.VerifyToken(ctx, oauth2Token) + if err != nil { + slog.Error(err.Error()) + return &result, err + } + + slog.Debug("Authentication was succesful.") + return &result, nil +} + +func (fw *GlanceAuth) IsAuthenticated(context context.Context, w http.ResponseWriter, r *http.Request) (*Claims, error) { + var claims Claims + + // Check if we have an Auth cookie + cookie, err := fw.GetAuthCookie(r) + if err != nil { + return &claims, err + } + + // check if the token is valid + idToken, err := fw.OidcVefifier.Verify(context, cookie.Value) + + switch { + case err == nil: // Token is valid + slog.Debug("Received valid token.") + + claims = Claims{} + if err := idToken.Claims(&claims); err != nil { + slog.Error(err.Error()) + return &claims, err + } + + return &claims, nil + + case strings.Contains(err.Error(), "expired"): // Token is expired + slog.Debug("Received expired token, trying to refesh it.") + + refreshCookie, err := fw.GetRefreshAuthCookie(r) + if err != nil { + slog.Error(err.Error()) + return &claims, err + } + + result, err := fw.RefreshToken(context, refreshCookie.Value) + if err != nil { + slog.Error(err.Error()) + return &claims, err + } + + http.SetCookie(w, fw.MakeAuthCookie(result)) + if len(result.RefreshToken) > 0 { // Do we have an refresh token? + http.SetCookie(w, fw.MakeRefreshAuthCookie(result)) + } + + return result.IDTokenClaims, nil + + default: + slog.Error(err.Error()) + return &claims, err + } +} diff --git a/internal/glance/config.go b/internal/glance/config.go index 0d424a2..6bec9d9 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -49,6 +49,14 @@ type config struct { FaviconURL string `yaml:"favicon-url"` } `yaml:"branding"` + Authentication struct { + Issuer string `yaml:"issuer"` + ClientId string `yaml:"client-id"` + ClientSecret string `yaml:"client-secret"` + Scopes string `yaml:"scopes"` + CookieName string `yaml:"cookie-name"` + } + Pages []page `yaml:"pages"` } @@ -96,6 +104,10 @@ func newConfigFromYAML(contents []byte) (*config, error) { } } + if config.Authentication.CookieName == "" { + config.Authentication.CookieName = "glanceauth" + } + return config, nil } diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 8fb3e40..9b13b25 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -6,12 +6,15 @@ import ( "fmt" "html/template" "log" + "log/slog" "net/http" "path/filepath" "strconv" "strings" "sync" "time" + + "github.com/google/uuid" ) var ( @@ -24,15 +27,26 @@ type application struct { Version string Config config ParsedThemeStyle template.HTML + Auth *GlanceAuth slugToPage map[string]*page widgetByID map[uint64]widget } func newApplication(config *config) (*application, error) { + var auth *GlanceAuth + if config.Authentication.Issuer != "" { + var err error + auth, err = CreateGlanceAuth(context.Background(), config) + if err != nil { + return nil, err + } + } + app := &application{ Version: buildVersion, Config: *config, + Auth: auth, slugToPage: make(map[string]*page), widgetByID: make(map[uint64]widget), } @@ -130,6 +144,10 @@ type pageTemplateData struct { } func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) { + if a.handleAuth(w, r, true) { + return + } + page, exists := a.slugToPage[r.PathValue("page")] if !exists { @@ -154,6 +172,10 @@ func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) } func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) { + if a.handleAuth(w, r, false) { + return + } + page, exists := a.slugToPage[r.PathValue("page")] if !exists { @@ -192,6 +214,10 @@ func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) { } func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) { + if a.handleAuth(w, r, false) { + return + } + widgetValue := r.PathValue("widget") widgetID, err := strconv.ParseUint(widgetValue, 10, 64) @@ -210,6 +236,42 @@ func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request widget.handleRequest(w, r) } +func (a *application) handleOidcCallback(w http.ResponseWriter, r *http.Request) { + authResult, err := a.Auth.HandleAuthentication(r.Context(), r.URL.Query().Get("code")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.SetCookie(w, a.Auth.MakeAuthCookie(authResult)) + if len(authResult.RefreshToken) > 0 { + http.SetCookie(w, a.Auth.MakeRefreshAuthCookie(authResult)) + } + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) +} + +func (a *application) handleAuth(w http.ResponseWriter, r *http.Request, allowRedir bool) bool { + if a.Auth == nil { + return false + } + + _, err := a.Auth.IsAuthenticated(r.Context(), w, r) + if err != nil { + if allowRedir { + slog.Debug("IsAuthenticated failed, initiating login flow") + http.SetCookie(w, a.Auth.ClearAuthCookie()) + http.SetCookie(w, a.Auth.ClearRefreshAuthCookie()) + + state := uuid.New().String() + http.Redirect(w, r, a.Auth.OAuth2Config.AuthCodeURL(state), http.StatusTemporaryRedirect) + } else { + w.WriteHeader(http.StatusUnauthorized) + } + return true + } + + return false +} + func (a *application) AssetPath(asset string) string { return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset } @@ -228,6 +290,10 @@ func (a *application) server() (func() error, func() error) { w.WriteHeader(http.StatusOK) }) + if a.Auth != nil { + mux.HandleFunc("GET /auth/resp", a.handleOidcCallback) + } + mux.Handle( fmt.Sprintf("GET /static/%s/{path...}", staticFSHash), http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)), diff --git a/internal/glance/unixtime.go b/internal/glance/unixtime.go new file mode 100644 index 0000000..7004475 --- /dev/null +++ b/internal/glance/unixtime.go @@ -0,0 +1,42 @@ +package glance + +import ( + "strconv" + "time" +) + +// UnixTime defines a timestamp encoded as epoch seconds in JSON +type UnixTime time.Time + +// MarshalJSON is used to convert the timestamp to JSON +func (t UnixTime) MarshalJSON() ([]byte, error) { + return []byte(strconv.FormatInt(time.Time(t).Unix(), 10)), nil +} + +// UnmarshalJSON is used to convert the timestamp from JSON +func (t *UnixTime) UnmarshalJSON(s []byte) (err error) { + r := string(s) + q, err := strconv.ParseInt(r, 10, 64) + if err != nil { + return err + } + *(*time.Time)(t) = time.Unix(q, 0) + return nil +} + +// Unix returns t as a Unix time, the number of seconds elapsed +// since January 1, 1970 UTC. The result does not depend on the +// location associated with t. +func (t UnixTime) Unix() int64 { + return time.Time(t).Unix() +} + +// Time returns the JSON time as a time.Time instance in UTC +func (t UnixTime) Time() time.Time { + return time.Time(t).UTC() +} + +// String returns t as a formatted string +func (t UnixTime) String() string { + return t.Time().String() +} From 85f8408e9bb1e31b1c5abca6fe3a9bdb4959904b Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Mon, 31 Mar 2025 02:05:46 +0200 Subject: [PATCH 2/5] add slightly hacky support for different domains --- internal/glance/auth.go | 30 +++++++++++++++++++++++++----- internal/glance/glance.go | 5 +++-- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/internal/glance/auth.go b/internal/glance/auth.go index 13b8601..7e2d3e5 100644 --- a/internal/glance/auth.go +++ b/internal/glance/auth.go @@ -14,9 +14,10 @@ import ( type GlanceAuth struct { OidcProvider *oidc.Provider - OAuth2Config oauth2.Config + oAuth2Config oauth2.Config OidcVefifier *oidc.IDTokenVerifier cookieName string + https bool } type AuthenticatationResult struct { @@ -51,7 +52,7 @@ func CreateGlanceAuth(ctx context.Context, config *config) (*GlanceAuth, error) return &GlanceAuth{ OidcProvider: provider, - OAuth2Config: oauth2.Config{ + oAuth2Config: oauth2.Config{ ClientID: config.Authentication.ClientId, ClientSecret: config.Authentication.ClientSecret, RedirectURL: config.Server.BaseURL + "/auth/resp", @@ -64,6 +65,7 @@ func CreateGlanceAuth(ctx context.Context, config *config) (*GlanceAuth, error) }, OidcVefifier: verifier, cookieName: config.Authentication.CookieName, + https: strings.HasPrefix(config.Server.BaseURL, "https://"), }, nil } @@ -95,7 +97,7 @@ func (fw *GlanceAuth) VerifyToken(ctx context.Context, oauth2Token *oauth2.Token func (fw *GlanceAuth) RefreshToken(ctx context.Context, refreshToken string) (*AuthenticatationResult, error) { var result AuthenticatationResult - tokenSource := fw.OAuth2Config.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken}) + tokenSource := fw.oAuth2Config.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken}) oauth2Token, err := tokenSource.Token() if err != nil { return &result, err @@ -159,10 +161,11 @@ func (fw *GlanceAuth) ClearRefreshAuthCookie() *http.Cookie { return cookie } -func (fw *GlanceAuth) HandleAuthentication(ctx context.Context, code string) (*AuthenticatationResult, error) { +func (fw *GlanceAuth) HandleAuthentication(ctx context.Context, code string, host string) (*AuthenticatationResult, error) { var result AuthenticatationResult - oauth2Token, err := fw.OAuth2Config.Exchange(ctx, code) + oauth := fw.OAuthConfig(host) + oauth2Token, err := oauth.Exchange(ctx, code) if err != nil { slog.Error(err.Error()) return &result, err @@ -229,3 +232,20 @@ func (fw *GlanceAuth) IsAuthenticated(context context.Context, w http.ResponseWr return &claims, err } } + +func (fw *GlanceAuth) OAuthConfig(host string) oauth2.Config { + var proto string + if fw.https { + proto = "https://" + } else { + proto = "http://" + } + + return oauth2.Config{ + ClientID: fw.oAuth2Config.ClientID, + ClientSecret: fw.oAuth2Config.ClientSecret, + RedirectURL: proto + host + "/auth/resp", + Endpoint: fw.oAuth2Config.Endpoint, + Scopes: fw.oAuth2Config.Scopes, + } +} diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 9b13b25..dcc9710 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -237,7 +237,7 @@ func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request } func (a *application) handleOidcCallback(w http.ResponseWriter, r *http.Request) { - authResult, err := a.Auth.HandleAuthentication(r.Context(), r.URL.Query().Get("code")) + authResult, err := a.Auth.HandleAuthentication(r.Context(), r.URL.Query().Get("code"), r.Host) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -262,7 +262,8 @@ func (a *application) handleAuth(w http.ResponseWriter, r *http.Request, allowRe http.SetCookie(w, a.Auth.ClearRefreshAuthCookie()) state := uuid.New().String() - http.Redirect(w, r, a.Auth.OAuth2Config.AuthCodeURL(state), http.StatusTemporaryRedirect) + oauth := a.Auth.OAuthConfig(r.Host) + http.Redirect(w, r, oauth.AuthCodeURL(state), http.StatusTemporaryRedirect) } else { w.WriteHeader(http.StatusUnauthorized) } From 9031e2dd8ea00a64918eeaa65227c6301ee8fd45 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Mon, 31 Mar 2025 03:26:08 +0200 Subject: [PATCH 3/5] fix https config, add hide-header option --- internal/glance/auth.go | 6 +----- internal/glance/config.go | 1 + internal/glance/widget.go | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/internal/glance/auth.go b/internal/glance/auth.go index 7e2d3e5..7807878 100644 --- a/internal/glance/auth.go +++ b/internal/glance/auth.go @@ -34,10 +34,6 @@ type Claims struct { } func CreateGlanceAuth(ctx context.Context, config *config) (*GlanceAuth, error) { - if config.Server.BaseURL == "" { - return nil, errors.New("Option server.base-url required for OIDC authentication") - } - provider, err := oidc.NewProvider(ctx, config.Authentication.Issuer) if err != nil { return nil, err @@ -65,7 +61,7 @@ func CreateGlanceAuth(ctx context.Context, config *config) (*GlanceAuth, error) }, OidcVefifier: verifier, cookieName: config.Authentication.CookieName, - https: strings.HasPrefix(config.Server.BaseURL, "https://"), + https: strings.HasPrefix(config.Server.BaseURL, "https://") || config.Server.UsesHttps, }, nil } diff --git a/internal/glance/config.go b/internal/glance/config.go index 6bec9d9..dfb2b07 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -23,6 +23,7 @@ type config struct { Port uint16 `yaml:"port"` AssetsPath string `yaml:"assets-path"` BaseURL string `yaml:"base-url"` + UsesHttps bool `yaml:"uses-https"` StartedAt time.Time `yaml:"-"` // used in custom css file } `yaml:"server"` diff --git a/internal/glance/widget.go b/internal/glance/widget.go index ab41c79..bb52458 100644 --- a/internal/glance/widget.go +++ b/internal/glance/widget.go @@ -157,7 +157,7 @@ type widgetBase struct { cacheType cacheType `yaml:"-"` nextUpdate time.Time `yaml:"-"` updateRetriedTimes int `yaml:"-"` - HideHeader bool `yaml:"-"` + HideHeader bool `yaml:"hide-header"` } type widgetProviders struct { From 342d1a9ea8d724cc8e6c6890a72c97f6669d5e6c Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 18 Apr 2025 10:43:37 +0200 Subject: [PATCH 4/5] build with version tag --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 0c4cc63..884fdba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,9 @@ FROM golang:1.24.2-alpine3.21 AS builder +ARG VERSION WORKDIR /app COPY . /app -RUN CGO_ENABLED=0 go build . +RUN CGO_ENABLED=0 go build -ldflags="-s -w -X github.com/glanceapp/glance/internal/glance.buildVersion=$VERSION" . FROM alpine:3.21 From a672995807949f3b886d1bbee7a1fb518ed2cf46 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 18 Apr 2025 11:46:40 +0200 Subject: [PATCH 5/5] chore: update release URL --- internal/glance/templates/page.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/glance/templates/page.html b/internal/glance/templates/page.html index 90f7177..2140a5e 100644 --- a/internal/glance/templates/page.html +++ b/internal/glance/templates/page.html @@ -72,7 +72,7 @@