// Package auth replaces what better-auth used to do: passwordless magic links // and cookie sessions. There are no passwords to leak and no reset flow to get // wrong. // // Anyone may request a link, but only an allowlisted or flagged account can // reach /admin — signing in and being an admin are separate questions. package auth import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "log" "net/http" "net/url" "slices" "strings" "time" "github.com/szabadpalya/api/internal/config" "github.com/szabadpalya/api/internal/store" ) const ( SessionCookie = "szp_session" linkTTL = 15 * time.Minute sessionTTL = 30 * 24 * time.Hour ) type Service struct { store *store.Store cfg *config.Config } func New(s *store.Store, cfg *config.Config) *Service { return &Service{store: s, cfg: cfg} } // Tokens are compared by hash, so what lands in the database is useless on its // own. sha256 without a salt is the right tool here: the input is 32 bytes of // entropy, not a guessable secret. func hashToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } func randomToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } // SendMagicLink issues a link for an address. It reports no error the caller // could use to tell whether the address exists — that is the handler's job to // preserve, but there is nothing here to leak either. func (a *Service) SendMagicLink(ctx context.Context, email, callback string) error { token, err := randomToken() if err != nil { return err } if err := a.store.CreateVerification(ctx, email, hashToken(token), time.Now().Add(linkTTL)); err != nil { return err } link := a.cfg.BaseURL + "/api/auth/verify?token=" + url.QueryEscape(token) + "&callback=" + url.QueryEscape(callback) // TODO: send through Resend/Postmark once email is set up (TODO.md 7). // Until then the link goes to the server log, which is enough for // development and makes it obvious that email is not wired up yet. log.Printf("\n magic link for %s:\n %s\n", email, link) return nil } var ErrInvalidToken = errors.New("invalid or expired token") // Verify redeems a token and returns the session cookie to set. func (a *Service) Verify(ctx context.Context, token, ip, ua string) (*http.Cookie, error) { email, err := a.store.ConsumeVerification(ctx, hashToken(token)) if errors.Is(err, store.ErrNotFound) { return nil, ErrInvalidToken } if err != nil { return nil, err } user, err := a.store.UpsertUser(ctx, email) if err != nil { return nil, err } sessionToken, err := randomToken() if err != nil { return nil, err } expires := time.Now().Add(sessionTTL) if err := a.store.CreateSession(ctx, user.ID, hashToken(sessionToken), expires, ip, ua); err != nil { return nil, err } return a.cookie(sessionToken, expires), nil } func (a *Service) Logout(ctx context.Context, token string) *http.Cookie { if token != "" { if err := a.store.DeleteSession(ctx, hashToken(token)); err != nil { log.Printf("logout: %v", err) } } return a.cookie("", time.Unix(0, 0)) } // UserFromRequest resolves the session cookie. A missing or stale cookie is // not an error: most requests are anonymous. func (a *Service) UserFromRequest(ctx context.Context, r *http.Request) *store.User { c, err := r.Cookie(SessionCookie) if err != nil || c.Value == "" { return nil } user, err := a.store.UserBySessionToken(ctx, hashToken(c.Value)) if err != nil { return nil } return user } // IsAdmin gates the admin surface. Admin rights come from two places: the // isAdmin flag on the user row, and the ADMIN_EMAILS allowlist. The allowlist // exists so a fresh deployment can grant the first admin without database // surgery; the flag is what a future admin UI will set. Neither writes on read. func (a *Service) IsAdmin(user *store.User) bool { if user == nil { return false } if user.IsAdmin { return true } return slices.Contains(a.cfg.AdminEmails, strings.ToLower(user.Email)) } func (a *Service) cookie(value string, expires time.Time) *http.Cookie { return &http.Cookie{ Name: SessionCookie, Value: value, Path: "/", Expires: expires, HttpOnly: true, Secure: a.cfg.Secure(), // Lax rather than Strict: the magic link is a top-level navigation from // a mail client, and Strict would drop the cookie on exactly that hop. SameSite: http.SameSiteLaxMode, } }