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 diff --git a/go.mod b/go.mod index 4c19477..c641df0 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,13 @@ module github.com/glanceapp/glance go 1.24.2 require ( + github.com/coreos/go-oidc/v3 v3.13.0 github.com/fsnotify/fsnotify v1.9.0 + github.com/google/uuid v1.6.0 github.com/mmcdole/gofeed v1.3.0 github.com/shirou/gopsutil/v4 v4.25.3 github.com/tidwall/gjson v1.18.0 + golang.org/x/oauth2 v0.28.0 golang.org/x/text v0.24.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -15,6 +18,7 @@ require ( github.com/PuerkitoBio/goquery v1.10.2 // 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/json-iterator/go v1.1.12 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect @@ -27,6 +31,7 @@ require ( github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/crypto v0.37.0 // indirect golang.org/x/net v0.39.0 // indirect golang.org/x/sys v0.32.0 // indirect ) diff --git a/go.sum b/go.sum index 9a79559..0729683 100644 --- a/go.sum +++ b/go.sum @@ -1,29 +1,29 @@ -github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU= -github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY= github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8= github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU= 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= github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= 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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.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= -github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= @@ -39,8 +39,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs= -github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI= github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE= github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -54,12 +52,8 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= -github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= -github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo= -github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI= github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -71,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.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 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= @@ -85,10 +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.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +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= @@ -110,8 +106,6 @@ 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.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= @@ -132,8 +126,6 @@ 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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/glance/auth.go b/internal/glance/auth.go new file mode 100644 index 0000000..7807878 --- /dev/null +++ b/internal/glance/auth.go @@ -0,0 +1,247 @@ +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 + https bool +} + +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) { + 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, + https: strings.HasPrefix(config.Server.BaseURL, "https://") || config.Server.UsesHttps, + }, 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, host string) (*AuthenticatationResult, error) { + var result AuthenticatationResult + + oauth := fw.OAuthConfig(host) + oauth2Token, err := oauth.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 + } +} + +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/config.go b/internal/glance/config.go index 0d424a2..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"` @@ -49,6 +50,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 +105,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..dcc9710 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,43 @@ 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"), r.Host) + 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() + oauth := a.Auth.OAuthConfig(r.Host) + http.Redirect(w, r, oauth.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 +291,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/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 @@