Skip to content

Auth

gas/auth turns a request into an authenticated identity and manages the credentials behind it. It has four services, one per sub-package:

jwt

Stateless bearer tokens, HS256 or RS256. No database. Cannot be revoked.

session

Server-side sessions behind a cookie. Revocable.

apikey

Long-lived keys with scopes for machine callers, stored as hashes. Revocable.

token

Single-use, expiring tokens for magic links, email verification and password resets.

The root package declares three interfaces:

  • auth.Authenticator turns an *http.Request into a gas.Principal. The jwt, session and apikey services implement it, and auth.Chain combines several. token does not: a token is verified once and consumed, not presented on every request.
  • auth.PrincipalRevoker invalidates credentials: Revoke one, RevokeAll for a subject, RevokeAllByScheme for one scheme of a subject. session and apikey implement it.
  • auth.Authorizer decides whether a principal may perform an action on a resource. The module ships no implementation; the app writes one, and authtest has 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.

Terminal window
go get github.com/gasmod/gas/auth
main.go
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.

accounts.go
// 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.

// 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.

// 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()
}
// 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.

// 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.

// 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.

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.
accounts_test.go
// 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.

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.

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.

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:.

The sections above cover everyday use. This section is the full contract, for when an exact edge case matters.

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.

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.

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.

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.

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.

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.