tako docs guide reference api
GitHub

tako.Auth — providers, sessions, Route.SignIn, the dev bar

Declare

auth = tako.Auth(tako.AuthConfig{Google: true})   // journal: Google sign-in
auth = tako.Auth(tako.AuthConfig{Password: true})    // shop: email/password

One declaration injects the session surface: POST /_tako/api/session (exchange a sign-in for a session), DELETE /_tako/api/session (sign out), GET /_tako/signin (the framework-owned sign-in page — your app just links here), and the form-friendly POST /_tako/api/session/signout.

Sessions are revocable entity records: the cookie carries only an opaque random id; the record holds the identity and the session-bound CSRF token. Sign-out (or an operator, in the console) deletes the record and the session is dead on the next request — revocation is real, not a cookie waiting to expire. Sessions last 7 days.

The declaration is a restriction. A session may only come from a provider you declared: AuthConfig{Google: true} refuses a token minted by email/password, whatever else the Identity Platform project has enabled.

That matters because a project's providers are not only yours. Identity Platform enables them per project, and a deploy of another app, a console click, or a default can turn on email/password with self-service sign-up. Before tako checked, an app declaring Google: true accepted any token the project would issue — so a self-registered password account produced a session, and the only thing between it and an admin was whatever the app compared next.

The refusal names both halves:

the ID token did not verify: this token was minted by email, and this app declares google — tako.AuthConfig decides which doors exist

It is checked at sign-in, so sessions already established keep working until they expire or are revoked. Changing what you declare never signs anyone out mid-visit.

Use

tako.User carries ID, Email, Name, and two claims about how the user got here:

field
Provider which declared provider minted this session: "google", "email"
EmailVerified the provider's claim about the address

EmailVerified is worth a moment if you authorize by email — an editor allowlist, say. That check is only as strong as the guarantee that the address belongs to whoever signed in. Google guarantees it. Email/password guarantees whatever the project was configured to require, which your app cannot see, so the claim is handed to you rather than assumed:

if !ctx.User().EmailVerified {
	return tako.HTML{}, tako.Errorf(403, "this address is not verified")
}

Route{SignIn: true} gates a route with a 401; past it, ctx.User() is guaranteed. On public routes use ctx.MaybeUser() — calling User() on an unauthenticated request panics. From examples/journal/app.go:

Save    = tako.Page("POST /p", save, tako.Route{SignIn: true})
Publish = tako.Page("POST /p/{id}/publish", publish, tako.Route{SignIn: true})
p := Post{ID: tako.NewID(), Owner: ctx.User().ID, Title: req.Title, ...}

Ownership is the handler's own rule, not the framework's: journal's publish checks current.Owner != ctx.User().ID and returns a 403. Shop examples/shop/app.go's public /api/me probe shows if u, ok := ctx.MaybeUser(); ok { ... }.

Sessions cross service calls automatically: an Endpoint.Call carries the caller's session id in X-Tako-Session, and the callee's record lookup IS the verification — revocation kills the identity mid-chain. See calls.md.

Authorize: tako.Policy and Route.Allow

Authentication says somebody signed in. Which of the people who signed in may use a route is a policy — declared, like everything else:

var Moderators = tako.Policy(moderator)

func moderator(ctx tako.Ctx, u tako.User) error {
	for _, m := range moderators {
		if u.Email == m {
			return nil
		}
	}
	return tako.Deny("the moderation desk is limited to this journal's moderators")
}
Moderate = tako.Page("GET /moderate", moderate, tako.Route{Allow: Moderators})
Retract  = tako.Page("POST /moderate/{id}/retract", retract, tako.Route{Allow: Moderators})

One policy serves as many routes as share the door, and the handlers open with their own work rather than with four lines of preamble. It is worth saying what that preamble cost, since it is the reason this exists: an app carrying an admin wrote the same check at 49 call sites in 12 files, and no tool could answer "which of my routes can a stranger reach" because the answer lived in function bodies.

return nil admit
return tako.Deny(msg) refuse: 403, msg shown to the visitor
return any other error could not decide: 500, logged under the request id, never rendered

That third row is the guard. A store read that failed while deciding is not a decision, so the only text that reaches a visitor is text you wrote deliberately.

Allow implies SignIn. A policy takes a User, so the 401 gate is in front of it and ctx.User() is guaranteed past it. Writing both is redundant, not wrong.

It sees the user, not the request. A policy answers who may reach this route. Whether this user may touch that record is a question about the record, and it belongs in the handler that loaded it — journal's publish still checks current.Owner != ctx.User().ID. Keeping the two apart is what lets one policy serve fifty routes.

Enforced by Endpoint.Call, not by the mount. That is one enforcement point for a browser request, an in-package call, a test, and a cross-service call — the last being the one place a trust boundary is actually crossed. A remote hop is the exception and deliberately so: the callee evaluates its own policy, because the rule may read the callee's entity store. Callers can test the verdict with tako.IsDenied(err).

Roles

A role is a NAME an identity carries into the request, and the only thing tako knows about it. Declare it, then put a door behind it:

var Moderator  = tako.Role("moderator")
var Moderators = tako.RequireRole(Moderator)          // any-of, if you pass several

var Moderate = tako.Page("GET /moderate", moderate, tako.Route{Allow: Moderators})

RequireRole returns an ordinary policy, so it goes exactly where a hand-written one goes and is enforced at the same point. A rule that needs two roles at once, or a role plus something else, is a tako.Policy with a body — this is the shorthand, not the ceiling.

Elsewhere, ask the user:

if ctx.User().HasRole(Moderator) { … }   // for the one extra button

The refusal names the role, because a role name is not a secret and "not authorized" sends somebody to ask a question nobody can answer:

this page needs the moderator role

Where roles live. In the cloud, an Identity Platform custom claim — so they arrive in the ID token and tako reads them at sign-in. On a laptop there is no Identity Platform, so they live on the local user record and tako dev's sign-in page offers a checkbox per declared role. Ticking is deliberate: a local session that carried every role would make the refusal path — the half of authorization worth walking — unreachable in dev.

They are what the session was minted with. tako.User.Roles comes off the session record, so a role granted or revoked elsewhere is not picked up mid-session. Granting through auth.Users() marks that user's sessions stale and the next request refreshes them (AB10b, not yet built); until then, a role change takes effect on next sign-in.

The name must be a string constant and the var must be exported — it is read at analysis time, so tako gen graph can print it beside the door:

GET /moderate → Moderate 🔒 Moderators (moderator) (app.go:189)

Accounts: auth.Users()

Creating accounts and handing out roles, from Go:

users := auth.Users()

u, err := users.Create(ctx, tako.NewUser{
	Email: "ana@example.com", Name: "Ana", Password: pw,   // Password is optional
})
err = users.Grant(ctx, u.ID, Moderator)
err = users.Revoke(ctx, u.ID, Moderator)
err = users.Disable(ctx, u.ID)   // sign-in refused, record kept, sessions ended
err = users.Enable(ctx, u.ID)
err = users.Delete(ctx, u.ID)

u, err = users.ByEmail(ctx, "ana@example.com")
page, err := users.List(ctx).Limit(50).Page("")   // page.Next is the cursor
cloud Identity Platform accounts; roles are a custom claim
local the same contract over the local store, in the same bbolt file tako dev already keeps

The same thing, from a terminal: tako users

An operator should not have to write, deploy and delete a program to make the first admin. tako users is a door onto exactly the surface above — it calls auth.Users() and nothing under it:

tako users list                      every account and its roles
tako users show   ana@example.com
tako users create ana@example.com --name Ana --password --role editor
tako users grant  ana@example.com moderator
tako users revoke ana@example.com moderator
tako users disable ana@example.com   sign-in refused, record kept
tako users delete  ana@example.com   permanent

Local unless you type a project. No --project means .tako/data, the accounts tako dev signs in. --project <id> [--env <env>] works on the deployed app's Identity Platform. Alone among the cloud commands, this one never reads GOOGLE_CLOUD_PROJECT or gcloud config: it writes people, and a project left over in a shell is how a dev fixture lands in production.

A role you have not declared is refused, with the declared ones and a did-you-mean. Granting a role no policy reads writes a claim nothing reads — the store accepts it, the operator sees success, and the person still cannot get in.

A grant reaches sessions that already exist. The role lands on the next request and nobody is signed out, because the command goes through auth.Users() and inherits its stale-marking. disable ends the sessions outright, for the same reason it does from Go.

Two things to know: tako dev holds .tako/data while it runs, so stop it first (the command says so if you forget); and against a project you need roles/identitytoolkit.admin, which the refusal prints as a ready-to-paste grant.

An address is one account. Create over an existing one is refused with an error tako.IsEmailTaken(err) recognises, because the remedy is different from every other failure: look it up and grant it what it needs, rather than retry.

Password is optional and usually absent — an account that signs in with Google has none. Where it is set, Identity Platform holds it in the cloud and the local store holds a PBKDF2 verifier; tako never keeps a password.

Signing in with one, locally. tako dev's sign-in page offers an email/password form when the app declares AuthConfig{Password: true}, and POST /_tako/api/session accepts email + password as a form. That branch is local-only by construction — in the cloud the browser posts credentials to Identity Platform and tako only ever sees the ID token that comes back, so password verification never runs in a deployed service. Without it, Create(email, password) would make an account a laptop cannot sign in as.

The refusal is one sentence for both halves:

that email and password do not match an account

A wrong password, an unknown address and a disabled account answer identically. Which of them it was is exactly what somebody guessing wants to learn.

Recovery is not here. Password reset and email verification are Identity Platform's hosted flows, because they are an outbound-email story and tako sends no email.

A role change lands on the next request

Roles ride in a custom claim, and a claim only reaches a token when the token is reissued — while a tako session lasts seven days. So Grant does two things: it writes the claim, and it MARKS that account's live sessions. The next request from that person re-reads exactly one account and rewrites its session; every request after it costs nothing extra, and nobody is signed out.

Disable and Delete do not mark, they END those sessions. A disabled user who stays signed in for a week is not disabled.

Granting and revoking are read-modify-write — Identity Platform replaces the whole claims blob, so tako reads it, changes the roles key, and writes it back, leaving claims it does not own alone. Both run under a lease keyed on the account, so two grants at the same moment cannot lose one.

The permission this needs

Derived from what your code calls, per service:

calls grant
Get, ByEmail, List roles/identitytoolkit.viewer
Create, Grant, Revoke, Disable, Enable, Delete roles/identitytoolkit.admin
nothing neither

tako deploy prints it in the security summary like every other grant.

The two account events

var (
	OnRegistered  = tako.Subscribe(auth.Registered, openAccount)
	OnFirstSignIn = tako.Subscribe(auth.FirstSignIn, appointFounder)
)

func openAccount(ctx tako.Ctx, u tako.User) error { … }
fires when
auth.Registered this app created the account, through auth.Users().Create
auth.FirstSignIn tako minted a session for an identity it has never seen

They are ordinary event sources — same tako.Subscribe, same topic underneath, same at-least-once delivery, retries and dead-lettering as every other tako event. Handlers must be idempotent: an event can arrive twice, and for a Google sign-up both events fire within a second of each other.

There is no "somebody registered with Identity Platform" event, and there cannot be one here. beforeCreate blocking functions and Firebase Auth create triggers are Gen1 Cloud Functions only, and tako provisions no functions. So the two events above are things tako itself does and can state without guessing — and the gap they leave is an account created in the console (or through Identity Platform's own client SDK) and never used, which nothing announces.

FirstSignIn is backed by one tiny record per person (_tako_seen), written before the publish. A crash between the two loses the announcement rather than repeating it forever, which is the right way round: a missed welcome is a gap somebody notices, while a loop that re-announces on every sign-in looks like a feature until the emails pile up.

The publish is best-effort on both: a subscriber failing must never fail the act that triggered it. A Create that reported failure after creating the account would invite a retry, and a second account.

Telling your 403 from tako's

Several things answer 403 on a guarded route: your policy, a stale CSRF token, a handler's own refusal. Status.Policy names the policy that refused and is empty for everything tako answers on its own account:

func status(ctx tako.Ctx, s tako.Status) (tako.HTML, error) {
	heading := statusHeadings[s.Code]   // 403 → "That form expired"
	if s.Policy != "" {
		heading = "Not your desk"
	}
	...
}

Without it a reader who is simply not a moderator is told their form expired, and an editor who tripped CSRF is sent to ask for access they already have.

Local

tako dev does auth with no GCP anywhere near it. HTML responses get the dev bar: sign in as test users alice / bob. The bar reflects the session instead of listing every control — signed out it offers "sign in as alice/bob"; signed in as alice it shows "switch to bob" and "sign out". /_tako/signin serves a dev page listing the same users. Dev sessions are real session records in the local entity store, so sign-out and revocation behave exactly as in the cloud.

The bar goes on documents only — an htmx fragment is a partial with nothing to sit at the bottom of, and injecting into one would put a fixed strip inside whatever the swap replaced. It carries id="tako-dev-bar", which is there to be targeted: an app whose own layout owns the bottom of the window moves or hides it with one rule.

#tako-dev-bar { display: none }       /* or: bottom: 2.5rem */

None of this reaches production: the bar is injected only under the local backend, so a deployed service never renders it and the rule above is dead CSS in the cloud.

Cloud

tako deploy runs sign-in on Identity Platform. Most of it is automated — initialization, enabling the email provider when declared, registering sign-in domains per deploy — and the deploy injects TAKO_AUTH_APIKEY / TAKO_AUTH_DOMAIN (the browser client config) into every service; the sign-in page uses them to run the browser flow and POST the resulting ID token to /_tako/api/session.

The one genuinely manual step — only with tako.AuthConfig{Google: true} — from the cloud guide:

Firebase console → your project → Authentication → Sign-in method → Google → Enable (pick a support email) → Save.

The deploy blocks with that exact instruction until it's done. For the email provider there is a complete copy-pasteable curl recipe (create a user, mint an ID token, exchange it for the session cookie) in validate/shop.md — the sign-in page's email form signs IN only; creating the account is that recipe's job.

The honest cost: one entity Get per authenticated request (the session lookup — v0 does not cache), plus Identity Platform's per-MAU pricing past its free tier.

Test it

Shop's examples/shop/api_test.go signs in the way the dev bar does — post dev_user with a cookie jar:

res, err := c.PostForm(srv.URL+"/_tako/api/session", url.Values{"dev_user": {name}})

then asserts the 401 text on anonymous writes, owner scoping, and that a post after sign-out is 401 again ("session record deleted"). With tako.NewTestApp(t), app.Ctx(app.SignIn("alice")) injects an identity directly.

Errors you'll meet

Not yet