this repo has no description smallweb.run
smallweb
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

switch back to using oidc

pomdtr a42bdc25 181fcdf8

+142 -165
+3 -27
app/app.go
··· 9 9 "path/filepath" 10 10 "strings" 11 11 12 - "github.com/bmatcuk/doublestar/v4" 13 12 "github.com/getsops/sops/v3/decrypt" 14 13 "github.com/joho/godotenv" 15 14 "github.com/pomdtr/smallweb/utils" ··· 21 20 ) 22 21 23 22 type AppConfig struct { 24 - Entrypoint string `json:"entrypoint,omitempty"` 25 - Root string `json:"root,omitempty"` 26 - Crons []CronJob `json:"crons,omitempty"` 27 - Private bool `json:"private,omitempty"` 28 - PrivateRoutes []string `json:"privateRoutes,omitempty"` 29 - PublicRoutes []string `json:"publicRoutes,omitempty"` 23 + Entrypoint string `json:"entrypoint,omitempty"` 24 + Root string `json:"root,omitempty"` 25 + Crons []CronJob `json:"crons,omitempty"` 30 26 } 31 27 32 28 type CronJob struct { ··· 224 220 225 221 return "jsr:@smallweb/file-server@0.8.2" 226 222 } 227 - 228 - func (me App) IsRoutePrivate(route string) bool { 229 - routeIsPrivate := me.Config.Private 230 - for _, publicRoute := range me.Config.PublicRoutes { 231 - if isMatch, _ := doublestar.Match(publicRoute, route); isMatch { 232 - routeIsPrivate = false 233 - break 234 - } 235 - 236 - } 237 - 238 - for _, privateRoute := range me.Config.PrivateRoutes { 239 - if isMatch, _ := doublestar.Match(privateRoute, route); isMatch { 240 - routeIsPrivate = true 241 - break 242 - } 243 - } 244 - 245 - return routeIsPrivate 246 - }
+133 -129
cmd/up.go
··· 11 11 "io" 12 12 "net" 13 13 "net/http" 14 - "net/url" 15 14 "os" 16 15 "os/exec" 17 16 "os/signal" ··· 22 21 23 22 _ "embed" 24 23 25 - "github.com/MicahParks/keyfunc/v3" 24 + "github.com/bmatcuk/doublestar/v4" 26 25 "github.com/caddyserver/certmagic" 27 26 "github.com/charmbracelet/ssh" 28 27 "github.com/charmbracelet/wish" 28 + "github.com/coreos/go-oidc/v3/oidc" 29 29 "github.com/creack/pty" 30 - "github.com/golang-jwt/jwt/v5" 31 30 "github.com/knadh/koanf/providers/file" 32 31 33 32 "github.com/knadh/koanf/providers/posflag" ··· 79 78 80 79 handler := &Handler{ 81 80 workers: make(map[string]*worker.Worker), 82 - issuer: k.String("openauth.issuer"), 81 + } 82 + 83 + if issuer := k.String("oidc.issuer"); issuer != "" { 84 + provider, err := oidc.NewProvider(context.Background(), issuer) 85 + if err != nil { 86 + return fmt.Errorf("failed to get provider: %v", err) 87 + } 88 + 89 + handler.oidcProvider = provider 83 90 } 84 91 85 92 watcher, err := watcher.NewWatcher(k.String("dir"), func() { ··· 91 98 _ = k.Load(envProvider, nil) 92 99 _ = k.Load(flagProvider, nil) 93 100 94 - if issuer := k.String("openauth.issuer"); issuer != handler.issuer { 95 - handler.issuer = issuer 96 - handler.issuerConfig = nil 97 - handler.keyfunc = nil 101 + if issuer := k.String("oidc.issuer"); issuer != "" { 102 + provider, err := oidc.NewProvider(context.Background(), issuer) 103 + if err != nil { 104 + fmt.Fprintf(cmd.ErrOrStderr(), "failed to get provider: %v\n", err) 105 + return 106 + } 107 + 108 + handler.oidcProvider = provider 98 109 } 99 110 }) 100 111 if err != nil { ··· 426 437 watcher *watcher.Watcher 427 438 mu sync.Mutex 428 439 workers map[string]*worker.Worker 429 - issuer string 430 - issuerConfig *IssuerConfig 431 - keyfunc keyfunc.Keyfunc 440 + oidcProvider *oidc.Provider 432 441 } 433 442 434 443 type AuthData struct { ··· 443 452 JwksUri string `json:"jwks_uri"` 444 453 } 445 454 446 - func getIssuerConfig(issuer string) (*IssuerConfig, error) { 447 - configUrl, err := url.JoinPath(issuer, ".well-known/oauth-authorization-server") 448 - if err != nil { 449 - return nil, fmt.Errorf("failed to get config url: %v", err) 450 - } 451 - 452 - resp, err := http.Get(configUrl) 453 - if err != nil { 454 - return nil, fmt.Errorf("failed to get config: %v", err) 455 - } 456 - defer resp.Body.Close() 457 - 458 - if resp.StatusCode != http.StatusOK { 459 - return nil, fmt.Errorf("failed to get config: %v", resp.Status) 460 - } 461 - 462 - var issuerConfig IssuerConfig 463 - if err := json.NewDecoder(resp.Body).Decode(&issuerConfig); err != nil { 464 - return nil, fmt.Errorf("failed to decode config: %v", err) 465 - } 466 - 467 - return &issuerConfig, nil 468 - } 469 - 470 455 func (me *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 471 456 hostname, _, err := net.SplitHostPort(r.Host) 472 457 if err != nil { ··· 492 477 return 493 478 } 494 479 495 - a, err := app.LoadApp(appname, k.String("dir"), k.String("domain"), k.Bool(fmt.Sprintf("apps.%s.admin", appname))) 496 - if err != nil { 497 - w.WriteHeader(http.StatusInternalServerError) 498 - w.Write([]byte(fmt.Sprintf("failed to load app: %v", err))) 499 - return 500 - } 501 - 502 - if a.IsRoutePrivate(r.URL.Path) { 503 - if me.issuer == "" { 504 - http.Error(w, "openauth issuer not set", http.StatusInternalServerError) 505 - return 480 + isRouteProtected := func(route string) bool { 481 + routeisProtected := k.Bool(fmt.Sprintf("apps.%s.private", appname)) 482 + for _, publicRoute := range k.Strings(fmt.Sprintf("apps.%s.publicRoutes", appname)) { 483 + if isMatch, _ := doublestar.Match(publicRoute, route); isMatch { 484 + routeisProtected = false 485 + break 486 + } 506 487 } 507 488 508 - if me.issuerConfig == nil { 509 - issuerConfig, err := getIssuerConfig(me.issuer) 510 - if err != nil { 511 - http.Error(w, fmt.Sprintf("failed to get issuer config: %v", err), http.StatusInternalServerError) 512 - return 489 + for _, privateRoute := range k.Strings(fmt.Sprintf("apps.%s.privateRoutes", appname)) { 490 + if isMatch, _ := doublestar.Match(privateRoute, route); isMatch { 491 + routeisProtected = true 492 + break 513 493 } 494 + } 514 495 515 - me.issuerConfig = issuerConfig 496 + return routeisProtected 497 + } 516 498 517 - kf, err := keyfunc.NewDefault([]string{issuerConfig.JwksUri}) 518 - if err != nil { 519 - http.Error(w, fmt.Sprintf("failed to create keyfunc: %v", err), http.StatusInternalServerError) 520 - return 521 - } 499 + if me.oidcProvider != nil { 500 + r.Header.Del("Remote-Email") 501 + r.Header.Del("Remote-User") 502 + r.Header.Del("Remote-Name") 503 + r.Header.Del("Remote-Groups") 504 + } 522 505 523 - me.keyfunc = kf 506 + if isRouteProtected(r.URL.Path) { 507 + if me.oidcProvider == nil { 508 + http.Error(w, "openauth issuer not set", http.StatusInternalServerError) 509 + return 524 510 } 525 511 526 512 clientID := fmt.Sprintf("https://%s", r.Host) 527 513 oauth2Config := &oauth2.Config{ 528 514 ClientID: clientID, 529 - Scopes: []string{"email"}, 515 + Scopes: []string{"openid", "email", "profile", "groups"}, 530 516 RedirectURL: fmt.Sprintf("https://%s/_smallweb/oauth/callback", r.Host), 531 - Endpoint: oauth2.Endpoint{ 532 - AuthURL: me.issuerConfig.AuthorizationEndpoint, 533 - TokenURL: me.issuerConfig.TokenEndpoint, 534 - AuthStyle: oauth2.AuthStyleInParams, 535 - }, 517 + Endpoint: me.oidcProvider.Endpoint(), 536 518 } 537 519 538 520 if r.URL.Path == "/_smallweb/signin" { ··· 576 558 577 559 if r.URL.Path == "/_smallweb/signout" { 578 560 http.SetCookie(w, &http.Cookie{ 579 - Name: "access_token", 561 + Name: "id_token", 580 562 Secure: true, 581 563 HttpOnly: true, 582 - Path: "/", 583 564 MaxAge: -1, 584 565 }) 585 566 ··· 642 623 return 643 624 } 644 625 645 - http.SetCookie(w, &http.Cookie{ 646 - Name: "access_token", 647 - Value: oauth2Token.AccessToken, 648 - SameSite: http.SameSiteLaxMode, 649 - Secure: true, 650 - HttpOnly: true, 651 - Path: "/", 652 - MaxAge: 34560000, 653 - }) 626 + idToken := oauth2Token.Extra("id_token").(string) 627 + if idToken == "" { 628 + http.Error(w, "id token not found", http.StatusInternalServerError) 629 + return 630 + } 654 631 655 632 http.SetCookie(w, &http.Cookie{ 656 - Name: "refresh_token", 657 - Value: oauth2Token.RefreshToken, 633 + Name: "id_token", 634 + Value: idToken, 658 635 SameSite: http.SameSiteLaxMode, 659 636 Secure: true, 660 637 HttpOnly: true, ··· 662 639 MaxAge: 34560000, 663 640 }) 664 641 642 + if oauth2Token.RefreshToken != "" { 643 + http.SetCookie(w, &http.Cookie{ 644 + Name: "refresh_token", 645 + Value: oauth2Token.RefreshToken, 646 + SameSite: http.SameSiteLaxMode, 647 + Secure: true, 648 + HttpOnly: true, 649 + Path: "/", 650 + MaxAge: 34560000, 651 + }) 652 + } 653 + 665 654 http.Redirect(w, r, authData.SuccessURL, http.StatusTemporaryRedirect) 666 655 return 667 656 } 668 657 669 - accessTokenCookie, err := r.Cookie("access_token") 658 + idTokenCookie, err := r.Cookie("id_token") 670 659 if err != nil { 671 660 http.Redirect(w, r, fmt.Sprintf("https://%s/_smallweb/signin?success_url=%s", r.Host, r.URL.Path), http.StatusTemporaryRedirect) 672 661 return 673 662 } 674 - accessToken := accessTokenCookie.Value 675 663 676 - var claims struct { 677 - Properties struct { 678 - Email string `json:"email"` 679 - } `json:"properties"` 680 - jwt.RegisteredClaims 681 - } 664 + verifier := me.oidcProvider.Verifier(&oidc.Config{ClientID: clientID}) 665 + idToken, err := verifier.Verify(r.Context(), idTokenCookie.Value) 666 + if err != nil { 667 + var expiredErr *oidc.TokenExpiredError 668 + if errors.As(err, &expiredErr) { 669 + refreshTokenCookie, err := r.Cookie("refresh_token") 670 + if err != nil { 671 + http.Redirect(w, r, fmt.Sprintf("https://%s/_smallweb/signin?success_url=%s", r.Host, r.URL.Path), http.StatusTemporaryRedirect) 672 + return 673 + } 682 674 683 - token, err := jwt.ParseWithClaims(accessToken, &claims, me.keyfunc.Keyfunc, jwt.WithAudience(clientID)) 684 - if err != nil && errors.Is(err, jwt.ErrTokenExpired) { 685 - refreshTokenCookie, err := r.Cookie("refresh_token") 686 - if err != nil { 687 - http.Redirect(w, r, fmt.Sprintf("https://%s/_smallweb/signin?success_url=%s", r.Host, r.URL.Path), http.StatusTemporaryRedirect) 688 - return 689 - } 675 + tokenSource := oauth2Config.TokenSource(context.Background(), &oauth2.Token{RefreshToken: refreshTokenCookie.Value}) 676 + oauth2Token, err := tokenSource.Token() 677 + if err != nil { 678 + http.Redirect(w, r, fmt.Sprintf("https://%s/_smallweb/signin?success_url=%s", r.Host, r.URL.Path), http.StatusTemporaryRedirect) 679 + return 680 + } 690 681 691 - refreshToken := refreshTokenCookie.Value 692 - tokenSource := oauth2Config.TokenSource(context.Background(), &oauth2.Token{RefreshToken: refreshToken}) 682 + idToken, ok := oauth2Token.Extra("id_token").(string) 683 + if !ok { 684 + http.Redirect(w, r, fmt.Sprintf("https://%s/_smallweb/signin?success_url=%s", r.Host, r.URL.Path), http.StatusTemporaryRedirect) 685 + return 686 + } 693 687 694 - oauth2Token, err := tokenSource.Token() 695 - if err != nil { 696 - http.Redirect(w, r, fmt.Sprintf("https://%s/_smallweb/signin?success_url=%s", r.Host, r.URL.Path), http.StatusTemporaryRedirect) 697 - return 698 - } 688 + http.SetCookie(w, &http.Cookie{ 689 + Name: "id_token", 690 + Value: idToken, 691 + SameSite: http.SameSiteLaxMode, 692 + Secure: true, 693 + HttpOnly: true, 694 + Path: "/", 695 + MaxAge: 34560000, 696 + }) 699 697 700 - http.SetCookie(w, &http.Cookie{ 701 - Name: "access_token", 702 - Value: oauth2Token.AccessToken, 703 - SameSite: http.SameSiteLaxMode, 704 - Secure: true, 705 - HttpOnly: true, 706 - Path: "/", 707 - MaxAge: 34560000, 708 - }) 698 + if oauth2Token.RefreshToken != "" { 699 + http.SetCookie(w, &http.Cookie{ 700 + Name: "refresh_token", 701 + Value: oauth2Token.RefreshToken, 702 + SameSite: http.SameSiteLaxMode, 703 + Secure: true, 704 + HttpOnly: true, 705 + Path: "/", 706 + MaxAge: 34560000, 707 + }) 708 + } 709 709 710 - http.SetCookie(w, &http.Cookie{ 711 - Name: "refresh_token", 712 - Value: oauth2Token.RefreshToken, 713 - SameSite: http.SameSiteLaxMode, 714 - Secure: true, 715 - HttpOnly: true, 716 - Path: "/", 717 - MaxAge: 34560000, 718 - }) 719 - 720 - token, err = jwt.ParseWithClaims(oauth2Token.AccessToken, &claims, me.keyfunc.Keyfunc, jwt.WithAudience(clientID)) 721 - if err != nil { 710 + } else { 722 711 http.Redirect(w, r, fmt.Sprintf("https://%s/_smallweb/signin?success_url=%s", r.Host, r.URL.Path), http.StatusTemporaryRedirect) 723 712 return 724 713 } 725 - } else if err != nil { 726 - http.Error(w, fmt.Sprintf("failed to parse token: %v", err), http.StatusInternalServerError) 727 - return 714 + } 715 + 716 + // Extract custom claims 717 + var claims struct { 718 + Email string `json:"email"` 719 + Subject string `json:"sub"` 720 + Name string `json:"name"` 721 + Groups []string `json:"groups"` 728 722 } 729 723 730 - if !token.Valid { 731 - http.Redirect(w, r, fmt.Sprintf("https://%s/_smallweb/signin?success_url=%s", r.Host, r.URL.Path), http.StatusTemporaryRedirect) 724 + if err := idToken.Claims(&claims); err != nil { 725 + http.Error(w, fmt.Sprintf("failed to get claims: %v", err), http.StatusInternalServerError) 732 726 return 733 727 } 734 728 735 729 var authorizedEmails []string 736 730 authorizedEmails = append(authorizedEmails, k.Strings("authorizedEmails")...) 737 731 authorizedEmails = append(authorizedEmails, k.Strings(fmt.Sprintf("apps.%s.authorizedEmails", appname))...) 738 - if len(authorizedEmails) > 0 && !slices.Contains(authorizedEmails, claims.Properties.Email) { 732 + if len(authorizedEmails) > 0 && !slices.Contains(authorizedEmails, claims.Email) { 739 733 http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) 740 734 return 741 735 } 736 + 737 + r.Header.Set("Remote-Email", claims.Email) 738 + r.Header.Set("Remote-User", claims.Subject) 739 + r.Header.Set("Remote-Name", claims.Name) 740 + r.Header.Set("Remote-Groups", strings.Join(claims.Groups, ",")) 742 741 } 743 742 744 - wk, err := me.GetWorker(a, k.String("dir"), k.String("domain")) 743 + wk, err := me.GetWorker(appname, k.String("dir"), k.String("domain")) 745 744 if err != nil { 746 745 if errors.Is(err, app.ErrAppNotFound) { 747 746 w.WriteHeader(http.StatusNotFound) ··· 785 784 return "", false, false 786 785 } 787 786 788 - func (me *Handler) GetWorker(a app.App, rootDir, domain string) (*worker.Worker, error) { 789 - if wk, ok := me.workers[a.Name]; ok && wk.IsRunning() && me.watcher.GetAppMtime(a.Name).Before(wk.StartedAt) { 787 + func (me *Handler) GetWorker(appname string, rootDir, domain string) (*worker.Worker, error) { 788 + if wk, ok := me.workers[appname]; ok && wk.IsRunning() && me.watcher.GetAppMtime(appname).Before(wk.StartedAt) { 790 789 return wk, nil 791 790 } 792 791 793 792 me.mu.Lock() 794 793 defer me.mu.Unlock() 794 + 795 + a, err := app.LoadApp(appname, k.String("dir"), k.String("domain"), k.Bool(fmt.Sprintf("apps.%s.admin", appname))) 796 + if err != nil { 797 + return nil, fmt.Errorf("failed to load app: %w", err) 798 + } 795 799 796 800 wk := worker.NewWorker(a) 797 801
+2 -3
go.mod
··· 19 19 ) 20 20 21 21 require ( 22 - github.com/MicahParks/keyfunc/v3 v3.3.10 23 22 github.com/bmatcuk/doublestar/v4 v4.8.1 24 23 github.com/caddyserver/certmagic v0.22.0 25 24 github.com/charmbracelet/log v0.4.1 26 25 github.com/charmbracelet/ssh v0.0.0-20250213143314-8712ec3ff3ef 27 26 github.com/charmbracelet/wish v1.4.6 28 27 github.com/cli/browser v1.3.0 28 + github.com/coreos/go-oidc/v3 v3.13.0 29 29 github.com/creack/pty v1.1.24 30 30 github.com/fsnotify/fsnotify v1.8.0 31 31 github.com/getsops/sops/v3 v3.9.4 32 - github.com/golang-jwt/jwt/v5 v5.2.2 33 32 github.com/knadh/koanf/providers/posflag v0.1.0 34 33 github.com/leaanthony/gosod v1.0.4 35 34 github.com/pkg/sftp v1.13.8 ··· 59 58 github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect 60 59 github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect 61 60 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect 62 - github.com/MicahParks/jwkset v0.8.0 // indirect 63 61 github.com/ProtonMail/go-crypto v1.1.6 // indirect 64 62 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 65 63 github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect ··· 110 108 github.com/go-logr/logr v1.4.2 // indirect 111 109 github.com/go-logr/stdr v1.2.2 // indirect 112 110 github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 111 + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 113 112 github.com/google/go-cmp v0.7.0 // indirect 114 113 github.com/google/s2a-go v0.1.9 // indirect 115 114 github.com/google/uuid v1.6.0 // indirect
+2 -4
go.sum
··· 59 59 github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 60 60 github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= 61 61 github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= 62 - github.com/MicahParks/jwkset v0.8.0 h1:jHtclI38Gibmu17XMI6+6/UB59srp58pQVxePHRK5o8= 63 - github.com/MicahParks/jwkset v0.8.0/go.mod h1:fVrj6TmG1aKlJEeceAz7JsXGTXEn72zP1px3us53JrA= 64 - github.com/MicahParks/keyfunc/v3 v3.3.10 h1:JtEGE8OcNeI297AMrR4gVXivV8fyAawFUMkbwNreJRk= 65 - github.com/MicahParks/keyfunc/v3 v3.3.10/go.mod h1:1TEt+Q3FO7Yz2zWeYO//fMxZMOiar808NqjWQQpBPtU= 66 62 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 67 63 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 68 64 github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= ··· 159 155 github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= 160 156 github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= 161 157 github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= 158 + github.com/coreos/go-oidc/v3 v3.13.0 h1:M66zd0pcc5VxvBNM4pB331Wrsanby+QomQYjN8HamW8= 159 + github.com/coreos/go-oidc/v3 v3.13.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= 162 160 github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 163 161 github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= 164 162 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+1 -1
schemas/config.schema.json
··· 2 2 "$schema": "http://json-schema.org/draft-07/schema#", 3 3 "type": "object", 4 4 "properties": { 5 - "openauth": { 5 + "oidc": { 6 6 "type": "object", 7 7 "description": "OpenID Connect configuration", 8 8 "required": [
+1 -1
workspace/.smallweb/config.json
··· 1 1 { 2 2 "$schema": "../../schemas/config.schema.json", 3 3 "domain": "smallweb.localhost", 4 - "openauth.issuer": "https://auth.smallweb.localhost", 4 + "oidc.issuer": "https://lastlogin.net", 5 5 "apps": { 6 6 "ls": { 7 7 "admin": true,