feat(sdk,client): add menu to help navigation between apps
All checks were successful
arcad/edge/pipeline/head This commit looks good

This commit is contained in:
2023-04-18 17:57:16 +02:00
parent 9e3fc427bb
commit b5b4042cc7
59 changed files with 22276 additions and 486 deletions

View File

@ -44,6 +44,10 @@ func (r *Repository) List(ctx context.Context) ([]*app.Manifest, error) {
}
func NewRepository(getURL GetURLFunc, manifests ...*app.Manifest) *Repository {
if manifests == nil {
manifests = make([]*app.Manifest, 0)
}
return &Repository{getURL, manifests}
}

112
pkg/module/app/mount.go Normal file
View File

@ -0,0 +1,112 @@
package app
import (
"net"
"net/http"
"forge.cadoles.com/arcad/edge/pkg/app"
"github.com/go-chi/chi/v5"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type MountFunc func(r chi.Router)
type Handler struct {
repo Repository
}
func (h *Handler) serveApps(w http.ResponseWriter, r *http.Request) {
manifests, err := h.repo.List(r.Context())
if err != nil {
logger.Error(r.Context(), "could not retrieve app manifest", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Manifests []*app.Manifest `json:"manifests"`
}{
Manifests: manifests,
})
}
func (h *Handler) serveApp(w http.ResponseWriter, r *http.Request) {
appID := app.ID(chi.URLParam(r, "appID"))
manifest, err := h.repo.Get(r.Context(), appID)
if err != nil {
if errors.Is(err, ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
return
}
logger.Error(r.Context(), "could not retrieve app manifest", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Manifest *app.Manifest `json:"manifest"`
}{
Manifest: manifest,
})
}
type serveAppURLRequest struct {
From string `json:"from,omitempty"`
}
func (h *Handler) serveAppURL(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
req := &serveAppURLRequest{}
if ok := api.Bind(w, r, req); !ok {
return
}
appID := app.ID(chi.URLParam(r, "appID"))
from := req.From
if from == "" {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
logger.Warn(ctx, "could not split remote address", logger.E(errors.WithStack(err)))
} else {
from = host
}
}
url, err := h.repo.GetURL(ctx, appID, from)
if err != nil {
if errors.Is(err, ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
return
}
logger.Error(r.Context(), "could not retrieve app url", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
URL string `json:"url"`
}{
URL: url,
})
}
func Mount(repository Repository) MountFunc {
handler := &Handler{repository}
return func(r chi.Router) {
r.Get("/api/v1/apps", handler.serveApps)
r.Get("/api/v1/apps/{appID}", handler.serveApp)
r.Post("/api/v1/apps/{appID}/url", handler.serveAppURL)
}
}

View File

@ -9,5 +9,5 @@ import (
type Repository interface {
List(context.Context) ([]*app.Manifest, error)
Get(context.Context, app.ID) (*app.Manifest, error)
GetURL(context.Context, app.ID, string) (string, error)
GetURL(ctx context.Context, id app.ID, from string) (string, error)
}

View File

@ -2,7 +2,4 @@ package auth
import "errors"
var (
ErrUnauthenticated = errors.New("unauthenticated")
ErrClaimNotFound = errors.New("claim not found")
)
var ErrUnauthenticated = errors.New("unauthenticated")

View File

@ -110,6 +110,8 @@ func (h *LocalHandler) handleForm(w http.ResponseWriter, r *http.Request) {
return
}
account.Claims[auth.ClaimIssuer] = "local"
token, err := generateSignedToken(h.algo, h.key, account.Claims)
if err != nil {
logger.Error(ctx, "could not generate signed token", logger.E(errors.WithStack(err)))

View File

@ -91,7 +91,7 @@
<form method="post" action="{{ .URL }}">
<div class="form-control">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{ .Username }}" required />
<input type="text" id="username" name="username" value="{{ .Username }}" required autofocus />
</div>
<div class="form-control">
<label for="password">Password</label>

View File

@ -19,10 +19,10 @@ type GetKeySetFunc func() (jwk.Set, error)
func WithJWT(getKeySet GetKeySetFunc) OptionFunc {
return func(o *Option) {
o.GetClaim = func(ctx context.Context, r *http.Request, claimName string) (string, error) {
claim, err := getClaim[string](r, claimName, getKeySet)
o.GetClaims = func(ctx context.Context, r *http.Request, names ...string) ([]string, error) {
claim, err := getClaims[string](r, getKeySet, names...)
if err != nil {
return "", errors.WithStack(err)
return nil, errors.WithStack(err)
}
return claim, nil
@ -76,28 +76,34 @@ func FindToken(r *http.Request, getKeySet GetKeySetFunc) (jwt.Token, error) {
return token, nil
}
func getClaim[T any](r *http.Request, claimAttr string, getKeySet GetKeySetFunc) (T, error) {
func getClaims[T any](r *http.Request, getKeySet GetKeySetFunc, names ...string) ([]T, error) {
token, err := FindToken(r, getKeySet)
if err != nil {
return *new(T), errors.WithStack(err)
return nil, errors.WithStack(err)
}
ctx := r.Context()
mapClaims, err := token.AsMap(ctx)
if err != nil {
return *new(T), errors.WithStack(err)
return nil, errors.WithStack(err)
}
rawClaim, exists := mapClaims[claimAttr]
if !exists {
return *new(T), errors.WithStack(ErrClaimNotFound)
claims := make([]T, len(names))
for idx, n := range names {
rawClaim, exists := mapClaims[n]
if !exists {
continue
}
claim, ok := rawClaim.(T)
if !ok {
return nil, errors.Errorf("unexpected claim '%s' to be of type '%T', got '%T'", n, new(T), rawClaim)
}
claims[idx] = claim
}
claim, ok := rawClaim.(T)
if !ok {
return *new(T), errors.Errorf("unexpected claim '%s' to be of type '%T', got '%T'", claimAttr, new(T), rawClaim)
}
return claim, nil
return claims, nil
}

View File

@ -8,15 +8,21 @@ import (
"forge.cadoles.com/arcad/edge/pkg/module/util"
"github.com/dop251/goja"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
const (
ClaimSubject = "sub"
ClaimSubject = "sub"
ClaimIssuer = "iss"
ClaimPreferredUsername = "preferred_username"
ClaimEdgeRole = "edge_role"
ClaimEdgeTenant = "edge_tenant"
ClaimEdgeEntrypoint = "edge_entrypoint"
)
type Module struct {
server *app.Server
getClaimFunc GetClaimFunc
server *app.Server
getClaims GetClaimsFunc
}
func (m *Module) Name() string {
@ -31,6 +37,22 @@ func (m *Module) Export(export *goja.Object) {
if err := export.Set("CLAIM_SUBJECT", ClaimSubject); err != nil {
panic(errors.Wrap(err, "could not set 'CLAIM_SUBJECT' property"))
}
if err := export.Set("CLAIM_TENANT", ClaimEdgeTenant); err != nil {
panic(errors.Wrap(err, "could not set 'CLAIM_TENANT' property"))
}
if err := export.Set("CLAIM_ENTRYPOINT", ClaimEdgeEntrypoint); err != nil {
panic(errors.Wrap(err, "could not set 'CLAIM_ENTRYPOINT' property"))
}
if err := export.Set("CLAIM_ROLE", ClaimEdgeRole); err != nil {
panic(errors.Wrap(err, "could not set 'CLAIM_ROLE' property"))
}
if err := export.Set("CLAIM_PREFERRED_USERNAME", ClaimPreferredUsername); err != nil {
panic(errors.Wrap(err, "could not set 'CLAIM_PREFERRED_USERNAME' property"))
}
}
func (m *Module) getClaim(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
@ -42,16 +64,21 @@ func (m *Module) getClaim(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
panic(rt.ToValue(errors.New("could not find http request in context")))
}
claim, err := m.getClaimFunc(ctx, req, claimName)
claim, err := m.getClaims(ctx, req, claimName)
if err != nil {
if errors.Is(err, ErrUnauthenticated) || errors.Is(err, ErrClaimNotFound) {
if errors.Is(err, ErrUnauthenticated) {
return nil
}
panic(rt.ToValue(errors.WithStack(err)))
logger.Error(ctx, "could not retrieve claim", logger.E(errors.WithStack(err)))
return nil
}
return rt.ToValue(claim)
if len(claim) == 0 || claim[0] == "" {
return nil
}
return rt.ToValue(claim[0])
}
func ModuleFactory(funcs ...OptionFunc) app.ServerModuleFactory {
@ -62,8 +89,8 @@ func ModuleFactory(funcs ...OptionFunc) app.ServerModuleFactory {
return func(server *app.Server) app.ServerModule {
return &Module{
server: server,
getClaimFunc: opt.GetClaim,
server: server,
getClaims: opt.GetClaims,
}
}
}

72
pkg/module/auth/mount.go Normal file
View File

@ -0,0 +1,72 @@
package auth
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type MountFunc func(r chi.Router)
type Handler struct {
getClaims GetClaimsFunc
profileClaims []string
}
func (h *Handler) serveProfile(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := h.getClaims(ctx, r, h.profileClaims...)
if err != nil {
if errors.Is(err, ErrUnauthenticated) {
api.ErrorResponse(
w, http.StatusUnauthorized,
api.ErrCodeUnauthorized,
nil,
)
return
}
logger.Error(ctx, "could not retrieve claims", logger.E(errors.WithStack(err)))
api.ErrorResponse(
w, http.StatusInternalServerError,
api.ErrCodeUnknownError,
nil,
)
return
}
profile := make(map[string]any)
for idx, cl := range h.profileClaims {
profile[cl] = claims[idx]
}
api.DataResponse(w, http.StatusOK, struct {
Profile map[string]any `json:"profile"`
}{
Profile: profile,
})
}
func Mount(authHandler http.Handler, funcs ...OptionFunc) MountFunc {
opt := defaultOptions()
for _, fn := range funcs {
fn(opt)
}
handler := &Handler{
profileClaims: opt.ProfileClaims,
getClaims: opt.GetClaims,
}
return func(r chi.Router) {
r.Get("/api/v1/profile", handler.serveProfile)
r.Handle("/auth/*", authHandler)
}
}

View File

@ -7,26 +7,41 @@ import (
"github.com/pkg/errors"
)
type GetClaimFunc func(ctx context.Context, r *http.Request, claimName string) (string, error)
type GetClaimsFunc func(ctx context.Context, r *http.Request, claims ...string) ([]string, error)
type Option struct {
GetClaim GetClaimFunc
GetClaims GetClaimsFunc
ProfileClaims []string
}
type OptionFunc func(*Option)
func defaultOptions() *Option {
return &Option{
GetClaim: dummyGetClaim,
GetClaims: dummyGetClaims,
ProfileClaims: []string{
ClaimSubject,
ClaimIssuer,
ClaimEdgeEntrypoint,
ClaimEdgeRole,
ClaimPreferredUsername,
ClaimEdgeTenant,
},
}
}
func dummyGetClaim(ctx context.Context, r *http.Request, claimName string) (string, error) {
return "", errors.Errorf("dummy getclaim func cannot retrieve claim '%s'", claimName)
func dummyGetClaims(ctx context.Context, r *http.Request, claims ...string) ([]string, error) {
return nil, errors.Errorf("dummy getclaim func cannot retrieve claims '%s'", claims)
}
func WithGetClaim(fn GetClaimFunc) OptionFunc {
func WithGetClaims(fn GetClaimsFunc) OptionFunc {
return func(o *Option) {
o.GetClaim = fn
o.GetClaims = fn
}
}
func WithProfileClaims(claims ...string) OptionFunc {
return func(o *Option) {
o.ProfileClaims = claims
}
}