diff --git a/nats-w-auth/README.md b/nats-w-auth/README.md index 5e230dc..ff63bd1 100644 --- a/nats-w-auth/README.md +++ b/nats-w-auth/README.md @@ -6,11 +6,25 @@ Explore NATS capabilities. - [x] Basic web - [ ] Setup Auth Callout w. OIDC (probably Google or GitHub) - Works by subscribing to JWT events from the auth service -- [ ] Sessions w. NATS +- [x] Sessions w. NATS +- [ ] Users workspace in KV +- [ ] Store users in NATS KV w. tokens +- [ ] Verify GitHub token - [ ] Try Datastar - [ ] Try clustering - [ ] Do we need websockets? +## Persistance + +Use NATS KV + +### Bucket for Users + +User details, roles, etc + +### Bucket for OAUTH + ### References - https://github.com/synadia-io/rethink_connectivity/blob/main/19-auth-callout +- https://github.com/alexedwards/scs diff --git a/nats-w-auth/github.go b/nats-w-auth/github.go new file mode 100644 index 0000000..9d52a45 --- /dev/null +++ b/nats-w-auth/github.go @@ -0,0 +1,109 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" +) + +// https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-a-user +type GitHubUser struct { +} + +func githubSuccessHandler(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + fmt.Println("callback from github", code) + token, err := tokenForCode(code) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + fmt.Println("access token", token) + user, err := getGithubUser(token) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + fmt.Println("user", user) + sessionManager.Put(r.Context(), "pie", "pizza") + // TODO: Find or create user according to GitHub user_id + // TODO: Set (our) user_id, user on session + + // DONE + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + +} + +func tokenForCode(code string) (string, error) { + // TODO: Validate the code + url := "https://github.com/login/oauth/access_token" + + payload := map[string]string{ + "client_id": os.Getenv("GH_CLIENT_ID"), + "client_secret": os.Getenv("GH_CLIENT_SECRET"), + "code": code, + } + body, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(body)) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + var tokenResp map[string]any + if err := json.Unmarshal(respBody, &tokenResp); err != nil { + return "", err + } + + token, ok := tokenResp["access_token"].(string) + if !ok { + return "", fmt.Errorf("no access toke in response: %v", tokenResp) + } + return token, nil + +} + +func getGithubUser(token string) (map[string]any, error) { + url := "https://api.github.com/user" + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("GitHub API responded with %d", resp.StatusCode) + } + + body, _ := io.ReadAll(resp.Body) + + var user map[string]any + if err := json.Unmarshal(body, &user); err != nil { + return nil, err + } + + return user, nil + +} diff --git a/nats-w-auth/go.mod b/nats-w-auth/go.mod index c795664..aa2e9c0 100644 --- a/nats-w-auth/go.mod +++ b/nats-w-auth/go.mod @@ -3,6 +3,9 @@ module nats-w-auth go 1.24.3 require ( + github.com/alexedwards/scs/v2 v2.9.0 // indirect + github.com/coreos/go-oidc/v3 v3.14.1 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/google/go-tpm v0.9.5 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/minio/highwayhash v1.0.3 // indirect @@ -13,6 +16,7 @@ require ( github.com/nats-io/nuid v1.0.1 // indirect go.uber.org/automaxprocs v1.6.0 // indirect golang.org/x/crypto v0.39.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/time v0.12.0 // indirect ) diff --git a/nats-w-auth/go.sum b/nats-w-auth/go.sum index 0f7a532..2f730a4 100644 --- a/nats-w-auth/go.sum +++ b/nats-w-auth/go.sum @@ -1,3 +1,9 @@ +github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= +github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= +github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= +github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +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/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -18,6 +24,8 @@ go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +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/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/nats-w-auth/http.go b/nats-w-auth/http.go index d428ec7..e2efd0b 100644 --- a/nats-w-auth/http.go +++ b/nats-w-auth/http.go @@ -2,18 +2,16 @@ package main import ( "embed" - "fmt" - "html/template" "io/fs" "log" "net/http" "os" + "path" "path/filepath" + "strings" ) -var bootstrap *template.Template - -//go:embed www +//go:embed all:www var wwwFS embed.FS var subFS fs.FS @@ -23,40 +21,43 @@ func init() { if err != nil { log.Fatalf("Failed to get sub filesystem: %v", err) } - fmt.Println("fs ready") -} - -func httpServe() { - var err error - - bootstrap, err = LoadTemplates(subFS) + templates, err = loadTemplates(subFS) if err != nil { panic(err) } - fmt.Println("templates", bootstrap.DefinedTemplates()) - fmt.Println("debug", bootstrap) +} + +func httpServe() { mux := http.NewServeMux() + mux.HandleFunc("/oauth/success", githubSuccessHandler) + mux.HandleFunc("/", fallbackHandler) - mux.HandleFunc("/", indexHandler) - http.ListenAndServe(":3000", mux) + http.ListenAndServe(":3000", sessionManager.LoadAndSave(mux)) } // 1. See if we have a static file with the same name. Exclude .gohtml and .go files. // 2. See if we have an index template matching the path. E.g. "/" = "index", "/about" = "about/index" // 3. If none of the above, 404 -func indexHandler(w http.ResponseWriter, r *http.Request) { +func fallbackHandler(w http.ResponseWriter, r *http.Request) { // Static files p := filepath.Join("./", r.URL.Path) info, err := fs.Stat(subFS, p) - if err == nil && !info.IsDir() { - // Serve file from subFS + + // Serve static files if available + // - Exclude gohtml files + if err == nil && !info.IsDir() && !strings.HasSuffix(p, ".gohtml") { http.ServeFileFS(w, r, subFS, p) - return // stop + return } - if err != nil { - log.Println("Error", err.Error()) + + // Template data + // TODO: Can we make this dynamic? + pieIs := sessionManager.GetString(r.Context(), "pie") + log.Println("debug pie", pieIs) + if pieIs == "" { + pieIs = "undefined" } tctx := map[string]any{} @@ -64,10 +65,44 @@ func indexHandler(w http.ResponseWriter, r *http.Request) { // Grab from ENV "GH_CLIENT_ID": os.Getenv("GH_CLIENT_ID"), } + tctx["Session"] = map[string]string{ + "pie": pieIs, + } + + templateName := pathToTemplateName(r.URL.Path) + if templateName == "" { + http.NotFound(w, r) + return + } - err = bootstrap.ExecuteTemplate(w, "index", tctx) + //fmt.Println("templates", templates.DefinedTemplates()) + + err = templates.ExecuteTemplate(w, templateName, tctx) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + +} + +func pathToTemplateName(p string) string { + //fmt.Println("path", p) + p, found := strings.CutPrefix(p, "/") + if !found || p == "" { + return "index" + } + + searchOrder := []string{ + p, + path.Join(p, "index"), + } + + for _, tname := range searchOrder { + tp := templates.Lookup(tname) + if tp != nil { + return tp.Name() + } } + return "" // 404 } diff --git a/nats-w-auth/internal/users/users.go b/nats-w-auth/internal/users/users.go new file mode 100644 index 0000000..985f96b --- /dev/null +++ b/nats-w-auth/internal/users/users.go @@ -0,0 +1,118 @@ +// Manages users within the system +package users + +import ( + "context" + "encoding/json" + "log" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +const USER_BUCKET = "user" +const OAUTH_BUCKET = "user_oauth" +const PROVIDER_SEPARATOR = "." + +type UserManager struct { + js jetstream.JetStream + nc *nats.Conn +} + +func New(clientURL string) *UserManager { + nc, err := nats.Connect(clientURL) + if err != nil { + log.Fatal("Failed to create nats client", err) + } + js, err := jetstream.New(nc) + if err != nil { + log.Fatal("Failed to create jetstream", err) + } + + um := &UserManager{ + js: js, + nc: nc, + } + um.setupBuckets() + + return um +} + +type UserModel struct { + ID string + Name string +} + +type OAuthModel struct { + Provider string + // UserID specific to the provider + UserID string + // Normalized email address + Email string + // Latest access token + Token string + // Refresh token if available. Used to refresh access token + RefreshToken string +} + +// Returns a user associated with the provider uid or nil +func (um *UserManager) GetOAuthUser(ctx context.Context, provider, puid string) (*UserModel, error) { + // TODO: Validate provider and puid + kv, err := um.js.KeyValue(ctx, OAUTH_BUCKET) + if err != nil { + return nil, err + } + + // Grab user_id mapping from provider, if any + val, err := kv.Get(ctx, provider+PROVIDER_SEPARATOR+puid) + if err != nil { + return nil, err + } + userID := string(val.Value()) + + user, err := um.GetUser(ctx, userID) + if err != nil { + return nil, err + } + + return user, nil +} + +func (um *UserManager) GetUser(ctx context.Context, userID string) (*UserModel, error) { + kv, err := um.js.KeyValue(ctx, USER_BUCKET) + if err != nil { + return nil, err + } + + ve, err := kv.Get(ctx, userID) + // convert value into usermodel + user := &UserModel{} + err = json.Unmarshal(ve.Value(), user) + if err != nil { + return nil, err + } + + return user, nil +} + +func (um *UserManager) setupBuckets() { + ctx := context.Background() + + _, err := um.js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ + Bucket: USER_BUCKET, + Storage: jetstream.FileStorage, + }) + + if err != nil { + log.Fatal(err) + } + + _, err = um.js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ + Bucket: OAUTH_BUCKET, + Storage: jetstream.FileStorage, + }) + + if err != nil { + log.Fatal(err) + } +} diff --git a/nats-w-auth/main.go b/nats-w-auth/main.go index daf2179..4a62dd2 100644 --- a/nats-w-auth/main.go +++ b/nats-w-auth/main.go @@ -1,6 +1,22 @@ package main +import ( + "html/template" + "nats-w-auth/internal/users" + + "github.com/alexedwards/scs/v2" + "github.com/nats-io/nats-server/v2/server" +) + +var natsd *server.Server +var templates *template.Template +var sessionManager *scs.SessionManager +var userManager *users.UserManager + func main() { setupNats() + setupSession() + userManager = users.New(natsd.ClientURL()) httpServe() + } diff --git a/nats-w-auth/nats.go b/nats-w-auth/nats.go index f72e893..afb1428 100644 --- a/nats-w-auth/nats.go +++ b/nats-w-auth/nats.go @@ -9,21 +9,22 @@ import ( ) func setupNats() { + var err error opts := server.Options{ JetStream: true, StoreDir: "./data", } - ns, err := server.NewServer(&opts) + natsd, err = server.NewServer(&opts) if err != nil { panic(err) } - go ns.Start() + go natsd.Start() - if !ns.ReadyForConnections(4 * time.Second) { + if !natsd.ReadyForConnections(4 * time.Second) { panic("not ready for connection") } - nc, err := nats.Connect(ns.ClientURL()) + nc, err := nats.Connect(natsd.ClientURL()) if err != nil { panic(err) @@ -31,6 +32,7 @@ func setupNats() { subject := "my-subject" + // TODO: Clean this up when we got this nats stuff working nc.Subscribe(subject, func(msg *nats.Msg) { data := string(msg.Data) fmt.Println("From subscription:", data) diff --git a/nats-w-auth/pkg/nats_scs/nats_scs.go b/nats-w-auth/pkg/nats_scs/nats_scs.go new file mode 100644 index 0000000..4c7226a --- /dev/null +++ b/nats-w-auth/pkg/nats_scs/nats_scs.go @@ -0,0 +1,107 @@ +// Adapted from https://gist.github.com/mrguamos/2640c2bbbb4bb4d5b73ba7d816734759 +package nats_scs + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "github.com/nats-io/nats.go/jetstream" +) + +const DEFAULT_TIMEOUT = 10 * time.Second +const BUCKET_NAME = "user_session" + +// Implements scs.Store interface +// TODO: Use StoreCtx instead: https://github.com/alexedwards/scs?tab=readme-ov-file#using-custom-session-stores-with-contextcontext +type NatsStore struct { + js jetstream.JetStream + kv jetstream.KeyValue +} + +func New(js jetstream.JetStream) *NatsStore { + parentCtx := context.Background() + + kv, err := initializeKeyValue(js, parentCtx) + if err != nil { + log.Fatalf("Failed to initialize key-value store: %v", err) + } + + return &NatsStore{ + js: js, + kv: kv, + } +} + +func initializeKeyValue(js jetstream.JetStream, parentCtx context.Context) (jetstream.KeyValue, error) { + ctx, cancel := context.WithTimeout(parentCtx, DEFAULT_TIMEOUT) + defer cancel() + + kv, err := js.KeyValue(ctx, BUCKET_NAME) + if err != nil { + if errors.Is(err, jetstream.ErrBucketNotFound) { + kv, err = js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ + Bucket: BUCKET_NAME, + Storage: jetstream.FileStorage, + }) + if err != nil { + return nil, err + } + return kv, nil + } + return nil, err + } + return kv, nil +} + +func (s *NatsStore) Delete(token string) error { + ctx, cancel := context.WithTimeout(context.Background(), DEFAULT_TIMEOUT) + defer cancel() + err := s.kv.Delete(ctx, token) + if err != nil { + return fmt.Errorf("natsstore error deleting key: %w", err) + } + return nil +} + +func (s *NatsStore) Find(token string) ([]byte, bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), DEFAULT_TIMEOUT) + defer cancel() + entry, err := s.kv.Get(ctx, token) + if err != nil { + if errors.Is(err, jetstream.ErrKeyNotFound) { + fmt.Println("key not found", token) + return nil, false, nil + } + return nil, false, fmt.Errorf("natsstore error getting key: %w", err) + } + return entry.Value(), true, nil +} + +func (s *NatsStore) Commit(token string, data []byte, expiry time.Time) error { + ctx, cancel := context.WithTimeout(context.Background(), DEFAULT_TIMEOUT) + defer cancel() + _, err := s.kv.Put(ctx, token, data) + if err != nil { + return err + } + fmt.Println("wrote", token, expiry) + return nil +} + +func (s *NatsStore) All() (map[string][]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second) + defer cancel() + kl, err := s.kv.ListKeys(ctx, jetstream.IgnoreDeletes()) + if err != nil { + return nil, fmt.Errorf("natsstore error listing keys: %w", err) + } + data := make(map[string][]byte) + for k := range kl.Keys() { + data[k], _, err = s.Find(k) + log.Println(fmt.Errorf("natsstore error getting key: %w", err)) + } + return data, nil +} diff --git a/nats-w-auth/session.go b/nats-w-auth/session.go new file mode 100644 index 0000000..2e0191b --- /dev/null +++ b/nats-w-auth/session.go @@ -0,0 +1,27 @@ +package main + +import ( + "log" + "nats-w-auth/pkg/nats_scs" + "time" + + "github.com/alexedwards/scs/v2" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +func setupSession() { + nc, err := nats.Connect(natsd.ClientURL()) + if err != nil { + log.Fatal("Failed to create nats client", err) + } + js, err := jetstream.New(nc) + if err != nil { + log.Fatal("Failed to create jetstream", err) + } + // Initialize a new session manager and configure the session lifetime. + natsStore := nats_scs.New(js) + sessionManager = scs.New() + sessionManager.Lifetime = 24 * time.Hour + sessionManager.Store = natsStore +} diff --git a/nats-w-auth/templates.go b/nats-w-auth/templates.go index f152cd2..b8e3edd 100644 --- a/nats-w-auth/templates.go +++ b/nats-w-auth/templates.go @@ -7,7 +7,8 @@ import ( "strings" ) -func LoadTemplates(fsys fs.FS) (*template.Template, error) { +// TODO: Make this recursive +func loadTemplates(fsys fs.FS) (*template.Template, error) { root := template.New("") err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { diff --git a/nats-w-auth/www/fragments/account-widget.gohtml b/nats-w-auth/www/_fragments/account-widget.gohtml similarity index 100% rename from nats-w-auth/www/fragments/account-widget.gohtml rename to nats-w-auth/www/_fragments/account-widget.gohtml diff --git a/nats-w-auth/www/_fragments/root.gohtml b/nats-w-auth/www/_fragments/root.gohtml new file mode 100644 index 0000000..a710bb6 --- /dev/null +++ b/nats-w-auth/www/_fragments/root.gohtml @@ -0,0 +1,20 @@ +{{define "root"}} + + + +
+Pie '{{ .Session.pie }}'