Guide 4 of 12

Authentication

Rails 8 has rails g authentication. Bogie's takes a shape, because every caller a Go service next to Rails has to answer is a different kind of caller, and all three turned out to be a middleware on a route group, the Go form of before_action.

bogie g authentication secret      # the Rails app calls this service
bogie g authentication token       # a user presents a bearer token
bogie g authentication api_key     # a machine client presents a key

Three shapes, one mechanism

Each shape comes from one of the two services Bogie was extracted from, each of which needed a different caller answered:

Whichever shape, the mechanism is the same: a function that checks the request and either calls c.Next() or aborts with 401, mounted on a *gin.RouterGroup instead of the whole router, so only the routes in that group are guarded.

What every shape writes

Three things, every time: the middleware and its test under app/middlewares, a group helper on the root Server in app/controllers/authentication_<shape>.go, and the setting itself, three lines inserted above three markers in config/config.go. Here is secret:

      create  app/middlewares/require_secret.go
      create  app/middlewares/require_secret_test.go
      create  app/controllers/authentication_secret.go
      insert  config/config.go: ControlSecret string `mapstructure:"control_secret"`
      insert  config/config.go: a.ControlSecret = envOr("BLOG_CONTROL_SECRET", a.ControlSecret)
      insert  config/config.go: requireSecret(c.Env, "BLOG_CONTROL_SECRET", a.ControlSecret, 32),
      insert  config/credentials.yml.enc: control_secret
        next  mount the routes it guards in app/controllers/routes.go: s.Posts.SetupRoutes(s.WithSecret(&r.RouterGroup))

The three lines in config/config.go are the field (above // bogie:config), its environment override (above // bogie:env), and a check that runs at boot (above // bogie:validate):

ControlSecret string `mapstructure:"control_secret"`
TokenSecret   string `mapstructure:"token_secret"`
// bogie:config
a.ControlSecret = envOr("BLOG_CONTROL_SECRET", a.ControlSecret)
a.TokenSecret = envOr("BLOG_TOKEN_SECRET", a.TokenSecret)
// bogie:env
requireSecret(c.Env, "BLOG_CONTROL_SECRET", a.ControlSecret, 32),
requireSecret(c.Env, "BLOG_TOKEN_SECRET", a.TokenSecret, 32),
// bogie:validate

The generator also writes a random development value into config/credentials.yml.enc, so the app keeps booting in development exactly as before. Staging and production get theirs by hand, with make credentials ENV=staging or ENV=production, each its own file and key. requireSecret is the check behind the third marker: in production it refuses to boot if the secret is shorter than 32 characters; everywhere else, the generated development value or the test's own is enough, so nothing is enforced until it has to be.

Mounting the guard

The generator stops short of one edit, on purpose: mounting a controller on the guarded group instead of the plain router, in app/controllers/routes.go. That line is the exposure boundary, and it belongs where it can be read, not written by a tool that cannot see what the group should protect:

func (s *Server) SetupRoutes() {
	r := s.Router

	r.Use(middlewares.RequestID(), middlewares.Recover(s.Log), middlewares.Logger(s.Log))

	// Unauthenticated, no side effects: what a load balancer polls.
	r.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") })

	// Guarded: every route Posts mounts needs the shared secret.
	s.Posts.SetupRoutes(s.WithSecret(&r.RouterGroup))

	// bogie:routes
}

A second guarded group reads the same way with s.WithUser(&r.RouterGroup) or s.WithAPIKey(&r.RouterGroup); a controller that needs no guard at all stays on &r.RouterGroup directly, as /healthz does above.

secret

A shared secret in the X-<App>-Secret header (the constant middlewares.SecretHeader), compared with crypto/subtle.ConstantTimeCompare so a wrong guess cannot be timed into a right one. A Basic auth fallback lets a browser open a guarded page too, since a browser cannot send a custom header; any username works and the password is the secret. On the Rails side, it is one header on the client:

Faraday.new(url: ENV["BLOG_URL"], headers: { "X-Blog-Secret" => Rails.application.credentials.blog_secret })

token

A bearer token, JWT, HS256, signed with one shared secret: <app>.token_secret in the credentials file, <APP>_TOKEN_SECRET in the environment. The Rails app mints a token for its own user with the jwt gem and the same secret, and this service only ever verifies:

JWT.encode({ iss: "blog", sub: user.id.to_s, iat: Time.now.to_i, exp: 15.minutes.from_now.to_i, jti: SecureRandom.uuid },
           Rails.application.credentials.blog_token_secret, "HS256")

In a handler behind WithUser, middlewares.Subject(c) is the token's subject, a Rails user id usually; nothing is loaded for you, because who the subject is is for the caller and the handler to agree on. A service that signs users in itself, rather than trusting Rails's token, mints its own with s.Tokens().Mint(subject, ttl).

Verify refuses a token that is expired, signed with another secret, issued for another issuer, or signed with any algorithm but HS256, so a token claiming "none" or an asymmetric algorithm never reaches the key at all.

api_key

A machine client presents a key rather than a session. The generator adds an api_keys table (name, a public prefix, and a bcrypt digest, never the key itself) and a subcommand:

bogie api_keys:create NAME

which prints the key once, as <prefix>.<secret>; there is no way to see it again, only to verify it or make another. The client sends it as a bearer token, the same header shape as token, and a handler behind WithAPIKey reads middlewares.APIKeyName(c) to see which key the request presented. Because this shape adds a migration, run bogie db:migrate (or restart the app, since it migrates at boot) before api_keys:create works.

Removing

bogie d authentication <shape> removes the middleware, its test, and the group helper, and reverses the three insertions in config/config.go. The development value stays behind in the credentials file; it guards nothing once the field that read it is gone, so it is harmless left in place.