From e78d5c42465f5fd7b034e5a719be94d1ce10da86 Mon Sep 17 00:00:00 2001 From: Angel Beltran Date: Wed, 19 Aug 2026 14:19:10 -0600 Subject: [PATCH] auth: split dev-mode auth constructor and wire up dev-login/logout UI Squeamish about New()'s empty-domain-string sentinel for "dev mode, skip OIDC discovery" - split into New (always makes a real OIDC discovery call, all params required) and NewDev (no ctx/domain/credentials at all, since none are used). main.go now branches on cfg.DevAuthEnabled to pick the right constructor instead of main.go/config.go coordinating on when it's safe to pass empty strings. Also finishes out the dev-auth flow this enables: config.Load reads a DEV_AUTH_ENABLED-aware env file and only requires Auth0 vars when dev auth is off; a PORT config var replaces the hardcoded :8082; and the nav UI (layout/index templates, ui router) points login/logout links at /api/auth/dev-login and a new /api/auth/dev-logout route when dev auth is enabled, so the whole login/logout loop works locally without a real Auth0 app. Co-Authored-By: Claude Sonnet 5 --- config/config.go | 86 ++++++++++++++++++----- domains/authentication/auth.go | 17 ++++- main.go | 31 +++++---- server/api/auth/router.go | 19 ++++++ server/server.go | 1 + server/ui/router.go | 30 ++++++--- styles/index.css | 116 +++++++++++++++++++++++++++++--- templates/layout.html.tmpl | 36 +++++++--- templates/pages/index.html.tmpl | 3 +- 9 files changed, 275 insertions(+), 64 deletions(-) diff --git a/config/config.go b/config/config.go index 7f0549b..f346576 100644 --- a/config/config.go +++ b/config/config.go @@ -3,6 +3,7 @@ package config import ( + "flag" "fmt" "os" "strconv" @@ -25,13 +26,32 @@ type Config struct { // login session for any user_id without going through Auth0. Must // never be true outside local development. DevAuthEnabled bool + + Port int +} + +var envFlagPtr = flag.String("env", "", "") + +func init() { + flag.Parse() } // Load reads a .env file, if present, into the process environment, then // reads the required configuration values from the environment. func Load() (Config, error) { - if err := godotenv.Load(); err != nil && !os.IsNotExist(err) { - return Config{}, fmt.Errorf("failed to load .env file: %w", err) + dev, err := checkEnvFlag() + if err != nil { + return Config{}, err + } + + if dev { + if err := godotenv.Load(".env.dev"); err != nil && !os.IsNotExist(err) { + return Config{}, fmt.Errorf("failed to load .env.dev file: %w", err) + } + } else { + if err := godotenv.Load(); err != nil && !os.IsNotExist(err) { + return Config{}, fmt.Errorf("failed to load .env file: %w", err) + } } var ( @@ -49,21 +69,8 @@ func Load() (Config, error) { cfg.DatabaseURL = required("DATABASE_URL") - cfg.Auth0Domain = required("AUTH0_DOMAIN") - cfg.Auth0ClientID = required("AUTH0_CLIENT_ID") - cfg.Auth0ClientSecret = required("AUTH0_CLIENT_SECRET") - cfg.Auth0CallbackURL = required("AUTH0_CALLBACK_URL") - - cfg.EtsyAPIKeystring = required("ETSY_API_KEYSTRING") - cfg.EtsyAPISharedSecret = required("ETSY_API_SHARED_SECRET") - - if len(missing) > 0 { - return Config{}, fmt.Errorf( - "missing required environment variables: %v (see .env.example)", - missing, - ) - } - + // DEV_AUTH_ENABLED must be known before the Auth0 vars are read below, + // since it decides whether those are required at all. if raw := os.Getenv("DEV_AUTH_ENABLED"); raw != "" { devAuthEnabled, err := strconv.ParseBool(raw) if err != nil { @@ -72,5 +79,50 @@ func Load() (Config, error) { cfg.DevAuthEnabled = devAuthEnabled } + // With DEV_AUTH_ENABLED, real Auth0 login/logout never runs (dev-login + // mints sessions locally instead), so these are unused and optional. + if cfg.DevAuthEnabled { + cfg.Auth0Domain = os.Getenv("AUTH0_DOMAIN") + cfg.Auth0ClientID = os.Getenv("AUTH0_CLIENT_ID") + cfg.Auth0ClientSecret = os.Getenv("AUTH0_CLIENT_SECRET") + cfg.Auth0CallbackURL = os.Getenv("AUTH0_CALLBACK_URL") + } else { + cfg.Auth0Domain = required("AUTH0_DOMAIN") + cfg.Auth0ClientID = required("AUTH0_CLIENT_ID") + cfg.Auth0ClientSecret = required("AUTH0_CLIENT_SECRET") + cfg.Auth0CallbackURL = required("AUTH0_CALLBACK_URL") + } + + cfg.EtsyAPIKeystring = required("ETSY_API_KEYSTRING") + cfg.EtsyAPISharedSecret = required("ETSY_API_SHARED_SECRET") + + portStr := required("PORT") + + if len(missing) > 0 { + return Config{}, fmt.Errorf( + "missing required environment variables: %v (see .env.example)", + missing, + ) + } + + if port, err := strconv.Atoi(portStr); err != nil { + return Config{}, fmt.Errorf("invalid port number: %w", err) + } else { + cfg.Port = port + } + return cfg, nil } + +func checkEnvFlag() (dev bool, err error) { + switch env := *envFlagPtr; env { + case "dev": + return true, nil + case "prd": + return false, nil + case "": + return false, fmt.Errorf("no env flag specified: must be one of dev, prd") + default: + return false, fmt.Errorf("invalid env flag specified: must be one of dev, prd: %q", env) + } +} diff --git a/domains/authentication/auth.go b/domains/authentication/auth.go index 4ca3f4c..2aafb28 100644 --- a/domains/authentication/auth.go +++ b/domains/authentication/auth.go @@ -45,7 +45,10 @@ type ( } ) -// New instantiates the *Authenticator. +// New instantiates an *Authenticator backed by a real Auth0 tenant: it makes +// an OIDC discovery call against domain, so login/callback/logout are fully +// functional. Use NewDev instead when DEV_AUTH_ENABLED is set and no real +// Auth0 app is configured. func New( ctx context.Context, db *pgxpool.Pool, @@ -75,6 +78,18 @@ func New( }, nil } +// NewDev instantiates an *Authenticator with no real Auth0 tenant behind it: +// no OIDC discovery call is made, and Provider/Config are left zero-valued. +// Only DevLogin is safe to call on the result - Exchange, VerifyIDToken, and +// GetLogoutURL all assume a real Auth0 setup and will misbehave. Only use +// this from a route gated on an explicit dev-mode flag. +func NewDev(db *pgxpool.Pool, logger *logging.Logger) *Authenticator { + return &Authenticator{ + log: logger, + db: db, + } +} + func (a *Authenticator) RunBackgroundCleanup(ctx context.Context) error { for { if _, err := a.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE expiry < NOW()"); err != nil { diff --git a/main.go b/main.go index 8b2489b..eddfc85 100644 --- a/main.go +++ b/main.go @@ -78,17 +78,22 @@ func runApp(ctx context.Context, logger *logging.Logger) error { return fmt.Errorf("failed to initialize database connection pool: %w", err) } - auth, err := authentication.New( - ctx, - connPool, - logger.WithGroup("authenticator"), - cfg.Auth0Domain, - cfg.Auth0ClientID, - cfg.Auth0ClientSecret, - cfg.Auth0CallbackURL, - ) - if err != nil { - return fmt.Errorf("failed to construct authenticator: %w", err) + var auth *authentication.Authenticator + if cfg.DevAuthEnabled { + auth = authentication.NewDev(connPool, logger.WithGroup("authenticator")) + } else { + auth, err = authentication.New( + ctx, + connPool, + logger.WithGroup("authenticator"), + cfg.Auth0Domain, + cfg.Auth0ClientID, + cfg.Auth0ClientSecret, + cfg.Auth0CallbackURL, + ) + if err != nil { + return fmt.Errorf("failed to construct authenticator: %w", err) + } } accts := accounts.NewStore(logger.WithGroup("accounts"), connPool) @@ -240,7 +245,7 @@ func runServer( ) srv := &http.Server{ - Addr: ":8082", // local + Addr: fmt.Sprintf(":%d", cfg.Port), // local Handler: r, } @@ -253,7 +258,7 @@ func runServer( defer cancel() defer close(alreadyShutdownCh) - logger.Info("server running on 8082...") + logger.Infof("server running on %d...", cfg.Port) if err := srv.ListenAndServe(); err != nil { if !errors.Is(err, http.ErrServerClosed) { runningErrCh <- fmt.Errorf("server experienced error: %w", err) diff --git a/server/api/auth/router.go b/server/api/auth/router.go index ddf2169..f325f28 100644 --- a/server/api/auth/router.go +++ b/server/api/auth/router.go @@ -35,6 +35,7 @@ func Routes( if devAuthEnabled { logger.Warn("DEV_AUTH_ENABLED is set: /api/auth/dev-login is live and lets any caller authenticate as any user_id with no credentials. Never enable this outside local development.") r.GET("/dev-login", response.Handler(ls.devLoginPage)) + r.GET("/dev-logout", response.Handler(ls.devLogoutPage)) } } @@ -135,3 +136,21 @@ func (s *loginSubrouter) logoutPage(c *gin.Context) (response.Response, error) { return response.TemporaryRedirect(s.auth.GetLogoutURL(host).String()). Cookie(cookies.Expired("access_token")), nil } + +func (s *loginSubrouter) devLogoutPage(c *gin.Context) (response.Response, error) { + r := c.Request + + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + + if ck, err := r.Cookie("access_token"); err == nil && ck != nil { + if err := s.auth.DeleteOAuthTokens(r.Context(), ck.Value); err != nil { + s.log.Error("failed to delete auth token", "error", err) + } + } + + return response.TemporaryRedirect("/ui"). + Cookie(cookies.Expired("access_token")), nil +} diff --git a/server/server.go b/server/server.go index aed20f9..0cb79fc 100644 --- a/server/server.go +++ b/server/server.go @@ -65,6 +65,7 @@ func NewRouter( reps, etsy, authM.Authenticate(), + devAuthEnabled, ) // non-html content: scripts, styles, images, etc diff --git a/server/ui/router.go b/server/ui/router.go index 7813ad7..bc5079e 100644 --- a/server/ui/router.go +++ b/server/ui/router.go @@ -24,13 +24,14 @@ import ( type ( webpageRouter struct { - log *logging.Logger - uiPath string - templater *templater.Templater - rawEvents *raw_events.Store - accts *accounts.Store - reports *reports.Store - etsy *etsy_platform.Platform + log *logging.Logger + uiPath string + templater *templater.Templater + rawEvents *raw_events.Store + accts *accounts.Store + reports *reports.Store + etsy *etsy_platform.Platform + devAuthEnabled bool } // ErrTemplateNotFound is returned if the reason the template failed to compile @@ -49,6 +50,7 @@ func Routes( reps *reports.Store, etsy *etsy_platform.Platform, authenticate gin.HandlerFunc, + devAuthEnabled bool, ) { s := &webpageRouter{ @@ -177,10 +179,11 @@ func Routes( } }, }), - rawEvents: rawEvents, - accts: accts, - reports: reps, - etsy: etsy, + rawEvents: rawEvents, + accts: accts, + reports: reps, + etsy: etsy, + devAuthEnabled: devAuthEnabled, } r.GET("", response.Handler(s.redirectToAccountsIfLoggedInWithAnAccount), response.Handler(s.serveTemplate)) @@ -219,6 +222,11 @@ func (s *webpageRouter) serveTemplate(c *gin.Context) (response.Response, error) args := []any{ "Request", r, + + // dev mode + "DevAuthEnabled", + s.devAuthEnabled, + // add services and data here "RawEvents", s.rawEvents.WithContext(ctx), diff --git a/styles/index.css b/styles/index.css index 4736b44..941bbe2 100644 --- a/styles/index.css +++ b/styles/index.css @@ -18,6 +18,7 @@ --text-xl--line-height: calc(1.75 / 1.25); --text-3xl: 1.875rem; --text-6xl: 3.75rem; + --text-6xl--line-height: 1; --font-weight-semibold: 600; --font-weight-bold: 700; --radius-lg: var(--radius); @@ -179,9 +180,18 @@ } } @layer utilities { + .invisible { + visibility: hidden; + } .visible { visibility: visible; } + .absolute { + position: absolute; + } + .fixed { + position: fixed; + } .relative { position: relative; } @@ -319,6 +329,9 @@ .hidden { display: none; } + .inline { + display: inline; + } .inline-block { display: inline-block; } @@ -585,6 +598,10 @@ .font-display { font-family: var(--display-family); } + .text-6xl { + font-size: var(--text-6xl); + line-height: var(--tw-leading, var(--text-6xl--line-height)); + } .text-lg { font-size: var(--text-lg); line-height: var(--tw-leading, var(--text-lg--line-height)); @@ -617,12 +634,22 @@ .capitalize { text-transform: capitalize; } + .lowercase { + text-transform: lowercase; + } .italic { font-style: italic; } .underline { text-decoration-line: underline; } + .accent-secondary { + accent-color: var(--secondary); + } + .shadow { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } .outline-1 { outline-style: var(--tw-outline-style); outline-width: 1px; @@ -643,11 +670,6 @@ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } - .not-group-focus-within\:hidden { - &:not(*:is(:where(.group):focus-within *)) { - display: none; - } - } .not-group-hover\:hidden { &:not(*:is(:where(.group):hover *)) { display: none; @@ -656,11 +678,6 @@ display: none; } } - .not-group-focus\:hidden { - &:not(*:is(:where(.group):focus *)) { - display: none; - } - } .not-open\:mb-\[1em\] { &:not(*:is([open], :popover-open, :open)) { margin-bottom: 1em; @@ -1249,6 +1266,71 @@ syntax: "*"; inherits: false; } +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} @property --tw-outline-style { syntax: "*"; inherits: false; @@ -1346,6 +1428,20 @@ --tw-skew-y: initial; --tw-border-style: solid; --tw-font-weight: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; diff --git a/templates/layout.html.tmpl b/templates/layout.html.tmpl index 4bfc7e7..0950b66 100644 --- a/templates/layout.html.tmpl +++ b/templates/layout.html.tmpl @@ -5,6 +5,7 @@ {{- $acctID = .Identity.Account.AccountID }} {{- end }} {{- $mockMode := .MockMode }} +{{- $devAuthEnabled := .DevAuthEnabled }} @@ -117,8 +118,13 @@ > {{- if not $loggedIn }} + {{- $loginPath := "/api/auth/login" }} + {{- if $devAuthEnabled }} + {{- $loginPath = "/api/auth/dev-login" }} + {{- end }} + {{ template "navbar-link" (props - "Href" "/api/auth/login" + "Href" $loginPath "NoHXBoost" true "Selected" (eq $path "/auth/login") "Content" "Log In" @@ -231,7 +237,8 @@ Contact Us Support - {{- if .Identity.Claims.Picture }} + + {{- if (or .Identity.Claims.Picture .Identity.Claims.Name) }}