Examples

spa-accounts

The backend of a single-page app: accounts' JSON mode with bearer tokens

Browse on GitHub
$ git clone https://github.com/angvp/tango
$ cd tango/examples/spa-accounts
$ go run .

Accounts for a single-page app

The backend of a separate client (a Next.js or other single-page app) that needs registration, login, password reset and email verification without cookies. It uses the accounts app's JSON mode, accounts.WithJSON, with bearer tokens from auth/jwt.

  • auth.go is the application's side of the bearer contract: it issues and checks JWTs, so accounts imports no token package.
  • apps/profiles is the host's own data: a Profile with a bounded Username (varchar=30, unique), DisplayName (varchar=60), an unbounded Bio, and Preferences, a JSON field (tango:"json"): the Store refuses text that is not valid JSON, and the admin edits it with its JSON editor by default. OnRegister creates the profile in the same transaction as the account, so there is never an account without one; ResolveUsername lets login take the username instead of the email.
  • cors.go is host-owned CORS middleware for the client's origin. tanGO ships none.
  • main.go wires it together. Emailed links point at the client (CLIENT_URL/verify#token=…, CLIENT_URL/reset#token=…); a token in the fragment never reaches a server log or a Referer header.

Run it

git clone https://github.com/angvp/tango
cd tango/examples/spa-accounts
export SPA_JWT_SECRET=$(openssl rand -hex 32)
go run . -migrate
go run .

Then, from any HTTP client:

curl -s localhost:8000/accounts/api/register/ -d '{"email":"[email protected]","password":"correct-horse","profile":{"username":"ada"}}' -H 'Content-Type: application/json'

The response carries the account and a grant with the access token; the verification email, with its link, is printed in the server's terminal. Use the token on the application's own route: curl -s localhost:8000/api/me/ -H "Authorization: Bearer <access_token>".

The six endpoints are register/, login/, password-reset/, password-reset/confirm/, verify/ and verify/resend/ under /accounts/api/. login/ takes {"identifier", "password"}, where the identifier is an email or a username.

The admin

admin.New(store) serves the staff admin at /admin/, so an operator can edit a profile. Create the first account with go run . -tango-admin-create=admin and log in at http://localhost:8000/admin/. The profile's Preferences field is tagged tango:"json", so it gets the JSON editor without any admin configuration: a value that is not one JSON document is refused with its error and the text as typed, the Formatted tab shows valid JSON indented (and Use this formatting copies it into the field), and the stored value is exactly what was submitted. The Bio field uses admin.Markdown() with a Preview tab rendered by Goldmark's defaults, which omit raw HTML and drop unsafe links; the preview shows in a sandboxed frame, and the public site renders nothing from Bio, so a public page would need its own sanitization policy. Run go test . to see the same flow over HTTP.

What it shows about bounded strings

A username longer than 30 characters, or a display name longer than 60, is refused by Store.Create before any SQL, counted in characters (é is one), on SQLite as well as PostgreSQL. The hook returns that error and accounts answers 422 with "code": "invalid_field", keyed by the Go field name; the registration is rolled back, so no account, profile or email remains. When a client needs a public JSON field name, return an accounts.FieldError instead, as the @ rule does. The generated migration gives the columns VARCHAR(30) and VARCHAR(60).

Things to know

  • Bearer tokens are stateless. A password reset ends the account's cookie sessions and tokens but cannot revoke an access token already issued; it stays valid until it expires (15 minutes here).
  • go test . walks the whole flow with mailtest.Sender, on SQLite or, with TANGO_TEST_DSN, PostgreSQL.
  • TANGO_DB_DSN picks the database (default sqlite://app.db); the address is TANGO_ADDR, else PORT, else :8000; BASE_URL is this API's origin.
examples/spa-accounts/main.go
// Command spa-accounts is the backend of a single-page app: the accounts
// app's JSON mode gives a separate client registration, login, password
// reset and email verification with bearer tokens, no cookies. A host
// profiles app stores a username and display name in the same transaction
// that creates the account, and login accepts either the email or the
// username. Emails are printed to the terminal instead of sent.
package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"net"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"syscall"

	"github.com/angvp/tango"
	"github.com/angvp/tango/accounts"
	"github.com/angvp/tango/admin"
	tangojwt "github.com/angvp/tango/auth/jwt"
	"github.com/angvp/tango/db"
	"github.com/angvp/tango/mail"

	"spa-accounts/apps/profiles"
	"spa-accounts/migrations"

	_ "modernc.org/sqlite"
)

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

// settings is what differs between a deployment and a test.
type settings struct {
	// BaseURL is the origin of this API; accounts' own pages and mail use it.
	BaseURL string
	// ClientURL is the origin of the single-page app, which serves the
	// /verify and /reset pages the emailed links open.
	ClientURL string
	// JWTSecret signs access tokens: at least 32 bytes, from the environment.
	JWTSecret []byte
}

func run() error {
	dsn, err := tango.LoadDBConfigFromEnv()
	if err != nil {
		return err
	}
	sqlDB, err := sql.Open(dsn.Driver, dsn.Source)
	if err != nil {
		return err
	}
	defer sqlDB.Close()

	addr := tango.LoadConfigFromEnv(tango.WithPortFromEnv()).Addr
	store := db.NewStore(sqlDB, dsn.Dialect)
	config, err := appConfig(store, mail.WriterSender(os.Stdout), settingsFromEnv(addr))
	if err != nil {
		return err
	}
	if handled, err := admin.HandleCLI(context.Background(), store, os.Args[1:], os.Stdin, os.Stdout, os.Stderr); handled || err != nil {
		return err
	}
	if handled, err := tango.DispatchFlags(config, sqlDB, dsn.Dialect, migrations.Migrations); handled || err != nil {
		return err
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	fmt.Println("listening on", config.Addr)
	return tango.ServeContext(ctx, config, sqlDB, dsn.Dialect)
}

// settingsFromEnv reads SPA_JWT_SECRET (required), BASE_URL (default
// http://localhost on the listening port) and CLIENT_URL (default
// http://localhost:3000, where a Next.js dev server runs).
func settingsFromEnv(addr string) settings {
	base := os.Getenv("BASE_URL")
	if base == "" {
		_, port, err := net.SplitHostPort(addr)
		if err != nil || port == "" {
			port = "8000"
		}
		base = "http://localhost:" + port
	}
	client := os.Getenv("CLIENT_URL")
	if client == "" {
		client = "http://localhost:3000"
	}
	return settings{BaseURL: base, ClientURL: client, JWTSecret: []byte(os.Getenv("SPA_JWT_SECRET"))}
}

// appConfig is the whole application, sending its emails through sender.
// The test builds the same one with a sender that records them.
func appConfig(store *db.Store, sender mail.Sender, s settings) (tango.Config, error) {
	if len(s.JWTSecret) < tangojwt.MinimumSecretBytes {
		return tango.Config{}, fmt.Errorf("SPA_JWT_SECRET must be set to at least %d bytes", tangojwt.MinimumSecretBytes)
	}
	service, err := tangojwt.NewService(tangojwt.Key{ID: "k1", Secret: s.JWTSecret}, nil, s.BaseURL, "spa-client")
	if err != nil {
		return tango.Config{}, err
	}

	config := tango.LoadConfigFromEnv(tango.WithPortFromEnv())
	config.Middleware = []tango.Middleware{cors(s.ClientURL)}
	config.MiddlewareScope = tango.MiddlewareScopeAll
	config.InstalledApps = []tango.App{
		accounts.New(store,
			accounts.WithMail(accounts.MailConfig{Sender: sender, From: "Example <noreply@localhost>", BaseURL: s.BaseURL}),
			accounts.WithJSON(accounts.JSONConfig{
				Auth: bearerAuth{service: service},
				// The links open pages of the single-page app. A token in
				// the fragment never reaches a server log or a Referer.
				VerifyURL:         s.ClientURL + "/verify#token={token}",
				ResetURL:          s.ClientURL + "/reset#token={token}",
				OnRegister:        profiles.OnRegister,
				ResolveIdentifier: profiles.ResolveUsername,
			}),
		),
		profiles.New(),
		apiApp(store, service),
		admin.New(store),
	}
	return config, nil
}

// apiApp is the application's own protected route: who the token names.
func apiApp(store *db.Store, service *tangojwt.Service) tango.App {
	return tango.NewApp("api", func(registry *tango.Registry) error {
		accountMeta, ok := registry.Models().Get("Account")
		if !ok {
			return errors.New("api: install accounts before this app")
		}
		me := tangojwt.Require(func(ctx *tango.Context) error {
			claims, _ := tangojwt.FromContext(ctx.Context())
			var account accounts.Account
			id, err := strconv.ParseInt(claims.Subject, 10, 64)
			if err != nil {
				return ctx.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
			}
			if err := store.Get(ctx.Context(), accountMeta, id, &account); err != nil {
				if errors.Is(err, db.ErrNotFound) {
					return ctx.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
				}
				return err
			}
			profile, _, err := profiles.ForAccount(ctx.Context(), store, account.ID)
			if err != nil {
				return err
			}
			return ctx.JSON(http.StatusOK, map[string]any{
				"id": account.ID, "email": account.Email, "email_verified": !account.EmailVerifiedAt.IsZero(),
				"username": profile.Username, "display_name": profile.DisplayName, "bio": profile.Bio,
			})
		})
		return registry.Routes().Include("/api/", tango.URLs{
			tango.Path(http.MethodGet, "/me/", me, tango.Name("me")),
		}, tango.WithMiddleware(service.Middleware(tangojwt.BearerToken)))
	})
}