Skip to content
Trilha
Chapters

Cookbook

An app in front of an existing API

Forwarding /api/ to a service that already exists, with a login of your own.

ctx --pack · est. 14932 tokens est. · the recipe's llms.txt

The shape of most migrations: the users are yours — e-mail, password hash, role — and the API stays where it is. Two pieces of Trilha meet here, and they meet because the proxy has to carry what the login stored.

examples/local-login is this recipe, whole and running.

1. The session, without a provider#

auth.Sessions is the same *Auth the OIDC flow uses, without a provider. Require(), RequireRole(), the Store, the rotation of the identifier and the idle window are the code that was already there; what changes is who says the person is who they claim to be.

var Flow = auth.Sessions(auth.Options{
	Store:      auth.NewMemoryStore(),
	Idle:       30 * time.Minute,
	LoginPath:  "/entrar",
	AfterLogin: "/painel",
})

User.Extra is what this session carries and OIDC has no claim for — the token the API expects, the tenant, the plan. It travels where the rest of the session travels, so keep it small and never put a password in it:

func Entrar(c *trilha.Ctx, u usuarios.Usuario) error {
	return Flow.Login(c, &auth.User{
		Subject: u.ID, Email: u.Email, Name: u.Nome, Roles: []string{u.Papel},
		// The token of the API the upstream forwards to. It travels in the
		// session, never in the page.
		Extra: map[string]string{"api_token": u.Token},
	})
}

2. The password, checked against your own table#

auth.CheckPBKDF2 reads the format Django and hashlib write — pbkdf2_sha256$iterations$salt$hash — so the users table that already exists stays valid, hashes and all. auth.HashPBKDF2 writes a new one at 600,000 iterations.

Answer the same thing for a wrong e-mail and for a wrong password, and take the same time: saying which of the two was wrong tells an attacker who has an account.

func (s *Store) Verify(email, senha string) (Usuario, error) {
	s.mu.RLock()
	u, ok := s.rows[strings.ToLower(strings.TrimSpace(email))]
	s.mu.RUnlock()
	if !ok {
		auth.CheckPBKDF2(semUsuario, senha)
		return Usuario{}, ErrCredencial
	}
	if !auth.CheckPBKDF2(u.Hash, senha) {
		return Usuario{}, ErrCredencial
	}
	return u, nil
}

The handler is a form like any other:

func POST(c *trilha.Ctx) error {
	email, senha := c.Form("email"), c.Form("senha")
	u, err := trilha.Use[*usuarios.Store](c).Verify(email, senha)
	if err != nil {
		return c.Render(422, formulario(c, email, "E-mail ou senha inválidos."))
	}
	return sessao.Entrar(c, u)
}

3. The API, on the same origin#

func Config(cfg *trilha.Config) {
	// Where the trail of who did what goes. Without this it would go to the
	// log, which is enough to grep and enough to ship with.
	cfg.Audit = sessao.Auditoria
	// O app é escrito para quem lê em português, e é isto que decide o idioma
	// dos componentes do kit que trazem texto próprio — a tela de auditoria
	// entre eles — e o fuso em que as datas dela aparecem.
	cfg.Locale = "pt-BR"
	cfg.TimeZone = "America/Sao_Paulo"
	if api := os.Getenv("API_URL"); api != "" {
		cfg.Upstreams = map[string]trilha.Upstream{
			"/api/": {
				Target: api,
				Headers: func(c *trilha.Ctx, hdr http.Header) {
					if tok := sessao.Token(c); tok != "" {
						hdr.Set("Authorization", "Bearer "+tok)
					}
				},
			},
		}
	}
}

The proxy answers outside the middleware chain, so nobody put the user in the request context: read the session from the cookie.

func Token(c *trilha.Ctx) string {
	u, err := Flow.Session(c)
	if err != nil {
		return ""
	}
	return u.Extra["api_token"]
}

The types the API already publishes#

The proxy above is for the browser. The page is not the browser: it runs on the server, and what it needs is not a URL, it is the shape of the answer — which the API already publishes, in its own OpenAPI document.

trilha client http://api:8801/openapi.json --out internal/api

That writes one file, committed like trilha_gen.go, with a struct per schema and a method per operation. trilha client ... --check in CI fails the day the document moves and nobody regenerated. The generated client imports the standard library and nothing else — not even Trilha — so it works in a job and in a test too.

// Acervo is the client of the API the front used to call from the browser. It
// is generated by `trilha client` from the document the API publishes, so the
// compiler is what catches the field that changed name.
var Acervo = api.New("http://api:8801", api.WithHeader(sessionToken), api.WithResponse(collectCookies))

The credential travels in the context, not in the client:

// tokenKey is how the credential travels from the request to the client. The
// client takes a context, not a *trilha.Ctx: it also runs in a job and in a
// test, where there is no request to borrow a token from.
type tokenKey struct{}

// Calling is the context to hand the client: the session's token, and nothing
// else about the request.
func Calling(c *trilha.Ctx) context.Context {
	tok, _ := c.Signed("api_token")
	return context.WithValue(c.Context(), tokenKey{}, tok)
}

// sessionToken is the only place the credential appears. It runs for every
// request the client makes, so a call from a context that carries no token
// goes out unauthenticated instead of borrowing somebody else's session.
func sessionToken(ctx context.Context) http.Header {
	tok, _ := ctx.Value(tokenKey{}).(string)
	if tok == "" {
		return nil
	}
	return http.Header{"Authorization": {"Bearer " + tok}}
}

When the API is authenticated by cookie, the credential goes the other way: it comes back in a Set-Cookie, and the operation that produces it is the login. WithResponse runs for every response the client receives and hands over its status and its headers, so the collector is the same idea as the token — it lives in the context of the call, and belongs to this request:

// cookieKey is the other direction of tokenKey. On an API authenticated by
// cookie the credential comes back in a Set-Cookie, and the operation that
// produces it is the login — so the page has to read the response and not only
// the body. The collector travels in the context of the call, which is what makes
// it right on a server: a http.CookieJar on the client would belong to the
// process, and would send one person's credential on the next person's call.
type cookieKey struct{}

// Collecting is Calling for an operation whose answer is also in the header: the
// context to hand the client, and where the cookies it set will be once the call
// returns.
func Collecting(c *trilha.Ctx) (context.Context, *[]*http.Cookie) {
	var set []*http.Cookie
	return context.WithValue(Calling(c), cookieKey{}, &set), &set
}

// collectCookies runs for every response the client receives, and fills the
// collector of that call — nothing at all when the call did not bring one.
func collectCookies(ctx context.Context, resp *http.Response) {
	if set, ok := ctx.Value(cookieKey{}).(*[]*http.Cookie); ok {
		*set = resp.Cookies()
	}
}

// SignIn hands the browser the credential the API set. The operation is whichever
// one the API calls a login — here, the one this document happens to declare —
// and what matters is that its Set-Cookie reaches the response of this request
// and of no other.
func SignIn(c *trilha.Ctx, in api.User) error {
	ctx, cookies := Collecting(c)
	if _, err := Acervo.Users().UpsertUser(ctx, in); err != nil {
		return err
	}
	for _, ck := range *cookies {
		ck.Domain = "" // the cookie is this app's host now, not the API's
		c.SetCookie(ck)
	}
	return c.Redirect("/documents")
}

The same hook reads the ETag or the Location of a POST that creates and the Link of a paginated answer. What it does not offer is the body: the body belongs to the operation, which returns it decoded.

And then the page is a page. There is no loading state, because the data is already in hand when the HTML is written:

// Documents is the page. It runs on the server, so there is no loading state
// and no second endpoint: the data it renders is already in hand.
func Documents(c *trilha.Ctx) (h.Node, error) {
	var q DocsQuery
	if err := c.Bind(&q); err != nil {
		return nil, err
	}
	page, err := Acervo.Documents().ListDocuments(Calling(c), api.DocumentsListDocumentsParams{
		Page:     int64(q.Page),
		PageSize: int64(q.PerPage),
		Q:        q.Q,
		Status:   api.Status(q.Status),
	})
	if err != nil {
		return nil, err
	}
	q.ListParams.Restrict("filename", "status")
	return ui.DataTable(c, DocsColumns, page.Items, ui.ListState{
		Params: q.ListParams,
		Total:  int(page.Total),
		ID:     "docs",
		Search: "search",
	}), nil
}

The columns are typed by the API's own struct, so a field that stops existing stops compiling — which is the whole point of generating the client instead of reading JSON into a map:

// DocsColumns names the columns of the table. The types are the API's, so a
// field that stops existing stops compiling.
var DocsColumns = ui.Columns[api.Document]{
	{Key: "filename", Label: "File", Sort: true},
	{Key: "status", Label: "Status", Cell: func(d api.Document) h.Node { return ui.Badge(h.Text(string(d.Status))) }},
	{Key: "pages", Label: "Pages", Num: true, Cell: func(d api.Document) h.Node { return h.Text(strconv.Itoa(int(d.Pages))) }},
}

A binary answer arrives as the *http.Response, so the body streams straight through:

// DownloadDocument hands the API's answer straight to the browser: the body is
// never read into memory, and the Content-Type the API chose is the one that
// arrives.
func DownloadDocument(c *trilha.Ctx) error {
	resp, err := Acervo.Documents().Download(Calling(c), c.Param("id"))
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	return c.Inline(c.Param("id")+".pdf", resp.Body, resp.Header.Get("Content-Type"))
}

The two doors are the same API and the same token: the page uses the client, the islands and any fetch keep calling /api/* on this origin.

// ProxyAPI is the other half. The page uses the client above; the islands and
// any fetch still call /api/* on the same origin, and the upstream puts the
// same credential on the request. One API, one token, two doors.
func ProxyAPI(cfg *trilha.Config) {
	cfg.Upstreams = map[string]trilha.Upstream{
		"/api/": {
			Target: "http://api:8801",
			Headers: func(c *trilha.Ctx, hdr http.Header) {
				if tok, _ := c.Signed("api_token"); tok != "" {
					hdr.Set("Authorization", "Bearer "+tok)
				}
			},
			Timeout: 60 * time.Second,
		},
	}
}

A refused write comes back as an *Error, and when the API is a FastAPI the 422 it writes is already one message per field. Fields is that list, keyed by the name the form uses, so the page that failed re-renders itself with each message beside its input:

// CreateDocument is the write. A 422 is not an error page: it is the same form
// again, with the API's own messages next to the fields that earned them.
func CreateDocument(c *trilha.Ctx) (h.Node, error) {
	var in api.DocumentIn
	if err := c.Bind(&in); err != nil {
		return nil, err
	}
	doc, err := Acervo.Documents().CreateDocument(Calling(c), in)
	if e, ok := api.AsError(err); ok && e.Status == http.StatusUnprocessableEntity {
		return documentForm(in, trilha.FieldErrors(e.Fields)), nil
	}
	if err != nil {
		return nil, err
	}
	return nil, c.Redirect("/documents/" + doc.ID)
}

trilha.FieldErrors is a map[string]string and so is Fields, so the conversion is the whole adapter: no second validation of the same rules on this side, and no version of them that drifts from the API's.

What the generator will not pretend to understand — a oneOf, a schema with no type — comes through as json.RawMessage and is printed as a line of the report when it runs; an API with no response_model anywhere gets the count of how much of it stayed untyped. See trilha client.

What you get for free#

  • POST /api/* requires the CSRF token, like every other write of the app.
  • The browser's own Authorization never reaches the API — only the session's.
  • A 200 MB upload and a PDF coming back both stream, past MaxBodyBytes and the write deadline.
  • A route.go you write at /api/documents answers before the proxy, so the API moves to Go one endpoint at a time and the front never notices.

trilha audit will tell you if the target is plain http:// to another host, if the upstream injects nothing in an app that has a login, or if the login has no rate limit.

See Upstreams and Auth for the details.