jwt
Auth
What it is for
Section titled “What it is for”gas/auth turns a request into an authenticated identity and manages the
credentials behind it. It has four services, one per sub-package:
session
apikey
token
The root package declares three interfaces:
auth.Authenticatorturns an*http.Requestinto agas.Principal. Thejwt,sessionandapikeyservices implement it, andauth.Chaincombines several.tokendoes not: a token is verified once and consumed, not presented on every request.auth.PrincipalRevokerinvalidates credentials:Revokeone,RevokeAllfor a subject,RevokeAllBySchemefor one scheme of a subject.sessionandapikeyimplement it.auth.Authorizerdecides whether a principal may perform an action on a resource. The module ships no implementation; the app writes one, andauthtesthas a mock.
gas.Principal and the helpers that carry it on a context live in core, so a
handler reads the principal with ctx.Principal() without importing this
module.
Install
Section titled “Install”go get github.com/gasmod/gas/authWiring
Section titled “Wiring”app := gas.NewApp( gas.WithServiceInstance[gas.ConfigProvider](cfg), gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
// session, apikey and token store credentials and register their // own migrations; jwt needs neither. gas.WithSingletonService[gas.DatabaseProvider](database.New()), gas.WithSingletonService[gas.MigrationManager](migrate.New()),
gas.WithSingletonService[jwt.Provider](jwt.New()), gas.WithSingletonService[session.Provider](session.New()), gas.WithSingletonService[apikey.Provider](apikey.New()), gas.WithSingletonService[token.Provider](token.New()),
gas.WithSingletonService[*Accounts](NewAccounts),)Each sub-package’s New(opts...) returns a constructor, and each service
implements gas.Service. Register it under the sub-package’s Provider
interface (jwt.Provider, session.Provider, apikey.Provider,
token.Provider) or as its *Service.
jwt needs only configuration and a logger. session, apikey and token
store credentials, so they also need a gas.DatabaseProvider and a
gas.MigrationManager, and they create their own tables. See
Constructors and configuration and
Databases.
Protecting routes
Section titled “Protecting routes”// Chain tries each authenticator in order, so the /api group accepts a JWT// bearer token, a session cookie or an API key. /api/settings additionally// insists on a session.func (a *Accounts) Init(context.Context) error { a.router.Post(a, "/login", a.login) a.router.Post(a, "/magic-link", a.sendMagicLink) a.router.Get(a, "/magic-link/{token}", a.useMagicLink)
chain := auth.Chain{a.jwt, a.sessions, a.keys}
a.router.Route("/api", func(api *gas.Router) { api.UseMiddlewareFunc(auth.Middleware(chain, jsonUnauthorized()))
api.Get(a, "/me", a.me) api.Post(a, "/keys", a.createKey) api.Post(a, "/logout", a.logout)
api.Group(func(settings *gas.Router) { settings.UseMiddlewareFunc(auth.RequireScheme(auth.SchemeSession)) settings.Get(a, "/settings", a.settings) }) }) return nil}auth.Middleware(a, opts...) runs the authenticator on each request. On
success it stores the principal on the request context with
gas.WithPrincipal and calls the next handler. On failure it calls the
WithOnError handler with the error; without one it answers a plain-text
401 Unauthorized.
auth.Chain{...} tries each authenticator in order and returns the first
success, so one route group can accept several credential types.
auth.RequireScheme(scheme) answers a plain-text 403 Forbidden when the
request has no principal or one of another scheme. Mount it after
Middleware for routes that must insist on one credential type, such as a
settings page a long-lived API key should not reach.
Reading the principal
Section titled “Reading the principal”// The middleware put the principal on the request context. Metadata is read// with a type check.func (a *Accounts) me(ctx gas.Context) error { p, err := ctx.Principal() if err != nil { return err }
role, _ := gas.MetadataValue[string](p.Metadata(), "role") return ctx.JSON(http.StatusOK, map[string]string{ "subject": p.Subject(), "scheme": p.Scheme(), "role": role, })}A gas.Principal has four methods:
| Method | Returns |
|---|---|
Subject() |
The stable user ID |
Scheme() |
jwt, session or apikey, also available as auth.SchemeJWT, auth.SchemeSession and auth.SchemeAPIKey |
CredentialID() |
The session ID, API key ID or JWT jti |
Metadata() |
The metadata stored with the credential |
ctx.Principal() returns the principal, or a 401 *gas.Error to return
directly when there is none. Outside a handler, gas.PrincipalFromContext(ctx)
returns it with a boolean. gas.MetadataValue[T](p.Metadata(), key) reads a
metadata value and reports false when the key is missing or holds another
type, so there is no unchecked type assertion.
Issuing credentials
Section titled “Issuing credentials”// login checks the password (not shown), then starts a server-side session// for browsers and signs a JWT for API clients.func (a *Accounts) login(ctx gas.Context) error { userID := "u-123"
sess, err := a.sessions.Create(ctx, userID, gas.BasePrincipalMetadata{"role": "admin"}, ctx.Request()) if err != nil { return err } a.sessions.SetCookie(ctx.ResponseWriter(), sess)
signed, err := a.jwt.Sign(userID, map[string]any{"role": "admin"}) if err != nil { return err } return ctx.JSON(http.StatusOK, map[string]string{"token": signed})}
// createKey returns the plaintext key once; only its hash is stored.func (a *Accounts) createKey(ctx gas.Context) error { p, err := ctx.Principal() if err != nil { return err }
key, info, err := a.keys.Generate(ctx, p.Subject(), "ci", []string{"notes:read"}, apikey.WithTTL(90*24*time.Hour), ) if err != nil { return err } return ctx.JSON(http.StatusCreated, map[string]string{"id": info.ID, "key": key})}| Scheme | Issue with | Revocable |
|---|---|---|
session |
Create, then SetCookie to write the cookie |
Yes |
jwt |
Sign, or SignWithExpiry for a custom lifetime |
No |
apikey |
Generate, which returns the full key only this once |
Yes |
To create a key in the same transaction as your own writes, call
WithTx(tx) first:
// WithTx runs the key insert in the caller's transaction, so the users row// and their first key are written together or not at all.func (a *Accounts) createUserWithKey(ctx context.Context, userID string) (string, error) { tx, err := a.db.DB().BeginTx(ctx, nil) if err != nil { return "", err } defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, "INSERT INTO users (id) VALUES (?)", userID); err != nil { return "", err } key, _, err := a.keys.WithTx(tx).Generate(ctx, userID, "default", nil) if err != nil { return "", err } return key, tx.Commit()}Issuing single-use tokens
Section titled “Issuing single-use tokens”// A single-use token for a magic link: Issue returns the raw token, Verify// consumes it.func (a *Accounts) sendMagicLink(ctx gas.Context) error { raw, err := a.tokens.Issue(ctx, "u-123", "magic-link", 0) // 0: the default TTL if err != nil { return err } log.Printf("send https://example.com/magic-link/%s", raw) // email it instead return ctx.NoContent()}
func (a *Accounts) useMagicLink(ctx gas.Context) error { subject, err := a.tokens.Verify(ctx, ctx.Param("token"), "magic-link") if errors.Is(err, token.ErrTokenInvalid) || errors.Is(err, token.ErrTokenExpired) { return gas.Unauthorized("link is invalid or expired") } if err != nil { return err }
sess, err := a.sessions.Create(ctx, subject, nil, ctx.Request()) if err != nil { return err } a.sessions.SetCookie(ctx.ResponseWriter(), sess) return ctx.NoContent()}Issue returns the raw token to send, for example in a link. Verify
consumes it the first time it is presented and returns the subject. See
Single-use tokens for the exact rules.
Revoking
Section titled “Revoking”// Revoke ends the credential the request was made with; RevokeAll ends// every credential of that kind for the subject.func (a *Accounts) logout(ctx gas.Context) error { p, err := ctx.Principal() if err != nil { return err }
switch p.Scheme() { case auth.SchemeSession: if err := a.sessions.Revoke(ctx, p); err != nil { return err } a.sessions.ClearCookie(ctx.ResponseWriter()) case auth.SchemeAPIKey: if err := a.keys.Revoke(ctx, p); err != nil { return err } default: return gas.BadRequest("a JWT cannot be revoked; let it expire") } return ctx.NoContent()}A JWT cannot be revoked: the service is not an auth.PrincipalRevoker, and
nothing is stored per token. Keep jwt.expiry short if that matters.
Error responses
Section titled “Error responses”// By default a failed authentication answers with a plain 401. WithOnError// takes over the response.func jsonUnauthorized() auth.MiddlewareOption { return auth.WithOnError(func(w http.ResponseWriter, _ *http.Request, err error) { status := http.StatusUnauthorized msg := "sign in to continue" if errors.Is(err, auth.ErrCredentialsExpired) { msg = "credentials expired" } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if encErr := json.NewEncoder(w).Encode(map[string]string{"error": msg}); encErr != nil { log.Printf("write 401: %v", encErr) } })}WithOnError(fn) receives the authenticator’s error and owns the response.
The errors are passed through unchanged, so errors.Is matches the
sentinels. Return the unified error shape from
it if your API uses one.
Testing
Section titled “Testing”authtest has a recording mock for each interface. Each records its calls in
Calls, has CallCount(method) and Reset(), and delegates to an Fn field
when set:
| Mock | With no Fn |
|---|---|
MockAuthenticator |
Authenticate returns auth.ErrUnauthenticated. |
MockAuthorizer |
Authorize returns nil, allowing everything. |
MockRevoker |
Every method returns nil. |
// A protected handler tested without issuing real credentials: the mock// authenticator returns whatever principal the test needs.func TestMiddlewareStoresPrincipal(t *testing.T) { mock := &authtest.MockAuthenticator{ AuthenticateFn: func(context.Context, *http.Request) (gas.Principal, error) { return auth.NewPrincipal("u-123", auth.SchemeSession, "s-1", nil), nil }, }
var subject string h := auth.Middleware(mock)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if p, ok := gas.PrincipalFromContext(r.Context()); ok { subject = p.Subject() } w.WriteHeader(http.StatusNoContent) }))
rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/me", nil))
if rec.Code != http.StatusNoContent || subject != "u-123" { t.Fatalf("status %d, subject %q", rec.Code, subject) } if n := mock.CallCount("Authenticate"); n != 1 { t.Fatalf("Authenticate called %d times, want 1", n) }}auth.NewPrincipal(subject, scheme, credentialID, meta) builds the principal
a mock returns; a nil metadata becomes an empty map. See
Testing for testing whole handlers.
Configuration
Section titled “Configuration”Each service binds its own section in Init. Keys are listed with their
defaults from DefaultConfig(); the environment variable for each follows
the naming rule.
| Key | Type | Default | Description |
|---|---|---|---|
jwt.signingmethod |
string | HS256 |
HS256 or RS256. |
jwt.signingkey |
string | empty | HMAC key for HS256. Required for HS256, at least 32 bytes. |
jwt.publickeypath |
string | empty | PEM RSA public key file. Required for RS256. |
jwt.privatekeypath |
string | empty | PEM RSA private key file. Needed for RS256, since Init requires a signing key. |
jwt.issuer |
string | empty | Set as iss when signing and required when verifying, if not empty. |
jwt.audience |
string | empty | Set as aud when signing and required when verifying, if not empty. |
jwt.expiry |
duration | 15m |
Lifetime used by Sign. Must be greater than zero. |
| Key | Type | Default | Description |
|---|---|---|---|
session.cookiename |
string | session_id |
Cookie that carries the session ID. Required. |
session.cookiepath |
string | / |
Cookie path. |
session.cookiedomain |
string | empty | Cookie domain. |
session.cookiesecure |
bool | true |
Secure attribute. |
session.cookiehttponly |
bool | true |
HttpOnly attribute. |
session.cookiesamesite |
int (http.SameSite) |
2 (Lax) |
1 default, 2 Lax, 3 Strict, 4 None. |
session.sessionttl |
duration | 24h |
Session lifetime. Must be greater than zero. |
session.extendonaccess |
bool | true |
Move the expiry forward on every authenticated request. |
session.cleanupinterval |
duration | 1h |
How often expired sessions are deleted. Zero or less disables cleanup. |
session.cleanuptimeout |
duration | 30s |
Bound on each cleanup run. Must be positive while cleanup is enabled. |
| Key | Type | Default | Description |
|---|---|---|---|
apikey.headername |
string | X-API-Key |
Request header that carries the key. |
apikey.prefix |
string | empty | Prepended to generated keys, for example sk_live_. |
apikey.keylength |
int | 32 |
Random bytes per key. At least 16. |
| Key | Type | Default | Description |
|---|---|---|---|
token.defaultttl |
duration | 15m |
Lifetime when Issue gets a ttl of 0. Must be greater than zero. |
token.tokenlength |
int | 32 |
Random bytes per token. At least 16. |
token.cleanupinterval |
duration | 1h |
How often expired tokens are deleted. Zero or less disables cleanup. |
token.cleanuptimeout |
duration | 30s |
Bound on each cleanup run. Must be positive while cleanup is enabled. |
The minimums and “greater than zero” rules are validate tags, checked only
when the config is bound. The HS256 key, RS256 public key path and
cleanup-timeout rules are checked for every config, including one given with
WithConfig.
Errors
Section titled “Errors”| Error | Returned when |
|---|---|
auth.ErrUnauthenticated |
No credential, or one that does not check out: a missing header or cookie, an unknown session or key, a revoked key, a bad JWT. Also an empty Chain, or an authenticator that returned a nil principal and nil error. |
auth.ErrCredentialsExpired |
An expired JWT, session or API key. |
auth.ErrForbidden, auth.ErrCredentialRevoked |
Declared for your own code, such as an Authorizer. No service in the module returns them. |
token.ErrTokenInvalid |
Verify with an unknown, used or wrong-purpose token; Revoke of a token that does not exist. |
token.ErrTokenExpired |
Verify with an expired token. |
The session and API key services wrap store lookups, so check with
errors.Is rather than ==. Other errors start with the service name
(gas/auth/jwt, gas/auth/session, gas/auth/apikey, gas/auth/token) and
wrap the underlying database, file or parsing error. The exceptions are the
JWT service’s Sign and Verify errors and jwt: signing key not configured, which start with jwt:.
Behavior details
Section titled “Behavior details”The sections above cover everyday use. This section is the full contract, for when an exact edge case matters.
Constructors and configuration
Section titled “Constructors and configuration”What the constructors resolve from the container:
| Constructor | Resolves |
|---|---|
jwt.New() |
gas.ConfigProvider, gas.Logger |
session.New(), apikey.New(), token.New() |
gas.DatabaseProvider, gas.Logger, gas.MigrationManager, gas.ConfigProvider |
In Init, each service binds its section of the configuration and validates
it (see Configuration), unless WithConfig(cfg) was passed:
a config given that way replaces the bound one and is used as given, without
its validate tags.
Start from the package’s DefaultConfig() when you use WithConfig, because
a zero field is taken literally: a zero Expiry in a jwt.Config signs
tokens that expire the moment they are issued, and a bare &jwt.Config{} has
no signing method, so Init fails with jwt: signing key not configured.
The checks that a tag cannot express run for every config, bound or given.
session, apikey and token also pick a SQL dialect from the database
provider’s Driver() and register their migrations with the
gas.MigrationManager in Init. Core runs the migrations after every
service’s Init, before the app starts serving. See
Migrations.
Middleware and chains
Section titled “Middleware and chains”An authenticator that returns a nil principal with a nil error has failed
with auth.ErrUnauthenticated; it never counts as a success. Passing a nil
authenticator to Middleware panics when the middleware is built.
When every authenticator in a Chain fails, the chain returns the last one’s
error, so with Chain{jwt, session, apikey} the error a handler sees comes
from apikey. An empty chain returns auth.ErrUnauthenticated.
Authenticate reads Authorization: Bearer <token>; the word Bearer is
matched case-insensitively.
| Token | Result |
|---|---|
| Missing or malformed header, bad signature, another algorithm, wrong issuer or audience | auth.ErrUnauthenticated |
| Expired | auth.ErrCredentialsExpired |
The principal’s subject is the sub claim, its credential ID the jti
claim, and its metadata every claim except sub, jti, iss, aud, exp,
iat and nbf.
Sign(subject, claims) signs a token that expires after jwt.expiry, and
SignWithExpiry takes the lifetime explicitly. Both set sub, iat and
exp, plus iss and aud when configured, after your claims, so your claims
cannot override them.
They add no jti of their own, so a principal from a token signed this way
has an empty credential ID unless your claims include one.
Verify(token) checks a token and returns its *jwt.TokenClaims without
building a principal.
HS256, the default, needs a signing key of at least 32 bytes. RS256 reads a
PEM public key (PKIX) from jwt.publickeypath for verification and a PEM
private key (PKCS #8 or PKCS #1) from jwt.privatekeypath for signing.
Init requires a signing key either way, so even a service that only
verifies RS256 tokens needs the private key path.
Sessions
Section titled “Sessions”Create(ctx, subject, meta, r) stores a new session with a random ID (32
random bytes, URL-safe base64), the metadata, the client’s address
(r.RemoteAddr) and user agent, and an expiry session.sessionttl from now,
and returns it as a *session.Session. Write the cookie with
SetCookie(w, sess); ClearCookie(w) writes an expired one.
Authenticate reads the session ID from the cookie. A missing cookie or an
unknown ID fails with auth.ErrUnauthenticated, an expired session with
auth.ErrCredentialsExpired.
With session.extendonaccess on, the default, every authenticated request
moves the session’s expiry to sessionttl from now in the database. The
cookie’s own Expires is not rewritten; call SetCookie again to extend it
in the browser.
| Method | Deletes |
|---|---|
Revoke(ctx, p) |
The session the principal came from |
RevokeAll |
Every session of a subject |
RevokeAllByScheme |
Every session of a subject when the scheme is session; a no-op otherwise |
A background goroutine deletes expired sessions every
session.cleanupinterval, bounding each run by session.cleanuptimeout;
Close stops it. An interval of zero or less disables it.
The service logs a warning at Init and on every SetCookie and
ClearCookie when the cookie is not Secure, not HttpOnly, or has a
SameSite other than Lax or Strict. It does not refuse to run.
API keys
Section titled “API keys”Generate(ctx, subject, name, scopes, opts...) creates a key of
apikey.keylength random bytes, URL-safe base64, after apikey.prefix. It
returns the full key exactly once, with a *apikey.KeyInfo; only the
key’s SHA-256 hash is stored, along with a display prefix (apikey.prefix
plus the first 8 characters of the random part). A scope containing a comma
is rejected.
| Option | Effect |
|---|---|
WithTTL(d) |
Expires the key d from now |
WithExpiresAt(t) |
Expires the key at t |
WithMetadata(m) |
Stores metadata returned with the principal |
A non-positive duration or zero time is ignored, and when both expiry options are given the last one wins.
Authenticate reads the key from the apikey.headername header (X-API-Key
by default). A missing, unknown or revoked key fails with
auth.ErrUnauthenticated, an expired one with auth.ErrCredentialsExpired.
On success it records the time as the key’s last use.
The principal’s credential ID is the key’s ID, and its metadata holds the
stored metadata plus the key’s scopes as a []string under scopes
(apikey.PrincipalMetadataKeyScopes).
Revoke, RevokeAll and RevokeAllByScheme soft-delete: the row keeps a
deleted_at time and is excluded from authentication and from List.
Delete and DeleteAll remove rows permanently. List(ctx, subject) returns
a subject’s active keys; with apikey.WithIncludeRevoked() it also returns
revoked ones, whose DeletedAt is set.
WithTx(tx) returns a provider whose operations run in your *sql.Tx, so a
key can be created in the same transaction as your own writes. You own the
transaction; do not keep the returned provider after it ends.
Single-use tokens
Section titled “Single-use tokens”Issue(ctx, subject, purpose, ttl) stores the SHA-256 hash of a random token
(token.tokenlength bytes, URL-safe base64) with its purpose, subject and
expiry, and returns the raw token. A ttl of 0 uses token.defaultttl.
Verify(ctx, raw, purpose) fetches and deletes the token in one atomic store
operation, then checks it, and returns the subject. A token is consumed the
first time it is presented, whatever the outcome:
| Token | Result |
|---|---|
| Unknown or already used | token.ErrTokenInvalid |
| Wrong purpose | token.ErrTokenInvalid, and the token is burned |
| Expired | token.ErrTokenExpired |
Of two concurrent Verify calls with the same token, only one can succeed.
Revoke(ctx, raw) deletes one token and returns token.ErrTokenInvalid when
there is none; RevokeAllByPurpose(ctx, subject, purpose) deletes a
subject’s tokens for one purpose. Expired tokens are cleaned up in the
background exactly as sessions are, with the token.cleanup* keys.
Databases
Section titled “Databases”session, apikey and token choose their SQL dialect from the
gas.DatabaseProvider’s Driver():
Driver() |
Dialect |
|---|---|
postgres, pgx |
PostgreSQL |
sqlite, sqlite3 |
SQLite |
mysql |
MySQL |
Any other driver fails Init with unsupported driver. Their tables are
__gas_auth_sessions, __gas_auth_api_keys and __gas_auth_tokens.
gas/database accepts postgres, pgx and sqlite as its own drivers.
For MySQL, give it a connector with database.WithConnector and set
database.driver to mysql, or register your own gas.DatabaseProvider
whose Driver() returns mysql; see Database. The
MySQL DSN needs two parameters: multiStatements=true, because each
migration file holds several statements and runs as one statement batch, and
parseTime=true, because the stores scan DATETIME columns into
time.Time.