auth: split dev-mode auth constructor and wire up dev-login/logout UI
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 5s
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:
+67
-15
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ func NewRouter(
|
||||
reps,
|
||||
etsy,
|
||||
authM.Authenticate(),
|
||||
devAuthEnabled,
|
||||
)
|
||||
|
||||
// non-html content: scripts, styles, images, etc
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user