From e530300eb26d0525e96d9c42bbe8d33f9127e089 Mon Sep 17 00:00:00 2001 From: Christian Banse Date: Thu, 28 Dec 2023 15:10:52 +0100 Subject: [PATCH] Preparing OAuth 2.0 based login (#272) --- cmd/moneyd/moneyd.go | 91 +++++++++++++++++-- go.mod | 14 ++- go.sum | 30 ++++-- service/portfolio/snapshot.go | 17 +++- ui/package-lock.json | 26 ++++++ ui/package.json | 1 + ui/src/hooks.server.js | 10 -- ui/src/lib/api/client.ts | 36 +++++++- ui/src/lib/oauth.ts | 21 +++++ ui/src/routes/{ => (app)}/+layout.svelte | 0 ui/src/routes/(app)/+layout.ts | 15 +++ .../routes/{ => (app)}/dashboard/+page.svelte | 0 .../{ => (app)}/portfolios/+page.svelte | 0 ui/src/routes/(app)/portfolios/+page.ts | 23 +++++ .../portfolios/[...portfolioName]/+layout.ts | 23 +++++ .../[...portfolioName]/+page.svelte | 0 .../transactions/+page.svelte | 0 .../[...portfolioName]/transactions/+page.ts | 13 ++- .../transactions/[txName]/+page.svelte | 0 .../transactions/[txName]/+page.ts | 6 +- .../{ => (app)}/securities/+page.svelte | 0 ui/src/routes/(auth)/callback/+page.ts | 20 ++++ ui/src/routes/(auth)/login/+page.ts | 6 ++ ui/src/routes/+error.svelte | 7 ++ ui/src/routes/+layout.ts | 10 -- ui/src/routes/portfolios/+page.ts | 19 ---- .../portfolios/[...portfolioName]/+layout.ts | 19 ---- 27 files changed, 317 insertions(+), 90 deletions(-) delete mode 100644 ui/src/hooks.server.js create mode 100644 ui/src/lib/oauth.ts rename ui/src/routes/{ => (app)}/+layout.svelte (100%) create mode 100644 ui/src/routes/(app)/+layout.ts rename ui/src/routes/{ => (app)}/dashboard/+page.svelte (100%) rename ui/src/routes/{ => (app)}/portfolios/+page.svelte (100%) create mode 100644 ui/src/routes/(app)/portfolios/+page.ts create mode 100644 ui/src/routes/(app)/portfolios/[...portfolioName]/+layout.ts rename ui/src/routes/{ => (app)}/portfolios/[...portfolioName]/+page.svelte (100%) rename ui/src/routes/{ => (app)}/portfolios/[...portfolioName]/transactions/+page.svelte (100%) rename ui/src/routes/{ => (app)}/portfolios/[...portfolioName]/transactions/+page.ts (50%) rename ui/src/routes/{ => (app)}/portfolios/[...portfolioName]/transactions/[txName]/+page.svelte (100%) rename ui/src/routes/{ => (app)}/portfolios/[...portfolioName]/transactions/[txName]/+page.ts (88%) rename ui/src/routes/{ => (app)}/securities/+page.svelte (100%) create mode 100644 ui/src/routes/(auth)/callback/+page.ts create mode 100644 ui/src/routes/(auth)/login/+page.ts delete mode 100644 ui/src/routes/portfolios/+page.ts delete mode 100644 ui/src/routes/portfolios/[...portfolioName]/+layout.ts diff --git a/cmd/moneyd/moneyd.go b/cmd/moneyd/moneyd.go index f672891d..82efdff6 100644 --- a/cmd/moneyd/moneyd.go +++ b/cmd/moneyd/moneyd.go @@ -17,24 +17,31 @@ package main import ( + "context" + "errors" "log/slog" "net/http" "os" "strings" "time" + "github.com/oxisto/money-gopher/gen/portfoliov1connect" + "github.com/oxisto/money-gopher/persistence" + "github.com/oxisto/money-gopher/service/portfolio" + "github.com/oxisto/money-gopher/service/securities" + "github.com/oxisto/money-gopher/ui" + + "connectrpc.com/connect" + "github.com/MicahParks/keyfunc/v3" "github.com/alecthomas/kong" + "github.com/golang-jwt/jwt/v5" "github.com/lmittmann/tint" "github.com/mattn/go-colorable" "github.com/mattn/go-isatty" + oauth2 "github.com/oxisto/oauth2go" + "github.com/oxisto/oauth2go/login" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" - - "github.com/oxisto/money-gopher/gen/portfoliov1connect" - "github.com/oxisto/money-gopher/persistence" - "github.com/oxisto/money-gopher/service/portfolio" - "github.com/oxisto/money-gopher/service/securities" - "github.com/oxisto/money-gopher/ui" ) var cmd moneydCmd @@ -52,8 +59,9 @@ func main() { func (cmd *moneydCmd) Run() error { var ( - w = os.Stdout - level = slog.LevelInfo + w = os.Stdout + level = slog.LevelInfo + authSrv *oauth2.AuthorizationServer ) if cmd.Debug { @@ -74,8 +82,22 @@ func (cmd *moneydCmd) Run() error { db, err := persistence.OpenDB(persistence.Options{}) if err != nil { slog.Error("Error while opening database", tint.Err(err)) + return err } + authSrv = oauth2.NewServer( + ":8000", + oauth2.WithClient("dashboard", "", "http://localhost:5173/callback"), + oauth2.WithPublicURL("http://localhost:8000"), + login.WithLoginPage( + login.WithUser("money", "money"), + ), + oauth2.WithAllowedOrigins("*"), + ) + go authSrv.ListenAndServe() + + interceptors := connect.WithInterceptors(NewAuthInterceptor()) + mux := http.NewServeMux() // The generated constructors return a path and a plain net/http // handler. @@ -84,8 +106,8 @@ func (cmd *moneydCmd) Run() error { DB: db, SecuritiesClient: portfoliov1connect.NewSecuritiesServiceClient(http.DefaultClient, portfolio.DefaultSecuritiesServiceURL), }, - ))) - mux.Handle(portfoliov1connect.NewSecuritiesServiceHandler(securities.NewService(db))) + ), interceptors)) + mux.Handle(portfoliov1connect.NewSecuritiesServiceHandler(securities.NewService(db), interceptors)) mux.Handle("/", ui.SvelteKitHandler("/")) err = http.ListenAndServe( @@ -121,3 +143,52 @@ func handleCORS(h http.Handler) http.Handler { } }) } + +func NewAuthInterceptor() connect.UnaryInterceptorFunc { + interceptor := func(next connect.UnaryFunc) connect.UnaryFunc { + k, err := keyfunc.NewDefault([]string{"http://localhost:8000/certs"}) + if err != nil { + slog.Error("Error while setting up JWKS", tint.Err(err)) + } + + return connect.UnaryFunc(func( + ctx context.Context, + req connect.AnyRequest, + ) (connect.AnyResponse, error) { + var ( + claims jwt.RegisteredClaims + auth string + token string + err error + ok bool + ) + auth = req.Header().Get("Authorization") + if auth == "" { + return nil, connect.NewError( + connect.CodeUnauthenticated, + errors.New("no token provided"), + ) + } + + token, ok = strings.CutPrefix(auth, "Bearer ") + if !ok { + return nil, connect.NewError( + connect.CodeUnauthenticated, + errors.New("no token provided"), + ) + } + + _, err = jwt.ParseWithClaims(token, &claims, k.Keyfunc) + if err != nil { + return nil, connect.NewError( + connect.CodeUnauthenticated, + err, + ) + } + + ctx = context.WithValue(ctx, "claims", claims) + return next(ctx, req) + }) + } + return connect.UnaryInterceptorFunc(interceptor) +} diff --git a/go.mod b/go.mod index fdbffa60..2338308a 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,20 @@ module github.com/oxisto/money-gopher -go 1.21 +go 1.21.5 require ( connectrpc.com/connect v1.14.0 + github.com/MicahParks/keyfunc/v3 v3.1.1 github.com/alecthomas/kong v0.8.1 github.com/fatih/color v1.16.0 + github.com/golang-jwt/jwt/v5 v5.2.0 github.com/jotaen/kong-completion v0.0.6 github.com/lmittmann/tint v1.0.3 github.com/mattn/go-colorable v0.1.13 github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-sqlite3 v1.14.19 github.com/oxisto/assert v0.0.6 + github.com/oxisto/oauth2go v0.12.0 github.com/posener/complete v1.2.3 golang.org/x/net v0.19.0 golang.org/x/text v0.14.0 @@ -19,8 +22,17 @@ require ( ) require ( + github.com/MicahParks/jwkset v0.5.4 // indirect github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab // indirect + golang.org/x/crypto v0.16.0 // indirect + golang.org/x/oauth2 v0.15.0 // indirect golang.org/x/sys v0.15.0 // indirect ) +// needed until https://github.com/golang/oauth2/issues/615 is resolved +require ( + github.com/golang/protobuf v1.5.3 // indirect + google.golang.org/appengine v1.6.7 // indirect +) + replace github.com/posener/complete v1.2.3 => github.com/oxisto/complete v0.0.0-20231209194436-0b605e2b5bff diff --git a/go.sum b/go.sum index 3c9da259..fcd25020 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,9 @@ -connectrpc.com/connect v1.13.0 h1:lGs5maZZzWOOD+PFFiOt5OncKmMsk9ZdPwpy5jcmaYg= -connectrpc.com/connect v1.13.0/go.mod h1:uHAFHtYgeSZJxXrkN1IunDpKghnTXhYbVh0wW4StPW0= connectrpc.com/connect v1.14.0 h1:PDS+J7uoz5Oui2VEOMcfz6Qft7opQM9hPiKvtGC01pA= connectrpc.com/connect v1.14.0/go.mod h1:uoAq5bmhhn43TwhaKdGKN/bZcGtzPW1v+ngDTn5u+8s= +github.com/MicahParks/jwkset v0.5.4 h1:59s9OUNIKF3g+IXYm3pa4vPXXEudRNetyy3+H6KpKdw= +github.com/MicahParks/jwkset v0.5.4/go.mod h1:fOx7dCX+XgPDzcRbZzi9DMY3vyebWXmsz7XPqstr3ms= +github.com/MicahParks/keyfunc/v3 v3.1.1 h1:ghC5jcuU4/TTQQ9Ns7TEVuhnscQOH+WL4//Jmsy5/DA= +github.com/MicahParks/keyfunc/v3 v3.1.1/go.mod h1:Qmrhb9tkHX1i/kCiLAPDOCWIEfN9yq7u/tkP16lmLL8= github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0= github.com/alecthomas/assert/v2 v2.1.0/go.mod h1:b/+1DI2Q6NckYi+3mXyH3wFb8qG37K/DuK80n7WefXA= github.com/alecthomas/kong v0.8.1 h1:acZdn3m4lLRobeh3Zi2S2EpnXTd1mOL6U7xVml+vfkY= @@ -12,7 +14,12 @@ 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/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -27,32 +34,43 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI= -github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/oxisto/assert v0.0.6 h1:Z/wRt0qndURRof+eOGr7GbcJ6BHZT2nyZd9diuZHS8o= github.com/oxisto/assert v0.0.6/go.mod h1:07ANKfyBm6j+pZk1qArFueno6fCoEGKvPbPeJSQkH3s= github.com/oxisto/complete v0.0.0-20231209194436-0b605e2b5bff h1:WiQOAXar+naQAYzv3P01lE1dagFrc61ZC/fbg9z5wuc= github.com/oxisto/complete v0.0.0-20231209194436-0b605e2b5bff/go.mod h1:wEf3y/0bTolv0kZjmKzgbU7L+PvFfm6KoWGeP/f2oCQ= +github.com/oxisto/oauth2go v0.12.0 h1:MQd9ZdI7NO/hNrbmuLyuR4kUT2sbHhZeEBHUSdlUjFM= +github.com/oxisto/oauth2go v0.12.0/go.mod h1:IKjnDnmIiXQTaHYhuWgDMXDg8kqZzLYb8ClQs3r0n6Q= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab h1:ZjX6I48eZSFetPb41dHudEyVr5v953N15TsNZXlkcWY= github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab/go.mod h1:/PfPXh0EntGc3QAAyUaviy4S9tzy4Zp0e2ilq4voC6E= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= +golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/service/portfolio/snapshot.go b/service/portfolio/snapshot.go index 8614c8a8..c12cd0eb 100644 --- a/service/portfolio/snapshot.go +++ b/service/portfolio/snapshot.go @@ -18,6 +18,7 @@ package portfolio import ( "context" + "fmt" moneygopher "github.com/oxisto/money-gopher" "github.com/oxisto/money-gopher/finance" @@ -69,14 +70,16 @@ func (svc *service) GetPortfolioSnapshot(ctx context.Context, req *connect.Reque // Retrieve market value of filtered securities secres, err = svc.securities.ListSecurities( context.Background(), - connect.NewRequest(&portfoliov1.ListSecuritiesRequest{ + forwardAuth(connect.NewRequest(&portfoliov1.ListSecuritiesRequest{ Filter: &portfoliov1.ListSecuritiesRequest_Filter{ SecurityNames: names, }, - }), + }), req), ) if err != nil { - return nil, connect.NewError(connect.CodeInternal, err) + return nil, connect.NewError(connect.CodeInternal, + fmt.Errorf("internal error while calling ListSecurities on securities service: %w", err), + ) } // Make a map out of the securities list so we can access it easier @@ -143,3 +146,11 @@ func keys[M ~map[K]V, K comparable, V any](m M) (keys []K) { return keys } + +// forwardAuth uses the authorization header of [authenticatedReq] to +// authenticate [req]. This is a little workaround, until we have proper +// service-to-service authentication. +func forwardAuth[T any, S any](req *connect.Request[T], authenticatedReq *connect.Request[S]) *connect.Request[T] { + req.Header().Set("Authorization", authenticatedReq.Header().Get("Authorization")) + return req +} diff --git a/ui/package-lock.json b/ui/package-lock.json index 7a793714..3f76439c 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -23,6 +23,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-svelte": "^2.30.0", "inter-ui": "^4.0.0", + "oidc-client-ts": "^2.4.0", "postcss": "^8.4.27", "prettier": "^3.0.1", "prettier-plugin-svelte": "^3.0.3", @@ -1537,6 +1538,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "dev": true + }, "node_modules/css-tree": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", @@ -2382,6 +2389,12 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==", + "dev": true + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -2656,6 +2669,19 @@ "node": ">= 6" } }, + "node_modules/oidc-client-ts": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.4.0.tgz", + "integrity": "sha512-WijhkTrlXK2VvgGoakWJiBdfIsVGz6CFzgjNNqZU1hPKV2kyeEaJgLs7RwuiSp2WhLfWBQuLvr2SxVlZnk3N1w==", + "dev": true, + "dependencies": { + "crypto-js": "^4.2.0", + "jwt-decode": "^3.1.2" + }, + "engines": { + "node": ">=12.13.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/ui/package.json b/ui/package.json index f4d5e0db..4cef8816 100644 --- a/ui/package.json +++ b/ui/package.json @@ -27,6 +27,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-svelte": "^2.30.0", "inter-ui": "^4.0.0", + "oidc-client-ts": "^2.4.0", "postcss": "^8.4.27", "prettier": "^3.0.1", "prettier-plugin-svelte": "^3.0.3", diff --git a/ui/src/hooks.server.js b/ui/src/hooks.server.js deleted file mode 100644 index f332ebb6..00000000 --- a/ui/src/hooks.server.js +++ /dev/null @@ -1,10 +0,0 @@ -// Note: this file is required for the universal fetch handler - -/** @type {import('@sveltejs/kit').Handle} */ -export async function handle({ event, resolve }) { - const response = await resolve(event, { - filterSerializedResponseHeaders: (name) => name === 'content-type' - }); - - return response; -} diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 38eb43d5..181b0813 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -1,7 +1,33 @@ import { PortfolioService, SecuritiesService } from '$lib/gen/mgo_connect'; -import { createPromiseClient } from '@connectrpc/connect'; +import { Code, ConnectError, createPromiseClient } from '@connectrpc/connect'; import { createConnectTransport } from '@connectrpc/connect-web'; -import type { PromiseClient } from '@connectrpc/connect'; +import type { Interceptor, PromiseClient } from '@connectrpc/connect'; +import { error } from '@sveltejs/kit'; + +/** + * This function converts an {@link ConnectError} into a error suitable for + * catching in SvelteKit (using {@link error}). + * + * @param err an error + */ +export function convertError(err: unknown): Promise { + if (err instanceof ConnectError) { + // convert it into a svelekit error and adjust the code for some errors + if (err.code == Code.Unauthenticated) { + throw error(401, err.rawMessage); + } else { + throw error(500, err.rawMessage); + } + } else { + // otherwise, just rethrow it + throw err; + } +} + +const authorizer: Interceptor = (next) => async (req) => { + req.header.set('Authorization', `Bearer ${localStorage.token}`); + return await next(req); +}; export function portfolioClient(fetch = window.fetch): PromiseClient { return createPromiseClient( @@ -9,7 +35,8 @@ export function portfolioClient(fetch = window.fetch): PromiseClient { + window.location.href = req.url; + }); +} + +export function setToken(token: string) { + localStorage.setItem('token', token); +} diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/(app)/+layout.svelte similarity index 100% rename from ui/src/routes/+layout.svelte rename to ui/src/routes/(app)/+layout.svelte diff --git a/ui/src/routes/(app)/+layout.ts b/ui/src/routes/(app)/+layout.ts new file mode 100644 index 00000000..fad8223a --- /dev/null +++ b/ui/src/routes/(app)/+layout.ts @@ -0,0 +1,15 @@ +import { securitiesClient, convertError } from '$lib/api/client'; +import { ListSecuritiesResponse } from '$lib/gen/mgo_pb'; +import type { LayoutLoad } from './$types'; + +export const load = (async ({ fetch }) => { + const client = securitiesClient(fetch); + + try { + const securities = (await client.listSecurities({}).catch(convertError)) + .securities; + return { securities }; + } catch (err) { + convertError(err); + } +}) satisfies LayoutLoad; diff --git a/ui/src/routes/dashboard/+page.svelte b/ui/src/routes/(app)/dashboard/+page.svelte similarity index 100% rename from ui/src/routes/dashboard/+page.svelte rename to ui/src/routes/(app)/dashboard/+page.svelte diff --git a/ui/src/routes/portfolios/+page.svelte b/ui/src/routes/(app)/portfolios/+page.svelte similarity index 100% rename from ui/src/routes/portfolios/+page.svelte rename to ui/src/routes/(app)/portfolios/+page.svelte diff --git a/ui/src/routes/(app)/portfolios/+page.ts b/ui/src/routes/(app)/portfolios/+page.ts new file mode 100644 index 00000000..11d6aa66 --- /dev/null +++ b/ui/src/routes/(app)/portfolios/+page.ts @@ -0,0 +1,23 @@ +import type { PageLoad } from './$types'; +import { ListPortfoliosResponse, PortfolioSnapshot, type Portfolio } from '$lib/gen/mgo_pb'; +import { convertError, portfolioClient } from '$lib/api/client'; + +export const load = (async ({ fetch }) => { + const client = portfolioClient(fetch); + + const portfolios = ( + await client.listPortfolios({}, {}).catch(convertError) + ).portfolios; + const snapshots = await Promise.all( + portfolios.map(async (p: Portfolio) => { + if (client == undefined) { + throw 'could not instantiate portfolio client'; + } + return await client + .getPortfolioSnapshot({ portfolioName: p.name }) + .catch(convertError); + }) + ); + + return { portfolios, snapshots }; +}) satisfies PageLoad; diff --git a/ui/src/routes/(app)/portfolios/[...portfolioName]/+layout.ts b/ui/src/routes/(app)/portfolios/[...portfolioName]/+layout.ts new file mode 100644 index 00000000..e5300aa1 --- /dev/null +++ b/ui/src/routes/(app)/portfolios/[...portfolioName]/+layout.ts @@ -0,0 +1,23 @@ +import type { LayoutData } from './$types'; +import { error } from '@sveltejs/kit'; +import { portfolioClient, convertError } from '$lib/api/client'; +import { Portfolio, PortfolioSnapshot } from '$lib/gen/mgo_pb'; + +export const load = (async ({ fetch, params, depends }) => { + if (params.portfolioName == undefined) { + throw error(405, 'Required parameter missing'); + } + + const client = portfolioClient(fetch); + + const portfolio = await client + .getPortfolio({ name: params.portfolioName }) + .catch(convertError); + const snapshot = await client + .getPortfolioSnapshot({ portfolioName: params.portfolioName }) + .catch(convertError); + + depends(`data:portfolio-snapshot:${params.portfolioName}`); + + return { portfolio, snapshot }; +}) as LayoutData; diff --git a/ui/src/routes/portfolios/[...portfolioName]/+page.svelte b/ui/src/routes/(app)/portfolios/[...portfolioName]/+page.svelte similarity index 100% rename from ui/src/routes/portfolios/[...portfolioName]/+page.svelte rename to ui/src/routes/(app)/portfolios/[...portfolioName]/+page.svelte diff --git a/ui/src/routes/portfolios/[...portfolioName]/transactions/+page.svelte b/ui/src/routes/(app)/portfolios/[...portfolioName]/transactions/+page.svelte similarity index 100% rename from ui/src/routes/portfolios/[...portfolioName]/transactions/+page.svelte rename to ui/src/routes/(app)/portfolios/[...portfolioName]/transactions/+page.svelte diff --git a/ui/src/routes/portfolios/[...portfolioName]/transactions/+page.ts b/ui/src/routes/(app)/portfolios/[...portfolioName]/transactions/+page.ts similarity index 50% rename from ui/src/routes/portfolios/[...portfolioName]/transactions/+page.ts rename to ui/src/routes/(app)/portfolios/[...portfolioName]/transactions/+page.ts index 49da2b28..fa85370f 100644 --- a/ui/src/routes/portfolios/[...portfolioName]/transactions/+page.ts +++ b/ui/src/routes/(app)/portfolios/[...portfolioName]/transactions/+page.ts @@ -1,6 +1,7 @@ import { error } from '@sveltejs/kit'; -import { portfolioClient } from '$lib/api/client'; +import { convertError, portfolioClient } from '$lib/api/client'; import type { PageData } from './$types'; +import { ListPortfolioTransactionsResponse } from '$lib/gen/mgo_pb'; export const load = (async ({ fetch, params, depends }) => { if (params.portfolioName == undefined) { @@ -10,12 +11,14 @@ export const load = (async ({ fetch, params, depends }) => { const client = portfolioClient(fetch); const transactions = ( - await client.listPortfolioTransactions({ - portfolioName: params.portfolioName - }) + await client + .listPortfolioTransactions({ + portfolioName: params.portfolioName + }) + .catch(convertError) ).transactions; - depends(`data:portfolio-transactions:${params.portfolioName}`) + depends(`data:portfolio-transactions:${params.portfolioName}`); return { transactions }; }) as PageData; diff --git a/ui/src/routes/portfolios/[...portfolioName]/transactions/[txName]/+page.svelte b/ui/src/routes/(app)/portfolios/[...portfolioName]/transactions/[txName]/+page.svelte similarity index 100% rename from ui/src/routes/portfolios/[...portfolioName]/transactions/[txName]/+page.svelte rename to ui/src/routes/(app)/portfolios/[...portfolioName]/transactions/[txName]/+page.svelte diff --git a/ui/src/routes/portfolios/[...portfolioName]/transactions/[txName]/+page.ts b/ui/src/routes/(app)/portfolios/[...portfolioName]/transactions/[txName]/+page.ts similarity index 88% rename from ui/src/routes/portfolios/[...portfolioName]/transactions/[txName]/+page.ts rename to ui/src/routes/(app)/portfolios/[...portfolioName]/transactions/[txName]/+page.ts index 8a5e1be3..9d34b360 100644 --- a/ui/src/routes/portfolios/[...portfolioName]/transactions/[txName]/+page.ts +++ b/ui/src/routes/(app)/portfolios/[...portfolioName]/transactions/[txName]/+page.ts @@ -1,7 +1,7 @@ import { portfolioClient } from '$lib/api/client'; import { PortfolioEvent, PortfolioEventType } from '$lib/gen/mgo_pb'; import type { PageLoad } from './$types'; -import {Timestamp } from "@bufbuild/protobuf"; +import { Timestamp } from '@bufbuild/protobuf'; export const load = (async ({ params, parent }) => { const data = await parent(); @@ -11,7 +11,7 @@ export const load = (async ({ params, parent }) => { let transaction: PortfolioEvent; if (add) { // Construct a new time, based on "now" but reset the minutes to 0 - var time = new Date(); + const time = new Date(); time.setMinutes(0); // Create a new default import template @@ -19,7 +19,7 @@ export const load = (async ({ params, parent }) => { amount: 1, type: PortfolioEventType.BUY, portfolioName: data.portfolio.name, - time: Timestamp.fromDate(time), + time: Timestamp.fromDate(time) }); } else { const client = portfolioClient(fetch); diff --git a/ui/src/routes/securities/+page.svelte b/ui/src/routes/(app)/securities/+page.svelte similarity index 100% rename from ui/src/routes/securities/+page.svelte rename to ui/src/routes/(app)/securities/+page.svelte diff --git a/ui/src/routes/(auth)/callback/+page.ts b/ui/src/routes/(auth)/callback/+page.ts new file mode 100644 index 00000000..ad01c6f9 --- /dev/null +++ b/ui/src/routes/(auth)/callback/+page.ts @@ -0,0 +1,20 @@ +import { client, setToken } from '$lib/oauth'; +import { ErrorResponse } from 'oidc-client-ts'; +import type { PageLoad } from './$types'; +import { error, redirect } from '@sveltejs/kit'; + +export const load = (async ({ url }) => { + let res; + try { + res = await client.processSigninResponse(url.toString()); + } catch (err) { + if (err instanceof ErrorResponse) { + throw error(400, `error while fetching OAuth 2.0 response: ${err.error_description}`); + } else { + throw error(400, 'could not complete OAuth 2.0 flow'); + } + } + + setToken(res.access_token); + throw redirect(301, (res.userState as string) ?? '/'); +}) satisfies PageLoad; diff --git a/ui/src/routes/(auth)/login/+page.ts b/ui/src/routes/(auth)/login/+page.ts new file mode 100644 index 00000000..35068711 --- /dev/null +++ b/ui/src/routes/(auth)/login/+page.ts @@ -0,0 +1,6 @@ +import { redirectLogin } from '$lib/oauth'; +import type { PageLoad } from './$types'; + +export const load = (async () => { + return redirectLogin(); +}) satisfies PageLoad; diff --git a/ui/src/routes/+error.svelte b/ui/src/routes/+error.svelte index b2b13d38..8ec1b5e8 100644 --- a/ui/src/routes/+error.svelte +++ b/ui/src/routes/+error.svelte @@ -1,5 +1,12 @@ {#if $page.status == 404} diff --git a/ui/src/routes/+layout.ts b/ui/src/routes/+layout.ts index 369b30f3..a12197d7 100644 --- a/ui/src/routes/+layout.ts +++ b/ui/src/routes/+layout.ts @@ -1,14 +1,4 @@ import 'inter-ui/inter-variable.css'; import '../app.css'; -import { securitiesClient } from '$lib/api/client'; -import type { LayoutLoad } from './$types'; export const ssr = false; - -export const load = (async ({ fetch }) => { - const client = securitiesClient(fetch); - - const securities = (await client.listSecurities({})).securities; - - return { securities }; -}) satisfies LayoutLoad; diff --git a/ui/src/routes/portfolios/+page.ts b/ui/src/routes/portfolios/+page.ts deleted file mode 100644 index 7c34a813..00000000 --- a/ui/src/routes/portfolios/+page.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { PageLoad } from './$types'; -import type { ListPortfoliosResponse, Portfolio } from '$lib/gen/mgo_pb'; -import { portfolioClient } from '$lib/api/client'; - -export const load = (async ({ fetch }) => { - const client = portfolioClient(fetch); - - const portfolios = ((await client.listPortfolios({}, {})) as ListPortfoliosResponse).portfolios; - const snapshots = await Promise.all( - portfolios.map(async (p: Portfolio) => { - if (client == undefined) { - throw 'could not instantiate portfolio client'; - } - return await client.getPortfolioSnapshot({ portfolioName: p.name }); - }) - ); - - return { portfolios, snapshots }; -}) satisfies PageLoad; diff --git a/ui/src/routes/portfolios/[...portfolioName]/+layout.ts b/ui/src/routes/portfolios/[...portfolioName]/+layout.ts deleted file mode 100644 index c1a10dfa..00000000 --- a/ui/src/routes/portfolios/[...portfolioName]/+layout.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { LayoutData } from './$types'; -import { error } from '@sveltejs/kit'; -import { portfolioClient } from '$lib/api/client'; - -export const load = (async ({ fetch, params, depends }) => { - if (params.portfolioName == undefined) { - throw error(405, 'Required parameter missing'); - } - - const client = portfolioClient(fetch); - console.log(params.portfolioName); - - const portfolio = await client.getPortfolio({ name: params.portfolioName }); - const snapshot = await client.getPortfolioSnapshot({ portfolioName: params.portfolioName }); - - depends(`data:portfolio-snapshot:${params.portfolioName}`) - - return { portfolio, snapshot }; -}) as LayoutData;