package main import ( "context" "fmt" "log/slog" "net/http" "os" "time" "atlas9.dev/c/core/dbi" "atlas9.dev/c/core/iam" "atlas9.dev/c/core/outbox" "atlas9.dev/c/core/routes" "atlas9.dev/c/core/throttle" "atlas9.dev/c/core/tokens" "atlas9.dev/c/iam/scs_session" "atlas9.dev/c/mail/mail_ses" "github.com/alexedwards/scs/v2" ) func main() { err := run() if err != nil { slog.Error(err.Error()) os.Exit(1) } } func run() error { // Load config config, err := loadConfig("config.toml") if err != nil { return err } // DB connection and migration db, err := initDB(config) if err != nil { return err } defer db.Close() // Data stores users := func(tx dbi.DBI) iam.UserStore { return NewSqliteUserStore(tx) } passwords := func(tx dbi.DBI) iam.PasswordStore { return NewSqlitePasswordHashStore(tx) } profiles := func(tx dbi.DBI) *ProfileStore { return NewProfileStore(tx) } consumers := []string{"welcome_email", "tenant_invitation"} outboxEmitter := func(tx dbi.DBI) outbox.Emitter { return outbox.NewSqliteStore(tx, consumers, false) } outboxConsumer := func(tx dbi.DBI) outbox.Consumer { return outbox.NewSqliteStore(tx, consumers, false) } groups := func(tx dbi.DBI) iam.GroupStore { return NewSqliteGroupStore(tx) } members := func(tx dbi.DBI) iam.MemberStore { return NewSqliteMemberStore(tx) } roles := func(tx dbi.DBI) iam.RoleStore { return NewSqliteRoleStore(tx) } grants := func(tx dbi.DBI) iam.GrantStore { return NewSqliteGrantStore(tx) } tenants := func(tx dbi.DBI) iam.TenantStore { return NewSqliteTenantStore(tx) } tenantMembers := func(tx dbi.DBI) iam.TenantMemberStore { return NewSqliteTenantMemberStore(tx) } sessions := sessionStore(config) // Verification tokens verificationTokens := tokens.NewSecureStore[VerificationData]( tokens.NewSqliteStore[tokens.VerifiedData[VerificationData]](db, tokens.Options{ Table: "email_verification_tokens", Expiration: 24 * time.Hour, }), tokens.RandomString(32), ) // Password reset tokens resetTokens := tokens.NewSecureStore[PasswordResetData]( tokens.NewSqliteStore[tokens.VerifiedData[PasswordResetData]](db, tokens.Options{ Table: "password_reset_tokens", Expiration: 1 * time.Hour, }), tokens.RandomString(32), ) // Invitation tokens invitationTokens := tokens.NewSecureStore[InvitationData]( tokens.NewSqliteStore[tokens.VerifiedData[InvitationData]](db, tokens.Options{ Table: "invitation_tokens", Expiration: 7 * 24 * time.Hour, }), tokens.RandomString(32), ) // OAuth links for login page var oauthLinks []OAuthLink if config.OAuth.Google.ClientID != "" { oauthLinks = append(oauthLinks, OAuthLink{Name: "Google", URL: "/auth/google"}) } if config.OAuth.Apple.ClientID != "" { oauthLinks = append(oauthLinks, OAuthLink{Name: "Apple", URL: "/auth/apple"}) } if config.OAuth.GitHub.ClientID != "" { oauthLinks = append(oauthLinks, OAuthLink{Name: "GitHub", URL: "/auth/github"}) } mailer, err := mail_ses.NewSender(context.Background(), config.Mail) if err != nil { return err } identity := &IdentityApi{ DB: db, Users: users, Passwords: passwords, Profiles: profiles, Tenants: tenants, TenantMembers: tenantMembers, Outbox: outboxEmitter, Sessions: sessions, VerificationTokens: verificationTokens, ResetTokens: resetTokens, Mailer: mailer, BaseURL: config.Server.BaseURL, } groupSvc := &GroupApi{ DB: db, Groups: groups, } memberSvc := &MemberApi{ DB: db, Members: members, } roleSvc := &RoleApi{ DB: db, Roles: roles, } grantSvc := &GrantApi{ DB: db, Grants: grants, } tenantSvc := &TenantApi{ DB: db, Tenants: tenants, } tenantMemberSvc := &TenantMemberApi{ DB: db, Members: tenantMembers, } // Login throttle throttleBucket := throttle.NewMemoryBucket() loginThrottle := NewLoginThrottle(throttleBucket) // Pages pages := NewPages(identity, oauthLinks, db, invitationTokens, tenantMembers, loginThrottle) admin := &AdminAPI{ DB: db, Identity: identity, Groups: groupSvc, Roles: roleSvc, Grants: grantSvc, Tenants: tenantSvc, TenantMembers: tenantMemberSvc, TenantMemberStore: tenantMembers, Outbox: outboxEmitter, } dashboard := &DashboardAPI{ DB: db, Sessions: sessions, Identity: identity, Tenants: tenants, TenantMembers: tenantMembers, Groups: groups, Roles: roles, Grants: grants, Outbox: outboxEmitter, } // Routes mux := http.NewServeMux() routes.Register(mux, PageRoutes(pages)) routes.Register(mux, AdminRoutes(admin)) routes.Register(mux, DashboardRoutes(dashboard)) routes.Register(mux, routes.SimpleHealth()) routes.Register(mux, GroupRoutes(groupSvc)) routes.Register(mux, MemberRoutes(memberSvc)) routes.Register(mux, RoleRoutes(roleSvc)) routes.Register(mux, GrantRoutes(grantSvc)) routes.Register(mux, TenantRoutes(tenantSvc)) routes.Register(mux, TenantMemberRoutes(tenantMemberSvc)) // OIDC providers err = initOauth(context.Background(), config, db, mux, users, sessions) if err != nil { return err } // Outbox worker worker := &OutboxWorker{ DB: db, Outbox: outboxConsumer, Consumers: consumers, Handler: func(ctx context.Context, event outbox.Event, task outbox.Task) error { slog.Info("outbox event", "type", event.EventType, "consumer", task.Consumer, "payload", event.Payload) if event.EventType == "user.registered" && task.Consumer == "welcome_email" { return SendWelcomeEmail(ctx, config.Server.BaseURL, verificationTokens, mailer, event) } if event.EventType == TenantInvitationEventType && task.Consumer == "tenant_invitation" { return SendTenantInvitationEmail(ctx, config.Server.BaseURL, invitationTokens, mailer, event) } return nil }, Interval: 5 * time.Second, LeaseDuration: 30 * time.Second, MaxAttempts: 5, } go worker.Run(context.Background()) // Periodic cleanup of stale throttle buckets go func() { for { time.Sleep(30 * time.Minute) throttleBucket.Cleanup(time.Hour) } }() // Cross-origin protection (CSRF) protection := http.NewCrossOriginProtection() protection.AddInsecureBypassPattern("POST /auth/{provider}/callback") // Wrap with middleware handler := scs_session.Middleware(sessions, mux) handler = protection.Handler(handler) // Serve addr := fmt.Sprintf(":%d", config.Server.Port) slog.Info("Server starting", "addr", addr) return http.ListenAndServe(addr, handler) } func sessionStore(config *Config) *scs_session.Store { sessionManager := scs.New() sessionManager.Lifetime = time.Duration(config.Session.LifetimeHours) * time.Hour sessionManager.Cookie.Name = "atlas9_demo_session" sessionManager.Cookie.HttpOnly = true sessionManager.Cookie.Secure = true // Lax is required for OAuth flows: Strict cookies are not sent on // redirects from external identity providers (e.g. Google). sessionManager.Cookie.SameSite = http.SameSiteLaxMode return scs_session.New(sessionManager) }