package main import ( "context" "crypto/rand" "crypto/rsa" "encoding/base64" "errors" "fmt" "io/fs" "log/slog" "os" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/ory/fosite" goredis "github.com/redis/go-redis/v9" "golang.org/x/crypto/argon2" "github.com/anthropic/oidc-platform/internal/adapter/memcache" "github.com/anthropic/oidc-platform/internal/adapter/postgres" "github.com/anthropic/oidc-platform/internal/adapter/redis" "github.com/anthropic/oidc-platform/internal/adapter/smtp" "github.com/anthropic/oidc-platform/internal/adapter/social" sqliteAdapter "github.com/anthropic/oidc-platform/internal/adapter/sqlite" "github.com/anthropic/oidc-platform/internal/config" "github.com/anthropic/oidc-platform/internal/domain" "github.com/anthropic/oidc-platform/internal/handler" "github.com/anthropic/oidc-platform/internal/oidcprovider" "github.com/anthropic/oidc-platform/internal/port" "github.com/anthropic/oidc-platform/internal/router" "github.com/anthropic/oidc-platform/internal/service" ) // bootstrap initializes all infrastructure and returns assembled router deps and a cleanup func. func bootstrap(ctx context.Context, cfg *config.Config) (router.Deps, func(), error) { cleanup := func() {} var ( userRepo port.UserRepository bindingRepo port.BindingRepository clientRepo port.ClientRepository accessRuleRepo port.ClientAccessRuleRepository ruleRepo port.RuleRepository sessionRepo port.SessionRepository providerCfgRepo port.ProviderConfigRepository auditRepo port.AuditRepository settingsRepo port.SettingsRepository aliasRepo port.AliasRestrictionRepository signingKeyRepo port.SigningKeyRepository riskReportRepo port.RiskReportRepository riskListRepo port.RiskListRepository consentRepo port.ConsentRepository passkeyRepo port.PasskeyRepository announcementRepo port.AnnouncementRepository cache port.Cache fositeStore fosite.Storage ) secretCipher, err := service.NewSecretCipher(cfg.Secrets.ClientSecretEncryptionKey) if err != nil { return router.Deps{}, cleanup, fmt.Errorf("client secret cipher: %w", err) } // Variables for health handler (nil when using sqlite). var pgPool *pgxpool.Pool var redisClient *goredis.Client switch cfg.Database.Driver { case "sqlite": sqliteDB, err := sqliteAdapter.NewDB(ctx, cfg.Database.DSN) if err != nil { return router.Deps{}, cleanup, fmt.Errorf("sqlite: %w", err) } cleanup = chainCleanup(cleanup, func() { sqliteDB.Close() }) if err := sqliteAdapter.RunMigrations(sqliteDB); err != nil { return router.Deps{}, cleanup, fmt.Errorf("sqlite migrations: %w", err) } userRepo = sqliteAdapter.NewUserRepo(sqliteDB) bindingRepo = sqliteAdapter.NewBindingRepo(sqliteDB) clientRepo = sqliteAdapter.NewClientRepo(sqliteDB) accessRuleRepo = sqliteAdapter.NewClientAccessRuleRepo(sqliteDB) ruleRepo = sqliteAdapter.NewRuleRepo(sqliteDB) sessionRepo = sqliteAdapter.NewSessionRepo(sqliteDB) providerCfgRepo = sqliteAdapter.NewProviderConfigRepo(sqliteDB) auditRepo = sqliteAdapter.NewAuditRepo(sqliteDB) settingsRepo = sqliteAdapter.NewSettingsRepo(sqliteDB) aliasRepo = sqliteAdapter.NewAliasRestrictionRepo(sqliteDB) signingKeyRepo = sqliteAdapter.NewSigningKeyRepo(sqliteDB) riskReportRepo = sqliteAdapter.NewRiskReportRepo(sqliteDB) riskListRepo = sqliteAdapter.NewRiskListRepo(sqliteDB) consentRepo = sqliteAdapter.NewConsentRepo(sqliteDB) fositeStore = sqliteAdapter.NewFositeStore(sqliteDB, secretCipher) passkeyRepo = sqliteAdapter.NewPasskeyRepo(sqliteDB) announcementRepo = sqliteAdapter.NewAnnouncementRepo(sqliteDB) mc := memcache.NewMemCache() cleanup = chainCleanup(cleanup, func() { _ = mc.Close() }) cache = mc default: // "postgres" db, err := postgres.NewDB(ctx, cfg.Database) if err != nil { return router.Deps{}, cleanup, fmt.Errorf("postgres: %w", err) } cleanup = chainCleanup(cleanup, func() { db.Close() }) pgPool = db migrationsPath := os.Getenv("OIDC_MIGRATIONS_PATH") if migrationsPath == "" { migrationsPath = "db/migrations" } if err := postgres.RunMigrations(cfg.Database, migrationsPath); err != nil { return router.Deps{}, cleanup, fmt.Errorf("postgres migrations %q: %w", migrationsPath, err) } redisCache, err := redis.NewCache(ctx, cfg.Redis) if err != nil { return router.Deps{}, cleanup, fmt.Errorf("redis: %w", err) } cleanup = chainCleanup(cleanup, func() { _ = redisCache.Close() }) redisClient = redisCache.Client() cache = redisCache userRepo = postgres.NewUserRepo(db) bindingRepo = postgres.NewBindingRepo(db) clientRepo = postgres.NewClientRepo(db) accessRuleRepo = postgres.NewClientAccessRuleRepo(db) ruleRepo = postgres.NewRuleRepo(db) sessionRepo = postgres.NewSessionRepo(db) providerCfgRepo = postgres.NewProviderConfigRepo(db) auditRepo = postgres.NewAuditRepo(db) settingsRepo = postgres.NewSettingsRepo(db) aliasRepo = postgres.NewAliasRestrictionRepo(db) signingKeyRepo = postgres.NewSigningKeyRepo(db) riskReportRepo = postgres.NewRiskReportRepo(db) riskListRepo = postgres.NewRiskListRepo(db) consentRepo = postgres.NewConsentRepo(db) fositeStore = postgres.NewFositeStore(db, secretCipher) passkeyRepo = postgres.NewPasskeyRepo(db) announcementRepo = postgres.NewAnnouncementRepo(db) } // settingsRepo is used by devHandler above. // Social provider registry. socialRegistry := buildSocialRegistry(cfg, providerCfgRepo) // Email sender. baseURL := cfg.Server.BaseURL if baseURL == "" { baseURL = fmt.Sprintf("http://localhost:%d", cfg.Server.Port) } emailSender := smtp.NewSender(cfg.SMTP, baseURL, settingsRepo) // Services. cacheSvc := service.NewCacheService(cache) securitySvc := service.NewSecurityLevelService(ruleRepo, bindingRepo, userRepo, auditRepo) authSvc := service.NewAuthService(userRepo, sessionRepo, cache, auditRepo, emailSender, settingsRepo, cfg) sessionSvc := service.NewSessionService(sessionRepo, userRepo, cfg) socialSvc := service.NewSocialService(bindingRepo, userRepo, socialRegistry, cache, securitySvc, sessionRepo, auditRepo, settingsRepo, riskListRepo, cfg) clientSvc := service.NewClientService(clientRepo, accessRuleRepo, auditRepo, secretCipher) adminSvc := service.NewAdminService(userRepo, providerCfgRepo, settingsRepo, aliasRepo, signingKeyRepo, auditRepo, passkeyRepo, cfg.Security) accessCtrl := service.NewAccessControlService(accessRuleRepo, aliasRepo) riskSvc := service.NewRiskService(riskReportRepo, riskListRepo, bindingRepo, userRepo, auditRepo, securitySvc, emailSender) // Passkey service. passkeySvc, err := service.NewPasskeyService(passkeyRepo, userRepo, sessionRepo, cache, settingsRepo, cfg) if err != nil { return router.Deps{}, cleanup, fmt.Errorf("passkey service: %w", err) } // OIDC provider. privKey, err := loadOrCreateSigningKey(ctx, signingKeyRepo, adminSvc) if err != nil { return router.Deps{}, cleanup, fmt.Errorf("signing key: %w", err) } oauth2Secret := []byte(os.Getenv("OIDC_OAUTH2_SECRET")) if len(oauth2Secret) < 32 { slog.Warn("OIDC_OAUTH2_SECRET is not set or too short (min 32 chars), using random secret (sessions will not survive restart)") randomBytes := make([]byte, 32) if _, err := rand.Read(randomBytes); err != nil { return router.Deps{}, cleanup, fmt.Errorf("generate random secret: %w", err) } oauth2Secret = randomBytes } provider := oidcprovider.NewOAuth2Provider(fositeStore, oauth2Secret, privKey, cfg.Server.Issuer) // Handlers. authHandler := handler.NewAuthHandler(authSvc, sessionSvc, cfg.Session) socialHandler := handler.NewSocialHandler(socialSvc, socialRegistry, sessionSvc, cfg.Session) userInfoHandler := handler.NewUserInfoHandler(userRepo, socialSvc, securitySvc, accessCtrl, authSvc, sessionSvc, consentRepo, auditRepo, riskSvc, clientSvc) adminHandler := handler.NewAdminHandler(adminSvc, clientSvc, securitySvc, userRepo, socialRegistry, riskSvc, sessionRepo, bindingRepo, consentRepo, cacheSvc) oidcHandler := handler.NewOIDCHandler(provider, userRepo, clientSvc, accessCtrl, sessionSvc, settingsRepo, cfg.Server, "/login") oidcHandler.SetCache(cache) wellKnownHandler := handler.NewWellKnownHandler(cfg.Server.BaseURL, settingsRepo, signingKeyRepo) devHandler := handler.NewDeveloperHandler(clientSvc, riskSvc, userRepo, settingsRepo, consentRepo, cfg.Server.Issuer) healthHandler := handler.NewHealthHandler(pgPool, redisClient) passkeyHandler := handler.NewPasskeyHandler(passkeySvc, cfg.Session) announcementHandler := handler.NewAnnouncementHandler(service.NewAnnouncementService(announcementRepo, auditRepo)) allowedOrigins := []string{"*"} if v := os.Getenv("OIDC_ALLOWED_ORIGINS"); v != "" { allowedOrigins = strings.Split(v, ",") } startBackgroundJobs(ctx, sessionRepo, securitySvc, socialSvc, cfg.SocialAuthSync) if cfg.Admin.Email != "" && cfg.Admin.Password != "" { if err := seedAdmin(ctx, userRepo, cfg.Admin); err != nil { slog.Warn("seed admin", "error", err) } } seedProviders(ctx, providerCfgRepo) seedSettings(ctx, settingsRepo, cfg) return router.Deps{ AuthHandler: authHandler, SocialHandler: socialHandler, OIDCHandler: oidcHandler, UserInfoHandler: userInfoHandler, AdminHandler: adminHandler, DeveloperHandler: devHandler, WellKnownHandler: wellKnownHandler, HealthHandler: healthHandler, PasskeyHandler: passkeyHandler, AnnouncementHandler: announcementHandler, SessionService: sessionSvc, UserRepo: userRepo, SettingsRepo: settingsRepo, Cache: cache, AllowedOrigins: allowedOrigins, CookieName: cfg.Session.CookieName, SPAFS: locateSPAFS(), }, cleanup, nil } func locateSPAFS() fs.FS { for _, dir := range []string{"frontend/dist", "../frontend/dist", "dist"} { if info, err := os.Stat(dir); err == nil && info.IsDir() { slog.Info("serving SPA from", "dir", dir) return os.DirFS(dir) } } slog.Warn("no frontend dist found, SPA disabled") return nil } func chainCleanup(a, b func()) func() { return func() { b() a() } } func buildSocialRegistry(cfg *config.Config, providerCfgRepo port.ProviderConfigRepository) port.SocialProviderRegistry { reg := social.NewRegistry(providerCfgRepo) // Load from config.yaml first (static config). for _, name := range domain.AllProviders() { pcfg, ok := cfg.OAuth2.Providers[name] if !ok || !pcfg.Enabled { continue } var p port.SocialProvider switch name { case domain.ProviderGitHub: p = social.NewGitHubProvider(pcfg.ClientID, pcfg.ClientSecret, pcfg.Scopes) case domain.ProviderGoogle: p = social.NewGoogleProvider(pcfg.ClientID, pcfg.ClientSecret, pcfg.Scopes) case domain.ProviderGitLab: p = social.NewGitLabProvider(pcfg.ClientID, pcfg.ClientSecret, "", pcfg.Scopes) case domain.ProviderGitee: p = social.NewGiteeProvider(pcfg.ClientID, pcfg.ClientSecret, pcfg.Scopes) case domain.ProviderLinuxDO: p = social.NewLinuxDOProvider(pcfg.ClientID, pcfg.ClientSecret, pcfg.Scopes) case domain.ProviderDiscord: p = social.NewDiscordProviderWithGuilds(pcfg.ClientID, pcfg.ClientSecret, pcfg.Scopes, pcfg.GuildIDs) case domain.ProviderMicrosoft: p = social.NewMicrosoftProvider(pcfg.ClientID, pcfg.ClientSecret, "", pcfg.Scopes) case domain.ProviderQQ: p = social.NewQQProvider(pcfg.ClientID, pcfg.ClientSecret, pcfg.Scopes) case domain.ProviderWeChat: p = social.NewWeChatProvider(pcfg.AppID, pcfg.AppSecret) case domain.ProviderTelegram: p = social.NewTelegramProvider(pcfg.AppSecret) default: slog.Warn("provider not wired", "provider", name) continue } if p != nil { reg.Register(p) } } // Then overlay with DB-stored configs (admin-configured providers). if err := reg.Reload(context.Background()); err != nil { slog.Warn("reload social providers from DB", "error", err) } return reg } func loadOrCreateSigningKey(ctx context.Context, repo port.SigningKeyRepository, adminSvc *service.AdminService) (*rsa.PrivateKey, error) { k, err := repo.GetCurrent(ctx) if err == nil && k != nil { key, err := parsePrivateKey(k.PrivateKey) if err != nil { return nil, err } rk, ok := key.(*rsa.PrivateKey) if !ok { return nil, fmt.Errorf("not an RSA private key") } return rk, nil } // Bootstrap: create the first signing key via admin service. newKey, err := adminSvc.RotateSigningKey(ctx) if err != nil { return nil, fmt.Errorf("create initial signing key: %w", err) } key, err := parsePrivateKey(newKey.PrivateKey) if err != nil { return nil, err } rk, ok := key.(*rsa.PrivateKey) if !ok { return nil, fmt.Errorf("not an RSA private key") } return rk, nil } func seedAdmin(ctx context.Context, userRepo port.UserRepository, cfg config.AdminConfig) error { existing, err := userRepo.GetByEmail(ctx, cfg.Email) if err != nil && !errors.Is(err, port.ErrNotFound) { return err } if existing != nil { return nil } salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { return err } key := argon2.IDKey([]byte(cfg.Password), salt, 1, 64*1024, 4, 32) hash := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, 64*1024, 1, 4, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)) now := time.Now().UTC() admin := &domain.User{ ID: uuid.New(), Email: cfg.Email, EmailVerified: true, PasswordHash: hash, DisplayName: "Admin", Role: domain.RoleSuperAdmin, Status: domain.UserStatusActive, CreatedAt: now, UpdatedAt: now, } if err := userRepo.Create(ctx, admin); err != nil { return err } slog.Info("admin account created", "email", cfg.Email) return nil } func seedProviders(ctx context.Context, repo port.ProviderConfigRepository) { displayNames := map[string]string{ domain.ProviderGitHub: "GitHub", domain.ProviderGoogle: "Google", domain.ProviderGitLab: "GitLab", domain.ProviderGitee: "Gitee", domain.ProviderLinuxDO: "Linux DO", domain.ProviderDiscord: "Discord", domain.ProviderTelegram: "Telegram", domain.ProviderMicrosoft: "Microsoft", domain.ProviderApple: "Apple", domain.ProviderQQ: "QQ", domain.ProviderWeChat: "WeChat", domain.ProviderPhone: "Phone", } existing, _ := repo.List(ctx) have := make(map[string]bool, len(existing)) for _, pc := range existing { have[pc.Provider] = true } for i, name := range domain.AllProviders() { if have[name] { continue } pc := &domain.ProviderConfig{ ID: uuid.New(), Provider: name, DisplayName: displayNames[name], IsEnabled: false, SortOrder: i, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } if err := repo.Upsert(ctx, pc); err != nil { slog.Warn("seed provider", "provider", name, "error", err) } } } func seedSettings(ctx context.Context, repo port.SettingsRepository, cfg *config.Config) { defaults := map[string]string{ "site_url": defaultSiteURL(cfg), "github_url": "https://github.com/Luotianyi-0712/OpenOIDC", "contact_info": "", "registration_enabled": "true", "registration_email_verification_required": "true", "password_login_enabled": "true", "social_login_enabled": "true", "social_register_enabled": "true", "social_binding_enabled": "true", "captcha_enabled": "true", "captcha_provider": "turnstile", "captcha_site_key": "", "captcha_secret_key": "", "risk_policy_enabled": "true", "risk_blocked_ips": "", "risk_blocked_emails": "", "risk_blocked_email_domains": "", "risk_blocked_email_patterns": `^[^@]*[.+][^@]*@`, "passkey_enabled": "true", } for key, value := range defaults { if _, err := repo.Get(ctx, key); err != nil { // Setting does not exist yet; seed the default. if err := repo.Upsert(ctx, key, value, ""); err != nil { slog.Warn("seed setting", "key", key, "error", err) } } } } func defaultSiteURL(cfg *config.Config) string { for _, candidate := range []string{cfg.Server.BaseURL, cfg.Server.Issuer} { if value := strings.TrimRight(strings.TrimSpace(candidate), "/"); value != "" { return value } } return fmt.Sprintf("http://localhost:%d", cfg.Server.Port) } func startBackgroundJobs(ctx context.Context, sessionRepo port.SessionRepository, securitySvc *service.SecurityLevelService, socialSvc *service.SocialService, socialSync config.SocialAuthSyncConfig) { go func() { ticker := time.NewTicker(15 * time.Minute) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: if err := sessionRepo.DeleteExpired(ctx); err != nil { slog.Warn("cleanup expired sessions", "error", err) } } } }() go func() { ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: if err := securitySvc.RecomputeAll(ctx); err != nil { slog.Warn("recompute security levels", "error", err) } } } }() if socialSync.Enabled && socialSvc != nil { interval := socialSync.Interval if interval <= 0 { interval = time.Hour } batchSize := socialSync.BatchSize if batchSize <= 0 { batchSize = 100 } go func() { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: staleBefore := time.Now().UTC().Add(-interval) if err := socialSvc.SyncAuthorizationStatus(ctx, batchSize, staleBefore); err != nil { slog.Warn("sync social authorization status", "error", err) } } } }() } }