auth: split dev-mode auth constructor and wire up dev-login/logout UI
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 5s

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 23:20:15 -06:00
co-authored by Claude Sonnet 5
parent e355f6984a
commit 0a17dce032
9 changed files with 275 additions and 64 deletions
+67 -15
View File
@@ -3,6 +3,7 @@
package config
import (
"flag"
"fmt"
"os"
"strconv"
@@ -25,14 +26,33 @@ 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) {
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 (
cfg Config
@@ -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)
}
}
+16 -1
View File
@@ -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 {
+8 -3
View File
@@ -78,7 +78,11 @@ func runApp(ctx context.Context, logger *logging.Logger) error {
return fmt.Errorf("failed to initialize database connection pool: %w", err)
}
auth, err := authentication.New(
var auth *authentication.Authenticator
if cfg.DevAuthEnabled {
auth = authentication.NewDev(connPool, logger.WithGroup("authenticator"))
} else {
auth, err = authentication.New(
ctx,
connPool,
logger.WithGroup("authenticator"),
@@ -90,6 +94,7 @@ func runApp(ctx context.Context, logger *logging.Logger) error {
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)
+19
View File
@@ -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
}
+1
View File
@@ -65,6 +65,7 @@ func NewRouter(
reps,
etsy,
authM.Authenticate(),
devAuthEnabled,
)
// non-html content: scripts, styles, images, etc
+8
View File
@@ -31,6 +31,7 @@ type (
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{
@@ -181,6 +183,7 @@ func Routes(
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),
+106 -10
View File
@@ -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: "<percentage>";
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: "<percentage>";
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: "<length>";
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;
+17 -3
View File
@@ -5,6 +5,7 @@
{{- $acctID = .Identity.Account.AccountID }}
{{- end }}
{{- $mockMode := .MockMode }}
{{- $devAuthEnabled := .DevAuthEnabled }}
<!DOCTYPE html>
@@ -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 @@
<a href="mailto:contact-us@inventory-plus-plus.com" class="p-[1em] font-display text-center">Contact Us</a>
<a href="mailto:support@inventory-plus-plus.com" class="p-[1em] font-display text-center">Support</a>
</address>
{{- if .Identity.Claims.Picture }}
{{- if (or .Identity.Claims.Picture .Identity.Claims.Name) }}
<button
popovertarget="identity-popover"
class="
@@ -244,6 +251,7 @@
items-center
"
>
{{ if .Identity.Claims.Picture }}
<img
src="{{ .Identity.Claims.Picture }}"
class="rounded-[50%]"
@@ -252,6 +260,12 @@
margin: 1rem 0;
"
/>
{{ else if .Identity.Claims.Name }}
{{ .Identity.Claims.Name }}
{{ else }}
User
{{ end }}
</button>
<div
popover="auto"
@@ -308,7 +322,7 @@
</div>
<a
href="/api/auth/logout"
href="/api/auth/{{if $devAuthEnabled}}dev-logout{{else}}logout{{end}}"
hx-boost="false"
class="
w-fit
+2 -1
View File
@@ -1,4 +1,5 @@
{{- $loggedIn := and (and .Identity .Identity.AccessToken) true -}}
{{- $devAuthEnabled := .DevAuthEnabled }}
<section class="flex justify-center max-w-full mt-[3em] mb-[3em]">
@@ -26,7 +27,7 @@
Create an Account
</a>
{{- else }}
<a href="/api/auth/login" hx-boost="false" class="block p-[1em] font-bold">
<a href="/api/auth/{{if $devAuthEnabled}}dev-login{{else}}login{{end}}" hx-boost="false" class="block p-[1em] font-bold">
New Login
</a>
{{- end }}